From ae4c0df54233a001117c9764c4ec41f45255863e Mon Sep 17 00:00:00 2001 From: Antiksec <159251060+Antiksec@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:20:52 +0300 Subject: [PATCH 1/2] fix errors when scanning broken symlink --- deepsecrets/core/modes/iscan_mode.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/deepsecrets/core/modes/iscan_mode.py b/deepsecrets/core/modes/iscan_mode.py index dd6450c..c6652ae 100644 --- a/deepsecrets/core/modes/iscan_mode.py +++ b/deepsecrets/core/modes/iscan_mode.py @@ -387,6 +387,11 @@ def _get_files_list(self) -> List[str]: self._skip(full_path, f'excluded_path:{exclusion}') continue + if os.path.islink(full_path) and not os.path.exists(full_path): + # a dangling symlink cannot be opened or stat'ed: os.path.getsize would raise below + self._skip(full_path, 'broken_symlink') + continue + if not self._size_check(full_path): self._skip(full_path, 'max_file_size') ''' @@ -430,7 +435,11 @@ def _size_check(self, path: str): if self.config.max_file_size == 0: return True - size = os.path.getsize(path) + try: + size = os.path.getsize(path) + except OSError: + # e.g. a path that vanished or a dangling symlink raced away between discovery and here + return False if size > self.config.max_file_size: return False return True From 0b712f9c80bdffde124f72d41abebd7dc3045cad Mon Sep 17 00:00:00 2001 From: Nikolai Khechumov Date: Tue, 15 Sep 2026 07:55:41 +0000 Subject: [PATCH 2/2] Tests for 2.1.1 and version bump --- README.md | 3 +- deepsecrets/config.py | 2 +- deepsecrets/core/modes/iscan_mode.py | 22 ++++-- pyproject.toml | 5 +- .../scan_modes/test_scan_mode_regressions.py | 67 +++++++++++++++++++ 5 files changed, 87 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index eda9890..1c04379 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -# DeepSecrets 2.0 - a better tool for secrets scanning +# [OWASP](https://owasp.org/projects/deepsecrets) DeepSecrets — a better tool for secrets scanning + ![Tests Status](https://github.com/ntoskernel/deepsecrets/actions/workflows/run-tests.yml/badge.svg) diff --git a/deepsecrets/config.py b/deepsecrets/config.py index cc84b4e..86b712e 100644 --- a/deepsecrets/config.py +++ b/deepsecrets/config.py @@ -11,7 +11,7 @@ FALLBACK_PROCESS_COUNT = 4 SCANNER_NAME = "DeepSecrets" -SCANNER_VERSION = "2.1.0" +SCANNER_VERSION = "2.1.1" SCANNER_VERSION_NUMERIC = [int(subver) for subver in SCANNER_VERSION.split('.')] SCANNER_URL = "https://github.com/ntoskernel/deepsecrets" diff --git a/deepsecrets/core/modes/iscan_mode.py b/deepsecrets/core/modes/iscan_mode.py index c6652ae..f9eb31e 100644 --- a/deepsecrets/core/modes/iscan_mode.py +++ b/deepsecrets/core/modes/iscan_mode.py @@ -12,6 +12,7 @@ from multiprocessing import Manager, get_context from multiprocessing.managers import DictProxy +import errno import os import pickle import tempfile @@ -388,11 +389,20 @@ def _get_files_list(self) -> List[str]: continue if os.path.islink(full_path) and not os.path.exists(full_path): - # a dangling symlink cannot be opened or stat'ed: os.path.getsize would raise below + # a dangling symlink or a symlink loop -- os.path.exists is False for both. Skipping here + # keeps it away from a worker that could not open it, and away from _size_check below, + # where os.path.getsize raises once --max-file-size is set. self._skip(full_path, 'broken_symlink') continue - if not self._size_check(full_path): + try: + size_ok = self._size_check(full_path) + except OSError as e: + # vanished or became unreadable between os.walk and here + self._skip(full_path, f'unreadable:{errno.errorcode.get(e.errno, e.errno)}') + continue + + if not size_ok: self._skip(full_path, 'max_file_size') ''' console.print( @@ -432,14 +442,12 @@ def _matching_exclusion(self, path: str) -> Optional[str]: return None def _size_check(self, path: str): + """True when `path` is within `--max-file-size`. Raises `OSError` when the path cannot be stat'ed: + the caller records that under its own skip reason, distinct from a genuinely oversized file.""" if self.config.max_file_size == 0: return True - try: - size = os.path.getsize(path) - except OSError: - # e.g. a path that vanished or a dangling symlink raced away between discovery and here - return False + size = os.path.getsize(path) if size > self.config.max_file_size: return False return True diff --git a/pyproject.toml b/pyproject.toml index b660247..7b1ad2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "deepsecrets" -version = "2.1.0" +version = "2.1.1" description = "A better tool for secrets scanning" license = { text = "MIT" } readme = "README.md" @@ -42,7 +42,7 @@ dependencies = [ ] [project.urls] -homepage = "https://owasp.org/www-project-deepsecrets/" +homepage = "https://owasp.org/projects/deepsecrets" repository = "https://github.com/ntoskernel/deepsecrets" "Bug Tracker" = "https://github.com/ntoskernel/deepsecrets/issues" @@ -83,7 +83,6 @@ plugins = [ pythonpath = [ "." ] -# the benchmark harness in deepsecrets_bench/ has its own tests and dependencies testpaths = ["tests"] [tool.black] diff --git a/tests/scan_modes/test_scan_mode_regressions.py b/tests/scan_modes/test_scan_mode_regressions.py index 5b10324..0ca5703 100644 --- a/tests/scan_modes/test_scan_mode_regressions.py +++ b/tests/scan_modes/test_scan_mode_regressions.py @@ -1,3 +1,4 @@ +import errno import os import pickle import shutil @@ -212,3 +213,69 @@ def test_hashed_scan_through_process_pool(tmp_path: Path): assert mode.stats.failed_files == 0 finally: mode.dispose() + + +def test_broken_symlink_does_not_abort_discovery(tmp_path: Path): + # a dangling symlink, and a symlink loop, used to raise out of _size_check and kill the whole + # scan before it started. --max-file-size must be set: at the default 0 the check short-circuits + # before os.path.getsize and this passes even against the unfixed code. + (tmp_path / 'real.py').write_text('password = "Xk9mQ2vL7pR4tZw8Jq"\n') + (tmp_path / 'dangling.py').symlink_to(tmp_path / 'gone.py') + (tmp_path / 'loop_a').symlink_to(tmp_path / 'loop_b') + (tmp_path / 'loop_b').symlink_to(tmp_path / 'loop_a') + + config = _config(str(tmp_path)) + config.set_max_file_size(10_000_000) + mode = CliScanMode(config=config) + try: + assert mode.filepaths == [str(tmp_path / 'real.py')] + assert mode.stats.skipped_files == 3 + for name in ('dangling.py', 'loop_a', 'loop_b'): + assert mode.files[str(tmp_path / name)].status == 'skipped' + assert mode.files[str(tmp_path / name)].skip_reason == 'broken_symlink' + finally: + mode.dispose() + + +def test_broken_symlink_skipped_without_a_size_limit(tmp_path: Path): + # at the default --max-file-size of 0 a dangling symlink never crashed, it reached a worker that + # could not open it and was reported as a per-file error. It is a skip with a reason now. + (tmp_path / 'real.py').write_text('password = "Xk9mQ2vL7pR4tZw8Jq"\n') + (tmp_path / 'dangling.py').symlink_to(tmp_path / 'gone.py') + + config = _config(str(tmp_path)) + assert config.max_file_size == 0 + mode = CliScanMode(config=config) + try: + assert mode.filepaths == [str(tmp_path / 'real.py')] + assert mode.files[str(tmp_path / 'dangling.py')].skip_reason == 'broken_symlink' + # skipped files carry no error, so they stay out of the per-file error report + assert mode.per_file_errors() == {} + finally: + mode.dispose() + + +def test_unreadable_file_is_not_filed_as_oversized(tmp_path: Path, monkeypatch): + # _size_check used to swallow OSError and return False, which the caller could only read as + # 'too big' -- so an unreadable file was reported as max_file_size in --report-diagnostics SARIF + (tmp_path / 'fine.txt').write_text('x' * 10) + victim = tmp_path / 'victim.txt' + victim.write_text('y' * 10) + + real_getsize = os.path.getsize + + def fake_getsize(path): + if str(path) == str(victim): + raise PermissionError(errno.EACCES, 'Permission denied', str(path)) + return real_getsize(path) + + monkeypatch.setattr(os.path, 'getsize', fake_getsize) + + config = _config(str(tmp_path)) + config.set_max_file_size(10_000_000) + mode = CliScanMode(config=config) + try: + assert mode.filepaths == [str(tmp_path / 'fine.txt')] + assert mode.files[str(victim)].skip_reason == 'unreadable:EACCES' + finally: + mode.dispose()