diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed4a71..0fde72b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [2.2.1] - 2026-07-30 + +### Fixed + +- Fixed TruffleHog secret scanning when `trufflehog_exclude_dir` is configured: + all entries now pass through one filter file and are honored for changed-file + and explicit-file scans. Previously, configured values could be interpreted + as filter filenames and fail or alter scans. +- Added glob-pattern support for exclusions such as + `**/appsettings.*.json`, with matching anchored beneath the workspace and + root-relative globs kept distinct from recursive `**` globs. +- Normalized exclusion entries before pattern generation so dot segments and + repeated path separators behave consistently. +- Fixed exclusion matching when the configured workspace is the filesystem root. +- Normalized in-workspace TruffleHog finding paths relative to the workspace so + host paths do not appear in facts and component identifiers remain stable + across runs, working directories, and operating systems. + ## [2.2.0] - 2026-07-29 ### Added diff --git a/action.yml b/action.yml index 42d6660..dcb6dc7 100644 --- a/action.yml +++ b/action.yml @@ -4,7 +4,7 @@ author: "Socket" runs: using: "docker" - image: "docker://ghcr.io/socketdev/socket-basics:2.2.0" + image: "docker://ghcr.io/socketdev/socket-basics:2.2.1" env: # Core GitHub variables (these are automatically available, but we explicitly pass GITHUB_TOKEN) GITHUB_TOKEN: ${{ inputs.github_token }} @@ -428,7 +428,7 @@ inputs: required: false default: "false" trufflehog_exclude_dir: - description: "Comma-separated list of directories to exclude from secret scanning" + description: "Comma-separated literal directory/file names or glob patterns to exclude from secret scanning beneath the workspace root; matching is case-sensitive" required: false default: "" trufflehog_show_unverified: diff --git a/docs/parameters.md b/docs/parameters.md index 8e9836d..4e8ed3f 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -321,7 +321,9 @@ socket-basics --disable-secrets ``` ### `--exclude-dir EXCLUDE_DIR` -Comma-separated list of directories to exclude from secret scanning. +Comma-separated literal directory/file names or glob patterns to exclude from +secret scanning beneath the workspace root. Matching is case-sensitive. For +example, `**/appsettings.*.json` matches files at any directory depth. **Example:** ```bash diff --git a/pyproject.toml b/pyproject.toml index 9a28003..e2d2364 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "socket_basics" -version = "2.2.0" +version = "2.2.1" description = "Socket Basics with integrated SAST, secret scanning, and container analysis" readme = "README.md" requires-python = ">=3.10" diff --git a/socket_basics/__init__.py b/socket_basics/__init__.py index b00b4f6..4fff3ee 100644 --- a/socket_basics/__init__.py +++ b/socket_basics/__init__.py @@ -12,7 +12,7 @@ from .socket_basics import SecurityScanner, main from .core.config import load_config_from_env, Config -__version__ = "2.2.0" +__version__ = "2.2.1" __author__ = "Socket.dev" __email__ = "support@socket.dev" diff --git a/socket_basics/connectors.yaml b/socket_basics/connectors.yaml index 7df8b03..b18f461 100644 --- a/socket_basics/connectors.yaml +++ b/socket_basics/connectors.yaml @@ -406,7 +406,7 @@ connectors: - trufflehog_show_unverified - name: trufflehog_exclude_dir option: --exclude-dir - description: "Comma-separated list of directories to exclude from secret scanning" + description: "Comma-separated literal directory/file names or glob patterns to exclude from secret scanning beneath the workspace root; matching is case-sensitive" env_variable: INPUT_TRUFFLEHOG_EXCLUDE_DIR type: str default: "" diff --git a/socket_basics/core/connector/trufflehog/__init__.py b/socket_basics/core/connector/trufflehog/__init__.py index 4ff6ccf..43eef14 100644 --- a/socket_basics/core/connector/trufflehog/__init__.py +++ b/socket_basics/core/connector/trufflehog/__init__.py @@ -6,8 +6,11 @@ import json import logging -import subprocess import os +import posixpath +import re +import subprocess +import tempfile from typing import Dict, List, Any from ..base import BaseConnector @@ -31,6 +34,181 @@ def is_enabled(self) -> bool: """Check if secret scanning should be enabled""" return self.config.get('secret_scanning_enabled', False) + @staticmethod + def _path_regex(value: str) -> str: + """Escape a filesystem path for TruffleHog's Go regular-expression input.""" + normalized = str(value).replace('\\', '/') + return re.escape(normalized).replace('/', r'[/\\]') + + @staticmethod + def _glob_regex(value: str) -> str: + """Translate a path glob into a TruffleHog-compatible regex. + + TruffleHog consumes Go/RE2 regexes, not shell globs. Keep ``*`` and + ``?`` within a path segment, and let ``**/`` span zero or more path + segments. All other characters are treated literally. + """ + normalized = str(value).replace('\\', '/').strip('/') + pieces = [] + index = 0 + while index < len(normalized): + char = normalized[index] + if char == '*': + if index + 1 < len(normalized) and normalized[index + 1] == '*': + index += 2 + if index < len(normalized) and normalized[index] == '/': + pieces.append(r'(?:.*[/\\])?') + index += 1 + else: + pieces.append(r'.*') + continue + pieces.append(r'[^/\\]*') + elif char == '?': + pieces.append(r'[^/\\]') + elif char == '/': + pieces.append(r'[/\\]') + else: + pieces.append(re.escape(char)) + index += 1 + return ''.join(pieces) + + def _workspace_root(self) -> str: + """Return the absolute workspace path used by the scanner command.""" + workspace = getattr(self.config, 'workspace', None) + if not isinstance(workspace, (str, bytes, os.PathLike)): + workspace = ( + getattr(workspace, 'path', None) + or getattr(workspace, 'root', None) + or workspace + ) + if not workspace: + return '' + try: + root = os.path.abspath(os.fspath(workspace)) + return root if root in ('/', '\\') else root.rstrip('/\\') + except (TypeError, ValueError): + return '' + + def _workspace_relative_path(self, file_path: Any) -> str: + """Return a finding path relative to the configured workspace.""" + if not file_path: + return '' + try: + path = os.path.normpath(os.fspath(file_path)) + except (TypeError, ValueError): + path = os.path.normpath(str(file_path)) + + workspace_root = self._workspace_root() + if not workspace_root or not path: + return path + + try: + candidate = ( + os.path.abspath(path) + if os.path.isabs(path) + else os.path.abspath(os.path.join(workspace_root, path)) + ) + if os.path.commonpath([workspace_root, candidate]) == workspace_root: + return os.path.normpath(os.path.relpath(candidate, workspace_root)) + except (OSError, ValueError): + pass + + return path + + def _build_exclude_patterns(self, exclude_dirs: Any) -> List[str]: + """Build workspace-relative path patterns for TruffleHog. + + TruffleHog expects one --exclude-paths value containing a file of + newline-separated regular expressions. The configured values are + directory names, so anchor each one below the workspace root. This + prevents a directory such as tmp from matching the workspace's + parent path (for example /tmp/...), and prevents .git from + matching .github. + """ + if isinstance(exclude_dirs, str): + entries = exclude_dirs.split(',') + else: + entries = exclude_dirs or [] + + workspace_root = self._workspace_root() + patterns = [] + for entry in entries: + directory = posixpath.normpath( + str(entry).strip().replace('\\', '/') + ).strip('/') + if directory in ('', '.'): + continue + + has_glob = '*' in directory or '?' in directory + if has_glob: + directory_regex = self._glob_regex(directory) + else: + directory_regex = self._path_regex(directory) + + # A pattern without a slash is a basename/segment pattern and + # should work at any depth below the workspace. Patterns with a + # slash stay root-relative unless they explicitly use ``**/``. + if '/' not in directory: + directory_regex = rf'(?:[^/\\]+[/\\])*{directory_regex}' + + if workspace_root: + root_regex = self._path_regex(workspace_root) + root_separator = '' if workspace_root in ('/', '\\') else r'[/\\]' + patterns.append( + rf'^{root_regex}{root_separator}{directory_regex}(?:[/\\]|$)' + ) + else: + patterns.append(rf'(?:^|[/\\]){directory_regex}(?:[/\\]|$)') + + return patterns + + def _write_exclude_file(self, exclude_dirs: Any) -> str | None: + """Write exclude regexes to a temporary file for TruffleHog.""" + patterns = self._build_exclude_patterns(exclude_dirs) + return self._write_exclude_patterns(patterns) + + def _write_exclude_patterns(self, patterns: List[str]) -> str | None: + """Write resolved exclude regexes to a temporary file.""" + if not patterns: + return None + + with tempfile.NamedTemporaryFile( + mode='w', + encoding='utf-8', + prefix='socket-basics-trufflehog-', + suffix='.txt', + delete=False, + ) as exclude_file: + exclude_file.write('\n'.join(patterns)) + exclude_file.write('\n') + return exclude_file.name + + @staticmethod + def _absolute_scan_target(target: Any) -> str: + """Return the absolute path string TruffleHog will filter against.""" + return os.path.abspath(os.fspath(target)) + + @staticmethod + def _path_matches_patterns(path: str, patterns: List[str]) -> bool: + """Return whether a path matches one of the generated exclude patterns.""" + return any(re.search(pattern, path) for pattern in patterns) + + def _warn_if_target_outside_workspace(self, target: str) -> None: + """Warn when workspace-anchored excludes cannot apply to a target.""" + workspace_root = self._workspace_root() + if not workspace_root: + return + try: + inside_workspace = os.path.commonpath([workspace_root, target]) == workspace_root + except (OSError, ValueError): + inside_workspace = False + if not inside_workspace: + logger.warning( + "TruffleHog scan target %s is outside workspace %s; configured excludes may not apply", + target, + workspace_root, + ) + def scan(self) -> Dict[str, Any]: """Run Trufflehog secret scanning""" if not self.is_enabled(): @@ -41,7 +219,9 @@ def scan(self) -> Dict[str, Any]: targets = self.config.get_scan_targets() results = {} - + + exclude_file_path = None + exclude_patterns: List[str] = [] try: # Prefer explicit changed_files, fallback to git staged changed_files = self.config.get('changed_files', []) if hasattr(self.config, '_config') else [] @@ -59,18 +239,43 @@ def scan(self) -> Dict[str, Any]: '--no-verification' if not self.config.get('trufflehog_show_unverified', False) else '--include-detectors=all' ] - # Add exclusion patterns + # TruffleHog accepts --exclude-paths only once and expects a file + # containing newline-separated regular expressions. exclude_dirs = self.config.get('trufflehog_exclude_dir', '') if exclude_dirs: - for exclude_dir in exclude_dirs.split(','): - cmd.extend(['--exclude-paths', exclude_dir.strip()]) - - # If changed_files present, pass those individual files, otherwise use configured targets + exclude_patterns = self._build_exclude_patterns(exclude_dirs) + logger.debug("TruffleHog exclude patterns: %s", exclude_patterns) + exclude_file_path = self._write_exclude_patterns(exclude_patterns) + if exclude_file_path: + cmd.extend(['--exclude-paths', exclude_file_path]) + + # If changed_files are present, pass those individual files; + # otherwise use the configured targets (including scan_files). if changed_files: - for cf in changed_files: - cmd.append(str(self.config.workspace / cf)) + target_candidates = [self.config.workspace / cf for cf in changed_files] + excluded_target_message = "Skipping excluded changed file: %s" + all_excluded_message = "All changed files were excluded from TruffleHog scanning" else: - cmd.extend(targets) + target_candidates = list(targets or []) + excluded_target_message = "Skipping excluded scan target: %s" + all_excluded_message = "All scan targets were excluded from TruffleHog scanning" + + scan_targets = [] + for candidate in target_candidates: + target = self._absolute_scan_target(candidate) + self._warn_if_target_outside_workspace(target) + if exclude_patterns and self._path_matches_patterns(target, exclude_patterns): + logger.info(excluded_target_message, target) + continue + scan_targets.append(target) + + if not scan_targets: + if target_candidates: + logger.info(all_excluded_message) + else: + logger.info("No TruffleHog scan targets found; skipping") + return results + cmd.extend(scan_targets) logger.info(f"Running: {' '.join(cmd)}") result = subprocess.run(cmd, capture_output=True, text=True) @@ -138,7 +343,15 @@ def scan(self) -> Dict[str, Any]: logger.error("Trufflehog not found. Please install Trufflehog") except Exception as e: logger.error(f"Error running Trufflehog: {e}") - + finally: + if exclude_file_path: + try: + os.unlink(exclude_file_path) + except FileNotFoundError: + pass + except OSError as e: + logger.warning(f"Failed to remove Trufflehog exclude file: {e}") + return results def _convert_to_socket_facts(self, raw_results: Any) -> Dict[str, Any]: @@ -154,79 +367,27 @@ def _process_results(self, findings: List[Dict[str, Any]]) -> Dict[str, Any]: return {} import hashlib - from pathlib import Path # Group findings by file path so each file gets its own component. comps: Dict[str, Dict[str, Any]] = {} def _hash_file_or_path(file_path: str) -> str: try: - p = Path(file_path) - # resolve relative to workspace when available - try: - ws = getattr(self.config, 'workspace', None) - if ws and not p.is_absolute(): - p = Path(ws) / file_path - # If path is absolute and inside the workspace, make it relative - elif ws and p.is_absolute(): - try: - ws_path = Path(getattr(ws, 'path', None) or getattr(ws, 'root', None) or str(ws)) - if str(p).startswith(str(ws_path)): - p = Path(os.path.relpath(str(p), str(ws_path))) - except Exception: - pass - except Exception: - pass # Use normalized posix path rather than file contents to avoid # reading files; this keeps IDs stable across runs and aligns # with the requested rule (sha256 of path+filename). - norm = str(p.as_posix()) + norm = ( + posixpath.normpath(str(file_path).replace('\\', '/')) + if file_path + else 'unknown' + ) return hashlib.sha256(norm.encode('utf-8')).hexdigest() except Exception: return hashlib.sha256((file_path or 'unknown').encode('utf-8')).hexdigest() for f in findings: fp = f.get('SourceMetadata', {}).get('Data', {}).get('Filesystem', {}).get('file') or '' - # normalize path - try: - # Normalize path first - try: - fp = os.path.normpath(fp) - except Exception: - pass - - # Attempt to strip workspace prefix whether the path is absolute - # or a relative path that includes the workspace folder like - # "../NodeGoat/...". This makes component names and alerts - # consistent across environments. - try: - workspace_root = getattr(self.config, 'workspace', None) - workspace_root = getattr(workspace_root, 'path', None) or getattr(workspace_root, 'root', None) or workspace_root - workspace_name = os.path.basename(workspace_root) if workspace_root else None - # If absolute and inside workspace, make relative - if workspace_root and os.path.isabs(fp): - try: - if str(fp).startswith(str(workspace_root)): - fp = os.path.normpath(os.path.relpath(fp, workspace_root)) - except Exception: - pass - else: - # For relative paths like "../NodeGoat/..." or "NodeGoat/...", - # remove leading '../' or './' or ''. - if workspace_name: - parts = fp.split(os.sep) - if parts and parts[0] == workspace_name: - parts = parts[1:] - elif len(parts) >= 2 and parts[0] in ('.', '..') and parts[1] == workspace_name: - parts = parts[2:] - if parts: - fp = os.path.join(*parts) - else: - fp = '' - except Exception: - pass - except Exception: - pass + fp = self._workspace_relative_path(fp) comp_id = _hash_file_or_path(fp) @@ -285,37 +446,7 @@ def _create_alert(self, finding: Dict[str, Any]) -> Dict[str, Any]: # Verified secrets are critical; unverified findings are low severity severity = 'critical' if verified else 'low' - # Make file paths relative to workspace root when possible - # Normalize and strip workspace prefix similar to above so alerts - # display paths without the workspace folder. - try: - file_path = os.path.normpath(file_path) - except Exception: - pass - - try: - workspace_root = getattr(self.config.workspace, 'path', None) or getattr(self.config.workspace, 'root', None) - except Exception: - workspace_root = None - - try: - if workspace_root and file_path and os.path.isabs(file_path): - file_path = os.path.normpath(os.path.relpath(file_path, workspace_root)) - else: - # remove leading workspace folder for relative paths like - # "../NodeGoat/..." or "NodeGoat/..." - workspace_name = os.path.basename(workspace_root) if workspace_root else None - if workspace_name: - parts = file_path.split(os.sep) - if parts and parts[0] == workspace_name: - parts = parts[1:] - elif len(parts) >= 2 and parts[0] in ('.', '..') and parts[1] == workspace_name: - parts = parts[2:] - file_path = os.path.join(*parts) if parts else file_path - else: - file_path = os.path.normpath(file_path) - except Exception: - pass + file_path = self._workspace_relative_path(file_path) # Redact the actual secret raw_secret = finding.get('Raw', '') diff --git a/socket_basics/version.py b/socket_basics/version.py index 8a124bf..b19ee4b 100644 --- a/socket_basics/version.py +++ b/socket_basics/version.py @@ -1 +1 @@ -__version__ = "2.2.0" +__version__ = "2.2.1" diff --git a/tests/test_trufflehog_excludes.py b/tests/test_trufflehog_excludes.py new file mode 100644 index 0000000..a057e0f --- /dev/null +++ b/tests/test_trufflehog_excludes.py @@ -0,0 +1,478 @@ +from pathlib import Path +import hashlib +import logging +import re +from types import SimpleNamespace + +from socket_basics.core.connector.trufflehog import TruffleHogScanner + + +def _scanner(tmp_path, exclude_dirs): + config = SimpleNamespace( + workspace=tmp_path, + trufflehog_exclude_dir=exclude_dirs, + ) + config.get = lambda key, default=None: { + "trufflehog_exclude_dir": exclude_dirs, + "trufflehog_show_unverified": False, + }.get(key, default) + config.get_action_for_severity = lambda severity: "error" + config.get_scan_targets = lambda: [] + scanner = TruffleHogScanner.__new__(TruffleHogScanner) + scanner.config = config + return scanner + + +def test_build_exclude_patterns_are_anchored_to_workspace(tmp_path): + scanner = _scanner(tmp_path, "") + + patterns = scanner._build_exclude_patterns("node_modules,.git,tmp") + + assert len(patterns) == 3 + workspace_regex = scanner._path_regex(str(tmp_path)) + assert all(workspace_regex in pattern for pattern in patterns) + assert any(r"\.git" in pattern for pattern in patterns) + assert all(".github" not in pattern for pattern in patterns) + assert all(not pattern.startswith(r"(?:^|[/\\])") for pattern in patterns) + + def matches(path): + return any(re.search(pattern, path) for pattern in patterns) + + assert matches(str(tmp_path / "src" / "node_modules" / "package.json")) + assert not matches(str(tmp_path / ".github" / "workflows" / "scan.yml")) + assert not re.search(patterns[2], str(tmp_path / "app.py")) + + +def test_build_exclude_patterns_skip_empty_entries(tmp_path): + scanner = _scanner(tmp_path, "") + + patterns = scanner._build_exclude_patterns(" node_modules, ,dist, ") + + assert len(patterns) == 2 + assert all("node_modules" in pattern or "dist" in pattern for pattern in patterns) + + +def test_build_exclude_patterns_normalize_dot_and_repeated_separators(tmp_path): + scanner = _scanner(tmp_path, "") + + dist_pattern, cache_pattern = scanner._build_exclude_patterns("./dist,sub//cache") + + assert re.search(dist_pattern, str(tmp_path / "dist" / "bundle.js")) + assert re.search(dist_pattern, str(tmp_path / "src" / "dist" / "bundle.js")) + assert re.search(cache_pattern, str(tmp_path / "sub" / "cache" / "data")) + assert not re.search(cache_pattern, str(tmp_path / "src" / "sub" / "cache" / "data")) + + +def test_build_exclude_patterns_translate_globs_at_any_depth(tmp_path): + scanner = _scanner(tmp_path, "") + + pattern = scanner._build_exclude_patterns("**/appsettings.*.json")[0] + + assert re.search(pattern, str(tmp_path / "appsettings.Production.json")) + assert re.search(pattern, str(tmp_path / "config" / "appsettings.Staging.json")) + assert not re.search(pattern, str(tmp_path / "config" / "appsettings.json")) + assert not re.search(pattern, str(tmp_path / "config" / "appsettings.Staging.json.bak")) + + +def test_build_exclude_patterns_keep_slash_globs_root_relative(tmp_path): + scanner = _scanner(tmp_path, "") + + pattern = scanner._build_exclude_patterns("config/*.json")[0] + + assert re.search(pattern, str(tmp_path / "config" / "secrets.json")) + assert not re.search(pattern, str(tmp_path / "src" / "config" / "secrets.json")) + + +def test_build_exclude_patterns_keep_literal_entries_segment_anchored(tmp_path): + scanner = _scanner(tmp_path, "") + + node_modules_pattern, app_pattern = scanner._build_exclude_patterns("node_modules,app") + + assert re.search( + node_modules_pattern, + str(tmp_path / "src" / "node_modules" / "package.json"), + ) + assert not re.search( + node_modules_pattern, + str(tmp_path / "src" / "node_modules_backup" / "package.json"), + ) + assert re.search(app_pattern, str(tmp_path / "src" / "app" / "main.py")) + assert not re.search(app_pattern, str(tmp_path / "src" / "myapp" / "main.py")) + + +def test_build_exclude_patterns_keep_slash_literals_root_relative(tmp_path): + scanner = _scanner(tmp_path, "") + + pattern = scanner._build_exclude_patterns("config/secrets")[0] + + assert re.search(pattern, str(tmp_path / "config" / "secrets" / "token.txt")) + assert not re.search(pattern, str(tmp_path / "src" / "config" / "secrets" / "token.txt")) + + +def test_build_exclude_patterns_handle_filesystem_root_workspace(tmp_path): + scanner = _scanner(tmp_path, "") + scanner._workspace_root = lambda: "/" + + pattern = scanner._build_exclude_patterns("tmp")[0] + + assert re.search(pattern, "/tmp/secret.txt") + assert re.search(pattern, "/var/tmp/secret.txt") + assert not re.search(pattern, "/template/secret.txt") + + +def test_write_exclude_file_contains_one_pattern_per_line(tmp_path): + scanner = _scanner(tmp_path, "") + + exclude_file = scanner._write_exclude_file("node_modules,.git") + try: + contents = Path(exclude_file).read_text(encoding="utf-8").splitlines() + finally: + Path(exclude_file).unlink() + + assert len(contents) == 2 + workspace_regex = scanner._path_regex(str(tmp_path)) + assert all(workspace_regex in pattern for pattern in contents) + + +def test_scan_passes_one_exclude_paths_flag_and_cleans_up(tmp_path, monkeypatch): + scanner = _scanner(tmp_path, "node_modules,.yarn,dist") + scanner.is_enabled = lambda: True + scanner.config.get = lambda key, default=None: { + "trufflehog_exclude_dir": scanner.config.trufflehog_exclude_dir, + "trufflehog_show_unverified": False, + }.get(key, default) + scanner.config.get_scan_targets = lambda: [str(tmp_path)] + scanner._process_results = lambda findings: {} + + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + exclude_path = Path(command[command.index("--exclude-paths") + 1]) + captured["contents"] = exclude_path.read_text(encoding="utf-8").splitlines() + captured["exists_during_run"] = exclude_path.exists() + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr( + "socket_basics.core.connector.trufflehog.subprocess.run", + fake_run, + ) + + scanner.scan() + + command = captured["command"] + assert command.count("--exclude-paths") == 1 + assert captured["exists_during_run"] is True + assert len(captured["contents"]) == 3 + assert not Path(command[command.index("--exclude-paths") + 1]).exists() + + +def test_scan_uses_absolute_targets_for_relative_workspace(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + scanner = _scanner(Path("."), "node_modules") + scanner.is_enabled = lambda: True + scanner.config.get = lambda key, default=None: { + "trufflehog_exclude_dir": scanner.config.trufflehog_exclude_dir, + "trufflehog_show_unverified": False, + }.get(key, default) + scanner.config.get_scan_targets = lambda: ["."] + scanner._process_results = lambda findings: {} + + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr( + "socket_basics.core.connector.trufflehog.subprocess.run", + fake_run, + ) + + scanner.scan() + + command = captured["command"] + assert command[-1] == str(tmp_path) + + +def test_process_results_strips_absolute_workspace_from_output_and_id(tmp_path): + scanner = _scanner(tmp_path, "") + scanner.generate_notifications = lambda components: {} + + file_path = tmp_path / "app" / "creds.txt" + finding = { + "DetectorName": "AWS", + "Verified": True, + "Raw": "AKIA1234567890EXAMPLE", + "SourceMetadata": {"Data": {"Filesystem": {"file": str(file_path), "line": 7}}}, + } + + result = scanner._process_results([finding]) + component = result["components"][0] + alert = component["alerts"][0] + + assert component["name"] == "app/creds.txt" + assert component["subpath"] == "app/creds.txt" + assert component["manifestFiles"] == [{"file": "app/creds.txt"}] + assert alert["props"]["filePath"] == "app/creds.txt" + assert component["id"] == hashlib.sha256(b"app/creds.txt").hexdigest() + + +def test_process_results_strips_relative_workspace_from_output(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + workspace = Path("ws") + scanner = _scanner(workspace, "") + scanner.generate_notifications = lambda components: {} + + finding = { + "DetectorName": "AWS", + "Verified": True, + "Raw": "AKIA1234567890EXAMPLE", + "SourceMetadata": { + "Data": { + "Filesystem": { + "file": str((tmp_path / "ws" / "app" / "creds.txt").resolve()), + "line": 7, + } + } + }, + } + + result = scanner._process_results([finding]) + + assert result["components"][0]["name"] == "app/creds.txt" + assert result["components"][0]["alerts"][0]["props"]["filePath"] == "app/creds.txt" + + +def test_process_results_relative_finding_path_is_independent_of_cwd( + tmp_path, + monkeypatch, +): + workspace = tmp_path / "workspace" + other_cwd = tmp_path / "other" + workspace.mkdir() + other_cwd.mkdir() + scanner = _scanner(workspace, "") + scanner.generate_notifications = lambda components: {} + finding_path = Path(workspace.name) / "app" / "creds.txt" + finding = { + "DetectorName": "AWS", + "Verified": True, + "Raw": "AKIA1234567890EXAMPLE", + "SourceMetadata": { + "Data": { + "Filesystem": { + "file": str(finding_path), + "line": 7, + } + } + }, + } + + monkeypatch.chdir(tmp_path) + from_workspace_parent = scanner._process_results([finding])["components"][0] + monkeypatch.chdir(other_cwd) + from_other_cwd = scanner._process_results([finding])["components"][0] + + expected = str(finding_path) + expected_id = hashlib.sha256(expected.replace("\\", "/").encode()).hexdigest() + assert from_workspace_parent["name"] == expected + assert from_other_cwd["name"] == expected + assert from_workspace_parent["id"] == expected_id + assert from_other_cwd["id"] == expected_id + + +def test_process_results_hashes_windows_paths_as_posix(tmp_path): + scanner = _scanner(tmp_path, "") + scanner.generate_notifications = lambda components: {} + + finding = { + "DetectorName": "AWS", + "Verified": True, + "Raw": "AKIA1234567890EXAMPLE", + "SourceMetadata": {"Data": {"Filesystem": {"file": r"app\creds.txt", "line": 7}}}, + } + + result = scanner._process_results([finding]) + + assert result["components"][0]["id"] == hashlib.sha256(b"app/creds.txt").hexdigest() + + +def test_scan_filters_excluded_changed_files(tmp_path, monkeypatch, caplog): + scanner = _scanner(tmp_path, "node_modules") + scanner.is_enabled = lambda: True + scanner.config._config = {} + scanner.config.get = lambda key, default=None: { + "changed_files": ["node_modules/staged.txt", "app/staged.txt"], + "trufflehog_exclude_dir": "node_modules", + "trufflehog_show_unverified": False, + }.get(key, default) + scanner._process_results = lambda findings: {} + + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr( + "socket_basics.core.connector.trufflehog.subprocess.run", + fake_run, + ) + + with caplog.at_level(logging.DEBUG): + scanner.scan() + + command = captured["command"] + assert str(tmp_path / "app" / "staged.txt") in command + assert str(tmp_path / "node_modules" / "staged.txt") not in command + assert "TruffleHog exclude patterns:" in caplog.text + assert "Skipping excluded changed file:" in caplog.text + + +def test_scan_filters_changed_files_with_glob_excludes(tmp_path, monkeypatch): + scanner = _scanner(tmp_path, "**/appsettings.*.json") + scanner.is_enabled = lambda: True + scanner.config._config = {} + scanner.config.get = lambda key, default=None: { + "changed_files": [ + "config/appsettings.Staging.json", + "config/appsettings.json", + ], + "trufflehog_exclude_dir": "**/appsettings.*.json", + "trufflehog_show_unverified": False, + }.get(key, default) + scanner._process_results = lambda findings: {} + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr( + "socket_basics.core.connector.trufflehog.subprocess.run", + fake_run, + ) + + scanner.scan() + + assert str(tmp_path / "config" / "appsettings.Staging.json") not in captured["command"] + assert str(tmp_path / "config" / "appsettings.json") in captured["command"] + + +def test_scan_filters_excluded_explicit_scan_targets(tmp_path, monkeypatch, caplog): + scanner = _scanner(tmp_path, "excluded") + scanner.is_enabled = lambda: True + scanner.config.get_scan_targets = lambda: [ + str(tmp_path / "excluded" / "secret.txt"), + str(tmp_path / "app" / "secret.txt"), + ] + scanner._process_results = lambda findings: {} + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr( + "socket_basics.core.connector.trufflehog.subprocess.run", + fake_run, + ) + + with caplog.at_level(logging.INFO): + scanner.scan() + + assert str(tmp_path / "excluded" / "secret.txt") not in captured["command"] + assert str(tmp_path / "app" / "secret.txt") in captured["command"] + assert "Skipping excluded scan target:" in caplog.text + + +def test_scan_cleans_filter_when_all_explicit_targets_are_excluded(tmp_path, monkeypatch, caplog): + scanner = _scanner(tmp_path, "excluded") + scanner.is_enabled = lambda: True + scanner.config.get_scan_targets = lambda: [ + str(tmp_path / "excluded" / "secret.txt"), + ] + captured = {} + write_patterns = scanner._write_exclude_patterns + + def capture_filter_path(patterns): + filter_path = write_patterns(patterns) + captured["filter_path"] = Path(filter_path) + return filter_path + + def unexpected_run(command, **kwargs): + raise AssertionError("TruffleHog should not run when all targets are excluded") + + monkeypatch.setattr(scanner, "_write_exclude_patterns", capture_filter_path) + monkeypatch.setattr( + "socket_basics.core.connector.trufflehog.subprocess.run", + unexpected_run, + ) + + with caplog.at_level(logging.INFO): + result = scanner.scan() + + assert result == {} + assert "All scan targets were excluded from TruffleHog scanning" in caplog.text + assert not captured["filter_path"].exists() + + +def test_scan_skips_when_no_targets_are_available(tmp_path, monkeypatch, caplog): + scanner = _scanner(tmp_path, "") + scanner.is_enabled = lambda: True + scanner.config.get_scan_targets = lambda: [] + + def unexpected_run(command, **kwargs): + raise AssertionError("TruffleHog should not run without scan targets") + + monkeypatch.setattr( + "socket_basics.core.connector.trufflehog.subprocess.run", + unexpected_run, + ) + + with caplog.at_level(logging.INFO): + result = scanner.scan() + + assert result == {} + assert "No TruffleHog scan targets found; skipping" in caplog.text + + +def test_scan_warns_for_target_outside_workspace(tmp_path, monkeypatch, caplog): + scanner = _scanner(tmp_path, "node_modules") + scanner.is_enabled = lambda: True + outside_target = tmp_path.parent / "outside-repo" + scanner.config.get_scan_targets = lambda: [str(outside_target)] + scanner._process_results = lambda findings: {} + + monkeypatch.setattr( + "socket_basics.core.connector.trufflehog.subprocess.run", + lambda command, **kwargs: SimpleNamespace(returncode=0, stdout="", stderr=""), + ) + + with caplog.at_level(logging.WARNING): + scanner.scan() + + assert "is outside workspace" in caplog.text + + +def test_scan_cleans_exclude_file_when_trufflehog_fails(tmp_path, monkeypatch): + scanner = _scanner(tmp_path, "node_modules") + scanner.is_enabled = lambda: True + scanner.config.get_scan_targets = lambda: [str(tmp_path)] + scanner._process_results = lambda findings: {} + captured = {} + + def fake_run(command, **kwargs): + exclude_path = Path(command[command.index("--exclude-paths") + 1]) + captured["exclude_path"] = exclude_path + return SimpleNamespace(returncode=1, stdout="", stderr="failed") + + monkeypatch.setattr( + "socket_basics.core.connector.trufflehog.subprocess.run", + fake_run, + ) + + scanner.scan() + + assert not captured["exclude_path"].exists() diff --git a/uv.lock b/uv.lock index b03fc51..6cade4d 100644 --- a/uv.lock +++ b/uv.lock @@ -672,7 +672,7 @@ wheels = [ [[package]] name = "socket-basics" -version = "2.2.0" +version = "2.2.1" source = { editable = "." } dependencies = [ { name = "jsonschema" },