secretshield 0.7.0__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {secretshield-0.7.0/secretshield.egg-info → secretshield-0.8.0}/PKG-INFO +17 -6
  2. {secretshield-0.7.0 → secretshield-0.8.0}/README.md +16 -5
  3. {secretshield-0.7.0 → secretshield-0.8.0}/pyproject.toml +1 -1
  4. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/__init__.py +1 -1
  5. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/cli.py +105 -4
  6. {secretshield-0.7.0 → secretshield-0.8.0/secretshield.egg-info}/PKG-INFO +17 -6
  7. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield.egg-info/SOURCES.txt +2 -1
  8. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_custom_rules_cli_integration.py +54 -1
  9. secretshield-0.8.0/tests/test_v080_self_detection_fix.py +341 -0
  10. {secretshield-0.7.0 → secretshield-0.8.0}/LICENSE +0 -0
  11. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/autofix/__init__.py +0 -0
  12. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/autofix/env.py +0 -0
  13. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/autofix/fixer.py +0 -0
  14. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/autofix/gitignore.py +0 -0
  15. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/autofix/javascript.py +0 -0
  16. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/autofix/python.py +0 -0
  17. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/baseline.py +0 -0
  18. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/color.py +0 -0
  19. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/config.py +0 -0
  20. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/custom_rules.py +0 -0
  21. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/detector.py +0 -0
  22. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/git/__init__.py +0 -0
  23. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/git/hooks.py +0 -0
  24. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/github/__init__.py +0 -0
  25. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/github/actions.py +0 -0
  26. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/guardian.py +0 -0
  27. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/ignorefile.py +0 -0
  28. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/notifications.py +0 -0
  29. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/patterns.py +0 -0
  30. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/project_config.py +0 -0
  31. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield/redactor.py +0 -0
  32. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield.egg-info/dependency_links.txt +0 -0
  33. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield.egg-info/entry_points.txt +0 -0
  34. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield.egg-info/requires.txt +0 -0
  35. {secretshield-0.7.0 → secretshield-0.8.0}/secretshield.egg-info/top_level.txt +0 -0
  36. {secretshield-0.7.0 → secretshield-0.8.0}/setup.cfg +0 -0
  37. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_autofix.py +0 -0
  38. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_baseline.py +0 -0
  39. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_color.py +0 -0
  40. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_color_cli_integration.py +0 -0
  41. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_custom_rules.py +0 -0
  42. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_detector.py +0 -0
  43. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_git_hooks.py +0 -0
  44. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_github_action.py +0 -0
  45. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_ignorefile.py +0 -0
  46. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_ignorefile_cli_integration.py +0 -0
  47. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_javascript_autofix.py +0 -0
  48. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_logging.py +0 -0
  49. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_project_config.py +0 -0
  50. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_redactor.py +0 -0
  51. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_scan.py +0 -0
  52. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_ss_alias_and_creator.py +0 -0
  53. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_stdout.py +0 -0
  54. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_v030_cli_integration.py +0 -0
  55. {secretshield-0.7.0 → secretshield-0.8.0}/tests/test_v040_cli_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secretshield
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Stop secrets before they leak -- runtime redaction, multi-language scanning, auto-fix, Git hooks, and CI, all in one CLI.
5
5
  Author: Samarth Chugh (Sam3360)
6
6
  License-Expression: MIT
@@ -32,10 +32,10 @@ Dynamic: license-file
32
32
 
33
33
  [![PyPI version](https://img.shields.io/pypi/v/secretshield?color=blue)](https://pypi.org/project/secretshield/)
34
34
  [![Python versions](https://img.shields.io/pypi/pyversions/secretshield)](https://pypi.org/project/secretshield/)
35
- [![PyPI downloads](https://img.shields.io/pypi/dm/secretshield)](https://pypi.org/project/secretshield/)
35
+ [![PyPI Downloads](https://api.pepy.tech/badge/secretshield/month)](https://pypi.org/project/secretshield/)
36
36
  [![License: MIT](https://img.shields.io/pypi/l/secretshield)](LICENSE)
37
37
 
38
- [Install](#install) · [Demo](#demo) · [Try it](#try-it) · [Full usage guide →](https://github.com/Sam3360/secretshield/blob/main/USAGE.md)
38
+ [Install](#install) · [Demo](#demo) · [Try it](#try-it) · [Homepage 🌐](https://therealsecretshield.freebuff.app/) · [Full usage guide →](https://github.com/Sam3360/secretshield/blob/main/USAGE.md)
39
39
 
40
40
  </div>
41
41
 
@@ -115,9 +115,12 @@ name = "My API Key"
115
115
  pattern = "MYAPP_[A-Za-z0-9]{32}"
116
116
  ```
117
117
 
118
- `secretshield scan .` picks it up automatically — no flag needed.
119
- Validate the file with `secretshield rules check`, or create a rule
120
- interactively with `secretshield rule create`.
118
+ `secretshield scan .` picks it up automatically — no flag needed. The
119
+ rules file itself is never treated as project content to scan, so a
120
+ pattern that resembles a real secret won't flag its own definition.
121
+ Validate rules with `secretshield rules check`, create one
122
+ interactively with `secretshield rule create`, or try a pattern against
123
+ sample text with `secretshield rule test` before committing to it.
121
124
 
122
125
  For the full command reference, configuration options, and exactly how
123
126
  Auto-Fix decides what's safe to rewrite, see the
@@ -145,3 +148,11 @@ If you find this project useful, consider [supporting its development through Gi
145
148
  ## License
146
149
 
147
150
  MIT — see [LICENSE](LICENSE).
151
+
152
+
153
+
154
+ ## Homepage
155
+
156
+ Visit the official SecretShield homepage for an overview of the project, features, releases, and more:
157
+
158
+ **🌐 [therealsecretshield.freebuff.app](https://therealsecretshield.freebuff.app/)**
@@ -6,10 +6,10 @@
6
6
 
7
7
  [![PyPI version](https://img.shields.io/pypi/v/secretshield?color=blue)](https://pypi.org/project/secretshield/)
8
8
  [![Python versions](https://img.shields.io/pypi/pyversions/secretshield)](https://pypi.org/project/secretshield/)
9
- [![PyPI downloads](https://img.shields.io/pypi/dm/secretshield)](https://pypi.org/project/secretshield/)
9
+ [![PyPI Downloads](https://api.pepy.tech/badge/secretshield/month)](https://pypi.org/project/secretshield/)
10
10
  [![License: MIT](https://img.shields.io/pypi/l/secretshield)](LICENSE)
11
11
 
12
- [Install](#install) · [Demo](#demo) · [Try it](#try-it) · [Full usage guide →](https://github.com/Sam3360/secretshield/blob/main/USAGE.md)
12
+ [Install](#install) · [Demo](#demo) · [Try it](#try-it) · [Homepage 🌐](https://therealsecretshield.freebuff.app/) · [Full usage guide →](https://github.com/Sam3360/secretshield/blob/main/USAGE.md)
13
13
 
14
14
  </div>
15
15
 
@@ -89,9 +89,12 @@ name = "My API Key"
89
89
  pattern = "MYAPP_[A-Za-z0-9]{32}"
90
90
  ```
91
91
 
92
- `secretshield scan .` picks it up automatically — no flag needed.
93
- Validate the file with `secretshield rules check`, or create a rule
94
- interactively with `secretshield rule create`.
92
+ `secretshield scan .` picks it up automatically — no flag needed. The
93
+ rules file itself is never treated as project content to scan, so a
94
+ pattern that resembles a real secret won't flag its own definition.
95
+ Validate rules with `secretshield rules check`, create one
96
+ interactively with `secretshield rule create`, or try a pattern against
97
+ sample text with `secretshield rule test` before committing to it.
95
98
 
96
99
  For the full command reference, configuration options, and exactly how
97
100
  Auto-Fix decides what's safe to rewrite, see the
@@ -119,3 +122,11 @@ If you find this project useful, consider [supporting its development through Gi
119
122
  ## License
120
123
 
121
124
  MIT — see [LICENSE](LICENSE).
125
+
126
+
127
+
128
+ ## Homepage
129
+
130
+ Visit the official SecretShield homepage for an overview of the project, features, releases, and more:
131
+
132
+ **🌐 [therealsecretshield.freebuff.app](https://therealsecretshield.freebuff.app/)**
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "secretshield"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "Stop secrets before they leak -- runtime redaction, multi-language scanning, auto-fix, Git hooks, and CI, all in one CLI."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -23,7 +23,7 @@ from .detector import Match, detect
23
23
  from .guardian import disable, enable, is_enabled
24
24
  from .redactor import redact
25
25
 
26
- __version__ = "0.7.0"
26
+ __version__ = "0.8.0"
27
27
 
28
28
  __all__ = [
29
29
  "__version__",
@@ -60,6 +60,32 @@ _SKIP_DIR_NAMES = {
60
60
  ".tox", "egg-info",
61
61
  }
62
62
 
63
+ # SecretShield's own meta-files, skipped by default during a directory
64
+ # scan -- not because of their extension (a targeted, narrow exclusion
65
+ # by exact name, not "skip all .toml/.json files"), but because their
66
+ # job is to *configure* detection, not to hold project source that
67
+ # might leak a secret. A custom rule's pattern necessarily resembles
68
+ # the very thing it's meant to catch (that's the whole point of it),
69
+ # so without this, .secretshield-rules.toml ends up reporting itself
70
+ # as a finding the moment a rule's pattern looks enough like a real
71
+ # secret -- which defeats the purpose of writing the rule in the first
72
+ # place. secretshield.toml and .secretshieldignore are excluded for
73
+ # the same reason (settings/ignore globs aren't project secrets
74
+ # either), and the baseline file is excluded as a defensive measure:
75
+ # its SHA-256 hex hashes sit safely below the *default* entropy
76
+ # threshold, but a project that lowers entropy_threshold via
77
+ # secretshield.toml could otherwise start flagging its own baseline.
78
+ # --no-ignore re-includes all of these, same as it does for
79
+ # _SKIP_DIR_NAMES; explicitly scanning one of them by path (e.g.
80
+ # `secretshield scan .secretshield-rules.toml`) is unaffected -- this
81
+ # only changes what a directory *walk* picks up by default.
82
+ _SKIP_FILE_NAMES = {
83
+ ".secretshield-rules.toml",
84
+ "secretshield.toml",
85
+ ".secretshieldignore",
86
+ ".secretshield-baseline.json",
87
+ }
88
+
63
89
  # Bytes read from the start of a file to decide whether it looks binary.
64
90
  _BINARY_SNIFF_SIZE = 1024
65
91
 
@@ -91,6 +117,10 @@ def _looks_binary(path: Path) -> bool:
91
117
  return False
92
118
 
93
119
 
120
+ def _is_secretshield_meta_file(name: str) -> bool:
121
+ return name in _SKIP_FILE_NAMES
122
+
123
+
94
124
  def _should_scan_file(
95
125
  path: Path,
96
126
  include_patterns: list[str],
@@ -140,6 +170,8 @@ def _iter_scan_files(
140
170
  continue
141
171
  if not no_ignore and any(part in _SKIP_DIR_NAMES for part in path.parts):
142
172
  continue
173
+ if not no_ignore and _is_secretshield_meta_file(path.name):
174
+ continue
143
175
  if not _should_scan_file(path, include_patterns, exclude_patterns):
144
176
  continue
145
177
  if _looks_binary(path):
@@ -168,6 +200,12 @@ _KIND_DISPLAY: dict[str, tuple[str, str]] = {
168
200
  }
169
201
 
170
202
 
203
+ def _plural(count: int, word: str) -> str:
204
+ """`f"{count} {word}"`, pluralized properly -- "1 rule" / "2 rules" --
205
+ instead of the awkward literal "rule(s)" regardless of count."""
206
+ return f"{count} {word}" if count == 1 else f"{count} {word}s"
207
+
208
+
171
209
  def _kind_display(kind: str) -> tuple[str, str]:
172
210
  return _KIND_DISPLAY.get(kind, (kind, "possible secret"))
173
211
 
@@ -372,8 +410,11 @@ def _iter_scan_sources(args: argparse.Namespace, target: Path, include_patterns:
372
410
  if not is_git_repo():
373
411
  print("secretshield: error: not inside a Git repository", file=sys.stderr)
374
412
  return
413
+ no_ignore = getattr(args, "no_ignore", False)
375
414
  for relpath in get_staged_files():
376
415
  path = Path(relpath)
416
+ if not no_ignore and _is_secretshield_meta_file(path.name):
417
+ continue
377
418
  if not _should_scan_file(path, include_patterns, exclude_patterns):
378
419
  continue
379
420
  content = get_staged_content(relpath)
@@ -389,8 +430,11 @@ def _iter_scan_sources(args: argparse.Namespace, target: Path, include_patterns:
389
430
  if not is_git_repo():
390
431
  print("secretshield: error: not inside a Git repository", file=sys.stderr)
391
432
  return
433
+ no_ignore = getattr(args, "no_ignore", False)
392
434
  for relpath in get_diff_files(diff_ref):
393
435
  path = Path(relpath)
436
+ if not no_ignore and _is_secretshield_meta_file(path.name):
437
+ continue
394
438
  if not _should_scan_file(path, include_patterns, exclude_patterns):
395
439
  continue
396
440
  if not path.exists() or _looks_binary(path):
@@ -437,8 +481,8 @@ def _run_scan(args: argparse.Namespace) -> int:
437
481
  custom_rules, custom_rule_errors = load_custom_rules(Path.cwd())
438
482
  if custom_rule_errors:
439
483
  print(
440
- f"secretshield: warning: {len(custom_rule_errors)} invalid custom "
441
- f"rule(s) in .secretshield-rules.toml -- run `secretshield rules "
484
+ f"secretshield: warning: {_plural(len(custom_rule_errors), 'invalid custom rule')} "
485
+ f"in .secretshield-rules.toml -- run `secretshield rules "
442
486
  f"check` for details.",
443
487
  file=sys.stderr,
444
488
  )
@@ -592,6 +636,8 @@ def _run_scan_staged(args: argparse.Namespace) -> int:
592
636
  findings: list[dict] = []
593
637
  for relpath in get_staged_files():
594
638
  path = Path(relpath)
639
+ if _is_secretshield_meta_file(path.name):
640
+ continue
595
641
  if not _should_scan_file(path, [], []):
596
642
  continue
597
643
  content = get_staged_content(relpath)
@@ -787,7 +833,7 @@ def _cmd_rules_check(args: argparse.Namespace) -> int:
787
833
  return 1
788
834
 
789
835
  print(f"{green(chr(0x2713), use_color)} {path.name}")
790
- print(f"{green(chr(0x2713), use_color)} {len(rules)} rule(s) loaded")
836
+ print(f"{green(chr(0x2713), use_color)} {_plural(len(rules), 'rule')} loaded")
791
837
  print(f"{green(chr(0x2713), use_color)} All patterns are valid")
792
838
  return 0
793
839
 
@@ -815,7 +861,7 @@ def _cmd_rules_list(args: argparse.Namespace) -> int:
815
861
  if errors:
816
862
  use_color = supports_color(sys.stdout)
817
863
  print(
818
- f"\n{red(chr(0x2717), use_color)} {len(errors)} rule(s) failed to load "
864
+ f"\n{red(chr(0x2717), use_color)} {_plural(len(errors), 'rule')} failed to load "
819
865
  f"-- run `secretshield rules check` for details."
820
866
  )
821
867
  return 0
@@ -877,6 +923,56 @@ def _cmd_rule_create(args: argparse.Namespace) -> int:
877
923
  return 0
878
924
 
879
925
 
926
+ def _cmd_rule_test(args: argparse.Namespace) -> int:
927
+ from .custom_rules import load_custom_rules, rules_file_path
928
+
929
+ try:
930
+ interactive = sys.stdin.isatty()
931
+ except Exception:
932
+ interactive = False
933
+
934
+ if not interactive:
935
+ print("secretshield rule test requires an interactive terminal.")
936
+ return 1
937
+
938
+ path = rules_file_path(Path.cwd())
939
+ if not path.exists():
940
+ print(f"No {path.name} found. Create one with `secretshield rule create`.")
941
+ return 0
942
+
943
+ rules, errors = load_custom_rules(Path.cwd())
944
+ if errors:
945
+ print(
946
+ f"secretshield: warning: {_plural(len(errors), 'invalid custom rule')} "
947
+ f"skipped -- run `secretshield rules check` for details.",
948
+ file=sys.stderr,
949
+ )
950
+ if not rules:
951
+ print("No valid custom rules to test.")
952
+ return 0
953
+
954
+ use_color = supports_color(sys.stdout)
955
+ print("SecretShield \u2014 Test custom rules\n")
956
+ print(f"Loaded {_plural(len(rules), 'rule')}: {', '.join(r.name for r in rules)}\n")
957
+
958
+ sample = input("Text to test: ")
959
+ if not sample:
960
+ print("No text entered.")
961
+ return 0
962
+
963
+ print()
964
+ for rule in rules:
965
+ m = rule.regex.search(sample)
966
+ if m:
967
+ span = m.end() - m.start()
968
+ print(f"{green(chr(0x2713), use_color)} Matched: {rule.name}")
969
+ print(f" at position {m.start()}-{m.end()} ({span} character(s), value masked)")
970
+ else:
971
+ print(f"{red(chr(0x2717), use_color)} No match: {rule.name}")
972
+
973
+ return 0
974
+
975
+
880
976
  # --- argparse wiring -----------------------------------------------------
881
977
 
882
978
 
@@ -1088,6 +1184,11 @@ def build_parser() -> argparse.ArgumentParser:
1088
1184
  "create", help="Interactively create a new rule in .secretshield-rules.toml."
1089
1185
  )
1090
1186
  rule_create_parser.set_defaults(func=_cmd_rule_create)
1187
+ rule_test_parser = rule_subparsers.add_parser(
1188
+ "test",
1189
+ help="Interactively test sample text against your custom rules without scanning any file.",
1190
+ )
1191
+ rule_test_parser.set_defaults(func=_cmd_rule_test)
1091
1192
 
1092
1193
  return parser
1093
1194
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secretshield
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Stop secrets before they leak -- runtime redaction, multi-language scanning, auto-fix, Git hooks, and CI, all in one CLI.
5
5
  Author: Samarth Chugh (Sam3360)
6
6
  License-Expression: MIT
@@ -32,10 +32,10 @@ Dynamic: license-file
32
32
 
33
33
  [![PyPI version](https://img.shields.io/pypi/v/secretshield?color=blue)](https://pypi.org/project/secretshield/)
34
34
  [![Python versions](https://img.shields.io/pypi/pyversions/secretshield)](https://pypi.org/project/secretshield/)
35
- [![PyPI downloads](https://img.shields.io/pypi/dm/secretshield)](https://pypi.org/project/secretshield/)
35
+ [![PyPI Downloads](https://api.pepy.tech/badge/secretshield/month)](https://pypi.org/project/secretshield/)
36
36
  [![License: MIT](https://img.shields.io/pypi/l/secretshield)](LICENSE)
37
37
 
38
- [Install](#install) · [Demo](#demo) · [Try it](#try-it) · [Full usage guide →](https://github.com/Sam3360/secretshield/blob/main/USAGE.md)
38
+ [Install](#install) · [Demo](#demo) · [Try it](#try-it) · [Homepage 🌐](https://therealsecretshield.freebuff.app/) · [Full usage guide →](https://github.com/Sam3360/secretshield/blob/main/USAGE.md)
39
39
 
40
40
  </div>
41
41
 
@@ -115,9 +115,12 @@ name = "My API Key"
115
115
  pattern = "MYAPP_[A-Za-z0-9]{32}"
116
116
  ```
117
117
 
118
- `secretshield scan .` picks it up automatically — no flag needed.
119
- Validate the file with `secretshield rules check`, or create a rule
120
- interactively with `secretshield rule create`.
118
+ `secretshield scan .` picks it up automatically — no flag needed. The
119
+ rules file itself is never treated as project content to scan, so a
120
+ pattern that resembles a real secret won't flag its own definition.
121
+ Validate rules with `secretshield rules check`, create one
122
+ interactively with `secretshield rule create`, or try a pattern against
123
+ sample text with `secretshield rule test` before committing to it.
121
124
 
122
125
  For the full command reference, configuration options, and exactly how
123
126
  Auto-Fix decides what's safe to rewrite, see the
@@ -145,3 +148,11 @@ If you find this project useful, consider [supporting its development through Gi
145
148
  ## License
146
149
 
147
150
  MIT — see [LICENSE](LICENSE).
151
+
152
+
153
+
154
+ ## Homepage
155
+
156
+ Visit the official SecretShield homepage for an overview of the project, features, releases, and more:
157
+
158
+ **🌐 [therealsecretshield.freebuff.app](https://therealsecretshield.freebuff.app/)**
@@ -49,4 +49,5 @@ tests/test_scan.py
49
49
  tests/test_ss_alias_and_creator.py
50
50
  tests/test_stdout.py
51
51
  tests/test_v030_cli_integration.py
52
- tests/test_v040_cli_integration.py
52
+ tests/test_v040_cli_integration.py
53
+ tests/test_v080_self_detection_fix.py
@@ -129,10 +129,63 @@ def test_rules_check_success(tmp_path, monkeypatch, capsys):
129
129
  exit_code = main(["rules", "check"])
130
130
  assert exit_code == 0
131
131
  out = capsys.readouterr().out
132
- assert "2 rule(s) loaded" in out
132
+ assert "2 rules loaded" in out
133
133
  assert "All patterns are valid" in out
134
134
 
135
135
 
136
+ # --- Regression tests: singular/plural grammar in rule-count output --------
137
+ # (0.7.1: these counts used to always print the literal "rule(s)"
138
+ # regardless of count, e.g. "1 rule(s) loaded" -- fixed to pluralize
139
+ # properly.)
140
+
141
+
142
+ def test_rules_check_singular_grammar(tmp_path, monkeypatch, capsys):
143
+ monkeypatch.chdir(tmp_path)
144
+ (tmp_path / ".secretshield-rules.toml").write_text(
145
+ '[[rules]]\nname = "Only One"\npattern = "ONE_[0-9]{4}"\n'
146
+ )
147
+ main(["rules", "check"])
148
+ out = capsys.readouterr().out
149
+ assert "1 rule loaded" in out
150
+ assert "rule(s)" not in out
151
+
152
+
153
+ def test_rules_check_plural_grammar(tmp_path, monkeypatch, capsys):
154
+ monkeypatch.chdir(tmp_path)
155
+ (tmp_path / ".secretshield-rules.toml").write_text(
156
+ '[[rules]]\nname = "A"\npattern = "AAA_[0-9]{4}"\n'
157
+ '[[rules]]\nname = "B"\npattern = "BBB_[0-9]{4}"\n'
158
+ '[[rules]]\nname = "C"\npattern = "CCC_[0-9]{4}"\n'
159
+ )
160
+ main(["rules", "check"])
161
+ out = capsys.readouterr().out
162
+ assert "3 rules loaded" in out
163
+ assert "rule(s)" not in out
164
+
165
+
166
+ def test_rules_list_singular_error_grammar(tmp_path, monkeypatch, capsys):
167
+ monkeypatch.chdir(tmp_path)
168
+ (tmp_path / ".secretshield-rules.toml").write_text(
169
+ '[[rules]]\nname = "Broken"\npattern = "["\n'
170
+ )
171
+ main(["rules"])
172
+ out = capsys.readouterr().out
173
+ assert "1 rule failed to load" in out
174
+ assert "rule(s)" not in out
175
+
176
+
177
+ def test_scan_warning_singular_grammar(tmp_path, monkeypatch, capsys):
178
+ monkeypatch.chdir(tmp_path)
179
+ (tmp_path / ".secretshield-rules.toml").write_text(
180
+ '[[rules]]\nname = "Broken"\npattern = "["\n'
181
+ )
182
+ (tmp_path / "clean.py").write_text("print('hello')\n")
183
+ main(["scan", "."])
184
+ err = capsys.readouterr().err
185
+ assert "1 invalid custom rule in" in err
186
+ assert "rule(s)" not in err
187
+
188
+
136
189
  def test_rules_check_reports_errors(tmp_path, monkeypatch, capsys):
137
190
  monkeypatch.chdir(tmp_path)
138
191
  (tmp_path / ".secretshield-rules.toml").write_text(
@@ -0,0 +1,341 @@
1
+ """
2
+ v0.8.0: regression tests for the custom-rule self-detection bug fix,
3
+ plus tests for the new `secretshield rule test` command.
4
+
5
+ Root cause (confirmed by direct reproduction before the fix): a custom
6
+ rule's `pattern` field, by design, resembles the very secret shape
7
+ it's meant to catch. `.secretshield-rules.toml` is an ordinary `.toml`
8
+ file, so a plain directory scan swept it in as regular project
9
+ content -- meaning a rule whose pattern looked enough like a real
10
+ secret (or whose example value happened to trip built-in entropy
11
+ detection) caused the rules file to report itself as a finding.
12
+
13
+ The fix: SecretShield's own meta-files (.secretshield-rules.toml,
14
+ secretshield.toml, .secretshieldignore, .secretshield-baseline.json)
15
+ are now skipped by name during a default directory scan, the same way
16
+ _SKIP_DIR_NAMES already skips well-known directories -- not a broad
17
+ "skip all .toml files" exclusion. --no-ignore re-includes them;
18
+ explicitly scanning one by path is unaffected.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import subprocess
24
+
25
+ from secretshield.cli import main
26
+
27
+ SELF_RESEMBLING_PATTERN = "MYAPP_abc123def456ghi789jkl012mno345"
28
+
29
+
30
+ def _init_repo(path):
31
+ subprocess.run(["git", "init", "-q"], cwd=path, check=True)
32
+ subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=path, check=True)
33
+ subprocess.run(["git", "config", "user.name", "T"], cwd=path, check=True)
34
+
35
+
36
+ # --- 1 & 2: reproduce, then confirm fixed -----------------------------------
37
+
38
+
39
+ def test_custom_rule_pattern_in_source_file_is_still_detected(tmp_path, monkeypatch):
40
+ """A custom rule must still catch its intended pattern in real
41
+ project files -- the fix must not weaken detection."""
42
+ monkeypatch.chdir(tmp_path)
43
+ (tmp_path / ".secretshield-rules.toml").write_text(
44
+ f'[[rules]]\nname = "My Test Rule"\npattern = "{SELF_RESEMBLING_PATTERN}"\n'
45
+ )
46
+ (tmp_path / "app.py").write_text(f'API_KEY = "{SELF_RESEMBLING_PATTERN}"\n')
47
+
48
+ exit_code = main(["scan", "."])
49
+ assert exit_code == 1
50
+
51
+
52
+ def test_rules_file_does_not_self_detect(tmp_path, monkeypatch, capsys):
53
+ """The core bug: a rule whose pattern resembles a real secret must
54
+ not cause .secretshield-rules.toml to report itself."""
55
+ monkeypatch.chdir(tmp_path)
56
+ (tmp_path / ".secretshield-rules.toml").write_text(
57
+ f'[[rules]]\nname = "My Test Rule"\npattern = "{SELF_RESEMBLING_PATTERN}"\n'
58
+ )
59
+ # No other files -- if anything is reported, it can only be the
60
+ # rules file self-matching.
61
+ exit_code = main(["scan", ".", "--json"])
62
+ assert exit_code == 0
63
+
64
+ import json
65
+
66
+ out = capsys.readouterr().out
67
+ payload = json.loads(out)
68
+ assert payload["secrets_found"] == 0
69
+ assert payload["files_scanned"] == 0
70
+
71
+
72
+ def test_rules_file_excluded_even_from_files_scanned_count(tmp_path, monkeypatch, capsys):
73
+ monkeypatch.chdir(tmp_path)
74
+ (tmp_path / ".secretshield-rules.toml").write_text(
75
+ '[[rules]]\nname = "X"\npattern = "AAA_[0-9]{6}"\n'
76
+ )
77
+ (tmp_path / "clean.py").write_text("print('hello')\n")
78
+ main(["scan", ".", "--json"])
79
+
80
+ import json
81
+
82
+ out = capsys.readouterr().out
83
+ payload = json.loads(out)
84
+ assert payload["files_scanned"] == 1 # only clean.py, not the rules file
85
+
86
+
87
+ # --- 4 & 5: multiple custom rules + real detection ---------------------------
88
+
89
+
90
+ def test_multiple_custom_rules_no_self_detection_any_of_them(tmp_path, monkeypatch, capsys):
91
+ monkeypatch.chdir(tmp_path)
92
+ (tmp_path / ".secretshield-rules.toml").write_text(
93
+ '[[rules]]\nname = "Rule A"\npattern = "AAA_bbbbbbbbbbbbbbbbbbbbbbbb"\n'
94
+ '[[rules]]\nname = "Rule B"\npattern = "BBB_cccccccccccccccccccccc"\n'
95
+ )
96
+ main(["scan", ".", "--json"])
97
+
98
+ import json
99
+
100
+ out = capsys.readouterr().out
101
+ payload = json.loads(out)
102
+ assert payload["secrets_found"] == 0
103
+
104
+
105
+ def test_multiple_custom_rules_still_detect_real_matches(tmp_path, monkeypatch, capsys):
106
+ monkeypatch.chdir(tmp_path)
107
+ (tmp_path / ".secretshield-rules.toml").write_text(
108
+ '[[rules]]\nname = "Rule A"\npattern = "AAA_[0-9]{6}"\n'
109
+ '[[rules]]\nname = "Rule B"\npattern = "BBB_[0-9]{6}"\n'
110
+ )
111
+ (tmp_path / "app.py").write_text('a = "AAA_123456"\nb = "BBB_654321"\n')
112
+ main(["scan", ".", "--json"])
113
+
114
+ import json
115
+
116
+ out = capsys.readouterr().out
117
+ payload = json.loads(out)
118
+ kinds = {m["kind"] for m in payload["matches"]}
119
+ assert {"Rule A", "Rule B"}.issubset(kinds)
120
+
121
+
122
+ # --- 6: built-in rules unaffected --------------------------------------------
123
+
124
+
125
+ def test_built_in_detection_still_works(tmp_path, monkeypatch, capsys):
126
+ monkeypatch.chdir(tmp_path)
127
+ aws_key = "AKIAABCDEFGHIJKLMNOP"
128
+ (tmp_path / "app.py").write_text(f'x = "{aws_key}"\n')
129
+ main(["scan", ".", "--json"])
130
+
131
+ import json
132
+
133
+ out = capsys.readouterr().out
134
+ payload = json.loads(out)
135
+ assert payload["matches"][0]["kind"] == "aws_access_key_id"
136
+
137
+
138
+ # --- 7: broken custom rules behave as before ---------------------------------
139
+
140
+
141
+ def test_broken_custom_rule_still_reported_via_rules_check(tmp_path, monkeypatch, capsys):
142
+ monkeypatch.chdir(tmp_path)
143
+ (tmp_path / ".secretshield-rules.toml").write_text('[[rules]]\nname = "Bad"\npattern = "["\n')
144
+ exit_code = main(["rules", "check"])
145
+ assert exit_code == 1
146
+
147
+
148
+ def test_broken_custom_rule_does_not_block_scan(tmp_path, monkeypatch):
149
+ monkeypatch.chdir(tmp_path)
150
+ (tmp_path / ".secretshield-rules.toml").write_text('[[rules]]\nname = "Bad"\npattern = "["\n')
151
+ (tmp_path / "clean.py").write_text("print('hello')\n")
152
+ assert main(["scan", "."]) == 0
153
+
154
+
155
+ # --- --no-ignore re-includes meta-files --------------------------------------
156
+
157
+
158
+ def test_no_ignore_flag_allows_scanning_meta_files_again(tmp_path, monkeypatch, capsys):
159
+ monkeypatch.chdir(tmp_path)
160
+ (tmp_path / ".secretshield-rules.toml").write_text(
161
+ f'[[rules]]\nname = "X"\npattern = "{SELF_RESEMBLING_PATTERN}"\n'
162
+ )
163
+ main(["scan", ".", "--no-ignore", "--json"])
164
+
165
+ import json
166
+
167
+ out = capsys.readouterr().out
168
+ payload = json.loads(out)
169
+ # With --no-ignore, the rules file IS scanned again, and since its
170
+ # own pattern text resembles a real secret it's expected to
171
+ # self-match -- this proves --no-ignore genuinely re-includes it,
172
+ # rather than the exclusion being silently unconditional.
173
+ assert payload["files_scanned"] == 1
174
+
175
+
176
+ def test_explicit_direct_scan_of_rules_file_still_works(tmp_path, monkeypatch):
177
+ monkeypatch.chdir(tmp_path)
178
+ rules_file = tmp_path / ".secretshield-rules.toml"
179
+ rules_file.write_text(f'[[rules]]\nname = "X"\npattern = "{SELF_RESEMBLING_PATTERN}"\n')
180
+ # Explicitly naming the file as the scan target still works --
181
+ # only the default directory *walk* excludes it.
182
+ exit_code = main(["scan", str(rules_file)])
183
+ assert exit_code == 1
184
+
185
+
186
+ # --- secretshield.toml / .secretshieldignore / baseline also excluded -------
187
+
188
+
189
+ def test_secretshield_toml_not_scanned_by_default(tmp_path, monkeypatch, capsys):
190
+ monkeypatch.chdir(tmp_path)
191
+ (tmp_path / "secretshield.toml").write_text("[scan]\nentropy_threshold = 4.2\n")
192
+ main(["scan", ".", "--json"])
193
+
194
+ import json
195
+
196
+ out = capsys.readouterr().out
197
+ payload = json.loads(out)
198
+ assert payload["files_scanned"] == 0
199
+
200
+
201
+ def test_secretshieldignore_not_scanned_by_default(tmp_path, monkeypatch, capsys):
202
+ monkeypatch.chdir(tmp_path)
203
+ (tmp_path / ".secretshieldignore").write_text("vendor/\n")
204
+ main(["scan", ".", "--json"])
205
+
206
+ import json
207
+
208
+ out = capsys.readouterr().out
209
+ payload = json.loads(out)
210
+ assert payload["files_scanned"] == 0
211
+
212
+
213
+ def test_baseline_file_not_scanned_by_default(tmp_path, monkeypatch, capsys):
214
+ monkeypatch.chdir(tmp_path)
215
+ from secretshield.baseline import write_baseline
216
+
217
+ write_baseline(tmp_path, [{"id": "a" * 64, "file": "app.py", "kind": "x", "line": 1}])
218
+ main(["scan", ".", "--json"])
219
+
220
+ import json
221
+
222
+ out = capsys.readouterr().out
223
+ payload = json.loads(out)
224
+ assert payload["files_scanned"] == 0
225
+
226
+
227
+ # --- --staged / scan-staged (the pre-commit hook path) -----------------------
228
+
229
+
230
+ def test_staged_rules_file_does_not_self_block(tmp_path, monkeypatch):
231
+ monkeypatch.chdir(tmp_path)
232
+ _init_repo(tmp_path)
233
+ (tmp_path / ".secretshield-rules.toml").write_text(
234
+ f'[[rules]]\nname = "X"\npattern = "{SELF_RESEMBLING_PATTERN}"\n'
235
+ )
236
+ subprocess.run(["git", "add", ".secretshield-rules.toml"], cwd=tmp_path, check=True)
237
+
238
+ exit_code = main(["scan", "--staged"])
239
+ assert exit_code == 0
240
+
241
+
242
+ def test_scan_staged_command_does_not_self_block(tmp_path, monkeypatch):
243
+ # This is the exact path the installed pre-commit hook invokes.
244
+ monkeypatch.chdir(tmp_path)
245
+ _init_repo(tmp_path)
246
+ (tmp_path / ".secretshield-rules.toml").write_text(
247
+ f'[[rules]]\nname = "X"\npattern = "{SELF_RESEMBLING_PATTERN}"\n'
248
+ )
249
+ subprocess.run(["git", "add", ".secretshield-rules.toml"], cwd=tmp_path, check=True)
250
+
251
+ exit_code = main(["scan-staged"])
252
+ assert exit_code == 0
253
+
254
+
255
+ def test_real_commit_of_rules_file_succeeds(tmp_path, monkeypatch):
256
+ """The exact real-world scenario reported: committing an update to
257
+ your own custom-rules file must not get blocked by itself."""
258
+ monkeypatch.chdir(tmp_path)
259
+ _init_repo(tmp_path)
260
+ main(["install-hook"])
261
+
262
+ (tmp_path / ".secretshield-rules.toml").write_text(
263
+ f'[[rules]]\nname = "X"\npattern = "{SELF_RESEMBLING_PATTERN}"\n'
264
+ )
265
+ subprocess.run(["git", "add", ".secretshield-rules.toml"], cwd=tmp_path, check=True)
266
+
267
+ result = subprocess.run(
268
+ ["git", "commit", "-m", "add custom rule"],
269
+ cwd=tmp_path,
270
+ capture_output=True,
271
+ text=True,
272
+ )
273
+ assert result.returncode == 0
274
+
275
+
276
+ # --- `secretshield rule test` ------------------------------------------------
277
+
278
+
279
+ def test_rule_test_non_interactive_refuses(tmp_path, monkeypatch):
280
+ monkeypatch.chdir(tmp_path)
281
+ exit_code = main(["rule", "test"])
282
+ assert exit_code == 1
283
+
284
+
285
+ def test_rule_test_no_rules_file(tmp_path, monkeypatch, capsys):
286
+ monkeypatch.chdir(tmp_path)
287
+ monkeypatch.setattr("sys.stdin.isatty", lambda: True)
288
+ exit_code = main(["rule", "test"])
289
+ assert exit_code == 0
290
+ out = capsys.readouterr().out
291
+ assert "No .secretshield-rules.toml found" in out
292
+
293
+
294
+ def test_rule_test_reports_match(tmp_path, monkeypatch, capsys):
295
+ monkeypatch.chdir(tmp_path)
296
+ (tmp_path / ".secretshield-rules.toml").write_text(
297
+ f'[[rules]]\nname = "My Rule"\npattern = "{SELF_RESEMBLING_PATTERN}"\n'
298
+ )
299
+ monkeypatch.setattr("sys.stdin.isatty", lambda: True)
300
+ monkeypatch.setattr("builtins.input", lambda prompt="": SELF_RESEMBLING_PATTERN)
301
+
302
+ exit_code = main(["rule", "test"])
303
+ assert exit_code == 0
304
+ out = capsys.readouterr().out
305
+ assert "Matched: My Rule" in out
306
+
307
+
308
+ def test_rule_test_reports_no_match(tmp_path, monkeypatch, capsys):
309
+ monkeypatch.chdir(tmp_path)
310
+ (tmp_path / ".secretshield-rules.toml").write_text(
311
+ '[[rules]]\nname = "My Rule"\npattern = "AAA_[0-9]{6}"\n'
312
+ )
313
+ monkeypatch.setattr("sys.stdin.isatty", lambda: True)
314
+ monkeypatch.setattr("builtins.input", lambda prompt="": "totally unrelated text")
315
+
316
+ exit_code = main(["rule", "test"])
317
+ assert exit_code == 0
318
+ out = capsys.readouterr().out
319
+ assert "No match: My Rule" in out
320
+
321
+
322
+ def test_rule_test_never_prints_raw_matched_value(tmp_path, monkeypatch, capsys):
323
+ monkeypatch.chdir(tmp_path)
324
+ (tmp_path / ".secretshield-rules.toml").write_text(
325
+ f'[[rules]]\nname = "My Rule"\npattern = "{SELF_RESEMBLING_PATTERN}"\n'
326
+ )
327
+ monkeypatch.setattr("sys.stdin.isatty", lambda: True)
328
+ monkeypatch.setattr("builtins.input", lambda prompt="": SELF_RESEMBLING_PATTERN)
329
+
330
+ main(["rule", "test"])
331
+ out = capsys.readouterr().out
332
+ assert "value masked" in out
333
+
334
+
335
+ # --- 8: existing suite remains green (spot-checked here too) ---------------
336
+
337
+
338
+ def test_scan_with_no_config_files_at_all_still_works(tmp_path, monkeypatch):
339
+ monkeypatch.chdir(tmp_path)
340
+ (tmp_path / "clean.py").write_text("print('nothing here')\n")
341
+ assert main(["scan", "."]) == 0
File without changes
File without changes