secretshield 0.4.2__tar.gz → 0.5.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 (46) hide show
  1. {secretshield-0.4.2/secretshield.egg-info → secretshield-0.5.0}/PKG-INFO +18 -3
  2. {secretshield-0.4.2 → secretshield-0.5.0}/README.md +17 -2
  3. {secretshield-0.4.2 → secretshield-0.5.0}/pyproject.toml +1 -1
  4. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/__init__.py +1 -1
  5. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/cli.py +3 -0
  6. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/guardian.py +24 -25
  7. secretshield-0.5.0/secretshield/ignorefile.py +42 -0
  8. {secretshield-0.4.2 → secretshield-0.5.0/secretshield.egg-info}/PKG-INFO +18 -3
  9. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield.egg-info/SOURCES.txt +3 -0
  10. secretshield-0.5.0/tests/test_ignorefile.py +44 -0
  11. secretshield-0.5.0/tests/test_ignorefile_cli_integration.py +62 -0
  12. {secretshield-0.4.2 → secretshield-0.5.0}/tests/test_stdout.py +56 -0
  13. {secretshield-0.4.2 → secretshield-0.5.0}/LICENSE +0 -0
  14. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/autofix/__init__.py +0 -0
  15. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/autofix/env.py +0 -0
  16. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/autofix/fixer.py +0 -0
  17. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/autofix/gitignore.py +0 -0
  18. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/autofix/python.py +0 -0
  19. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/baseline.py +0 -0
  20. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/config.py +0 -0
  21. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/detector.py +0 -0
  22. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/git/__init__.py +0 -0
  23. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/git/hooks.py +0 -0
  24. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/github/__init__.py +0 -0
  25. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/github/actions.py +0 -0
  26. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/notifications.py +0 -0
  27. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/patterns.py +0 -0
  28. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/project_config.py +0 -0
  29. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield/redactor.py +0 -0
  30. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield.egg-info/dependency_links.txt +0 -0
  31. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield.egg-info/entry_points.txt +0 -0
  32. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield.egg-info/requires.txt +0 -0
  33. {secretshield-0.4.2 → secretshield-0.5.0}/secretshield.egg-info/top_level.txt +0 -0
  34. {secretshield-0.4.2 → secretshield-0.5.0}/setup.cfg +0 -0
  35. {secretshield-0.4.2 → secretshield-0.5.0}/tests/test_autofix.py +0 -0
  36. {secretshield-0.4.2 → secretshield-0.5.0}/tests/test_baseline.py +0 -0
  37. {secretshield-0.4.2 → secretshield-0.5.0}/tests/test_detector.py +0 -0
  38. {secretshield-0.4.2 → secretshield-0.5.0}/tests/test_git_hooks.py +0 -0
  39. {secretshield-0.4.2 → secretshield-0.5.0}/tests/test_github_action.py +0 -0
  40. {secretshield-0.4.2 → secretshield-0.5.0}/tests/test_logging.py +0 -0
  41. {secretshield-0.4.2 → secretshield-0.5.0}/tests/test_project_config.py +0 -0
  42. {secretshield-0.4.2 → secretshield-0.5.0}/tests/test_redactor.py +0 -0
  43. {secretshield-0.4.2 → secretshield-0.5.0}/tests/test_scan.py +0 -0
  44. {secretshield-0.4.2 → secretshield-0.5.0}/tests/test_ss_alias_and_creator.py +0 -0
  45. {secretshield-0.4.2 → secretshield-0.5.0}/tests/test_v030_cli_integration.py +0 -0
  46. {secretshield-0.4.2 → secretshield-0.5.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.4.2
3
+ Version: 0.5.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
@@ -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)
@@ -272,6 +272,20 @@ format = "text"
272
272
 
273
273
  CLI flags always override the file.
274
274
 
275
+ Prefer a plain `.gitignore`-style file instead of editing TOML? Drop a
276
+ `.secretshieldignore` in the project root — one glob pattern per line,
277
+ `#` comments and blank lines ignored, same idea as `.gitignore`:
278
+
279
+ ```text
280
+ # .secretshieldignore
281
+ vendor/
282
+ *.min.js
283
+ tests/fixtures/
284
+ ```
285
+
286
+ Both `secretshield.toml`'s `[scan.ignore]` and `.secretshieldignore`
287
+ can be used together — their patterns combine.
288
+
275
289
  ## Runtime configuration
276
290
 
277
291
  ```python
@@ -304,7 +318,7 @@ secretshield/
304
318
  ├── patterns.py, detector.py, redactor.py # detection & redaction engine
305
319
  ├── guardian.py # stdout/stderr + logging protection
306
320
  ├── config.py, notifications.py # runtime settings & safe warnings
307
- ├── project_config.py, baseline.py # secretshield.toml, --baseline
321
+ ├── project_config.py, baseline.py, ignorefile.py # secretshield.toml, --baseline, .secretshieldignore
308
322
  ├── cli.py # command-line interface
309
323
  ├── autofix/ # interactive scan --fix
310
324
  ├── git/ # pre-commit hook
@@ -357,3 +371,4 @@ If you find this project useful, consider [supporting its development through Gi
357
371
  ## License
358
372
 
359
373
  MIT — see [LICENSE](LICENSE).
374
+
@@ -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)
@@ -246,6 +246,20 @@ format = "text"
246
246
 
247
247
  CLI flags always override the file.
248
248
 
249
+ Prefer a plain `.gitignore`-style file instead of editing TOML? Drop a
250
+ `.secretshieldignore` in the project root — one glob pattern per line,
251
+ `#` comments and blank lines ignored, same idea as `.gitignore`:
252
+
253
+ ```text
254
+ # .secretshieldignore
255
+ vendor/
256
+ *.min.js
257
+ tests/fixtures/
258
+ ```
259
+
260
+ Both `secretshield.toml`'s `[scan.ignore]` and `.secretshieldignore`
261
+ can be used together — their patterns combine.
262
+
249
263
  ## Runtime configuration
250
264
 
251
265
  ```python
@@ -278,7 +292,7 @@ secretshield/
278
292
  ├── patterns.py, detector.py, redactor.py # detection & redaction engine
279
293
  ├── guardian.py # stdout/stderr + logging protection
280
294
  ├── config.py, notifications.py # runtime settings & safe warnings
281
- ├── project_config.py, baseline.py # secretshield.toml, --baseline
295
+ ├── project_config.py, baseline.py, ignorefile.py # secretshield.toml, --baseline, .secretshieldignore
282
296
  ├── cli.py # command-line interface
283
297
  ├── autofix/ # interactive scan --fix
284
298
  ├── git/ # pre-commit hook
@@ -331,3 +345,4 @@ If you find this project useful, consider [supporting its development through Gi
331
345
  ## License
332
346
 
333
347
  MIT — see [LICENSE](LICENSE).
348
+
@@ -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.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.4.2"
26
+ __version__ = "0.5.0"
27
27
 
28
28
  __all__ = [
29
29
  "__version__",
@@ -423,6 +423,9 @@ def _run_scan(args: argparse.Namespace) -> int:
423
423
  # A trailing "/" means "this directory and everything in it" --
424
424
  # translate that into a glob so it matches nested files too.
425
425
  exclude_patterns.append(raw if not raw.endswith("/") else raw + "*")
426
+ from .ignorefile import load_ignore_patterns
427
+
428
+ exclude_patterns.extend(load_ignore_patterns(Path.cwd()))
426
429
 
427
430
  json_output = bool(args.json) or (project_cfg.output_format == "json")
428
431
 
@@ -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.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
@@ -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)
@@ -272,6 +272,20 @@ format = "text"
272
272
 
273
273
  CLI flags always override the file.
274
274
 
275
+ Prefer a plain `.gitignore`-style file instead of editing TOML? Drop a
276
+ `.secretshieldignore` in the project root — one glob pattern per line,
277
+ `#` comments and blank lines ignored, same idea as `.gitignore`:
278
+
279
+ ```text
280
+ # .secretshieldignore
281
+ vendor/
282
+ *.min.js
283
+ tests/fixtures/
284
+ ```
285
+
286
+ Both `secretshield.toml`'s `[scan.ignore]` and `.secretshieldignore`
287
+ can be used together — their patterns combine.
288
+
275
289
  ## Runtime configuration
276
290
 
277
291
  ```python
@@ -304,7 +318,7 @@ secretshield/
304
318
  ├── patterns.py, detector.py, redactor.py # detection & redaction engine
305
319
  ├── guardian.py # stdout/stderr + logging protection
306
320
  ├── config.py, notifications.py # runtime settings & safe warnings
307
- ├── project_config.py, baseline.py # secretshield.toml, --baseline
321
+ ├── project_config.py, baseline.py, ignorefile.py # secretshield.toml, --baseline, .secretshieldignore
308
322
  ├── cli.py # command-line interface
309
323
  ├── autofix/ # interactive scan --fix
310
324
  ├── git/ # pre-commit hook
@@ -357,3 +371,4 @@ If you find this project useful, consider [supporting its development through Gi
357
371
  ## License
358
372
 
359
373
  MIT — see [LICENSE](LICENSE).
374
+
@@ -7,6 +7,7 @@ secretshield/cli.py
7
7
  secretshield/config.py
8
8
  secretshield/detector.py
9
9
  secretshield/guardian.py
10
+ secretshield/ignorefile.py
10
11
  secretshield/notifications.py
11
12
  secretshield/patterns.py
12
13
  secretshield/project_config.py
@@ -31,6 +32,8 @@ tests/test_baseline.py
31
32
  tests/test_detector.py
32
33
  tests/test_git_hooks.py
33
34
  tests/test_github_action.py
35
+ tests/test_ignorefile.py
36
+ tests/test_ignorefile_cli_integration.py
34
37
  tests/test_logging.py
35
38
  tests/test_project_config.py
36
39
  tests/test_redactor.py
@@ -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