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.
@@ -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
@@ -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)
@@ -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
@@ -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
+ )
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ secretshield = secretshield.cli:main
@@ -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