secretshield 0.13.0__tar.gz → 0.13.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 (74) hide show
  1. {secretshield-0.13.0/secretshield.egg-info → secretshield-0.13.2}/PKG-INFO +29 -2
  2. {secretshield-0.13.0 → secretshield-0.13.2}/README.md +28 -1
  3. {secretshield-0.13.0 → secretshield-0.13.2}/pyproject.toml +1 -1
  4. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/__init__.py +1 -1
  5. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/cli.py +294 -20
  6. secretshield-0.13.2/secretshield/history.py +198 -0
  7. secretshield-0.13.2/secretshield/inline_ignore.py +95 -0
  8. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/rotation/workflow.py +25 -12
  9. {secretshield-0.13.0 → secretshield-0.13.2/secretshield.egg-info}/PKG-INFO +29 -2
  10. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield.egg-info/SOURCES.txt +9 -0
  11. secretshield-0.13.2/tests/test_frozen_binary.py +442 -0
  12. secretshield-0.13.2/tests/test_installer_test_helpers.py +283 -0
  13. secretshield-0.13.2/tests/test_packaging.py +824 -0
  14. secretshield-0.13.2/tests/test_standalone_entry.py +311 -0
  15. secretshield-0.13.2/tests/test_v0131_inline_ignore.py +506 -0
  16. secretshield-0.13.2/tests/test_v0132_encoding_and_run_hint.py +301 -0
  17. secretshield-0.13.2/tests/test_v0132_history.py +536 -0
  18. {secretshield-0.13.0 → secretshield-0.13.2}/LICENSE +0 -0
  19. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/autofix/__init__.py +0 -0
  20. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/autofix/env.py +0 -0
  21. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/autofix/fixer.py +0 -0
  22. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/autofix/gitignore.py +0 -0
  23. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/autofix/javascript.py +0 -0
  24. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/autofix/python.py +0 -0
  25. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/baseline.py +0 -0
  26. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/color.py +0 -0
  27. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/config.py +0 -0
  28. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/custom_rules.py +0 -0
  29. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/detector.py +0 -0
  30. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/git/__init__.py +0 -0
  31. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/git/hooks.py +0 -0
  32. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/github/__init__.py +0 -0
  33. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/github/actions.py +0 -0
  34. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/guardian.py +0 -0
  35. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/ignorefile.py +0 -0
  36. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/notifications.py +0 -0
  37. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/patterns.py +0 -0
  38. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/project_config.py +0 -0
  39. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/redactor.py +0 -0
  40. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/rotation/__init__.py +0 -0
  41. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/rotation/providers.py +0 -0
  42. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/sarif.py +0 -0
  43. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield.egg-info/dependency_links.txt +0 -0
  44. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield.egg-info/entry_points.txt +0 -0
  45. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield.egg-info/requires.txt +0 -0
  46. {secretshield-0.13.0 → secretshield-0.13.2}/secretshield.egg-info/top_level.txt +0 -0
  47. {secretshield-0.13.0 → secretshield-0.13.2}/setup.cfg +0 -0
  48. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_autofix.py +0 -0
  49. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_baseline.py +0 -0
  50. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_color.py +0 -0
  51. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_color_cli_integration.py +0 -0
  52. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_custom_rules.py +0 -0
  53. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_custom_rules_cli_integration.py +0 -0
  54. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_detector.py +0 -0
  55. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_git_hooks.py +0 -0
  56. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_github_action.py +0 -0
  57. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_ignorefile.py +0 -0
  58. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_ignorefile_cli_integration.py +0 -0
  59. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_javascript_autofix.py +0 -0
  60. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_logging.py +0 -0
  61. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_project_config.py +0 -0
  62. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_redactor.py +0 -0
  63. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_sarif.py +0 -0
  64. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_sarif_cli_integration.py +0 -0
  65. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_scan.py +0 -0
  66. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_ss_alias_and_creator.py +0 -0
  67. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_stdout.py +0 -0
  68. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_v0110_url_entropy_and_init.py +0 -0
  69. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_v0120_rotation.py +0 -0
  70. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_v0130_detection.py +0 -0
  71. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_v030_cli_integration.py +0 -0
  72. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_v040_cli_integration.py +0 -0
  73. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_v080_self_detection_fix.py +0 -0
  74. {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_v090_cli_additions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: secretshield
3
- Version: 0.13.0
3
+ Version: 0.13.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
@@ -32,7 +32,7 @@ Dynamic: license-file
32
32
 
33
33
  [![PyPI version](https://img.shields.io/pypi/v/secretshield?color=yellow)](https://pypi.org/project/secretshield/)
34
34
  [![Python versions](https://img.shields.io/pypi/pyversions/secretshield)](https://pypi.org/project/secretshield/)
35
- [![PyPI Downloads](https://static.pepy.tech/personalized-badge/secretshield?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=RED&left_text=downloads%2Fmonth)](https://pepy.tech/projects/secretshield)
35
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/secretshield?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=ORANGE&left_text=downloads)](https://pepy.tech/projects/secretshield)
36
36
  [![License: MIT](https://img.shields.io/pypi/l/secretshield)](LICENSE)
37
37
 
38
38
  [Install](#install) · [Demo](#demo) · [Try it](#try-it) · [Homepage 🌐](https://therealsecretshield.freebuff.app/) · [Full usage guide →](https://github.com/Sam3360/secretshield/blob/main/USAGE.md)
@@ -56,6 +56,22 @@ gets that far.
56
56
 
57
57
  ## Install
58
58
 
59
+ **Windows (recommended):** download `secretshield-windows-x64-setup.exe` from the
60
+ [latest release](https://github.com/Sam3360/secretshield/releases/latest), run it, then open a
61
+ **new** terminal and try:
62
+
63
+ ```powershell
64
+ ss --version
65
+ ss scan .
66
+ ```
67
+
68
+ No Python needed, no administrator rights. A portable zip is also available, and Linux and macOS
69
+ get a single-file binary. The builds are not code-signed, so Windows or macOS may show a warning:
70
+ see the [installation guide](https://github.com/Sam3360/secretshield/blob/main/USAGE.md#installation)
71
+ for checksums, upgrading, uninstalling and troubleshooting.
72
+
73
+ **Python developers:**
74
+
59
75
  ```bash
60
76
  pip install secretshield
61
77
  ```
@@ -157,3 +173,14 @@ MIT — see [LICENSE](LICENSE).
157
173
  Visit the official SecretShield homepage for an overview of the project, features, releases, and more:
158
174
 
159
175
  **🌐 [therealsecretshield.freebuff.app](https://therealsecretshield.freebuff.app/)**
176
+
177
+
178
+ ## 🏆 Recognition
179
+
180
+ SecretShield was recognised as **Best Developer Tool** in [Waycode's Founding 100](https://www.waycode.in/articles/founding-100-eight-projects-different-ideas-one-community-of-builders).
181
+
182
+ Waycode's Founding 100 brought together builders and their projects, recognising eight projects across different categories.
183
+
184
+ This recognition highlights SecretShield's work in open-source security tooling and local-first secret detection.
185
+
186
+ **[Read the announcement →](https://www.waycode.in/articles/founding-100-eight-projects-different-ideas-one-community-of-builders)**
@@ -6,7 +6,7 @@
6
6
 
7
7
  [![PyPI version](https://img.shields.io/pypi/v/secretshield?color=yellow)](https://pypi.org/project/secretshield/)
8
8
  [![Python versions](https://img.shields.io/pypi/pyversions/secretshield)](https://pypi.org/project/secretshield/)
9
- [![PyPI Downloads](https://static.pepy.tech/personalized-badge/secretshield?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=RED&left_text=downloads%2Fmonth)](https://pepy.tech/projects/secretshield)
9
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/secretshield?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=ORANGE&left_text=downloads)](https://pepy.tech/projects/secretshield)
10
10
  [![License: MIT](https://img.shields.io/pypi/l/secretshield)](LICENSE)
11
11
 
12
12
  [Install](#install) · [Demo](#demo) · [Try it](#try-it) · [Homepage 🌐](https://therealsecretshield.freebuff.app/) · [Full usage guide →](https://github.com/Sam3360/secretshield/blob/main/USAGE.md)
@@ -30,6 +30,22 @@ gets that far.
30
30
 
31
31
  ## Install
32
32
 
33
+ **Windows (recommended):** download `secretshield-windows-x64-setup.exe` from the
34
+ [latest release](https://github.com/Sam3360/secretshield/releases/latest), run it, then open a
35
+ **new** terminal and try:
36
+
37
+ ```powershell
38
+ ss --version
39
+ ss scan .
40
+ ```
41
+
42
+ No Python needed, no administrator rights. A portable zip is also available, and Linux and macOS
43
+ get a single-file binary. The builds are not code-signed, so Windows or macOS may show a warning:
44
+ see the [installation guide](https://github.com/Sam3360/secretshield/blob/main/USAGE.md#installation)
45
+ for checksums, upgrading, uninstalling and troubleshooting.
46
+
47
+ **Python developers:**
48
+
33
49
  ```bash
34
50
  pip install secretshield
35
51
  ```
@@ -131,3 +147,14 @@ MIT — see [LICENSE](LICENSE).
131
147
  Visit the official SecretShield homepage for an overview of the project, features, releases, and more:
132
148
 
133
149
  **🌐 [therealsecretshield.freebuff.app](https://therealsecretshield.freebuff.app/)**
150
+
151
+
152
+ ## 🏆 Recognition
153
+
154
+ SecretShield was recognised as **Best Developer Tool** in [Waycode's Founding 100](https://www.waycode.in/articles/founding-100-eight-projects-different-ideas-one-community-of-builders).
155
+
156
+ Waycode's Founding 100 brought together builders and their projects, recognising eight projects across different categories.
157
+
158
+ This recognition highlights SecretShield's work in open-source security tooling and local-first secret detection.
159
+
160
+ **[Read the announcement →](https://www.waycode.in/articles/founding-100-eight-projects-different-ideas-one-community-of-builders)**
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "secretshield"
7
- version = "0.13.0"
7
+ version = "0.13.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.13.0"
26
+ __version__ = "0.13.2"
27
27
 
28
28
  __all__ = [
29
29
  "__version__",
@@ -20,8 +20,10 @@ the file happens to be ``.py``, ``.js``, or ``.env``.
20
20
  from __future__ import annotations
21
21
 
22
22
  import argparse
23
+ import codecs
23
24
  import fnmatch
24
25
  import json
26
+ import os
25
27
  import runpy
26
28
  import sys
27
29
  from pathlib import Path
@@ -29,6 +31,7 @@ from pathlib import Path
29
31
  from . import __version__, disable, enable, is_enabled
30
32
  from .color import green, hyperlink, red, supports_color
31
33
  from .detector import detect
34
+ from .inline_ignore import filter_inline_ignored
32
35
 
33
36
  # --- File type support -----------------------------------------------------
34
37
 
@@ -272,6 +275,17 @@ def _cmd_run(args: argparse.Namespace) -> int:
272
275
  return int(exc.code) if isinstance(exc.code, int) else 1
273
276
  except Exception as exc: # noqa: BLE001 - surface script errors to the user
274
277
  print(f"secretshield: script raised an exception: {exc}", file=sys.stderr)
278
+ if isinstance(exc, ImportError) and getattr(sys, "frozen", False):
279
+ # Only the standalone (PyInstaller) build: it bundles SecretShield's
280
+ # own runtime, not a full Python environment, so a script's other
281
+ # imports usually can't be found there.
282
+ print(
283
+ "secretshield: note: this is the standalone SecretShield build. It bundles "
284
+ "only the Python modules SecretShield itself needs, so `run` cannot import "
285
+ "other packages or standard-library modules. Use the pip-installed version "
286
+ "(pip install secretshield) to run scripts that need them.",
287
+ file=sys.stderr,
288
+ )
275
289
  return 1
276
290
  return 0
277
291
 
@@ -458,6 +472,27 @@ def _iter_scan_sources(args: argparse.Namespace, target: Path, include_patterns:
458
472
  yield display_path, _read_text_safe(file_path)
459
473
 
460
474
 
475
+ def _history_sources(history, include_patterns: list, exclude_patterns: list, no_ignore: bool):
476
+ """Adapt a HistoryScan to the (display_path, text, first_line, commit) shape."""
477
+ from .history import HistoryError
478
+
479
+ def accept(relpath: str) -> bool:
480
+ path = Path(relpath)
481
+ # Same rules as the working-tree walk in _iter_scan_files.
482
+ if not no_ignore and any(part in _SKIP_DIR_NAMES for part in path.parts):
483
+ return False
484
+ if not no_ignore and _is_secretshield_meta_file(path.name):
485
+ return False
486
+ return _should_scan_file(path, include_patterns, exclude_patterns)
487
+
488
+ try:
489
+ for relpath, text, first_line, commit in history.iter_sources(accept):
490
+ yield str(Path(relpath)), text, first_line, commit
491
+ except HistoryError as exc:
492
+ print(f"secretshield: error: {exc}", file=sys.stderr)
493
+ history.failed = True
494
+
495
+
461
496
  def _run_scan(args: argparse.Namespace) -> int:
462
497
  from .baseline import (
463
498
  baseline_exists,
@@ -551,9 +586,38 @@ def _run_scan(args: argparse.Namespace) -> int:
551
586
  # that came from secretshield.toml.
552
587
  json_output = sarif_output = False
553
588
 
589
+ history_mode = bool(getattr(args, "history", False))
590
+ max_commits = getattr(args, "max_commits", None)
591
+ if max_commits is not None and not history_mode:
592
+ print("secretshield: error: --max-commits requires --history", file=sys.stderr)
593
+ return 2
594
+ if history_mode:
595
+ clash = None
596
+ if getattr(args, "staged", False):
597
+ clash = "--staged"
598
+ elif getattr(args, "diff", None):
599
+ clash = "--diff"
600
+ elif getattr(args, "fix", False):
601
+ clash = "--fix (a secret in a past commit can't be fixed in place)"
602
+ elif getattr(args, "baseline", False):
603
+ clash = "--baseline"
604
+ elif cli_format == "sarif":
605
+ clash = "--format sarif (history findings have no file location in the working tree)"
606
+ if clash:
607
+ print(f"secretshield: error: --history cannot be combined with {clash}", file=sys.stderr)
608
+ return 2
609
+ sarif_output = False # a sarif default from secretshield.toml doesn't apply here
610
+
554
611
  target = Path(args.path)
555
612
  using_explicit_source = bool(getattr(args, "staged", False) or getattr(args, "diff", None))
556
613
 
614
+ if history_mode:
615
+ from .git import is_git_repo
616
+
617
+ if not is_git_repo():
618
+ print("secretshield: error: not inside a Git repository", file=sys.stderr)
619
+ return 2
620
+
557
621
  if using_explicit_source:
558
622
  from .git import is_git_repo
559
623
 
@@ -561,7 +625,7 @@ def _run_scan(args: argparse.Namespace) -> int:
561
625
  print("secretshield: error: not inside a Git repository", file=sys.stderr)
562
626
  return 2
563
627
 
564
- if not using_explicit_source and not target.exists():
628
+ if not using_explicit_source and not history_mode and not target.exists():
565
629
  print(f"secretshield: error: no such path: {target}", file=sys.stderr)
566
630
  return 1
567
631
 
@@ -573,9 +637,36 @@ def _run_scan(args: argparse.Namespace) -> int:
573
637
  # endLine, endColumn). Keeps the tuple shape above (used by baseline
574
638
  # handling) unchanged and costs nothing for text/json scans.
575
639
  sarif_regions: dict[int, tuple[int, int, int, int]] = {}
576
- show_progress = _progress_supported() and not (json_output or sarif_output)
577
-
578
- for display_path, text in _iter_scan_sources(args, target, include_patterns, exclude_patterns):
640
+ show_progress = _progress_supported() and not (json_output or sarif_output or history_mode)
641
+ honor_inline_ignore = not getattr(args, "no_inline_ignore", False)
642
+ inline_ignored = 0
643
+
644
+ # --history: the same loop below runs over each historical hunk instead of
645
+ # each file, so every detector and filter behaves exactly as in a normal scan.
646
+ history = None
647
+ history_info: dict[int, str] = {} # id(match) -> full commit hash
648
+ also_in: dict[int, int] = {} # id(match) -> other commits containing it
649
+ if history_mode:
650
+ from .history import HistoryScan
651
+
652
+ pathspec = None if str(target) in (".", "") else str(target)
653
+ history = HistoryScan(Path.cwd(), pathspec=pathspec, max_commits=max_commits)
654
+ sources = _history_sources(history, include_patterns, exclude_patterns, args.no_ignore)
655
+ else:
656
+ sources = _iter_scan_sources(args, target, include_patterns, exclude_patterns)
657
+
658
+ def make_finding(file: str, line: int, match) -> dict:
659
+ entry = {"file": file, "line": line, "kind": match.kind}
660
+ commit = history_info.get(id(match))
661
+ if commit:
662
+ entry["commit"] = commit[:8]
663
+ if id(match) in also_in:
664
+ entry["also_in_commits"] = also_in[id(match)]
665
+ return entry
666
+
667
+ for source in sources:
668
+ display_path, text = source[0], source[1]
669
+ line_base = source[2] if len(source) > 2 else 1
579
670
  files_scanned += 1
580
671
  _progress_update(show_progress, files_scanned)
581
672
 
@@ -585,11 +676,18 @@ def _run_scan(args: argparse.Namespace) -> int:
585
676
  m for m in scan_text_with_rules(text, custom_rules) if not _is_placeholder(m.value)
586
677
  ]
587
678
  file_matches = file_matches + custom_matches
679
+ if honor_inline_ignore and file_matches:
680
+ # Applied before everything downstream (baseline, fix, rotate,
681
+ # JSON/SARIF), so a suppressed finding is simply never seen.
682
+ file_matches, suppressed = filter_inline_ignored(text, file_matches)
683
+ inline_ignored += suppressed
588
684
  if file_matches:
589
685
  matches_by_file[display_path] = file_matches
590
686
  for match in file_matches:
591
- line_no = _line_number_for_offset(text, match.start)
592
- all_findings.append({"file": display_path, "line": line_no, "kind": match.kind})
687
+ line_no = _line_number_for_offset(text, match.start) + line_base - 1
688
+ if history is not None:
689
+ history_info[id(match)] = source[3]
690
+ all_findings.append(make_finding(display_path, line_no, match))
593
691
  matches_with_context.append((display_path, line_no, match))
594
692
  if sarif_output:
595
693
  from .sarif import compute_region
@@ -598,6 +696,21 @@ def _run_scan(args: argparse.Namespace) -> int:
598
696
 
599
697
  _progress_clear(show_progress)
600
698
 
699
+ if history is not None:
700
+ if history.failed:
701
+ return 2
702
+ from .history import dedupe_oldest
703
+
704
+ matches_with_context, also_in_found = dedupe_oldest(
705
+ matches_with_context, history_info, history.rank
706
+ )
707
+ also_in.update(also_in_found)
708
+ matches_with_context.sort(key=lambda item: (item[0], item[1], item[2].kind))
709
+ matches_by_file = {}
710
+ for file, _line, m in matches_with_context:
711
+ matches_by_file.setdefault(file, []).append(m)
712
+ all_findings = [make_finding(*item) for item in matches_with_context]
713
+
601
714
  project_root = Path.cwd()
602
715
 
603
716
  # --baseline: write a fresh baseline capturing every current finding,
@@ -614,6 +727,11 @@ def _run_scan(args: argparse.Namespace) -> int:
614
727
  ]
615
728
  path = write_baseline(project_root, baseline_findings)
616
729
  print(f"\u2713 Baseline written to {path} ({len(baseline_findings)} finding(s))")
730
+ if inline_ignored:
731
+ print(
732
+ f"\u2713 {inline_ignored} finding(s) suppressed by inline ignore "
733
+ "comments (not added to the baseline)"
734
+ )
617
735
  return 0
618
736
 
619
737
  ignored_by_baseline = 0
@@ -633,9 +751,7 @@ def _run_scan(args: argparse.Namespace) -> int:
633
751
  new_matches_by_file.setdefault(file, []).append(m)
634
752
  matches_by_file = new_matches_by_file
635
753
  matches_with_context = kept_context
636
- all_findings = [
637
- {"file": file, "line": line, "kind": m.kind} for file, line, m in matches_with_context
638
- ]
754
+ all_findings = [make_finding(file, line, m) for file, line, m in matches_with_context]
639
755
 
640
756
  if getattr(args, "rotate", False):
641
757
  from .rotation import run_rotation
@@ -646,12 +762,19 @@ def _run_scan(args: argparse.Namespace) -> int:
646
762
  tree_args = argparse.Namespace(staged=False, diff=None, no_ignore=args.no_ignore)
647
763
  return _iter_scan_sources(tree_args, Path.cwd(), [], exclude_patterns)
648
764
 
765
+ rotate_findings = matches_with_context
766
+ if history is not None:
767
+ rotate_findings = [
768
+ (f"{file} @ {history_info[id(m)][:8]}", line, m)
769
+ for file, line, m in matches_with_context
770
+ ]
649
771
  rotation = run_rotation(
650
772
  project_root,
651
- matches_with_context,
773
+ rotate_findings,
652
774
  entropy_threshold=entropy_threshold,
653
775
  custom_rules=custom_rules,
654
776
  iter_sources=_working_tree_sources,
777
+ allow_autofix=history is None,
655
778
  )
656
779
  return rotation.exit_code
657
780
 
@@ -677,13 +800,24 @@ def _run_scan(args: argparse.Namespace) -> int:
677
800
  )
678
801
 
679
802
  if json_output:
680
- payload = {
681
- "files_scanned": files_scanned,
682
- "matches": all_findings,
683
- "secrets_found": len(all_findings),
684
- }
803
+ if history is not None:
804
+ payload = {
805
+ "commits_scanned": history.commits_scanned,
806
+ "matches": all_findings,
807
+ "secrets_found": len(all_findings),
808
+ }
809
+ if history.max_commits_reached:
810
+ payload["max_commits_reached"] = True
811
+ else:
812
+ payload = {
813
+ "files_scanned": files_scanned,
814
+ "matches": all_findings,
815
+ "secrets_found": len(all_findings),
816
+ }
685
817
  if ignored_by_baseline:
686
818
  payload["ignored_by_baseline"] = ignored_by_baseline
819
+ if inline_ignored:
820
+ payload["inline_ignored"] = inline_ignored
687
821
  report = json.dumps(payload, indent=2)
688
822
  if output_file is not None:
689
823
  written = _write_report(output_file, report + "\n", "JSON")
@@ -701,7 +835,12 @@ def _run_scan(args: argparse.Namespace) -> int:
701
835
  label, category = _kind_display(finding["kind"])
702
836
  print(f"{red(chr(0x2717), use_color)} {finding['file']}:{finding['line']}")
703
837
  print(f" Potential secret: {label}")
704
- print(f" Type: {category}\n")
838
+ print(f" Type: {category}")
839
+ if "commit" in finding:
840
+ more = finding.get("also_in_commits")
841
+ suffix = f" (also in {more} other commit(s))" if more else ""
842
+ print(f" Commit: {finding['commit']}{suffix}")
843
+ print()
705
844
 
706
845
  if ignored_by_baseline:
707
846
  print(
@@ -709,11 +848,32 @@ def _run_scan(args: argparse.Namespace) -> int:
709
848
  f"{ignored_by_baseline} existing finding(s) ignored by baseline"
710
849
  )
711
850
 
851
+ if inline_ignored:
852
+ print(
853
+ f"{green(chr(0x2713), use_color)} "
854
+ f"{inline_ignored} finding(s) suppressed by inline ignore comments"
855
+ )
856
+
857
+ scanned_label = (
858
+ _plural(history.commits_scanned, "commit") + " scanned"
859
+ if history is not None
860
+ else f"{files_scanned} files scanned"
861
+ )
862
+ if history is not None and history.max_commits_reached:
863
+ print(
864
+ f"{green(chr(0x2713), use_color)} Stopped after {max_commits} commit(s) "
865
+ "(--max-commits); older history was not scanned"
866
+ )
712
867
  if all_findings:
713
- print(f"{green(chr(0x2713), use_color)} {files_scanned} files scanned")
868
+ print(f"{green(chr(0x2713), use_color)} {scanned_label}")
714
869
  print(f"{red(chr(0x2717), use_color)} {len(all_findings)} potential secret(s) found")
870
+ if history is not None:
871
+ print(
872
+ "\nRemoving a secret from history does not make it safe: revoke it with its "
873
+ "provider.\nRun `secretshield scan --history --rotate` to be guided through that."
874
+ )
715
875
  else:
716
- print(f"{green(chr(0x2713), use_color)} {files_scanned} files scanned")
876
+ print(f"{green(chr(0x2713), use_color)} {scanned_label}")
717
877
  print(f"{green(chr(0x2713), use_color)} No potential secrets found")
718
878
 
719
879
  exit_code = 1 if all_findings else 0
@@ -797,6 +957,8 @@ def _run_scan_staged(args: argparse.Namespace) -> int:
797
957
  print("SecretShield: scanning staged files...\n")
798
958
 
799
959
  findings: list[dict] = []
960
+ honor_inline_ignore = not getattr(args, "no_inline_ignore", False)
961
+ inline_ignored = 0
800
962
  for relpath in get_staged_files():
801
963
  path = Path(relpath)
802
964
  if _is_secretshield_meta_file(path.name):
@@ -810,9 +972,11 @@ def _run_scan_staged(args: argparse.Namespace) -> int:
810
972
  matches = detect(content, entropy_threshold=args.entropy_threshold)
811
973
  except Exception:
812
974
  continue
975
+ matches = [m for m in matches if not _is_placeholder(m.value)]
976
+ if honor_inline_ignore and matches:
977
+ matches, suppressed = filter_inline_ignored(content, matches)
978
+ inline_ignored += suppressed
813
979
  for match in matches:
814
- if _is_placeholder(match.value):
815
- continue
816
980
  label, _category = _kind_display(match.kind)
817
981
  findings.append(
818
982
  {
@@ -824,6 +988,12 @@ def _run_scan_staged(args: argparse.Namespace) -> int:
824
988
 
825
989
  use_color = supports_color(sys.stdout)
826
990
 
991
+ if inline_ignored:
992
+ print(
993
+ f"{green(chr(0x2713), use_color)} "
994
+ f"{inline_ignored} finding(s) suppressed by inline ignore comments\n"
995
+ )
996
+
827
997
  if not findings:
828
998
  print(f"{green(chr(0x2713), use_color)} No secrets detected.")
829
999
  return 0
@@ -1339,6 +1509,31 @@ def build_parser() -> argparse.ArgumentParser:
1339
1509
  "__pycache__, .venv, venv, dist, build, coverage, etc.)."
1340
1510
  ),
1341
1511
  )
1512
+ scan_parser.add_argument(
1513
+ "--no-inline-ignore",
1514
+ action="store_true",
1515
+ help=(
1516
+ "Report findings even on lines marked `secretshield:ignore` or "
1517
+ "`secretshield:ignore-next-line`. Use it to audit what inline "
1518
+ "ignore comments are hiding."
1519
+ ),
1520
+ )
1521
+ scan_parser.add_argument(
1522
+ "--history",
1523
+ action="store_true",
1524
+ help=(
1525
+ "Scan every commit in the repository's Git history (all refs) for "
1526
+ "secrets that were ever added, including ones since deleted from "
1527
+ "the working tree. Reports the commit that introduced each one. "
1528
+ "Local and read-only."
1529
+ ),
1530
+ )
1531
+ scan_parser.add_argument(
1532
+ "--max-commits",
1533
+ type=_positive_int,
1534
+ metavar="N",
1535
+ help="With --history, scan only the N most recent commits.",
1536
+ )
1342
1537
  scan_parser.add_argument(
1343
1538
  "--fix",
1344
1539
  action="store_true",
@@ -1404,6 +1599,11 @@ def build_parser() -> argparse.ArgumentParser:
1404
1599
  default=4.2,
1405
1600
  help="Shannon entropy threshold for generic secret detection (default: 4.2).",
1406
1601
  )
1602
+ scan_staged_parser.add_argument(
1603
+ "--no-inline-ignore",
1604
+ action="store_true",
1605
+ help="Report findings even on lines marked `secretshield:ignore`.",
1606
+ )
1407
1607
  scan_staged_parser.set_defaults(func=_cmd_scan_staged)
1408
1608
 
1409
1609
  install_hook_parser = subparsers.add_parser(
@@ -1478,7 +1678,81 @@ def build_parser() -> argparse.ArgumentParser:
1478
1678
  return parser
1479
1679
 
1480
1680
 
1681
+ def _positive_int(value: str) -> int:
1682
+ try:
1683
+ number = int(value)
1684
+ except ValueError:
1685
+ raise argparse.ArgumentTypeError(f"{value!r} is not a whole number") from None
1686
+ if number < 1:
1687
+ raise argparse.ArgumentTypeError("must be 1 or more")
1688
+ return number
1689
+
1690
+
1691
+ # The marks this CLI prints plus a non-ASCII file-name sample. A stream whose
1692
+ # encoding and error handler can't encode all of them would crash on a clean
1693
+ # scan (U+2713/U+2717 exist in no legacy Windows code page).
1694
+ _OUTPUT_PROBE = "\u2713\u2717\u5bc6\u043a"
1695
+
1696
+
1697
+ def _explicit_io_error_handler() -> bool:
1698
+ """True if PYTHONIOENCODING names a handler (``encoding:handler``).
1699
+
1700
+ That is the one case where the user asked for a specific handler, even a
1701
+ strict one; anywhere else the handler is just whatever default the
1702
+ interpreter picked.
1703
+ """
1704
+ return bool(os.environ.get("PYTHONIOENCODING", "").partition(":")[2].strip())
1705
+
1706
+
1707
+ def _stream_would_crash_on_unicode(stream) -> bool:
1708
+ """Would printing this CLI's symbols raise UnicodeEncodeError on `stream`?
1709
+
1710
+ Decided by actually encoding a sample with the stream's own encoding and
1711
+ error handler, not by the handler's name: ``strict`` raises, and so does
1712
+ ``surrogateescape`` (a common default, including in embedded/frozen
1713
+ interpreters) for any ordinary character the encoding lacks, while a UTF-8
1714
+ stream of any handler is fine.
1715
+ """
1716
+ encoding = getattr(stream, "encoding", None)
1717
+ if not encoding:
1718
+ return True # unknown: be safe
1719
+ try:
1720
+ codecs.lookup(encoding).incrementalencoder(getattr(stream, "errors", None) or "strict").encode(
1721
+ _OUTPUT_PROBE
1722
+ )
1723
+ except UnicodeEncodeError:
1724
+ return True
1725
+ except (LookupError, TypeError, ValueError):
1726
+ return True
1727
+ return False
1728
+
1729
+
1730
+ def _tolerate_unencodable_output() -> None:
1731
+ """Never crash just because the output encoding can't represent a symbol.
1732
+
1733
+ Redirected or piped output on Windows uses a legacy code page (cp1252,
1734
+ cp437, ...) that can't encode the check/cross marks this CLI prints, and
1735
+ a plain `print()` then raises UnicodeEncodeError, even for a clean scan.
1736
+ For a stream that would crash, switch only the *error handler* to
1737
+ "replace" (Python's documented behavior: the character becomes `?`).
1738
+
1739
+ Left alone: a stream that can already encode the output (UTF-8, whatever
1740
+ its handler), and any stream when the user named a handler themselves with
1741
+ ``PYTHONIOENCODING=encoding:handler``.
1742
+ """
1743
+ if _explicit_io_error_handler():
1744
+ return
1745
+ for stream in (sys.stdout, sys.stderr):
1746
+ reconfigure = getattr(stream, "reconfigure", None)
1747
+ if callable(reconfigure) and _stream_would_crash_on_unicode(stream):
1748
+ try:
1749
+ reconfigure(errors="replace")
1750
+ except (ValueError, OSError):
1751
+ pass # a stream that can't be reconfigured keeps its behavior
1752
+
1753
+
1481
1754
  def main(argv: list[str] | None = None) -> int:
1755
+ _tolerate_unencodable_output()
1482
1756
  parser = build_parser()
1483
1757
  args = parser.parse_args(argv)
1484
1758