diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 1e41e14..a609193 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -150,7 +150,7 @@ jobs: echo "sha=${{ github.event.pull_request.head.sha }}" } > sfw-artifacts/context.txt - - uses: ./.github/actions/setup-sfw + - uses: $/.github/actions/setup-sfw with: uv: "true" mode: firewall-free @@ -241,7 +241,7 @@ jobs: echo "sha=${{ github.event.pull_request.head.sha }}" } > sfw-artifacts/context.txt - - uses: ./.github/actions/setup-sfw + - uses: $/.github/actions/setup-sfw with: uv: "true" mode: firewall-enterprise @@ -332,7 +332,7 @@ jobs: echo "sha=${{ github.event.pull_request.head.sha }}" } > sfw-artifacts/context.txt - - uses: ./.github/actions/setup-sfw + - uses: $/.github/actions/setup-sfw with: node: "true" mode: firewall-free @@ -393,7 +393,7 @@ jobs: echo "sha=${{ github.event.pull_request.head.sha }}" } > sfw-artifacts/context.txt - - uses: ./.github/actions/setup-sfw + - uses: $/.github/actions/setup-sfw with: node: "true" mode: firewall-enterprise @@ -454,7 +454,7 @@ jobs: echo "sha=${{ github.event.pull_request.head.sha }}" } > sfw-artifacts/context.txt - - uses: ./.github/actions/setup-sfw + - uses: $/.github/actions/setup-sfw with: python: "true" mode: firewall-free @@ -518,7 +518,7 @@ jobs: echo "sha=${{ github.event.pull_request.head.sha }}" } > sfw-artifacts/context.txt - - uses: ./.github/actions/setup-sfw + - uses: $/.github/actions/setup-sfw with: python: "true" mode: firewall-enterprise diff --git a/.github/workflows/docker-stable.yml b/.github/workflows/docker-stable.yml index f07f45b..de1fe16 100644 --- a/.github/workflows/docker-stable.yml +++ b/.github/workflows/docker-stable.yml @@ -29,7 +29,7 @@ jobs: echo "Version ${INPUT_VERSION} found on PyPI - proceeding with release" - name: Set up Docker publishing - uses: ./.github/actions/setup-docker + uses: $/.github/actions/setup-docker with: dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/package-check.yml b/.github/workflows/package-check.yml index a6f946a..c80a041 100644 --- a/.github/workflows/package-check.yml +++ b/.github/workflows/package-check.yml @@ -28,7 +28,7 @@ jobs: python-version: "3.12" - name: Install build tooling - uses: ./.github/actions/setup-hatch + uses: $/.github/actions/setup-hatch - name: Install distribution validator run: python -m pip install "twine>=4.0.0" diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 1b2086a..7b08eff 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -112,7 +112,7 @@ jobs: python-version: "3.12" - name: Install build tooling - uses: ./.github/actions/setup-hatch + uses: $/.github/actions/setup-hatch - name: Install distribution validator run: python -m pip install "twine>=4.0.0" @@ -252,7 +252,7 @@ jobs: path: dist - name: Set up Docker publishing - uses: ./.github/actions/setup-docker + uses: $/.github/actions/setup-docker with: # QEMU is required to build the arm64 layer on an amd64 runner. enable-qemu: "true" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 878698f..ef33417 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,7 @@ jobs: permissions: id-token: write contents: read + issues: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -19,7 +20,7 @@ jobs: python-version: '3.13' - name: Install build tooling - uses: ./.github/actions/setup-hatch + uses: $/.github/actions/setup-hatch - name: Get Version id: version @@ -69,7 +70,7 @@ jobs: uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 - name: Set up Docker publishing - uses: ./.github/actions/setup-docker + uses: $/.github/actions/setup-docker with: dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} @@ -123,3 +124,44 @@ jobs: socketdev/cli:${{ env.VERSION }} build-args: | CLI_VERSION=${{ env.VERSION }} + + # Releases are immutable, so a failed run leaves a published GitHub release whose + # tag can never be reused, with nothing on PyPI or Docker Hub behind it. A failed + # run only notifies whoever published the release, so raise it where the team + # will see it. + - name: Open an issue for the failed release + if: failure() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + VERSION: ${{ env.VERSION }} + with: + script: | + const release = context.payload.release; + const runUrl = + `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}` + + `/actions/runs/${context.runId}`; + const body = [ + `@SocketDev/customer-engineering the release workflow for [${release.tag_name}](${release.html_url}) ` + + `(published by @${release.author.login}) failed: ${runUrl}`, + '', + `- Tag: \`${release.tag_name}\``, + `- Package version at that commit: \`${process.env.VERSION || 'unknown'}\``, + '', + 'Check the run for which step failed. If it failed before "Publish to PyPI", nothing reached PyPI or ' + + 'Docker Hub, but the GitHub release is immutable and its tag can never be reused.', + '', + 'Before publishing another release:', + '', + '1. Edit this release\'s notes to say it was not published and name the release that replaces it.', + '2. If the tag and the package version disagree, merge a PR that bumps the version to one with no ' + + 'existing tag (the Version Check workflow enforces this), then publish a release whose tag matches ' + + 'that version exactly. Do not pick the next tag number from the releases list.', + ].join('\n'); + + const created = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Release ${release.tag_name} failed`, + body, + }); + core.error(`Release failed; opened #${created.data.number}`); diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index d505c06..ae18733 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -44,6 +44,7 @@ jobs: python3 <<'PY' import json import os + import subprocess import tomllib import urllib.request from packaging import version @@ -96,6 +97,21 @@ jobs: ) raise SystemExit(1) + # Releases are immutable: once a v release is published, its tag can't + # be moved or reused, even when that release failed and nothing reached PyPI. + # PyPI alone misses that, so a burned tag would only surface when the release + # run fails after the merge. + tag = f"v{os.environ['PR_VERSION']}" + if subprocess.run( + ["git", "rev-parse", "--quiet", "--verify", f"refs/tags/{tag}"], + capture_output=True, + ).returncode == 0: + print( + f"❌ Tag {tag} already exists, so {pr_ver} can never be released " + f"(latest PyPI release: {pypi_ver}). Bump to a version with no tag." + ) + raise SystemExit(1) + if pr_ver < main_ver: print( f"❌ Version moves backwards: main is {main_ver}, PR is {pr_ver}." diff --git a/CHANGELOG.md b/CHANGELOG.md index 9554c2c..0d3ef49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,24 @@ # Changelog -## 2.11.8 +## 2.11.10 -### Changed: bump pinned Coana CLI to 15.14.0 +Versions 2.11.7 through 2.11.9 were never published to PyPI or Docker Hub. The +GitHub releases tagged `v2.11.8` and `v2.11.9` have no packages behind them; +their changes ship here. -- Bumped the pinned reachability engine (the Coana CLI) from `15.13.3` to - `15.14.0`. See the - [reachability analysis changelog](https://docs.socket.dev/docs/reachability-analysis-changelog) - for engine changes. +### Fixed: report why a new repository couldn't be created -## 2.11.7 +- When a repository isn't in Socket yet and the API token can't create it, the + CLI now logs the failure, the HTTP status, and on a 403 a hint about the + `repo:create` scope before exiting `2`. Previously it exited `2` right after + file discovery with nothing logged. +- After the CLI creates a missing repository, its logging is no longer silenced + for the rest of the run. -### Changed: bump pinned Coana CLI to 15.13.3 +### Changed: bump pinned Coana CLI to 15.14.0 - Bumped the pinned reachability engine (the Coana CLI) from `15.13.2` to - `15.13.3`. See the + `15.14.0`. See the [reachability analysis changelog](https://docs.socket.dev/docs/reachability-analysis-changelog) for engine changes. diff --git a/pyproject.toml b/pyproject.toml index 6675a80..c60155d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.11.8" +version = "2.11.10" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index bbf3e24..0fae66b 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.11.8' +__version__ = '2.11.10' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 06c037e..f78f6c1 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -20,7 +20,11 @@ from socketsecurity.config import CliConfig from git import Repo from socketdev import socketdev -from socketdev.exceptions import APIFailure +from socketdev.exceptions import ( + APIFailure, + APIInsufficientQuota, + APIOrganizationNotAllowed, +) from socketdev.fullscans import DiffArtifacts, FullScanParams, SocketArtifact from socketdev.org import Organization from socketdev.repos import RepositoryInfo @@ -1505,8 +1509,11 @@ def get_repo_info(self, repo_slug: str, default_branch: str = "socket-default-br sdk_logger = logging_std.getLogger("socketdev") original_level = sdk_logger.level sdk_logger.setLevel(logging_std.CRITICAL) - response = self.sdk.repos.repo(self.config.org_slug, repo_slug, use_types=True) - sdk_logger.setLevel(original_level) + try: + response = self.sdk.repos.repo(self.config.org_slug, repo_slug, use_types=True) + finally: + # This module logs through "socketdev" too, so a failed lookup must not leave it muted. + sdk_logger.setLevel(original_level) if not response.success: log.error(f"Failed to get repository: {response.status}") # log.error(response.message) @@ -1530,7 +1537,13 @@ def get_repo_info(self, repo_slug: str, default_branch: str = "socket-default-br return response.data except APIFailure as e: - log.error(f"API failure while creating repository: {e}") + status = f" (HTTP {e.status_code})" if e.status_code else "" + # The SDK appends the request path and response headers after the first line, + # and some subclasses carry no message at all. + reason = str(e).split("\n", 1)[0] or type(e).__name__ + log.error(f"API failure while creating repository {repo_slug}{status}: {reason}") + if e.status_code == 403 and not isinstance(e, (APIInsufficientQuota, APIOrganizationNotAllowed)): + log.error("A 403 here usually means the API token is missing the repo:create scope.") sys.exit(2) # Exit here with code 2. Code 1 indicates a successfully-detected security issue. return response.data diff --git a/tests/core/test_sdk_methods.py b/tests/core/test_sdk_methods.py index bf439a3..9235bc6 100644 --- a/tests/core/test_sdk_methods.py +++ b/tests/core/test_sdk_methods.py @@ -1,5 +1,13 @@ +import logging + import pytest -from socketdev.exceptions import APIFailure +from socketdev.exceptions import ( + APIAccessDenied, + APIFailure, + APIInsufficientPermissions, + APIInsufficientQuota, + APIResourceNotFound, +) from socketdev.fullscans import FullScanParams, FullScanStreamResponse, ScanType from socketsecurity.config import CliConfig @@ -44,6 +52,65 @@ def test_get_repo_info(core, mock_sdk_with_responses): assert repo_info.id == "f639d6c9-acc3-4d8a-9fb5-2090ad651c7e" assert repo_info.head_full_scan_id == "head" + +def test_get_repo_info_logs_why_repo_creation_failed(core, mock_sdk_with_responses, caplog): + """A repo that isn't in Socket yet and can't be created exits 2 with the reason logged""" + mock_sdk_with_responses.repos.repo.side_effect = APIResourceNotFound(status_code=404) + # Mirrors the SDK, which appends the request path and response headers to the message + mock_sdk_with_responses.repos.post.side_effect = APIAccessDenied( + "Insufficient permissions\nPath: https://api.socket.dev/v0/orgs/test/repos\n\nHeaders:\nx-req: 1", + status_code=403, + ) + + with caplog.at_level(logging.INFO, logger="socketdev"), pytest.raises(SystemExit) as exit_info: + core.get_repo_info("new-repo") + + assert exit_info.value.code == 2 + messages = [record.getMessage() for record in caplog.records] + assert "Failed to get repository new-repo, attempting to create it" in messages + assert "API failure while creating repository new-repo (HTTP 403): Insufficient permissions" in messages + assert any("repo:create" in message for message in messages) + + +@pytest.mark.parametrize( + ("error", "expected_reason", "expects_scope_hint"), + [ + (APIInsufficientPermissions(status_code=403), "APIInsufficientPermissions", True), + (APIInsufficientQuota(status_code=403), "APIInsufficientQuota", False), + ], +) +def test_get_repo_info_repo_creation_failure_reason( + core, mock_sdk_with_responses, caplog, error, expected_reason, expects_scope_hint +): + """Message-less SDK errors still name a reason, and only permission 403s get the scope hint""" + mock_sdk_with_responses.repos.repo.side_effect = APIResourceNotFound(status_code=404) + mock_sdk_with_responses.repos.post.side_effect = error + + with caplog.at_level(logging.INFO, logger="socketdev"), pytest.raises(SystemExit): + core.get_repo_info("new-repo") + + messages = [record.getMessage() for record in caplog.records] + assert f"API failure while creating repository new-repo (HTTP 403): {expected_reason}" in messages + assert any("repo:create" in message for message in messages) is expects_scope_hint + + +def test_get_repo_info_restores_log_level_after_creating_repo( + core, mock_sdk_with_responses, repo_info_response, caplog +): + """Creating a missing repo must not leave the socketdev logger muted for the rest of the run""" + mock_sdk_with_responses.repos.repo.side_effect = [ + APIResourceNotFound(status_code=404), + repo_info_response, + ] + mock_sdk_with_responses.repos.post.return_value = {"id": "created"} + + with caplog.at_level(logging.INFO, logger="socketdev"): + repo_info = core.get_repo_info("new-repo") + assert logging.getLogger("socketdev").level == logging.INFO + + assert repo_info.id == "f639d6c9-acc3-4d8a-9fb5-2090ad651c7e" + + def test_get_head_scan_for_repo(core, mock_sdk_with_responses): """Test getting head scan ID for a repository""" head_scan_id = core.get_head_scan_for_repo("test") diff --git a/uv.lock b/uv.lock index 4d8e44e..5890103 100644 --- a/uv.lock +++ b/uv.lock @@ -1332,7 +1332,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.11.8" +version = "2.11.10" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },