secretshield 0.4.2__tar.gz → 0.5.2__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 (49) hide show
  1. {secretshield-0.4.2/secretshield.egg-info → secretshield-0.5.2}/PKG-INFO +25 -3
  2. {secretshield-0.4.2 → secretshield-0.5.2}/README.md +24 -2
  3. {secretshield-0.4.2 → secretshield-0.5.2}/pyproject.toml +1 -1
  4. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/__init__.py +1 -1
  5. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/cli.py +30 -19
  6. secretshield-0.5.2/secretshield/color.py +42 -0
  7. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/guardian.py +24 -25
  8. secretshield-0.5.2/secretshield/ignorefile.py +42 -0
  9. {secretshield-0.4.2 → secretshield-0.5.2/secretshield.egg-info}/PKG-INFO +25 -3
  10. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield.egg-info/SOURCES.txt +6 -0
  11. secretshield-0.5.2/tests/test_color.py +77 -0
  12. secretshield-0.5.2/tests/test_color_cli_integration.py +69 -0
  13. secretshield-0.5.2/tests/test_ignorefile.py +44 -0
  14. secretshield-0.5.2/tests/test_ignorefile_cli_integration.py +62 -0
  15. {secretshield-0.4.2 → secretshield-0.5.2}/tests/test_stdout.py +56 -0
  16. {secretshield-0.4.2 → secretshield-0.5.2}/LICENSE +0 -0
  17. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/autofix/__init__.py +0 -0
  18. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/autofix/env.py +0 -0
  19. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/autofix/fixer.py +0 -0
  20. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/autofix/gitignore.py +0 -0
  21. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/autofix/python.py +0 -0
  22. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/baseline.py +0 -0
  23. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/config.py +0 -0
  24. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/detector.py +0 -0
  25. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/git/__init__.py +0 -0
  26. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/git/hooks.py +0 -0
  27. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/github/__init__.py +0 -0
  28. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/github/actions.py +0 -0
  29. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/notifications.py +0 -0
  30. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/patterns.py +0 -0
  31. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/project_config.py +0 -0
  32. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield/redactor.py +0 -0
  33. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield.egg-info/dependency_links.txt +0 -0
  34. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield.egg-info/entry_points.txt +0 -0
  35. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield.egg-info/requires.txt +0 -0
  36. {secretshield-0.4.2 → secretshield-0.5.2}/secretshield.egg-info/top_level.txt +0 -0
  37. {secretshield-0.4.2 → secretshield-0.5.2}/setup.cfg +0 -0
  38. {secretshield-0.4.2 → secretshield-0.5.2}/tests/test_autofix.py +0 -0
  39. {secretshield-0.4.2 → secretshield-0.5.2}/tests/test_baseline.py +0 -0
  40. {secretshield-0.4.2 → secretshield-0.5.2}/tests/test_detector.py +0 -0
  41. {secretshield-0.4.2 → secretshield-0.5.2}/tests/test_git_hooks.py +0 -0
  42. {secretshield-0.4.2 → secretshield-0.5.2}/tests/test_github_action.py +0 -0
  43. {secretshield-0.4.2 → secretshield-0.5.2}/tests/test_logging.py +0 -0
  44. {secretshield-0.4.2 → secretshield-0.5.2}/tests/test_project_config.py +0 -0
  45. {secretshield-0.4.2 → secretshield-0.5.2}/tests/test_redactor.py +0 -0
  46. {secretshield-0.4.2 → secretshield-0.5.2}/tests/test_scan.py +0 -0
  47. {secretshield-0.4.2 → secretshield-0.5.2}/tests/test_ss_alias_and_creator.py +0 -0
  48. {secretshield-0.4.2 → secretshield-0.5.2}/tests/test_v030_cli_integration.py +0 -0
  49. {secretshield-0.4.2 → secretshield-0.5.2}/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.4.2
3
+ Version: 0.5.2
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
@@ -33,7 +33,7 @@ Dynamic: license-file
33
33
 
34
34
  [![PyPI version](https://img.shields.io/pypi/v/secretshield?color=blue)](https://pypi.org/project/secretshield/)
35
35
  [![Python versions](https://img.shields.io/pypi/pyversions/secretshield)](https://pypi.org/project/secretshield/)
36
- [![PyPI downloads](https://img.shields.io/pypi/dm/secretshield)](https://pypi.org/project/secretshield/)
36
+ [![PyPI Downloads](https://api.pepy.tech/badge/secretshield/month)](https://pypi.org/project/secretshield/)
37
37
  [![License: MIT](https://img.shields.io/pypi/l/secretshield)](LICENSE)
38
38
 
39
39
  [Install](#installation) · [Demo](#demo) · [Quick start](#quick-start) · [Features](#what-secretshield-does) · [Docs below](#cli-reference)
@@ -185,6 +185,12 @@ redact("aws_key=AKIAABCDEFGHIJKLMNOP")
185
185
 
186
186
  *(Every command below also works via the shorter `ss` alias — `ss scan .` is identical to `secretshield scan .`.)*
187
187
 
188
+ Output is colored (red `✗` / green `✓`) automatically when running in a
189
+ real terminal, and plain everywhere else — piped output, CI logs, and
190
+ `--json` are never colored, so nothing downstream ever has to deal with
191
+ stray escape codes. Force it either way with the standard `NO_COLOR=1`
192
+ / `FORCE_COLOR=1` environment variables.
193
+
188
194
  ### `secretshield init` — set a project up in one step
189
195
 
190
196
  ```bash
@@ -272,6 +278,20 @@ format = "text"
272
278
 
273
279
  CLI flags always override the file.
274
280
 
281
+ Prefer a plain `.gitignore`-style file instead of editing TOML? Drop a
282
+ `.secretshieldignore` in the project root — one glob pattern per line,
283
+ `#` comments and blank lines ignored, same idea as `.gitignore`:
284
+
285
+ ```text
286
+ # .secretshieldignore
287
+ vendor/
288
+ *.min.js
289
+ tests/fixtures/
290
+ ```
291
+
292
+ Both `secretshield.toml`'s `[scan.ignore]` and `.secretshieldignore`
293
+ can be used together — their patterns combine.
294
+
275
295
  ## Runtime configuration
276
296
 
277
297
  ```python
@@ -304,7 +324,8 @@ secretshield/
304
324
  ├── patterns.py, detector.py, redactor.py # detection & redaction engine
305
325
  ├── guardian.py # stdout/stderr + logging protection
306
326
  ├── config.py, notifications.py # runtime settings & safe warnings
307
- ├── project_config.py, baseline.py # secretshield.toml, --baseline
327
+ ├── project_config.py, baseline.py, ignorefile.py # secretshield.toml, --baseline, .secretshieldignore
328
+ ├── color.py # dependency-free ANSI colors, TTY-aware
308
329
  ├── cli.py # command-line interface
309
330
  ├── autofix/ # interactive scan --fix
310
331
  ├── git/ # pre-commit hook
@@ -357,3 +378,4 @@ If you find this project useful, consider [supporting its development through Gi
357
378
  ## License
358
379
 
359
380
  MIT — see [LICENSE](LICENSE).
381
+
@@ -7,7 +7,7 @@
7
7
 
8
8
  [![PyPI version](https://img.shields.io/pypi/v/secretshield?color=blue)](https://pypi.org/project/secretshield/)
9
9
  [![Python versions](https://img.shields.io/pypi/pyversions/secretshield)](https://pypi.org/project/secretshield/)
10
- [![PyPI downloads](https://img.shields.io/pypi/dm/secretshield)](https://pypi.org/project/secretshield/)
10
+ [![PyPI Downloads](https://api.pepy.tech/badge/secretshield/month)](https://pypi.org/project/secretshield/)
11
11
  [![License: MIT](https://img.shields.io/pypi/l/secretshield)](LICENSE)
12
12
 
13
13
  [Install](#installation) · [Demo](#demo) · [Quick start](#quick-start) · [Features](#what-secretshield-does) · [Docs below](#cli-reference)
@@ -159,6 +159,12 @@ redact("aws_key=AKIAABCDEFGHIJKLMNOP")
159
159
 
160
160
  *(Every command below also works via the shorter `ss` alias — `ss scan .` is identical to `secretshield scan .`.)*
161
161
 
162
+ Output is colored (red `✗` / green `✓`) automatically when running in a
163
+ real terminal, and plain everywhere else — piped output, CI logs, and
164
+ `--json` are never colored, so nothing downstream ever has to deal with
165
+ stray escape codes. Force it either way with the standard `NO_COLOR=1`
166
+ / `FORCE_COLOR=1` environment variables.
167
+
162
168
  ### `secretshield init` — set a project up in one step
163
169
 
164
170
  ```bash
@@ -246,6 +252,20 @@ format = "text"
246
252
 
247
253
  CLI flags always override the file.
248
254
 
255
+ Prefer a plain `.gitignore`-style file instead of editing TOML? Drop a
256
+ `.secretshieldignore` in the project root — one glob pattern per line,
257
+ `#` comments and blank lines ignored, same idea as `.gitignore`:
258
+
259
+ ```text
260
+ # .secretshieldignore
261
+ vendor/
262
+ *.min.js
263
+ tests/fixtures/
264
+ ```
265
+
266
+ Both `secretshield.toml`'s `[scan.ignore]` and `.secretshieldignore`
267
+ can be used together — their patterns combine.
268
+
249
269
  ## Runtime configuration
250
270
 
251
271
  ```python
@@ -278,7 +298,8 @@ secretshield/
278
298
  ├── patterns.py, detector.py, redactor.py # detection & redaction engine
279
299
  ├── guardian.py # stdout/stderr + logging protection
280
300
  ├── config.py, notifications.py # runtime settings & safe warnings
281
- ├── project_config.py, baseline.py # secretshield.toml, --baseline
301
+ ├── project_config.py, baseline.py, ignorefile.py # secretshield.toml, --baseline, .secretshieldignore
302
+ ├── color.py # dependency-free ANSI colors, TTY-aware
282
303
  ├── cli.py # command-line interface
283
304
  ├── autofix/ # interactive scan --fix
284
305
  ├── git/ # pre-commit hook
@@ -331,3 +352,4 @@ If you find this project useful, consider [supporting its development through Gi
331
352
  ## License
332
353
 
333
354
  MIT — see [LICENSE](LICENSE).
355
+
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "secretshield"
7
- version = "0.4.2"
7
+ version = "0.5.2"
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.4.2"
26
+ __version__ = "0.5.2"
27
27
 
28
28
  __all__ = [
29
29
  "__version__",
@@ -27,6 +27,7 @@ import sys
27
27
  from pathlib import Path
28
28
 
29
29
  from . import __version__, disable, enable, is_enabled
30
+ from .color import green, red, supports_color
30
31
  from .detector import detect
31
32
 
32
33
  # --- File type support -----------------------------------------------------
@@ -423,6 +424,9 @@ def _run_scan(args: argparse.Namespace) -> int:
423
424
  # A trailing "/" means "this directory and everything in it" --
424
425
  # translate that into a glob so it matches nested files too.
425
426
  exclude_patterns.append(raw if not raw.endswith("/") else raw + "*")
427
+ from .ignorefile import load_ignore_patterns
428
+
429
+ exclude_patterns.extend(load_ignore_patterns(Path.cwd()))
426
430
 
427
431
  json_output = bool(args.json) or (project_cfg.output_format == "json")
428
432
 
@@ -513,23 +517,27 @@ def _run_scan(args: argparse.Namespace) -> int:
513
517
  print(json.dumps(payload, indent=2))
514
518
  return 1 if all_findings else 0
515
519
 
520
+ use_color = supports_color(sys.stdout)
516
521
  print("SecretShield scan\n")
517
522
 
518
523
  for finding in all_findings:
519
524
  label, category = _kind_display(finding["kind"])
520
- print(f"\u2717 {finding['file']}:{finding['line']}")
525
+ print(f"{red(chr(0x2717), use_color)} {finding['file']}:{finding['line']}")
521
526
  print(f" Potential secret: {label}")
522
527
  print(f" Type: {category}\n")
523
528
 
524
529
  if ignored_by_baseline:
525
- print(f"\u2713 {ignored_by_baseline} existing finding(s) ignored by baseline")
530
+ print(
531
+ f"{green(chr(0x2713), use_color)} "
532
+ f"{ignored_by_baseline} existing finding(s) ignored by baseline"
533
+ )
526
534
 
527
535
  if all_findings:
528
- print(f"\u2713 {files_scanned} files scanned")
529
- print(f"\u2717 {len(all_findings)} potential secret(s) found")
536
+ print(f"{green(chr(0x2713), use_color)} {files_scanned} files scanned")
537
+ print(f"{red(chr(0x2717), use_color)} {len(all_findings)} potential secret(s) found")
530
538
  else:
531
- print(f"\u2713 {files_scanned} files scanned")
532
- print("\u2713 No potential secrets found")
539
+ print(f"{green(chr(0x2713), use_color)} {files_scanned} files scanned")
540
+ print(f"{green(chr(0x2713), use_color)} No potential secrets found")
533
541
 
534
542
  exit_code = 1 if all_findings else 0
535
543
  print(f"\nExit code: {exit_code}")
@@ -580,12 +588,14 @@ def _run_scan_staged(args: argparse.Namespace) -> int:
580
588
  }
581
589
  )
582
590
 
591
+ use_color = supports_color(sys.stdout)
592
+
583
593
  if not findings:
584
- print("\u2713 No secrets detected.")
594
+ print(f"{green(chr(0x2713), use_color)} No secrets detected.")
585
595
  return 0
586
596
 
587
597
  for finding in findings:
588
- print("\u2717 Potential secret detected\n")
598
+ print(f"{red(chr(0x2717), use_color)} Potential secret detected\n")
589
599
  print(f"File: {finding['file']}")
590
600
  print(f"Line: {finding['line']}")
591
601
  print(f"Type: {finding['label']}\n")
@@ -605,7 +615,7 @@ def _cmd_install_hook(args: argparse.Namespace) -> int:
605
615
  if result.status == "error":
606
616
  print(f"secretshield: error: {result.message}", file=sys.stderr)
607
617
  return 1
608
- print(f"\u2713 {result.message}")
618
+ print(f"{green(chr(0x2713), supports_color(sys.stdout))} {result.message}")
609
619
  return 0
610
620
 
611
621
 
@@ -616,7 +626,7 @@ def _cmd_uninstall_hook(args: argparse.Namespace) -> int:
616
626
  if result.status == "error":
617
627
  print(f"secretshield: error: {result.message}", file=sys.stderr)
618
628
  return 1
619
- print(f"\u2713 {result.message}")
629
+ print(f"{green(chr(0x2713), supports_color(sys.stdout))} {result.message}")
620
630
  return 0
621
631
 
622
632
 
@@ -633,7 +643,7 @@ def _cmd_github_action(args: argparse.Namespace) -> int:
633
643
  file=sys.stderr,
634
644
  )
635
645
  return 1
636
- print(f"\u2713 Created {path}")
646
+ print(f"{green(chr(0x2713), supports_color(sys.stdout))} Created {path}")
637
647
  return 0
638
648
 
639
649
 
@@ -670,10 +680,11 @@ def _cmd_init(args: argparse.Namespace) -> int:
670
680
  )
671
681
  in_git_repo = is_git_repo()
672
682
 
683
+ use_color = supports_color(sys.stdout)
673
684
  print("SecretShield project setup\n")
674
- print(f"{'\u2713' if is_python_project else '\u2717'} "
685
+ print(f"{green(chr(0x2713), use_color) if is_python_project else red(chr(0x2717), use_color)} "
675
686
  f"{'Detected Python project' if is_python_project else 'No Python project markers found'}")
676
- print(f"{'\u2713' if in_git_repo else '\u2717'} "
687
+ print(f"{green(chr(0x2713), use_color) if in_git_repo else red(chr(0x2717), use_color)} "
677
688
  f"{'Detected Git repository' if in_git_repo else 'Not a Git repository'}")
678
689
  print()
679
690
 
@@ -696,28 +707,28 @@ def _cmd_init(args: argparse.Namespace) -> int:
696
707
  if want_config:
697
708
  existing = find_config_file(project_root)
698
709
  if existing is not None:
699
- print(f"\u2713 secretshield.toml already exists, leaving it unchanged")
710
+ print(f"{green(chr(0x2713), use_color)} secretshield.toml already exists, leaving it unchanged")
700
711
  else:
701
712
  config_path = project_root / "secretshield.toml"
702
713
  config_path.write_text(render_default_toml(), encoding="utf-8")
703
- print(f"\u2713 Created {config_path.name}")
714
+ print(f"{green(chr(0x2713), use_color)} Created {config_path.name}")
704
715
  created.append(config_path.name)
705
716
 
706
717
  if want_hook:
707
718
  result = install_hook()
708
719
  if result.status == "error":
709
- print(f"\u2717 {result.message}")
720
+ print(f"{red(chr(0x2717), use_color)} {result.message}")
710
721
  else:
711
- print(f"\u2713 {result.message}")
722
+ print(f"{green(chr(0x2713), use_color)} {result.message}")
712
723
  created.append("Git pre-commit hook")
713
724
 
714
725
  if want_actions:
715
726
  written, path = generate_workflow(project_root)
716
727
  if written:
717
- print(f"\u2713 Created {path.relative_to(project_root)}")
728
+ print(f"{green(chr(0x2713), use_color)} Created {path.relative_to(project_root)}")
718
729
  created.append(str(path.relative_to(project_root)))
719
730
  else:
720
- print(f"\u2713 {path.relative_to(project_root)} already exists, leaving it unchanged")
731
+ print(f"{green(chr(0x2713), use_color)} {path.relative_to(project_root)} already exists, leaving it unchanged")
721
732
 
722
733
  print("\nSecretShield is ready.")
723
734
  return 0
@@ -0,0 +1,42 @@
1
+ """
2
+ Minimal, dependency-free ANSI color support for CLI output.
3
+
4
+ Colors are only applied when the target stream is a real interactive
5
+ terminal -- never when output is piped, redirected, going into CI logs,
6
+ or serialized as `--json`. Respects the informal-but-widely-followed
7
+ `NO_COLOR` (disable) and `FORCE_COLOR` (force on) environment variable
8
+ conventions.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ from typing import TextIO
16
+
17
+ _RED = "\033[31m"
18
+ _GREEN = "\033[32m"
19
+ _RESET = "\033[0m"
20
+
21
+
22
+ def supports_color(stream: TextIO | None = None) -> bool:
23
+ """Whether ANSI colors should be used when writing to `stream`
24
+ (default: sys.stdout)."""
25
+ if os.environ.get("NO_COLOR") is not None:
26
+ return False
27
+ if os.environ.get("FORCE_COLOR") is not None:
28
+ return True
29
+
30
+ target = stream if stream is not None else sys.stdout
31
+ try:
32
+ return bool(target.isatty())
33
+ except Exception:
34
+ return False
35
+
36
+
37
+ def red(text: str, enabled: bool) -> str:
38
+ return f"{_RED}{text}{_RESET}" if enabled else text
39
+
40
+
41
+ def green(text: str, enabled: bool) -> str:
42
+ return f"{_GREEN}{text}{_RESET}" if enabled else text
@@ -17,13 +17,12 @@ from .redactor import redact
17
17
  # enable() calls don't stack wrappers on top of each other.
18
18
  _WRAPPED_MARKER = "_secretshield_wrapped"
19
19
 
20
- # Module-level state tracking whether protection is currently active and
21
- # what the original (unwrapped) streams / logging factory were, so
22
- # disable() can cleanly restore them.
20
+ # Module-level state tracking whether protection has been explicitly
21
+ # enabled (used by is_enabled()). The actual wrap/unwrap decision on
22
+ # each enable()/disable() call is made by inspecting sys.stdout/
23
+ # sys.stderr directly (see below) rather than trusting this flag alone.
23
24
  _state: dict[str, Any] = {
24
25
  "active": False,
25
- "orig_stdout": None,
26
- "orig_stderr": None,
27
26
  }
28
27
 
29
28
  # Guard against re-entrant redaction triggered by secretshield's own
@@ -235,13 +234,16 @@ def enable() -> None:
235
234
  Enable secretshield protection for ``sys.stdout``, ``sys.stderr``, and
236
235
  the standard ``logging`` module. Safe to call multiple times; repeated
237
236
  calls do not create duplicate wrappers.
238
- """
239
- if _state["active"]:
240
- return
241
-
242
- _state["orig_stdout"] = sys.stdout
243
- _state["orig_stderr"] = sys.stderr
244
237
 
238
+ Always (re)wraps whatever ``sys.stdout``/``sys.stderr`` currently
239
+ are, rather than relying solely on a single global "already active"
240
+ flag to skip the work. That matters if something else reassigns
241
+ those streams between calls -- a test framework's output capture,
242
+ a user's own redirection, etc. -- so a later ``enable()`` correctly
243
+ protects the stream actually in use *now*, instead of silently
244
+ doing nothing because protection was already on for some earlier,
245
+ now-replaced stream.
246
+ """
245
247
  sys.stdout = _wrap_stream(sys.stdout)
246
248
  sys.stderr = _wrap_stream(sys.stderr)
247
249
 
@@ -254,29 +256,26 @@ def disable() -> None:
254
256
  """
255
257
  Disable secretshield protection, restoring the original ``sys.stdout``
256
258
  and ``sys.stderr`` streams and removing the logging filter.
257
- """
258
- if not _state["active"]:
259
- return
260
259
 
261
- # Flush any buffered partial-line content (e.g. output with no
262
- # trailing newline yet) before swapping the streams out, so it isn't
263
- # silently lost.
264
- for stream in (sys.stdout, sys.stderr):
260
+ Unwraps whatever ``sys.stdout``/``sys.stderr`` currently are (only
261
+ if they're currently a guarded stream), rather than restoring a
262
+ reference captured back whenever ``enable()`` first happened to run
263
+ -- which could be stale if the streams were swapped out since then.
264
+ """
265
+ for name in ("stdout", "stderr"):
266
+ stream = getattr(sys, name)
265
267
  if isinstance(stream, _GuardedStream):
268
+ # Flush any buffered partial-line content (e.g. output with
269
+ # no trailing newline yet) before unwrapping, so it isn't
270
+ # silently lost.
266
271
  try:
267
272
  stream.flush()
268
273
  except Exception:
269
274
  pass
270
-
271
- if _state["orig_stdout"] is not None:
272
- sys.stdout = _state["orig_stdout"]
273
- if _state["orig_stderr"] is not None:
274
- sys.stderr = _state["orig_stderr"]
275
+ setattr(sys, name, stream._wrapped)
275
276
 
276
277
  _remove_logging_protection()
277
278
 
278
- _state["orig_stdout"] = None
279
- _state["orig_stderr"] = None
280
279
  _state["active"] = False
281
280
 
282
281
 
@@ -0,0 +1,42 @@
1
+ """
2
+ `.secretshieldignore` -- a familiar, `.gitignore`-style companion to
3
+ `secretshield.toml`'s `[scan.ignore]` section, for anyone who'd rather
4
+ drop in a plain ignore file than edit TOML.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ IGNORE_FILENAME = ".secretshieldignore"
12
+
13
+
14
+ def ignore_file_path(root: Path) -> Path:
15
+ return root / IGNORE_FILENAME
16
+
17
+
18
+ def load_ignore_patterns(root: Path | None = None) -> list[str]:
19
+ """
20
+ Read `.secretshieldignore` from `root` (default: current directory)
21
+ if present. One glob pattern per line; blank lines and lines
22
+ starting with `#` are skipped, same as `.gitignore`. A trailing
23
+ `/` means "this directory and everything under it," matching
24
+ `secretshield.toml`'s `[scan.ignore].paths` convention -- both
25
+ ultimately feed the same exclude-pattern matching used by
26
+ `--exclude`.
27
+ """
28
+ path = ignore_file_path(root or Path.cwd())
29
+ if not path.exists():
30
+ return []
31
+ try:
32
+ lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
33
+ except Exception:
34
+ return []
35
+
36
+ patterns: list[str] = []
37
+ for raw_line in lines:
38
+ line = raw_line.strip()
39
+ if not line or line.startswith("#"):
40
+ continue
41
+ patterns.append(line if not line.endswith("/") else line + "*")
42
+ return patterns
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secretshield
3
- Version: 0.4.2
3
+ Version: 0.5.2
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
@@ -33,7 +33,7 @@ Dynamic: license-file
33
33
 
34
34
  [![PyPI version](https://img.shields.io/pypi/v/secretshield?color=blue)](https://pypi.org/project/secretshield/)
35
35
  [![Python versions](https://img.shields.io/pypi/pyversions/secretshield)](https://pypi.org/project/secretshield/)
36
- [![PyPI downloads](https://img.shields.io/pypi/dm/secretshield)](https://pypi.org/project/secretshield/)
36
+ [![PyPI Downloads](https://api.pepy.tech/badge/secretshield/month)](https://pypi.org/project/secretshield/)
37
37
  [![License: MIT](https://img.shields.io/pypi/l/secretshield)](LICENSE)
38
38
 
39
39
  [Install](#installation) · [Demo](#demo) · [Quick start](#quick-start) · [Features](#what-secretshield-does) · [Docs below](#cli-reference)
@@ -185,6 +185,12 @@ redact("aws_key=AKIAABCDEFGHIJKLMNOP")
185
185
 
186
186
  *(Every command below also works via the shorter `ss` alias — `ss scan .` is identical to `secretshield scan .`.)*
187
187
 
188
+ Output is colored (red `✗` / green `✓`) automatically when running in a
189
+ real terminal, and plain everywhere else — piped output, CI logs, and
190
+ `--json` are never colored, so nothing downstream ever has to deal with
191
+ stray escape codes. Force it either way with the standard `NO_COLOR=1`
192
+ / `FORCE_COLOR=1` environment variables.
193
+
188
194
  ### `secretshield init` — set a project up in one step
189
195
 
190
196
  ```bash
@@ -272,6 +278,20 @@ format = "text"
272
278
 
273
279
  CLI flags always override the file.
274
280
 
281
+ Prefer a plain `.gitignore`-style file instead of editing TOML? Drop a
282
+ `.secretshieldignore` in the project root — one glob pattern per line,
283
+ `#` comments and blank lines ignored, same idea as `.gitignore`:
284
+
285
+ ```text
286
+ # .secretshieldignore
287
+ vendor/
288
+ *.min.js
289
+ tests/fixtures/
290
+ ```
291
+
292
+ Both `secretshield.toml`'s `[scan.ignore]` and `.secretshieldignore`
293
+ can be used together — their patterns combine.
294
+
275
295
  ## Runtime configuration
276
296
 
277
297
  ```python
@@ -304,7 +324,8 @@ secretshield/
304
324
  ├── patterns.py, detector.py, redactor.py # detection & redaction engine
305
325
  ├── guardian.py # stdout/stderr + logging protection
306
326
  ├── config.py, notifications.py # runtime settings & safe warnings
307
- ├── project_config.py, baseline.py # secretshield.toml, --baseline
327
+ ├── project_config.py, baseline.py, ignorefile.py # secretshield.toml, --baseline, .secretshieldignore
328
+ ├── color.py # dependency-free ANSI colors, TTY-aware
308
329
  ├── cli.py # command-line interface
309
330
  ├── autofix/ # interactive scan --fix
310
331
  ├── git/ # pre-commit hook
@@ -357,3 +378,4 @@ If you find this project useful, consider [supporting its development through Gi
357
378
  ## License
358
379
 
359
380
  MIT — see [LICENSE](LICENSE).
381
+
@@ -4,9 +4,11 @@ pyproject.toml
4
4
  secretshield/__init__.py
5
5
  secretshield/baseline.py
6
6
  secretshield/cli.py
7
+ secretshield/color.py
7
8
  secretshield/config.py
8
9
  secretshield/detector.py
9
10
  secretshield/guardian.py
11
+ secretshield/ignorefile.py
10
12
  secretshield/notifications.py
11
13
  secretshield/patterns.py
12
14
  secretshield/project_config.py
@@ -28,9 +30,13 @@ secretshield/github/__init__.py
28
30
  secretshield/github/actions.py
29
31
  tests/test_autofix.py
30
32
  tests/test_baseline.py
33
+ tests/test_color.py
34
+ tests/test_color_cli_integration.py
31
35
  tests/test_detector.py
32
36
  tests/test_git_hooks.py
33
37
  tests/test_github_action.py
38
+ tests/test_ignorefile.py
39
+ tests/test_ignorefile_cli_integration.py
34
40
  tests/test_logging.py
35
41
  tests/test_project_config.py
36
42
  tests/test_redactor.py
@@ -0,0 +1,77 @@
1
+ """Tests for secretshield.color -- dependency-free ANSI color support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+
7
+ from secretshield.color import green, red, supports_color
8
+
9
+
10
+ class _FakeTTY(io.StringIO):
11
+ def isatty(self):
12
+ return True
13
+
14
+
15
+ class _FakeNonTTY(io.StringIO):
16
+ def isatty(self):
17
+ return False
18
+
19
+
20
+ def test_red_wraps_with_ansi_when_enabled():
21
+ result = red("X", True)
22
+ assert result.startswith("\033[31m")
23
+ assert result.endswith("\033[0m")
24
+ assert "X" in result
25
+
26
+
27
+ def test_red_plain_when_disabled():
28
+ assert red("X", False) == "X"
29
+
30
+
31
+ def test_green_wraps_with_ansi_when_enabled():
32
+ result = green("Y", True)
33
+ assert result.startswith("\033[32m")
34
+ assert result.endswith("\033[0m")
35
+
36
+
37
+ def test_green_plain_when_disabled():
38
+ assert green("Y", False) == "Y"
39
+
40
+
41
+ def test_supports_color_true_for_tty(monkeypatch):
42
+ monkeypatch.delenv("NO_COLOR", raising=False)
43
+ monkeypatch.delenv("FORCE_COLOR", raising=False)
44
+ assert supports_color(_FakeTTY()) is True
45
+
46
+
47
+ def test_supports_color_false_for_non_tty(monkeypatch):
48
+ monkeypatch.delenv("NO_COLOR", raising=False)
49
+ monkeypatch.delenv("FORCE_COLOR", raising=False)
50
+ assert supports_color(_FakeNonTTY()) is False
51
+
52
+
53
+ def test_no_color_env_disables_even_for_tty(monkeypatch):
54
+ monkeypatch.setenv("NO_COLOR", "1")
55
+ assert supports_color(_FakeTTY()) is False
56
+
57
+
58
+ def test_force_color_env_enables_even_for_non_tty(monkeypatch):
59
+ monkeypatch.delenv("NO_COLOR", raising=False)
60
+ monkeypatch.setenv("FORCE_COLOR", "1")
61
+ assert supports_color(_FakeNonTTY()) is True
62
+
63
+
64
+ def test_no_color_takes_precedence_over_force_color(monkeypatch):
65
+ monkeypatch.setenv("NO_COLOR", "1")
66
+ monkeypatch.setenv("FORCE_COLOR", "1")
67
+ assert supports_color(_FakeTTY()) is False
68
+
69
+
70
+ def test_supports_color_handles_stream_without_isatty(monkeypatch):
71
+ monkeypatch.delenv("NO_COLOR", raising=False)
72
+ monkeypatch.delenv("FORCE_COLOR", raising=False)
73
+
74
+ class _Weird:
75
+ pass
76
+
77
+ assert supports_color(_Weird()) is False
@@ -0,0 +1,69 @@
1
+ """
2
+ CLI-level integration tests confirming colored output actually applies
3
+ (or doesn't) in `scan`'s real report, based on TTY status.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import io
9
+
10
+ from secretshield.cli import main
11
+
12
+ FAKE_SECRET = "colorIntegrationFakeSecret123456789"
13
+
14
+
15
+ class _FakeTTY(io.StringIO):
16
+ def isatty(self):
17
+ return True
18
+
19
+
20
+ def test_scan_output_has_no_ansi_codes_by_default_under_pytest(tmp_path, monkeypatch, capsys):
21
+ # Under pytest, stdout is never a real TTY -- output must be plain.
22
+ monkeypatch.chdir(tmp_path)
23
+ monkeypatch.delenv("FORCE_COLOR", raising=False)
24
+ (tmp_path / "app.py").write_text(f'API_KEY = "{FAKE_SECRET}"\n')
25
+
26
+ main(["scan", "."])
27
+ out = capsys.readouterr().out
28
+ assert "\033[" not in out
29
+
30
+
31
+ def test_scan_json_output_never_contains_ansi_codes(tmp_path, monkeypatch, capsys):
32
+ monkeypatch.chdir(tmp_path)
33
+ monkeypatch.setenv("FORCE_COLOR", "1") # even if forced on, --json must stay clean
34
+ (tmp_path / "app.py").write_text(f'API_KEY = "{FAKE_SECRET}"\n')
35
+
36
+ main(["scan", ".", "--json"])
37
+ out = capsys.readouterr().out
38
+ assert "\033[" not in out
39
+
40
+
41
+ def test_scan_with_force_color_env_produces_ansi_codes(tmp_path, monkeypatch, capsys):
42
+ monkeypatch.chdir(tmp_path)
43
+ monkeypatch.setenv("FORCE_COLOR", "1")
44
+ (tmp_path / "app.py").write_text(f'API_KEY = "{FAKE_SECRET}"\n')
45
+
46
+ main(["scan", "."])
47
+ out = capsys.readouterr().out
48
+ assert "\033[31m" in out # red for the finding
49
+
50
+
51
+ def test_scan_clean_with_force_color_produces_green(tmp_path, monkeypatch, capsys):
52
+ monkeypatch.chdir(tmp_path)
53
+ monkeypatch.setenv("FORCE_COLOR", "1")
54
+ (tmp_path / "app.py").write_text("print('clean')\n")
55
+
56
+ main(["scan", "."])
57
+ out = capsys.readouterr().out
58
+ assert "\033[32m" in out # green for "No potential secrets found"
59
+
60
+
61
+ def test_no_color_env_suppresses_even_with_force_color_tty(tmp_path, monkeypatch, capsys):
62
+ monkeypatch.chdir(tmp_path)
63
+ monkeypatch.setenv("FORCE_COLOR", "1")
64
+ monkeypatch.setenv("NO_COLOR", "1")
65
+ (tmp_path / "app.py").write_text(f'API_KEY = "{FAKE_SECRET}"\n')
66
+
67
+ main(["scan", "."])
68
+ out = capsys.readouterr().out
69
+ assert "\033[" not in out
@@ -0,0 +1,44 @@
1
+ """Tests for secretshield.ignorefile -- .secretshieldignore support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from secretshield.ignorefile import ignore_file_path, load_ignore_patterns
6
+
7
+
8
+ def test_no_ignore_file_returns_empty_list(tmp_path):
9
+ assert load_ignore_patterns(tmp_path) == []
10
+
11
+
12
+ def test_loads_simple_patterns(tmp_path):
13
+ (tmp_path / ".secretshieldignore").write_text("*.min.js\nvendor/\n")
14
+ patterns = load_ignore_patterns(tmp_path)
15
+ assert "*.min.js" in patterns
16
+
17
+
18
+ def test_trailing_slash_becomes_glob(tmp_path):
19
+ (tmp_path / ".secretshieldignore").write_text("vendor/\n")
20
+ patterns = load_ignore_patterns(tmp_path)
21
+ assert "vendor/*" in patterns
22
+ assert "vendor/" not in patterns
23
+
24
+
25
+ def test_comments_and_blank_lines_skipped(tmp_path):
26
+ (tmp_path / ".secretshieldignore").write_text(
27
+ "# this is a comment\n\nvendor/\n\n# another comment\n*.log\n"
28
+ )
29
+ patterns = load_ignore_patterns(tmp_path)
30
+ assert patterns == ["vendor/*", "*.log"]
31
+
32
+
33
+ def test_ignore_file_path_helper(tmp_path):
34
+ path = ignore_file_path(tmp_path)
35
+ assert path.name == ".secretshieldignore"
36
+ assert path.parent == tmp_path
37
+
38
+
39
+ def test_malformed_file_does_not_crash(tmp_path):
40
+ # Write invalid UTF-8 bytes directly.
41
+ (tmp_path / ".secretshieldignore").write_bytes(b"\xff\xfe\x00garbage")
42
+ # Should not raise -- errors="ignore" handles invalid bytes.
43
+ patterns = load_ignore_patterns(tmp_path)
44
+ assert isinstance(patterns, list)
@@ -0,0 +1,62 @@
1
+ """CLI-level integration tests for .secretshieldignore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from secretshield.cli import main
6
+
7
+ FAKE_SECRET = "ignoreFileIntegrationFakeSecret123456"
8
+
9
+
10
+ def test_scan_respects_secretshieldignore(tmp_path, monkeypatch):
11
+ monkeypatch.chdir(tmp_path)
12
+ (tmp_path / ".secretshieldignore").write_text("vendor/\n")
13
+ (tmp_path / "vendor").mkdir()
14
+ (tmp_path / "vendor" / "lib.py").write_text(f'API_KEY = "{FAKE_SECRET}"\n')
15
+ (tmp_path / "app.py").write_text("print('clean')\n")
16
+
17
+ exit_code = main(["scan", "."])
18
+ assert exit_code == 0
19
+
20
+
21
+ def test_scan_still_catches_non_ignored_secret(tmp_path, monkeypatch):
22
+ monkeypatch.chdir(tmp_path)
23
+ (tmp_path / ".secretshieldignore").write_text("vendor/\n")
24
+ (tmp_path / "vendor").mkdir()
25
+ (tmp_path / "vendor" / "lib.py").write_text('API_KEY = "not-caught-anyway"\n')
26
+ (tmp_path / "app.py").write_text(f'API_KEY = "{FAKE_SECRET}"\n')
27
+
28
+ exit_code = main(["scan", "."])
29
+ assert exit_code == 1
30
+
31
+
32
+ def test_no_ignore_file_behaves_as_before(tmp_path, monkeypatch):
33
+ monkeypatch.chdir(tmp_path)
34
+ (tmp_path / "app.py").write_text(f'API_KEY = "{FAKE_SECRET}"\n')
35
+
36
+ exit_code = main(["scan", "."])
37
+ assert exit_code == 1
38
+
39
+
40
+ def test_secretshieldignore_and_toml_ignore_paths_combine(tmp_path, monkeypatch):
41
+ monkeypatch.chdir(tmp_path)
42
+ (tmp_path / ".secretshieldignore").write_text("vendor/\n")
43
+ (tmp_path / "secretshield.toml").write_text('[scan.ignore]\npaths = ["docs/"]\n')
44
+ (tmp_path / "vendor").mkdir()
45
+ (tmp_path / "docs").mkdir()
46
+ (tmp_path / "vendor" / "a.py").write_text(f'A = "{FAKE_SECRET}"\n')
47
+ (tmp_path / "docs" / "b.py").write_text(f'B = "{FAKE_SECRET}"\n')
48
+ (tmp_path / "app.py").write_text("print('clean')\n")
49
+
50
+ exit_code = main(["scan", "."])
51
+ assert exit_code == 0
52
+
53
+
54
+ def test_explicit_exclude_flag_still_works_alongside_ignorefile(tmp_path, monkeypatch):
55
+ monkeypatch.chdir(tmp_path)
56
+ (tmp_path / ".secretshieldignore").write_text("vendor/\n")
57
+ (tmp_path / "vendor").mkdir()
58
+ (tmp_path / "vendor" / "a.py").write_text(f'A = "{FAKE_SECRET}"\n')
59
+ (tmp_path / "other.py").write_text(f'B = "{FAKE_SECRET}"\n')
60
+
61
+ exit_code = main(["scan", ".", "--exclude", "other.py"])
62
+ assert exit_code == 0
@@ -154,3 +154,59 @@ def test_partial_line_without_newline_flushed_on_flush_call():
154
154
  output = buffer.getvalue()
155
155
  assert "MyDogFluffy99" not in output
156
156
  assert "********" in output
157
+
158
+
159
+ # --- Regression test for a real bug: enable() was a no-op once already
160
+ # "active", even if sys.stdout/sys.stderr had been swapped out from
161
+ # under it (e.g. by a test framework's own output capture, or a user's
162
+ # own redirection). disable()/enable() must always operate on whatever
163
+ # the CURRENT streams are, not a stale reference from the first call.
164
+
165
+ def test_enable_rewraps_a_stream_swapped_in_after_first_enable():
166
+ original_stdout = sys.stdout
167
+ try:
168
+ # Protection is already globally active at this point (it was
169
+ # enabled automatically on package import). Something else now
170
+ # swaps sys.stdout out -- simulating pytest's capsys, or a
171
+ # user redirecting output themselves.
172
+ new_buffer = io.StringIO()
173
+ sys.stdout = new_buffer
174
+
175
+ # Calling enable() again must wrap THIS new stream, not silently
176
+ # no-op just because protection was "already on" for a
177
+ # different, now-replaced stream.
178
+ secretshield.enable()
179
+ assert hasattr(sys.stdout, "_secretshield_wrapped")
180
+
181
+ secret = "sk-regressionTestFakeSecretSwap123456"
182
+ print("API key:", secret)
183
+ sys.stdout.flush()
184
+
185
+ output = new_buffer.getvalue()
186
+ assert secret not in output
187
+ assert "********" in output
188
+ finally:
189
+ secretshield.disable()
190
+ sys.stdout = original_stdout
191
+
192
+
193
+ def test_disable_unwraps_whatever_stream_is_current_not_stale_one():
194
+ original_stdout = sys.stdout
195
+ try:
196
+ buffer_a = io.StringIO()
197
+ sys.stdout = buffer_a
198
+ secretshield.enable()
199
+
200
+ # Swap to a second buffer while still "enabled".
201
+ buffer_b = io.StringIO()
202
+ sys.stdout = buffer_b
203
+ secretshield.enable() # re-wrap the new current stream
204
+ assert sys.stdout is not buffer_b # now wrapped
205
+
206
+ secretshield.disable()
207
+ # Must unwrap back to buffer_b (the current stream), not jump
208
+ # back to buffer_a (a stale reference from the first enable()).
209
+ assert sys.stdout is buffer_b
210
+ finally:
211
+ secretshield.disable()
212
+ sys.stdout = original_stdout
File without changes
File without changes