keysnare 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.
keysnare/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """KeySnare: find plain-text credentials and trap credential stealers."""
2
+
3
+ __version__ = "0.1.0"
keysnare/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from keysnare.cli import main
2
+
3
+ raise SystemExit(main())
keysnare/cli.py ADDED
@@ -0,0 +1,249 @@
1
+ """Command-line interface: `keysnare scan` and `keysnare trap`."""
2
+
3
+ import argparse
4
+ import io
5
+ import os
6
+ import sys
7
+ import time
8
+ from collections.abc import Sequence
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import TextIO
12
+
13
+ from keysnare import __version__
14
+ from keysnare.json_report import render_json
15
+ from keysnare.report import ReportContext
16
+ from keysnare.risk import Severity
17
+ from keysnare.sarif_report import render_sarif
18
+ from keysnare.scanner import scan
19
+ from keysnare.terminal import (
20
+ ProgressLine,
21
+ enable_windows_colors,
22
+ render_brief,
23
+ render_summary,
24
+ render_text,
25
+ use_color,
26
+ )
27
+ from keysnare.trap.commands import (
28
+ TrapDeps,
29
+ run_alert,
30
+ run_install,
31
+ run_remove,
32
+ run_status,
33
+ run_test,
34
+ )
35
+ from keysnare.trap.manager import TrapPaths
36
+ from keysnare.trap.placement import gather_machine
37
+ from keysnare.trap.state import STATE_FILE_NAME, data_dir, load_state
38
+ from keysnare.trap.windows import is_elevated, is_windows
39
+
40
+ EXIT_OK = 0
41
+ EXIT_FINDINGS = 1
42
+ EXIT_ERROR = 2
43
+
44
+ EXIT_CODES_HELP = """\
45
+ exit codes:
46
+ 0 no findings at or above the --fail-on level
47
+ 1 findings at or above the --fail-on level
48
+ 2 error (invalid arguments, missing path, or the scan failed)
49
+ """
50
+
51
+
52
+ def build_parser() -> argparse.ArgumentParser:
53
+ """Return the argument parser for the keysnare command."""
54
+ parser = argparse.ArgumentParser(
55
+ prog="keysnare",
56
+ description="Find plain-text credentials that credential-stealing malware would grab.",
57
+ )
58
+ parser.add_argument("--version", action="version", version=f"keysnare {__version__}")
59
+ commands = parser.add_subparsers(dest="command", required=True)
60
+ scan_parser = commands.add_parser(
61
+ "scan",
62
+ help="scan for plain-text credentials",
63
+ description="Scan your home folder, or PATH, for plain-text credentials. "
64
+ "Runs fully offline. Secrets are always masked.",
65
+ epilog=EXIT_CODES_HELP,
66
+ formatter_class=argparse.RawDescriptionHelpFormatter,
67
+ )
68
+ scan_parser.add_argument(
69
+ "path", nargs="?", type=Path, help="folder or file to scan (default: your home folder)"
70
+ )
71
+ output = scan_parser.add_mutually_exclusive_group()
72
+ output.add_argument("--json", action="store_true", help="print a JSON report")
73
+ output.add_argument("--sarif", action="store_true", help="print a SARIF 2.1.0 report")
74
+ output.add_argument("--quiet", action="store_true", help="print only the summary line")
75
+ output.add_argument(
76
+ "--brief",
77
+ action="store_true",
78
+ help="print one line per finding, without explanations and fix steps",
79
+ )
80
+ scan_parser.add_argument(
81
+ "--fail-on",
82
+ choices=[severity.value for severity in Severity],
83
+ default=Severity.HIGH.value,
84
+ help="lowest severity that makes the exit code 1 (default: high)",
85
+ )
86
+ scan_parser.add_argument(
87
+ "--show-full-paths",
88
+ action="store_true",
89
+ help="show full paths instead of ~ for your home folder",
90
+ )
91
+ scan_parser.add_argument(
92
+ "--output",
93
+ metavar="FILE",
94
+ type=Path,
95
+ help="write the report to FILE (UTF-8 without BOM) instead of the screen",
96
+ )
97
+ _add_trap_parser(commands)
98
+ return parser
99
+
100
+
101
+ def _add_trap_parser(commands: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
102
+ trap_parser = commands.add_parser(
103
+ "trap",
104
+ help="decoy credentials that alert when read (Windows)",
105
+ description="Plant fake, clearly marked credentials and get a Windows notification "
106
+ "when a program reads one. See docs/design-traps.md.",
107
+ )
108
+ actions = trap_parser.add_subparsers(dest="action", required=True)
109
+ install = actions.add_parser("install", help="plant decoys and register the alert task")
110
+ install.add_argument("--yes", action="store_true", help="do not ask questions (for CI)")
111
+ actions.add_parser("status", help="show decoys, the alert task and recent alerts")
112
+ actions.add_parser("test", help="simulate a theft to check that alerts work")
113
+ remove = actions.add_parser("remove", help="undo everything trap install did")
114
+ remove.add_argument("--purge", action="store_true", help="also delete the alert history")
115
+ actions.add_parser("alert", help=argparse.SUPPRESS)
116
+
117
+
118
+ def main(argv: Sequence[str] | None = None) -> int:
119
+ """Run the command line and return the exit code."""
120
+ args = build_parser().parse_args(argv)
121
+ _use_utf8_output()
122
+ if is_elevated():
123
+ print(
124
+ "keysnare: error: KeySnare refuses to run as administrator, so that its own code "
125
+ "never runs elevated. Run it from a normal terminal.",
126
+ file=sys.stderr,
127
+ )
128
+ return EXIT_ERROR
129
+ try:
130
+ if args.command == "trap":
131
+ return _trap_command(args)
132
+ return _scan_command(args)
133
+ except Exception as error:
134
+ # CI relies on exit code 2 for any failure, so no exception may escape as a traceback.
135
+ print(f"keysnare: error: {error}", file=sys.stderr)
136
+ return EXIT_ERROR
137
+
138
+
139
+ def _trap_command(args: argparse.Namespace) -> int:
140
+ if not is_windows():
141
+ print("keysnare: error: traps are Windows-only in this version.", file=sys.stderr)
142
+ return EXIT_ERROR
143
+ deps = _windows_trap_deps()
144
+ if args.action == "install":
145
+ return run_install(deps, yes=args.yes)
146
+ if args.action == "status":
147
+ return run_status(deps)
148
+ if args.action == "test":
149
+ return run_test(deps)
150
+ if args.action == "remove":
151
+ return run_remove(deps, purge=args.purge)
152
+ return run_alert(deps)
153
+
154
+
155
+ def _windows_trap_deps() -> TrapDeps:
156
+ from keysnare.trap import windows
157
+
158
+ home = Path.home()
159
+ folder = data_dir(os.environ)
160
+ return TrapDeps(
161
+ system=windows.WindowsSystem(),
162
+ paths=TrapPaths(
163
+ home=home, data_dir=folder, store_data_dir=windows.store_python_data_dir(folder)
164
+ ),
165
+ machine=lambda: gather_machine(home, os.environ),
166
+ handler=windows.handler_path(),
167
+ protected_dirs=windows.protected_dirs,
168
+ path_forms=windows.path_forms,
169
+ notify=windows.notify,
170
+ now=lambda: datetime.now(timezone.utc).isoformat(timespec="seconds"),
171
+ ask=input,
172
+ out=sys.stdout,
173
+ read_export=windows.read_export,
174
+ start_reader=windows.start_reader,
175
+ sleep=time.sleep,
176
+ monotonic=time.monotonic,
177
+ )
178
+
179
+
180
+ def shows_progress(args: argparse.Namespace, stream: TextIO) -> bool:
181
+ """Show the progress line only to a person at a terminal, never in machine output."""
182
+ return stream.isatty() and not (args.quiet or args.json or args.sarif)
183
+
184
+
185
+ def may_hold_decoys(root: Path | None, home: Path) -> bool:
186
+ """Return True if the scan could reach a decoy; decoys only ever live in the home folder."""
187
+ return root is None or root.is_relative_to(home) or home.is_relative_to(root)
188
+
189
+
190
+ def _installed_decoys(root: Path | None) -> list[Path]:
191
+ if not is_windows() or not may_hold_decoys(root, Path.home()):
192
+ return []
193
+ try:
194
+ state = load_state(data_dir(os.environ) / STATE_FILE_NAME)
195
+ except ValueError:
196
+ return []
197
+ return [record.path for record in state.decoys] if state else []
198
+
199
+
200
+ def _scan_command(args: argparse.Namespace) -> int:
201
+ root: Path | None = args.path.absolute() if args.path else None
202
+ if root is not None and not root.exists():
203
+ print(f"keysnare: error: {args.path} does not exist", file=sys.stderr)
204
+ return EXIT_ERROR
205
+ started = time.perf_counter()
206
+ progress = ProgressLine(sys.stderr) if shows_progress(args, sys.stderr) else None
207
+ try:
208
+ result = scan(root, decoys=_installed_decoys(root), progress=progress)
209
+ finally:
210
+ if progress:
211
+ progress.clear()
212
+ context = ReportContext(
213
+ result, time.perf_counter() - started, Path.home(), args.show_full_paths
214
+ )
215
+ report = _render(args, context)
216
+ if args.output:
217
+ try:
218
+ args.output.write_text(report, encoding="utf-8", newline="\n")
219
+ except OSError as error:
220
+ print(
221
+ f"keysnare: error: could not write {args.output}: {error.strerror}", file=sys.stderr
222
+ )
223
+ return EXIT_ERROR
224
+ else:
225
+ sys.stdout.write(report)
226
+ highest = context.highest_severity
227
+ fails = highest is not None and highest <= Severity(args.fail_on)
228
+ return EXIT_FINDINGS if fails else EXIT_OK
229
+
230
+
231
+ def _render(args: argparse.Namespace, context: ReportContext) -> str:
232
+ if args.json:
233
+ return render_json(context)
234
+ if args.sarif:
235
+ return render_sarif(context)
236
+ if args.quiet:
237
+ return render_summary(context) + "\n"
238
+ color = not args.output and use_color(sys.stdout, os.environ) and enable_windows_colors()
239
+ if args.brief:
240
+ return render_brief(context, color)
241
+ return render_text(context, color)
242
+
243
+
244
+ def _use_utf8_output() -> None:
245
+ # Redirected output on Windows defaults to a legacy code page that cannot encode every
246
+ # character in the report, such as the arrows in fix steps.
247
+ for stream in (sys.stdout, sys.stderr):
248
+ if isinstance(stream, io.TextIOWrapper):
249
+ stream.reconfigure(encoding="utf-8", errors="replace")
keysnare/detect.py ADDED
@@ -0,0 +1,314 @@
1
+ """Find secrets in a credential file's text, following docs/detection-rules.md."""
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+
7
+ from keysnare.locations import Category
8
+ from keysnare.readers import (
9
+ Entry,
10
+ KeyProtection,
11
+ read_json_entries,
12
+ read_key_values,
13
+ read_nuget_entries,
14
+ ssh_key_protection,
15
+ )
16
+ from keysnare.rules import (
17
+ is_placeholder,
18
+ is_secret_name,
19
+ is_secret_value,
20
+ match_format_rules,
21
+ url_password,
22
+ )
23
+
24
+ KeyTest = Callable[[tuple[str, ...]], bool]
25
+
26
+ # Binary credential stores: reported when present and non-empty, never opened.
27
+ PRESENCE_RULES = {
28
+ Category.AZURE_DPAPI: "S-AZURE-DPAPI",
29
+ Category.GCP_DB: "S-GCP-CREDENTIALS-DB",
30
+ }
31
+
32
+ AWS_SECRET_SHAPE = re.compile(r"[A-Za-z0-9/+]{40}")
33
+ HISTORY_ASSIGNMENT = re.compile(
34
+ r"(?:\b(?:export|set)\s+|\$env:)([A-Za-z_]\w*)\s*=\s*[\"']?([^\"'\s;]+)"
35
+ )
36
+ HISTORY_SECRET_ARGUMENTS = (
37
+ re.compile(r"--password[=\s]+[\"']?([^\"'\s]+)"),
38
+ re.compile(r"\bdocker\s+login\b.*?\s-p\s+[\"']?([^\"'\s]+)"),
39
+ re.compile(r"authorization:\s*bearer\s+([^\"'\s]+)", re.IGNORECASE),
40
+ )
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Detection:
45
+ """A secret found in a file. `secret` is None when the file itself is the finding."""
46
+
47
+ rule_id: str
48
+ line: int | None
49
+ # repr=False: printing or logging this object must never show the secret.
50
+ secret: str | None = field(default=None, repr=False)
51
+
52
+
53
+ def _last(*names: str) -> KeyTest:
54
+ wanted = frozenset(name.lower() for name in names)
55
+ return lambda key: bool(key) and key[-1].lower() in wanted
56
+
57
+
58
+ def _exact(*path: str) -> KeyTest:
59
+ return lambda key: key == path
60
+
61
+
62
+ def _is_api_key(key: tuple[str, ...]) -> bool:
63
+ return bool(key) and key[-1].lower().replace("-", "").replace("_", "").endswith("apikey")
64
+
65
+
66
+ AWS_RULES = [
67
+ (_last("aws_secret_access_key"), "S-AWS-SECRET"),
68
+ (_last("aws_session_token"), "S-AWS-SESSION"),
69
+ ]
70
+
71
+ KEY_VALUE_RULES: dict[Category, list[tuple[KeyTest, str]]] = {
72
+ Category.AWS_CREDENTIALS: AWS_RULES,
73
+ Category.AWS_CONFIG: AWS_RULES,
74
+ Category.NPMRC: [
75
+ (
76
+ lambda key: key[-1].lower().endswith(("_authtoken", "_auth", "_password")),
77
+ "S-NPMRC-TOKEN",
78
+ )
79
+ ],
80
+ Category.YARNRC: [(_last("npmAuthToken"), "S-YARNRC-TOKEN")],
81
+ Category.GH_HOSTS: [(_last("oauth_token"), "S-GH-HOSTS")],
82
+ Category.PYPIRC: [(_last("password"), "S-PYPIRC-PASSWORD")],
83
+ Category.POETRY_AUTH: [
84
+ (lambda key: key[0].startswith("http-basic.") and key[-1] == "password", "S-POETRY-AUTH"),
85
+ (lambda key: key[0] == "pypi-token", "S-POETRY-AUTH"),
86
+ ],
87
+ Category.KUBECONFIG: [
88
+ (
89
+ lambda key: "auth-provider" in key and key[-1] in ("access-token", "refresh-token"),
90
+ "S-KUBE-AUTH-PROVIDER",
91
+ ),
92
+ (lambda key: key[0] == "users" and key[-1] == "token", "S-KUBE-TOKEN"),
93
+ (lambda key: key[0] == "users" and key[-1] == "client-key-data", "S-KUBE-CLIENT-KEY"),
94
+ (lambda key: key[0] == "users" and key[-1] == "password", "S-KUBE-PASSWORD"),
95
+ ],
96
+ }
97
+
98
+ JSON_RULES: dict[Category, list[tuple[KeyTest, str]]] = {
99
+ Category.AWS_CACHE: [
100
+ (_exact("Credentials", "SecretAccessKey"), "S-AWS-CACHE"),
101
+ (_exact("accessToken"), "S-AWS-CACHE"),
102
+ ],
103
+ Category.DOCKER: [
104
+ (
105
+ lambda key: len(key) == 3 and key[0] == "auths" and key[2] in ("auth", "identitytoken"),
106
+ "S-DOCKER-AUTH",
107
+ ),
108
+ ],
109
+ Category.CLAUDE_CODE: [
110
+ (_exact("claudeAiOauth", "accessToken"), "S-CLAUDE-OAUTH"),
111
+ (_exact("claudeAiOauth", "refreshToken"), "S-CLAUDE-OAUTH"),
112
+ ],
113
+ Category.CODEX: [
114
+ (_exact("OPENAI_API_KEY"), "S-CODEX-AUTH"),
115
+ (_exact("tokens", "refresh_token"), "S-CODEX-AUTH"),
116
+ ],
117
+ Category.GEMINI_OAUTH: [(_exact("refresh_token"), "S-GEMINI-OAUTH")],
118
+ Category.COPILOT: [(_last("oauth_token"), "S-COPILOT-TOKEN")],
119
+ Category.AZURE_JSON: [
120
+ (lambda key: key[0] == "RefreshToken" and key[-1] == "secret", "S-AZURE-MSAL"),
121
+ (_last("client_secret"), "S-AZURE-SP"),
122
+ (_last("accessToken", "refreshToken"), "S-AZURE-LEGACY"),
123
+ ],
124
+ }
125
+
126
+ URL_RULES: dict[Category, tuple[KeyTest, str]] = {
127
+ Category.GIT_REMOTE: (_last("url"), "S-GIT-REMOTE"),
128
+ Category.PIP_CONFIG: (_last("index-url", "extra-index-url"), "S-PIP-INDEX-URL"),
129
+ }
130
+
131
+ GCP_SECRET_KEYS = {
132
+ "service_account": (("private_key",), "S-GCP-SERVICE-ACCOUNT"),
133
+ "authorized_user": (("refresh_token",), "S-GCP-ADC-USER"),
134
+ }
135
+
136
+
137
+ def find_secrets(category: Category, text: str) -> list[Detection]:
138
+ """Return one detection per secret in a credential file's text.
139
+
140
+ Categories in PRESENCE_RULES are binary stores that the caller reports without reading.
141
+ """
142
+ if category is Category.SSH_KEY:
143
+ return _private_key(text, "S-SSH-KEY")
144
+ if category is Category.PRIVATE_KEY:
145
+ return _private_key(text, "S-PRIVATE-KEY-FILE")
146
+ detections = [*_structure(category, text), *_format(text), *_fallback(category, text)]
147
+ return _one_per_secret(detections)
148
+
149
+
150
+ def _structure(category: Category, text: str) -> list[Detection]:
151
+ if category in KEY_VALUE_RULES:
152
+ return _apply(read_key_values(text), KEY_VALUE_RULES[category])
153
+ if category in JSON_RULES:
154
+ return _apply(_json_entries(text), JSON_RULES[category])
155
+ if category in URL_RULES:
156
+ key_test, rule_id = URL_RULES[category]
157
+ return _url_entries(read_key_values(text), key_test, rule_id)
158
+ special = {
159
+ Category.AI_TOOL_CONFIG: _ai_tool_config,
160
+ Category.GCP_JSON: _gcp_json,
161
+ Category.NUGET: _nuget,
162
+ Category.HUGGING_FACE: _hugging_face,
163
+ Category.GIT_CREDENTIALS: _git_credentials,
164
+ }.get(category)
165
+ return special(text) if special else []
166
+
167
+
168
+ def _format(text: str) -> list[Detection]:
169
+ return [
170
+ Detection(match.rule_id, number, match.secret)
171
+ for number, line in enumerate(text.splitlines(), start=1)
172
+ for match in match_format_rules(line)
173
+ ]
174
+
175
+
176
+ def _fallback(category: Category, text: str) -> list[Detection]:
177
+ if category in (Category.ENV_FILE, Category.GEMINI_ENV):
178
+ return _named_secrets(read_key_values(text), "K-SECRET-NAME")
179
+ if category is Category.MCP_CONFIG:
180
+ env_entries = [
181
+ entry
182
+ for entry in _json_entries(text)
183
+ if len(entry.key) == 4 and entry.key[0] == "mcpServers" and entry.key[2] == "env"
184
+ ]
185
+ return _named_secrets(env_entries, "S-MCP-ENV")
186
+ if category is Category.SHELL_HISTORY:
187
+ return _shell_history(text)
188
+ return []
189
+
190
+
191
+ def _apply(entries: list[Entry], rules: list[tuple[KeyTest, str]]) -> list[Detection]:
192
+ detections = []
193
+ for entry in entries:
194
+ if is_placeholder(entry.value):
195
+ continue
196
+ rule_id = next((rule_id for key_test, rule_id in rules if key_test(entry.key)), None)
197
+ if rule_id:
198
+ detections.append(Detection(rule_id, entry.line, entry.value))
199
+ return detections
200
+
201
+
202
+ def _named_secrets(entries: list[Entry], rule_id: str) -> list[Detection]:
203
+ return [
204
+ Detection(_named_rule(entry.key[-1], entry.value, rule_id), entry.line, entry.value)
205
+ for entry in entries
206
+ if is_secret_name(entry.key[-1]) and is_secret_value(entry.value)
207
+ ]
208
+
209
+
210
+ def _named_rule(name: str, value: str, default_rule: str) -> str:
211
+ # An AWS secret key has no recognisable prefix, but its standard variable name plus its
212
+ # exact 40-character shape make it as certain as a format rule.
213
+ if name.lower() == "aws_secret_access_key" and AWS_SECRET_SHAPE.fullmatch(value):
214
+ return "S-AWS-SECRET-ENV"
215
+ return default_rule
216
+
217
+
218
+ def _url_entries(entries: list[Entry], key_test: KeyTest, rule_id: str) -> list[Detection]:
219
+ detections = []
220
+ for entry in entries:
221
+ password = url_password(entry.value) if key_test(entry.key) else None
222
+ if password:
223
+ detections.append(Detection(rule_id, entry.line, password))
224
+ return detections
225
+
226
+
227
+ def _json_entries(text: str) -> list[Entry]:
228
+ try:
229
+ return read_json_entries(text)
230
+ except (ValueError, RecursionError):
231
+ # Not strict JSON (often JSON with comments). Format rules still run on its lines.
232
+ return []
233
+
234
+
235
+ def _ai_tool_config(text: str) -> list[Detection]:
236
+ is_json = text.lstrip().startswith("{")
237
+ entries = _json_entries(text) if is_json else read_key_values(text)
238
+ return _apply(entries, [(_is_api_key, "S-CONFIG-APIKEY")])
239
+
240
+
241
+ def _gcp_json(text: str) -> list[Detection]:
242
+ values = {entry.key: entry for entry in _json_entries(text)}
243
+ kind = values.get(("type",))
244
+ if kind is None or kind.value not in GCP_SECRET_KEYS:
245
+ return []
246
+ key, rule_id = GCP_SECRET_KEYS[kind.value]
247
+ entry = values.get(key)
248
+ if entry is None or is_placeholder(entry.value):
249
+ return []
250
+ return [Detection(rule_id, entry.line, entry.value)]
251
+
252
+
253
+ def _nuget(text: str) -> list[Detection]:
254
+ rules = [
255
+ (_last("ClearTextPassword"), "S-NUGET-CLEARTEXT"),
256
+ (_last("Password"), "S-NUGET-DPAPI"),
257
+ ]
258
+ return _apply(read_nuget_entries(text), rules)
259
+
260
+
261
+ def _hugging_face(text: str) -> list[Detection]:
262
+ token = text.strip()
263
+ return [] if is_placeholder(token) else [Detection("S-HF-TOKEN-FILE", 1, token)]
264
+
265
+
266
+ def _git_credentials(text: str) -> list[Detection]:
267
+ detections = []
268
+ for number, line in enumerate(text.splitlines(), start=1):
269
+ password = url_password(line)
270
+ if password:
271
+ detections.append(Detection("S-GIT-CREDENTIALS", number, password))
272
+ return detections
273
+
274
+
275
+ def _private_key(text: str, rule_id: str) -> list[Detection]:
276
+ if ssh_key_protection(text) is not KeyProtection.UNENCRYPTED:
277
+ return []
278
+ header_line = next(
279
+ (
280
+ number
281
+ for number, line in enumerate(text.splitlines(), start=1)
282
+ if "PRIVATE KEY-----" in line or line.startswith("PuTTY-User-Key-File-")
283
+ ),
284
+ None,
285
+ )
286
+ return [Detection(rule_id, header_line)]
287
+
288
+
289
+ def _shell_history(text: str) -> list[Detection]:
290
+ detections = []
291
+ for number, line in enumerate(text.splitlines(), start=1):
292
+ for name, value in HISTORY_ASSIGNMENT.findall(line):
293
+ if is_secret_name(name) and is_secret_value(value):
294
+ rule_id = _named_rule(name, value, "S-HISTORY-SECRET")
295
+ detections.append(Detection(rule_id, number, value))
296
+ for pattern in HISTORY_SECRET_ARGUMENTS:
297
+ for value in pattern.findall(line):
298
+ if is_secret_value(value):
299
+ detections.append(Detection("S-HISTORY-SECRET", number, value))
300
+ return detections
301
+
302
+
303
+ def _one_per_secret(detections: list[Detection]) -> list[Detection]:
304
+ kept: list[Detection] = []
305
+ for detection in detections:
306
+ if not any(_same_secret(detection, earlier) for earlier in kept):
307
+ kept.append(detection)
308
+ return kept
309
+
310
+
311
+ def _same_secret(a: Detection, b: Detection) -> bool:
312
+ if a.line != b.line or a.secret is None or b.secret is None:
313
+ return False
314
+ return a.secret in b.secret or b.secret in a.secret