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.
- {secretshield-0.6.1/secretshield.egg-info → secretshield-0.7.0}/PKG-INFO +16 -1
- {secretshield-0.6.1 → secretshield-0.7.0}/README.md +15 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/pyproject.toml +1 -1
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/__init__.py +1 -1
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/autofix/fixer.py +13 -1
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/cli.py +165 -1
- secretshield-0.7.0/secretshield/custom_rules.py +163 -0
- {secretshield-0.6.1 → secretshield-0.7.0/secretshield.egg-info}/PKG-INFO +16 -1
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield.egg-info/SOURCES.txt +3 -0
- secretshield-0.7.0/tests/test_custom_rules.py +240 -0
- secretshield-0.7.0/tests/test_custom_rules_cli_integration.py +305 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/LICENSE +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/autofix/__init__.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/autofix/env.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/autofix/gitignore.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/autofix/javascript.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/autofix/python.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/baseline.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/color.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/config.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/detector.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/git/__init__.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/git/hooks.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/github/__init__.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/github/actions.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/guardian.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/ignorefile.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/notifications.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/patterns.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/project_config.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield/redactor.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield.egg-info/dependency_links.txt +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield.egg-info/entry_points.txt +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield.egg-info/requires.txt +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/secretshield.egg-info/top_level.txt +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/setup.cfg +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_autofix.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_baseline.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_color.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_color_cli_integration.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_detector.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_git_hooks.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_github_action.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_ignorefile.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_ignorefile_cli_integration.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_javascript_autofix.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_logging.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_project_config.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_redactor.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_scan.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_ss_alias_and_creator.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_stdout.py +0 -0
- {secretshield-0.6.1 → secretshield-0.7.0}/tests/test_v030_cli_integration.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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(
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|