secretshield 0.7.1__tar.gz → 0.9.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 (56) hide show
  1. {secretshield-0.7.1/secretshield.egg-info → secretshield-0.9.0}/PKG-INFO +16 -5
  2. {secretshield-0.7.1 → secretshield-0.9.0}/README.md +15 -4
  3. {secretshield-0.7.1 → secretshield-0.9.0}/pyproject.toml +1 -1
  4. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/__init__.py +1 -1
  5. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/cli.py +176 -3
  6. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/color.py +13 -0
  7. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/ignorefile.py +26 -0
  8. {secretshield-0.7.1 → secretshield-0.9.0/secretshield.egg-info}/PKG-INFO +16 -5
  9. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield.egg-info/SOURCES.txt +3 -1
  10. secretshield-0.9.0/tests/test_v080_self_detection_fix.py +341 -0
  11. secretshield-0.9.0/tests/test_v090_cli_additions.py +275 -0
  12. {secretshield-0.7.1 → secretshield-0.9.0}/LICENSE +0 -0
  13. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/autofix/__init__.py +0 -0
  14. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/autofix/env.py +0 -0
  15. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/autofix/fixer.py +0 -0
  16. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/autofix/gitignore.py +0 -0
  17. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/autofix/javascript.py +0 -0
  18. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/autofix/python.py +0 -0
  19. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/baseline.py +0 -0
  20. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/config.py +0 -0
  21. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/custom_rules.py +0 -0
  22. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/detector.py +0 -0
  23. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/git/__init__.py +0 -0
  24. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/git/hooks.py +0 -0
  25. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/github/__init__.py +0 -0
  26. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/github/actions.py +0 -0
  27. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/guardian.py +0 -0
  28. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/notifications.py +0 -0
  29. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/patterns.py +0 -0
  30. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/project_config.py +0 -0
  31. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield/redactor.py +0 -0
  32. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield.egg-info/dependency_links.txt +0 -0
  33. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield.egg-info/entry_points.txt +0 -0
  34. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield.egg-info/requires.txt +0 -0
  35. {secretshield-0.7.1 → secretshield-0.9.0}/secretshield.egg-info/top_level.txt +0 -0
  36. {secretshield-0.7.1 → secretshield-0.9.0}/setup.cfg +0 -0
  37. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_autofix.py +0 -0
  38. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_baseline.py +0 -0
  39. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_color.py +0 -0
  40. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_color_cli_integration.py +0 -0
  41. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_custom_rules.py +0 -0
  42. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_custom_rules_cli_integration.py +0 -0
  43. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_detector.py +0 -0
  44. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_git_hooks.py +0 -0
  45. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_github_action.py +0 -0
  46. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_ignorefile.py +0 -0
  47. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_ignorefile_cli_integration.py +0 -0
  48. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_javascript_autofix.py +0 -0
  49. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_logging.py +0 -0
  50. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_project_config.py +0 -0
  51. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_redactor.py +0 -0
  52. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_scan.py +0 -0
  53. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_ss_alias_and_creator.py +0 -0
  54. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_stdout.py +0 -0
  55. {secretshield-0.7.1 → secretshield-0.9.0}/tests/test_v030_cli_integration.py +0 -0
  56. {secretshield-0.7.1 → secretshield-0.9.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.1
3
+ Version: 0.9.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
@@ -35,7 +35,7 @@ Dynamic: license-file
35
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/)**
@@ -9,7 +9,7 @@
9
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.1"
7
+ version = "0.9.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.1"
26
+ __version__ = "0.9.0"
27
27
 
28
28
  __all__ = [
29
29
  "__version__",
@@ -27,7 +27,7 @@ import sys
27
27
  from pathlib import Path
28
28
 
29
29
  from . import __version__, disable, enable, is_enabled
30
- from .color import green, red, supports_color
30
+ from .color import green, hyperlink, red, supports_color
31
31
  from .detector import detect
32
32
 
33
33
  # --- File type support -----------------------------------------------------
@@ -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):
@@ -378,8 +410,11 @@ def _iter_scan_sources(args: argparse.Namespace, target: Path, include_patterns:
378
410
  if not is_git_repo():
379
411
  print("secretshield: error: not inside a Git repository", file=sys.stderr)
380
412
  return
413
+ no_ignore = getattr(args, "no_ignore", False)
381
414
  for relpath in get_staged_files():
382
415
  path = Path(relpath)
416
+ if not no_ignore and _is_secretshield_meta_file(path.name):
417
+ continue
383
418
  if not _should_scan_file(path, include_patterns, exclude_patterns):
384
419
  continue
385
420
  content = get_staged_content(relpath)
@@ -395,8 +430,11 @@ def _iter_scan_sources(args: argparse.Namespace, target: Path, include_patterns:
395
430
  if not is_git_repo():
396
431
  print("secretshield: error: not inside a Git repository", file=sys.stderr)
397
432
  return
433
+ no_ignore = getattr(args, "no_ignore", False)
398
434
  for relpath in get_diff_files(diff_ref):
399
435
  path = Path(relpath)
436
+ if not no_ignore and _is_secretshield_meta_file(path.name):
437
+ continue
400
438
  if not _should_scan_file(path, include_patterns, exclude_patterns):
401
439
  continue
402
440
  if not path.exists() or _looks_binary(path):
@@ -598,6 +636,8 @@ def _run_scan_staged(args: argparse.Namespace) -> int:
598
636
  findings: list[dict] = []
599
637
  for relpath in get_staged_files():
600
638
  path = Path(relpath)
639
+ if _is_secretshield_meta_file(path.name):
640
+ continue
601
641
  if not _should_scan_file(path, [], []):
602
642
  continue
603
643
  content = get_staged_content(relpath)
@@ -761,6 +801,18 @@ def _cmd_init(args: argparse.Namespace) -> int:
761
801
  else:
762
802
  print(f"{green(chr(0x2713), use_color)} {path.relative_to(project_root)} already exists, leaving it unchanged")
763
803
 
804
+ # .secretshieldignore is created unconditionally (no extra prompt --
805
+ # it's a tiny, harmless starter file), the same helper --ignore-file
806
+ # uses, and it's always safe: an existing file is left untouched.
807
+ from .ignorefile import create_default_ignore_file
808
+
809
+ ignore_created, ignore_path = create_default_ignore_file(project_root)
810
+ if ignore_created:
811
+ print(f"{green(chr(0x2713), use_color)} Created {ignore_path.name}")
812
+ created.append(ignore_path.name)
813
+ else:
814
+ print(f"{green(chr(0x2713), use_color)} {ignore_path.name} already exists, leaving it unchanged")
815
+
764
816
  print("\nSecretShield is ready.")
765
817
  return 0
766
818
 
@@ -883,22 +935,128 @@ def _cmd_rule_create(args: argparse.Namespace) -> int:
883
935
  return 0
884
936
 
885
937
 
938
+ def _cmd_rule_test(args: argparse.Namespace) -> int:
939
+ from .custom_rules import load_custom_rules, rules_file_path
940
+
941
+ try:
942
+ interactive = sys.stdin.isatty()
943
+ except Exception:
944
+ interactive = False
945
+
946
+ if not interactive:
947
+ print("secretshield rule test requires an interactive terminal.")
948
+ return 1
949
+
950
+ path = rules_file_path(Path.cwd())
951
+ if not path.exists():
952
+ print(f"No {path.name} found. Create one with `secretshield rule create`.")
953
+ return 0
954
+
955
+ rules, errors = load_custom_rules(Path.cwd())
956
+ if errors:
957
+ print(
958
+ f"secretshield: warning: {_plural(len(errors), 'invalid custom rule')} "
959
+ f"skipped -- run `secretshield rules check` for details.",
960
+ file=sys.stderr,
961
+ )
962
+ if not rules:
963
+ print("No valid custom rules to test.")
964
+ return 0
965
+
966
+ use_color = supports_color(sys.stdout)
967
+ print("SecretShield \u2014 Test custom rules\n")
968
+ print(f"Loaded {_plural(len(rules), 'rule')}: {', '.join(r.name for r in rules)}\n")
969
+
970
+ sample = input("Text to test: ")
971
+ if not sample:
972
+ print("No text entered.")
973
+ return 0
974
+
975
+ print()
976
+ for rule in rules:
977
+ m = rule.regex.search(sample)
978
+ if m:
979
+ span = m.end() - m.start()
980
+ print(f"{green(chr(0x2713), use_color)} Matched: {rule.name}")
981
+ print(f" at position {m.start()}-{m.end()} ({span} character(s), value masked)")
982
+ else:
983
+ print(f"{red(chr(0x2717), use_color)} No match: {rule.name}")
984
+
985
+ return 0
986
+
987
+
886
988
  # --- argparse wiring -----------------------------------------------------
887
989
 
888
990
 
889
991
  CREATOR_MESSAGE = "SecretShield\nCreated by Samarth Chugh (Sam3360)\ngithub.com/Sam3360"
992
+ CREATOR_GITHUB_URL = "https://github.com/Sam3360"
993
+
994
+ USAGE_URL = "https://github.com/Sam3360/secretshield/blob/main/USAGE.md"
890
995
 
891
996
 
892
997
  class _CreatorAction(argparse.Action):
893
998
  """Prints creator info and exits -- mirrors argparse's built-in
894
999
  `action="version"` behavior so `--creator` works the same way
895
- `--version` does, with no subcommand required."""
1000
+ `--version` does, with no subcommand required.
1001
+
1002
+ The GitHub line is wrapped in a standard OSC 8 terminal hyperlink
1003
+ (so supported terminals can Ctrl+Click it) only when connected to a
1004
+ real terminal; piped/non-interactive output gets the same plain
1005
+ text CREATOR_MESSAGE always had, with no escape codes.
1006
+ """
1007
+
1008
+ def __init__(self, option_strings, dest, nargs=0, **kwargs):
1009
+ super().__init__(option_strings, dest, nargs=nargs, **kwargs)
1010
+
1011
+ def __call__(self, parser, namespace, values, option_string=None):
1012
+ link_text = "github.com/Sam3360"
1013
+ link = hyperlink(link_text, CREATOR_GITHUB_URL, supports_color(sys.stdout))
1014
+ print(f"SecretShield\nCreated by Samarth Chugh (Sam3360)\n{link}")
1015
+ parser.exit()
1016
+
1017
+
1018
+ class _UsageAction(argparse.Action):
1019
+ """Prompts, then opens the USAGE.md documentation in the system's
1020
+ default browser on Enter -- never before the user confirms. Ctrl+C
1021
+ and EOF are handled cleanly, with no traceback and no browser
1022
+ launch."""
1023
+
1024
+ def __init__(self, option_strings, dest, nargs=0, **kwargs):
1025
+ super().__init__(option_strings, dest, nargs=nargs, **kwargs)
1026
+
1027
+ def __call__(self, parser, namespace, values, option_string=None):
1028
+ print("SecretShield\n")
1029
+ print("Press Enter to open the USAGE documentation.")
1030
+ print("Ctrl+C to cancel.")
1031
+ try:
1032
+ input()
1033
+ except (KeyboardInterrupt, EOFError):
1034
+ print()
1035
+ parser.exit()
1036
+ return
1037
+ import webbrowser
1038
+
1039
+ webbrowser.open(USAGE_URL)
1040
+ parser.exit()
1041
+
1042
+
1043
+ class _IgnoreFileAction(argparse.Action):
1044
+ """Creates a starter .secretshieldignore in the current directory,
1045
+ reusing the same helper `secretshield init` uses -- never
1046
+ overwrites an existing file."""
896
1047
 
897
1048
  def __init__(self, option_strings, dest, nargs=0, **kwargs):
898
1049
  super().__init__(option_strings, dest, nargs=nargs, **kwargs)
899
1050
 
900
1051
  def __call__(self, parser, namespace, values, option_string=None):
901
- print(CREATOR_MESSAGE)
1052
+ from .ignorefile import create_default_ignore_file
1053
+
1054
+ created, path = create_default_ignore_file(Path.cwd())
1055
+ use_color = supports_color(sys.stdout)
1056
+ if created:
1057
+ print(f"{green(chr(0x2713), use_color)} Created {path.name}")
1058
+ else:
1059
+ print(f"{path.name} already exists.")
902
1060
  parser.exit()
903
1061
 
904
1062
 
@@ -916,6 +1074,16 @@ def build_parser() -> argparse.ArgumentParser:
916
1074
  parser.add_argument(
917
1075
  "--creator", action=_CreatorAction, help="Show who made this and exit."
918
1076
  )
1077
+ parser.add_argument(
1078
+ "--usage",
1079
+ action=_UsageAction,
1080
+ help="Open the USAGE.md documentation in your browser (asks for confirmation first).",
1081
+ )
1082
+ parser.add_argument(
1083
+ "--ignore-file",
1084
+ action=_IgnoreFileAction,
1085
+ help="Create a starter .secretshieldignore in the current directory.",
1086
+ )
919
1087
 
920
1088
  subparsers = parser.add_subparsers(dest="command")
921
1089
 
@@ -1094,6 +1262,11 @@ def build_parser() -> argparse.ArgumentParser:
1094
1262
  "create", help="Interactively create a new rule in .secretshield-rules.toml."
1095
1263
  )
1096
1264
  rule_create_parser.set_defaults(func=_cmd_rule_create)
1265
+ rule_test_parser = rule_subparsers.add_parser(
1266
+ "test",
1267
+ help="Interactively test sample text against your custom rules without scanning any file.",
1268
+ )
1269
+ rule_test_parser.set_defaults(func=_cmd_rule_test)
1097
1270
 
1098
1271
  return parser
1099
1272
 
@@ -40,3 +40,16 @@ def red(text: str, enabled: bool) -> str:
40
40
 
41
41
  def green(text: str, enabled: bool) -> str:
42
42
  return f"{_GREEN}{text}{_RESET}" if enabled else text
43
+
44
+
45
+ def hyperlink(text: str, url: str, enabled: bool) -> str:
46
+ """
47
+ Wrap `text` in a standard OSC 8 terminal hyperlink escape sequence
48
+ pointing at `url`, so supported terminals can Ctrl+Click it --
49
+ while the *visible* text stays exactly `text`. Falls back to plain
50
+ `text` (no escape codes at all) when `enabled` is False, so
51
+ terminals that don't understand OSC 8 never see raw escape junk.
52
+ """
53
+ if not enabled:
54
+ return text
55
+ return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
@@ -10,6 +10,32 @@ from pathlib import Path
10
10
 
11
11
  IGNORE_FILENAME = ".secretshieldignore"
12
12
 
13
+ DEFAULT_IGNORE_CONTENT = (
14
+ "# Add files, directories, or patterns here that SecretShield should ignore.\n"
15
+ )
16
+
17
+
18
+ def ignore_file_path(root: Path) -> Path:
19
+ return root / IGNORE_FILENAME
20
+
21
+
22
+ def create_default_ignore_file(root: Path | None = None) -> tuple[bool, Path]:
23
+ """
24
+ Create a starter `.secretshieldignore` in `root` (default: current
25
+ directory) with a short explanatory comment, if one doesn't already
26
+ exist. Never overwrites an existing file -- shared by both the
27
+ `--ignore-file` CLI option and `secretshield init`, so there's only
28
+ one place that generates this starter content.
29
+
30
+ Returns `(created, path)`: `created` is False if the file was
31
+ already there (nothing was touched).
32
+ """
33
+ path = ignore_file_path(root or Path.cwd())
34
+ if path.exists():
35
+ return False, path
36
+ path.write_text(DEFAULT_IGNORE_CONTENT, encoding="utf-8")
37
+ return True, path
38
+
13
39
 
14
40
  def ignore_file_path(root: Path) -> Path:
15
41
  return root / IGNORE_FILENAME
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secretshield
3
- Version: 0.7.1
3
+ Version: 0.9.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
@@ -35,7 +35,7 @@ Dynamic: license-file
35
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,6 @@ 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
54
+ tests/test_v090_cli_additions.py
@@ -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
@@ -0,0 +1,275 @@
1
+ """
2
+ Tests for v0.9.0: `--usage`, the OSC 8 hyperlink in `--creator`,
3
+ `--ignore-file`, and `init` now creating `.secretshieldignore`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+
10
+ import pytest
11
+
12
+ from secretshield.cli import CREATOR_GITHUB_URL, USAGE_URL, build_parser, main
13
+ from secretshield.color import hyperlink
14
+ from secretshield.ignorefile import DEFAULT_IGNORE_CONTENT, create_default_ignore_file
15
+
16
+
17
+ def _init_repo(path):
18
+ subprocess.run(["git", "init", "-q"], cwd=path, check=True)
19
+ subprocess.run(["git", "config", "user.email", "t@t.com"], cwd=path, check=True)
20
+ subprocess.run(["git", "config", "user.name", "T"], cwd=path, check=True)
21
+
22
+
23
+ # --- --usage ------------------------------------------------------------
24
+
25
+
26
+ def test_usage_option_exists_in_parser():
27
+ parser = build_parser()
28
+ # Should not raise / should be recognized (will exit via the action
29
+ # once input() is reached -- exercised properly in the tests below).
30
+ assert any("--usage" in opt.option_strings for opt in parser._actions)
31
+
32
+
33
+ def test_usage_enter_opens_browser_with_correct_url(monkeypatch, capsys):
34
+ monkeypatch.setattr("builtins.input", lambda: "")
35
+ opened = {}
36
+
37
+ def fake_open(url):
38
+ opened["url"] = url
39
+
40
+ monkeypatch.setattr("webbrowser.open", fake_open)
41
+
42
+ with pytest.raises(SystemExit):
43
+ main(["--usage"])
44
+
45
+ assert opened.get("url") == USAGE_URL
46
+ assert USAGE_URL == "https://github.com/Sam3360/secretshield/blob/main/USAGE.md"
47
+
48
+
49
+ def test_usage_ctrl_c_does_not_open_browser(monkeypatch, capsys):
50
+ def raise_interrupt():
51
+ raise KeyboardInterrupt
52
+
53
+ monkeypatch.setattr("builtins.input", lambda: raise_interrupt())
54
+ opened = {"called": False}
55
+ monkeypatch.setattr("webbrowser.open", lambda url: opened.update(called=True))
56
+
57
+ with pytest.raises(SystemExit) as exc_info:
58
+ main(["--usage"])
59
+
60
+ assert opened["called"] is False
61
+ assert exc_info.value.code in (0, None)
62
+
63
+
64
+ def test_usage_eof_does_not_open_browser(monkeypatch, capsys):
65
+ def raise_eof():
66
+ raise EOFError
67
+
68
+ monkeypatch.setattr("builtins.input", lambda: raise_eof())
69
+ opened = {"called": False}
70
+ monkeypatch.setattr("webbrowser.open", lambda url: opened.update(called=True))
71
+
72
+ with pytest.raises(SystemExit) as exc_info:
73
+ main(["--usage"])
74
+
75
+ assert opened["called"] is False
76
+ assert exc_info.value.code in (0, None)
77
+
78
+
79
+ def test_usage_prompt_text_shown(monkeypatch, capsys):
80
+ monkeypatch.setattr("builtins.input", lambda: "")
81
+ monkeypatch.setattr("webbrowser.open", lambda url: None)
82
+
83
+ with pytest.raises(SystemExit):
84
+ main(["--usage"])
85
+
86
+ out = capsys.readouterr().out
87
+ assert "Press Enter to open the USAGE documentation." in out
88
+ assert "Ctrl+C to cancel." in out
89
+
90
+
91
+ # --- --creator hyperlink -----------------------------------------------
92
+
93
+
94
+ def test_creator_visible_text_still_correct(monkeypatch, capsys):
95
+ monkeypatch.setattr("sys.stdout.isatty", lambda: False) # plain output
96
+ with pytest.raises(SystemExit):
97
+ main(["--creator"])
98
+ out = capsys.readouterr().out
99
+ assert "Samarth Chugh (Sam3360)" in out
100
+ assert "github.com/Sam3360" in out
101
+
102
+
103
+ def test_creator_github_url_constant_correct():
104
+ assert CREATOR_GITHUB_URL == "https://github.com/Sam3360"
105
+
106
+
107
+ def test_hyperlink_osc8_format_correct():
108
+ link = hyperlink("github.com/Sam3360", CREATOR_GITHUB_URL, True)
109
+ assert link == "\033]8;;https://github.com/Sam3360\033\\github.com/Sam3360\033]8;;\033\\"
110
+
111
+
112
+ def test_hyperlink_visible_text_unchanged():
113
+ import re
114
+
115
+ link = hyperlink("github.com/Sam3360", CREATOR_GITHUB_URL, True)
116
+ # Parse the OSC 8 structure properly: \x1b]8;;URL\x1b\TEXT\x1b]8;;\x1b\
117
+ match = re.match(r"\x1b\]8;;(?P<url>[^\x1b]*)\x1b\\(?P<text>.*)\x1b\]8;;\x1b\\$", link)
118
+ assert match is not None
119
+ assert match.group("text") == "github.com/Sam3360"
120
+ assert match.group("url") == CREATOR_GITHUB_URL
121
+
122
+
123
+ def test_hyperlink_falls_back_to_plain_text_when_disabled():
124
+ link = hyperlink("github.com/Sam3360", CREATOR_GITHUB_URL, False)
125
+ assert link == "github.com/Sam3360"
126
+ assert "\033" not in link
127
+
128
+
129
+ def test_creator_output_has_no_escape_codes_when_not_a_tty(monkeypatch, capsys):
130
+ monkeypatch.setattr("sys.stdout.isatty", lambda: False)
131
+ with pytest.raises(SystemExit):
132
+ main(["--creator"])
133
+ out = capsys.readouterr().out
134
+ assert "\033" not in out
135
+
136
+
137
+ def test_creator_output_has_hyperlink_when_tty(monkeypatch, capsys):
138
+ monkeypatch.setattr("sys.stdout.isatty", lambda: True)
139
+ monkeypatch.delenv("NO_COLOR", raising=False)
140
+ monkeypatch.delenv("FORCE_COLOR", raising=False)
141
+ with pytest.raises(SystemExit):
142
+ main(["--creator"])
143
+ out = capsys.readouterr().out
144
+ assert "\033]8;;https://github.com/Sam3360\033\\" in out
145
+
146
+
147
+ # --- --ignore-file --------------------------------------------------------
148
+
149
+
150
+ def test_ignore_file_creates_file(tmp_path, monkeypatch):
151
+ monkeypatch.chdir(tmp_path)
152
+ with pytest.raises(SystemExit):
153
+ main(["--ignore-file"])
154
+ assert (tmp_path / ".secretshieldignore").exists()
155
+
156
+
157
+ def test_ignore_file_contains_starter_comment(tmp_path, monkeypatch):
158
+ monkeypatch.chdir(tmp_path)
159
+ with pytest.raises(SystemExit):
160
+ main(["--ignore-file"])
161
+ content = (tmp_path / ".secretshieldignore").read_text()
162
+ assert content == DEFAULT_IGNORE_CONTENT
163
+ assert content.strip().startswith("#")
164
+
165
+
166
+ def test_ignore_file_does_not_overwrite_existing(tmp_path, monkeypatch, capsys):
167
+ monkeypatch.chdir(tmp_path)
168
+ (tmp_path / ".secretshieldignore").write_text("my custom rules\n")
169
+ with pytest.raises(SystemExit):
170
+ main(["--ignore-file"])
171
+ assert (tmp_path / ".secretshieldignore").read_text() == "my custom rules\n"
172
+ out = capsys.readouterr().out
173
+ assert "already exists" in out
174
+
175
+
176
+ def test_ignore_file_exits_successfully(tmp_path, monkeypatch):
177
+ monkeypatch.chdir(tmp_path)
178
+ with pytest.raises(SystemExit) as exc_info:
179
+ main(["--ignore-file"])
180
+ assert exc_info.value.code in (0, None)
181
+
182
+
183
+ def test_ignore_file_works_through_normal_cli(tmp_path, monkeypatch):
184
+ monkeypatch.chdir(tmp_path)
185
+ with pytest.raises(SystemExit):
186
+ main(["--ignore-file"])
187
+ assert (tmp_path / ".secretshieldignore").exists()
188
+
189
+
190
+ def test_create_default_ignore_file_helper_directly(tmp_path):
191
+ created, path = create_default_ignore_file(tmp_path)
192
+ assert created is True
193
+ assert path.exists()
194
+ created2, path2 = create_default_ignore_file(tmp_path)
195
+ assert created2 is False
196
+ assert path2 == path
197
+
198
+
199
+ # --- init creates .secretshieldignore ---------------------------------------
200
+
201
+
202
+ def test_init_creates_secretshieldignore(tmp_path, monkeypatch):
203
+ monkeypatch.chdir(tmp_path)
204
+ monkeypatch.setattr("sys.stdin.isatty", lambda: True)
205
+ # Not a git repo, so the hook question is skipped entirely (no
206
+ # prompt consumed for it) -- only config, actions, and apply remain.
207
+ answers = iter(["y", "n", "y"])
208
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
209
+
210
+ main(["init"])
211
+ assert (tmp_path / ".secretshieldignore").exists()
212
+
213
+
214
+ def test_init_preserves_existing_secretshieldignore(tmp_path, monkeypatch):
215
+ monkeypatch.chdir(tmp_path)
216
+ (tmp_path / ".secretshieldignore").write_text("existing content\n")
217
+ monkeypatch.setattr("sys.stdin.isatty", lambda: True)
218
+ answers = iter(["n", "n", "y"])
219
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
220
+
221
+ main(["init"])
222
+ assert (tmp_path / ".secretshieldignore").read_text() == "existing content\n"
223
+
224
+
225
+ def test_init_twice_does_not_duplicate_or_overwrite(tmp_path, monkeypatch):
226
+ monkeypatch.chdir(tmp_path)
227
+ monkeypatch.setattr("sys.stdin.isatty", lambda: True)
228
+
229
+ answers1 = iter(["n", "n", "y"])
230
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(answers1))
231
+ main(["init"])
232
+ first_content = (tmp_path / ".secretshieldignore").read_text()
233
+
234
+ answers2 = iter(["n", "n", "y"])
235
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(answers2))
236
+ main(["init"])
237
+ second_content = (tmp_path / ".secretshieldignore").read_text()
238
+
239
+ assert first_content == second_content == DEFAULT_IGNORE_CONTENT
240
+
241
+
242
+ def test_init_still_creates_everything_else(tmp_path, monkeypatch):
243
+ monkeypatch.chdir(tmp_path)
244
+ _init_repo(tmp_path)
245
+ monkeypatch.setattr("sys.stdin.isatty", lambda: True)
246
+ answers = iter(["y", "y", "y", "y"])
247
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
248
+
249
+ main(["init"])
250
+ assert (tmp_path / "secretshield.toml").exists()
251
+ assert (tmp_path / ".git" / "hooks" / "pre-commit").exists()
252
+ assert (tmp_path / ".github" / "workflows" / "secretshield.yml").exists()
253
+ assert (tmp_path / ".secretshieldignore").exists()
254
+
255
+
256
+ def test_init_non_interactive_still_refuses_cleanly(tmp_path, monkeypatch):
257
+ monkeypatch.chdir(tmp_path)
258
+ exit_code = main(["init"])
259
+ assert exit_code == 1
260
+ assert not (tmp_path / ".secretshieldignore").exists()
261
+
262
+
263
+ # --- ss alias covers all new flags too --------------------------------------
264
+
265
+
266
+ def test_ss_entry_point_supports_new_flags():
267
+ import tomllib
268
+
269
+ with open("pyproject.toml", "rb") as f:
270
+ data = tomllib.load(f)
271
+ scripts = data["project"]["scripts"]
272
+ # Both commands point at the same entry point, so any flag added to
273
+ # the shared parser automatically works for both -- confirm the
274
+ # wiring is still single-entry-point, not duplicated.
275
+ assert scripts["secretshield"] == scripts["ss"] == "secretshield.cli:main"
File without changes
File without changes