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.
- {secretshield-0.13.0/secretshield.egg-info → secretshield-0.13.2}/PKG-INFO +29 -2
- {secretshield-0.13.0 → secretshield-0.13.2}/README.md +28 -1
- {secretshield-0.13.0 → secretshield-0.13.2}/pyproject.toml +1 -1
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/__init__.py +1 -1
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/cli.py +294 -20
- secretshield-0.13.2/secretshield/history.py +198 -0
- secretshield-0.13.2/secretshield/inline_ignore.py +95 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/rotation/workflow.py +25 -12
- {secretshield-0.13.0 → secretshield-0.13.2/secretshield.egg-info}/PKG-INFO +29 -2
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield.egg-info/SOURCES.txt +9 -0
- secretshield-0.13.2/tests/test_frozen_binary.py +442 -0
- secretshield-0.13.2/tests/test_installer_test_helpers.py +283 -0
- secretshield-0.13.2/tests/test_packaging.py +824 -0
- secretshield-0.13.2/tests/test_standalone_entry.py +311 -0
- secretshield-0.13.2/tests/test_v0131_inline_ignore.py +506 -0
- secretshield-0.13.2/tests/test_v0132_encoding_and_run_hint.py +301 -0
- secretshield-0.13.2/tests/test_v0132_history.py +536 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/LICENSE +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/autofix/__init__.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/autofix/env.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/autofix/fixer.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/autofix/gitignore.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/autofix/javascript.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/autofix/python.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/baseline.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/color.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/config.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/custom_rules.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/detector.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/git/__init__.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/git/hooks.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/github/__init__.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/github/actions.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/guardian.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/ignorefile.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/notifications.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/patterns.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/project_config.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/redactor.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/rotation/__init__.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/rotation/providers.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield/sarif.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield.egg-info/dependency_links.txt +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield.egg-info/entry_points.txt +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield.egg-info/requires.txt +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/secretshield.egg-info/top_level.txt +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/setup.cfg +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_autofix.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_baseline.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_color.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_color_cli_integration.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_custom_rules.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_custom_rules_cli_integration.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_detector.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_git_hooks.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_github_action.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_ignorefile.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_ignorefile_cli_integration.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_javascript_autofix.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_logging.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_project_config.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_redactor.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_sarif.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_sarif_cli_integration.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_scan.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_ss_alias_and_creator.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_stdout.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_v0110_url_entropy_and_init.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_v0120_rotation.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_v0130_detection.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_v030_cli_integration.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_v040_cli_integration.py +0 -0
- {secretshield-0.13.0 → secretshield-0.13.2}/tests/test_v080_self_detection_fix.py +0 -0
- {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.
|
|
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
|
[](https://pypi.org/project/secretshield/)
|
|
34
34
|
[](https://pypi.org/project/secretshield/)
|
|
35
|
-
[](https://pepy.tech/projects/secretshield)
|
|
36
36
|
[](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
|
[](https://pypi.org/project/secretshield/)
|
|
8
8
|
[](https://pypi.org/project/secretshield/)
|
|
9
|
-
[](https://pepy.tech/projects/secretshield)
|
|
10
10
|
[](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.
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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}
|
|
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)} {
|
|
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)} {
|
|
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
|
|