secretshield 0.1.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.
- secretshield/__init__.py +44 -0
- secretshield/cli.py +168 -0
- secretshield/config.py +57 -0
- secretshield/detector.py +125 -0
- secretshield/guardian.py +222 -0
- secretshield/notifications.py +73 -0
- secretshield/patterns.py +102 -0
- secretshield/redactor.py +49 -0
- secretshield-0.1.0.dist-info/METADATA +297 -0
- secretshield-0.1.0.dist-info/RECORD +14 -0
- secretshield-0.1.0.dist-info/WHEEL +5 -0
- secretshield-0.1.0.dist-info/entry_points.txt +2 -0
- secretshield-0.1.0.dist-info/licenses/LICENSE +21 -0
- secretshield-0.1.0.dist-info/top_level.txt +1 -0
secretshield/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
secretshield: detect and redact likely secrets before they reach
|
|
3
|
+
Python's terminal output or logging system.
|
|
4
|
+
|
|
5
|
+
Importing this package automatically enables protection for
|
|
6
|
+
``sys.stdout``, ``sys.stderr``, and the standard ``logging`` module::
|
|
7
|
+
|
|
8
|
+
import secretshield
|
|
9
|
+
|
|
10
|
+
api_key = "example-secret-value"
|
|
11
|
+
print("API key:", api_key)
|
|
12
|
+
# API key: ********
|
|
13
|
+
# \u26a0 secretshield: Potential secret detected and redacted.
|
|
14
|
+
|
|
15
|
+
Protection can be toggled manually with :func:`enable` / :func:`disable`,
|
|
16
|
+
and behavior can be tuned with :func:`configure`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .config import Config, configure, get_config, reset_config
|
|
22
|
+
from .detector import Match, detect
|
|
23
|
+
from .guardian import disable, enable, is_enabled
|
|
24
|
+
from .redactor import redact
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"__version__",
|
|
30
|
+
"enable",
|
|
31
|
+
"disable",
|
|
32
|
+
"is_enabled",
|
|
33
|
+
"configure",
|
|
34
|
+
"get_config",
|
|
35
|
+
"reset_config",
|
|
36
|
+
"Config",
|
|
37
|
+
"detect",
|
|
38
|
+
"redact",
|
|
39
|
+
"Match",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# Automatically protect stdout/stderr/logging as soon as secretshield is
|
|
43
|
+
# imported, per the tool's core promise: "import it and you're protected."
|
|
44
|
+
enable()
|
secretshield/cli.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for secretshield.
|
|
3
|
+
|
|
4
|
+
Provides two commands:
|
|
5
|
+
|
|
6
|
+
* ``secretshield run <script.py> [args...]`` -- run a Python script with
|
|
7
|
+
secretshield protection enabled for stdout/stderr/logging.
|
|
8
|
+
* ``secretshield scan <path>`` -- scan a text file (or directory of text
|
|
9
|
+
files) for likely secrets without executing anything. This is a static
|
|
10
|
+
scan, distinct from runtime protection.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import runpy
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .detector import detect
|
|
22
|
+
|
|
23
|
+
# Reasonable default set of extensions to consider "text" when scanning a
|
|
24
|
+
# directory. Binary files and common non-source directories are skipped.
|
|
25
|
+
_SCAN_EXTENSIONS = {
|
|
26
|
+
".py", ".txt", ".md", ".env", ".yml", ".yaml", ".json",
|
|
27
|
+
".ini", ".cfg", ".toml", ".sh", ".js", ".ts",
|
|
28
|
+
}
|
|
29
|
+
_SKIP_DIR_NAMES = {".git", "__pycache__", "node_modules", ".venv", "venv"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
33
|
+
script_path = Path(args.script)
|
|
34
|
+
if not script_path.is_file():
|
|
35
|
+
print(f"secretshield: error: no such file: {script_path}", file=sys.stderr)
|
|
36
|
+
return 1
|
|
37
|
+
|
|
38
|
+
# Enable protection for the duration of the script's execution.
|
|
39
|
+
from . import enable
|
|
40
|
+
|
|
41
|
+
enable()
|
|
42
|
+
|
|
43
|
+
# Make the script's own arguments available to it via sys.argv, and
|
|
44
|
+
# ensure its directory is importable the way `python script.py` would.
|
|
45
|
+
sys.argv = [str(script_path), *args.script_args]
|
|
46
|
+
script_dir = str(script_path.resolve().parent)
|
|
47
|
+
if script_dir not in sys.path:
|
|
48
|
+
sys.path.insert(0, script_dir)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
runpy.run_path(str(script_path), run_name="__main__")
|
|
52
|
+
except SystemExit as exc:
|
|
53
|
+
return int(exc.code) if isinstance(exc.code, int) else 1
|
|
54
|
+
except Exception as exc: # noqa: BLE001 - surface script errors to the user
|
|
55
|
+
print(f"secretshield: script raised an exception: {exc}", file=sys.stderr)
|
|
56
|
+
return 1
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _scan_text(text: str, entropy_threshold: float) -> list[str]:
|
|
61
|
+
findings = []
|
|
62
|
+
try:
|
|
63
|
+
matches = detect(text, entropy_threshold=entropy_threshold)
|
|
64
|
+
except Exception:
|
|
65
|
+
return findings
|
|
66
|
+
for match in matches:
|
|
67
|
+
findings.append(f" - {match.kind} at offset {match.start}-{match.end}")
|
|
68
|
+
return findings
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _iter_scan_files(root: Path):
|
|
72
|
+
if root.is_file():
|
|
73
|
+
yield root
|
|
74
|
+
return
|
|
75
|
+
for path in root.rglob("*"):
|
|
76
|
+
if any(part in _SKIP_DIR_NAMES for part in path.parts):
|
|
77
|
+
continue
|
|
78
|
+
if path.is_file() and path.suffix.lower() in _SCAN_EXTENSIONS:
|
|
79
|
+
yield path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _cmd_scan(args: argparse.Namespace) -> int:
|
|
83
|
+
target = Path(args.path)
|
|
84
|
+
if not target.exists():
|
|
85
|
+
print(f"secretshield: error: no such path: {target}", file=sys.stderr)
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
total_findings = 0
|
|
89
|
+
for file_path in _iter_scan_files(target):
|
|
90
|
+
try:
|
|
91
|
+
text = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
92
|
+
except Exception:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
findings = _scan_text(text, entropy_threshold=args.entropy_threshold)
|
|
96
|
+
if findings:
|
|
97
|
+
total_findings += len(findings)
|
|
98
|
+
print(f"{file_path}")
|
|
99
|
+
for line in findings:
|
|
100
|
+
print(line)
|
|
101
|
+
|
|
102
|
+
if total_findings == 0:
|
|
103
|
+
print("secretshield: scan complete, no potential secrets found.")
|
|
104
|
+
else:
|
|
105
|
+
print(
|
|
106
|
+
f"\nsecretshield: scan complete, {total_findings} potential "
|
|
107
|
+
"secret(s) found."
|
|
108
|
+
)
|
|
109
|
+
return 1 if total_findings else 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
113
|
+
parser = argparse.ArgumentParser(
|
|
114
|
+
prog="secretshield",
|
|
115
|
+
description=(
|
|
116
|
+
"secretshield: detect and redact likely secrets before they "
|
|
117
|
+
"reach Python's terminal output or logging system."
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"--version", action="version", version=f"secretshield {__version__}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
125
|
+
|
|
126
|
+
run_parser = subparsers.add_parser(
|
|
127
|
+
"run", help="Run a Python script with secretshield protection enabled."
|
|
128
|
+
)
|
|
129
|
+
run_parser.add_argument("script", help="Path to the Python script to run.")
|
|
130
|
+
run_parser.add_argument(
|
|
131
|
+
"script_args",
|
|
132
|
+
nargs=argparse.REMAINDER,
|
|
133
|
+
help="Arguments to pass through to the script.",
|
|
134
|
+
)
|
|
135
|
+
run_parser.set_defaults(func=_cmd_run)
|
|
136
|
+
|
|
137
|
+
scan_parser = subparsers.add_parser(
|
|
138
|
+
"scan",
|
|
139
|
+
help=(
|
|
140
|
+
"Statically scan a file or directory of text files for "
|
|
141
|
+
"potential secrets (does not execute anything)."
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
scan_parser.add_argument("path", help="File or directory to scan.")
|
|
145
|
+
scan_parser.add_argument(
|
|
146
|
+
"--entropy-threshold",
|
|
147
|
+
type=float,
|
|
148
|
+
default=4.2,
|
|
149
|
+
help="Shannon entropy threshold for generic secret detection (default: 4.2).",
|
|
150
|
+
)
|
|
151
|
+
scan_parser.set_defaults(func=_cmd_scan)
|
|
152
|
+
|
|
153
|
+
return parser
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main(argv: list[str] | None = None) -> int:
|
|
157
|
+
parser = build_parser()
|
|
158
|
+
args = parser.parse_args(argv)
|
|
159
|
+
|
|
160
|
+
if not getattr(args, "command", None):
|
|
161
|
+
parser.print_help()
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
return args.func(args)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
raise SystemExit(main())
|
secretshield/config.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration for secretshield.
|
|
3
|
+
|
|
4
|
+
A single module-level :class:`Config` instance holds the active settings.
|
|
5
|
+
Use :func:`configure` to update it; :func:`get_config` to read it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Config:
|
|
15
|
+
"""Runtime configuration for secretshield."""
|
|
16
|
+
|
|
17
|
+
enabled: bool = True
|
|
18
|
+
redact_with: str = "********"
|
|
19
|
+
entropy_threshold: float = 4.2
|
|
20
|
+
notify: bool = True
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_config = Config()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def configure(
|
|
27
|
+
enabled: bool | None = None,
|
|
28
|
+
redact_with: str | None = None,
|
|
29
|
+
entropy_threshold: float | None = None,
|
|
30
|
+
notify: bool | None = None,
|
|
31
|
+
) -> Config:
|
|
32
|
+
"""
|
|
33
|
+
Update the active configuration. Only provided (non-None) fields are
|
|
34
|
+
changed; omitted fields keep their current value.
|
|
35
|
+
"""
|
|
36
|
+
global _config
|
|
37
|
+
if enabled is not None:
|
|
38
|
+
_config.enabled = enabled
|
|
39
|
+
if redact_with is not None:
|
|
40
|
+
_config.redact_with = redact_with
|
|
41
|
+
if entropy_threshold is not None:
|
|
42
|
+
_config.entropy_threshold = entropy_threshold
|
|
43
|
+
if notify is not None:
|
|
44
|
+
_config.notify = notify
|
|
45
|
+
return _config
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_config() -> Config:
|
|
49
|
+
"""Return the current active :class:`Config` instance."""
|
|
50
|
+
return _config
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def reset_config() -> Config:
|
|
54
|
+
"""Reset configuration back to defaults. Primarily useful for tests."""
|
|
55
|
+
global _config
|
|
56
|
+
_config = Config()
|
|
57
|
+
return _config
|
secretshield/detector.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Secret detection logic.
|
|
3
|
+
|
|
4
|
+
Combines pattern-based matching (for well-known secret formats) with a
|
|
5
|
+
generic high-entropy scan (for random-looking tokens that don't match a
|
|
6
|
+
known shape). Entropy detection is intentionally conservative and is used
|
|
7
|
+
as a supplement, never as the sole detection strategy, to avoid excessive
|
|
8
|
+
false positives.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from . import patterns
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Match:
|
|
21
|
+
"""A single detected secret span within a piece of text."""
|
|
22
|
+
|
|
23
|
+
start: int
|
|
24
|
+
end: int
|
|
25
|
+
value: str
|
|
26
|
+
kind: str
|
|
27
|
+
|
|
28
|
+
def __len__(self) -> int:
|
|
29
|
+
return self.end - self.start
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _shannon_entropy(s: str) -> float:
|
|
33
|
+
"""Return the Shannon entropy (bits per character) of a string."""
|
|
34
|
+
if not s:
|
|
35
|
+
return 0.0
|
|
36
|
+
freq: dict[str, int] = {}
|
|
37
|
+
for ch in s:
|
|
38
|
+
freq[ch] = freq.get(ch, 0) + 1
|
|
39
|
+
length = len(s)
|
|
40
|
+
entropy = 0.0
|
|
41
|
+
for count in freq.values():
|
|
42
|
+
p = count / length
|
|
43
|
+
entropy -= p * math.log2(p)
|
|
44
|
+
return entropy
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _looks_like_real_candidate(token: str) -> bool:
|
|
48
|
+
"""Filter out obvious non-secrets before running entropy checks."""
|
|
49
|
+
lowered = token.lower()
|
|
50
|
+
if any(word in lowered for word in patterns.ENTROPY_ALLOWLIST_SUBSTRINGS):
|
|
51
|
+
return False
|
|
52
|
+
# Skip strings composed of a single repeated character or simple runs.
|
|
53
|
+
if len(set(token)) <= 2:
|
|
54
|
+
return False
|
|
55
|
+
# Skip pure digit sequences (phone numbers, IDs, timestamps, etc.)
|
|
56
|
+
if token.isdigit():
|
|
57
|
+
return False
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _pattern_matches(text: str) -> list[Match]:
|
|
62
|
+
matches: list[Match] = []
|
|
63
|
+
for name, regex in patterns.PATTERNS:
|
|
64
|
+
for m in regex.finditer(text):
|
|
65
|
+
if name == "generic_labeled_secret":
|
|
66
|
+
# Only redact the captured value, not the label/key name.
|
|
67
|
+
start, end = m.span(patterns.GENERIC_VALUE_GROUP)
|
|
68
|
+
value = m.group(patterns.GENERIC_VALUE_GROUP)
|
|
69
|
+
else:
|
|
70
|
+
start, end = m.span()
|
|
71
|
+
value = m.group()
|
|
72
|
+
if not value:
|
|
73
|
+
continue
|
|
74
|
+
matches.append(Match(start=start, end=end, value=value, kind=name))
|
|
75
|
+
return matches
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _entropy_matches(text: str, threshold: float) -> list[Match]:
|
|
79
|
+
matches: list[Match] = []
|
|
80
|
+
for m in patterns.ENTROPY_CANDIDATE_RE.finditer(text):
|
|
81
|
+
token = m.group()
|
|
82
|
+
if not _looks_like_real_candidate(token):
|
|
83
|
+
continue
|
|
84
|
+
entropy = _shannon_entropy(token)
|
|
85
|
+
if entropy >= threshold:
|
|
86
|
+
matches.append(
|
|
87
|
+
Match(start=m.start(), end=m.end(), value=token, kind="high_entropy")
|
|
88
|
+
)
|
|
89
|
+
return matches
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _merge_overlapping(matches: list[Match]) -> list[Match]:
|
|
93
|
+
"""Merge/deduplicate overlapping spans, keeping the widest match."""
|
|
94
|
+
if not matches:
|
|
95
|
+
return []
|
|
96
|
+
ordered = sorted(matches, key=lambda m: (m.start, -(m.end - m.start)))
|
|
97
|
+
merged: list[Match] = [ordered[0]]
|
|
98
|
+
for current in ordered[1:]:
|
|
99
|
+
last = merged[-1]
|
|
100
|
+
if current.start < last.end:
|
|
101
|
+
# Overlaps with previous match; keep whichever is wider.
|
|
102
|
+
if (current.end - current.start) > (last.end - last.start):
|
|
103
|
+
merged[-1] = current
|
|
104
|
+
continue
|
|
105
|
+
merged.append(current)
|
|
106
|
+
return merged
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def detect(text: str, entropy_threshold: float = 4.2) -> list[Match]:
|
|
110
|
+
"""
|
|
111
|
+
Detect likely secrets within ``text``.
|
|
112
|
+
|
|
113
|
+
Returns a list of :class:`Match` objects sorted by position. This
|
|
114
|
+
combines known-pattern detection with generic high-entropy detection.
|
|
115
|
+
Overlapping matches are merged so a single secret isn't reported twice.
|
|
116
|
+
"""
|
|
117
|
+
if not text:
|
|
118
|
+
return []
|
|
119
|
+
try:
|
|
120
|
+
found = _pattern_matches(text)
|
|
121
|
+
found.extend(_entropy_matches(text, entropy_threshold))
|
|
122
|
+
except Exception:
|
|
123
|
+
# Detection must never crash the host application.
|
|
124
|
+
return []
|
|
125
|
+
return _merge_overlapping(found)
|
secretshield/guardian.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core guardian logic: wraps sys.stdout/sys.stderr and the logging module so
|
|
3
|
+
that secrets are redacted before they are ever written out.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any, TextIO
|
|
11
|
+
|
|
12
|
+
from . import notifications
|
|
13
|
+
from .config import get_config
|
|
14
|
+
from .redactor import redact
|
|
15
|
+
|
|
16
|
+
# Sentinel attribute used to mark a stream as already wrapped, so repeated
|
|
17
|
+
# enable() calls don't stack wrappers on top of each other.
|
|
18
|
+
_WRAPPED_MARKER = "_secretshield_wrapped"
|
|
19
|
+
|
|
20
|
+
# Module-level state tracking whether protection is currently active and
|
|
21
|
+
# what the original (unwrapped) streams / logging factory were, so
|
|
22
|
+
# disable() can cleanly restore them.
|
|
23
|
+
_state: dict[str, Any] = {
|
|
24
|
+
"active": False,
|
|
25
|
+
"orig_stdout": None,
|
|
26
|
+
"orig_stderr": None,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Guard against re-entrant redaction triggered by secretshield's own
|
|
30
|
+
# console warnings being written through a wrapped stream.
|
|
31
|
+
_in_write = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _GuardedStream:
|
|
35
|
+
"""A drop-in replacement for a text stream that redacts secrets."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, wrapped: TextIO) -> None:
|
|
38
|
+
self._wrapped = wrapped
|
|
39
|
+
setattr(self, _WRAPPED_MARKER, True)
|
|
40
|
+
|
|
41
|
+
def write(self, s: str) -> int:
|
|
42
|
+
global _in_write
|
|
43
|
+
config = get_config()
|
|
44
|
+
|
|
45
|
+
if not config.enabled or _in_write:
|
|
46
|
+
return self._wrapped.write(s)
|
|
47
|
+
|
|
48
|
+
_in_write = True
|
|
49
|
+
try:
|
|
50
|
+
safe_text, was_redacted = redact(
|
|
51
|
+
s,
|
|
52
|
+
entropy_threshold=config.entropy_threshold,
|
|
53
|
+
redact_with=config.redact_with,
|
|
54
|
+
)
|
|
55
|
+
except Exception:
|
|
56
|
+
# Never let a detection failure prevent output entirely.
|
|
57
|
+
safe_text, was_redacted = s, False
|
|
58
|
+
finally:
|
|
59
|
+
_in_write = False
|
|
60
|
+
|
|
61
|
+
result = self._wrapped.write(safe_text)
|
|
62
|
+
|
|
63
|
+
if was_redacted and config.notify:
|
|
64
|
+
notifications.notify_console()
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
def flush(self) -> None:
|
|
69
|
+
self._wrapped.flush()
|
|
70
|
+
|
|
71
|
+
def isatty(self) -> bool:
|
|
72
|
+
try:
|
|
73
|
+
return self._wrapped.isatty()
|
|
74
|
+
except Exception:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def __getattr__(self, name: str) -> Any:
|
|
78
|
+
# Delegate any other attribute access (encoding, buffer, etc.) to
|
|
79
|
+
# the wrapped stream so behavior stays consistent with a normal
|
|
80
|
+
# file-like object.
|
|
81
|
+
return getattr(self._wrapped, name)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _wrap_stream(stream: TextIO) -> TextIO:
|
|
85
|
+
if getattr(stream, _WRAPPED_MARKER, False):
|
|
86
|
+
return stream
|
|
87
|
+
return _GuardedStream(stream)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _redact_record(record: logging.LogRecord) -> None:
|
|
91
|
+
"""Redact secrets from a LogRecord's msg and args, in place."""
|
|
92
|
+
config = get_config()
|
|
93
|
+
if not config.enabled:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
redacted_any = False
|
|
98
|
+
|
|
99
|
+
if isinstance(record.msg, str):
|
|
100
|
+
new_msg, changed = redact(
|
|
101
|
+
record.msg,
|
|
102
|
+
entropy_threshold=config.entropy_threshold,
|
|
103
|
+
redact_with=config.redact_with,
|
|
104
|
+
)
|
|
105
|
+
record.msg = new_msg
|
|
106
|
+
redacted_any = redacted_any or changed
|
|
107
|
+
|
|
108
|
+
if record.args:
|
|
109
|
+
if isinstance(record.args, dict):
|
|
110
|
+
new_args = {}
|
|
111
|
+
for key, value in record.args.items():
|
|
112
|
+
if isinstance(value, str):
|
|
113
|
+
new_value, changed = redact(
|
|
114
|
+
value,
|
|
115
|
+
entropy_threshold=config.entropy_threshold,
|
|
116
|
+
redact_with=config.redact_with,
|
|
117
|
+
)
|
|
118
|
+
new_args[key] = new_value
|
|
119
|
+
redacted_any = redacted_any or changed
|
|
120
|
+
else:
|
|
121
|
+
new_args[key] = value
|
|
122
|
+
record.args = new_args
|
|
123
|
+
else:
|
|
124
|
+
new_args = []
|
|
125
|
+
for value in record.args:
|
|
126
|
+
if isinstance(value, str):
|
|
127
|
+
new_value, changed = redact(
|
|
128
|
+
value,
|
|
129
|
+
entropy_threshold=config.entropy_threshold,
|
|
130
|
+
redact_with=config.redact_with,
|
|
131
|
+
)
|
|
132
|
+
new_args.append(new_value)
|
|
133
|
+
redacted_any = redacted_any or changed
|
|
134
|
+
else:
|
|
135
|
+
new_args.append(value)
|
|
136
|
+
record.args = tuple(new_args)
|
|
137
|
+
|
|
138
|
+
if redacted_any and config.notify:
|
|
139
|
+
notifications.notify_console()
|
|
140
|
+
|
|
141
|
+
except Exception:
|
|
142
|
+
# Logging must never break because of a detection failure.
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# We protect logging by wrapping the global LogRecord factory rather than
|
|
147
|
+
# using a Filter. Filters attached to a specific Logger (e.g. the root
|
|
148
|
+
# logger) are only consulted by the logger that originated the call, so a
|
|
149
|
+
# filter on the root logger would NOT see records from child loggers
|
|
150
|
+
# (logging.getLogger(__name__).warning(...)). The record factory, on the
|
|
151
|
+
# other hand, is invoked for every LogRecord created anywhere in the
|
|
152
|
+
# process, which gives us reliable, hierarchy-independent coverage.
|
|
153
|
+
_log_filter_installed = False
|
|
154
|
+
_orig_log_record_factory = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _guarded_log_record_factory(*args: Any, **kwargs: Any) -> logging.LogRecord:
|
|
158
|
+
record = _orig_log_record_factory(*args, **kwargs) # type: ignore[misc]
|
|
159
|
+
_redact_record(record)
|
|
160
|
+
return record
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _install_logging_protection() -> None:
|
|
164
|
+
global _log_filter_installed, _orig_log_record_factory
|
|
165
|
+
if _log_filter_installed:
|
|
166
|
+
return
|
|
167
|
+
_orig_log_record_factory = logging.getLogRecordFactory()
|
|
168
|
+
logging.setLogRecordFactory(_guarded_log_record_factory)
|
|
169
|
+
_log_filter_installed = True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _remove_logging_protection() -> None:
|
|
173
|
+
global _log_filter_installed, _orig_log_record_factory
|
|
174
|
+
if _log_filter_installed and _orig_log_record_factory is not None:
|
|
175
|
+
logging.setLogRecordFactory(_orig_log_record_factory)
|
|
176
|
+
_orig_log_record_factory = None
|
|
177
|
+
_log_filter_installed = False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def enable() -> None:
|
|
181
|
+
"""
|
|
182
|
+
Enable secretshield protection for ``sys.stdout``, ``sys.stderr``, and
|
|
183
|
+
the standard ``logging`` module. Safe to call multiple times; repeated
|
|
184
|
+
calls do not create duplicate wrappers.
|
|
185
|
+
"""
|
|
186
|
+
if _state["active"]:
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
_state["orig_stdout"] = sys.stdout
|
|
190
|
+
_state["orig_stderr"] = sys.stderr
|
|
191
|
+
|
|
192
|
+
sys.stdout = _wrap_stream(sys.stdout)
|
|
193
|
+
sys.stderr = _wrap_stream(sys.stderr)
|
|
194
|
+
|
|
195
|
+
_install_logging_protection()
|
|
196
|
+
|
|
197
|
+
_state["active"] = True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def disable() -> None:
|
|
201
|
+
"""
|
|
202
|
+
Disable secretshield protection, restoring the original ``sys.stdout``
|
|
203
|
+
and ``sys.stderr`` streams and removing the logging filter.
|
|
204
|
+
"""
|
|
205
|
+
if not _state["active"]:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if _state["orig_stdout"] is not None:
|
|
209
|
+
sys.stdout = _state["orig_stdout"]
|
|
210
|
+
if _state["orig_stderr"] is not None:
|
|
211
|
+
sys.stderr = _state["orig_stderr"]
|
|
212
|
+
|
|
213
|
+
_remove_logging_protection()
|
|
214
|
+
|
|
215
|
+
_state["orig_stdout"] = None
|
|
216
|
+
_state["orig_stderr"] = None
|
|
217
|
+
_state["active"] = False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def is_enabled() -> bool:
|
|
221
|
+
"""Return True if secretshield protection is currently active."""
|
|
222
|
+
return bool(_state["active"])
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Notification system for secretshield.
|
|
3
|
+
|
|
4
|
+
Notifications never include the detected secret value, only a generic
|
|
5
|
+
warning. Desktop notifications are best-effort and optional: if the
|
|
6
|
+
underlying OS mechanism is unavailable, failures are swallowed silently
|
|
7
|
+
so the host application is never disrupted.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
WARNING_MESSAGE = "\u26a0 secretshield: Potential secret detected and redacted."
|
|
15
|
+
|
|
16
|
+
# Guards against recursive notification loops (e.g. a notification
|
|
17
|
+
# triggering logging which triggers detection which triggers another
|
|
18
|
+
# notification).
|
|
19
|
+
_in_notification = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def notify_console(stream=None) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Print the safe warning message to the given stream (defaults to the
|
|
25
|
+
*original*, unwrapped stderr to avoid re-triggering detection).
|
|
26
|
+
"""
|
|
27
|
+
global _in_notification
|
|
28
|
+
if _in_notification:
|
|
29
|
+
return
|
|
30
|
+
_in_notification = True
|
|
31
|
+
try:
|
|
32
|
+
target = stream if stream is not None else sys.__stderr__
|
|
33
|
+
if target is None:
|
|
34
|
+
return
|
|
35
|
+
target.write(WARNING_MESSAGE + "\n")
|
|
36
|
+
target.flush()
|
|
37
|
+
except Exception:
|
|
38
|
+
# Notifications must never crash the host application.
|
|
39
|
+
pass
|
|
40
|
+
finally:
|
|
41
|
+
_in_notification = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def notify_desktop(title: str = "secretshield", message: str = WARNING_MESSAGE) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Best-effort desktop notification. Optional and silent on failure.
|
|
47
|
+
Does not perform any network activity. If no desktop notification
|
|
48
|
+
backend is available, this is a no-op.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
import subprocess # local import: only needed for this optional path
|
|
52
|
+
|
|
53
|
+
if sys.platform == "darwin":
|
|
54
|
+
script = f'display notification "{message}" with title "{title}"'
|
|
55
|
+
subprocess.run(
|
|
56
|
+
["osascript", "-e", script],
|
|
57
|
+
check=False,
|
|
58
|
+
capture_output=True,
|
|
59
|
+
timeout=2,
|
|
60
|
+
)
|
|
61
|
+
elif sys.platform.startswith("linux"):
|
|
62
|
+
subprocess.run(
|
|
63
|
+
["notify-send", title, message],
|
|
64
|
+
check=False,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
timeout=2,
|
|
67
|
+
)
|
|
68
|
+
# Other platforms (e.g. Windows) are intentionally no-ops unless a
|
|
69
|
+
# user wires up their own backend; failing silently is preferred
|
|
70
|
+
# over adding heavier optional dependencies.
|
|
71
|
+
except Exception:
|
|
72
|
+
# Desktop notifications are optional; never let this raise.
|
|
73
|
+
pass
|
secretshield/patterns.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Regex patterns used to detect well-known secret formats.
|
|
3
|
+
|
|
4
|
+
Each entry in ``PATTERNS`` is a tuple of ``(name, compiled_regex)``. These
|
|
5
|
+
patterns intentionally target *shapes* of known credential formats (AWS
|
|
6
|
+
keys, GitHub tokens, JWTs, etc.) rather than relying purely on entropy,
|
|
7
|
+
which keeps the false-positive rate low.
|
|
8
|
+
|
|
9
|
+
None of the values in this module are real credentials. They are regular
|
|
10
|
+
expressions only.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
# Each pattern is (name, compiled regex). Order matters only in that more
|
|
18
|
+
# specific patterns are listed before more generic ones.
|
|
19
|
+
PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|
20
|
+
(
|
|
21
|
+
"aws_access_key_id",
|
|
22
|
+
re.compile(r"\b(AKIA|ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASCA)[0-9A-Z]{16}\b"),
|
|
23
|
+
),
|
|
24
|
+
(
|
|
25
|
+
"aws_secret_access_key",
|
|
26
|
+
re.compile(
|
|
27
|
+
r"(?i)\baws(.{0,20})?(secret|access)?(.{0,20})?key(.{0,20})?"
|
|
28
|
+
r"['\"]?\s*[:=]\s*['\"]?[A-Za-z0-9/+=]{40}['\"]?"
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
(
|
|
32
|
+
"github_token",
|
|
33
|
+
re.compile(r"\b(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,255}\b"),
|
|
34
|
+
),
|
|
35
|
+
(
|
|
36
|
+
"openai_api_key",
|
|
37
|
+
re.compile(r"\bsk-[A-Za-z0-9]{20,}(?:T3BlbkFJ[A-Za-z0-9]{20,})?\b"),
|
|
38
|
+
),
|
|
39
|
+
(
|
|
40
|
+
"slack_token",
|
|
41
|
+
re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,48}\b"),
|
|
42
|
+
),
|
|
43
|
+
(
|
|
44
|
+
"stripe_key",
|
|
45
|
+
re.compile(r"\b(sk|pk|rk)_(live|test)_[A-Za-z0-9]{16,}\b"),
|
|
46
|
+
),
|
|
47
|
+
(
|
|
48
|
+
"google_api_key",
|
|
49
|
+
re.compile(r"\bAIza[0-9A-Za-z\-_]{35}\b"),
|
|
50
|
+
),
|
|
51
|
+
(
|
|
52
|
+
"jwt",
|
|
53
|
+
re.compile(
|
|
54
|
+
r"\bey[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b"
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
(
|
|
58
|
+
"bearer_token",
|
|
59
|
+
re.compile(r"(?i)\bbearer\s+[A-Za-z0-9\-._~+/]{10,}=*"),
|
|
60
|
+
),
|
|
61
|
+
(
|
|
62
|
+
"private_key_block",
|
|
63
|
+
re.compile(
|
|
64
|
+
r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"
|
|
65
|
+
r"[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----"
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
(
|
|
69
|
+
"generic_labeled_secret",
|
|
70
|
+
re.compile(
|
|
71
|
+
r"""(?ix)
|
|
72
|
+
\b(api[_-]?key|secret|token|password|passwd|pwd|access[_-]?key|
|
|
73
|
+
client[_-]?secret|auth[_-]?token|private[_-]?key)
|
|
74
|
+
\b
|
|
75
|
+
\s*
|
|
76
|
+
[:=]
|
|
77
|
+
\s*
|
|
78
|
+
['\"]?
|
|
79
|
+
(?P<value>[A-Za-z0-9\-_/+.=]{8,})
|
|
80
|
+
['\"]?
|
|
81
|
+
"""
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
# Group name used by the generic labeled-secret pattern to isolate the
|
|
87
|
+
# value portion (so we don't redact the label itself, only the secret).
|
|
88
|
+
GENERIC_VALUE_GROUP = "value"
|
|
89
|
+
|
|
90
|
+
# Characters considered when computing Shannon entropy for generic
|
|
91
|
+
# high-entropy token detection. Long runs of base64/hex-like strings are
|
|
92
|
+
# candidates; short common words are filtered out by min length checks.
|
|
93
|
+
ENTROPY_CANDIDATE_RE = re.compile(r"\b[A-Za-z0-9+/_\-]{20,}={0,2}\b")
|
|
94
|
+
|
|
95
|
+
# Words that commonly appear as long identifiers but are NOT secrets.
|
|
96
|
+
# Used to reduce false positives in entropy-based detection.
|
|
97
|
+
ENTROPY_ALLOWLIST_SUBSTRINGS = (
|
|
98
|
+
"lorem",
|
|
99
|
+
"ipsum",
|
|
100
|
+
"example",
|
|
101
|
+
"placeholder",
|
|
102
|
+
)
|
secretshield/redactor.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Redaction logic: turns detected secrets into safe placeholder text.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from .detector import Match, detect
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def redact(
|
|
11
|
+
text: str,
|
|
12
|
+
entropy_threshold: float = 4.2,
|
|
13
|
+
redact_with: str = "********",
|
|
14
|
+
) -> tuple[str, bool]:
|
|
15
|
+
"""
|
|
16
|
+
Redact any detected secrets in ``text``.
|
|
17
|
+
|
|
18
|
+
Returns a tuple of ``(redacted_text, was_redacted)``. ``was_redacted``
|
|
19
|
+
is ``True`` if at least one secret was found and replaced. The
|
|
20
|
+
original secret value is never present in the returned string.
|
|
21
|
+
"""
|
|
22
|
+
if not text:
|
|
23
|
+
return text, False
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
matches: list[Match] = detect(text, entropy_threshold=entropy_threshold)
|
|
27
|
+
except Exception:
|
|
28
|
+
# Detection failures must never break output; fail open (no redaction)
|
|
29
|
+
# rather than raise, but never leak partial state.
|
|
30
|
+
return text, False
|
|
31
|
+
|
|
32
|
+
if not matches:
|
|
33
|
+
return text, False
|
|
34
|
+
|
|
35
|
+
# Rebuild the string, replacing each match span with the placeholder.
|
|
36
|
+
# Process in order, tracking an offset since replacement length may
|
|
37
|
+
# differ from the original match length.
|
|
38
|
+
result_parts: list[str] = []
|
|
39
|
+
cursor = 0
|
|
40
|
+
for match in sorted(matches, key=lambda m: m.start):
|
|
41
|
+
if match.start < cursor:
|
|
42
|
+
# Overlap already consumed by a previous replacement; skip.
|
|
43
|
+
continue
|
|
44
|
+
result_parts.append(text[cursor:match.start])
|
|
45
|
+
result_parts.append(redact_with)
|
|
46
|
+
cursor = match.end
|
|
47
|
+
|
|
48
|
+
result_parts.append(text[cursor:])
|
|
49
|
+
return "".join(result_parts), True
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: secretshield
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detect and redact likely secrets before they reach Python's terminal output or logging system.
|
|
5
|
+
Author: secretshield contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Sam3360/secretshield
|
|
8
|
+
Project-URL: Repository, https://github.com/Sam3360/secretshield
|
|
9
|
+
Project-URL: Issues, https://github.com/Sam3360/secretshield/issues
|
|
10
|
+
Keywords: security,secrets,redaction,logging,stdout,credentials
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# secretshield
|
|
28
|
+
|
|
29
|
+
`secretshield` is a local Python security utility that detects likely
|
|
30
|
+
secrets (API keys, tokens, passwords, private keys, and other
|
|
31
|
+
credential-shaped strings) and redacts them **before** they are printed
|
|
32
|
+
through Python's terminal output (`stdout`/`stderr`) or the standard
|
|
33
|
+
`logging` module.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import secretshield
|
|
37
|
+
|
|
38
|
+
api_key = "sk-example1234567890abcdefFAKEKEY"
|
|
39
|
+
print("API key:", api_key)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
API key: ********
|
|
44
|
+
⚠ secretshield: Potential secret detected and redacted.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The real secret value never appears in the redacted output, in
|
|
48
|
+
secretshield's own warning messages, or in any exception it raises.
|
|
49
|
+
|
|
50
|
+
## Why it exists
|
|
51
|
+
|
|
52
|
+
Secrets end up in terminal output and logs more often than anyone
|
|
53
|
+
intends: a debug `print()` left in accidentally, a stack trace that
|
|
54
|
+
includes a config dict, a `logger.info()` call that dumps request
|
|
55
|
+
headers. `secretshield` is a small, dependency-free safety net for
|
|
56
|
+
exactly that class of mistake during local development and debugging.
|
|
57
|
+
|
|
58
|
+
It is **not** a replacement for secret management, code review, or
|
|
59
|
+
static-analysis security tooling — see [Limitations](#limitations) below.
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install secretshield
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
For local development, from a cloned copy of this repository:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install -e ".[dev]"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Requires Python 3.10 or newer. No third-party runtime dependencies.
|
|
74
|
+
|
|
75
|
+
## Basic usage
|
|
76
|
+
|
|
77
|
+
Protection for `sys.stdout`, `sys.stderr`, and `logging` is enabled the
|
|
78
|
+
moment you import the package:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import secretshield
|
|
82
|
+
|
|
83
|
+
password = "hunter2-example-not-real"
|
|
84
|
+
print("Using password:", password)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
Using password: ********
|
|
89
|
+
⚠ secretshield: Potential secret detected and redacted.
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
You can also toggle protection manually:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
import secretshield
|
|
96
|
+
|
|
97
|
+
secretshield.disable() # protection off
|
|
98
|
+
secretshield.enable() # protection back on (idempotent, safe to call repeatedly)
|
|
99
|
+
secretshield.is_enabled()
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Detecting or redacting text directly
|
|
103
|
+
|
|
104
|
+
You don't need to route text through stdout/logging to use the
|
|
105
|
+
detection and redaction logic:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from secretshield import detect, redact
|
|
109
|
+
|
|
110
|
+
matches = detect("aws_key=AKIAABCDEFGHIJKLMNOP")
|
|
111
|
+
# [Match(start=8, end=28, value='AKIA...', kind='aws_access_key_id')]
|
|
112
|
+
|
|
113
|
+
safe_text, was_redacted = redact("aws_key=AKIAABCDEFGHIJKLMNOP")
|
|
114
|
+
# ("aws_key=********", True)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Examples
|
|
118
|
+
|
|
119
|
+
See the [`examples/`](examples/) directory:
|
|
120
|
+
|
|
121
|
+
* [`examples/basic.py`](examples/basic.py) — a fake secret printed to
|
|
122
|
+
the terminal.
|
|
123
|
+
* [`examples/logging_demo.py`](examples/logging_demo.py) — a fake secret
|
|
124
|
+
logged via both `%s`-style arguments and an f-string.
|
|
125
|
+
|
|
126
|
+
Run either with:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
python examples/basic.py
|
|
130
|
+
python examples/logging_demo.py
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## CLI
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
secretshield --help
|
|
137
|
+
secretshield --version
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### `run` — execute a script with runtime protection
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
secretshield run app.py [args...]
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Runs `app.py` as `__main__` with `sys.stdout`, `sys.stderr`, and
|
|
147
|
+
`logging` protected for the duration of the script's execution. This is
|
|
148
|
+
useful for wrapping an existing script without editing its source.
|
|
149
|
+
|
|
150
|
+
### `scan` — static file/directory scanning
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
secretshield scan .
|
|
154
|
+
secretshield scan path/to/file.py
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Scans a file, or recursively scans a directory of text-like files
|
|
158
|
+
(`.py`, `.txt`, `.md`, `.env`, `.yml`, `.json`, `.ini`, `.toml`, `.sh`,
|
|
159
|
+
`.js`, `.ts`, etc.), reporting the *kind* and *location* of any likely
|
|
160
|
+
secrets found. `scan` does **not** execute any code and does **not**
|
|
161
|
+
print the secret values themselves — only where they were found. It
|
|
162
|
+
exits with status `1` if anything was found, `0` otherwise, so it can be
|
|
163
|
+
used as a pre-commit or CI check.
|
|
164
|
+
|
|
165
|
+
**`scan` is static analysis; `run` (and the automatic protection on
|
|
166
|
+
import) is runtime redaction.** They are separate features: `scan`
|
|
167
|
+
looks at file contents on disk, `run`/import-time protection looks at
|
|
168
|
+
what a running program actually writes out.
|
|
169
|
+
|
|
170
|
+
## Configuration
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
import secretshield
|
|
174
|
+
|
|
175
|
+
secretshield.configure(
|
|
176
|
+
enabled=True, # master on/off switch
|
|
177
|
+
redact_with="********", # placeholder used in place of a secret
|
|
178
|
+
entropy_threshold=4.2, # bits/char threshold for generic detection
|
|
179
|
+
notify=True, # print the "potential secret" warning
|
|
180
|
+
)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Sensible defaults mean most projects need zero configuration.
|
|
184
|
+
|
|
185
|
+
## Detection methods
|
|
186
|
+
|
|
187
|
+
`secretshield` combines two strategies:
|
|
188
|
+
|
|
189
|
+
1. **Known-format pattern matching** — regexes tuned to the shape of
|
|
190
|
+
common credential formats: AWS access keys, GitHub tokens, OpenAI-style
|
|
191
|
+
keys, Slack tokens, Stripe keys, Google API keys, JWTs, bearer tokens,
|
|
192
|
+
PEM-style private-key blocks, and generic `key = value` pairs whose
|
|
193
|
+
label looks like `api_key`, `secret`, `token`, `password`, etc.
|
|
194
|
+
2. **Generic high-entropy detection** — a Shannon-entropy check over
|
|
195
|
+
long, non-dictionary-like character runs, used to catch random-looking
|
|
196
|
+
secrets that don't match a known format. This is intentionally used
|
|
197
|
+
as a *supplement*, not the primary mechanism, because entropy alone
|
|
198
|
+
produces far too many false positives on things like hashes, UUIDs,
|
|
199
|
+
and encoded binary data that aren't secrets.
|
|
200
|
+
|
|
201
|
+
## Architecture
|
|
202
|
+
|
|
203
|
+
```text
|
|
204
|
+
secretshield/
|
|
205
|
+
├── patterns.py # regexes for known secret formats
|
|
206
|
+
├── detector.py # detect(): pattern + entropy matching -> Match objects
|
|
207
|
+
├── redactor.py # redact(): turns Match spans into "********"
|
|
208
|
+
├── config.py # configure()/get_config(): runtime settings
|
|
209
|
+
├── notifications.py # safe, secret-free console/desktop warnings
|
|
210
|
+
├── guardian.py # stdout/stderr wrapping + logging record-factory hook
|
|
211
|
+
└── cli.py # `secretshield` command-line entry point
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Key design points:
|
|
215
|
+
|
|
216
|
+
* **Stream wrapping**, not monkey-patching `print`: `sys.stdout` and
|
|
217
|
+
`sys.stderr` are replaced with a thin wrapper object that redacts on
|
|
218
|
+
`write()` and delegates everything else (`flush`, `isatty`, attribute
|
|
219
|
+
access) to the original stream.
|
|
220
|
+
* **Logging protection** hooks `logging.setLogRecordFactory`, not a
|
|
221
|
+
`Filter` on the root logger. Filters attached to the root logger are
|
|
222
|
+
only consulted by the logger that originated a given call, so a
|
|
223
|
+
root-only filter would miss records from `logging.getLogger(__name__)`
|
|
224
|
+
child loggers. The record factory is invoked for every `LogRecord`
|
|
225
|
+
created anywhere in the process, so both `record.msg` (f-strings /
|
|
226
|
+
pre-formatted messages) and `record.args` (`%s`-style lazy arguments)
|
|
227
|
+
are reliably covered regardless of logger hierarchy.
|
|
228
|
+
* **Re-entrancy guards** prevent secretshield's own warning output from
|
|
229
|
+
being fed back into detection/logging and causing recursive loops.
|
|
230
|
+
* Detection and redaction failures are caught and swallowed — a bug in
|
|
231
|
+
secretshield should never crash or block the host application's
|
|
232
|
+
normal output.
|
|
233
|
+
|
|
234
|
+
## Testing
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
pip install -e ".[dev]"
|
|
238
|
+
pytest
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The test suite covers known-token detection, entropy detection, false
|
|
242
|
+
positives, single/multiple/repeated secrets, multiline text, stdout,
|
|
243
|
+
stderr, logging (`%s` args and f-strings), enable/disable idempotency,
|
|
244
|
+
and stream restoration. All secrets used in tests and examples are fake.
|
|
245
|
+
|
|
246
|
+
## Limitations
|
|
247
|
+
|
|
248
|
+
`secretshield` protects **Python's own `stdout`, `stderr`, and `logging`
|
|
249
|
+
output within the current process.** It is a helpful safety net, not a
|
|
250
|
+
comprehensive security boundary. Specifically, it does **not**:
|
|
251
|
+
|
|
252
|
+
* Prevent secrets from appearing in **screenshots** or screen recordings.
|
|
253
|
+
* Prevent **clipboard** leaks.
|
|
254
|
+
* Prevent secrets written via **arbitrary file writes** (e.g. `open(...).write(...)`,
|
|
255
|
+
`json.dump`, writing to a database).
|
|
256
|
+
* Protect **other applications** or processes outside this Python
|
|
257
|
+
interpreter.
|
|
258
|
+
* Redact output from **arbitrary subprocesses** — only output written
|
|
259
|
+
through this process's own `sys.stdout`/`sys.stderr`/`logging` is
|
|
260
|
+
covered, not everything a spawned subprocess itself prints to its own
|
|
261
|
+
inherited file descriptors before Python sees it.
|
|
262
|
+
* Prevent **network leaks** (secrets sent over HTTP, sockets, etc.).
|
|
263
|
+
* Catch **every possible way** a secret can leave a computer. Detection
|
|
264
|
+
is pattern- and entropy-based and can miss unusual or obfuscated
|
|
265
|
+
formats, and can occasionally over- or under-match.
|
|
266
|
+
|
|
267
|
+
Treat `secretshield` as a defense-in-depth safety net for accidental
|
|
268
|
+
local exposure during development and debugging — not as a substitute
|
|
269
|
+
for proper secret management (vaults, environment isolation, `.gitignore`
|
|
270
|
+
discipline, secret scanning in CI, least-privilege credentials, etc.).
|
|
271
|
+
|
|
272
|
+
## Security considerations
|
|
273
|
+
|
|
274
|
+
* secretshield performs **no network calls** and collects **no
|
|
275
|
+
telemetry**. All detection and redaction happens locally, in-process.
|
|
276
|
+
* Desktop notifications (if you wire up your own backend beyond the
|
|
277
|
+
built-in best-effort `notify-send`/`osascript` calls) are optional and
|
|
278
|
+
fail silently if unavailable — they never crash the host application.
|
|
279
|
+
* Because detection is heuristic, it can produce false negatives (a real
|
|
280
|
+
secret slips through) or false positives (harmless text gets redacted).
|
|
281
|
+
Tune `entropy_threshold` and, where needed, extend `patterns.py` for
|
|
282
|
+
your own credential formats.
|
|
283
|
+
|
|
284
|
+
## Contributing
|
|
285
|
+
|
|
286
|
+
Issues and pull requests are welcome. Please:
|
|
287
|
+
|
|
288
|
+
1. Add tests for any new detection pattern or behavior change.
|
|
289
|
+
2. Use only fake/example credentials in tests, examples, and docs —
|
|
290
|
+
never real secrets.
|
|
291
|
+
3. Keep the standard-library-only dependency policy unless there's a
|
|
292
|
+
strong reason to add a dependency, and discuss it in an issue first.
|
|
293
|
+
4. Run `pytest` before opening a PR.
|
|
294
|
+
|
|
295
|
+
## License
|
|
296
|
+
|
|
297
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
secretshield/__init__.py,sha256=SkxDSh_6HeD3XZA4GapBCkJjZgGCMiWzpyKm7mJ9qxg,1146
|
|
2
|
+
secretshield/cli.py,sha256=E7ueu8R02RiaSYqAg353HCEt_S-iz3VEv9bdpRlE4Do,5190
|
|
3
|
+
secretshield/config.py,sha256=n0gjy6KEWWfM1sokX_NaZro2NUFFElUeC4gp-pMvzrM,1393
|
|
4
|
+
secretshield/detector.py,sha256=00yHZMHZtAmgVBSdwJKvZUNPNtil3w6hluf8qpN1fp4,3965
|
|
5
|
+
secretshield/guardian.py,sha256=OAlKxs89cvLpz9-E-vcieHxV2kdMFtkr4vy3-Jv7xbs,7078
|
|
6
|
+
secretshield/notifications.py,sha256=z9_0Ahcjx6ciX93dAdjMGAjzB5cKmSdeNRhNYmLiCsY,2446
|
|
7
|
+
secretshield/patterns.py,sha256=iOAuG0f1oxJVEkAvGRoqAFMOEYrfh6SmDw6y6v3Di28,3068
|
|
8
|
+
secretshield/redactor.py,sha256=HKuvrowwBp3w9vKW2nboX7_uvdSenWp_vrrwlBJJiDQ,1537
|
|
9
|
+
secretshield-0.1.0.dist-info/licenses/LICENSE,sha256=evWX6Xn2-RiZD62EhcIAPXeFjqT9MmAGXmmRXax98bI,1082
|
|
10
|
+
secretshield-0.1.0.dist-info/METADATA,sha256=BMiZol3Dd7v93vV6dhmving-hlbx7TrKxMn1grUz4TU,10954
|
|
11
|
+
secretshield-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
secretshield-0.1.0.dist-info/entry_points.txt,sha256=H_hdDswYwoiLoGbxqGEW0FW1kDgg7NXyQAtEsmAlVKg,55
|
|
13
|
+
secretshield-0.1.0.dist-info/top_level.txt,sha256=903lcxIpnUvvGImf4-bkNR0HvLJ2_8vq668k8_rDu6Y,13
|
|
14
|
+
secretshield-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 secretshield contributors
|
|
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 @@
|
|
|
1
|
+
secretshield
|