secretshield 0.6.1__tar.gz → 0.7.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 (54) hide show
  1. {secretshield-0.6.1/secretshield.egg-info → secretshield-0.7.0}/PKG-INFO +16 -1
  2. {secretshield-0.6.1 → secretshield-0.7.0}/README.md +15 -0
  3. {secretshield-0.6.1 → secretshield-0.7.0}/pyproject.toml +1 -1
  4. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/__init__.py +1 -1
  5. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/autofix/fixer.py +13 -1
  6. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/cli.py +165 -1
  7. secretshield-0.7.0/secretshield/custom_rules.py +163 -0
  8. {secretshield-0.6.1 → secretshield-0.7.0/secretshield.egg-info}/PKG-INFO +16 -1
  9. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield.egg-info/SOURCES.txt +3 -0
  10. secretshield-0.7.0/tests/test_custom_rules.py +240 -0
  11. secretshield-0.7.0/tests/test_custom_rules_cli_integration.py +305 -0
  12. {secretshield-0.6.1 → secretshield-0.7.0}/LICENSE +0 -0
  13. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/autofix/__init__.py +0 -0
  14. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/autofix/env.py +0 -0
  15. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/autofix/gitignore.py +0 -0
  16. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/autofix/javascript.py +0 -0
  17. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/autofix/python.py +0 -0
  18. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/baseline.py +0 -0
  19. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/color.py +0 -0
  20. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/config.py +0 -0
  21. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/detector.py +0 -0
  22. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/git/__init__.py +0 -0
  23. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/git/hooks.py +0 -0
  24. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/github/__init__.py +0 -0
  25. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/github/actions.py +0 -0
  26. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/guardian.py +0 -0
  27. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/ignorefile.py +0 -0
  28. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/notifications.py +0 -0
  29. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/patterns.py +0 -0
  30. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/project_config.py +0 -0
  31. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/redactor.py +0 -0
  32. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield.egg-info/dependency_links.txt +0 -0
  33. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield.egg-info/entry_points.txt +0 -0
  34. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield.egg-info/requires.txt +0 -0
  35. {secretshield-0.6.1 → secretshield-0.7.0}/secretshield.egg-info/top_level.txt +0 -0
  36. {secretshield-0.6.1 → secretshield-0.7.0}/setup.cfg +0 -0
  37. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_autofix.py +0 -0
  38. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_baseline.py +0 -0
  39. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_color.py +0 -0
  40. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_color_cli_integration.py +0 -0
  41. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_detector.py +0 -0
  42. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_git_hooks.py +0 -0
  43. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_github_action.py +0 -0
  44. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_ignorefile.py +0 -0
  45. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_ignorefile_cli_integration.py +0 -0
  46. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_javascript_autofix.py +0 -0
  47. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_logging.py +0 -0
  48. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_project_config.py +0 -0
  49. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_redactor.py +0 -0
  50. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_scan.py +0 -0
  51. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_ss_alias_and_creator.py +0 -0
  52. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_stdout.py +0 -0
  53. {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_v030_cli_integration.py +0 -0
  54. {secretshield-0.6.1 → secretshield-0.7.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.6.1
3
+ Version: 0.7.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
@@ -104,6 +104,21 @@ secretshield init
104
104
  No required dependencies, no telemetry, no network calls. Everything
105
105
  runs locally, in your own process.
106
106
 
107
+ ## Custom rules
108
+
109
+ Have a credential format of your own? Drop a
110
+ `.secretshield-rules.toml` in your project root:
111
+
112
+ ```toml
113
+ [[rules]]
114
+ name = "My API Key"
115
+ pattern = "MYAPP_[A-Za-z0-9]{32}"
116
+ ```
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`.
121
+
107
122
  For the full command reference, configuration options, and exactly how
108
123
  Auto-Fix decides what's safe to rewrite, see the
109
124
  **[usage guide](https://github.com/Sam3360/secretshield/blob/main/USAGE.md)**.
@@ -78,6 +78,21 @@ secretshield init
78
78
  No required dependencies, no telemetry, no network calls. Everything
79
79
  runs locally, in your own process.
80
80
 
81
+ ## Custom rules
82
+
83
+ Have a credential format of your own? Drop a
84
+ `.secretshield-rules.toml` in your project root:
85
+
86
+ ```toml
87
+ [[rules]]
88
+ name = "My API Key"
89
+ pattern = "MYAPP_[A-Za-z0-9]{32}"
90
+ ```
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`.
95
+
81
96
  For the full command reference, configuration options, and exactly how
82
97
  Auto-Fix decides what's safe to rewrite, see the
83
98
  **[usage guide](https://github.com/Sam3360/secretshield/blob/main/USAGE.md)**.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "secretshield"
7
- version = "0.6.1"
7
+ version = "0.7.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.6.1"
26
+ __version__ = "0.7.0"
27
27
 
28
28
  __all__ = [
29
29
  "__version__",
@@ -14,6 +14,7 @@ from pathlib import Path
14
14
  from typing import Callable
15
15
 
16
16
  from ..detector import detect
17
+ from ..custom_rules import scan_text_with_rules
17
18
  from . import javascript as jsfix
18
19
  from . import python as pyfix
19
20
  from .env import append_env_example_var, append_env_var, unique_env_key
@@ -86,6 +87,7 @@ def run_autofix(
86
87
  entropy_threshold: float = 4.2,
87
88
  print_func: Callable[[str], None] = _default_print,
88
89
  input_func: Callable[[str], str] = _default_input,
90
+ custom_rules: list | None = None,
89
91
  ) -> FixSummary:
90
92
  """
91
93
  Run the interactive Auto-Fix flow.
@@ -94,6 +96,12 @@ def run_autofix(
94
96
  `secretshield.detector.Match` objects found in that file (already
95
97
  filtered for placeholders by the caller). Files are processed in
96
98
  sorted order for deterministic output.
99
+
100
+ `custom_rules` (optional): the same `.secretshield-rules.toml`
101
+ rules used to build `findings_by_file`, if any. Needed here too so
102
+ that the internal re-scan (used to get fresh, accurate line numbers
103
+ after an `import os` insertion) can re-locate a match that only a
104
+ custom rule -- not a built-in pattern -- would find.
97
105
  """
98
106
  summary = FixSummary()
99
107
  env_path = project_root / ".env"
@@ -163,9 +171,13 @@ def run_autofix(
163
171
  source = pyfix.insert_import_os(source)
164
172
 
165
173
  # Re-scan the (possibly import-adjusted) source fresh, so line
166
- # numbers reflect the current content exactly.
174
+ # numbers reflect the current content exactly. Custom rules are
175
+ # merged in too, so a match only a custom rule (not a built-in
176
+ # pattern) would find isn't lost here.
167
177
  try:
168
178
  current_matches = detect(source, entropy_threshold=entropy_threshold)
179
+ if custom_rules:
180
+ current_matches = current_matches + scan_text_with_rules(source, custom_rules)
169
181
  except Exception:
170
182
  current_matches = []
171
183
 
@@ -408,6 +408,7 @@ def _iter_scan_sources(args: argparse.Namespace, target: Path, include_patterns:
408
408
 
409
409
  def _run_scan(args: argparse.Namespace) -> int:
410
410
  from .baseline import baseline_exists, compute_finding_id, load_baseline_ids, write_baseline
411
+ from .custom_rules import load_custom_rules, scan_text_with_rules
411
412
  from .project_config import load_project_config
412
413
 
413
414
  project_cfg = load_project_config()
@@ -428,6 +429,20 @@ def _run_scan(args: argparse.Namespace) -> int:
428
429
 
429
430
  exclude_patterns.extend(load_ignore_patterns(Path.cwd()))
430
431
 
432
+ # Custom rules (.secretshield-rules.toml), if present, become
433
+ # additional detectors alongside the built-in patterns -- no flag
434
+ # required. A broken rules file never blocks the scan (valid rules
435
+ # in the same file still load and run); it's surfaced as a single
436
+ # warning pointing at `rules check` rather than silently ignored.
437
+ custom_rules, custom_rule_errors = load_custom_rules(Path.cwd())
438
+ if custom_rule_errors:
439
+ print(
440
+ f"secretshield: warning: {len(custom_rule_errors)} invalid custom "
441
+ f"rule(s) in .secretshield-rules.toml -- run `secretshield rules "
442
+ f"check` for details.",
443
+ file=sys.stderr,
444
+ )
445
+
431
446
  json_output = bool(args.json) or (project_cfg.output_format == "json")
432
447
 
433
448
  target = Path(args.path)
@@ -455,6 +470,11 @@ def _run_scan(args: argparse.Namespace) -> int:
455
470
  _progress_update(show_progress, files_scanned)
456
471
 
457
472
  file_matches = _scan_text_matches(text, entropy_threshold=entropy_threshold)
473
+ if custom_rules:
474
+ custom_matches = [
475
+ m for m in scan_text_with_rules(text, custom_rules) if not _is_placeholder(m.value)
476
+ ]
477
+ file_matches = file_matches + custom_matches
458
478
  if file_matches:
459
479
  matches_by_file[display_path] = file_matches
460
480
  for match in file_matches:
@@ -503,7 +523,12 @@ def _run_scan(args: argparse.Namespace) -> int:
503
523
  if getattr(args, "fix", False):
504
524
  from .autofix import run_autofix
505
525
 
506
- summary = run_autofix(project_root, matches_by_file, entropy_threshold=entropy_threshold)
526
+ summary = run_autofix(
527
+ project_root,
528
+ matches_by_file,
529
+ entropy_threshold=entropy_threshold,
530
+ custom_rules=custom_rules,
531
+ )
507
532
  return 1 if summary.fixed < summary.detected else 0
508
533
 
509
534
  if json_output:
@@ -734,6 +759,124 @@ def _cmd_init(args: argparse.Namespace) -> int:
734
759
  return 0
735
760
 
736
761
 
762
+ # --- `rules` / `rule create` commands -----------------------------------
763
+
764
+
765
+ def _cmd_rules_check(args: argparse.Namespace) -> int:
766
+ from .custom_rules import load_custom_rules, rules_file_path
767
+
768
+ use_color = supports_color(sys.stdout)
769
+ path = rules_file_path(Path.cwd())
770
+
771
+ if not path.exists():
772
+ print(f"No {path.name} found.")
773
+ return 0
774
+
775
+ rules, errors = load_custom_rules(Path.cwd())
776
+
777
+ print("SecretShield Rules\n")
778
+
779
+ if errors:
780
+ for err in errors:
781
+ if err.name:
782
+ print(f'{red(chr(0x2717), use_color)} Invalid custom rule "{err.name}"')
783
+ print(f" {err.message}")
784
+ else:
785
+ label = f"rule #{err.rule_index + 1}" if err.rule_index is not None else path.name
786
+ print(f"{red(chr(0x2717), use_color)} {label}: {err.message}")
787
+ return 1
788
+
789
+ print(f"{green(chr(0x2713), use_color)} {path.name}")
790
+ print(f"{green(chr(0x2713), use_color)} {len(rules)} rule(s) loaded")
791
+ print(f"{green(chr(0x2713), use_color)} All patterns are valid")
792
+ return 0
793
+
794
+
795
+ def _cmd_rules_list(args: argparse.Namespace) -> int:
796
+ from .custom_rules import load_custom_rules, rules_file_path
797
+
798
+ path = rules_file_path(Path.cwd())
799
+ if not path.exists():
800
+ print(f"No {path.name} found.")
801
+ return 0
802
+
803
+ rules, errors = load_custom_rules(Path.cwd())
804
+
805
+ if not rules and not errors:
806
+ print("No custom rules defined.")
807
+ return 0
808
+
809
+ if rules:
810
+ print("SecretShield Custom Rules\n")
811
+ for i, rule in enumerate(rules, start=1):
812
+ print(f"{i}. {rule.name}")
813
+ print(f" {rule.pattern}")
814
+
815
+ if errors:
816
+ use_color = supports_color(sys.stdout)
817
+ print(
818
+ f"\n{red(chr(0x2717), use_color)} {len(errors)} rule(s) failed to load "
819
+ f"-- run `secretshield rules check` for details."
820
+ )
821
+ return 0
822
+
823
+
824
+ def _cmd_rules_no_subcommand(args: argparse.Namespace) -> int:
825
+ # `secretshield rules` with no further action -> list.
826
+ return _cmd_rules_list(args)
827
+
828
+
829
+ def _cmd_rule_no_subcommand(args: argparse.Namespace) -> int:
830
+ print("secretshield rule: missing subcommand. Try `secretshield rule create`.", file=sys.stderr)
831
+ return 1
832
+
833
+
834
+ def _cmd_rule_create(args: argparse.Namespace) -> int:
835
+ import re as _re
836
+
837
+ from .custom_rules import append_rule, rules_file_path
838
+
839
+ try:
840
+ interactive = sys.stdin.isatty()
841
+ except Exception:
842
+ interactive = False
843
+
844
+ if not interactive:
845
+ print("secretshield rule create requires an interactive terminal.")
846
+ print("No rule was created.")
847
+ return 1
848
+
849
+ print("SecretShield \u2014 Create custom rule\n")
850
+
851
+ name = input("Rule name: ").strip()
852
+ if not name:
853
+ print("Rule name cannot be empty. No rule was created.", file=sys.stderr)
854
+ return 1
855
+
856
+ while True:
857
+ pattern = input("Pattern: ").strip()
858
+ if not pattern:
859
+ print("Pattern cannot be empty.", file=sys.stderr)
860
+ continue
861
+ try:
862
+ _re.compile(pattern)
863
+ except _re.error as exc:
864
+ print(f"Invalid regular expression: {exc}", file=sys.stderr)
865
+ print("Please try again.\n")
866
+ continue
867
+ break
868
+
869
+ path = rules_file_path(Path.cwd())
870
+ try:
871
+ append_rule(path, name, pattern)
872
+ except Exception as exc:
873
+ print(f"\u2717 Could not write {path.name}: {exc}", file=sys.stderr)
874
+ return 1
875
+
876
+ print(f"{green(chr(0x2713), supports_color(sys.stdout))} Added rule to {path.name}")
877
+ return 0
878
+
879
+
737
880
  # --- argparse wiring -----------------------------------------------------
738
881
 
739
882
 
@@ -925,6 +1068,27 @@ def build_parser() -> argparse.ArgumentParser:
925
1068
  )
926
1069
  init_parser.set_defaults(func=_cmd_init)
927
1070
 
1071
+ rules_parser = subparsers.add_parser(
1072
+ "rules",
1073
+ help="List or validate .secretshield-rules.toml custom detection rules.",
1074
+ )
1075
+ rules_subparsers = rules_parser.add_subparsers(dest="rules_action")
1076
+ rules_check_parser = rules_subparsers.add_parser(
1077
+ "check", help="Validate .secretshield-rules.toml (TOML syntax, required fields, regex validity)."
1078
+ )
1079
+ rules_check_parser.set_defaults(func=_cmd_rules_check)
1080
+ rules_parser.set_defaults(func=_cmd_rules_list)
1081
+
1082
+ rule_parser = subparsers.add_parser(
1083
+ "rule", help="Create a custom detection rule."
1084
+ )
1085
+ rule_parser.set_defaults(func=_cmd_rule_no_subcommand)
1086
+ rule_subparsers = rule_parser.add_subparsers(dest="rule_action")
1087
+ rule_create_parser = rule_subparsers.add_parser(
1088
+ "create", help="Interactively create a new rule in .secretshield-rules.toml."
1089
+ )
1090
+ rule_create_parser.set_defaults(func=_cmd_rule_create)
1091
+
928
1092
  return parser
929
1093
 
930
1094
 
@@ -0,0 +1,163 @@
1
+ """
2
+ `.secretshield-rules.toml` -- simple, project-local regex rules that
3
+ become additional detectors alongside SecretShield's built-in
4
+ patterns, automatically, for any project that has the file.
5
+
6
+ Deliberately minimal by design: a rule has a `name` and a `pattern`,
7
+ nothing else. No severity, no IDs, no capture-group logic, no path
8
+ filters -- see the project's USAGE.md for the reasoning. This module
9
+ only loads and compiles rules and runs them against already-loaded
10
+ text; everything else (scanning, ignoring, reporting, redaction,
11
+ Auto-Fix) is the existing SecretShield pipeline, unchanged.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ try:
21
+ import tomllib # Python 3.11+
22
+ except ModuleNotFoundError: # pragma: no cover -- exercised only on 3.10
23
+ try:
24
+ import tomli as tomllib # type: ignore[no-redef]
25
+ except ModuleNotFoundError:
26
+ tomllib = None # type: ignore[assignment]
27
+
28
+ from .detector import Match
29
+
30
+ RULES_FILENAME = ".secretshield-rules.toml"
31
+
32
+
33
+ @dataclass
34
+ class CustomRule:
35
+ name: str
36
+ pattern: str
37
+ regex: re.Pattern
38
+
39
+
40
+ @dataclass
41
+ class RuleError:
42
+ """A problem found while loading the rules file. `rule_index` and
43
+ `name` are None for file-level errors (bad TOML, wrong shape)."""
44
+
45
+ rule_index: int | None
46
+ name: str | None
47
+ message: str
48
+
49
+
50
+ def rules_file_path(root: Path) -> Path:
51
+ return root / RULES_FILENAME
52
+
53
+
54
+ def load_custom_rules(root: Path | None = None) -> tuple[list[CustomRule], list[RuleError]]:
55
+ """
56
+ Load and compile custom rules from `.secretshield-rules.toml` in
57
+ `root` (default: current directory).
58
+
59
+ Returns `(rules, errors)`. A rule with a problem is skipped and
60
+ recorded as an error rather than silently dropped or aborting the
61
+ whole file -- one bad rule doesn't disable the rest. No file, an
62
+ empty file, or an empty `rules` list all return `([], [])`, which
63
+ is a valid, non-error state.
64
+ """
65
+ path = rules_file_path(root or Path.cwd())
66
+ rules: list[CustomRule] = []
67
+ errors: list[RuleError] = []
68
+
69
+ if not path.exists():
70
+ return rules, errors
71
+
72
+ if tomllib is None:
73
+ errors.append(
74
+ RuleError(None, None, "No TOML parser available to read this file.")
75
+ )
76
+ return rules, errors
77
+
78
+ try:
79
+ with open(path, "rb") as f:
80
+ data = tomllib.load(f)
81
+ except Exception as exc:
82
+ errors.append(RuleError(None, None, f"Invalid TOML: {exc}"))
83
+ return rules, errors
84
+
85
+ if not data:
86
+ return rules, errors
87
+
88
+ raw_rules = data.get("rules")
89
+ if raw_rules is None:
90
+ return rules, errors
91
+ if not isinstance(raw_rules, list):
92
+ errors.append(RuleError(None, None, '"rules" must be a list of [[rules]] tables.'))
93
+ return rules, errors
94
+
95
+ for index, raw in enumerate(raw_rules):
96
+ if not isinstance(raw, dict):
97
+ errors.append(RuleError(index, None, "Each rule must be a [[rules]] table."))
98
+ continue
99
+
100
+ name = raw.get("name")
101
+ if not name or not isinstance(name, str):
102
+ errors.append(RuleError(index, None, 'Missing or invalid "name".'))
103
+ continue
104
+
105
+ pattern = raw.get("pattern")
106
+ if not pattern or not isinstance(pattern, str):
107
+ errors.append(RuleError(index, name, 'Missing or invalid "pattern".'))
108
+ continue
109
+
110
+ try:
111
+ compiled = re.compile(pattern)
112
+ except re.error as exc:
113
+ errors.append(RuleError(index, name, f"Invalid regular expression: {exc}"))
114
+ continue
115
+
116
+ rules.append(CustomRule(name=name, pattern=pattern, regex=compiled))
117
+
118
+ return rules, errors
119
+
120
+
121
+ def scan_text_with_rules(text: str, rules: list[CustomRule]) -> list[Match]:
122
+ """
123
+ Run custom rules against already-loaded text, returning
124
+ `secretshield.detector.Match` objects -- the same shape built-in
125
+ detection produces. `kind` is set to the rule's own name, so a
126
+ custom-rule finding flows through the existing finding/report/
127
+ JSON/--fix pipeline with no separate output system: it's
128
+ identified simply by its name, e.g. "My API Key".
129
+ """
130
+ if not text or not rules:
131
+ return []
132
+ matches: list[Match] = []
133
+ for rule in rules:
134
+ for m in rule.regex.finditer(text):
135
+ value = m.group(0)
136
+ if not value:
137
+ continue
138
+ matches.append(Match(start=m.start(), end=m.end(), value=value, kind=rule.name))
139
+ return matches
140
+
141
+
142
+ def append_rule(path: Path, name: str, pattern: str) -> None:
143
+ """
144
+ Append a new `[[rules]]` block to `path`, creating it if it
145
+ doesn't exist. Never rewrites or reformats whatever's already
146
+ there -- existing rules (and any manual edits) are preserved
147
+ byte-for-byte, the same append-only convention used elsewhere in
148
+ SecretShield (.env, .gitignore, .secretshieldignore).
149
+ """
150
+
151
+ def _toml_string(s: str) -> str:
152
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
153
+
154
+ block = f"[[rules]]\nname = {_toml_string(name)}\npattern = {_toml_string(pattern)}\n"
155
+
156
+ prefix = ""
157
+ if path.exists():
158
+ existing = path.read_text(encoding="utf-8", errors="ignore")
159
+ if existing and not existing.endswith("\n"):
160
+ prefix = "\n"
161
+
162
+ with open(path, "a", encoding="utf-8", newline="") as f:
163
+ f.write(f"{prefix}{block}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secretshield
3
- Version: 0.6.1
3
+ Version: 0.7.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
@@ -104,6 +104,21 @@ secretshield init
104
104
  No required dependencies, no telemetry, no network calls. Everything
105
105
  runs locally, in your own process.
106
106
 
107
+ ## Custom rules
108
+
109
+ Have a credential format of your own? Drop a
110
+ `.secretshield-rules.toml` in your project root:
111
+
112
+ ```toml
113
+ [[rules]]
114
+ name = "My API Key"
115
+ pattern = "MYAPP_[A-Za-z0-9]{32}"
116
+ ```
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`.
121
+
107
122
  For the full command reference, configuration options, and exactly how
108
123
  Auto-Fix decides what's safe to rewrite, see the
109
124
  **[usage guide](https://github.com/Sam3360/secretshield/blob/main/USAGE.md)**.
@@ -6,6 +6,7 @@ secretshield/baseline.py
6
6
  secretshield/cli.py
7
7
  secretshield/color.py
8
8
  secretshield/config.py
9
+ secretshield/custom_rules.py
9
10
  secretshield/detector.py
10
11
  secretshield/guardian.py
11
12
  secretshield/ignorefile.py
@@ -33,6 +34,8 @@ tests/test_autofix.py
33
34
  tests/test_baseline.py
34
35
  tests/test_color.py
35
36
  tests/test_color_cli_integration.py
37
+ tests/test_custom_rules.py
38
+ tests/test_custom_rules_cli_integration.py
36
39
  tests/test_detector.py
37
40
  tests/test_git_hooks.py
38
41
  tests/test_github_action.py
@@ -0,0 +1,240 @@
1
+ """
2
+ Tests for secretshield.custom_rules -- .secretshield-rules.toml loading,
3
+ compiling, and running custom detection rules.
4
+
5
+ All secrets used are fake / non-functional.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from secretshield.custom_rules import (
11
+ append_rule,
12
+ load_custom_rules,
13
+ rules_file_path,
14
+ scan_text_with_rules,
15
+ )
16
+
17
+ FAKE_MATCH = "MYAPP_12345678901234567890123456789012"
18
+
19
+
20
+ # --- Loading -----------------------------------------------------------------
21
+
22
+
23
+ def test_no_rules_file_returns_empty(tmp_path):
24
+ rules, errors = load_custom_rules(tmp_path)
25
+ assert rules == []
26
+ assert errors == []
27
+
28
+
29
+ def test_one_rule_loads(tmp_path):
30
+ (tmp_path / ".secretshield-rules.toml").write_text(
31
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
32
+ )
33
+ rules, errors = load_custom_rules(tmp_path)
34
+ assert errors == []
35
+ assert len(rules) == 1
36
+ assert rules[0].name == "My API Key"
37
+ assert rules[0].pattern == "MYAPP_[A-Za-z0-9]{32}"
38
+
39
+
40
+ def test_multiple_rules_load(tmp_path):
41
+ (tmp_path / ".secretshield-rules.toml").write_text(
42
+ '[[rules]]\nname = "Acme API Key"\npattern = "ACME_[A-Za-z0-9]{32}"\n'
43
+ '[[rules]]\nname = "Internal Token"\npattern = "internal_[a-f0-9]{40}"\n'
44
+ '[[rules]]\nname = "Company Credential"\npattern = "company_[A-Za-z0-9_-]+"\n'
45
+ )
46
+ rules, errors = load_custom_rules(tmp_path)
47
+ assert errors == []
48
+ assert [r.name for r in rules] == ["Acme API Key", "Internal Token", "Company Credential"]
49
+
50
+
51
+ def test_empty_file_is_valid_no_rules(tmp_path):
52
+ (tmp_path / ".secretshield-rules.toml").write_text("")
53
+ rules, errors = load_custom_rules(tmp_path)
54
+ assert rules == []
55
+ assert errors == []
56
+
57
+
58
+ def test_empty_rules_list_is_valid(tmp_path):
59
+ (tmp_path / ".secretshield-rules.toml").write_text("rules = []\n")
60
+ rules, errors = load_custom_rules(tmp_path)
61
+ assert rules == []
62
+ assert errors == []
63
+
64
+
65
+ def test_rules_file_path_helper(tmp_path):
66
+ path = rules_file_path(tmp_path)
67
+ assert path.name == ".secretshield-rules.toml"
68
+ assert path.parent == tmp_path
69
+
70
+
71
+ # --- Detection -----------------------------------------------------------------
72
+
73
+
74
+ def test_matching_regex_is_detected(tmp_path):
75
+ (tmp_path / ".secretshield-rules.toml").write_text(
76
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
77
+ )
78
+ rules, _ = load_custom_rules(tmp_path)
79
+ text = f'API_KEY = "{FAKE_MATCH}"'
80
+ matches = scan_text_with_rules(text, rules)
81
+ assert len(matches) == 1
82
+ assert matches[0].kind == "My API Key"
83
+ assert matches[0].value == FAKE_MATCH
84
+
85
+
86
+ def test_non_matching_content_is_ignored(tmp_path):
87
+ (tmp_path / ".secretshield-rules.toml").write_text(
88
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
89
+ )
90
+ rules, _ = load_custom_rules(tmp_path)
91
+ matches = scan_text_with_rules("nothing interesting here", rules)
92
+ assert matches == []
93
+
94
+
95
+ def test_multiple_rules_detect_multiple_findings(tmp_path):
96
+ (tmp_path / ".secretshield-rules.toml").write_text(
97
+ '[[rules]]\nname = "Rule A"\npattern = "AAA_[0-9]{6}"\n'
98
+ '[[rules]]\nname = "Rule B"\npattern = "BBB_[0-9]{6}"\n'
99
+ )
100
+ rules, _ = load_custom_rules(tmp_path)
101
+ text = "x = AAA_123456\ny = BBB_654321"
102
+ matches = scan_text_with_rules(text, rules)
103
+ kinds = sorted(m.kind for m in matches)
104
+ assert kinds == ["Rule A", "Rule B"]
105
+
106
+
107
+ def test_custom_rule_name_appears_as_kind(tmp_path):
108
+ (tmp_path / ".secretshield-rules.toml").write_text(
109
+ '[[rules]]\nname = "Stripe Test Key"\npattern = "sk_test_[A-Za-z0-9]+"\n'
110
+ )
111
+ rules, _ = load_custom_rules(tmp_path)
112
+ matches = scan_text_with_rules("key = sk_test_abcdef123456", rules)
113
+ assert matches[0].kind == "Stripe Test Key"
114
+
115
+
116
+ def test_no_rules_scans_nothing(tmp_path):
117
+ assert scan_text_with_rules("MYAPP_12345678901234567890123456789012", []) == []
118
+
119
+
120
+ def test_empty_text_scans_nothing(tmp_path):
121
+ (tmp_path / ".secretshield-rules.toml").write_text(
122
+ '[[rules]]\nname = "X"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
123
+ )
124
+ rules, _ = load_custom_rules(tmp_path)
125
+ assert scan_text_with_rules("", rules) == []
126
+
127
+
128
+ # --- Validation -----------------------------------------------------------------
129
+
130
+
131
+ def test_malformed_toml_reports_error(tmp_path):
132
+ (tmp_path / ".secretshield-rules.toml").write_text("this is [ not valid toml {{{")
133
+ rules, errors = load_custom_rules(tmp_path)
134
+ assert rules == []
135
+ assert len(errors) == 1
136
+ assert "toml" in errors[0].message.lower() or "expected" in errors[0].message.lower()
137
+
138
+
139
+ def test_missing_name_reports_error(tmp_path):
140
+ (tmp_path / ".secretshield-rules.toml").write_text(
141
+ '[[rules]]\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
142
+ )
143
+ rules, errors = load_custom_rules(tmp_path)
144
+ assert rules == []
145
+ assert len(errors) == 1
146
+ assert "name" in errors[0].message.lower()
147
+
148
+
149
+ def test_missing_pattern_reports_error(tmp_path):
150
+ (tmp_path / ".secretshield-rules.toml").write_text('[[rules]]\nname = "Broken Rule"\n')
151
+ rules, errors = load_custom_rules(tmp_path)
152
+ assert rules == []
153
+ assert len(errors) == 1
154
+ assert errors[0].name == "Broken Rule"
155
+ assert "pattern" in errors[0].message.lower()
156
+
157
+
158
+ def test_invalid_regex_reports_error(tmp_path):
159
+ (tmp_path / ".secretshield-rules.toml").write_text(
160
+ '[[rules]]\nname = "Bad Regex"\npattern = "["\n'
161
+ )
162
+ rules, errors = load_custom_rules(tmp_path)
163
+ assert rules == []
164
+ assert len(errors) == 1
165
+ assert errors[0].name == "Bad Regex"
166
+ assert "regular expression" in errors[0].message.lower()
167
+
168
+
169
+ def test_invalid_rule_structure_reports_error(tmp_path):
170
+ (tmp_path / ".secretshield-rules.toml").write_text("rules = [1, 2, 3]\n")
171
+ rules, errors = load_custom_rules(tmp_path)
172
+ assert rules == []
173
+ assert len(errors) == 3
174
+
175
+
176
+ def test_one_bad_rule_does_not_block_others(tmp_path):
177
+ (tmp_path / ".secretshield-rules.toml").write_text(
178
+ '[[rules]]\nname = "Good Rule"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
179
+ '[[rules]]\nname = "Bad Rule"\npattern = "["\n'
180
+ )
181
+ rules, errors = load_custom_rules(tmp_path)
182
+ assert [r.name for r in rules] == ["Good Rule"]
183
+ assert len(errors) == 1
184
+ assert errors[0].name == "Bad Rule"
185
+
186
+
187
+ # --- Rule creation -----------------------------------------------------------------
188
+
189
+
190
+ def test_append_rule_creates_file_when_missing(tmp_path):
191
+ path = rules_file_path(tmp_path)
192
+ assert not path.exists()
193
+ append_rule(path, "My API Key", "MYAPP_[A-Za-z0-9]{32}")
194
+ assert path.exists()
195
+
196
+
197
+ def test_append_rule_creates_valid_toml(tmp_path):
198
+ path = rules_file_path(tmp_path)
199
+ append_rule(path, "My API Key", "MYAPP_[A-Za-z0-9]{32}")
200
+ rules, errors = load_custom_rules(tmp_path)
201
+ assert errors == []
202
+ assert len(rules) == 1
203
+ assert rules[0].name == "My API Key"
204
+
205
+
206
+ def test_append_rule_preserves_existing_rules(tmp_path):
207
+ path = rules_file_path(tmp_path)
208
+ append_rule(path, "First Rule", "FIRST_[0-9]{6}")
209
+ append_rule(path, "Second Rule", "SECOND_[0-9]{6}")
210
+ rules, errors = load_custom_rules(tmp_path)
211
+ assert errors == []
212
+ assert [r.name for r in rules] == ["First Rule", "Second Rule"]
213
+
214
+
215
+ def test_append_rule_does_not_overwrite_existing_content(tmp_path):
216
+ path = rules_file_path(tmp_path)
217
+ append_rule(path, "First Rule", "FIRST_[0-9]{6}")
218
+ original = path.read_text()
219
+ append_rule(path, "Second Rule", "SECOND_[0-9]{6}")
220
+ updated = path.read_text()
221
+ assert updated.startswith(original)
222
+
223
+
224
+ def test_append_rule_escapes_special_characters(tmp_path):
225
+ path = rules_file_path(tmp_path)
226
+ append_rule(path, 'Rule "with" quotes', r'a\b"c')
227
+ rules, errors = load_custom_rules(tmp_path)
228
+ assert errors == []
229
+ assert rules[0].name == 'Rule "with" quotes'
230
+ assert rules[0].pattern == r'a\b"c'
231
+
232
+
233
+ def test_append_rule_never_creates_broken_config(tmp_path):
234
+ path = rules_file_path(tmp_path)
235
+ append_rule(path, "Rule One", "ONE_[0-9]{4}")
236
+ append_rule(path, "Rule Two", "TWO_[0-9]{4}")
237
+ append_rule(path, "Rule Three", "THREE_[0-9]{4}")
238
+ rules, errors = load_custom_rules(tmp_path)
239
+ assert errors == []
240
+ assert len(rules) == 3
@@ -0,0 +1,305 @@
1
+ """
2
+ CLI-level integration tests for custom rules: `secretshield scan`
3
+ automatically picking up `.secretshield-rules.toml`, `secretshield
4
+ rules check`, `secretshield rules`, `secretshield rule create`, and
5
+ their interaction with .secretshieldignore, --fix, --json, and
6
+ redaction safety.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ from secretshield.cli import main
14
+
15
+ FAKE_MATCH = "MYAPP_12345678901234567890123456789012"
16
+
17
+
18
+ # --- Automatic inclusion in scans --------------------------------------------
19
+
20
+
21
+ def test_scan_automatically_picks_up_custom_rules(tmp_path, monkeypatch):
22
+ monkeypatch.chdir(tmp_path)
23
+ (tmp_path / ".secretshield-rules.toml").write_text(
24
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
25
+ )
26
+ (tmp_path / "test.py").write_text(f'value = "{FAKE_MATCH}"\n')
27
+
28
+ exit_code = main(["scan", "."])
29
+ assert exit_code == 1
30
+
31
+
32
+ def test_scan_behaves_normally_without_rules_file(tmp_path, monkeypatch):
33
+ monkeypatch.chdir(tmp_path)
34
+ (tmp_path / "test.py").write_text("print('nothing secret here')\n")
35
+ exit_code = main(["scan", "."])
36
+ assert exit_code == 0
37
+
38
+
39
+ def test_scan_json_includes_custom_rule_name_as_kind(tmp_path, monkeypatch, capsys):
40
+ monkeypatch.chdir(tmp_path)
41
+ (tmp_path / ".secretshield-rules.toml").write_text(
42
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
43
+ )
44
+ (tmp_path / "test.py").write_text(f'value = "{FAKE_MATCH}"\n')
45
+
46
+ main(["scan", ".", "--json"])
47
+ out = capsys.readouterr().out
48
+ payload = json.loads(out)
49
+ kinds = [m["kind"] for m in payload["matches"]]
50
+ assert "My API Key" in kinds
51
+
52
+
53
+ def test_scan_no_custom_rules_flag_needed(tmp_path, monkeypatch):
54
+ # The core requirement: no --custom-rules / --rules-file / etc.
55
+ monkeypatch.chdir(tmp_path)
56
+ (tmp_path / ".secretshield-rules.toml").write_text(
57
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
58
+ )
59
+ (tmp_path / "test.py").write_text(f'value = "{FAKE_MATCH}"\n')
60
+ # Plain `scan .` with nothing extra must find it.
61
+ assert main(["scan", "."]) == 1
62
+
63
+
64
+ def test_built_in_rules_still_work_alongside_custom(tmp_path, monkeypatch, capsys):
65
+ monkeypatch.chdir(tmp_path)
66
+ (tmp_path / ".secretshield-rules.toml").write_text(
67
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
68
+ )
69
+ aws_key = "AKIAABCDEFGHIJKLMNOP"
70
+ (tmp_path / "test.py").write_text(f'aws = "{aws_key}"\nother = "{FAKE_MATCH}"\n')
71
+
72
+ main(["scan", ".", "--json"])
73
+ out = capsys.readouterr().out
74
+ payload = json.loads(out)
75
+ kinds = {m["kind"] for m in payload["matches"]}
76
+ assert "aws_access_key_id" in kinds
77
+ assert "My API Key" in kinds
78
+
79
+
80
+ def test_multiple_custom_rules_all_scanned(tmp_path, monkeypatch, capsys):
81
+ monkeypatch.chdir(tmp_path)
82
+ (tmp_path / ".secretshield-rules.toml").write_text(
83
+ '[[rules]]\nname = "Acme API Key"\npattern = "ACME_[0-9]{6}"\n'
84
+ '[[rules]]\nname = "Internal Token"\npattern = "internal_[a-f0-9]{8}"\n'
85
+ '[[rules]]\nname = "Company Credential"\npattern = "company_[A-Za-z0-9_-]+"\n'
86
+ )
87
+ (tmp_path / "test.py").write_text(
88
+ 'a = "ACME_123456"\nb = "internal_deadbeef"\nc = "company_secretvalue"\n'
89
+ )
90
+ main(["scan", ".", "--json"])
91
+ out = capsys.readouterr().out
92
+ payload = json.loads(out)
93
+ kinds = {m["kind"] for m in payload["matches"]}
94
+ assert {"Acme API Key", "Internal Token", "Company Credential"}.issubset(kinds)
95
+
96
+
97
+ # --- Broken rules file doesn't block a scan ---------------------------------
98
+
99
+
100
+ def test_broken_custom_rules_does_not_block_scan(tmp_path, monkeypatch, capsys):
101
+ monkeypatch.chdir(tmp_path)
102
+ (tmp_path / ".secretshield-rules.toml").write_text(
103
+ '[[rules]]\nname = "Bad Rule"\npattern = "["\n'
104
+ )
105
+ (tmp_path / "clean.py").write_text("print('hello')\n")
106
+ exit_code = main(["scan", "."])
107
+ assert exit_code == 0 # scan still runs
108
+ err = capsys.readouterr().err
109
+ assert "rules check" in err # points the user at how to see details
110
+
111
+
112
+ # --- `secretshield rules check` ---------------------------------------------
113
+
114
+
115
+ def test_rules_check_no_file(tmp_path, monkeypatch, capsys):
116
+ monkeypatch.chdir(tmp_path)
117
+ exit_code = main(["rules", "check"])
118
+ assert exit_code == 0
119
+ out = capsys.readouterr().out
120
+ assert "No .secretshield-rules.toml found" in out
121
+
122
+
123
+ def test_rules_check_success(tmp_path, monkeypatch, capsys):
124
+ monkeypatch.chdir(tmp_path)
125
+ (tmp_path / ".secretshield-rules.toml").write_text(
126
+ '[[rules]]\nname = "A"\npattern = "AAA_[0-9]{4}"\n'
127
+ '[[rules]]\nname = "B"\npattern = "BBB_[0-9]{4}"\n'
128
+ )
129
+ exit_code = main(["rules", "check"])
130
+ assert exit_code == 0
131
+ out = capsys.readouterr().out
132
+ assert "2 rule(s) loaded" in out
133
+ assert "All patterns are valid" in out
134
+
135
+
136
+ def test_rules_check_reports_errors(tmp_path, monkeypatch, capsys):
137
+ monkeypatch.chdir(tmp_path)
138
+ (tmp_path / ".secretshield-rules.toml").write_text(
139
+ '[[rules]]\nname = "Internal Token"\npattern = "["\n'
140
+ )
141
+ exit_code = main(["rules", "check"])
142
+ assert exit_code == 1
143
+ out = capsys.readouterr().out
144
+ assert "Internal Token" in out
145
+ assert "regular expression" in out.lower()
146
+
147
+
148
+ def test_rules_check_never_dumps_traceback(tmp_path, monkeypatch, capsys):
149
+ monkeypatch.chdir(tmp_path)
150
+ (tmp_path / ".secretshield-rules.toml").write_text("not [ valid toml {{{")
151
+ exit_code = main(["rules", "check"])
152
+ assert exit_code == 1
153
+ out = capsys.readouterr().out
154
+ assert "Traceback" not in out
155
+
156
+
157
+ # --- `secretshield rules` (list) --------------------------------------------
158
+
159
+
160
+ def test_rules_list_shows_rules(tmp_path, monkeypatch, capsys):
161
+ monkeypatch.chdir(tmp_path)
162
+ (tmp_path / ".secretshield-rules.toml").write_text(
163
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
164
+ )
165
+ main(["rules"])
166
+ out = capsys.readouterr().out
167
+ assert "My API Key" in out
168
+ assert "MYAPP_[A-Za-z0-9]{32}" in out
169
+
170
+
171
+ def test_rules_list_no_file(tmp_path, monkeypatch, capsys):
172
+ monkeypatch.chdir(tmp_path)
173
+ exit_code = main(["rules"])
174
+ assert exit_code == 0
175
+ out = capsys.readouterr().out
176
+ assert "No .secretshield-rules.toml found" in out
177
+
178
+
179
+ # --- `secretshield rule create` ---------------------------------------------
180
+
181
+
182
+ def test_rule_create_non_interactive_refuses(tmp_path, monkeypatch):
183
+ monkeypatch.chdir(tmp_path)
184
+ exit_code = main(["rule", "create"])
185
+ assert exit_code == 1
186
+ assert not (tmp_path / ".secretshield-rules.toml").exists()
187
+
188
+
189
+ def test_rule_no_subcommand_shows_message(tmp_path, monkeypatch, capsys):
190
+ monkeypatch.chdir(tmp_path)
191
+ exit_code = main(["rule"])
192
+ assert exit_code == 1
193
+
194
+
195
+ # --- .secretshieldignore interaction ----------------------------------------
196
+
197
+
198
+ def test_ignored_files_not_scanned_by_custom_rules(tmp_path, monkeypatch):
199
+ monkeypatch.chdir(tmp_path)
200
+ (tmp_path / ".secretshield-rules.toml").write_text(
201
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
202
+ )
203
+ (tmp_path / ".secretshieldignore").write_text("ignored/\n")
204
+ (tmp_path / "ignored").mkdir()
205
+ (tmp_path / "ignored" / "secret.py").write_text(f'x = "{FAKE_MATCH}"\n')
206
+ (tmp_path / "clean.py").write_text("print('clean')\n")
207
+
208
+ exit_code = main(["scan", "."])
209
+ assert exit_code == 0
210
+
211
+
212
+ def test_non_ignored_file_still_caught_by_custom_rule(tmp_path, monkeypatch):
213
+ monkeypatch.chdir(tmp_path)
214
+ (tmp_path / ".secretshield-rules.toml").write_text(
215
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
216
+ )
217
+ (tmp_path / ".secretshieldignore").write_text("ignored/\n")
218
+ (tmp_path / "ignored").mkdir()
219
+ (tmp_path / "ignored" / "secret.py").write_text(f'x = "{FAKE_MATCH}"\n')
220
+ (tmp_path / "not_ignored.py").write_text(f'x = "{FAKE_MATCH}"\n')
221
+
222
+ exit_code = main(["scan", "."])
223
+ assert exit_code == 1
224
+
225
+
226
+ # --- Redaction / no-leak safety ----------------------------------------------
227
+
228
+
229
+ def test_custom_rule_value_never_printed_in_scan_output(tmp_path, monkeypatch, capsys):
230
+ monkeypatch.chdir(tmp_path)
231
+ (tmp_path / ".secretshield-rules.toml").write_text(
232
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
233
+ )
234
+ (tmp_path / "test.py").write_text(f'x = "{FAKE_MATCH}"\n')
235
+
236
+ main(["scan", "."])
237
+ out = capsys.readouterr().out
238
+ assert FAKE_MATCH not in out
239
+
240
+
241
+ def test_custom_rule_value_never_in_json_output(tmp_path, monkeypatch, capsys):
242
+ monkeypatch.chdir(tmp_path)
243
+ (tmp_path / ".secretshield-rules.toml").write_text(
244
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
245
+ )
246
+ (tmp_path / "test.py").write_text(f'x = "{FAKE_MATCH}"\n')
247
+
248
+ main(["scan", ".", "--json"])
249
+ out = capsys.readouterr().out
250
+ assert FAKE_MATCH not in out
251
+
252
+
253
+ # --- --fix integration -------------------------------------------------------
254
+
255
+
256
+ def test_fix_moves_safe_custom_rule_match_to_env(tmp_path, monkeypatch):
257
+ monkeypatch.chdir(tmp_path)
258
+ (tmp_path / ".secretshield-rules.toml").write_text(
259
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
260
+ )
261
+ app = tmp_path / "app.py"
262
+ app.write_text(f'CUSTOM_KEY = "{FAKE_MATCH}"\n')
263
+
264
+ import secretshield.autofix.fixer as fixer_mod
265
+
266
+ monkeypatch.setattr(fixer_mod, "is_interactive", lambda: True)
267
+ answers = iter(["y", ""])
268
+ monkeypatch.setattr("builtins.input", lambda prompt="": next(answers))
269
+
270
+ exit_code = main(["scan", ".", "--fix"])
271
+ assert exit_code == 0
272
+ assert FAKE_MATCH not in app.read_text()
273
+ assert FAKE_MATCH in (tmp_path / ".env").read_text()
274
+
275
+
276
+ def test_fix_leaves_ambiguous_custom_rule_match_untouched(tmp_path, monkeypatch):
277
+ monkeypatch.chdir(tmp_path)
278
+ (tmp_path / ".secretshield-rules.toml").write_text(
279
+ '[[rules]]\nname = "My API Key"\npattern = "MYAPP_[A-Za-z0-9]{32}"\n'
280
+ )
281
+ app = tmp_path / "app.py"
282
+ original = f'headers = {{"X-Key": "{FAKE_MATCH}"}}\n'
283
+ app.write_text(original)
284
+
285
+ import secretshield.autofix.fixer as fixer_mod
286
+
287
+ monkeypatch.setattr(fixer_mod, "is_interactive", lambda: True)
288
+ monkeypatch.setattr("builtins.input", lambda prompt="": (_ for _ in ()).throw(AssertionError))
289
+
290
+ main(["scan", ".", "--fix"])
291
+ assert app.read_text() == original
292
+
293
+
294
+ # --- Compatibility ------------------------------------------------------------
295
+
296
+
297
+ def test_built_in_only_scan_unaffected_by_absent_rules_file(tmp_path, monkeypatch, capsys):
298
+ monkeypatch.chdir(tmp_path)
299
+ aws_key = "AKIAABCDEFGHIJKLMNOP"
300
+ (tmp_path / "test.py").write_text(f'x = "{aws_key}"\n')
301
+ main(["scan", ".", "--json"])
302
+ out = capsys.readouterr().out
303
+ payload = json.loads(out)
304
+ assert payload["secrets_found"] == 1
305
+ assert payload["matches"][0]["kind"] == "aws_access_key_id"
File without changes
File without changes