From ff7702c74b05e12dccc971002a9515944140cc7f Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:32:12 -0400 Subject: [PATCH 01/10] feat: add diff-only scan scoping (#77) Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --- CHANGELOG.md | 12 ++ action.yml | 22 +++ docs/github-action.md | 51 ++++++ docs/parameters.md | 25 ++- socket_basics/core/config.py | 158 ++++++++++++---- .../core/connector/opengrep/__init__.py | 7 + tests/test_changed_files_scope.py | 172 ++++++++++++++++++ 7 files changed, 413 insertions(+), 34 deletions(-) create mode 100644 tests/test_changed_files_scope.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 057682e..ddc0828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [2.1.0] - 2026-06-02 + +### Added +- Diff-only scan scoping now applies to SAST/OpenGrep via `changed_files` and + `scan_files`. +- Added GitHub Action inputs for `changed_files` and `scan_files`. + +### Fixed +- Delete-only changed-file scans now skip instead of falling back to a full + workspace scan. +- Updated parameter docs to reflect SAST/OpenGrep diff-only scoping. + ## [2.0.3] - 2026-04-24 diff --git a/action.yml b/action.yml index aa3537b..2480e97 100644 --- a/action.yml +++ b/action.yml @@ -9,6 +9,9 @@ runs: # Core GitHub variables (these are automatically available, but we explicitly pass GITHUB_TOKEN) GITHUB_TOKEN: ${{ inputs.github_token }} INPUT_WORKSPACE: ${{ inputs.workspace }} + # Scan scope + INPUT_CHANGED_FILES: ${{ inputs.changed_files }} + INPUT_SCAN_FILES: ${{ inputs.scan_files }} # Input mappings for all parameters INPUT_ALL_LANGUAGES_ENABLED: ${{ inputs.all_languages_enabled }} INPUT_ALL_RULES_ENABLED: ${{ inputs.all_rules_enabled }} @@ -103,6 +106,25 @@ inputs: description: "Workspace directory to scan (defaults to GITHUB_WORKSPACE)" required: false default: "" + changed_files: + description: >- + Diff-only mode: scope every scanner (SAST/OpenGrep, secrets, containers) + to changed files only, instead of the whole repository. Accepts a + comma-separated file list, a commit hash, 'auto' (diffs against the PR + base branch in CI, else staged changes), 'pr' (diff against + GITHUB_BASE_REF), or 'current-commit'. For PR/'auto' modes, check out with + actions/checkout fetch-depth: 0 so the base branch is available. When the + diff resolves to no existing files (e.g. a delete-only PR) the scanners + are skipped rather than scanning the whole repo. + required: false + default: "" + scan_files: + description: >- + Explicit comma-separated list of files to scan. Scopes SAST/OpenGrep, + secret, and container scans to just these files. Used when changed_files + is not set; changed_files takes precedence when both are provided. + required: false + default: "" socket_org: description: "Socket organization slug (required for Enterprise features)" required: false diff --git a/docs/github-action.md b/docs/github-action.md index 33c6c49..2438b3c 100644 --- a/docs/github-action.md +++ b/docs/github-action.md @@ -271,6 +271,57 @@ Include these in your workflow's `jobs..permissions` section. verbose: 'true' ``` +## Diff-Only Mode (Changed Files) + +By default the scanners run against the **entire repository**, so every PR +re-reports the whole repo's existing findings. To report only on what the PR +changed — the way Socket SCA Pull Request alerts behave — use the +`changed_files` input. This scopes SAST/OpenGrep, secret, and container scans to +the changed files and dramatically reduces PR finding volume. + +```yaml +name: Socket Basics (PR diff-only) +on: + pull_request: + +jobs: + socket-basics: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Required so the PR base branch is available for the diff + fetch-depth: 0 + + - name: Run Socket Basics (changed files only) + uses: SocketDev/socket-basics@v2.0.3 + env: + GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + # Diff-only: scope all scanners to files changed in this PR + changed_files: 'auto' + python_sast_enabled: 'true' + javascript_sast_enabled: 'true' + secret_scanning_enabled: 'true' +``` + +`changed_files` accepts: + +- `auto` — diff against the PR base branch in CI (`GITHUB_BASE_REF`), else staged changes +- `pr` — diff against the PR base branch (`GITHUB_BASE_REF`) +- a commit hash — files changed in that commit +- a comma-separated file list — e.g. `src/app.py,src/utils.js` + +> [!IMPORTANT] +> For `auto`/`pr` modes, check out with `fetch-depth: 0` so the base branch is +> available to diff against. Deletions are excluded, so a delete-only PR scans +> nothing rather than falling back to the whole repo. To scan an explicit file +> list regardless of git state, use the `scan_files` input instead. + ## PR Comment Customization Socket Basics automatically posts enhanced PR comments with **smart defaults that work out of the box** — clickable file links, collapsible sections, syntax highlighting, CVE links, CVSS scores, and auto-labels are all enabled by default. diff --git a/docs/parameters.md b/docs/parameters.md index 91c389e..e1ad6c6 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -92,7 +92,11 @@ socket-basics --committers "user1@example.com,user2@example.com" ``` ### `--scan-files SCAN_FILES` -Comma-separated list of files to scan. +Explicit comma-separated list of files to scan. Scopes **all** scanners — +SAST/OpenGrep, secrets, and container scanning — to just these files instead of +the whole workspace. Used when `--changed-files` is not set (`--changed-files` +takes precedence when both are provided). Paths that do not exist are skipped; +if none exist, the scanners are skipped rather than scanning the whole repo. **Example:** ```bash @@ -100,7 +104,21 @@ socket-basics --scan-files "src/app.py,src/utils.js" ``` ### `--changed-files CHANGED_FILES` -Comma-separated list of files to scan or 'auto' to detect changed files from git. +Diff-only mode: scope **all** scanners (SAST/OpenGrep, secrets, containers) to +changed files only, the way Socket SCA Pull Request alerts behave. Accepts: + +- a comma-separated file list (e.g. `src/app.py,src/utils.js`) +- a commit hash — files changed in that commit +- `auto` — the PR base-ref diff when running in a PR CI context + (`GITHUB_BASE_REF` is set), otherwise staged (`--cached`) changes +- `pr` — diff against the PR base branch (`GITHUB_BASE_REF`) +- `current-commit` — files in the `HEAD` commit + +Deletions are excluded from PR/`auto`/`pr` diffs so removed paths never become +scan targets. When the diff resolves to no existing files (e.g. a delete-only +PR), the scanners are skipped rather than falling back to scanning the whole +repository. For PR/`auto`/`pr` modes, check out with full history (e.g. +`actions/checkout` with `fetch-depth: 0`) so the base branch is available. **Example:** ```bash @@ -623,6 +641,9 @@ socket-basics \ ### CI/CD Scan (Changed Files Only) +Scope every scanner — SAST/OpenGrep included — to only the files the PR changed, +so each PR reports findings for its own changes rather than the whole repo: + ```bash socket-basics \ --changed-files auto \ diff --git a/socket_basics/core/config.py b/socket_basics/core/config.py index 55512cf..5f6a962 100644 --- a/socket_basics/core/config.py +++ b/socket_basics/core/config.py @@ -76,24 +76,58 @@ def _parse_scan_files(self, scan_files_str: str) -> List[str]: return [f.strip() for f in scan_files_str.split(',') if f.strip()] def get_scan_targets(self) -> List[str]: - """Determine files to scan based on configuration""" - # If explicit 'scan_all' set, return workspace directory + """Determine files to scan based on configuration. + + Precedence (highest to lowest): + 1. ``scan_all`` -> scan the entire workspace (explicit override). + 2. ``changed_files`` -> scope the scan to the PR/diff changed files + (diff-only mode; mirrors how Socket SCA Pull Request alerts behave). + 3. ``scan_files`` -> explicit user-provided file list. + 4. default -> scan the entire workspace. + + For the scoped modes (2 and 3) an empty list may be returned when none + of the requested paths exist (for example a delete-only PR). Callers + MUST treat an empty result as "nothing to scan" and skip the scanner + rather than falling back to scanning the whole workspace or their own + working directory. + """ + # Explicit "scan everything" override. if self.get('scan_all', False): return [str(self.workspace)] - # If user provided specific files to scan, validate their existence + # Diff-only mode: scope the scan to the files changed in the PR/commit. + # Keep honoring the scope when git resolves to zero files, e.g. a + # delete-only PR, so callers skip instead of scanning the workspace. + changed_files = self.get('changed_files', []) or [] + if changed_files or self.get('changed_files_scope_requested', False): + return self._resolve_file_targets(changed_files) + + # Explicit list of files to scan. if self.scan_files: - targets = [self.workspace / f for f in self.scan_files] - valid = [] - for t in targets: - if t.exists(): - valid.append(str(t)) - else: - logging.getLogger(__name__).warning("Scan target does not exist: %s", str(t)) - return valid - - # Default: scan the workspace itself + return self._resolve_file_targets(self.scan_files) + + # Default: scan the workspace itself. return [str(self.workspace)] + + def _resolve_file_targets(self, files: List[str]) -> List[str]: + """Resolve a list of file paths to absolute scan targets. + + Relative paths are resolved against the workspace; absolute paths are + used as-is. Paths that do not exist are skipped with a warning (a + common case for delete-only PRs). Returns an empty list when none of + the provided paths exist, signalling callers that there is nothing to + scan. + """ + valid: List[str] = [] + for f in files: + p = Path(f) + if not p.is_absolute(): + p = self.workspace / f + if p.exists(): + valid.append(str(p)) + else: + logging.getLogger(__name__).warning("Scan target does not exist: %s", str(p)) + return valid def get_action_for_severity(self, severity: str) -> str: """Map severity to action according to security policy""" @@ -1096,7 +1130,10 @@ def add_dynamic_cli_args(parser: argparse.ArgumentParser): # Add optional changed-files CLI argument to limit scans to changed files parser.add_argument('--changed-files', type=str, default='', - help="Comma-separated list of files to scan or 'auto' to detect changed files from git") + help="Scope all scanners (SAST/OpenGrep, secrets, containers) to changed " + "files only. Accepts a comma-separated file list, a commit hash, " + "'auto' (PR base-ref diff in CI, else staged changes), 'pr' (diff " + "against GITHUB_BASE_REF), or 'current-commit'.") # Also add CLI args for notification plugins declared in notifications.yaml try: @@ -1303,17 +1340,30 @@ def create_config_from_args(args) -> Config: except Exception: pass - # Handle changed-files: CLI overrides env/config. Accept 'auto' to detect via git + # Handle changed-files: CLI overrides env/config. Accept 'auto' to detect via git. + # When invoked via the GitHub Action (entrypoint passes no CLI args) the value + # arrives through the INPUT_CHANGED_FILES environment variable instead. changed_files_arg = getattr(args, 'changed_files', '') if args is not None else '' + if not changed_files_arg: + changed_files_arg = os.getenv('INPUT_CHANGED_FILES', '') if changed_files_arg: val = str(changed_files_arg).strip() - # 'auto' defaults to staged changes (--cached) + config_dict['changed_files_scope_requested'] = True + # 'auto' resolves to the PR base-ref diff in CI, else staged changes. if val.lower() == 'auto': try: - git_changed = _detect_git_changed_files(config_dict.get('workspace', os.getcwd()), mode='staged') + git_changed = _detect_git_changed_files(config_dict.get('workspace', os.getcwd()), mode='auto') config_dict['changed_files'] = git_changed except Exception as e: - logging.getLogger(__name__).warning("Warning: failed to detect git changed files (staged): %s", e) + logging.getLogger(__name__).warning("Warning: failed to detect git changed files (auto): %s", e) + config_dict['changed_files'] = [] + elif val.lower() == 'pr': + # Explicit PR diff against the base branch (GITHUB_BASE_REF). + try: + git_changed = _detect_git_changed_files(config_dict.get('workspace', os.getcwd()), mode='pr') + config_dict['changed_files'] = git_changed + except Exception as e: + logging.getLogger(__name__).warning("Warning: failed to detect git changed files (pr): %s", e) config_dict['changed_files'] = [] elif val.lower() in ('current-commit', 'current_commit'): try: @@ -1370,27 +1420,34 @@ def create_config_from_args(args) -> Config: return Config(config_dict) -def _detect_git_changed_files(workspace_path: str, mode: str = 'staged', commit: str | None = None) -> List[str]: +def _detect_git_changed_files(workspace_path: str, mode: str = 'staged', commit: str | None = None, base_ref: str | None = None) -> List[str]: """Detect changed files in a git repository. mode: - - 'staged' -> files staged for commit (git diff --name-only --cached) - - 'current-commit' -> files included in HEAD commit - - 'commit' -> files included in the given commit hash (commit param required) - - Returns a list of file paths relative to the workspace root. If not a git repo or detection fails, returns []. + - 'staged' -> files staged for commit (git diff --name-only --cached) + - 'current-commit' -> files included in the HEAD commit + - 'commit' -> files included in the given commit hash (commit param required) + - 'pr' -> files changed relative to a base ref (a GitHub PR). + Uses ``base_ref`` or ``GITHUB_BASE_REF`` and excludes + deletions so removed paths never become scan targets. + - 'auto' -> the PR base-ref diff when running in a PR CI context + (``GITHUB_BASE_REF`` is set), otherwise staged changes. + This is what ``--changed-files auto`` resolves to. + + Returns a list of file paths relative to the workspace root. If not a git + repo or detection fails, returns []. """ try: from subprocess import check_output, CalledProcessError import subprocess - + # Prefer GITHUB_WORKSPACE if set (GitHub Actions environment) # Otherwise use the provided workspace_path if os.environ.get('GITHUB_WORKSPACE'): ws = Path(os.environ['GITHUB_WORKSPACE']) else: ws = Path(workspace_path) if workspace_path else Path.cwd() - + if not ws.exists(): return [] @@ -1404,24 +1461,61 @@ def _detect_git_changed_files(workspace_path: str, mode: str = 'staged', commit: original_cwd = os.getcwd() try: os.chdir(str(ws)) - - if mode == 'staged': + + def _split(out: str) -> List[str]: + return [line.strip() for line in out.splitlines() if line.strip()] + + def _diff_against_base(ref: str) -> Optional[List[str]]: + """Diff changed files (excluding deletions) against a base ref. + + Tries the remote-tracking ref (``origin/``) first, then the + bare ref. Returns None when neither ref can be resolved so the + caller can fall back to another detection strategy. The + ``--diff-filter=ACMR`` excludes deleted paths so they never + become scan targets. + """ + if not ref: + return None + for candidate in (f'origin/{ref}', ref): + try: + out = check_output( + ['git', 'diff', '--name-only', '--diff-filter=ACMR', f'{candidate}...HEAD'], + text=True, stderr=subprocess.DEVNULL, + ) + return _split(out) + except CalledProcessError: + continue + return None + + if mode == 'auto': + # Prefer the PR base-ref diff in CI; fall back to staged changes + # for local/pre-commit use. + base = base_ref or os.environ.get('GITHUB_BASE_REF', '') + pr_files = _diff_against_base(base) + if pr_files is not None: + return pr_files + out = check_output(['git', 'diff', '--name-only', '--cached'], text=True, stderr=subprocess.DEVNULL) + return _split(out) + elif mode == 'pr': + base = base_ref or os.environ.get('GITHUB_BASE_REF', '') + return _diff_against_base(base) or [] + elif mode == 'staged': # staged but not yet committed out = check_output(['git', 'diff', '--name-only', '--cached'], text=True, stderr=subprocess.DEVNULL) + return _split(out) elif mode == 'current-commit': # files that are part of HEAD commit out = check_output(['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'], text=True, stderr=subprocess.DEVNULL) + return _split(out) elif mode == 'commit' and commit: out = check_output(['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', commit], text=True, stderr=subprocess.DEVNULL) + return _split(out) else: return [] - - files = [line.strip() for line in out.splitlines() if line.strip()] - return files finally: # Always restore original working directory os.chdir(original_cwd) - + except CalledProcessError: return [] except Exception: diff --git a/socket_basics/core/connector/opengrep/__init__.py b/socket_basics/core/connector/opengrep/__init__.py index 0cf4135..1125d11 100644 --- a/socket_basics/core/connector/opengrep/__init__.py +++ b/socket_basics/core/connector/opengrep/__init__.py @@ -48,6 +48,13 @@ def scan(self) -> Dict[str, Any]: targets = self.config.get_scan_targets() + # Diff-only / explicit file scoping resolved to no existing files (for + # example a delete-only PR). Running OpenGrep without any target makes it + # scan its own working directory and emit spurious findings, so skip. + if not targets: + logger.info('No scan targets to analyze (scoped scan matched no existing files); skipping OpenGrep') + return {} + # Check if custom rules mode is enabled custom_rules_path = self.config.get_custom_rules_path() custom_rule_files: Dict[str, Path] = {} diff --git a/tests/test_changed_files_scope.py b/tests/test_changed_files_scope.py new file mode 100644 index 0000000..3eda06d --- /dev/null +++ b/tests/test_changed_files_scope.py @@ -0,0 +1,172 @@ +"""Tests for diff-only (changed-files) scan scoping. + +SAST/OpenGrep (and the other connectors that call ``get_scan_targets``) must +honor ``changed_files`` so PRs report only on what the PR changed, instead of +re-scanning the whole repository. +""" + +import os +import subprocess +from argparse import Namespace + +import pytest + +from socket_basics.core.config import Config, _detect_git_changed_files, create_config_from_args + + +def _make_config(workspace, **overrides): + cfg = {"workspace": str(workspace)} + cfg.update(overrides) + return Config(cfg) + + +class TestGetScanTargets: + """Precedence and scoping behaviour of Config.get_scan_targets().""" + + def test_default_scans_whole_workspace(self, tmp_path): + (tmp_path / "a.py").write_text("x = 1") + assert _make_config(tmp_path).get_scan_targets() == [str(tmp_path)] + + def test_scan_all_returns_workspace(self, tmp_path): + (tmp_path / "a.py").write_text("x = 1") + cfg = _make_config(tmp_path, scan_all=True, changed_files=["a.py"]) + # scan_all is an explicit override and wins over changed_files + assert cfg.get_scan_targets() == [str(tmp_path)] + + def test_changed_files_scopes_to_existing_files(self, tmp_path): + (tmp_path / "a.py").write_text("x = 1") + (tmp_path / "b.py").write_text("y = 2") + cfg = _make_config(tmp_path, changed_files=["a.py"]) + assert cfg.get_scan_targets() == [str(tmp_path / "a.py")] + + def test_changed_files_skips_missing_paths(self, tmp_path): + (tmp_path / "a.py").write_text("x = 1") + cfg = _make_config(tmp_path, changed_files=["a.py", "gone.py"]) + assert cfg.get_scan_targets() == [str(tmp_path / "a.py")] + + def test_delete_only_pr_returns_empty(self, tmp_path): + # All changed paths were deleted -> nothing to scan. Must NOT fall back + # to scanning the whole workspace/cwd (the footgun this fixes). + cfg = _make_config(tmp_path, changed_files=["gone.py"]) + assert cfg.get_scan_targets() == [] + + def test_changed_files_takes_precedence_over_scan_files(self, tmp_path): + (tmp_path / "a.py").write_text("x = 1") + (tmp_path / "b.py").write_text("y = 2") + cfg = _make_config(tmp_path, scan_files="a.py", changed_files=["b.py"]) + assert cfg.get_scan_targets() == [str(tmp_path / "b.py")] + + def test_scan_files_used_when_no_changed_files(self, tmp_path): + (tmp_path / "a.py").write_text("x = 1") + cfg = _make_config(tmp_path, scan_files="a.py") + assert cfg.get_scan_targets() == [str(tmp_path / "a.py")] + + def test_absolute_changed_file_path_preserved(self, tmp_path): + abs_path = tmp_path / "a.py" + abs_path.write_text("x = 1") + cfg = _make_config(tmp_path, changed_files=[str(abs_path)]) + assert cfg.get_scan_targets() == [str(abs_path)] + + +def _git(repo, *args): + env = { + **os.environ, + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@example.com", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@example.com", + } + return subprocess.run( + ["git", "-C", str(repo), *args], capture_output=True, text=True, env=env + ) + + +def _config_args(workspace, changed_files): + return Namespace( + config=None, + workspace=str(workspace), + scan_files=None, + console_tabular_enabled=False, + output_console_enabled=False, + console_json_enabled=False, + output_json_enabled=False, + verbose=False, + repo="test/repo", + branch="feature", + default_branch=False, + commit_message=None, + pull_request=None, + committers=None, + enable_s3_upload=False, + output=".socket.facts.json", + changed_files=changed_files, + ) + + +@pytest.fixture +def pr_repo(tmp_path, monkeypatch): + """A git repo with a 'main' base and a 'feature' branch ahead of it.""" + # _detect_git_changed_files prefers GITHUB_WORKSPACE; clear it so the + # explicit workspace path is used. + monkeypatch.delenv("GITHUB_WORKSPACE", raising=False) + monkeypatch.delenv("GITHUB_BASE_REF", raising=False) + + _git(tmp_path, "init", "-b", "main") + (tmp_path / "base.py").write_text("base = 1") + (tmp_path / "old.py").write_text("old = 1") + _git(tmp_path, "add", ".") + _git(tmp_path, "commit", "-m", "base") + + _git(tmp_path, "checkout", "-b", "feature") + (tmp_path / "feat.py").write_text("feat = 1") + (tmp_path / "base.py").write_text("base = 2") # modify + (tmp_path / "old.py").unlink() # delete + _git(tmp_path, "add", "-A") + _git(tmp_path, "commit", "-m", "feature") + return tmp_path + + +class TestDetectGitChangedFiles: + + def test_pr_mode_lists_added_and_modified(self, pr_repo): + files = _detect_git_changed_files(str(pr_repo), mode="pr", base_ref="main") + assert sorted(files) == ["base.py", "feat.py"] + + def test_pr_mode_excludes_deletions(self, pr_repo): + files = _detect_git_changed_files(str(pr_repo), mode="pr", base_ref="main") + assert "old.py" not in files + + def test_auto_uses_base_ref_env(self, pr_repo, monkeypatch): + monkeypatch.setenv("GITHUB_BASE_REF", "main") + files = _detect_git_changed_files(str(pr_repo), mode="auto") + assert sorted(files) == ["base.py", "feat.py"] + + def test_auto_falls_back_to_staged_without_base_ref(self, pr_repo): + # No GITHUB_BASE_REF and no base_ref -> staged changes (none staged here) + (pr_repo / "staged.py").write_text("s = 1") + _git(pr_repo, "add", "staged.py") + files = _detect_git_changed_files(str(pr_repo), mode="auto") + assert files == ["staged.py"] + + def test_non_git_dir_returns_empty(self, tmp_path, monkeypatch): + monkeypatch.delenv("GITHUB_WORKSPACE", raising=False) + assert _detect_git_changed_files(str(tmp_path), mode="pr", base_ref="main") == [] + + def test_delete_only_pr_config_creation_keeps_empty_scope(self, tmp_path, monkeypatch): + monkeypatch.delenv("GITHUB_WORKSPACE", raising=False) + monkeypatch.setenv("GITHUB_BASE_REF", "main") + + _git(tmp_path, "init", "-b", "main") + (tmp_path / "old.py").write_text("old = 1") + _git(tmp_path, "add", ".") + _git(tmp_path, "commit", "-m", "base") + + _git(tmp_path, "checkout", "-b", "feature") + (tmp_path / "old.py").unlink() + _git(tmp_path, "add", "-A") + _git(tmp_path, "commit", "-m", "delete old") + + cfg = create_config_from_args(_config_args(tmp_path, "pr")) + + assert cfg.get("changed_files") == [] + assert cfg.get_scan_targets() == [] From e7e24ee7e2ca43a6c0235cee3dfecbb446b4f1cf Mon Sep 17 00:00:00 2001 From: David Larsen Date: Fri, 26 Jun 2026 17:16:56 -0400 Subject: [PATCH 02/10] fix: warn when socket_org is missing (#23) Warn when scan results cannot be uploaded because socket_org is unavailable after API key configuration. Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- socket_basics/socket_basics.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/socket_basics/socket_basics.py b/socket_basics/socket_basics.py index a7f7f04..deb323d 100644 --- a/socket_basics/socket_basics.py +++ b/socket_basics/socket_basics.py @@ -240,7 +240,12 @@ def submit_socket_facts(self, socket_facts_path: Path, results: Dict[str, Any]) socket_org = self.config.get('socket_org') if not socket_org: - logger.debug("No Socket organization configured, skipping full scan submission") + logger.warning( + "No Socket organization configured - scan results will not be uploaded to the dashboard. " + "This typically means your API key is missing the 'socket-basics:read' scope. " + "Please create an API key with the required scopes in Settings > API Tokens " + "in the Socket dashboard (https://socket.dev)." + ) return results # Import socketdev SDK From 4a91df776f113d139b51844ca5b5f41cfd428661 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:18:22 -0400 Subject: [PATCH 03/10] feat: add local ignore overrides for rule IDs + filepaths (#59) * fix: support local SAST ignore overrides by rule id, path Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix: skip ignored SAST findings in blocking logic Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix: strip common CI workspace prefixes from filepaths before matching Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * chore: add tests for GHA and common CI checkout path routes Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * docs: describe new low severity label option Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * chore: add low severity config to notifications Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix: check severity label on reruns, improve error logging, add tests Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix: clear stale labels when findings are downgraded, ignored, or resolved Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix: reconcile severity labels on all-clear reruns Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix: update existing PR comments to add all-clear message Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix: update phrasing in test assertion for PR comment body Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * docs: update docs to reflect new override and PR comment lifecycle capabilities Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * chore: revert action manifest back to proper GHCR image path Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix: add diagnostics, logging for invalid SAST override filepaths Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * chore: point action manifest at Dockerfile for branch testing Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix: scope all-clear PR message to the correct scanner comment Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * chore: restore action manifest to GHCR image path Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix: reconcile PR comments on all-clear reruns --------- Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --- action.yml | 12 +- docs/github-action.md | 36 ++ docs/github-pr-comment-guide.md | 23 +- docs/parameters.md | 28 ++ socket_basics/connectors.yaml | 6 + socket_basics/core/config.py | 193 ++++++++ socket_basics/core/connector/normalizer.py | 14 + .../core/connector/opengrep/__init__.py | 3 + .../core/notification/github_pr_notifier.py | 445 ++++++++++++++++-- socket_basics/core/notification/manager.py | 9 + socket_basics/notifications.yaml | 6 + socket_basics/socket_basics.py | 18 +- tests/test_github_pr_notifier.py | 349 ++++++++++++++ tests/test_notification_manager_github_pr.py | 22 + tests/test_sast_ignore_overrides.py | 182 +++++++ 15 files changed, 1298 insertions(+), 48 deletions(-) create mode 100644 tests/test_github_pr_notifier.py create mode 100644 tests/test_notification_manager_github_pr.py create mode 100644 tests/test_sast_ignore_overrides.py diff --git a/action.yml b/action.yml index 2480e97..867c3f7 100644 --- a/action.yml +++ b/action.yml @@ -43,6 +43,7 @@ runs: INPUT_JAVASCRIPT_DISABLED_RULES: ${{ inputs.javascript_disabled_rules }} INPUT_JAVASCRIPT_ENABLED_RULES: ${{ inputs.javascript_enabled_rules }} INPUT_JAVASCRIPT_SAST_ENABLED: ${{ inputs.javascript_sast_enabled }} + INPUT_SAST_IGNORE_OVERRIDES: ${{ inputs.sast_ignore_overrides }} INPUT_JAVA_DISABLED_RULES: ${{ inputs.java_disabled_rules }} INPUT_JAVA_ENABLED_RULES: ${{ inputs.java_enabled_rules }} INPUT_JAVA_SAST_ENABLED: ${{ inputs.java_sast_enabled }} @@ -100,6 +101,7 @@ runs: INPUT_PR_LABEL_CRITICAL: ${{ inputs.pr_label_critical }} INPUT_PR_LABEL_HIGH: ${{ inputs.pr_label_high }} INPUT_PR_LABEL_MEDIUM: ${{ inputs.pr_label_medium }} + INPUT_PR_LABEL_LOW: ${{ inputs.pr_label_low }} inputs: workspace: @@ -269,6 +271,10 @@ inputs: description: "Enable JavaScript/TypeScript SAST scanning" required: false default: "false" + sast_ignore_overrides: + description: "Comma-separated list of SAST ignore overrides in rule_id or rule_id:path format" + required: false + default: "" jira_api_token: description: "Jira Api Token" required: false @@ -473,7 +479,11 @@ inputs: description: "Label name for medium severity findings" required: false default: "security: medium" + pr_label_low: + description: "Label name for low severity findings" + required: false + default: "security: low" branding: icon: "shield" - color: "blue" + color: "purple" diff --git a/docs/github-action.md b/docs/github-action.md index 2438b3c..80e3c5c 100644 --- a/docs/github-action.md +++ b/docs/github-action.md @@ -705,8 +705,29 @@ jobs: # JavaScript with custom rules javascript_sast_enabled: 'true' javascript_enabled_rules: 'eval-usage,prototype-pollution' + + # Ignore one or more SAST rules globally or for exact repo-relative files + sast_ignore_overrides: 'js-sql-injection:index.js' ``` +`sast_ignore_overrides` supports: +- `rule_id` to ignore a SAST rule everywhere in the repo +- `rule_id:path` to ignore a SAST rule for one exact repo-relative file + +Examples: +- `js-sql-injection` +- `js-sql-injection:index.js` +- `js-sql-injection:src/unsafe/demo.js` +- `js-express-async-no-error-handler,js-sql-injection:index.js,js-missing-helmet` + +Notes: +- Paths must be exact repo-relative paths using `/` separators after normalization. +- Windows-style input such as `src\\unsafe\\demo.js` is accepted and normalized automatically. +- Globs and directory-prefix matching are not supported in this first version. +- A `rule_id:path` entry is an exact `rule_id AND path` match. If the path does not match, Socket Basics will not fall back to a rule-only ignore. +- Broad dashboard rule disables such as `_disabled_rules` still ignore that rule everywhere in the repo. If both are configured, the broad disabled-rule behavior can make it look like a narrow path override matched when it did not. +- In `.socket.facts.json`, ignored alerts include `actionReason` so you can see whether the ignore came from `sast_ignore_override` or `disabled_rule`. + ## Configuration Reference ### All Available Inputs @@ -734,6 +755,7 @@ See [`action.yml`](../action.yml) for the complete list of inputs. **Rule Configuration (per language):** - `_enabled_rules` — Comma-separated rules to enable - `_disabled_rules` — Comma-separated rules to disable +- `sast_ignore_overrides` — Comma-separated `rule_id` or `rule_id:path` SAST ignore overrides **Security Scanning:** - `secret_scanning_enabled` — Enable secret scanning @@ -833,6 +855,20 @@ permissions: 2. Check that `socket_org` and `socket_security_api_key` are set correctly 3. Confirm API key has required permissions in Socket Dashboard +### `sast_ignore_overrides` Seems Too Broad + +**Problem:** A `rule_id:path` override appears to ignore findings outside the specified file. + +**Likely cause:** The rule is also disabled more broadly in dashboard settings or other config through `_disabled_rules`. + +**How to confirm:** +1. Open the generated `.socket.facts.json` +2. Find the ignored alert and inspect `actionReason` +3. `actionReason: "sast_ignore_override"` means the exact path override matched +4. `actionReason: "disabled_rule"` means the finding was ignored by a broad rule disable instead + +**Additional signal:** If the configured path does not exist under the workspace, Socket Basics logs a warning and does not fall back to rule-only matching. + ### High Memory Usage **Problem:** Action runs out of memory. diff --git a/docs/github-pr-comment-guide.md b/docs/github-pr-comment-guide.md index 865bd31..640e28f 100644 --- a/docs/github-pr-comment-guide.md +++ b/docs/github-pr-comment-guide.md @@ -243,6 +243,7 @@ Automatically tag PRs with severity-based labels **and matching colors**. - `security: critical` 🔴 - Red (`#D73A4A`) - `security: high` 🟠 - Orange (`#D93F0B`) - `security: medium` 🟡 - Yellow (`#FBCA04`) +- `security: low` ⚪ - Light gray (`#E4E4E4`) **Smart color detection:** Labels are automatically created with colors matching the severity emojis. If you customize label names, the system intelligently detects severity keywords and applies appropriate colors: @@ -253,7 +254,9 @@ pr_label_high: 'security-high' # Gets orange color automatically ``` **How it works:** -- First scan checks for critical → high → medium (highest severity wins) +- Each run keeps only the current highest-severity managed PR label: critical → high → medium → low +- Stale managed severity labels from earlier runs are removed automatically +- If a later run has no active findings, the managed severity label is removed - Labels are created automatically if they don't exist - Existing labels are not modified (preserves your customizations) - Requires a token with `repo` scope to create new labels; without it, label creation may fail (comments still post) @@ -264,6 +267,7 @@ pr_labels_enabled: 'true' pr_label_critical: 'vulnerability: critical' pr_label_high: 'vulnerability: high' pr_label_medium: 'vulnerability: medium' +pr_label_low: 'vulnerability: low' ``` **Disable:** @@ -287,6 +291,22 @@ The logo is a 32px PNG rendered at 24x24 for retina-crisp display, with a transp --- +### 9. All-Clear Comment Updates + +When a later Socket Basics run no longer has active findings for a previously-reported scanner section, the existing PR comment section is updated in place instead of being left stale or deleted. + +**Behavior:** +- Existing Socket-managed sections are preserved for auditability +- Stale findings content is replaced with a short all-clear message +- This keeps the PR history readable while making it obvious that the latest run is clean + +**Example all-clear message:** +```text +✅ Socket Basics found no active findings in the latest run. +``` + +--- + ## 📋 Configuration Reference ### All Options @@ -302,6 +322,7 @@ The logo is a 32px PNG rendered at 24x24 for retina-crisp display, with a transp | `pr_label_critical` | `"security: critical"` | string | Label name for critical findings | | `pr_label_high` | `"security: high"` | string | Label name for high findings | | `pr_label_medium` | `"security: medium"` | string | Label name for medium findings | +| `pr_label_low` | `"security: low"` | string | Label name for low findings | ### Configuration Methods diff --git a/docs/parameters.md b/docs/parameters.md index e1ad6c6..95fd352 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -259,6 +259,32 @@ socket-basics --go --go-enabled-rules "error-handling,sql-injection" - `--rust-enabled-rules` / `--rust-disabled-rules` - `--elixir-enabled-rules` / `--elixir-disabled-rules` +### `--sast-ignore-overrides SAST_IGNORE_OVERRIDES` +Comma-separated list of SAST ignore overrides in `rule_id` or `rule_id:path` format. + +**Environment Variable:** `INPUT_SAST_IGNORE_OVERRIDES` + +**Examples:** +```bash +# Ignore a rule everywhere in the repo +socket-basics --javascript --sast-ignore-overrides "js-sql-injection" + +# Ignore a rule only for one exact repo-relative file +socket-basics --javascript --sast-ignore-overrides "js-sql-injection:index.js" + +# Mix rule-only and rule+path overrides in one comma-separated list +socket-basics --javascript --sast-ignore-overrides "js-express-async-no-error-handler,js-sql-injection:index.js,js-missing-helmet" +``` + +Notes: +- Paths must be exact repo-relative paths. +- Paths are normalized to forward-slash form, so Windows-style input such as `src\\unsafe\\demo.js` is accepted. +- Globs and directory-prefix matching are not supported in this first version. +- A `rule_id:path` entry uses exact `rule_id AND path` matching. A bad path does not degrade into a rule-only ignore. +- If the configured path does not exist under the current workspace, Socket Basics logs a warning to help catch typos or copied paths from another repo. +- If the same rule is also disabled via `-disabled-rules` or dashboard policy, that broader ignore still applies across the repo. +- Ignored alerts in `.socket.facts.json` include `actionReason` so you can distinguish `sast_ignore_override` from `disabled_rule`. + ### `--opengrep-notify OPENGREP_NOTIFY` Notification method for OpenGrep SAST results (e.g., console, slack). @@ -550,6 +576,7 @@ All notification integrations support environment variables as alternatives to C | Variable | Description | |----------|-------------| | `INPUT_OPENGREP_RULES_DIR` | Custom directory containing SAST rules | +| `INPUT_SAST_IGNORE_OVERRIDES` | Comma-separated `rule_id` or `rule_id:path` SAST ignore overrides | ## Configuration File @@ -567,6 +594,7 @@ You can provide configuration via a JSON file using `--config`: "python_sast_enabled": true, "javascript_sast_enabled": true, "go_sast_enabled": true, + "sast_ignore_overrides": "js-sql-injection:index.js", "secrets_enabled": true, "trufflehog_exclude_dir": "node_modules,vendor,dist,.git", diff --git a/socket_basics/connectors.yaml b/socket_basics/connectors.yaml index 815a85e..7df8b03 100644 --- a/socket_basics/connectors.yaml +++ b/socket_basics/connectors.yaml @@ -191,6 +191,12 @@ connectors: env_variable: INPUT_JAVASCRIPT_DISABLED_RULES type: str default: "" + - name: sast_ignore_overrides + option: --sast-ignore-overrides + description: "Comma-separated list of SAST ignore overrides in rule_id or rule_id:path format" + env_variable: INPUT_SAST_IGNORE_OVERRIDES + type: str + default: "" # Go rule configuration - name: go_enabled_rules diff --git a/socket_basics/core/config.py b/socket_basics/core/config.py index 5f6a962..7c9b2f6 100644 --- a/socket_basics/core/config.py +++ b/socket_basics/core/config.py @@ -15,6 +15,160 @@ logger = logging.getLogger(__name__) +def _normalize_path_parts(path_value: str | None) -> List[str] | None: + """Normalize a path-like string into comparable POSIX-style path segments.""" + if path_value is None: + return None + + path_str = str(path_value).strip() + if not path_str: + return None + + path_str = path_str.replace('\\', '/') + while path_str.startswith('./'): + path_str = path_str[2:] + path_str = path_str.lstrip('/') + + normalized_parts: List[str] = [] + for part in path_str.split('/'): + if not part or part == '.': + continue + if part == '..': + return None + normalized_parts.append(part) + + return normalized_parts or None + + +def _get_workspace_prefix_candidates() -> List[List[str]]: + """Return normalized workspace roots from common CI systems and local cwd.""" + candidate_values: List[str] = [] + for env_var in ( + 'BITBUCKET_CLONE_DIR', + 'BUILD_SOURCESDIRECTORY', + 'BUILDKITE_BUILD_CHECKOUT_PATH', + 'CI_PROJECT_DIR', + 'CIRCLE_WORKING_DIRECTORY', + 'DRONE_WORKSPACE', + 'GITHUB_WORKSPACE', + 'SYSTEM_DEFAULTWORKINGDIRECTORY', + 'WORKSPACE', + ): + env_value = os.getenv(env_var) + if env_value: + candidate_values.append(env_value) + + try: + candidate_values.append(os.getcwd()) + except Exception: + pass + + normalized_candidates: List[List[str]] = [] + seen: set[tuple[str, ...]] = set() + for value in candidate_values: + parts = _normalize_path_parts(value) + if not parts: + continue + parts_key = tuple(parts) + if parts_key in seen: + continue + seen.add(parts_key) + normalized_candidates.append(parts) + + # Check longer, more specific prefixes first. + normalized_candidates.sort(key=len, reverse=True) + return normalized_candidates + + +def normalize_repo_relative_path(path_value: str | None) -> str | None: + """Normalize a repo-relative path to the POSIX form emitted by SAST alerts.""" + normalized_parts = _normalize_path_parts(path_value) + if not normalized_parts: + return None + + for workspace_parts in _get_workspace_prefix_candidates(): + if len(normalized_parts) > len(workspace_parts) and normalized_parts[:len(workspace_parts)] == workspace_parts: + normalized_parts = normalized_parts[len(workspace_parts):] + break + + normalized = '/'.join(normalized_parts) + return normalized or None + + +def parse_sast_ignore_overrides(raw_value: str | None) -> List[Dict[str, str | None]]: + """Parse `rule_id` and `rule_id:path` ignore override entries.""" + overrides: List[Dict[str, str | None]] = [] + seen: set[tuple[str, str | None]] = set() + + if not raw_value: + return overrides + + for raw_entry in str(raw_value).split(','): + entry = raw_entry.strip() + if not entry: + continue + + rule_id = entry + path = None + + if ':' in entry: + rule_id, path_part = entry.split(':', 1) + rule_id = rule_id.strip() + path_part = path_part.strip() + + if not rule_id or not path_part: + logger.warning("Ignoring malformed SAST ignore override: %r", entry) + continue + + if any(ch in path_part for ch in ('*', '?', '[')): + logger.warning( + "Ignoring unsupported SAST ignore override with glob syntax: %r", + entry, + ) + continue + + path = normalize_repo_relative_path(path_part) + if not path: + logger.warning("Ignoring invalid repo-relative path in SAST override: %r", entry) + continue + else: + rule_id = rule_id.strip() + if not rule_id: + logger.warning("Ignoring malformed SAST ignore override: %r", entry) + continue + + key = (rule_id, path) + if key in seen: + continue + seen.add(key) + overrides.append({'rule_id': rule_id, 'path': path}) + + return overrides + + +def alert_matches_sast_ignore_override( + alert: Dict[str, Any], + override: Dict[str, str | None], +) -> bool: + """Return True when an alert matches a parsed SAST ignore override.""" + props = alert.get('props', {}) or {} + rule_id = props.get('ruleId') or alert.get('title') or alert.get('ruleId') + if not rule_id or rule_id != override.get('rule_id'): + return False + + override_path = override.get('path') + if not override_path: + return True + + alert_path = ( + props.get('filePath') + or (alert.get('location') or {}).get('path') + or '' + ) + normalized_alert_path = normalize_repo_relative_path(alert_path) + return normalized_alert_path == override_path + + class Config: """Configuration object that provides unified access to all settings""" @@ -146,6 +300,45 @@ def get_action_for_severity(self, severity: str) -> str: else: # Default action for unknown severities return 'monitor' + + def get_sast_ignore_overrides(self) -> List[Dict[str, str | None]]: + """Return parsed SAST ignore overrides from config.""" + if not hasattr(self, '_sast_ignore_overrides_cache'): + raw_value = self.get('sast_ignore_overrides', '') + overrides = parse_sast_ignore_overrides(raw_value) + + workspace_value = self.get('workspace') or os.getcwd() + try: + workspace_root = Path(str(workspace_value)).expanduser() + except Exception: + workspace_root = Path(os.getcwd()) + + for override in overrides: + override_path = override.get('path') + if not override_path: + continue + + try: + candidate = workspace_root.joinpath(*str(override_path).split('/')) + if not candidate.exists(): + logger.warning( + "SAST ignore override path %r for rule %r does not exist under workspace %s; " + "exact path overrides require a repo-relative file path and will not fall back to " + "rule-only matching.", + override_path, + override.get('rule_id'), + workspace_root, + ) + except Exception: + logger.debug( + "Failed to validate SAST ignore override path %r under workspace %r", + override_path, + workspace_value, + exc_info=True, + ) + + self._sast_ignore_overrides_cache = overrides + return self._sast_ignore_overrides_cache @property def repo(self) -> str: diff --git a/socket_basics/core/connector/normalizer.py b/socket_basics/core/connector/normalizer.py index 8837aa2..cd8c1c2 100644 --- a/socket_basics/core/connector/normalizer.py +++ b/socket_basics/core/connector/normalizer.py @@ -11,6 +11,7 @@ from typing import Any, Dict, List, Tuple import logging import os +from ..config import alert_matches_sast_ignore_override logger = logging.getLogger(__name__) @@ -38,6 +39,18 @@ def _normalize_alert(a: Dict[str, Any], connector: Any | None = None, default_ge a['severity'] = a['severity'].lower() # Minimal normalization: lowercase severity and ensure action exists + # Honor local SAST ignore overrides before deriving actions from severity. + try: + if connector and hasattr(connector, 'config') and hasattr(connector.config, 'get_sast_ignore_overrides'): + for override in connector.config.get_sast_ignore_overrides(): + if alert_matches_sast_ignore_override(a, override): + logger.debug("Alert matched sast_ignore_overrides entry %s", override) + a['action'] = 'ignore' + a['actionReason'] = 'sast_ignore_override' + return a + except Exception: + logger.debug('Failed to check SAST ignore overrides for alert', exc_info=True) + # Check if this alert's rule is in the disabled rules list for any language # If so, set action to 'ignore' regardless of severity try: @@ -61,6 +74,7 @@ def _normalize_alert(a: Dict[str, Any], connector: Any | None = None, default_ge if rule_id in disabled_rules: logger.debug(f"Rule {rule_id} is disabled via {param}, setting action to 'ignore'") a['action'] = 'ignore' + a['actionReason'] = 'disabled_rule' return a except Exception: pass diff --git a/socket_basics/core/connector/opengrep/__init__.py b/socket_basics/core/connector/opengrep/__init__.py index 1125d11..ff4aebb 100644 --- a/socket_basics/core/connector/opengrep/__init__.py +++ b/socket_basics/core/connector/opengrep/__init__.py @@ -727,6 +727,9 @@ def generate_notifications(self, components: List[Dict[str, Any]]) -> Dict[str, groups: Dict[str, List[Dict[str, Any]]] = {} for c in comps_map.values(): for a in c.get('alerts', []): + alert_action = (a.get('action') or '').strip().lower() + if alert_action == 'ignore': + continue # Filter by severity - only include alerts that match allowed severities alert_severity = (a.get('severity') or '').strip().lower() if alert_severity and hasattr(self, 'allowed_severities') and alert_severity not in self.allowed_severities: diff --git a/socket_basics/core/notification/github_pr_notifier.py b/socket_basics/core/notification/github_pr_notifier.py index 555d03e..a87e1ea 100644 --- a/socket_basics/core/notification/github_pr_notifier.py +++ b/socket_basics/core/notification/github_pr_notifier.py @@ -1,5 +1,6 @@ from typing import Any, Dict, List, Optional import logging +from urllib.parse import quote from socket_basics.core.notification.base import BaseNotifier from socket_basics.core.config import get_github_token, get_github_repository, get_github_pr_number @@ -35,14 +36,11 @@ def __init__(self, params: Dict[str, Any] | None = None): def notify(self, facts: Dict[str, Any]) -> None: notifications = facts.get('notifications', []) or [] + labels_enabled = self.config.get('pr_labels_enabled', True) if not isinstance(notifications, list): logger.error('GithubPRNotifier: only supports new format - list of dicts with title/content') return - - if not notifications: - logger.info('GithubPRNotifier: no notifications present; skipping') - return # Get full scan URL if available and store it for use in truncation self.full_scan_url = facts.get('full_scan_html_url') @@ -56,7 +54,21 @@ def notify(self, facts: Dict[str, Any]) -> None: else: logger.warning('GithubPRNotifier: skipping invalid notification item: %s', type(item)) + notification_section_types = self._extract_section_types_from_notifications(valid_notifications) + facts_section_types = self._infer_section_types_from_facts(facts) + if not valid_notifications: + pr_number = self._get_pr_number() + if pr_number: + if labels_enabled: + self._reconcile_pr_labels(pr_number, []) + self._replace_existing_sections_with_all_clear( + pr_number, + section_types=facts_section_types, + ) + else: + logger.warning('GithubPRNotifier: unable to determine PR number for all-clear reconciliation') + logger.info('GithubPRNotifier: no notifications present; skipping comments') return # Get PR number for current branch @@ -115,11 +127,46 @@ def notify(self, facts: Dict[str, Any]) -> None: else: logger.error('GithubPRNotifier: failed to post individual comment') + stale_section_types = facts_section_types - notification_section_types + if stale_section_types: + self._replace_existing_sections_with_all_clear(pr_number, section_types=stale_section_types) + # Add labels to PR if enabled - if self.config.get('pr_labels_enabled', True) and pr_number: + if labels_enabled and pr_number: labels = self._determine_pr_labels(valid_notifications) - if labels: - self._add_pr_labels(pr_number, labels) + self._reconcile_pr_labels(pr_number, labels) + def _managed_pr_label_config(self) -> Dict[str, str]: + """Return the managed severity label names configured for PRs.""" + return { + 'critical': self.config.get('pr_label_critical', 'security: critical'), + 'high': self.config.get('pr_label_high', 'security: high'), + 'medium': self.config.get('pr_label_medium', 'security: medium'), + 'low': self.config.get('pr_label_low', 'security: low'), + } + + def _get_label_color_info(self, label: str) -> Optional[tuple[str, str]]: + """Infer color/description for managed or custom severity labels.""" + label_colors = { + self.config.get('pr_label_critical', 'security: critical'): ('D73A4A', 'Critical security vulnerabilities'), + self.config.get('pr_label_high', 'security: high'): ('D93F0B', 'High severity security issues'), + self.config.get('pr_label_medium', 'security: medium'): ('FBCA04', 'Medium severity security issues'), + self.config.get('pr_label_low', 'security: low'): ('E4E4E4', 'Low severity security issues'), + } + color_info = label_colors.get(label) + if color_info: + return color_info + + label_lower = label.lower() + if 'critical' in label_lower: + return ('D73A4A', 'Critical security vulnerabilities') + if 'high' in label_lower: + return ('D93F0B', 'High severity security issues') + if 'medium' in label_lower: + return ('FBCA04', 'Medium severity security issues') + if 'low' in label_lower: + return ('E4E4E4', 'Low severity security issues') + return None + def _send_pr_comment(self, facts: Dict[str, Any], title: str, content: str) -> None: """Send a single PR comment with title and content.""" @@ -252,6 +299,204 @@ def _extract_section_markers(self, content: str) -> Optional[Dict[str, str]]: return None + def _extract_all_section_types(self, comment_body: str) -> List[str]: + """Extract all managed section markers from a comment body.""" + import re + + pattern = r'' + return re.findall(pattern, comment_body or '') + + def _extract_section_types_from_notifications(self, notifications: List[Dict[str, Any]]) -> set[str]: + """Return the managed section types present in notifier payload content.""" + section_types: set[str] = set() + for notification in notifications or []: + if not isinstance(notification, dict): + continue + section_match = self._extract_section_markers(notification.get('content', '')) + if section_match and section_match.get('type'): + section_types.add(section_match['type']) + return section_types + + def _infer_section_types_from_facts(self, facts: Dict[str, Any]) -> set[str]: + """Infer managed PR section types represented by the current run's components.""" + section_types: set[str] = set() + + for component in facts.get('components', []) or []: + if not isinstance(component, dict): + continue + + self._add_section_type_from_component(component, section_types) + + alerts = component.get('alerts', []) or [] + for alert in alerts: + if not isinstance(alert, dict): + continue + + self._add_section_type_from_alert(alert, section_types) + + section_types.update(self._infer_section_types_from_config()) + return section_types + + def _add_section_type_from_alert(self, alert: Dict[str, Any], section_types: set[str]) -> None: + """Add a managed section type based on alert metadata.""" + generated_by = (alert.get('generatedBy') or '').strip().lower() + subtype = (alert.get('subType') or alert.get('subtype') or '').strip().lower() + + if subtype.startswith('sast-'): + section_types.add(subtype) + return + + if generated_by == 'socket-tier1' or subtype == 'socket-tier1': + section_types.add('socket-tier1') + return + + if generated_by == 'trufflehog' or subtype == 'secrets': + section_types.add('trufflehog-secrets') + return + + if generated_by.startswith('trivy-') or subtype in {'dockerfile', 'container-image'}: + section_types.add('trivy-container') + + def _add_section_type_from_component(self, component: Dict[str, Any], section_types: set[str]) -> None: + """Add a managed section type based on component metadata.""" + subtype = ( + component.get('subType') + or component.get('subtype') + or component.get('subPath') + or '' + ) + subtype = str(subtype).strip().lower() + if subtype.startswith('sast-'): + section_types.add(subtype) + return + + qualifiers = component.get('qualifiers') or {} + if not isinstance(qualifiers, dict): + qualifiers = {} + + scanner = str(qualifiers.get('scanner') or '').strip().lower() + language = str(qualifiers.get('type') or qualifiers.get('language') or '').strip().lower() + if scanner in {'sast', 'opengrep'} and language: + mapped = self._sast_language_section_type(language) + if mapped: + section_types.add(mapped) + + def _runtime_config_value(self, key: str, default: Any = None) -> Any: + """Return notifier params, falling back to the application config.""" + if isinstance(self.config, dict) and key in self.config: + return self.config.get(key) + + app_config = getattr(self, 'app_config', None) + if isinstance(app_config, dict) and key in app_config: + return app_config.get(key) + + return default + + def _runtime_config_enabled(self, key: str) -> bool: + """Interpret a runtime config value as a boolean enablement flag.""" + value = self._runtime_config_value(key, False) + if isinstance(value, str): + return value.strip().lower() in {'1', 'true', 'yes', 'on'} + return bool(value) + + def _sast_language_section_type(self, language: str) -> Optional[str]: + """Map a SAST language/config name to the GitHub PR section marker.""" + normalized = language.strip().lower().replace('_', '-') + mapping = { + 'python': 'sast-python', + 'javascript': 'sast-javascript', + 'typescript': 'sast-javascript', + 'js': 'sast-javascript', + 'ts': 'sast-javascript', + 'go': 'sast-golang', + 'golang': 'sast-golang', + 'java': 'sast-java', + 'php': 'sast-php', + 'ruby': 'sast-ruby', + 'csharp': 'sast-csharp', + 'c-sharp': 'sast-csharp', + 'dotnet': 'sast-dotnet', + '.net': 'sast-dotnet', + 'c': 'sast-c', + 'cpp': 'sast-cpp', + 'c++': 'sast-cpp', + 'kotlin': 'sast-kotlin', + 'scala': 'sast-scala', + 'swift': 'sast-swift', + 'rust': 'sast-rust', + 'elixir': 'sast-elixir', + 'erlang': 'sast-erlang', + } + return mapping.get(normalized) + + def _infer_section_types_from_config(self) -> set[str]: + """Infer managed PR sections from enabled scanner configuration.""" + section_types: set[str] = set() + sast_flags = { + 'python_sast_enabled': 'python', + 'javascript_sast_enabled': 'javascript', + 'typescript_sast_enabled': 'typescript', + 'go_sast_enabled': 'go', + 'golang_sast_enabled': 'golang', + 'java_sast_enabled': 'java', + 'php_sast_enabled': 'php', + 'ruby_sast_enabled': 'ruby', + 'csharp_sast_enabled': 'csharp', + 'dotnet_sast_enabled': 'dotnet', + 'c_sast_enabled': 'c', + 'cpp_sast_enabled': 'cpp', + 'kotlin_sast_enabled': 'kotlin', + 'scala_sast_enabled': 'scala', + 'swift_sast_enabled': 'swift', + 'rust_sast_enabled': 'rust', + 'elixir_sast_enabled': 'elixir', + 'erlang_sast_enabled': 'erlang', + } + + if self._runtime_config_enabled('all_languages_enabled'): + for language in sast_flags.values(): + mapped = self._sast_language_section_type(language) + if mapped: + section_types.add(mapped) + else: + for flag, language in sast_flags.items(): + if self._runtime_config_enabled(flag): + mapped = self._sast_language_section_type(language) + if mapped: + section_types.add(mapped) + + if self._runtime_config_enabled('socket_tier_1_enabled'): + section_types.add('socket-tier1') + + if ( + self._runtime_config_enabled('secret_scanning_enabled') + or self._runtime_config_enabled('secrets_enabled') + ): + section_types.add('trufflehog-secrets') + + if ( + self._runtime_config_enabled('trivy_image_enabled') + or self._runtime_config_enabled('container_image_scanning_enabled') + or self._runtime_config_enabled('trivy_dockerfile_enabled') + or self._runtime_config_enabled('dockerfile_scanning_enabled') + or self._runtime_config_enabled('trivy_vuln_enabled') + ): + section_types.add('trivy-container') + + return section_types + + def _extract_section_title(self, section_content: str) -> str: + """Extract the display title from a wrapped PR comment section.""" + import re + + for line in (section_content or '').splitlines(): + stripped = line.strip() + if stripped.startswith('## '): + title = stripped[3:].strip() + title = re.sub(r']+>\s*', '', title).strip() + return title or 'Socket Security' + return 'Socket Security' + def _find_comment_with_section(self, comments: List[Dict[str, Any]], section_type: str) -> Optional[Dict[str, Any]]: """Find an existing comment that contains the given section type.""" import re @@ -277,6 +522,51 @@ def _update_section_in_comment(self, comment_body: str, section_type: str, new_s return updated_body + def _build_all_clear_section(self, section_type: str, existing_section_content: str) -> str: + """Build an all-clear replacement for an existing managed section.""" + from socket_basics.core.notification import github_pr_helpers as helpers + + title = self._extract_section_title(existing_section_content) + body = "✅ Socket Basics found no active findings in the latest run." + return helpers.wrap_pr_comment_section(section_type, title, body, self.full_scan_url) + + def _replace_existing_sections_with_all_clear(self, pr_number: int, section_types: Optional[set[str]] = None) -> None: + """Rewrite existing managed PR comment sections to an all-clear state.""" + existing_comments = self._get_pr_comments(pr_number) + for comment in existing_comments: + original_body = comment.get('body', '') + if not original_body: + continue + + updated_body = original_body + changed = False + for section_type in self._extract_all_section_types(original_body): + if section_types is not None and section_type not in section_types: + continue + section_match = self._extract_section_markers(updated_body) + if not section_match or section_match.get('type') != section_type: + import re + pattern = rf'.*?' + match = re.search(pattern, updated_body, re.DOTALL) + if not match: + continue + section_content = match.group(0) + else: + section_content = section_match['content'] + + all_clear_section = self._build_all_clear_section(section_type, section_content) + next_body = self._update_section_in_comment(updated_body, section_type, all_clear_section) + if next_body != updated_body: + updated_body = next_body + changed = True + + if changed: + success = self._update_comment(pr_number, comment['id'], updated_body) + if success: + logger.info('GithubPRNotifier: updated existing comment %s to all-clear state', comment['id']) + else: + logger.error('GithubPRNotifier: failed to update comment %s to all-clear state', comment['id']) + def _truncate_comment_if_needed(self, comment_body: str, full_scan_url: Optional[str] = None) -> str: """Truncate comment if it exceeds GitHub's character limit. @@ -423,19 +713,93 @@ def _ensure_label_exists_with_color(self, label_name: str, color: str, descripti logger.info('GithubPRNotifier: created label "%s" with color #%s', label_name, color) return True else: - logger.warning('GithubPRNotifier: failed to create label "%s": %s', - label_name, create_resp.status_code) + logger.warning( + 'GithubPRNotifier: failed to create label "%s": %s %s', + label_name, + create_resp.status_code, + create_resp.text[:200], + ) return False else: - logger.warning('GithubPRNotifier: unexpected response checking label: %s', resp.status_code) + logger.warning( + 'GithubPRNotifier: unexpected response checking label "%s": %s %s', + label_name, + resp.status_code, + resp.text[:200], + ) return False except Exception as e: logger.debug('GithubPRNotifier: exception ensuring label exists: %s', e) return False + def _ensure_pr_labels_exist(self, labels: List[str]) -> None: + """Ensure desired labels exist in the repository with appropriate colors.""" + for label in labels: + color_info = self._get_label_color_info(label) + if color_info: + color, description = color_info + self._ensure_label_exists_with_color(label, color, description) + + def _get_current_pr_label_names(self, pr_number: int) -> List[str]: + """Fetch current label names for the PR.""" + if not self.repository: + return [] + + try: + import requests + headers = { + 'Authorization': f'token {self.token}', + 'Accept': 'application/vnd.github.v3+json' + } + url = f"{self.api_base}/repos/{self.repository}/issues/{pr_number}/labels" + resp = requests.get(url, headers=headers, timeout=10) + if resp.status_code == 200: + payload = resp.json() + return [label.get('name') for label in payload if isinstance(label, dict) and label.get('name')] + logger.warning( + 'GithubPRNotifier: failed to fetch current labels for PR %s: %s %s', + pr_number, + resp.status_code, + resp.text[:200], + ) + except Exception as e: + logger.error('GithubPRNotifier: exception fetching current labels: %s', e) + return [] + + def _remove_pr_label(self, pr_number: int, label: str) -> bool: + """Remove a label from a PR.""" + if not self.repository or not label: + return False + + try: + import requests + headers = { + 'Authorization': f'token {self.token}', + 'Accept': 'application/vnd.github.v3+json' + } + encoded_label = quote(label, safe='') + url = f"{self.api_base}/repos/{self.repository}/issues/{pr_number}/labels/{encoded_label}" + resp = requests.delete(url, headers=headers, timeout=10) + if resp.status_code == 200: + logger.info('GithubPRNotifier: removed label from PR %s: %s', pr_number, label) + return True + if resp.status_code == 404: + logger.debug('GithubPRNotifier: label %s already absent from PR %s', label, pr_number) + return True + logger.warning( + 'GithubPRNotifier: failed to remove label "%s" from PR %s: %s %s', + label, + pr_number, + resp.status_code, + resp.text[:200], + ) + except Exception as e: + logger.error('GithubPRNotifier: exception removing label %s: %s', label, e) + return False + def _add_pr_labels(self, pr_number: int, labels: List[str]) -> bool: - """Add labels to a PR, ensuring they exist with appropriate colors. + """Add missing labels to a PR. Args: pr_number: PR number @@ -447,34 +811,6 @@ def _add_pr_labels(self, pr_number: int, labels: List[str]) -> bool: if not self.repository or not labels: return False - # Color mapping for severity labels (matching emoji colors) - label_colors = { - 'security: critical': ('D73A4A', 'Critical security vulnerabilities'), - 'security: high': ('D93F0B', 'High severity security issues'), - 'security: medium': ('FBCA04', 'Medium severity security issues'), - 'security: low': ('E4E4E4', 'Low severity security issues'), - } - - # Ensure labels exist with correct colors - for label in labels: - # Get color and description if this is a known severity label - color_info = label_colors.get(label) - if color_info: - color, description = color_info - self._ensure_label_exists_with_color(label, color, description) - # For custom label names, use a default color - elif ':' in label: - # Try to infer severity from label name - label_lower = label.lower() - if 'critical' in label_lower: - self._ensure_label_exists_with_color(label, 'D73A4A', 'Critical security vulnerabilities') - elif 'high' in label_lower: - self._ensure_label_exists_with_color(label, 'D93F0B', 'High severity security issues') - elif 'medium' in label_lower: - self._ensure_label_exists_with_color(label, 'FBCA04', 'Medium severity security issues') - elif 'low' in label_lower: - self._ensure_label_exists_with_color(label, 'E4E4E4', 'Low severity security issues') - try: import requests headers = { @@ -490,12 +826,33 @@ def _add_pr_labels(self, pr_number: int, labels: List[str]) -> bool: logger.info('GithubPRNotifier: added labels to PR %s: %s', pr_number, ', '.join(labels)) return True else: - logger.warning('GithubPRNotifier: failed to add labels: %s', resp.status_code) + logger.warning('GithubPRNotifier: failed to add labels: %s %s', resp.status_code, resp.text[:200]) return False except Exception as e: logger.error('GithubPRNotifier: exception adding labels: %s', e) return False + def _reconcile_pr_labels(self, pr_number: int, desired_labels: List[str]) -> bool: + """Reconcile managed severity labels on the PR to match the latest run.""" + managed_labels = set(filter(None, self._managed_pr_label_config().values())) + current_labels = set(self._get_current_pr_label_names(pr_number)) + desired_label_set = set(filter(None, desired_labels)) + + stale_labels = sorted(label for label in current_labels if label in managed_labels and label not in desired_label_set) + labels_to_add = sorted(label for label in desired_label_set if label not in current_labels) + + success = True + for label in stale_labels: + success = self._remove_pr_label(pr_number, label) and success + + if labels_to_add: + self._ensure_pr_labels_exist(labels_to_add) + success = self._add_pr_labels(pr_number, labels_to_add) and success + + if not stale_labels and not labels_to_add: + logger.info('GithubPRNotifier: PR %s severity labels already up to date', pr_number) + return success + def _determine_pr_labels(self, notifications: List[Dict[str, Any]]) -> List[str]: """Determine which labels to add based on notifications. @@ -517,6 +874,7 @@ def _determine_pr_labels(self, notifications: List[Dict[str, Any]]) -> List[str] critical_match = re.search(r'Critical:\s*(\d+)', content) high_match = re.search(r'High:\s*(\d+)', content) medium_match = re.search(r'Medium:\s*(\d+)', content) + low_match = re.search(r'Low:\s*(\d+)', content) if critical_match and int(critical_match.group(1)) > 0: severities_found.add('critical') @@ -524,6 +882,8 @@ def _determine_pr_labels(self, notifications: List[Dict[str, Any]]) -> List[str] severities_found.add('high') if medium_match and int(medium_match.group(1)) > 0: severities_found.add('medium') + if low_match and int(low_match.group(1)) > 0: + severities_found.add('low') # Map severities to label names (using configurable labels) labels = [] @@ -536,5 +896,8 @@ def _determine_pr_labels(self, notifications: List[Dict[str, Any]]) -> List[str] elif 'medium' in severities_found: label_name = self.config.get('pr_label_medium', 'security: medium') labels.append(label_name) + elif 'low' in severities_found: + label_name = self.config.get('pr_label_low', 'security: low') + labels.append(label_name) - return labels \ No newline at end of file + return labels diff --git a/socket_basics/core/notification/manager.py b/socket_basics/core/notification/manager.py index d6e8dcc..7063f06 100644 --- a/socket_basics/core/notification/manager.py +++ b/socket_basics/core/notification/manager.py @@ -510,6 +510,15 @@ def _alert_group(alert: Dict[str, Any], comp: Dict[str, Any]) -> str: notifier_data = per_notifier_notifications[notification_key] notifier_facts['notifications'] = notifier_data logger.debug('Using pre-formatted data for notifier %s: %s items', notifier_name, len(notifier_data) if isinstance(notifier_data, list) else 1) + elif notification_key == 'github_pr': + # GitHub PR label reconciliation still needs to run on "all clear" + # reruns where there are no current notification sections. + notifier_facts['notifications'] = [] + logger.debug( + 'No pre-formatted data found for notifier %s (key: %s); passing empty notifications for label reconciliation', + notifier_name, + notification_key, + ) else: # No pre-formatted data available - skip this notifier to avoid sending wrong format logger.debug('No pre-formatted data found for notifier %s (key: %s), skipping to avoid format mismatch', notifier_name, notification_key) diff --git a/socket_basics/notifications.yaml b/socket_basics/notifications.yaml index a6eec60..02c2e3d 100644 --- a/socket_basics/notifications.yaml +++ b/socket_basics/notifications.yaml @@ -144,6 +144,12 @@ notifiers: type: str default: "security: medium" description: "Label name for medium severity findings" + - name: pr_label_low + option: --pr-label-low + env_variable: INPUT_PR_LABEL_LOW + type: str + default: "security: low" + description: "Label name for low severity findings" msteams: module_path: "socket_basics.core.notification.ms_teams_notifier" diff --git a/socket_basics/socket_basics.py b/socket_basics/socket_basics.py index deb323d..75c2b0c 100644 --- a/socket_basics/socket_basics.py +++ b/socket_basics/socket_basics.py @@ -54,6 +54,18 @@ logger = logging.getLogger(__name__) +def count_blocking_alerts(results: Dict[str, Any]) -> int: + """Count alerts that should fail the run.""" + blocking_alerts = 0 + for comp in results.get('components', []): + for alert in comp.get('alerts', []): + if (alert.get('action') or '').strip().lower() == 'ignore': + continue + if alert.get('severity') in ['high', 'critical']: + blocking_alerts += 1 + return blocking_alerts + + class SecurityScanner: """Main security scanning orchestrator using dynamic connectors""" @@ -461,11 +473,7 @@ def main(): logger.info(f"Total alerts: {total_alerts}") # Exit with non-zero code if high/critical issues found - high_critical_alerts = 0 - for comp in results.get('components', []): - for alert in comp.get('alerts', []): - if alert.get('severity') in ['high', 'critical']: - high_critical_alerts += 1 + high_critical_alerts = count_blocking_alerts(results) exit_code = 1 if high_critical_alerts > 0 else 0 if high_critical_alerts > 0: diff --git a/tests/test_github_pr_notifier.py b/tests/test_github_pr_notifier.py new file mode 100644 index 0000000..196e150 --- /dev/null +++ b/tests/test_github_pr_notifier.py @@ -0,0 +1,349 @@ +from socket_basics.core.notification.github_pr_notifier import GithubPRNotifier + + +def _notification(summary: str) -> dict: + return {'title': 'Socket SAST JavaScript', 'content': summary} + + +def test_determine_pr_labels_prefers_highest_current_severity(): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_label_critical': 'security: critical', + 'pr_label_high': 'security: high', + 'pr_label_medium': 'security: medium', + 'pr_label_low': 'security: low', + } + ) + + labels = notifier._determine_pr_labels( + [_notification('Critical: 0 | High: 1 | Medium: 2 | Low: 3')] + ) + + assert labels == ['security: high'] + + +def test_determine_pr_labels_supports_low_severity(): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_label_low': 'security: low', + } + ) + + labels = notifier._determine_pr_labels( + [_notification('Critical: 0 | High: 0 | Medium: 0 | Low: 2')] + ) + + assert labels == ['security: low'] + + +def test_reconcile_pr_labels_replaces_stale_managed_severity(monkeypatch): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_label_critical': 'security: critical', + 'pr_label_high': 'security: high', + 'pr_label_medium': 'security: medium', + 'pr_label_low': 'security: low', + } + ) + + removed: list[str] = [] + added: list[str] = [] + ensured: list[str] = [] + + monkeypatch.setattr(notifier, '_get_current_pr_label_names', lambda pr_number: ['security: critical', 'team: backend']) + monkeypatch.setattr(notifier, '_remove_pr_label', lambda pr_number, label: removed.append(label) or True) + monkeypatch.setattr(notifier, '_ensure_pr_labels_exist', lambda labels: ensured.extend(labels)) + monkeypatch.setattr(notifier, '_add_pr_labels', lambda pr_number, labels: added.extend(labels) or True) + + success = notifier._reconcile_pr_labels(123, ['security: medium']) + + assert success is True + assert removed == ['security: critical'] + assert ensured == ['security: medium'] + assert added == ['security: medium'] + + +def test_reconcile_pr_labels_clears_managed_labels_when_none_desired(monkeypatch): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_label_critical': 'security: critical', + 'pr_label_high': 'security: high', + 'pr_label_medium': 'security: medium', + 'pr_label_low': 'security: low', + } + ) + + removed: list[str] = [] + monkeypatch.setattr(notifier, '_get_current_pr_label_names', lambda pr_number: ['security: high', 'docs']) + monkeypatch.setattr(notifier, '_remove_pr_label', lambda pr_number, label: removed.append(label) or True) + monkeypatch.setattr(notifier, '_ensure_pr_labels_exist', lambda labels: (_ for _ in ()).throw(AssertionError('should not ensure labels'))) + monkeypatch.setattr(notifier, '_add_pr_labels', lambda pr_number, labels: (_ for _ in ()).throw(AssertionError('should not add labels'))) + + success = notifier._reconcile_pr_labels(123, []) + + assert success is True + assert removed == ['security: high'] + + +def test_notify_reconciles_labels_even_when_notifications_are_empty(monkeypatch): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_labels_enabled': True, + } + ) + + reconciled: list[tuple[int, list[str]]] = [] + all_clear_calls: list[tuple[int, set[str]]] = [] + monkeypatch.setattr(notifier, '_get_pr_number', lambda: 123) + monkeypatch.setattr(notifier, '_reconcile_pr_labels', lambda pr_number, labels: reconciled.append((pr_number, labels)) or True) + monkeypatch.setattr( + notifier, + '_replace_existing_sections_with_all_clear', + lambda pr_number, section_types=None: all_clear_calls.append((pr_number, section_types)) or None, + ) + + notifier.notify({'notifications': []}) + + assert reconciled == [(123, [])] + assert all_clear_calls == [(123, set())] + + +def test_notify_rewrites_existing_section_to_all_clear_when_notifications_are_empty(monkeypatch): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_labels_enabled': True, + } + ) + + comment_body = """ +## Socket SAST JavaScript + +### Summary +🟡 Medium: 1 +""" + updated_bodies: list[str] = [] + + monkeypatch.setattr(notifier, '_get_pr_number', lambda: 123) + monkeypatch.setattr(notifier, '_reconcile_pr_labels', lambda pr_number, labels: True) + monkeypatch.setattr(notifier, '_get_pr_comments', lambda pr_number: [{'id': 99, 'body': comment_body}]) + monkeypatch.setattr( + notifier, + '_update_comment', + lambda pr_number, comment_id, body: updated_bodies.append(body) or True, + ) + + notifier.notify( + { + 'notifications': [], + 'components': [ + { + 'alerts': [ + { + 'generatedBy': 'opengrep-javascript', + 'subType': 'sast-javascript', + 'action': 'ignore', + } + ] + } + ], + } + ) + + assert len(updated_bodies) == 1 + assert 'Socket Basics found no active findings in the latest run.' in updated_bodies[0] + assert '' in updated_bodies[0] + + +def test_notify_empty_sast_notifications_do_not_rewrite_unrelated_sections(monkeypatch): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_labels_enabled': True, + } + ) + + sast_comment = """ +## Socket SAST JavaScript + +### Summary +🟡 Medium: 1 +""" + tier1_comment = """ +## Socket Security Tier 1 + +### Summary +🟠 High: 2 +""" + updated_comments: list[tuple[int, str]] = [] + + monkeypatch.setattr(notifier, '_get_pr_number', lambda: 123) + monkeypatch.setattr(notifier, '_reconcile_pr_labels', lambda pr_number, labels: True) + monkeypatch.setattr( + notifier, + '_get_pr_comments', + lambda pr_number: [ + {'id': 99, 'body': sast_comment}, + {'id': 100, 'body': tier1_comment}, + ], + ) + monkeypatch.setattr( + notifier, + '_update_comment', + lambda pr_number, comment_id, body: updated_comments.append((comment_id, body)) or True, + ) + + notifier.notify( + { + 'notifications': [], + 'components': [ + { + 'alerts': [ + { + 'generatedBy': 'opengrep-javascript', + 'subType': 'sast-javascript', + 'action': 'ignore', + } + ] + } + ], + } + ) + + assert len(updated_comments) == 1 + assert updated_comments[0][0] == 99 + assert '' in updated_comments[0][1] + assert 'Socket Basics found no active findings in the latest run.' in updated_comments[0][1] + assert '' not in updated_comments[0][1] + + +def test_notify_rewrites_all_clear_even_when_pr_labels_are_disabled(monkeypatch): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_labels_enabled': False, + } + ) + + comment_body = """ +## Socket SAST JavaScript + +### Summary +🟠 High: 1 +""" + updated_bodies: list[str] = [] + + monkeypatch.setattr(notifier, '_get_pr_number', lambda: 123) + monkeypatch.setattr( + notifier, + '_reconcile_pr_labels', + lambda pr_number, labels: (_ for _ in ()).throw(AssertionError('labels are disabled')), + ) + monkeypatch.setattr(notifier, '_get_pr_comments', lambda pr_number: [{'id': 99, 'body': comment_body}]) + monkeypatch.setattr( + notifier, + '_update_comment', + lambda pr_number, comment_id, body: updated_bodies.append(body) or True, + ) + + notifier.notify( + { + 'notifications': [], + 'components': [ + { + 'alerts': [ + { + 'generatedBy': 'opengrep-javascript', + 'subType': 'sast-javascript', + 'action': 'ignore', + } + ] + } + ], + } + ) + + assert len(updated_bodies) == 1 + assert 'Socket Basics found no active findings in the latest run.' in updated_bodies[0] + assert '' in updated_bodies[0] + + +def test_notify_zero_alert_component_metadata_rewrites_matching_section(monkeypatch): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_labels_enabled': True, + } + ) + + comment_body = """ +## Socket SAST JavaScript + +### Summary +🟠 High: 1 +""" + updated_bodies: list[str] = [] + + monkeypatch.setattr(notifier, '_get_pr_number', lambda: 123) + monkeypatch.setattr(notifier, '_reconcile_pr_labels', lambda pr_number, labels: True) + monkeypatch.setattr(notifier, '_get_pr_comments', lambda pr_number: [{'id': 99, 'body': comment_body}]) + monkeypatch.setattr( + notifier, + '_update_comment', + lambda pr_number, comment_id, body: updated_bodies.append(body) or True, + ) + + notifier.notify( + { + 'notifications': [], + 'components': [ + { + 'id': 'src/index.js', + 'subPath': 'sast-javascript', + 'alerts': [], + } + ], + } + ) + + assert len(updated_bodies) == 1 + assert 'Socket Basics found no active findings in the latest run.' in updated_bodies[0] + assert '' in updated_bodies[0] + + +def test_notify_zero_alert_enabled_sast_config_rewrites_matching_section(monkeypatch): + notifier = GithubPRNotifier( + { + 'repository': 'SocketDev/socket-basics', + 'pr_labels_enabled': True, + } + ) + notifier.app_config = {'javascript_sast_enabled': True} + + comment_body = """ +## Socket SAST JavaScript + +### Summary +🟠 High: 1 +""" + updated_bodies: list[str] = [] + + monkeypatch.setattr(notifier, '_get_pr_number', lambda: 123) + monkeypatch.setattr(notifier, '_reconcile_pr_labels', lambda pr_number, labels: True) + monkeypatch.setattr(notifier, '_get_pr_comments', lambda pr_number: [{'id': 99, 'body': comment_body}]) + monkeypatch.setattr( + notifier, + '_update_comment', + lambda pr_number, comment_id, body: updated_bodies.append(body) or True, + ) + + notifier.notify({'notifications': [], 'components': []}) + + assert len(updated_bodies) == 1 + assert 'Socket Basics found no active findings in the latest run.' in updated_bodies[0] + assert '' in updated_bodies[0] diff --git a/tests/test_notification_manager_github_pr.py b/tests/test_notification_manager_github_pr.py new file mode 100644 index 0000000..cc77309 --- /dev/null +++ b/tests/test_notification_manager_github_pr.py @@ -0,0 +1,22 @@ +from socket_basics.core.notification.manager import NotificationManager + + +class _DummyGithubPrNotifier: + name = "github_pr" + + def __init__(self): + self.payloads = [] + + def notify(self, facts): + self.payloads.append(facts) + + +def test_notify_all_passes_empty_notifications_to_github_pr_for_all_clear(): + notifier = _DummyGithubPrNotifier() + nm = NotificationManager({}, app_config={"repo": "SocketDev/socket-basics"}) + nm.notifiers = [notifier] + + nm.notify_all({"components": [], "notifications": {}}) + + assert len(notifier.payloads) == 1 + assert notifier.payloads[0]["notifications"] == [] diff --git a/tests/test_sast_ignore_overrides.py b/tests/test_sast_ignore_overrides.py new file mode 100644 index 0000000..de36943 --- /dev/null +++ b/tests/test_sast_ignore_overrides.py @@ -0,0 +1,182 @@ +from socket_basics.core.config import ( + Config, + normalize_repo_relative_path, + parse_sast_ignore_overrides, +) +from socket_basics.core.connector.normalizer import _normalize_alert +from socket_basics.core.connector.opengrep import OpenGrepScanner +from socket_basics.socket_basics import count_blocking_alerts + + +class _DummyConnector: + def __init__(self, config: Config): + self.config = config + + +def _build_alert(path: str = 'index.js') -> dict: + return { + 'title': 'js-sql-injection', + 'severity': 'critical', + 'props': { + 'ruleId': 'js-sql-injection', + 'filePath': path, + 'startLine': 14, + 'endLine': 14, + }, + 'location': { + 'path': path, + 'start': 14, + 'end': 14, + }, + } + + +def test_parse_sast_ignore_overrides_supports_rule_and_exact_path(): + parsed = parse_sast_ignore_overrides( + 'js-sql-injection, js-sql-injection:./src/db/query.js' + ) + + assert parsed == [ + {'rule_id': 'js-sql-injection', 'path': None}, + {'rule_id': 'js-sql-injection', 'path': 'src/db/query.js'}, + ] + + +def test_parse_sast_ignore_overrides_skips_glob_paths(caplog): + parsed = parse_sast_ignore_overrides('js-sql-injection:src/**/*.js') + + assert parsed == [] + assert 'glob syntax' in caplog.text + + +def test_normalize_alert_ignores_rule_only_override(): + connector = _DummyConnector(Config({'workspace': '.', 'sast_ignore_overrides': 'js-sql-injection'})) + + alert = _normalize_alert(_build_alert(), connector=connector) + + assert alert['action'] == 'ignore' + assert alert['actionReason'] == 'sast_ignore_override' + + +def test_normalize_alert_ignores_matching_rule_and_path_override(): + connector = _DummyConnector( + Config({'workspace': '.', 'sast_ignore_overrides': 'js-sql-injection:index.js'}) + ) + + alert = _normalize_alert(_build_alert(), connector=connector) + + assert alert['action'] == 'ignore' + assert alert['actionReason'] == 'sast_ignore_override' + + +def test_normalize_alert_accepts_windows_style_override_paths(): + connector = _DummyConnector( + Config({'workspace': '.', 'sast_ignore_overrides': r'js-sql-injection:src\unsafe\demo.js'}) + ) + + alert = _normalize_alert(_build_alert('src/unsafe/demo.js'), connector=connector) + + assert alert['action'] == 'ignore' + assert alert['actionReason'] == 'sast_ignore_override' + + +def test_get_sast_ignore_overrides_warns_when_path_does_not_exist(tmp_path, caplog): + config = Config( + { + 'workspace': str(tmp_path), + 'sast_ignore_overrides': 'js-sql-injection:src/services/credential_sync/api.ts', + } + ) + + parsed = config.get_sast_ignore_overrides() + + assert parsed == [{'rule_id': 'js-sql-injection', 'path': 'src/services/credential_sync/api.ts'}] + assert 'does not exist under workspace' in caplog.text + assert 'will not fall back to rule-only matching' in caplog.text + + +def test_normalize_repo_relative_path_strips_github_actions_workspace_prefix(monkeypatch): + monkeypatch.setenv('GITHUB_WORKSPACE', '/github/workspace') + + assert normalize_repo_relative_path('github/workspace/index.js') == 'index.js' + + +def test_normalize_repo_relative_path_strips_gitlab_workspace_prefix(monkeypatch): + monkeypatch.setenv('CI_PROJECT_DIR', '/builds/acme/sample-repo') + + assert normalize_repo_relative_path('/builds/acme/sample-repo/src/index.js') == 'src/index.js' + + +def test_normalize_repo_relative_path_strips_bitbucket_workspace_prefix(monkeypatch): + monkeypatch.setenv('BITBUCKET_CLONE_DIR', '/opt/atlassian/pipelines/agent/build') + + assert normalize_repo_relative_path('/opt/atlassian/pipelines/agent/build/index.js') == 'index.js' + + +def test_normalize_repo_relative_path_strips_buildkite_workspace_prefix(monkeypatch): + monkeypatch.setenv('BUILDKITE_BUILD_CHECKOUT_PATH', '/var/lib/buildkite-agent/builds/agent-1/org/repo') + + assert normalize_repo_relative_path( + '/var/lib/buildkite-agent/builds/agent-1/org/repo/app/index.js' + ) == 'app/index.js' + + +def test_normalize_alert_strips_github_actions_workspace_prefix(monkeypatch): + monkeypatch.setenv('GITHUB_WORKSPACE', '/github/workspace') + + connector = _DummyConnector( + Config({'workspace': '.', 'sast_ignore_overrides': 'js-sql-injection:index.js'}) + ) + + alert = _normalize_alert( + _build_alert('github/workspace/index.js'), + connector=connector, + ) + + assert alert['action'] == 'ignore' + + +def test_normalize_alert_does_not_ignore_non_matching_path_override(): + connector = _DummyConnector( + Config({'workspace': '.', 'sast_ignore_overrides': 'js-sql-injection:src/index.js'}) + ) + + alert = _normalize_alert(_build_alert(), connector=connector) + + assert alert['action'] == 'error' + assert 'actionReason' not in alert + + +def test_normalize_alert_marks_disabled_rule_ignores(): + connector = _DummyConnector( + Config({'workspace': '.', 'javascript_disabled_rules': 'js-sql-injection'}) + ) + + alert = _normalize_alert(_build_alert('src/services/credential_sync/api.ts'), connector=connector) + + assert alert['action'] == 'ignore' + assert alert['actionReason'] == 'disabled_rule' + + +def test_count_blocking_alerts_skips_ignored_findings(): + results = { + 'components': [ + {'id': 'ignored.js', 'alerts': [{**_build_alert('ignored.js'), 'action': 'ignore'}]}, + {'id': 'active.js', 'alerts': [{**_build_alert('active.js'), 'action': 'error'}]}, + ] + } + + assert count_blocking_alerts(results) == 1 + + +def test_opengrep_notifications_skip_ignored_findings(): + scanner = OpenGrepScanner(Config({'workspace': '.'})) + component = { + 'id': 'index.js', + 'qualifiers': {'scanner': 'opengrep', 'type': 'javascript'}, + 'alerts': [{**_build_alert(), 'action': 'ignore', 'subType': 'sast-javascript'}], + } + + notifications = scanner.generate_notifications([component]) + + assert notifications == {} From 28c0ad705d28998c0fa9bfc7d1278eb01e90ddd6 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Fri, 26 Jun 2026 17:32:51 -0400 Subject: [PATCH 04/10] fix: gate SAST notifications on actionReason so suppressions are honored (#83) Findings suppressed via *_disabled_rules or a local SAST ignore override are forced to action 'ignore' and tagged with an actionReason by the normalizer. OpenGrepScanner.generate_notifications() filtered by severity only, so a suppressed critical/high finding still posted to the PR comment, Slack, Jira, and the other notifiers even though the dashboard treats it as ignored. Skip alerts carrying an actionReason ('disabled_rule' or 'sast_ignore_override') when building notification groups. Gate on the explicit reason rather than action == 'ignore', because 'ignore' is also the default action the normalizer derives for low-severity findings -- those must still notify when a user opts in to low severities. Suppressed alerts still ship in the uploaded facts; only notifications are gated. Adds a regression test for the OpenGrep PR-comment path: a suppressed finding is excluded while an active finding survives, a fully-suppressed component yields no notifications, and an opted-in low-severity finding still notifies. Fixes CE-285 --- .../core/connector/opengrep/__init__.py | 12 +- tests/test_notification_action_filter.py | 149 ++++++++++++++++++ tests/test_sast_ignore_overrides.py | 11 +- 3 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 tests/test_notification_action_filter.py diff --git a/socket_basics/core/connector/opengrep/__init__.py b/socket_basics/core/connector/opengrep/__init__.py index ff4aebb..c557022 100644 --- a/socket_basics/core/connector/opengrep/__init__.py +++ b/socket_basics/core/connector/opengrep/__init__.py @@ -727,9 +727,17 @@ def generate_notifications(self, components: List[Dict[str, Any]]) -> Dict[str, groups: Dict[str, List[Dict[str, Any]]] = {} for c in comps_map.values(): for a in c.get('alerts', []): - alert_action = (a.get('action') or '').strip().lower() - if alert_action == 'ignore': + # Skip suppressed alerts. A rule disabled via *_disabled_rules or + # matched by a local SAST ignore override is forced to action + # 'ignore' and tagged with an actionReason by the normalizer. Gate + # on that explicit reason rather than action == 'ignore', because + # 'ignore' is also the default action the normalizer derives for + # low-severity findings -- those should still notify when a user + # opts in to low severities. Suppressed alerts still ship in the + # uploaded facts; this only gates notifications. + if (a.get('actionReason') or '') in ('disabled_rule', 'sast_ignore_override'): continue + # Filter by severity - only include alerts that match allowed severities alert_severity = (a.get('severity') or '').strip().lower() if alert_severity and hasattr(self, 'allowed_severities') and alert_severity not in self.allowed_severities: diff --git a/tests/test_notification_action_filter.py b/tests/test_notification_action_filter.py new file mode 100644 index 0000000..6b25f2d --- /dev/null +++ b/tests/test_notification_action_filter.py @@ -0,0 +1,149 @@ +"""Suppressed findings must be excluded from notifications. + +A rule disabled via *_disabled_rules (or matched by a local SAST ignore +override) is forced to action 'ignore' and tagged with an ``actionReason`` by +the normalizer. The dashboard honors that, but notification generation +previously keyed off severity alone, so suppressed critical/high findings still +posted to the PR comment, Slack, etc. + +generate_notifications() now drops suppressed alerts before building any notifier +output. It gates on the explicit ``actionReason`` rather than +``action == 'ignore'``, because 'ignore' is also the default action the +normalizer derives for low-severity findings -- those must still notify when a +user opts in to low severities. Suppressed alerts still ship in the uploaded +facts; only notifications are gated. +""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from scripts.preview_pr_comments import make_mock_config + +from socket_basics.core.connector.normalizer import _normalize_alert +from socket_basics.core.connector.opengrep import OpenGrepScanner + + +def _make_scanner(config, allowed_severities=("critical", "high")): + # generate_notifications() only needs .config and .allowed_severities; + # bypass __init__, which shells out to build the opengrep rule set. + scanner = OpenGrepScanner.__new__(OpenGrepScanner) + scanner.config = config + scanner.allowed_severities = set(allowed_severities) + return scanner + + +def _alert(rule_id, severity, snippet, line): + # A raw alert as the connector emits it, before normalization. No action or + # actionReason -- the normalizer derives those from config + severity. + return { + "title": rule_id, + "severity": severity, + "subType": "sast-generic", + "location": {"path": "slik/domain/GetActionablePlanStatusesUseCase.kt"}, + "props": { + "ruleId": rule_id, + "filePath": "slik/domain/GetActionablePlanStatusesUseCase.kt", + "startLine": line, + "endLine": line, + "codeSnippet": snippet, + }, + } + + +def _normalize(scanner, components): + # Run every alert through the real normalizer with the scanner's config, so + # actionReason and default actions are set exactly as in the pipeline. + for c in components: + c["alerts"] = [_normalize_alert(a, connector=scanner) for a in c["alerts"]] + return components + + +@pytest.fixture +def config(): + cfg = make_mock_config() + # Suppress a SAST rule the way org/repo config lands in config: the dashboard + # API's kotlinDisabledRules maps to kotlin_disabled_rules, as does a repo-level + # *_disabled_rules action input. + cfg["kotlin_disabled_rules"] = "kotlin-sql-injection" + return cfg + + +def test_suppressed_alert_excluded_from_pr_notifications(config): + scanner = _make_scanner(config) + components = _normalize(scanner, [ + { + "id": "GetActionablePlanStatusesUseCase.kt", + "name": "GetActionablePlanStatusesUseCase.kt", + "type": "generic", + "alerts": [ + _alert("kotlin-sql-injection", "critical", "SUPPRESSED_SNIPPET_XYZ", 66), + _alert("kotlin-weak-hash", "critical", "ACTIVE_SNIPPET_ABC", 70), + ], + } + ]) + + # The normalizer flagged only the disabled rule as suppressed. + alerts = components[0]["alerts"] + assert alerts[0]["actionReason"] == "disabled_rule" + assert "actionReason" not in alerts[1] + + result = scanner.generate_notifications(components) + content = "\n".join(item["content"] for item in result.get("github_pr", [])) + + # The active finding survives. + assert "kotlin-weak-hash" in content + assert "ACTIVE_SNIPPET_ABC" in content + + # The suppressed finding is gone, even though it is critical. + assert "kotlin-sql-injection" not in content + assert "SUPPRESSED_SNIPPET_XYZ" not in content + + # Summary counts only the non-suppressed critical. + assert "Critical: 1" in content + assert "Critical: 2" not in content + + +def test_all_suppressed_yields_no_notifications(config): + scanner = _make_scanner(config) + components = _normalize(scanner, [ + { + "id": "f.kt", + "name": "f.kt", + "type": "generic", + "alerts": [_alert("kotlin-sql-injection", "critical", "X", 66)], + } + ]) + + # Every alert suppressed -> no groups -> empty per-notifier mapping. + assert scanner.generate_notifications(components) == {} + + +def test_low_severity_not_suppressed_still_notifies(config): + # The normalizer maps low severity to the default action 'ignore'. That must + # NOT be treated as suppression: a user who opts in to low severities should + # still see those findings in the PR comment. Regression guard against gating + # notifications on action == 'ignore' instead of the explicit actionReason. + scanner = _make_scanner(config, allowed_severities=("critical", "high", "low")) + components = _normalize(scanner, [ + { + "id": "f.kt", + "name": "f.kt", + "type": "generic", + "alerts": [_alert("kotlin-style-nit", "low", "LOW_SNIPPET_LMN", 12)], + } + ]) + + # Low maps to action 'ignore' but carries no actionReason (not suppressed). + low = components[0]["alerts"][0] + assert low["action"] == "ignore" + assert "actionReason" not in low + + result = scanner.generate_notifications(components) + content = "\n".join(item["content"] for item in result.get("github_pr", [])) + + # The opted-in low-severity finding still notifies. + assert "kotlin-style-nit" in content + assert "LOW_SNIPPET_LMN" in content diff --git a/tests/test_sast_ignore_overrides.py b/tests/test_sast_ignore_overrides.py index de36943..04cccc1 100644 --- a/tests/test_sast_ignore_overrides.py +++ b/tests/test_sast_ignore_overrides.py @@ -171,10 +171,19 @@ def test_count_blocking_alerts_skips_ignored_findings(): def test_opengrep_notifications_skip_ignored_findings(): scanner = OpenGrepScanner(Config({'workspace': '.'})) + # A genuinely-suppressed finding carries an actionReason (the normalizer + # attaches one for disabled rules and SAST ignore overrides). Notifications + # gate on that reason rather than action == 'ignore', because 'ignore' is + # also the default action for low-severity findings. component = { 'id': 'index.js', 'qualifiers': {'scanner': 'opengrep', 'type': 'javascript'}, - 'alerts': [{**_build_alert(), 'action': 'ignore', 'subType': 'sast-javascript'}], + 'alerts': [{ + **_build_alert(), + 'action': 'ignore', + 'actionReason': 'disabled_rule', + 'subType': 'sast-javascript', + }], } notifications = scanner.generate_notifications([component]) From d573d3a3224762e18547103ad37df7761da1cde0 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:34:55 -0400 Subject: [PATCH 05/10] fix: improve custom SAST rule activation, filtering semantics + config observability (#61) * fix: custom SAST config normalization + precedence handling Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix: harden custom SAST rule selection + filtering behavior Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * docs: clarify custom SAST config, predence, rule-path semantics Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix: include custom rules in all-language scans --------- Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --- docs/github-action.md | 35 ++- docs/parameters.md | 30 ++- socket_basics/core/config.py | 30 ++- .../core/connector/opengrep/__init__.py | 69 +++++- tests/test_config_custom_sast.py | 69 ++++++ tests/test_opengrep_custom_rules.py | 222 ++++++++++++++++++ 6 files changed, 417 insertions(+), 38 deletions(-) create mode 100644 tests/test_config_custom_sast.py create mode 100644 tests/test_opengrep_custom_rules.py diff --git a/docs/github-action.md b/docs/github-action.md index 80e3c5c..b9ba113 100644 --- a/docs/github-action.md +++ b/docs/github-action.md @@ -670,8 +670,12 @@ jobs: ### Custom Rule Configuration +Use custom rules from your repository by setting `use_custom_sast_rules` and +`custom_sast_rule_path`. This path is resolved relative to `GITHUB_WORKSPACE` +in GitHub Actions. + ```yaml -name: Security Scan with Custom Rules +name: Security Scan with Custom SAST Rules on: pull_request: types: [opened, synchronize, reopened] @@ -692,24 +696,31 @@ jobs: GITHUB_PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} with: github_token: ${{ secrets.GITHUB_TOKEN }} - - # Enable Python SAST + + # Enable SAST languages you expect to run. python_sast_enabled: 'true' - - # Enable specific Python rules - python_enabled_rules: 'sql-injection,xss,hardcoded-credentials' - - # Disable noisy rules - python_disabled_rules: 'unused-import,line-too-long' - - # JavaScript with custom rules javascript_sast_enabled: 'true' + + # Enable custom rules from repository path. + use_custom_sast_rules: 'true' + custom_sast_rule_path: '.socket/rules' + + # Optional: to avoid allowlist exclusions, run all rules for enabled languages. + all_rules_enabled: 'true' + + # Optional: enable specific bundled or custom rule IDs. javascript_enabled_rules: 'eval-usage,prototype-pollution' # Ignore one or more SAST rules globally or for exact repo-relative files sast_ignore_overrides: 'js-sql-injection:index.js' ``` +Important behavior: +- `socket_security_api_key` + `socket_org` enables dashboard config loading. +- Dashboard/API settings override overlapping `with:` values. +- `_enabled_rules` is an allowlist and can suppress custom rule IDs. +- `all_rules_enabled: 'true'` disables allowlist filtering for enabled languages. + `sast_ignore_overrides` supports: - `rule_id` to ignore a SAST rule everywhere in the repo - `rule_id:path` to ignore a SAST rule for one exact repo-relative file @@ -755,6 +766,8 @@ See [`action.yml`](../action.yml) for the complete list of inputs. **Rule Configuration (per language):** - `_enabled_rules` — Comma-separated rules to enable - `_disabled_rules` — Comma-separated rules to disable +- `use_custom_sast_rules` — Enable custom SAST rule discovery from repo files +- `custom_sast_rule_path` — Relative path to custom SAST rule directory - `sast_ignore_overrides` — Comma-separated `rule_id` or `rule_id:path` SAST ignore overrides **Security Scanning:** diff --git a/docs/parameters.md b/docs/parameters.md index 95fd352..8e9836d 100644 --- a/docs/parameters.md +++ b/docs/parameters.md @@ -212,6 +212,10 @@ Use custom SAST rules instead of bundled rules (falls back to bundled rules for socket-basics --python --use-custom-sast-rules ``` +When this is enabled, custom rules are loaded from YAML files under +`--custom-sast-rule-path`. Each rule must include a `languages` list so Socket +Basics can map it to the correct OpenGrep language rule file. + ### `--custom-sast-rule-path CUSTOM_SAST_RULE_PATH` Relative path to custom SAST rules directory (relative to workspace if set, otherwise cwd). @@ -224,6 +228,11 @@ Relative path to custom SAST rules directory (relative to workspace if set, othe socket-basics --python --use-custom-sast-rules --custom-sast-rule-path "my_custom_rules" ``` +Custom rule file notes: +- `.yml` and `.yaml` files are discovered recursively. +- Files ending in `.test.yml` or `.test.yaml` are ignored. +- Rules without `languages` are skipped. + ### Language-Specific Rule Configuration For each language, you can enable or disable specific rules: @@ -575,7 +584,9 @@ All notification integrations support environment variables as alternatives to C | Variable | Description | |----------|-------------| -| `INPUT_OPENGREP_RULES_DIR` | Custom directory containing SAST rules | +| `INPUT_OPENGREP_RULES_DIR` | Override directory for bundled OpenGrep rule files (`*.yml`) | +| `INPUT_USE_CUSTOM_SAST_RULES` | Enable repository custom SAST rules | +| `INPUT_CUSTOM_SAST_RULE_PATH` | Relative directory path for repository custom SAST rules | | `INPUT_SAST_IGNORE_OVERRIDES` | Comma-separated `rule_id` or `rule_id:path` SAST ignore overrides | ## Configuration File @@ -593,6 +604,8 @@ You can provide configuration via a JSON file using `--config`: "python_sast_enabled": true, "javascript_sast_enabled": true, + "use_custom_sast_rules": true, + "custom_sast_rule_path": ".socket/rules", "go_sast_enabled": true, "sast_ignore_overrides": "js-sql-injection:index.js", @@ -617,17 +630,18 @@ You can provide configuration via a JSON file using `--config`: Configuration is merged in the following order (later sources override earlier ones): 1. Default values -2. JSON configuration file (via `--config`) -3. Environment variables -4. Command-line arguments +2. Environment variables +3. Socket Basics API configuration (when available and no `--config` file is used) +4. JSON configuration file (via `--config`) +5. Command-line arguments **Example:** ```bash -# JSON file sets python_sast_enabled: true -# Environment has PYTHON_SAST_ENABLED=false +# Environment sets python_sast_enabled=true +# Dashboard/API sets python_sast_enabled=false # CLI has --javascript -# Result: JavaScript enabled, Python disabled (env override), other settings from JSON -socket-basics --config config.json --javascript +# Result: JavaScript enabled, Python follows dashboard/API value, other settings from env/API +socket-basics --javascript ``` ## Common Usage Patterns diff --git a/socket_basics/core/config.py b/socket_basics/core/config.py index 7c9b2f6..def1e96 100644 --- a/socket_basics/core/config.py +++ b/socket_basics/core/config.py @@ -1124,6 +1124,10 @@ def normalize_api_config(api_config: Dict[str, Any]) -> Dict[str, Any]: # OpenGrep/SAST Configuration 'openGrepNotificationMethod': 'opengrep_notification_method', + 'useCustomSastRules': 'use_custom_sast_rules', + 'customSastRulePath': 'custom_sast_rule_path', + # Accept common pluralized variant for robustness. + 'customSastRulesPath': 'custom_sast_rule_path', # Socket Tier 1 'socketTier1Enabled': 'socket_tier_1_enabled', @@ -1231,13 +1235,15 @@ def merge_json_and_env_config(json_config: Dict[str, Any] | None = None) -> Dict Returns: Merged configuration dictionary """ + logger = logging.getLogger(__name__) + # Start with environment defaults (lowest priority) config = load_config_from_env() + logger.info("Configuration sources: environment defaults loaded") # Override with Socket Basics API config if no explicit JSON config provided # API config takes precedence over environment defaults if not json_config: - logger = logging.getLogger(__name__) logger.debug(" No JSON config provided, attempting to load Socket Basics API config") socket_basics_config = load_socket_basics_config() logger.debug(f" Socket Basics API config result: {socket_basics_config is not None}") @@ -1254,7 +1260,10 @@ def merge_json_and_env_config(json_config: Dict[str, Any] | None = None) -> Dict continue filtered_config[k] = v config.update(filtered_config) - logging.getLogger(__name__).info("Loaded Socket Basics API configuration (overrides environment defaults)") + if bool(filtered_config.get('socket_has_enterprise', False)): + logging.getLogger(__name__).info("Loaded Socket Basics API configuration (overrides environment defaults)") + else: + logging.getLogger(__name__).info("Loaded Socket plan metadata (free/non-enterprise mode; no dashboard overrides)") else: logger.debug(" No Socket Basics API config loaded") @@ -1276,6 +1285,13 @@ def merge_json_and_env_config(json_config: Dict[str, Any] | None = None) -> Dict # Note: CLI arguments are handled separately and take highest priority # They override the config object after this merge completes + logger.info( + "Effective custom SAST config: use_custom_sast_rules=%s custom_sast_rule_path=%s all_languages_enabled=%s all_rules_enabled=%s", + bool(config.get('use_custom_sast_rules', False)), + config.get('custom_sast_rule_path', ''), + bool(config.get('all_languages_enabled', False)), + bool(config.get('all_rules_enabled', False)), + ) return config @@ -1314,9 +1330,9 @@ def add_dynamic_cli_args(parser: argparse.ArgumentParser): if param_type == 'bool': parser.add_argument(option, action='store_true', help=description) elif param_type == 'str': - parser.add_argument(option, type=str, default=default, help=description) + parser.add_argument(option, type=str, default=None, help=description) elif param_type == 'int': - parser.add_argument(option, type=int, default=default, help=description) + parser.add_argument(option, type=int, default=None, help=description) except Exception as e: logging.getLogger(__name__).warning("Warning: Could not load dynamic CLI args: %s", e) @@ -1346,9 +1362,9 @@ def add_dynamic_cli_args(parser: argparse.ArgumentParser): if p_type == 'bool': parser.add_argument(option, action='store_true', help=desc) elif p_type == 'int': - parser.add_argument(option, type=int, default=default, help=desc) + parser.add_argument(option, type=int, default=None, help=desc) else: - parser.add_argument(option, type=str, default=default, help=desc) + parser.add_argument(option, type=str, default=None, help=desc) except Exception: pass @@ -1357,7 +1373,7 @@ def parse_cli_args(): """Parse command line arguments and return argument parser""" parser = argparse.ArgumentParser(description='Socket Security Basics - Dynamic security scanning') parser.add_argument('--config', type=str, - help='Path to JSON configuration file. JSON config is merged with environment variables (environment takes precedence)') + help='Path to JSON configuration file. JSON config is merged with environment variables (JSON takes precedence)') parser.add_argument('--output', type=str, default='.socket.facts.json', help='Output file name (default: .socket.facts.json)') parser.add_argument('--workspace', type=str, help='Workspace directory to scan') diff --git a/socket_basics/core/connector/opengrep/__init__.py b/socket_basics/core/connector/opengrep/__init__.py index c557022..30d4889 100644 --- a/socket_basics/core/connector/opengrep/__init__.py +++ b/socket_basics/core/connector/opengrep/__init__.py @@ -40,6 +40,12 @@ def scan(self) -> Dict[str, Any]: rule_files = self.config.build_opengrep_rules() or [] except Exception: rule_files = [] + logger.info( + "OpenGrep config summary: all_languages_enabled=%s all_rules_enabled=%s requested_rule_files=%s", + bool(self.config.get('all_languages_enabled', False)), + bool(self.config.get('all_rules_enabled', False)), + rule_files, + ) # If no languages selected and not explicitly allowing all, skip if not rule_files and not self.config.get('all_languages_enabled', False): @@ -55,9 +61,35 @@ def scan(self) -> Dict[str, Any]: logger.info('No scan targets to analyze (scoped scan matched no existing files); skipping OpenGrep') return {} + # Locate bundled rules directory for fallback and all-language expansion. + module_dir = Path(__file__).resolve().parents[3] + bundled_rules_dir = module_dir / 'rules' + rules_dir = self.config.get('opengrep_rules_dir') or (str(bundled_rules_dir) if bundled_rules_dir.exists() else None) + if not rules_dir: + logger.error('No rules directory found') + return {} + + if not rule_files and self.config.get('all_languages_enabled', False): + try: + rule_files = [ + p.name + for p in Path(rules_dir).glob('*.yml') + if p.name != 'tests.yml' + ] + logger.info("Expanded all-languages scan to rule files: %s", rule_files) + except Exception: + logger.debug('Failed expanding all-languages into rule files', exc_info=True) + rule_files = [] + # Check if custom rules mode is enabled custom_rules_path = self.config.get_custom_rules_path() custom_rule_files: Dict[str, Path] = {} + logger.info( + "Custom SAST requested=%s custom_path=%s resolved_path=%s", + bool(self.config.get('use_custom_sast_rules', False)), + self.config.get('custom_sast_rule_path', ''), + str(custom_rules_path) if custom_rules_path else '(none)', + ) if custom_rules_path: logger.info(f"Custom SAST rules enabled, loading from: {custom_rules_path}") @@ -68,19 +100,16 @@ def scan(self) -> Dict[str, Any]: logger.error(f"Failed to build custom rule files: {e}", exc_info=True) custom_rule_files = {} - # Locate bundled rules directory for fallback - module_dir = Path(__file__).resolve().parents[3] - bundled_rules_dir = module_dir / 'rules' - rules_dir = self.config.get('opengrep_rules_dir') or (str(bundled_rules_dir) if bundled_rules_dir.exists() else None) - if not rules_dir: - logger.error('No rules directory found') - return {} - # Read filtered rule definitions if available try: filtered = self.config.build_filtered_opengrep_rules() or {} except Exception: filtered = {} + if filtered: + filtered_counts = {k: len(v or []) for k, v in filtered.items()} + logger.info("Per-language enabled-rule filters detected: %s", filtered_counts) + else: + logger.info("Per-language enabled-rule filters disabled for this run") # Debugging: log computed rule files and filtered rules for diagnosis try: @@ -98,25 +127,42 @@ def scan(self) -> Dict[str, Any]: # Process all enabled languages - use filtered rules if specified, otherwise use all rules for rf in rule_files: # Check if we have a custom rule file for this language + using_custom_rules = bool(custom_rule_files and rf in custom_rule_files) if custom_rule_files and rf in custom_rule_files: p = custom_rule_files[rf] - logger.info(f"Using custom rules for {rf}") + logger.info("Using custom rules for %s from %s", rf, p) else: # Fall back to bundled rules p = Path(rules_dir) / rf if not p.exists(): logger.debug('Rule file missing: %s', p) continue + logger.info("Using bundled rules for %s from %s", rf, p) # Check if this language has specific rules enabled (filtered mode) if filtered and rf in filtered: enabled_ids = filtered[rf] - logger.debug(f"Using filtered rules for {rf}: {len(enabled_ids)} rules enabled") + logger.info("Filtering rules for %s: %d enabled IDs configured", rf, len(enabled_ids)) try: with open(p, 'r') as fh: data = yaml.safe_load(fh) or {} all_ids = [r.get('id') for r in (data.get('rules') or []) if r.get('id')] - to_exclude = [rid for rid in all_ids if rid not in (enabled_ids or [])] + # Custom-rule mode can coexist with legacy bundled allowlists. + # If none of the configured enabled IDs match custom IDs, keep all + # custom IDs active to avoid silently disabling user-authored rules. + if using_custom_rules: + matched_enabled_ids = [rid for rid in all_ids if rid in (enabled_ids or [])] + if enabled_ids and not matched_enabled_ids: + logger.warning( + "No configured enabled-rule IDs matched custom rules for %s; using all custom rules from %s", + rf, + p, + ) + config_args.extend(['--config', str(p)]) + continue + to_exclude = [rid for rid in all_ids if rid not in matched_enabled_ids] + else: + to_exclude = [rid for rid in all_ids if rid not in (enabled_ids or [])] config_args.extend(['--config', str(p)]) for ex in to_exclude: config_args.extend(['--exclude-rule', ex]) @@ -762,4 +808,3 @@ def generate_notifications(self, components: List[Dict[str, Any]]) -> Dict[str, notifications_by_notifier['webhook'] = webhook.format_notifications(groups) return notifications_by_notifier - diff --git a/tests/test_config_custom_sast.py b/tests/test_config_custom_sast.py new file mode 100644 index 0000000..868c0c2 --- /dev/null +++ b/tests/test_config_custom_sast.py @@ -0,0 +1,69 @@ +from socket_basics.core import config as config_module +from socket_basics.core.config import ( + create_config_from_args, + merge_json_and_env_config, + normalize_api_config, + parse_cli_args, +) + + +def test_normalize_api_config_maps_custom_sast_keys(): + normalized = normalize_api_config( + { + "useCustomSastRules": True, + "customSastRulePath": ".socket/rules", + } + ) + + assert normalized["use_custom_sast_rules"] is True + assert normalized["custom_sast_rule_path"] == ".socket/rules" + + +def test_normalize_api_config_maps_custom_sast_plural_path_alias(): + normalized = normalize_api_config({"customSastRulesPath": "custom_rules"}) + assert normalized["custom_sast_rule_path"] == "custom_rules" + + +def test_merge_json_and_env_config_api_overrides_env_custom_sast(monkeypatch): + monkeypatch.setenv("INPUT_USE_CUSTOM_SAST_RULES", "true") + monkeypatch.setenv("INPUT_CUSTOM_SAST_RULE_PATH", ".socket/rules") + + monkeypatch.setattr( + config_module, + "load_socket_basics_config", + lambda: {"useCustomSastRules": False, "customSastRulePath": "dashboard/rules"}, + ) + + merged = merge_json_and_env_config() + assert merged["use_custom_sast_rules"] is False + assert merged["custom_sast_rule_path"] == "dashboard/rules" + + +def test_merge_json_and_env_config_json_overrides_env_custom_sast(monkeypatch): + monkeypatch.setenv("INPUT_USE_CUSTOM_SAST_RULES", "false") + monkeypatch.setenv("INPUT_CUSTOM_SAST_RULE_PATH", "custom_rules") + + merged = merge_json_and_env_config( + {"useCustomSastRules": True, "customSastRulePath": ".socket/rules"} + ) + assert merged["use_custom_sast_rules"] is True + assert merged["custom_sast_rule_path"] == ".socket/rules" + + +def test_create_config_from_args_does_not_override_env_custom_path(monkeypatch): + monkeypatch.setenv("INPUT_USE_CUSTOM_SAST_RULES", "true") + monkeypatch.setenv("INPUT_CUSTOM_SAST_RULE_PATH", ".socket/rules") + + monkeypatch.setattr(config_module, "_discover_repository", lambda *args, **kwargs: "repo") + monkeypatch.setattr(config_module, "_discover_branch", lambda *args, **kwargs: "branch") + monkeypatch.setattr(config_module, "_discover_commit_hash", lambda *args, **kwargs: "commit") + monkeypatch.setattr(config_module, "_discover_is_default_branch", lambda *args, **kwargs: False) + monkeypatch.setattr(config_module, "_discover_pull_request", lambda *args, **kwargs: 0) + monkeypatch.setattr(config_module, "_discover_committers", lambda *args, **kwargs: []) + + parser = parse_cli_args() + args = parser.parse_args([]) + config = create_config_from_args(args) + + assert config.get("use_custom_sast_rules") is True + assert config.get("custom_sast_rule_path") == ".socket/rules" diff --git a/tests/test_opengrep_custom_rules.py b/tests/test_opengrep_custom_rules.py new file mode 100644 index 0000000..3a85fad --- /dev/null +++ b/tests/test_opengrep_custom_rules.py @@ -0,0 +1,222 @@ +import json +from pathlib import Path +from types import SimpleNamespace + +from socket_basics.core.config import Config +from socket_basics.core.connector.opengrep import OpenGrepScanner + + +def _write_rule_file(path: Path, rule_ids: list[str]) -> None: + rules = [{"id": rid, "languages": ["javascript"], "pattern": "eval(...)"} for rid in rule_ids] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"rules": rules}), encoding="utf-8") + + +def _write_custom_rules_file(path: Path, rule_ids: list[str]) -> None: + lines = ["rules:"] + for rid in rule_ids: + lines.extend( + [ + f" - id: {rid}", + " pattern: eval(...)", + " languages: [javascript, typescript]", + f' message: Rule {rid}', + " severity: ERROR", + ] + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines), encoding="utf-8") + + +def _mock_subprocess_run(monkeypatch, captured_cmd: list[str]): + def _runner(cmd, capture_output, text): + captured_cmd.extend(cmd) + out_file = cmd[cmd.index("--output") + 1] + Path(out_file).write_text(json.dumps({"results": []}), encoding="utf-8") + return SimpleNamespace(stdout="", stderr="", returncode=0) + + monkeypatch.setattr("socket_basics.core.connector.opengrep.subprocess.run", _runner) + + +def test_scan_uses_custom_rule_file_when_available(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir(parents=True, exist_ok=True) + + custom_rules_dir = workspace / ".socket" / "rules" + # Custom file can be any yaml name; builder groups by languages. + custom_rules_file = custom_rules_dir / "org-rules.yml" + _write_custom_rules_file(custom_rules_file, ["org.no-eval"]) + + bundled_rules_dir = tmp_path / "bundled-rules" + _write_rule_file(bundled_rules_dir / "javascript_typescript.yml", ["js-default-rule"]) + + config = Config( + { + "workspace": str(workspace), + "output_dir": str(workspace), + "javascript_sast_enabled": True, + "use_custom_sast_rules": True, + "custom_sast_rule_path": ".socket/rules", + "opengrep_rules_dir": str(bundled_rules_dir), + "all_languages_enabled": False, + "all_rules_enabled": False, + "verbose": False, + } + ) + scanner = OpenGrepScanner(config) + scanner._convert_to_socket_facts = lambda _: {"components": []} + scanner.generate_notifications = lambda _: {} + + captured_cmd: list[str] = [] + _mock_subprocess_run(monkeypatch, captured_cmd) + scanner.scan() + + cmd_str = " ".join(captured_cmd) + assert "socket_custom_rules_" in cmd_str + assert str(bundled_rules_dir / "javascript_typescript.yml") not in cmd_str + + +def test_all_languages_custom_rules_without_individual_language_flags(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir(parents=True, exist_ok=True) + + custom_rules_file = workspace / ".socket" / "rules" / "org-rules.yml" + _write_custom_rules_file(custom_rules_file, ["org.no-eval"]) + + bundled_rules_dir = tmp_path / "bundled-rules" + bundled_js_file = bundled_rules_dir / "javascript_typescript.yml" + bundled_python_file = bundled_rules_dir / "python.yml" + _write_rule_file(bundled_js_file, ["js-default-rule"]) + _write_rule_file(bundled_python_file, ["py-default-rule"]) + + config = Config( + { + "workspace": str(workspace), + "output_dir": str(workspace), + "all_languages_enabled": True, + "use_custom_sast_rules": True, + "custom_sast_rule_path": ".socket/rules", + "opengrep_rules_dir": str(bundled_rules_dir), + "all_rules_enabled": False, + "verbose": False, + } + ) + scanner = OpenGrepScanner(config) + scanner._convert_to_socket_facts = lambda _: {"components": []} + scanner.generate_notifications = lambda _: {} + + captured_cmd: list[str] = [] + _mock_subprocess_run(monkeypatch, captured_cmd) + scanner.scan() + + cmd_str = " ".join(captured_cmd) + assert "socket_custom_rules_" in cmd_str + assert str(bundled_js_file) not in cmd_str + assert str(bundled_python_file) in cmd_str + + +def test_scan_falls_back_to_bundled_file_when_custom_missing(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir(parents=True, exist_ok=True) + + bundled_rules_dir = tmp_path / "bundled-rules" + bundled_file = bundled_rules_dir / "javascript_typescript.yml" + _write_rule_file(bundled_file, ["js-default-rule"]) + + config = Config( + { + "workspace": str(workspace), + "output_dir": str(workspace), + "javascript_sast_enabled": True, + "use_custom_sast_rules": True, + "custom_sast_rule_path": ".socket/missing-rules", + "opengrep_rules_dir": str(bundled_rules_dir), + "all_languages_enabled": False, + "all_rules_enabled": False, + "verbose": False, + } + ) + scanner = OpenGrepScanner(config) + scanner._convert_to_socket_facts = lambda _: {"components": []} + scanner.generate_notifications = lambda _: {} + + captured_cmd: list[str] = [] + _mock_subprocess_run(monkeypatch, captured_cmd) + scanner.scan() + + assert str(bundled_file) in " ".join(captured_cmd) + + +def test_custom_rules_ignore_nonmatching_bundled_allowlist_ids(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir(parents=True, exist_ok=True) + + custom_rules_file = workspace / ".socket" / "rules" / "org-rules.yml" + _write_custom_rules_file(custom_rules_file, ["org.no-eval", "org.no-innerhtml"]) + + bundled_rules_dir = tmp_path / "bundled-rules" + _write_rule_file(bundled_rules_dir / "javascript_typescript.yml", ["js-default-rule"]) + + config = Config( + { + "workspace": str(workspace), + "output_dir": str(workspace), + "javascript_sast_enabled": True, + "javascript_enabled_rules": "js-default-rule", + "use_custom_sast_rules": True, + "custom_sast_rule_path": ".socket/rules", + "opengrep_rules_dir": str(bundled_rules_dir), + "all_languages_enabled": False, + "all_rules_enabled": False, + "verbose": False, + } + ) + scanner = OpenGrepScanner(config) + scanner._convert_to_socket_facts = lambda _: {"components": []} + scanner.generate_notifications = lambda _: {} + + captured_cmd: list[str] = [] + _mock_subprocess_run(monkeypatch, captured_cmd) + scanner.scan() + + cmd_str = " ".join(captured_cmd) + assert "socket_custom_rules_" in cmd_str + assert "--exclude-rule org.no-eval" not in cmd_str + assert "--exclude-rule org.no-innerhtml" not in cmd_str + + +def test_custom_rules_apply_allowlist_when_custom_ids_match(tmp_path, monkeypatch): + workspace = tmp_path / "workspace" + workspace.mkdir(parents=True, exist_ok=True) + + custom_rules_file = workspace / ".socket" / "rules" / "org-rules.yml" + _write_custom_rules_file(custom_rules_file, ["org.no-eval", "org.no-innerhtml"]) + + bundled_rules_dir = tmp_path / "bundled-rules" + _write_rule_file(bundled_rules_dir / "javascript_typescript.yml", ["js-default-rule"]) + + config = Config( + { + "workspace": str(workspace), + "output_dir": str(workspace), + "javascript_sast_enabled": True, + "javascript_enabled_rules": "org.no-eval", + "use_custom_sast_rules": True, + "custom_sast_rule_path": ".socket/rules", + "opengrep_rules_dir": str(bundled_rules_dir), + "all_languages_enabled": False, + "all_rules_enabled": False, + "verbose": False, + } + ) + scanner = OpenGrepScanner(config) + scanner._convert_to_socket_facts = lambda _: {"components": []} + scanner.generate_notifications = lambda _: {} + + captured_cmd: list[str] = [] + _mock_subprocess_run(monkeypatch, captured_cmd) + scanner.scan() + + cmd_str = " ".join(captured_cmd) + assert "--exclude-rule org.no-innerhtml" in cmd_str + assert "--exclude-rule org.no-eval" not in cmd_str From e8d52a60c8c4a6d51ba5a3a67ee7795659e1306b Mon Sep 17 00:00:00 2001 From: David Larsen Date: Fri, 26 Jun 2026 17:36:51 -0400 Subject: [PATCH 06/10] docs: document required API token scopes for Socket Basics (#68) * docs: document required API token scopes for Socket Basics * docs: clarify Socket Basics token scopes --------- Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c0c2b9..d13d9e1 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ jobs: > with a review gate. See [docs/github-action.md](docs/github-action.md#pinning-strategies) > for the full explanation and Dependabot setup. -**That's it!** With just your `SOCKET_SECURITY_API_KEY`, all scanning configurations are managed through the [Socket Dashboard](https://socket.dev/dashboard) — no workflow changes needed. +**That's it!** With a properly scoped `SOCKET_SECURITY_API_KEY`, all scanning configurations are managed through the [Socket Dashboard](https://socket.dev/dashboard) — no workflow changes needed. See [Required API Token Scopes](#required-api-token-scopes) for details. ### What You Get @@ -160,6 +160,19 @@ Configure scanning policies, notification channels, and rule sets for your entir ![Socket Basics Section Config](docs/screenshots/socket_basics_section_config.png) +### Required API Token Scopes + +Create your `SOCKET_SECURITY_API_KEY` in the [Socket Dashboard](https://socket.dev/dashboard) under **Settings → API Tokens**. Dashboard routes can depend on your organization and login session, so start from the dashboard or see the [Socket API Tokens docs](https://docs.socket.dev/docs/api-keys) for token-management details. Socket Basics needs the following scopes: + +| Scope | Required for | +|-------|--------------| +| `full-scans` | Submitting scan results to your organization | +| `socket-basics` | Loading scanner configuration from the Socket Dashboard | + +If Socket Basics is configured from the Socket Dashboard, the `socket-basics` scope is required. If it is missing, you will see `Insufficient permissions` when Socket Basics loads dashboard configuration. + +If Socket Basics is configured with CLI arguments, environment variables, or a JSON config file, only `full-scans` permissions are required for result submission. Set `SOCKET_ORG` explicitly in your workflow when using this mode. + ## 💻 Other Usage Methods For GitHub Actions, see the [Quick Start](#-quick-start---github-actions) above or the **[Complete GitHub Actions Guide](docs/github-action.md)** for advanced workflows. @@ -251,6 +264,7 @@ Add new connectors by: **Socket API errors:** - Ensure `SOCKET_SECURITY_API_KEY` and `SOCKET_ORG` are set correctly - Verify your Socket Enterprise subscription is active +- If you see `Insufficient permissions`, confirm your API token has the scopes required for your configuration mode (see [Required API Token Scopes](#required-api-token-scopes)) **Notifier errors:** - Check that notification credentials (Slack webhook, Jira token, etc.) are properly configured From 8998fc9ffc117b84dd37f0208f220652d1de4766 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Fri, 26 Jun 2026 17:51:43 -0400 Subject: [PATCH 07/10] feat(config): log config source (#18) Log the selected configuration source during Config initialization. Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- socket_basics/core/config.py | 14 ++++++++++++-- tests/test_config_source.py | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 tests/test_config_source.py diff --git a/socket_basics/core/config.py b/socket_basics/core/config.py index def1e96..ac623b4 100644 --- a/socket_basics/core/config.py +++ b/socket_basics/core/config.py @@ -197,9 +197,19 @@ def __init__(self, config_dict: Dict[str, Any] | None = None, json_config_path: self._config = merge_json_and_env_config() self._config = self._config - - # DEBUG: Log final configuration values + + # Log where the configuration is being loaded from logger = logging.getLogger(__name__) + config_source = self._config.get('_config_source', 'environment') + source_descriptions = { + 'api': 'Socket dashboard (API)', + 'json_file': 'JSON config file (--config)', + 'environment': 'environment variables', + } + source_desc = source_descriptions.get(config_source, config_source) + logger.info(f"Configuration loaded from: {source_desc}") + + # DEBUG: Log final configuration values logger.debug("Final Config object created with key values:") logger.debug(f" javascript_sast_enabled: {self._config.get('javascript_sast_enabled')}") logger.debug(f" socket_tier_1_enabled: {self._config.get('socket_tier_1_enabled')}") diff --git a/tests/test_config_source.py b/tests/test_config_source.py new file mode 100644 index 0000000..4c02455 --- /dev/null +++ b/tests/test_config_source.py @@ -0,0 +1,19 @@ +import logging + +from socket_basics.core.config import Config + + +def test_config_logs_default_environment_source(caplog, tmp_path): + caplog.set_level(logging.INFO, logger="socket_basics.core.config") + + Config({"workspace": str(tmp_path)}) + + assert "Configuration loaded from: environment variables" in caplog.text + + +def test_config_logs_named_source(caplog, tmp_path): + caplog.set_level(logging.INFO, logger="socket_basics.core.config") + + Config({"workspace": str(tmp_path), "_config_source": "api"}) + + assert "Configuration loaded from: Socket dashboard (API)" in caplog.text From 0f67c8394e298c2047b8f63929227e107873ef58 Mon Sep 17 00:00:00 2001 From: Chris Bailey <46387933+ammkrn@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:26:48 -0700 Subject: [PATCH 08/10] chore: add nonempty license (LICENSE.md) (#79) Applies the PolyForm Shield License 1.0.0 --- .gitignore | 1 + LICENSE | 0 LICENSE.md | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) delete mode 100644 LICENSE create mode 100644 LICENSE.md diff --git a/.gitignore b/.gitignore index c234ce8..0cf62b9 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,7 @@ logs/ !docs/*.md !tests/README.md !.github/PULL_REQUEST_TEMPLATE.md +!LICENSE.md # Project-specific (local scripts and test files) test/ diff --git a/LICENSE b/LICENSE deleted file mode 100644 index e69de29..0000000 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d9ae5ff --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,166 @@ +# PolyForm Shield License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the +software to do everything you might do with the software +that would otherwise infringe the licensor's copyright +in it for any permitted purpose. However, you may +only distribute the software according to [Distribution +License](#distribution-license) and make changes or new works +based on the software according to [Changes and New Works +License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license +to distribute copies of the software. Your license +to distribute covers distributing the software with +changes and new works permitted by [Changes and New Works +License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of +the software from you also gets a copy of these terms or the +URL for them above, as well as copies of any plain-text lines +beginning with `Required Notice:` that the licensor provided +with the software. For example: + +> Required Notice: Socket, Inc. (http://socket.dev) + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Noncompete + +Any purpose is a permitted purpose, except for providing any +product that competes with the software or any product the +licensor or any of its affiliates provides using the software. + +## Competition + +Goods and services compete even when they provide functionality +through different kinds of interfaces or for different technical +platforms. Applications can compete with services, libraries +with plugins, frameworks with development tools, and so on, +even if they're written in different programming languages +or for different computer architectures. Goods and services +compete even when provided free of charge. If you market a +product as a practical substitute for the software or another +product, it definitely competes. + +## New Products + +If you are using the software to provide a product that does +not compete, but the licensor or any of its affiliates brings +your product into competition by providing a new version of +the software or another product using the software, you may +continue using versions of the software available under these +terms beforehand to provide your competing product, but not +any later versions. + +## Discontinued Products + +You may begin using the software to compete with a product +or service that the licensor or any of its affiliates has +stopped providing, unless the licensor includes a plain-text +line beginning with `Licensor Line of Business:` with the +software that mentions that line of business. For example: + +> Licensor Line of Business: YoyodyneCMS Content Management +System (http://example.com/cms) + +## Sales of Business + +If the licensor or any of its affiliates sells a line of +business developing the software or using the software +to provide a product, the buyer can also enforce +[Noncompete](#noncompete) for that product. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +A **product** can be a good or service, or a combination +of them. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +its affiliates. + +**Affiliates** means the other organizations than an +organization has control over, is under the control of, or is +under common control with. + +**Control** means ownership of substantially all the assets of +an entity, or the power to direct its management and policies +by vote, contract, or otherwise. Control can be direct or +indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. + +Licensor Line of Business: Socket, Inc. software supply chain security \ No newline at end of file From 496d37ea2a5647b16bff4e6dd7fb157d140fcabb Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:12:03 -0400 Subject: [PATCH 09/10] fix: restore standard LICENSE filename (revert LICENSE.md rename from #79) (#88) PR #79 renamed LICENSE to LICENSE.md, which deviates from standard OSS naming conventions and broke references that point at the extensionless LICENSE file: - Dockerfile:61 COPY ... LICENSE ... (Docker build failed, file missing) - pyproject.toml license = {file = "LICENSE"} (packaging failed) Renames LICENSE.md back to LICENSE and drops the now-unnecessary !LICENSE.md exception from .gitignore. Both references above resolve correctly again with no further changes. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --- .gitignore | 1 - LICENSE.md => LICENSE | 0 2 files changed, 1 deletion(-) rename LICENSE.md => LICENSE (100%) diff --git a/.gitignore b/.gitignore index 0cf62b9..c234ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -90,7 +90,6 @@ logs/ !docs/*.md !tests/README.md !.github/PULL_REQUEST_TEMPLATE.md -!LICENSE.md # Project-specific (local scripts and test files) test/ diff --git a/LICENSE.md b/LICENSE similarity index 100% rename from LICENSE.md rename to LICENSE From cd08db96a88d93dd7dc6e2fec661dc40c31c41ca Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:19:45 -0400 Subject: [PATCH 10/10] chore(deps): bundle Dependabot updates + harden dependency review workflows (#78) * chore(deps): bundle dependency updates + harden supply-chain review Bundles 8 open Dependabot PRs into one verified change and hardens the Dependabot config + dependency-review workflows, mirroring the work in socket-sdk-python#84 and socket-python-cli#207/#217. Adds a supply-chain watch for the four core OSS tools Dependabot cannot cleanly track. - uv.lock: idna 3.10->3.18 (CVE-2026-45409), pygments 2.19.2->2.20.0, pytest 8.4.2->9.0.3, urllib3 2.6.3->2.7.0 - _docker-pipeline.yml: bump 4 docker/* actions (setup-buildx, login, metadata, build-push) - dependabot.yml: add uv ecosystem, group every ecosystem into minor/patch + major bundles, scan composite actions - dependency-review.yml (was dependabot-review.yml): runs on every PR; free/enterprise sfw split; report artifacts; app_tests docker smoke - core-tool-watch.yml + scripts/check_core_tools.py: discover latest versions of opengrep/trufflehog/trivy/socketdev and score them through the Socket API (socketdev SDK purl.post); drift issue + report artifact - python-tests.yml: uv.lock drift guard Co-Authored-By: Claude Opus 4.8 * fix(ci): drop socket-firewall environment gate, add required coverage gate Mirroring the Python CLI/SDK used `environment: socket-firewall` to scope the SFW token, but that environment can carry a required-reviewers approval gate. Because the enterprise SFW check can't be a required status check (it would block Dependabot/fork PRs that only run the free edition), maintainers could merge without approving the deployment -- the meaningful check silently never ran, and approvers could rubber-stamp their own PRs. On the scheduled core-tool-watch job an approval gate would hang the cron run outright. - Remove `environment:` from python-sfw-smoke-enterprise and core-tool-watch; use a plain repo/org SOCKET_SFW_API_TOKEN (zizmor secrets-outside-env is already disabled here, so no lint cost). Job split still isolates the token to the enterprise job only. - Add always-on `dependency-review-gate` job: pass when no python deps changed, else require the free (Dependabot/fork) or enterprise (maintainer) smoke job to have succeeded. Mark THIS as the single required status check -- safe on every PR, no manual gate, no bypass. Co-Authored-By: Claude Opus 4.8 * fix(ci): scope SFW token via environment (no approval rule), harden gate Adopt the socket-python-cli#224 pattern uniformly. The environment was never the problem -- the required-reviewers approval RULE on it was. Keep the environment for secret scoping; forbid the rule. - Restore `environment: socket-firewall` on python-sfw-smoke-enterprise and the core-tool-watch analyze job so SOCKET_SFW_API_TOKEN is scoped to those jobs. Header documents that the environment must have NO reviewers rule, with the gh api command to enforce it (reviewers: null). - dependency-review-gate (Pattern 2 aggregator): now also needs docker-smoke-app-tests; fails on any failure/cancelled result (success and skipped pass) AND requires the trust-appropriate SFW edition to have succeeded when Python deps changed. Runs if: always() so the required context is always created -- no Pattern 1 bypass twin needed. Must land on main before being added to branch protection. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix(ci): degrade SFW enterprise to free when token absent; upload JSON report Live CI exposed two things on the now-enabled Actions: - socketdev/action firewall-enterprise HARD-ERRORS on an empty token (no silent fallback), so a trusted dep PR opened before the SOCKET_SFW_API_TOKEN secret exists fails and the required gate blocks merge. setup-sfw now resolves the effective mode and falls back to firewall-free when enterprise is requested without a token -- still a real supply-chain check, ships green today, auto-upgrades to enterprise the moment the secret is added. Token is read via env, never interpolated into the script. - socketdev/action writes a structured report to $SFW_JSON_REPORT_PATH; both smoke jobs now capture it and upload it alongside the tee'd log. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * feat(core-tool-watch): add Semgrep upstream proxy for OpenGrep Socket scoring OpenGrep ships as a GitHub-release binary that Socket has no data for under its pkg:github coordinate, so the watcher reported 'no data' for it. OpenGrep is a hard fork of Semgrep, so fall back to scoring the upstream Semgrep lineage (pkg:pypi/semgrep) as a project-health proxy. The proxy is report-only and never build-failing: it does not analyze OpenGrep's own release artifacts, so a Semgrep alert must not block an OpenGrep build. The pinned/latest verdicts show the proxy result labeled '(via semgrep upstream proxy)' when the primary coordinate has no data, and the JSON report records it under a separate 'proxy' key. The npm 'opengrep' package is a single-version squat (not the official distribution) and is deliberately not used as a coordinate. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * chore(deps): bundle 3 newly-filed Dependabot PRs (#81, #86, #87) - requests >=2.31.0 -> >=2.33.0 (#87); targeted 'uv lock --upgrade-package requests' resolves 2.34.2 (newer than Dependabot's 2.33.0) and pulls light-s3-client 0.0.40 -- the only two packages Dependabot's own PR touched. - actions/setup-python 6.2.0 -> 6.3.0 in python-tests.yml (#86, SHA verified against the v6.3.0 tag). The group's setup-buildx 4.1.0 bump is already in this branch. - docker/metadata-action 6.1.0 (#81) is already applied here (identical SHA) -- #81 is fully superseded, no code change. All three PRs to be closed manually as superseded by #78. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix(core-tool-watch): address Cursor Bugbot findings on #78 Five hardening fixes flagged by Bugbot: 1. Critical alerts now fail-on-malware (was malware-only). Track any_critical separately and exit non-zero on malware OR critical, matching the documented intent; add a 'critical' GitHub output. 2. Pins are read from BOTH Dockerfiles. app_tests/Dockerfile pins the same core tools (trufflehog/trivy/opengrep) independently; the reader only saw the root Dockerfile, so a divergent app_tests bump went unscored. Tool.pinned is now a list of every distinct pinned version, all of which are scored. 3. Watch mode no longer fails on latest. Only PINNED (in-use) versions are fail-worthy; the discovered latest is scored for drift reporting only, so a scheduled watch can't go red on an upstream release we haven't adopted. 4. dependency-review-gate fails closed when inspect fails. A failed inspect left DEPS_CHANGED/IS_TRUSTED empty, so the coverage rules silently passed and a PR with dep changes could merge with no Socket Firewall run. Added Rule 0. 5. Socket API errors fail closed in build mode. A swallowed analyze_purls exception let --fail-on-malware exit 0 with pinned versions unverified; now a scoring error (token present) fails the run. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix(ci): address 2 more Bugbot findings on #78 1. Drift issue never opened (High). gh issue list --jq '.[0].number' prints the literal string 'null' when no open core-tool-drift issue exists; 'null' is non-empty in bash, so the first scheduled drift run would call 'gh issue edit null' instead of creating the issue. Use '// empty' so an absent issue yields an empty string and the create branch runs. 2. Tests ignored the lockfile (Medium). python-tests installed deps via 'pip install -e .[dev]' (a fresh resolution) while only asserting the lock separately, so tests could run against different versions than uv.lock. Switch to 'uv sync --locked --extra dev' + 'uv run --no-sync pytest' so tests run against exactly the locked set. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix(ci): restore bare LICENSE to unblock CI (mirrors #88) Temporary: pull #88's fix into this branch so CI's merge-with-main ref builds. PR #79 renamed the license to LICENSE.md and deleted the empty LICENSE, but pyproject.toml and the Dockerfile still reference LICENSE, so hatchling and the Docker build fail against current main. This restores the PolyForm content into bare LICENSE (rename LICENSE.md -> LICENSE) and drops the now-moot !LICENSE.md .gitignore exception. Revert this commit and pull latest main once #88 lands there (main will then carry the identical fix). Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * fix(core-tool-watch): per-event concurrency group + document Dependabot secret gap - Include github.event_name in the concurrency group so a merge to main can't cancel the in-flight weekly watch run (or vice versa). - Document that Dependabot-triggered pull_request runs never receive the environment-scoped SOCKET_SFW_API_TOKEN (only Dependabot secrets), so build-mode scoring silently degrades to drift-only there; note the 'gh secret set --app dependabot' mirror needed for pre-merge coverage. Co-Authored-By: Claude Fable 5 * feat(deps-review): route Dependabot through sfw-enterprise; isolate core-tool-watch scan env Dependabot-triggered runs can read a *Dependabot-store* secret (set via 'gh secret set SOCKET_SFW_API_TOKEN --app dependabot'), so the free-tier routing for Dependabot -- a workaround for the assumption that its runs could never hold a token -- is gone: - dependency-review.yml: trusted == any in-repo (non-fork) PR, now including Dependabot. Its dep bumps get full org-policy (enterprise) enforcement; forks stay on the anonymous free edition. setup-sfw's existing empty-token fallback covers the window until the Dependabot secret mirror exists. - core-tool-watch.yml: sync the scan's Python env from the DEFAULT BRANCH lockfile (second checkout at .scan-env) so the token-holding step never imports packages bumped by the PR under review -- it only reads the PR's pins. Makes the Dependabot token mirror safe here too. - dependency-review.yml: import smokes use 'uv run --no-sync' so the post-firewall step can't re-sync outside sfw (Bugbot finding). - check_core_tools.py: docstring/help now honestly describe the strict fail thresholds (curated malware-class list + high/critical), which are intentional (Bugbot finding). Co-Authored-By: Claude Fable 5 * fix(core-tool-watch): sync scan env with --no-install-project The scan env checkout of main fails to build the socket-basics package editable while main carries the LICENSE.md rename breakage (#79, fix pending in #88). The scan only imports the dependencies (socketdev SDK), never socket_basics itself, so skip installing the project entirely -- also insulates this guard from any future main-side packaging breakage. Co-Authored-By: Claude Fable 5 * fix(core-tool-watch): org-scoped purl endpoint + fail closed on empty API result Two related hardenings from external review of purl.post() usage: - Pass org_slug (resolved via client.org.get) when the installed SDK supports it: socketdev >= 3.1 (socket-sdk-python#76) deprecates the legacy POST /v0/purl in favor of POST /v0/orgs/{slug}/purl, and a future major may drop the legacy route. The pinned 3.0.29 predates the parameter, so it is signature-gated -- activates automatically when the scan env's lockfile bumps the SDK. - Raise on an empty purl.post result: the SDK swallows ANY non-200 (expired token, dropped endpoint, outage) into [], which previously flowed through as 'no data' verdicts and exit 0 -- fail-open. Every run scores coordinates Socket definitely has data for, so empty is an API failure; raising routes it into the existing scoring_error fail-closed path under --fail-on-malware. Co-Authored-By: Claude Fable 5 * fix(core-tool-watch): resolve org slug only when unambiguous Match socket-python-cli's get_org_id_slug() semantics: pass org_slug to purl.post only when the token maps to exactly one org; multi-org tokens fall back to the legacy endpoint with a notice rather than guessing and scoring under the wrong org's policies. Co-Authored-By: Claude Fable 5 * fix(core-tool-watch): fail closed on unverified pinned coordinates Bugbot: a non-empty but incomplete Socket batch (or a pinned coordinate that never matches a returned analysis row) previously passed as 'no data' with exit 0 -- the guard could green-light a build without verifying every pin it exists to gate. Tools now declare socket_coverage (default True); with a token and a successful scoring pass, any covered pinned coordinate missing from the results is collected as unverified and fails a --fail-on-malware run, listing the exact coordinates. OpenGrep sets socket_coverage=False: its pkg:github coordinate is the documented no-data case with the report-only semgrep proxy, and must not perma-fail the guard. The unverified list is also surfaced in the JSON report. Co-Authored-By: Claude Fable 5 --------- Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- .github/actions/setup-sfw/action.yml | 57 +++ .github/dependabot.yml | 79 +++- .github/workflows/_docker-pipeline.yml | 12 +- .github/workflows/core-tool-watch.yml | 186 ++++++++ .github/workflows/dependabot-review.yml | 104 ----- .github/workflows/dependency-review.yml | 382 ++++++++++++++++ .github/workflows/python-tests.yml | 14 +- pyproject.toml | 2 +- scripts/check_core_tools.py | 573 ++++++++++++++++++++++++ uv.lock | 38 +- 10 files changed, 1310 insertions(+), 137 deletions(-) create mode 100644 .github/actions/setup-sfw/action.yml create mode 100644 .github/workflows/core-tool-watch.yml delete mode 100644 .github/workflows/dependabot-review.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 scripts/check_core_tools.py diff --git a/.github/actions/setup-sfw/action.yml b/.github/actions/setup-sfw/action.yml new file mode 100644 index 0000000..b580759 --- /dev/null +++ b/.github/actions/setup-sfw/action.yml @@ -0,0 +1,57 @@ +name: "Set up Socket Firewall" +description: >- + Set up Python 3.12 + uv and install Socket Firewall so subsequent steps can + run package-manager commands wrapped with `sfw`. Defaults to free/anonymous + mode (no API token -- safe on untrusted / Dependabot / fork PRs). Pass + mode: firewall-enterprise + socket-token for full org-policy enforcement on + trusted maintainer PRs. + +inputs: + uv: + description: "Install uv (Python is always set up)" + default: "true" + mode: + description: "socketdev/action mode: firewall-free or firewall-enterprise" + default: "firewall-free" + socket-token: + description: "Socket API token (only used/required for firewall-enterprise)" + default: "" + +runs: + using: "composite" + steps: + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + # Resolve the effective mode. socketdev/action's firewall-enterprise mode + # HARD-ERRORS on an empty token (it does not silently fall back), so a + # trusted PR opened before the SOCKET_SFW_API_TOKEN secret exists would + # fail. Degrade to firewall-free in that case: the anonymous edition is + # still a real supply-chain check, the workflow ships green today, and it + # auto-upgrades to enterprise the moment the secret is added. Token is read + # via env (not interpolated into the script) so it never lands in the log. + - id: resolve + shell: bash + env: + REQUESTED_MODE: ${{ inputs.mode }} + SOCKET_TOKEN: ${{ inputs.socket-token }} + run: | + mode="$REQUESTED_MODE" + if [ "$mode" = "firewall-enterprise" ] && [ -z "$SOCKET_TOKEN" ]; then + echo "::warning::firewall-enterprise requested but no socket-token is set; falling back to firewall-free. Add the SOCKET_SFW_API_TOKEN secret to enable enterprise org-policy enforcement." + mode="firewall-free" + fi + echo "mode=$mode" >> "$GITHUB_OUTPUT" + + # Official Socket setup action. Wires up sfw routing correctly. + # socket-token is ignored in firewall-free mode and empty when absent. + - uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2 + with: + mode: ${{ steps.resolve.outputs.mode }} + socket-token: ${{ inputs.socket-token }} + + - if: ${{ inputs.uv == 'true' }} + name: Install uv + shell: bash + run: python -m pip install --upgrade pip uv diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6ee280f..9b19124 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,50 @@ +# Dependabot configuration for socket-basics. +# +# Design notes: +# - Every ecosystem is grouped into a weekly minor/patch PR plus a separate +# major-update PR, so routine bumps land as one reviewable bundle while +# breaking majors stay isolated. +# - 7-day cooldown across all ecosystems (skip just-published releases). +# - Python deps (idna, urllib3, pygments, pytest, ...) are uv-tracked via +# uv.lock — the `uv` ecosystem governs them. Without this entry the uv PRs +# pile up ungrouped. +# - The two Dockerfiles track their pinned tool/base images; OPENGREP_VERSION +# is NOT Dependabot-trackable (no Docker image) — bump it manually. +# - GitHub Actions scans the workflows AND the local composite actions under +# /.github/actions/*. + version: 2 updates: + # Python deps (uv-tracked via uv.lock) + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 2 + groups: + python-minor-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + python-major: + patterns: + - "*" + update-types: + - "major" + labels: + - "dependencies" + - "python:uv" + commit-message: + prefix: "chore" + include: "scope" + cooldown: + default-days: 7 + # Main Dockerfile — tracks aquasec/trivy, trufflesecurity/trufflehog, - # ghcr.io/astral-sh/uv, and python base image. + # ghcr.io/astral-sh/uv, and the python base image. # NOTE: OPENGREP_VERSION is not trackable via Dependabot (no Docker image); # update it manually in the Dockerfile ARG. - package-ecosystem: "docker" @@ -15,6 +57,18 @@ updates: - dependency-name: "ghcr.io/astral-sh/uv" - dependency-name: "trufflesecurity/trufflehog" - dependency-name: "aquasec/trivy" + groups: + docker-main-minor-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + docker-main-major: + patterns: + - "*" + update-types: + - "major" labels: - "dependencies" - "docker" @@ -36,6 +90,18 @@ updates: - dependency-name: "securego/gosec" - dependency-name: "trufflesecurity/trufflehog" - dependency-name: "aquasec/trivy" + groups: + docker-app-tests-minor-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + docker-app-tests-major: + patterns: + - "*" + update-types: + - "major" labels: - "dependencies" - "docker" @@ -45,9 +111,11 @@ updates: cooldown: default-days: 7 - # GitHub Actions — tracks all uses: ... action versions. + # GitHub Actions used in workflows and local composite actions. - package-ecosystem: "github-actions" - directory: "/" + directories: + - "/" + - "/.github/actions/*" schedule: interval: "weekly" open-pull-requests-limit: 4 @@ -58,6 +126,11 @@ updates: update-types: - "minor" - "patch" + github-actions-major: + patterns: + - "*" + update-types: + - "major" labels: - "dependencies" - "github-actions" diff --git a/.github/workflows/_docker-pipeline.yml b/.github/workflows/_docker-pipeline.yml index 91d8367..fde4334 100644 --- a/.github/workflows/_docker-pipeline.yml +++ b/.github/workflows/_docker-pipeline.yml @@ -70,12 +70,12 @@ jobs: persist-credentials: false - name: 🔨 Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 # GHCR login runs before the build — needed to pull ghcr.io/astral-sh/uv. - name: Login to GHCR if: inputs.push - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -90,7 +90,7 @@ jobs: - name: Extract image metadata if: inputs.push id: meta - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: | ghcr.io/socketdev/${{ inputs.name }} @@ -113,7 +113,7 @@ jobs: # Loads image into the local Docker daemon without pushing. # Writes all layers to the GHA cache so the push step is just an upload. - name: 🔨 Build (load for testing) - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: # zizmor: ignore[template-injection] — safe: always hardcoded "." from same-repo callers; passed as array element to exec, not shell-interpolated context: ${{ inputs.context }} @@ -159,7 +159,7 @@ jobs: # with public image pulls during the build step. - name: Login to Docker Hub if: inputs.push - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -167,7 +167,7 @@ jobs: # All layers are in the GHA cache from step 1 — this is just an upload. - name: 🚀 Push to registries if: inputs.push - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: # zizmor: ignore[template-injection] — safe: always hardcoded "." from same-repo callers; passed as array element to exec, not shell-interpolated context: ${{ inputs.context }} diff --git a/.github/workflows/core-tool-watch.yml b/.github/workflows/core-tool-watch.yml new file mode 100644 index 0000000..51dd985 --- /dev/null +++ b/.github/workflows/core-tool-watch.yml @@ -0,0 +1,186 @@ +name: core-tool-watch + +# Supply-chain / malware watch for the four core OSS tools that Socket Basics +# orchestrates. Three of them (OpenGrep, TruffleHog, Trivy) ship as +# binaries / container images / GitHub releases that Dependabot cannot cleanly +# track; the fourth (Socket's own SCA SDK) is a PyPI package. This workflow +# closes that gap by running scripts/check_core_tools.py, which discovers the +# latest upstream version of each tool and scores the relevant package +# coordinates through the Socket API (dogfooding the socketdev SDK that Socket +# Basics already depends on). +# +# Two triggers, two intents: +# - schedule / workflow_dispatch → mode=watch: discover latest versions, +# analyze BOTH pinned and latest, report drift, upsert a tracking issue. +# - pull_request / push touching the pins → mode=build: analyze the versions +# this change would bake into the image. Fails on a malware/critical alert. +# +# Socket scoring needs SOCKET_SFW_API_TOKEN, scoped to the `socket-firewall` +# environment (which must carry NO approval rule -- see dependency-review.yml). +# Dependabot-triggered runs only receive *Dependabot* secrets, never +# Actions/environment secrets, so the token must ALSO be mirrored into the +# Dependabot store (one-time admin step, same as dependency-review.yml): +# +# gh secret set SOCKET_SFW_API_TOKEN --app dependabot +# +# That mirror is the EXPECTED setup: Dependabot's pin bumps are precisely what +# build mode exists to score pre-merge. It is safe to hand this job the token +# on Dependabot PRs because the scan's Python environment is synced from the +# DEFAULT BRANCH lockfile (see the .scan-env checkout below) -- the +# token-holding step never imports packages bumped by the PR under review; it +# only READS the PR's pins. When the token is absent anyway (fork PRs, or +# before the mirror exists), version-drift detection still runs and scoring is +# skipped with a notice; the push-to-main run re-scores after merge as a +# backstop. + +on: + schedule: + # Mondays 07:00 UTC, after the weekly Dependabot run. + - cron: "0 7 * * 1" + workflow_dispatch: + pull_request: + paths: + - "Dockerfile" + - "app_tests/Dockerfile" + - "pyproject.toml" + - "uv.lock" + - "scripts/check_core_tools.py" + - ".github/workflows/core-tool-watch.yml" + push: + branches: [main] + paths: + - "Dockerfile" + - "app_tests/Dockerfile" + - "pyproject.toml" + - "uv.lock" + - "scripts/check_core_tools.py" + - ".github/workflows/core-tool-watch.yml" + +permissions: + contents: read + +concurrency: + # Include the event name: schedule, workflow_dispatch, and push all run on + # refs/heads/main, and a shared group would let a merge to main cancel the + # in-flight weekly watch (or the cron cancel a push-triggered build guard). + group: core-tool-watch-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + runs-on: ubuntu-latest + timeout-minutes: 15 + # `environment:` scopes SOCKET_SFW_API_TOKEN to this job. The environment + # MUST have no required-reviewers rule -- an approval gate would hang the + # scheduled cron run forever (and is the bypass footgun called out in + # dependency-review.yml). Configure it with `reviewers: null` (see that + # file's header for the gh api command). + environment: socket-firewall + permissions: + contents: read + issues: write # upsert the drift tracking issue on scheduled runs + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + # Second checkout: the DEFAULT BRANCH, used only to build the scan's + # Python environment. The socketdev SDK (and its dependency chain) is + # imported by the token-holding scan step, so it must come from + # already-merged, already-scored lockfile versions -- never from the PR + # under review, whose freshly-bumped packages are the very thing being + # judged. On push/schedule runs both checkouts are identical. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + path: .scan-env + fetch-depth: 1 + persist-credentials: false + + - name: 🐍 Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: 🛠️ Install uv + sync scan env from main's lockfile (provides the socketdev SDK) + # --no-install-project: the scan only imports the DEPENDENCIES + # (socketdev SDK), never socket_basics itself, so skip building the + # local package -- faster, and immune to packaging breakage on main + # (e.g. a bad license-file rename) taking this guard down with it. + run: | + python -m pip install --upgrade pip uv + uv sync --locked --project .scan-env --no-install-project + + - name: Select mode + id: mode + env: + EVENT: ${{ github.event_name }} + run: | + # Scheduled/manual runs watch for upstream drift; PR/push runs guard + # the versions a build would actually pull in. + if [ "$EVENT" = "schedule" ] || [ "$EVENT" = "workflow_dispatch" ]; then + echo "mode=watch" >> "$GITHUB_OUTPUT" + else + echo "mode=build" >> "$GITHUB_OUTPUT" + fi + + - name: Run core-tool supply-chain analysis + id: scan + # --project .scan-env --no-sync: execute with main's already-vetted + # dependency versions (never the PR's bumps) while the script itself + # reads the pins from this checkout's working tree. + env: + SOCKET_API_TOKEN: ${{ secrets.SOCKET_SFW_API_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} + run: | + uv run --project .scan-env --no-sync python scripts/check_core_tools.py \ + --mode "${{ steps.mode.outputs.mode }}" \ + --summary-file core-tools-report.md \ + --json-out core-tools-report.json \ + --github-output "$GITHUB_OUTPUT" \ + --fail-on-malware + + - name: Render report to job summary + if: always() + run: | + if [ -f core-tools-report.md ]; then + cat core-tools-report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload core-tool report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: core-tools-report + path: | + core-tools-report.md + core-tools-report.json + if-no-files-found: warn + retention-days: 30 + + - name: Open/update drift tracking issue + if: ${{ always() && steps.mode.outputs.mode == 'watch' && steps.scan.outputs.drift == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + gh label create core-tool-drift \ + --color FBCA04 \ + --description "A core OSS tool has a newer upstream release" 2>/dev/null || true + + title="Core tool version drift detected" + # `// empty` so an absent issue yields "" (not the literal "null", + # which is non-empty in bash and would send us to `gh issue edit null`). + existing="$(gh issue list --label core-tool-drift --state open \ + --json number --jq '.[0].number // empty' 2>/dev/null || true)" + + if [ -n "$existing" ]; then + gh issue edit "$existing" --body-file core-tools-report.md + gh issue comment "$existing" \ + --body "Drift re-detected by [run #${GITHUB_RUN_ID}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}); body updated." + else + gh issue create \ + --title "$title" \ + --label core-tool-drift \ + --body-file core-tools-report.md + fi diff --git a/.github/workflows/dependabot-review.yml b/.github/workflows/dependabot-review.yml deleted file mode 100644 index 9163e8f..0000000 --- a/.github/workflows/dependabot-review.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: dependabot-review - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - -permissions: - contents: read - -concurrency: - group: dependabot-review-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - inspect: - if: github.event.pull_request.user.login == 'dependabot[bot]' - runs-on: ubuntu-latest - outputs: - root_docker_changed: ${{ steps.diff.outputs.root_docker_changed }} - app_tests_docker_changed: ${{ steps.diff.outputs.app_tests_docker_changed }} - workflow_or_action_changed: ${{ steps.diff.outputs.workflow_or_action_changed }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Inspect changed files - id: diff - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - CHANGED_FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" - - echo "Changed files:" >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" - printf '%s\n' "$CHANGED_FILES" >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" - - has_file() { - local pattern="$1" - if printf '%s\n' "$CHANGED_FILES" | grep -Eq "$pattern"; then - echo "true" - else - echo "false" - fi - } - - echo "root_docker_changed=$(has_file '^Dockerfile$')" >> "$GITHUB_OUTPUT" - echo "app_tests_docker_changed=$(has_file '^app_tests/Dockerfile$')" >> "$GITHUB_OUTPUT" - echo "workflow_or_action_changed=$(has_file '^\\.github/workflows/|^action\\.yml$|^\\.github/dependabot\\.yml$')" >> "$GITHUB_OUTPUT" - - - name: Summarize review expectations - env: - PR_URL: ${{ github.event.pull_request.html_url }} - run: | - { - echo "## Dependabot Review Checklist" - echo "- PR: $PR_URL" - echo "- Confirm upstream release notes before merge" - echo "- Confirm Docker/toolchain changes match the files in this PR" - echo "- Do not treat a Dependabot PR as trusted solely because of the actor" - echo "- This workflow runs in pull_request context only; no publish secrets are exposed" - } >> "$GITHUB_STEP_SUMMARY" - - docker-smoke-main: - needs: inspect - if: github.event.pull_request.user.login == 'dependabot[bot]' && needs.inspect.outputs.root_docker_changed == 'true' - uses: ./.github/workflows/_docker-pipeline.yml - permissions: - contents: read - with: - name: socket-basics - dockerfile: Dockerfile - context: . - check_set: main - push: false - - docker-smoke-app-tests: - needs: inspect - if: github.event.pull_request.user.login == 'dependabot[bot]' && needs.inspect.outputs.app_tests_docker_changed == 'true' - uses: ./.github/workflows/_docker-pipeline.yml - permissions: - contents: read - with: - name: socket-basics-app-tests - dockerfile: app_tests/Dockerfile - context: . - check_set: app-tests - push: false - - workflow-notice: - needs: inspect - if: github.event.pull_request.user.login == 'dependabot[bot]' && needs.inspect.outputs.workflow_or_action_changed == 'true' - runs-on: ubuntu-latest - steps: - - name: Flag workflow-sensitive updates - run: | - { - echo "## Sensitive File Notice" - echo "This Dependabot PR changes workflow or action metadata files." - echo "Require explicit human review before merge." - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..850317f --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,382 @@ +name: dependency-review + +# Supply-chain guardrails for dependency-change PRs -- for BOTH Dependabot and +# maintainers. `inspect` classifies the PR, then the right Socket Firewall (sfw) +# smoke job runs when Python deps change: +# +# - python-sfw-smoke-enterprise -- trusted authors: any in-repo (non-fork) +# PR, i.e. maintainers with write access AND Dependabot (its dependabot/* +# branches live in-repo). Runs the authenticated enterprise edition for +# full org-policy enforcement, reading the SOCKET_SFW_API_TOKEN secret. +# Dependabot-triggered runs read secrets from the *Dependabot* secret +# store, never Actions/environment secrets, so the token must ALSO be +# mirrored there (one-time admin step): +# +# gh secret set SOCKET_SFW_API_TOKEN --app dependabot +# +# This is NOT the pull_request_target footgun: Dependabot secrets are +# handed only to runs actually triggered by dependabot[bot], still in the +# unprivileged read-only pull_request context -- fork PRs from external +# contributors can never read them. Until the mirror exists, setup-sfw +# degrades Dependabot runs to firewall-free with a warning (see that +# action's resolve step), so nothing strands. +# - python-sfw-smoke-free -- fork PRs from external contributors. Anonymous +# free edition, no token. Never references the secret. +# +# Splitting the jobs (rather than picking a mode in one job) means only the +# enterprise job ever names the token; the free path (forks) has no +# secret-leak surface. Both run in the unprivileged `pull_request` context. +# +# Secret scoping vs. the approval-gate trap (matches socket-python-cli#224): +# The enterprise job uses `environment: socket-firewall` so the +# SOCKET_SFW_API_TOKEN can be scoped to that environment -- only this job can +# read it. KEEP the environment; it is good secret hygiene. What must NOT exist +# on that environment is a "required reviewers" approval rule. That rule is the +# trap: the enterprise SFW check cannot itself be a required status check (it is +# skipped on fork PRs, which only run the free edition, and a +# never-created required check blocks merge forever), so a manual deployment +# gate is both self-approvable (prevent_self_review defaults off; admins bypass) +# AND skippable -- maintainers merge without it ever running. Configure the +# environment with no reviewers: +# +# gh api -X PUT repos/SocketDev/socket-basics/environments/socket-firewall \ +# --input - <<<'{"wait_timer":0,"prevent_self_review":false,"reviewers":null,"deployment_branch_policy":null}' +# +# Coverage is instead enforced by the always-on `dependency-review-gate` job +# below -- mark THAT as the single required status check. It runs on every PR +# (if: always(), never skipped, so the required context is always created), +# requires the free job for forks and the enterprise job for maintainers and +# Dependabot, and is a no-op when no Python deps changed. +# +# Docker dependency changes: the main image is already build-smoke-tested by +# smoke-test.yml on every PR, so only the app_tests image (uncovered elsewhere) +# is built here. +# +# Pattern adapted from SocketDev/socket-sdk-python and SocketDev/socket-python-cli. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: dependency-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + inspect: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + python_deps_changed: ${{ steps.diff.outputs.python_deps_changed }} + app_tests_docker_changed: ${{ steps.diff.outputs.app_tests_docker_changed }} + workflow_or_action_changed: ${{ steps.diff.outputs.workflow_or_action_changed }} + is_trusted: ${{ steps.trust.outputs.is_trusted }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Inspect changed files + id: diff + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + CHANGED_FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" + + { + echo "## Changed files" + echo '```' + printf '%s\n' "$CHANGED_FILES" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + has_file() { + local pattern="$1" + if printf '%s\n' "$CHANGED_FILES" | grep -Eq "$pattern"; then + echo "true" + else + echo "false" + fi + } + + { + echo "python_deps_changed=$(has_file '^(pyproject\.toml|uv\.lock)$')" + echo "app_tests_docker_changed=$(has_file '^app_tests/Dockerfile$')" + echo "workflow_or_action_changed=$(has_file '^\.github/workflows/|^\.github/actions/|^action\.yml$|^\.github/dependabot\.yml$')" + } >> "$GITHUB_OUTPUT" + + - name: Classify PR trust + id: trust + # Trusted == any in-repo (non-fork) PR. Only accounts with write access + # can push a branch to this repo, so a non-fork PR already implies a + # trusted author -- the same boundary GitHub uses to decide whether + # secrets are exposed at all. That includes Dependabot: its + # dependabot/* branches are in-repo, and dependency bumps are exactly + # where org-policy (enterprise) enforcement matters most. The + # enterprise job's token resolves from the Dependabot secret store on + # its runs (see the header note). + # + # NB: author_association is deliberately NOT used to require strict org + # membership. It only reflects PUBLIC org membership, so private members + # (the common case) show up as CONTRIBUTOR and would be misclassified. + # This step references NO secret regardless -- it only decides which + # smoke job runs. + env: + IS_DEPENDABOT: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} + IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + AUTHOR_ASSOC: ${{ github.event.pull_request.author_association }} + run: | + is_trusted=false + if [ "$IS_FORK" != "true" ]; then + is_trusted=true + fi + + echo "is_trusted=$is_trusted" >> "$GITHUB_OUTPUT" + { + echo "## Socket Firewall edition: \`$([ "$is_trusted" = true ] && echo enterprise || echo free)\`" + echo "- author_association: \`$AUTHOR_ASSOC\`" + echo "- dependabot: \`$IS_DEPENDABOT\` | fork: \`$IS_FORK\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Summarize review expectations + env: + PR_URL: ${{ github.event.pull_request.html_url }} + run: | + { + echo "## Dependency Review Checklist" + echo "- PR: $PR_URL" + echo "- Confirm upstream release notes before merge" + echo "- Do not treat a dependency PR as trusted solely because of the actor" + echo "- This workflow runs in pull_request context only; no publish secrets are exposed" + } >> "$GITHUB_STEP_SUMMARY" + + # Untrusted PRs (forks from outside collaborators / externals): + # anonymous free edition. Never references the token. + python-sfw-smoke-free: + needs: inspect + if: needs.inspect.outputs.python_deps_changed == 'true' && needs.inspect.outputs.is_trusted != 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - uses: ./.github/actions/setup-sfw + with: + uv: "true" + mode: firewall-free + + - name: Sync project through Socket Firewall (free) + env: + UV_PYTHON: "3.12" + UV_PYTHON_DOWNLOADS: never + run: | + set -o pipefail + sfw uv sync --locked --extra dev 2>&1 | tee sfw-report-free.log + + - name: Collect Socket Firewall JSON report + if: always() + # socketdev/action writes a structured report to $SFW_JSON_REPORT_PATH. + run: cp "${SFW_JSON_REPORT_PATH:-/nonexistent}" sfw-report-free.json 2>/dev/null || echo "no SFW JSON report produced" + + - name: Import smoke test + # --no-sync: plain `uv run` would re-sync (and potentially hit package + # indexes) OUTSIDE the firewall wrapper; the env was already synced + # through sfw above. + run: | + uv run --no-sync python -c " + import socket_basics + from socket_basics.version import __version__ + print('import smoke OK', __version__) + " + + - name: Upload Socket Firewall report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sfw-report-free + path: | + sfw-report-free.log + sfw-report-free.json + if-no-files-found: warn + retention-days: 14 + + # Trusted authors (SocketDev members + Dependabot): authenticated enterprise + # edition. Only this job references the token (the free job never does). + # `environment:` scopes the secret to this job for maintainer runs; on + # Dependabot runs the same expression resolves from the Dependabot secret + # store instead (mirror required -- header note). The environment must have + # NO required-reviewers rule (see the header note); coverage is enforced by + # dependency-review-gate, not a manual approval gate. + python-sfw-smoke-enterprise: + needs: inspect + if: needs.inspect.outputs.python_deps_changed == 'true' && needs.inspect.outputs.is_trusted == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: socket-firewall + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - uses: ./.github/actions/setup-sfw + with: + uv: "true" + mode: firewall-enterprise + socket-token: ${{ secrets.SOCKET_SFW_API_TOKEN }} + + - name: Sync project through Socket Firewall (enterprise) + # UV_PYTHON pins the runner's interpreter so uv does not fetch a + # uv-managed Python through the firewall (blocked by its TLS interception). + env: + UV_PYTHON: "3.12" + UV_PYTHON_DOWNLOADS: never + run: | + set -o pipefail + sfw uv sync --locked --extra dev 2>&1 | tee sfw-report-enterprise.log + + - name: Collect Socket Firewall JSON report + if: always() + # socketdev/action writes a structured report to $SFW_JSON_REPORT_PATH. + run: cp "${SFW_JSON_REPORT_PATH:-/nonexistent}" sfw-report-enterprise.json 2>/dev/null || echo "no SFW JSON report produced" + + - name: Import smoke test + # --no-sync: plain `uv run` would re-sync (and potentially hit package + # indexes) OUTSIDE the firewall wrapper; the env was already synced + # through sfw above. + run: | + uv run --no-sync python -c " + import socket_basics + from socket_basics.version import __version__ + print('import smoke OK', __version__) + " + + - name: Upload Socket Firewall report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sfw-report-enterprise + path: | + sfw-report-enterprise.log + sfw-report-enterprise.json + if-no-files-found: warn + retention-days: 14 + + # app_tests image build-smoke (the main image is covered by smoke-test.yml). + docker-smoke-app-tests: + needs: inspect + if: needs.inspect.outputs.app_tests_docker_changed == 'true' + uses: ./.github/workflows/_docker-pipeline.yml + permissions: + contents: read + with: + name: socket-basics-app-tests + dockerfile: app_tests/Dockerfile + context: . + check_set: app-tests + push: false + + workflow-notice: + needs: inspect + if: needs.inspect.outputs.workflow_or_action_changed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Flag workflow-sensitive updates + run: | + { + echo "## Sensitive File Notice" + echo "This PR changes workflow, composite-action, action.yml, or dependabot config files." + echo "Require explicit human review before merge." + } >> "$GITHUB_STEP_SUMMARY" + + # Aggregator gate (socket-python-cli#224, Pattern 2). Single always-on status + # that closes the bypass blindspot -- mark THIS job (and only this job) as the + # required status check for the branch (Settings -> Branches). Two rules: + # + # 1. Fail if ANY needed conditional job ended in failure/cancelled + # (success and skipped both pass -- a skipped job is a legitimate no-run). + # 2. Coverage: when Python deps changed, the trust-appropriate SFW edition + # (enterprise for maintainers + Dependabot, free for forks) must have + # actually succeeded -- not merely been skipped. + # + # It runs on every PR (if: always(), never skipped via a job-level condition, + # so the required context is always created -- avoiding the "Expected -- + # Waiting for status" deadlock that strands a required-but-skipped check), and + # never waits on a manual gate. IMPORTANT: merge this job to main BEFORE adding + # it to branch protection, or every other open PR strands on the same trap. + dependency-review-gate: + needs: + - inspect + - python-sfw-smoke-free + - python-sfw-smoke-enterprise + - docker-smoke-app-tests + if: always() + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Enforce dependency-review coverage + env: + INSPECT_RESULT: ${{ needs.inspect.result }} + DEPS_CHANGED: ${{ needs.inspect.outputs.python_deps_changed }} + IS_TRUSTED: ${{ needs.inspect.outputs.is_trusted }} + FREE_RESULT: ${{ needs.python-sfw-smoke-free.result }} + ENTERPRISE_RESULT: ${{ needs.python-sfw-smoke-enterprise.result }} + DOCKER_RESULT: ${{ needs.docker-smoke-app-tests.result }} + run: | + fail=0 + + # Rule 0: inspect must succeed -- its outputs drive every rule below. + # If it failed/cancelled, DEPS_CHANGED/IS_TRUSTED are empty and the + # coverage rules would silently pass, so fail closed instead. + echo "inspect: $INSPECT_RESULT" + if [ "$INSPECT_RESULT" != "success" ]; then + echo "::error::inspect job did not succeed (result: $INSPECT_RESULT); cannot trust PR classification. Failing closed." + fail=1 + fi + + # Rule 1: any real failure/cancellation in a conditional job blocks. + for pair in \ + "python-sfw-smoke-free=$FREE_RESULT" \ + "python-sfw-smoke-enterprise=$ENTERPRISE_RESULT" \ + "docker-smoke-app-tests=$DOCKER_RESULT"; do + name="${pair%%=*}"; res="${pair#*=}" + echo "$name: $res" + if [ "$res" = "failure" ] || [ "$res" = "cancelled" ]; then + echo "::error::$name ended in $res" + fail=1 + fi + done + + # Rule 2: when deps changed, the required SFW edition must have run+passed. + if [ "$DEPS_CHANGED" = "true" ]; then + if [ "$IS_TRUSTED" = "true" ]; then + edition="enterprise"; required="$ENTERPRISE_RESULT" + else + edition="free"; required="$FREE_RESULT" + fi + echo "Python deps changed; required Socket Firewall edition: $edition ($required)" + if [ "$required" != "success" ]; then + echo "::error::Required Socket Firewall smoke ($edition) did not succeed (result: $required). This PR changes Python dependencies and must pass the Socket Firewall check before merge." + fail=1 + fi + else + echo "No Python dependency changes -- Socket Firewall smoke not required." + fi + + if [ "$fail" -eq 0 ]; then + echo "dependency-review-gate: all required checks satisfied. ✅" + fi + exit "$fail" diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 55052c5..a5d7482 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -37,15 +37,21 @@ jobs: fetch-depth: 1 persist-credentials: false - name: 🐍 Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.12" cache: "pip" - name: 🛠️ Install deps + # `uv sync --locked` installs exactly the versions in uv.lock, so tests + # run against the locked dependency set (not a fresh pip resolution). run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" + python -m pip install --upgrade pip uv + uv sync --locked --extra dev + - name: 🔐 Assert uv.lock is in sync with pyproject.toml + # Catches dependency PRs (Dependabot or maintainer) that change + # pyproject.toml without regenerating the lock, or vice versa. + run: uv lock --locked - name: 🔒 Assert release version metadata is in sync run: python3 scripts/sync_release_version.py --check - name: 🧪 Run tests - run: pytest -q tests/ + run: uv run --no-sync pytest -q tests/ diff --git a/pyproject.toml b/pyproject.toml index bdb6cdc..bf82834 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ # Keep runtime deps minimal and explicit. Remove unused packages (mdutils, PyGithub) - "requests>=2.31.0", + "requests>=2.33.0", "tabulate~=0.9.0", "light-s3-client~=0.0.30", "PyYAML>=6.0.0", diff --git a/scripts/check_core_tools.py b/scripts/check_core_tools.py new file mode 100644 index 0000000..733e30d --- /dev/null +++ b/scripts/check_core_tools.py @@ -0,0 +1,573 @@ +#!/usr/bin/env python3 +"""Supply-chain watch for the four core OSS tools bundled by Socket Basics. + +Socket Basics is a thin orchestration layer over four upstream security tools. +Three of them ship as binaries / container images / GitHub releases that +Dependabot cannot cleanly track, and one (Socket's own SCA SDK) is a PyPI +package. This script closes that gap: it discovers the latest upstream version +of each tool, compares it against the version currently pinned in the repo, and +runs Socket supply-chain / malware analysis against the relevant package +coordinates -- dogfooding the `socketdev` SDK that Socket Basics already +depends on. + +Tools tracked: + - opengrep (SAST engine) pin: Dockerfile ARG OPENGREP_VERSION + - trufflehog (secret scanner) pin: Dockerfile ARG TRUFFLEHOG_VERSION + - trivy (container scanner) pin: Dockerfile ARG TRIVY_VERSION + - socketdev (Socket SCA SDK) pin: uv.lock / pyproject.toml + +Two modes (the caller picks via flags): + + --mode build Analyze the versions CURRENTLY PINNED in the repo. This is the + build-time guardrail: if Socket flags malware or a critical + alert on a version we are about to bake into the image, fail. + + --mode watch Additionally discover the latest upstream version and analyze + THAT too, reporting drift. This is the scheduled watch: "is + there a newer version, and is it safe to adopt?" + +Socket analysis requires a Socket API token (env SOCKET_API_TOKEN). Without it, +version discovery + drift reporting still run; the Socket scoring is skipped +with a notice (graceful degradation, mirroring the free/enterprise split in +dependency-review.yml). + +Exit code is 0 unless --fail-on-malware is set AND a PINNED version trips the +(deliberately strict) thresholds: any alert type in MALWARE_ALERT_TYPES -- a +curated list that goes beyond outright malware to include strong risk signals +like install scripts, obfuscation, and telemetry -- OR any alert of high or +critical severity. With a token present, a Socket scoring error -- or a +covered pinned coordinate missing from the returned batch -- also fails +(fail-closed: unverified pins must not ship; OpenGrep's documented pkg:github +coverage gap is the one exemption). Drift alone never fails the run, +and the discovered *latest* version is scored for reporting only; both are +surfaced via the JSON report and the `drift`/`malware`/`critical` GitHub +outputs so the workflow decides what to do. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Optional + +REPO_ROOT = Path(__file__).resolve().parent.parent +# Both Dockerfiles pin the core tools and can drift independently, so scoring +# must cover every version pinned across all of them. +DOCKERFILES = [REPO_ROOT / "Dockerfile", REPO_ROOT / "app_tests" / "Dockerfile"] +UV_LOCK = REPO_ROOT / "uv.lock" + +# Alert types treated as fail-worthy on a pinned version. Deliberately broader +# than literal malware: alongside outright compromise (malware, trojan, +# backdoor) it includes strong risk signals (obfuscation, install scripts, +# shell access, telemetry, typosquat hints) -- for the four core tools we bake +# into the image, any of these deserves a hard stop and a human look, at the +# cost of occasional false positives. Trim this set rather than disabling +# --fail-on-malware if it proves too noisy. +MALWARE_ALERT_TYPES = { + "malware", + "gptMalware", + "gptSecurity", + "didYouMean", + "obfuscatedFile", + "obfuscatedRequire", + "shellAccess", + "suspiciousStarActivity", + "cryptoMiner", + "installScript", + "telemetry", + "trojan", + "backdoor", +} +# Severities that count as fail-worthy: includes "high", not just "critical". +CRITICAL_SEVERITIES = {"critical", "high"} + + +@dataclass +class Tool: + key: str + label: str + # Returns every distinct version currently pinned in the repo (across both + # Dockerfiles / uv.lock; no leading-v normalization -- as written). + read_pinned: Callable[[], list[str]] + # Returns the latest upstream version tag (as published). + discover_latest: Callable[[], Optional[str]] + # Builds a Socket PURL for a given version string. + purl: Callable[[str], str] + note: str = "" + # Optional fallback coordinate scored when `purl` has no Socket coverage + # (e.g. pkg:github). Returns a fully-formed PURL string (or None). Used for + # reporting only -- a proxy is never build-failing. + proxy_purl: Callable[[], Optional[str]] | None = None + proxy_label: str = "" + # Whether Socket is expected to have data for this tool's primary PURL. + # When True, a pinned version with no matching analysis row is treated as + # UNVERIFIED and fails a --fail-on-malware run (an incomplete batch must + # not pass the guard). False only for tools with a documented coverage gap + # (OpenGrep's pkg:github coordinate), where "no data" is the known state + # and the proxy provides report-only signal instead. + socket_coverage: bool = True + pinned: list[str] = field(default_factory=list) + latest: Optional[str] = None + resolved_proxy_purl: Optional[str] = None + analyses: dict[str, dict[str, Any]] = field(default_factory=dict) + + +# ── HTTP helpers ────────────────────────────────────────────────────────────── + + +def _get_json(url: str, token: Optional[str] = None) -> Any: + req = urllib.request.Request(url, headers={"User-Agent": "socket-basics-core-tool-watch"}) + if token: + req.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 (trusted hosts) + return json.loads(resp.read().decode("utf-8")) + + +def _github_latest_release(repo: str) -> Optional[str]: + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + try: + data = _get_json(f"https://api.github.com/repos/{repo}/releases/latest", token) + return data.get("tag_name") + except Exception as exc: # noqa: BLE001 + print(f" ! GitHub latest-release lookup failed for {repo}: {exc}", file=sys.stderr) + return None + + +def _pypi_latest(package: str) -> Optional[str]: + try: + data = _get_json(f"https://pypi.org/pypi/{package}/json") + return data.get("info", {}).get("version") + except Exception as exc: # noqa: BLE001 + print(f" ! PyPI latest lookup failed for {package}: {exc}", file=sys.stderr) + return None + + +def _pypi_purl(package: str) -> Optional[str]: + """Latest-version PyPI PURL for a package, or None if discovery fails.""" + v = _pypi_latest(package) + return f"pkg:pypi/{package}@{v}" if v else None + + +# ── pin readers ───────────────────────────────────────────────────────────── + + +def _read_dockerfile_args(name: str) -> list[str]: + """Distinct pinned versions of an ARG across all Dockerfiles (order preserved). + + The root and app_tests Dockerfiles pin the same tools independently, so a + version can appear in one, both, or (if they diverge) at two different + values -- all of which must be scored. + """ + versions: list[str] = [] + for df in DOCKERFILES: + if not df.exists(): + continue + m = re.search(rf"^ARG\s+{re.escape(name)}=(.+)$", df.read_text(), re.MULTILINE) + if m: + v = m.group(1).strip() + if v and v not in versions: + versions.append(v) + return versions + + +def _read_locked_versions(package: str) -> list[str]: + """Resolved version of a package from uv.lock, as a (0- or 1-element) list.""" + if not UV_LOCK.exists(): + return [] + # uv.lock is TOML with [[package]] blocks: name = "x"\nversion = "y" + m = re.search( + rf'name = "{re.escape(package)}"\s*\nversion = "([^"]+)"', + UV_LOCK.read_text(), + ) + return [m.group(1)] if m else [] + + +# ── version normalization for PURLs ─────────────────────────────────────────── + + +def _strip_v(v: str) -> str: + return v[1:] if v.startswith("v") else v + + +def _ensure_v(v: str) -> str: + return v if v.startswith("v") else f"v{v}" + + +# ── tool registry ───────────────────────────────────────────────────────────── + + +def build_tools() -> list[Tool]: + return [ + Tool( + key="opengrep", + label="OpenGrep (SAST engine)", + read_pinned=lambda: _read_dockerfile_args("OPENGREP_VERSION"), + discover_latest=lambda: _github_latest_release("opengrep/opengrep"), + # No package-registry coordinate; use the GitHub source PURL. + purl=lambda v: f"pkg:github/opengrep/opengrep@{_ensure_v(v)}", + # OpenGrep is a hard fork of Semgrep and Socket has no data for the + # pkg:github coordinate, so fall back to scoring the upstream Semgrep + # lineage as a project-health proxy. (The npm `opengrep` package is a + # single-version squat, not the official distribution -- not used.) + proxy_purl=lambda: _pypi_purl("semgrep"), + proxy_label="semgrep upstream proxy", + socket_coverage=False, # documented gap: pkg:github has no Socket data + note="GitHub-release binary; not Dependabot-trackable and not covered by " + "Socket's pkg:github coordinates. Falls back to the upstream Semgrep " + "lineage (pkg:pypi/semgrep) as a project-health proxy -- this does NOT " + "analyze OpenGrep's own release artifacts, so it is reported, never " + "build-failing.", + ), + Tool( + key="trufflehog", + label="TruffleHog (secret scanner)", + read_pinned=lambda: _read_dockerfile_args("TRUFFLEHOG_VERSION"), + discover_latest=lambda: _github_latest_release("trufflesecurity/trufflehog"), + purl=lambda v: f"pkg:golang/github.com/trufflesecurity/trufflehog/v3@{_ensure_v(v)}", + ), + Tool( + key="trivy", + label="Trivy (container scanner)", + read_pinned=lambda: _read_dockerfile_args("TRIVY_VERSION"), + discover_latest=lambda: _github_latest_release("aquasecurity/trivy"), + purl=lambda v: f"pkg:golang/github.com/aquasecurity/trivy@{_ensure_v(v)}", + ), + Tool( + key="socketdev", + label="Socket SCA (socketdev SDK)", + read_pinned=lambda: _read_locked_versions("socketdev"), + discover_latest=lambda: _pypi_latest("socketdev"), + purl=lambda v: f"pkg:pypi/socketdev@{_strip_v(v)}", + ), + ] + + +# ── Socket analysis ──────────────────────────────────────────────────────────── + + +def analyze_purls(purls: list[str], token: str) -> dict[str, dict[str, Any]]: + """Score a batch of PURLs through the Socket API via the socketdev SDK. + + Returns a map of purl -> {score, alerts, malware: [...], critical: [...]}. + Raises on an empty API result: the SDK returns [] on ANY non-200 (expired + token, dropped endpoint, outage) without raising, and every run scores + coordinates Socket definitely has data for (pkg:pypi/socketdev at minimum), + so an empty result is an API failure, not a clean bill -- surfacing it lets + the caller fail closed instead of reporting "no data" and exiting 0. + """ + import inspect + + from socketdev import socketdev # imported lazily; only needed with a token + + client = socketdev(token=token, timeout=60) + + # Prefer the org-scoped purl endpoint. socketdev >= 3.1 deprecates the + # legacy POST /v0/purl (used when org_slug is absent) in favor of + # POST /v0/orgs/{org_slug}/purl, and a future major may drop the legacy + # route entirely. The pinned 3.0.29 predates the parameter, so pass it + # only when the installed SDK supports it (the scan env tracks main's + # lockfile -- this activates automatically on the eventual SDK bump). + kwargs: dict[str, Any] = {} + if "org_slug" in inspect.signature(client.purl.post).parameters: + # Match socket-python-cli's get_org_id_slug(): only trust the slug + # when the token maps to exactly one org -- guessing among several + # could score under the wrong org's policies. + orgs = (client.org.get() or {}).get("organizations") or {} + slug = next(iter(orgs.values())).get("slug") if len(orgs) == 1 else None + if slug: + kwargs["org_slug"] = slug + else: + print( + f" ! org slug not resolvable ({len(orgs)} orgs on token); using legacy purl endpoint", + file=sys.stderr, + ) + + components = [{"purl": p} for p in purls] + results = client.purl.post(license="false", components=components, **kwargs) or [] + if not results: + raise RuntimeError( + f"Socket purl API returned no results for {len(purls)} PURLs " + "(the SDK swallows non-200s into an empty list) -- treating as a scoring failure" + ) + + by_purl: dict[str, dict[str, Any]] = {} + for item in results: + # The purl API echoes type/name/version; rebuild a best-effort key and + # also index by any returned id/purl so lookups are resilient. + alerts = item.get("alerts") or [] + norm_alerts = [] + malware = [] + critical = [] + for a in alerts: + a_type = a.get("type", "") + a_sev = (a.get("severity") or "").lower() + norm_alerts.append({"type": a_type, "severity": a_sev}) + if a_type in MALWARE_ALERT_TYPES: + malware.append(a_type) + if a_sev in CRITICAL_SEVERITIES: + critical.append(a_type or a_sev) + record = { + "name": item.get("name"), + "version": item.get("version"), + "type": item.get("type"), + "score": item.get("score"), + "alerts": norm_alerts, + "malware": sorted(set(malware)), + "critical": sorted(set(critical)), + } + # Index under any purl-ish key we can derive. + key = item.get("purl") or item.get("id") + if key: + by_purl[key] = record + # Also index by reconstructed pkg coordinate for matching. + t, n, ver = item.get("type"), item.get("name"), item.get("version") + if t and n and ver: + by_purl.setdefault(f"pkg:{t}/{n}@{ver}", record) + return by_purl + + +def _match_analysis(analyses: dict[str, dict[str, Any]], purl: str) -> dict[str, Any]: + if purl in analyses: + return analyses[purl] + # Loose match on name@version tail (handles type/namespace differences). + tail = purl.split("/")[-1] # e.g. socketdev@3.0.29 or trufflehog/v3@v3.93.8 + for k, v in analyses.items(): + if k.endswith(tail): + return v + return {} + + +# ── report rendering ──────────────────────────────────────────────────────── + + +def render_markdown(tools: list[Tool], token_present: bool) -> str: + lines: list[str] = [] + lines.append("## Core tool supply-chain watch\n") + if not token_present: + lines.append( + "> **Socket analysis skipped** — no `SOCKET_API_TOKEN` present. " + "Version-drift detection ran; package scoring did not. Add the " + "`socket-firewall` environment secret to enable Socket scoring.\n" + ) + lines.append("| Tool | Pinned | Latest | Drift | Socket (pinned) | Socket (latest) |") + lines.append("|------|--------|--------|-------|-----------------|-----------------|") + for t in tools: + drift = "—" + if t.pinned and t.latest: + current = all(_strip_v(p) == _strip_v(t.latest) for p in t.pinned) + drift = "✅ current" if current else f"⬆️ `{t.latest}`" + + def verdict(version: Optional[str]) -> str: + if not version: + return "—" + if not token_present: + return "skipped" + a = _match_analysis(t.analyses, t.purl(version)) + suffix = "" + if not a and t.resolved_proxy_purl: + a = _match_analysis(t.analyses, t.resolved_proxy_purl) + if a: + suffix = f" _(via {t.proxy_label})_" + if not a: + return "no data" + if a.get("malware"): + return "🚨 MALWARE: " + ", ".join(a["malware"]) + suffix + if a.get("critical"): + return "⚠️ " + ", ".join(sorted(set(a["critical"]))) + suffix + n_alerts = len(a.get("alerts", [])) + base = f"✅ clean ({n_alerts} alerts)" if n_alerts else "✅ clean" + return base + suffix + + pinned_cell = ", ".join(f"`{p}`" for p in t.pinned) if t.pinned else "`?`" + if len(t.pinned) > 1: + pinned_verdict = "; ".join(f"`{p}`: {verdict(p)}" for p in t.pinned) + else: + pinned_verdict = verdict(t.pinned[0]) if t.pinned else "—" + lines.append( + f"| {t.label} | {pinned_cell} | `{t.latest or '?'}` | {drift} " + f"| {pinned_verdict} | {verdict(t.latest)} |" + ) + notes = [t for t in tools if t.note] + if notes: + lines.append("\n### Notes\n") + for t in notes: + lines.append(f"- **{t.label}**: {t.note}") + return "\n".join(lines) + "\n" + + +# ── main ─────────────────────────────────────────────────────────────────────── + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mode", choices=["build", "watch"], default="watch") + parser.add_argument("--summary-file", help="Append a markdown report here (e.g. GITHUB_STEP_SUMMARY)") + parser.add_argument("--json-out", help="Write the full structured report to this path") + parser.add_argument("--github-output", help="Write drift/malware outputs here (e.g. GITHUB_OUTPUT)") + parser.add_argument( + "--fail-on-malware", + action="store_true", + help="Exit non-zero if a PINNED version has a malware-class alert (see " + "MALWARE_ALERT_TYPES -- deliberately broader than literal malware) or any " + "high/critical severity alert, or (when a token is present) if Socket " + "scoring itself errored -- fail-closed. The discovered latest version is " + "report-only and never fails the run.", + ) + args = parser.parse_args() + + token = os.environ.get("SOCKET_API_TOKEN", "").strip() + token_present = bool(token) + + tools = build_tools() + + print(f"== Core tool supply-chain watch (mode={args.mode}) ==") + for t in tools: + t.pinned = t.read_pinned() + print(f"- {t.key}: pinned={t.pinned}") + if args.mode == "watch": + t.latest = t.discover_latest() + print(f" latest={t.latest}") + if t.proxy_purl: + t.resolved_proxy_purl = t.proxy_purl() + print(f" proxy={t.resolved_proxy_purl}") + + # Collect the versions to analyze: every pinned version, plus the discovered + # latest (watch mode) for drift reporting, plus any proxy coordinate. + purls: list[str] = [] + for t in tools: + for v in t.pinned: + purls.append(t.purl(v)) + if args.mode == "watch" and t.latest: + purls.append(t.purl(t.latest)) + if t.resolved_proxy_purl: + purls.append(t.resolved_proxy_purl) + purls = sorted(set(purls)) + + analyses: dict[str, dict[str, Any]] = {} + scoring_error = False + if token_present and purls: + print(f"== Scoring {len(purls)} PURLs through Socket ==") + try: + analyses = analyze_purls(purls, token) + except Exception as exc: # noqa: BLE001 + print(f"! Socket analysis failed: {exc}", file=sys.stderr) + scoring_error = True + for t in tools: + t.analyses = analyses + + # Determine drift + fail-worthy alerts. Only PINNED versions (the ones + # actually baked into an image / in use) can fail the run; the discovered + # latest is analyzed for drift reporting only, so a scheduled watch never + # blocks on an upstream release we have not adopted yet. + any_drift = False + any_malware = False + any_critical = False + unverified: list[str] = [] + findings: list[dict[str, Any]] = [] + for t in tools: + drift = bool(t.latest and any(_strip_v(p) != _strip_v(t.latest) for p in t.pinned)) + any_drift = any_drift or drift + tool_finding: dict[str, Any] = { + "tool": t.key, + "label": t.label, + "pinned": t.pinned, + "latest": t.latest, + "drift": drift, + "analyses": {}, + } + # Pinned versions are fail-worthy. + for v in t.pinned: + a = _match_analysis(t.analyses, t.purl(v)) + if a: + tool_finding["analyses"][v] = a + if a.get("malware"): + any_malware = True + if a.get("critical"): + any_critical = True + elif token_present and not scoring_error and t.socket_coverage: + # Scoring "succeeded" but this pinned coordinate has no row -- + # a partial batch or a purl/echo mismatch. The guard's job is + # to verify every pin, so an unverified one is fail-worthy + # (except documented coverage gaps like OpenGrep). + unverified.append(f"{t.key} {t.purl(v)}") + # Latest is report-only (a drift signal); it never fails the run. + if t.latest: + a = _match_analysis(t.analyses, t.purl(t.latest)) + if a: + tool_finding["analyses"].setdefault(t.latest, a) + # Proxy coverage (e.g. semgrep for opengrep) is reported, never build-failing. + if t.resolved_proxy_purl: + pa = _match_analysis(t.analyses, t.resolved_proxy_purl) + if pa: + tool_finding["proxy"] = { + "purl": t.resolved_proxy_purl, + "label": t.proxy_label, + "analysis": pa, + } + findings.append(tool_finding) + + markdown = render_markdown(tools, token_present) + print("\n" + markdown) + + if args.summary_file: + with open(args.summary_file, "a", encoding="utf-8") as fh: + fh.write(markdown) + + if args.json_out: + Path(args.json_out).write_text( + json.dumps( + { + "mode": args.mode, + "token_present": token_present, + "scoring_error": scoring_error, + "unverified": unverified, + "findings": findings, + }, + indent=2, + ) + ) + print(f"Wrote JSON report to {args.json_out}") + + if args.github_output: + with open(args.github_output, "a", encoding="utf-8") as fh: + fh.write(f"drift={'true' if any_drift else 'false'}\n") + fh.write(f"malware={'true' if any_malware else 'false'}\n") + fh.write(f"critical={'true' if any_critical else 'false'}\n") + + if args.fail_on_malware: + if any_malware or any_critical: + print( + "::error::Socket flagged malware/critical alerts on a pinned core tool version.", + file=sys.stderr, + ) + return 1 + # Fail closed: a token was provided but scoring errored, so the pinned + # versions went unverified -- don't let a build pass unchecked. + if scoring_error: + print( + "::error::Socket scoring failed (API error); pinned core tool versions " + "could not be verified. Failing closed.", + file=sys.stderr, + ) + return 1 + # Fail closed: scoring returned rows, but some covered pinned + # coordinate has none -- a partial batch is not a clean bill. + if unverified: + print( + "::error::Socket scoring returned no analysis for pinned coordinate(s): " + + "; ".join(unverified) + + ". Failing closed (unverified pins must not ship).", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index 6dd71c2..821d045 100644 --- a/uv.lock +++ b/uv.lock @@ -257,11 +257,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -302,15 +302,15 @@ wheels = [ [[package]] name = "light-s3-client" -version = "0.0.30" +version = "0.0.40" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "xmltodict" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/dd/d42c0badca0071e19e004ce2c783dac3a1c646d5a7b59b65d0ec79be3f9f/light_s3_client-0.0.30.tar.gz", hash = "sha256:91bcdbf51b7f15f2b947acd180df81eb890577a3b36636519c9b054edb641ee5", size = 7642, upload-time = "2025-08-25T09:11:29.458Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/3e/e3b09bf358e030f43bb1a71e95b5517473e7bfd54a0e6e82bb4a13233d25/light_s3_client-0.0.40.tar.gz", hash = "sha256:73cd22d141a19813b5351edebd962db00a770d26be52e3c47eae5ce5d3b41851", size = 11312, upload-time = "2026-07-12T00:02:29.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/18/5bf1ce41bbba606f48c08423c3fd03172e3eabe5c71b3d82189aa9d55508/light_s3_client-0.0.30-py3-none-any.whl", hash = "sha256:c60275b0c2d3fd7576ba30729c581468dac8a6b466d1e92b52a3a4773fa05461", size = 6890, upload-time = "2025-08-25T09:11:28.034Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f7/dc0da16ce05f84d03428f33b391b5ef855db323eeb887c3b66e2e57fe89f/light_s3_client-0.0.40-py3-none-any.whl", hash = "sha256:9bea67138cc1b451965ee559f7f34e6b6818c2cd1b725f7eb6abccb03b9800a8", size = 15669, upload-time = "2026-07-12T00:02:28.773Z" }, ] [[package]] @@ -387,16 +387,16 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -407,9 +407,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -486,7 +486,7 @@ wheels = [ [[package]] name = "requests" -version = "2.31.0" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -494,9 +494,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/be/10918a2eac4ae9f02f6cfe6414b7a155ccd8f7f9d4380d62fd5b955065c3/requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1", size = 110794, upload-time = "2023-05-22T15:12:44.175Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", size = 62574, upload-time = "2023-05-22T15:12:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -652,7 +652,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, - { name = "requests", specifier = ">=2.31.0" }, + { name = "requests", specifier = ">=2.33.0" }, { name = "socketdev", specifier = ">=3.0.29" }, { name = "tabulate", specifier = "~=0.9.0" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -734,11 +734,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]]