From ebdc3e42e6d4342013e506ad9499f31266f7333b Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:00:03 +0200 Subject: [PATCH 01/47] Bump pinned @coana-tech/cli to 15.10.3 (#286) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 4 ++-- 6 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eeabe681..2b7deb25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.5.9 + +### Changed: bump pinned @coana-tech/cli to 15.10.3 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.2` to + `15.10.3`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + ## 2.5.8 ### Changed: bump pinned @coana-tech/cli to 15.10.2 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6a5015fe..9f4ca88f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -275,7 +275,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.2 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.3 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index 5c728f0a..25fbd17b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.5.8" +version = "2.5.9" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 47be8b9c..0189af7b 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.5.8' +__version__ = '2.5.9' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 1c24bd2f..34d79099 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -18,7 +18,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.2" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.3" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -55,7 +55,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.2'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.3'). Args: version: Coana CLI version to use. @@ -64,7 +64,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.2'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.3'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index 8dd295ef..ce7c38a4 100644 --- a/uv.lock +++ b/uv.lock @@ -726,7 +726,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -1294,7 +1294,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.5.8" +version = "2.5.9" source = { editable = "." } dependencies = [ { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, From 142449b2e2319bc5e7cbe2ba64b1868518c4c615 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:15:15 -0400 Subject: [PATCH 02/47] Make package and Docker previews opt-in (#287) * Make package previews opt-in * Fix Dockerfile smoke build mode * Document contributing and preview workflows * Harden opt-in preview publishing --- .github/actions/setup-docker/action.yml | 5 + .github/workflows/dependency-review.yml | 5 +- .github/workflows/package-check.yml | 66 +++++ .github/workflows/pr-preview.yml | 353 ++++++++++++++++-------- .hooks/sync_version.py | 36 ++- CONTRIBUTING.md | 62 +++++ Dockerfile | 15 +- Dockerfile.preview | 68 +++++ 8 files changed, 495 insertions(+), 115 deletions(-) create mode 100644 .github/workflows/package-check.yml create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile.preview diff --git a/.github/actions/setup-docker/action.yml b/.github/actions/setup-docker/action.yml index 846efd47..2eea2fb8 100644 --- a/.github/actions/setup-docker/action.yml +++ b/.github/actions/setup-docker/action.yml @@ -5,6 +5,10 @@ description: >- preview, and stable workflows. inputs: + enable-qemu: + description: "Set up QEMU for multi-platform builds" + required: false + default: "true" dockerhub-username: description: "Docker Hub username (pass from secrets)" required: true @@ -16,6 +20,7 @@ runs: using: "composite" steps: - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + if: inputs.enable-qemu == 'true' - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 39d05b53..fba08631 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -571,7 +571,10 @@ jobs: persist-credentials: false - name: Build the Dockerfile (no push) - run: docker build --pull -t socket-python-cli:dependabot-smoke . + run: >- + docker build --pull + --build-arg USE_LOCAL_INSTALL=true + -t socket-python-cli:dependabot-smoke . workflow-notice: needs: inspect diff --git a/.github/workflows/package-check.yml b/.github/workflows/package-check.yml new file mode 100644 index 00000000..b2e8409a --- /dev/null +++ b/.github/workflows/package-check.yml @@ -0,0 +1,66 @@ +name: Package Check + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: package-check-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + package-check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install build tooling + uses: ./.github/actions/setup-hatch + + - name: Install distribution validator + run: python -m pip install "twine>=4.0.0" + + - name: Build distributions + run: hatch build + + - name: Validate distributions + run: python -m twine check dist/* + + - name: Install and inspect wheel without resolving dependencies + run: | + python -m venv "$RUNNER_TEMP/package-check" + "$RUNNER_TEMP/package-check/bin/pip" install --no-deps dist/*.whl + "$RUNNER_TEMP/package-check/bin/python" - <<'PY' + import compileall + import importlib.metadata + import pathlib + import sysconfig + + distribution = importlib.metadata.distribution("socketsecurity") + entry_points = {entry_point.name for entry_point in distribution.entry_points} + assert "socketcli" in entry_points + package = pathlib.Path(sysconfig.get_paths()["purelib"]) / "socketsecurity" + assert compileall.compile_dir(package, quiet=1) + print("wheel metadata and bytecode smoke OK", distribution.version) + PY + + - name: Upload distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: socketsecurity-${{ github.sha }} + path: dist/* + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 113538ab..473a68a0 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -1,169 +1,306 @@ -name: PR Preview +name: Publish PR Preview + on: pull_request: - types: [opened, synchronize, ready_for_review] + types: [labeled] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to publish + required: true + type: string + publish_test_pypi: + description: Publish the Python package to TestPyPI + required: true + default: true + type: boolean + publish_docker: + description: Publish socketdev/cli:pr- to Docker Hub + required: true + default: false + type: boolean + sdk_preview_version: + description: Optional exact TestPyPI socketdev prerelease for the Docker image + required: false + type: string -# Cancel an in-flight preview when the PR is pushed again -- previews are slow -# (publish + multi-step Docker build), so superseded runs shouldn't keep going. concurrency: - group: pr-preview-${{ github.event.pull_request.number }} - cancel-in-progress: true + group: publish-pr-preview-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: false jobs: - preview: - # Skip on: - # - PRs from forks (no access to publish secrets) - # - Dependabot PRs: preview-publishing a dependency bump to Test PyPI / - # Docker Hub is pointless and fails (no version bump, secret access). + context: if: >- - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.user.login != 'dependabot[bot]' + github.event_name == 'workflow_dispatch' || + (github.event.label.name == 'publish-preview' || + github.event.label.name == 'publish-docker-preview') && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + timeout-minutes: 5 permissions: - id-token: write contents: read - pull-requests: write + pull-requests: read + outputs: + pr_number: ${{ steps.context.outputs.pr_number }} + head_sha: ${{ steps.context.outputs.head_sha }} + steps: + - name: Validate pull request context + id: context + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + WORKFLOW_REF: ${{ github.ref }} + with: + script: | + const rawPrNumber = context.eventName === 'workflow_dispatch' + ? process.env.INPUT_PR_NUMBER + : process.env.EVENT_PR_NUMBER; + if (!/^[1-9][0-9]*$/.test(rawPrNumber || '')) { + core.setFailed('Pull request number must contain ASCII digits only.'); + return; + } + + if (context.eventName === 'workflow_dispatch') { + const defaultRef = `refs/heads/${process.env.DEFAULT_BRANCH}`; + if (process.env.WORKFLOW_REF !== defaultRef) { + core.setFailed(`Run manual previews from ${defaultRef}.`); + return; + } + } + + const prNumber = Number(rawPrNumber); + if (!Number.isSafeInteger(prNumber)) { + core.setFailed('Pull request number is outside the supported range.'); + return; + } + const {data: pullRequest} = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + if (pullRequest.state !== 'open') { + core.setFailed(`Pull request #${prNumber} is not open.`); + return; + } + if (pullRequest.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + core.setFailed('Preview publication is limited to branches in this repository.'); + return; + } + + core.setOutput('pr_number', String(prNumber)); + core.setOutput('head_sha', pullRequest.head.sha); + + build: + needs: context + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + outputs: + preview_version: ${{ steps.version.outputs.preview_version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.context.outputs.head_sha }} fetch-depth: 0 persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: '3.13' + python-version: "3.12" - name: Install build tooling uses: ./.github/actions/setup-hatch - - name: Inject full dynamic version - run: python .hooks/sync_version.py --dev + - name: Install distribution validator + run: python -m pip install "twine>=4.0.0" - - name: Clean previous builds - run: rm -rf dist/ build/ *.egg-info + - name: Inject deterministic preview version + env: + PREVIEW_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + PREVIEW_ID=$((PREVIEW_ID * 100 + RUN_ATTEMPT)) + python .hooks/sync_version.py --dev --preview-id "$PREVIEW_ID" --skip-lock - - name: Get Hatch version + - name: Read preview version id: version - run: | - VERSION=$(hatch version | cut -d+ -f1) - echo "VERSION=$VERSION" >> $GITHUB_ENV + run: echo "preview_version=$(hatch version)" >> "$GITHUB_OUTPUT" - - name: Check if version already exists on Test PyPI - id: version_check - env: - VERSION: ${{ env.VERSION }} - run: | - if curl -s -f https://test.pypi.org/pypi/socketsecurity/${VERSION}/json > /dev/null; then - echo "exists=true" >> $GITHUB_OUTPUT - else - echo "exists=false" >> $GITHUB_OUTPUT - fi - - - name: Build package - if: steps.version_check.outputs.exists != 'true' + - name: Build and validate distributions run: | hatch build + python -m twine check dist/* + + - name: Install and inspect wheel locally + run: | + python -m venv "$RUNNER_TEMP/preview-check" + "$RUNNER_TEMP/preview-check/bin/pip" install --no-deps dist/*.whl + "$RUNNER_TEMP/preview-check/bin/python" - <<'PY' + import compileall + import importlib.metadata + import pathlib + import sysconfig + + distribution = importlib.metadata.distribution("socketsecurity") + entry_points = {entry_point.name for entry_point in distribution.entry_points} + assert "socketcli" in entry_points + package = pathlib.Path(sysconfig.get_paths()["purelib"]) / "socketsecurity" + assert compileall.compile_dir(package, quiet=1) + print("preview wheel smoke OK", distribution.version) + PY + + - name: Upload preview distributions + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: socketsecurity-preview-${{ github.run_id }}-${{ github.run_attempt }} + path: dist/* + if-no-files-found: error + retention-days: 14 - - name: Publish to Test PyPI - if: steps.version_check.outputs.exists != 'true' - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 + publish-package: + needs: [context, build] + if: >- + github.event.label.name == 'publish-preview' || + (github.event_name == 'workflow_dispatch' && inputs.publish_test_pypi) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + pull-requests: write + steps: + - name: Download preview distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: socketsecurity-preview-${{ github.run_id }}-${{ github.run_attempt }} + path: dist + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ verbose: true - - name: Comment on PR - if: steps.version_check.outputs.exists != 'true' + - name: Comment on pull request uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: - VERSION: ${{ env.VERSION }} + PREVIEW_VERSION: ${{ needs.build.outputs.preview_version }} + PR_NUMBER: ${{ needs.context.outputs.pr_number }} with: script: | - const version = process.env.VERSION; - const prNumber = context.payload.pull_request.number; - const owner = context.repo.owner; - const repo = context.repo.repo; - // Find existing bot comments - const comments = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - }); - - const botComment = comments.data.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('🚀 Preview package published!') - ); - - const comment = ` - 🚀 Preview package published! + const marker = ''; + const prNumber = Number(process.env.PR_NUMBER); + const version = process.env.PREVIEW_VERSION; + const body = `${marker} + 🚀 CLI preview published: \`socketsecurity==${version}\` - Install with: \`\`\`bash pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple socketsecurity==${version} \`\`\` - Docker image: \`socketdev/cli:pr-${prNumber}\` - `; - - if (botComment) { - // Update existing comment + TestPyPI's package index can take several minutes to expose a newly uploaded version.`; + const {data: comments} = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + const existing = comments.find(comment => + comment.user.type === 'Bot' && comment.body.includes(marker) + ); + if (existing) { await github.rest.issues.updateComment({ - owner: owner, - repo: repo, - comment_id: botComment.id, - body: comment + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, }); } else { - // Create new comment await github.rest.issues.createComment({ - owner: owner, - repo: repo, + owner: context.repo.owner, + repo: context.repo.repo, issue_number: prNumber, - body: comment + body, }); } - - name: Verify package is available - if: steps.version_check.outputs.exists != 'true' - id: verify_package - env: - VERSION: ${{ env.VERSION }} - run: | - for i in {1..30}; do - if pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple socketsecurity==${VERSION}; then - echo "Package ${VERSION} is now available and installable on Test PyPI" - pip uninstall -y socketsecurity - echo "success=true" >> $GITHUB_OUTPUT - exit 0 - fi - echo "Attempt $i: Package not yet installable, waiting 20s... (${i}/30)" - sleep 20 - done - echo "success=false" >> $GITHUB_OUTPUT - exit 1 + publish-docker: + needs: [context, build] + if: >- + github.event.label.name == 'publish-docker-preview' || + (github.event_name == 'workflow_dispatch' && inputs.publish_docker) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Keep the Dockerfile and credential-handling action on trusted code. + # The pull request enters this job only through the built wheel. + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 1 + persist-credentials: false + + - name: Download preview distributions + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: socketsecurity-preview-${{ github.run_id }}-${{ github.run_attempt }} + path: dist - name: Set up Docker publishing - if: steps.verify_package.outputs.success == 'true' uses: ./.github/actions/setup-docker with: + enable-qemu: "false" dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Build & Push Docker Preview - if: steps.verify_package.outputs.success == 'true' + - name: Build and push Docker preview uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - env: - VERSION: ${{ env.VERSION }} with: + file: Dockerfile.preview push: true - # Preview images are for quick testing -- build amd64 only. arm64 via - # QEMU emulation is the slowest part of the job; release builds keep - # multi-arch. GHA layer cache speeds up repeated preview builds. + pull: true platforms: linux/amd64 - cache-from: type=gha - cache-to: type=gha,mode=max - tags: | - socketdev/cli:pr-${{ github.event.pull_request.number }} + tags: socketdev/cli:pr-${{ needs.context.outputs.pr_number }} build-args: | - CLI_VERSION=${{ env.VERSION }} - PIP_INDEX_URL=https://test.pypi.org/simple - PIP_EXTRA_INDEX_URL=https://pypi.org/simple + SDK_PREVIEW_VERSION=${{ inputs.sdk_preview_version }} + + - name: Comment on pull request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PR_NUMBER: ${{ needs.context.outputs.pr_number }} + with: + script: | + const marker = ''; + const prNumber = Number(process.env.PR_NUMBER); + const body = `${marker} + 🐳 Docker preview published: \`socketdev/cli:pr-${prNumber}\` + + This mutable tag is only created when a Docker preview is explicitly requested.`; + const {data: comments} = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + const existing = comments.find(comment => + comment.user.type === 'Bot' && comment.body.includes(marker) + ); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } diff --git a/.hooks/sync_version.py b/.hooks/sync_version.py index 57b29d31..51835c3f 100644 --- a/.hooks/sync_version.py +++ b/.hooks/sync_version.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 -import subprocess +import json import pathlib import re +import subprocess import sys import urllib.request -import json INIT_FILE = pathlib.Path("socketsecurity/__init__.py") PYPROJECT_FILE = pathlib.Path("pyproject.toml") @@ -125,13 +125,45 @@ def run_uv_lock() -> bool: after = UV_LOCK_FILE.read_bytes() if UV_LOCK_FILE.exists() else b"" return before != after + +def read_preview_id(): + if "--preview-id" not in sys.argv: + return None + + option_index = sys.argv.index("--preview-id") + try: + preview_id = sys.argv[option_index + 1] + except IndexError: + print("❌ `--preview-id` requires a numeric value.") + sys.exit(1) + + if not preview_id.isascii() or not preview_id.isdigit(): + print("❌ `--preview-id` must contain ASCII digits only.") + sys.exit(1) + return preview_id + + def main(): dev_mode = "--dev" in sys.argv + skip_lock = "--skip-lock" in sys.argv + preview_id = read_preview_id() current_version = read_version_from_init(INIT_FILE) previous_version = read_version_from_git("socketsecurity/__init__.py") print(f"Current: {current_version}, Previous: {previous_version}") + if preview_id is not None: + if not dev_mode: + print("❌ `--preview-id` can only be used with `--dev`.") + sys.exit(1) + base_version = current_version.split(".dev")[0] + new_version = f"{base_version}.dev{preview_id}" + inject_version(new_version) + if not skip_lock: + run_uv_lock() + print(f"✅ Prepared deterministic preview version {new_version}.") + sys.exit(0) + if current_version == previous_version: if dev_mode: base_version = current_version.split(".dev")[0] if ".dev" in current_version else current_version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..ed9ee2b0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,62 @@ +# Contributing + +## Development setup + +Use Python 3.11 or newer and install +[`uv`](https://docs.astral.sh/uv/getting-started/installation/). From the +repository root, create the environment and install all development +dependencies: + +```bash +uv sync --all-extras +``` + +Before opening a pull request, run: + +```bash +make test +uv run hatch build +uv run python -m twine check dist/* +``` + +To develop against a local SDK checkout, set `SOCKET_SDK_PATH` if it is not at +`../socketdev`, then run `make first-time-local-setup`. + +## Pull request validation + +The `Package Check` workflow runs automatically for pull requests. It builds +and validates the distributions, smoke-tests the wheel, and uploads the +distributions as workflow artifacts. It does not publish a package or Docker +image. + +## Publishing pull request previews + +Preview publication is intentionally opt-in. Only request previews for code +that is trusted to run with the repository's publishing permissions. + +For a pull request from this repository, apply the label for the artifact that +needs testing: + +- `publish-preview` publishes a uniquely versioned `socketsecurity` prerelease + to TestPyPI and adds or updates a pull request comment with the exact version + and installation command. +- `publish-docker-preview` publishes the mutable + `socketdev/cli:pr-` image to Docker Hub and adds or + updates a pull request comment with the image tag. + +Both label-triggered and manually dispatched previews are limited to open pull +requests whose branches belong to this repository. Each label is handled as a +separate event, so applying both labels starts two workflow runs. Use manual +dispatch instead when both artifacts should be published in a single run. + +The workflow reacts when a label is added; pushing another commit while the +label remains on the pull request does not publish a new preview. To publish the +new pull request head or retry a failed publication, remove the relevant label +and apply it again. + +Maintainers can also open **Actions > Publish PR Preview > Run workflow**, run +it from the repository's default branch, enter the pull request number, and +choose whether to publish to TestPyPI, Docker Hub, or both. When testing the CLI +against an SDK preview, enter the exact TestPyPI `socketdev` prerelease in +`sdk_preview_version`; publish the SDK preview first and allow time for +TestPyPI to expose it before starting the CLI Docker preview. diff --git a/Dockerfile b/Dockerfile index 75110780..52959a30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -115,14 +115,22 @@ RUN curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/p RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ echo "Using local development install"; \ else \ + cli_installed=false; \ for i in $(seq 1 10); do \ echo "Attempt $i/10: Installing socketsecurity==$CLI_VERSION"; \ if pip install --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socketsecurity==$CLI_VERSION; then \ + cli_installed=true; \ break; \ fi; \ - echo "Install failed, waiting 30s before retry..."; \ - sleep 30; \ - done && \ + if [ "$i" -lt 10 ]; then \ + echo "Install failed, waiting 30s before retry..."; \ + sleep 30; \ + fi; \ + done; \ + if [ "$cli_installed" != "true" ]; then \ + echo "Failed to install socketsecurity==$CLI_VERSION after 10 attempts"; \ + exit 1; \ + fi; \ if [ ! -z "$SDK_VERSION" ]; then \ pip install --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socketdev==${SDK_VERSION}; \ fi; \ @@ -133,7 +141,6 @@ COPY . /app WORKDIR /app RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ pip install --upgrade -e .; \ - pip install --upgrade socketdev; \ fi # Create workspace directory with proper permissions diff --git a/Dockerfile.preview b/Dockerfile.preview new file mode 100644 index 00000000..62cb3ce7 --- /dev/null +++ b/Dockerfile.preview @@ -0,0 +1,68 @@ +# syntax=docker/dockerfile:1 + +FROM socketdev/cli:latest + +ARG SDK_PREVIEW_VERSION="" +ARG PYPI_INDEX_URL=https://pypi.org/simple/ +ARG TEST_PYPI_INDEX_URL=https://test.pypi.org/simple/ + +COPY dist/socketsecurity-*.whl /tmp/socket-preview/ + +RUN <<'SH' +set -eux + +wheel=$(find /tmp/socket-preview -maxdepth 1 -name 'socketsecurity-*.whl' -print -quit) + +# Resolve every wheel dependency from production PyPI. When an exact SDK +# preview is requested, leave socketdev out so the prerelease can intentionally +# override a final-version floor such as socketdev>=3.4.0. +python - "$wheel" > /tmp/socket-preview/requirements.txt <<'PY' +import email +import os +import sys +import zipfile + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name + +wheel_path = sys.argv[1] +with zipfile.ZipFile(wheel_path) as archive: + metadata_path = next( + name for name in archive.namelist() if name.endswith(".dist-info/METADATA") + ) + metadata = email.message_from_bytes(archive.read(metadata_path)) + +sdk_preview = os.environ.get("SDK_PREVIEW_VERSION") +for value in metadata.get_all("Requires-Dist", []): + if sdk_preview and canonicalize_name(Requirement(value).name) == "socketdev": + continue + print(value) +PY + +python -m pip install \ + --no-cache-dir \ + --index-url "$PYPI_INDEX_URL" \ + --requirement /tmp/socket-preview/requirements.txt + +if [ -n "$SDK_PREVIEW_VERSION" ]; then + mkdir /tmp/socket-preview/sdk + python -m pip download \ + --no-cache-dir \ + --no-deps \ + --dest /tmp/socket-preview/sdk \ + --index-url "$TEST_PYPI_INDEX_URL" \ + "socketdev==$SDK_PREVIEW_VERSION" + python -m pip install \ + --no-cache-dir \ + --index-url "$PYPI_INDEX_URL" \ + /tmp/socket-preview/sdk/socketdev-*.whl +fi + +python -m pip install \ + --no-cache-dir \ + --no-deps \ + --force-reinstall \ + "$wheel" +socketcli --help >/dev/null +rm -rf /tmp/socket-preview +SH From 0b577a91a92d3a65ea54cd2c63949d45d557b966 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:50:24 -0400 Subject: [PATCH 03/47] Harden PyPI install verification against stale cache (#290) * Harden release verify step against PyPI index propagation delays The verify loop assumed a new release appears in PyPI's simple index within its 10-minute budget. Both socketsecurity 2.5.9 and socketdev 3.4.2 (2026-08-05) took longer than that: the upload succeeded and the JSON API showed the release immediately, but the CDN-cached simple index kept serving a stale version list past the loop's last attempt, failing the release and skipping the Docker publish. Extend the retry window to 30 minutes, add --no-cache-dir so each attempt refetches the index rather than revalidating pip's locally cached stale copy, and log when the JSON API already has the version so index staleness is distinguishable from a failed publish. Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * Align release verify step with socket-sdk-python hardening Use python -m pip with an explicit production Simple-index URL, quote workflow outputs, and skip the sleep after the final attempt, matching the socket-sdk-python release workflow so the verify step is identical in both repos. Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * Trim release-specific details from verify step comment Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --------- Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .github/workflows/release.yml | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 342060b4..5be9e266 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,17 +79,31 @@ jobs: env: VERSION: ${{ env.VERSION }} run: | - for i in {1..30}; do - if pip install socketsecurity==${VERSION}; then + # The first lookup can race PyPI's Simple-index propagation, and a delayed + # CDN purge can leave the index stale well after a successful upload. + # pip caches HTTP responses by default, so without --no-cache-dir every + # retry can reuse that initial stale response instead of checking whether + # the release has appeared. Budget: 30 minutes. + MAX_ATTEMPTS=60 + for i in $(seq 1 "$MAX_ATTEMPTS"); do + if python -m pip install \ + --no-cache-dir \ + --index-url https://pypi.org/simple/ \ + "socketsecurity==${VERSION}"; then echo "Package ${VERSION} is now available and installable on PyPI" - pip uninstall -y socketsecurity - echo "success=true" >> $GITHUB_OUTPUT + python -m pip uninstall -y socketsecurity + echo "success=true" >> "$GITHUB_OUTPUT" exit 0 fi - echo "Attempt $i: Package not yet installable, waiting 20s... (${i}/30)" - sleep 20 + if curl -s -f "https://pypi.org/pypi/socketsecurity/${VERSION}/json" > /dev/null; then + echo "Release ${VERSION} exists on PyPI (JSON API) but is not in the Simple index yet - CDN propagation delay" + fi + if [ "$i" -lt "$MAX_ATTEMPTS" ]; then + echo "Attempt $i: Package not yet installable, waiting 30s... (${i}/${MAX_ATTEMPTS})" + sleep 30 + fi done - echo "success=false" >> $GITHUB_OUTPUT + echo "success=false" >> "$GITHUB_OUTPUT" exit 1 - name: Build & Push Docker From 72bf0c21ec206e6a040da2d3fac51ea8d9066229 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:19:15 -0400 Subject: [PATCH 04/47] Pin all Python dependencies (#289) * Pin all Python dependencies Pin every runtime dependency in pyproject.toml to an exact version, replace the bs4 shim with a direct beautifulsoup4 dependency, pin the socketdev SDK to 3.4.2, and install Docker image dependencies from the committed uv.lock with pip hash verification so image builds no longer resolve loose versions from PyPI at build time. Also pins the hatchling build backend and the uv binary used in the Dockerfile. Refs CE-359. Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * Keep locked dep install on prod PyPI; run pip check before SDK override Pass explicit production index flags on the hash-locked dependency install so the PIP_INDEX_URL/PIP_EXTRA_INDEX_URL build args (pointed at TestPyPI by the preview build scripts) don't leak in via pip's env vars, and move pip check ahead of the SDK_VERSION override so a preview SDK that deviates from the exact socketdev pin doesn't abort the build. Addresses PR#289 review findings. Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * Fail the Docker build when pip check fails Docker's shell-form RUN only propagates the last command's exit status, so once pip check moved ahead of the SDK_VERSION block its failure was silently discarded whenever SDK_VERSION was empty or the override install succeeded. Gate it explicitly with || exit 1. Addresses PR#289 review finding. Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * Drop ticket reference from changelog entry Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * Bump version to 2.6.0 Switching to fully pinned dependencies warrants a minor bump rather than a patch. Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * Pin socketdev to 3.5.0 Ingest the SDK release that bounds its own runtime dependency ranges, so the pinned closure is hygienic end to end. Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * ci(e2e): retry reachability on empty results, upload diagnostics on failure The e2e-reachability job intermittently fails with 'no components with alerts in .socket.facts.json': the tier-1 reachability backend can return empty results while the CLI reports success (ENG-5093), and the same flake has hit unrelated PRs. - Add a retry-probe hook to the e2e matrix: entries that define it get up to 3 scan attempts, retrying only when the probe says the output looks incomplete. Persistent failures still fail via the validate step. Each retry emits a warning annotation and a step-summary line so flake frequency stays visible. - Add tests/e2e/reach-facts-probe.sh: exits 0 when the facts file has alerted components, non-zero (retry) when empty or missing. - Upload /tmp/e2e-output.log, SARIF/GitLab outputs, and facts files as artifacts when any e2e job fails, so flakes are diagnosable without a re-run. Also bump version to 2.6.2 (2.6.0 and 2.6.1 are being released ahead of this PR). Co-Authored-By: Claude Fable 5 * ci(e2e): classify known empty reachability backend result * Drop ticket references from e2e hardening and note it in the changelog Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --------- Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .github/workflows/e2e-test.yml | 49 +++++++++++++++++++++++++++++- CHANGELOG.md | 24 +++++++++++++++ Dockerfile | 19 ++++++++++-- pyproject.toml | 26 ++++++++-------- socketsecurity/__init__.py | 2 +- tests/e2e/reach-facts-probe.sh | 19 ++++++++++++ tests/e2e/validate-reachability.sh | 19 ++++++++++++ uv.lock | 44 ++++++++++----------------- 8 files changed, 156 insertions(+), 46 deletions(-) create mode 100755 tests/e2e/reach-facts-probe.sh diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 17bdc15c..a1d2f290 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -46,6 +46,11 @@ jobs: --enable-debug validate: tests/e2e/validate-reachability.sh setup-node: "true" + # The tier-1 reachability backend intermittently returns empty + # results while the CLI reports success. The probe exits 0 when + # the facts file has alerted components; anything else is + # retried before validation fails the job. + retry-probe: bash tests/e2e/reach-facts-probe.sh tests/e2e/fixtures/simple-npm - name: gitlab args: >- @@ -94,17 +99,59 @@ jobs: run: pip install uv - name: Run Socket CLI + id: run-cli env: SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_CLI_API_TOKEN }} + RETRY_PROBE: ${{ matrix.retry-probe }} run: | set -o pipefail - socketcli ${{ matrix.args }} 2>&1 | tee /tmp/e2e-output.log + # Entries with retry-probe get up to 3 attempts: the probe exits 0 + # when the scan output looks complete, and a run that fails it is + # re-run on the assumption of a transient backend failure. A + # persistent incomplete result still reaches validation. Validation + # only treats the explicit zero-project backend signature as + # inconclusive; any other empty result remains a failure. Retries are + # surfaced as warning annotations so flake frequency stays visible. + max_attempts=3 + attempt=1 + while :; do + socketcli ${{ matrix.args }} 2>&1 | tee /tmp/e2e-output.log + [ -z "$RETRY_PROBE" ] && break + if bash -c "$RETRY_PROBE"; then + break + fi + if [ "$attempt" -ge "$max_attempts" ]; then + echo "::warning title=e2e-${{ matrix.name }} incomplete results::output still fails the completeness probe after ${max_attempts} attempts; letting validation classify the result" + echo "diagnostics=true" >> "$GITHUB_OUTPUT" + break + fi + echo "::warning title=e2e-${{ matrix.name }} transient retry::attempt ${attempt} failed the completeness probe (suspected backend transient); retrying" + echo "e2e-${{ matrix.name }}: retry after attempt ${attempt} — completeness probe failed (suspected transient)" >> "$GITHUB_STEP_SUMMARY" + attempt=$((attempt+1)) + sleep 30 + done - name: Validate results env: SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_CLI_API_TOKEN }} run: bash ${{ matrix.validate }} + - name: Upload diagnostics on failure + if: failure() || steps.run-cli.outputs.diagnostics == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-${{ matrix.name }}-diagnostics-attempt${{ github.run_attempt }} + path: | + /tmp/e2e-output.log + /tmp/*.sarif + tests/e2e/fixtures/simple-npm/.socket.facts.json + tests/e2e/fixtures/simple-pypi/.socket.facts.json + gl-*.json + license_output.json + if-no-files-found: ignore + include-hidden-files: true + retention-days: 14 + # Branch protection requires the e2e-* checks, but the `e2e` job above is # skipped on PRs that can't access repository secrets -- fork PRs and # Dependabot PRs. A job skipped via a job-level `if` never expands its diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7deb25..202c6ed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 2.6.0 + +### Changed: pin all Python dependencies + +- Pinned every runtime dependency in `pyproject.toml` to an exact version; + several were previously unpinned or open ranges. +- Replaced the `bs4` shim package with a direct, pinned `beautifulsoup4` + dependency (the shim provided no version control over the actual library). +- Pinned the bundled `socketdev` SDK to `3.5.0` (previously `>=3.3.0,<4.0.0`). +- Docker images now install Python dependencies from the committed `uv.lock` + with pip hash verification (`--require-hashes`), so image builds no longer + resolve dependency versions from PyPI at build time. `pip check` validates + the environment after install. +- Pinned the `hatchling` build backend and the `uv` binary used in the + Dockerfile. + +### Changed: e2e reachability jobs retry transient empty results + +- Reachability e2e runs that report success with no alerted components in the + facts file are retried up to three times as a suspected transient backend + failure. After retries, only the known zero-project backend signature is + classified as inconclusive — any other empty result still fails — and e2e + jobs upload their logs and reports as diagnostics on failure. + ## 2.5.9 ### Changed: bump pinned @coana-tech/cli to 15.10.3 diff --git a/Dockerfile b/Dockerfile index 52959a30..06e8b0a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,7 +86,7 @@ ENV PATH="/usr/local/go/bin:/usr/lib/go/bin:/root/.cargo/bin:${PATH}" ENV GOPATH="/go" # Install uv -COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv +COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /usr/local/bin/uv # Install pyenv # pyenv lets us build/install arbitrary Python versions on demand. We install @@ -111,6 +111,18 @@ RUN curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/p ln -s ~/.pyenv/bin/pyenv /bin/pyenv && \ pyenv --version +# Install Python dependencies from the lockfile with hash verification so the +# image never resolves loose versions from PyPI at build time. +COPY pyproject.toml uv.lock /tmp/socket-cli-lock/ +# Index flags are passed explicitly (always production PyPI) so the +# PIP_INDEX_URL/PIP_EXTRA_INDEX_URL ARGs used to point CLI/SDK preview installs +# at TestPyPI don't leak into the locked dependency install via pip's env vars. +RUN uv export --directory /tmp/socket-cli-lock --frozen --no-dev --no-emit-project \ + --format requirements-txt -o /tmp/socket-cli-lock/requirements.txt && \ + pip install --require-hashes --no-deps \ + --index-url https://pypi.org/simple --extra-index-url https://pypi.org/simple \ + -r /tmp/socket-cli-lock/requirements.txt + # Install CLI based on build mode RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ echo "Using local development install"; \ @@ -118,7 +130,7 @@ RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ cli_installed=false; \ for i in $(seq 1 10); do \ echo "Attempt $i/10: Installing socketsecurity==$CLI_VERSION"; \ - if pip install --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socketsecurity==$CLI_VERSION; then \ + if pip install --no-deps --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socketsecurity==$CLI_VERSION; then \ cli_installed=true; \ break; \ fi; \ @@ -131,6 +143,7 @@ RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ echo "Failed to install socketsecurity==$CLI_VERSION after 10 attempts"; \ exit 1; \ fi; \ + pip check || exit 1; \ if [ ! -z "$SDK_VERSION" ]; then \ pip install --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socketdev==${SDK_VERSION}; \ fi; \ @@ -140,7 +153,7 @@ RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ COPY . /app WORKDIR /app RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ - pip install --upgrade -e .; \ + pip install --no-deps -e . && pip check; \ fi # Create workspace directory with proper permissions diff --git a/pyproject.toml b/pyproject.toml index 25fbd17b..1b49c2ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,26 +1,26 @@ [build-system] requires = [ - "hatchling" + "hatchling==1.31.0" ] build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.5.9" +version = "2.6.0" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ - 'requests', - 'mdutils', - 'prettytable', - 'GitPython', - 'packaging', - 'python-dotenv', - "socketdev>=3.3.0,<4.0.0", - "bs4>=0.0.2", - "markdown>=3.10", - "brotli>=1.0.9; platform_python_implementation == 'CPython'", - "brotlicffi>=1.0.9; platform_python_implementation != 'CPython'", + "requests==2.34.2", + "mdutils==1.8.1", + "prettytable==3.18.0", + "GitPython==3.1.57", + "packaging==26.2", + "python-dotenv==1.2.2", + "socketdev==3.5.0", + "beautifulsoup4==4.14.3", + "markdown==3.10.2", + "brotli==1.2.0; platform_python_implementation == 'CPython'", + "brotlicffi==1.2.0.1; platform_python_implementation != 'CPython'", ] readme = "README.md" description = "Socket Security CLI for CI/CD" diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 0189af7b..2a2ecb9c 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.5.9' +__version__ = '2.6.0' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/tests/e2e/reach-facts-probe.sh b/tests/e2e/reach-facts-probe.sh new file mode 100755 index 00000000..c1db4dd4 --- /dev/null +++ b/tests/e2e/reach-facts-probe.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Exits 0 when the reachability facts file contains components with alerts. +# +# Used by the e2e workflow's retry-probe hook: a --reach run against the +# known-vulnerable fixture that reports success but yields no alerted +# components is the signature of a transient tier-1 backend failure, +# so the run is worth repeating before validation fails the job. +set -euo pipefail + +TARGET="${1:?usage: reach-facts-probe.sh }" + +uv run python - "$TARGET" <<'PY' +import sys + +from socketsecurity.core.alert_selection import load_components_with_alerts + +components = load_components_with_alerts(sys.argv[1], ".socket.facts.json") +sys.exit(0 if components else 1) +PY diff --git a/tests/e2e/validate-reachability.sh b/tests/e2e/validate-reachability.sh index e32f0042..07326164 100755 --- a/tests/e2e/validate-reachability.sh +++ b/tests/e2e/validate-reachability.sh @@ -34,6 +34,25 @@ if [ ! -f "$FACTS_PATH" ]; then fi echo "PASS: Reachability facts file present at $FACTS_PATH" +# The tier-1 backend intermittently returns the known fixture as one orphaned +# component with zero projects, so Coana has no vulnerability to analyze even +# though manifest upload, facts generation, and scan finalization all succeed. +# After the workflow's bounded retries, classify only that explicit +# upstream signature as inconclusive. Any other empty facts result still fails, +# including the important regression case where Coana received a vulnerability +# but the CLI lost its alerted component. +if ! bash tests/e2e/reach-facts-probe.sh tests/e2e/fixtures/simple-npm; then + if grep -q "Found 1 manifest files for reachability upload" "$LOG" && \ + grep -q "Found 0 projects across 0 ecosystems to analyze" "$LOG" && \ + grep -q "Filtered out 1 orphaned component" "$LOG"; then + echo "::warning title=e2e-reachability inconclusive backend result::tier-1 returned the known zero-project/orphaned-component signature after retries; core reachability execution and finalization passed" + echo "e2e-reachability: inconclusive after retries — known zero-project backend signature; diagnostics uploaded" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + exit 0 + fi + echo "FAIL: no components with alerts in .socket.facts.json and the known backend signature was not present" + exit 1 +fi + # 3-4. Build SARIF from the facts file produced by the initial --reach run. # Avoid re-running reach + full scan here; duplicate API scans are slow and flaky in CI. uv run python -c " diff --git a/uv.lock b/uv.lock index ce7c38a4..fb5a540f 100644 --- a/uv.lock +++ b/uv.lock @@ -196,18 +196,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, ] -[[package]] -name = "bs4" -version = "0.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/aa/4acaf814ff901145da37332e05bb510452ebed97bc9602695059dd46ef39/bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925", size = 698, upload-time = "2024-01-17T18:15:47.371Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189, upload-time = "2024-01-17T18:15:48.613Z" }, -] - [[package]] name = "certifi" version = "2025.11.12" @@ -1281,25 +1269,25 @@ wheels = [ [[package]] name = "socketdev" -version = "3.3.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/30/16155f7f27d18274f364b3bd3506ee45d17f53fc8938aaea9a618054449b/socketdev-3.3.0.tar.gz", hash = "sha256:3d60bd4ac3201e9d581b1fe02bf2e6aef1b90c13ae75d15a8664aa9ef966734e", size = 181519, upload-time = "2026-06-10T11:41:17.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/0c11290dc7d59e24b7075035c7e1a3ab87fa17a445cebc88cfa6ee98b22c/socketdev-3.5.0.tar.gz", hash = "sha256:a2b20f9b98f73c25f3d2e97a1ae730504509c91219c0b393f28a9230266b3531", size = 195138, upload-time = "2026-08-06T03:47:14.185Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/dd/25622e033182e8c744d2420bb4f056206edc096a1e5ce8e4af4b0a0c0791/socketdev-3.3.0-py3-none-any.whl", hash = "sha256:513c045ce42bdd6cc2bb66a527f5863e0c399e56dbdcb1832cd5d94a5fb1a5e4", size = 67956, upload-time = "2026-06-10T11:41:16.534Z" }, + { url = "https://files.pythonhosted.org/packages/c3/75/5a8506a473716740e94f2f01b697909333f966c143d8a0a566d278e6118d/socketdev-3.5.0-py3-none-any.whl", hash = "sha256:780f5841770397035ff87de6181d954b6318cd0a07f6fdd304d1376667f33f68", size = 72027, upload-time = "2026-08-06T03:47:12.773Z" }, ] [[package]] name = "socketsecurity" -version = "2.5.9" +version = "2.6.0" source = { editable = "." } dependencies = [ + { name = "beautifulsoup4" }, { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, - { name = "bs4" }, { name = "gitpython" }, { name = "markdown" }, { name = "mdutils" }, @@ -1333,25 +1321,25 @@ dev = [ [package.metadata] requires-dist = [ - { name = "brotli", marker = "platform_python_implementation == 'CPython'", specifier = ">=1.0.9" }, - { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'", specifier = ">=1.0.9" }, - { name = "bs4", specifier = ">=0.0.2" }, - { name = "gitpython" }, + { name = "beautifulsoup4", specifier = "==4.14.3" }, + { name = "brotli", marker = "platform_python_implementation == 'CPython'", specifier = "==1.2.0" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'", specifier = "==1.2.0.1" }, + { name = "gitpython", specifier = "==3.1.57" }, { name = "hatch", marker = "extra == 'dev'" }, - { name = "markdown", specifier = ">=3.10" }, - { name = "mdutils" }, - { name = "packaging" }, + { name = "markdown", specifier = "==3.10.2" }, + { name = "mdutils", specifier = "==1.8.1" }, + { name = "packaging", specifier = "==26.2" }, { name = "pre-commit", marker = "extra == 'dev'" }, - { name = "prettytable" }, + { name = "prettytable", specifier = "==3.18.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=7.4.0" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23.0" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.1.0" }, { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.12.0" }, { name = "pytest-watch", marker = "extra == 'test'", specifier = ">=4.2.0" }, - { name = "python-dotenv" }, - { name = "requests" }, + { name = "python-dotenv", specifier = "==1.2.2" }, + { name = "requests", specifier = "==2.34.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" }, - { name = "socketdev", specifier = ">=3.3.0,<4.0.0" }, + { name = "socketdev", specifier = "==3.5.0" }, { name = "twine", marker = "extra == 'dev'" }, { name = "uv", marker = "extra == 'dev'", specifier = ">=0.1.0" }, ] From 7566334e873384babff43200d386974720bdf8d6 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:48:38 -0400 Subject: [PATCH 05/47] Fix intermittent connection resets on scan comparison by polling the diff-scans endpoints (#284) * Poll diff-scans endpoints for scan comparison instead of streaming The scan comparison (fullscans.stream_diff) held a single HTTP connection open, fully idle, while the API computed the diff. Network middleboxes with TCP idle timeouts - notably Azure NAT gateways, which default to 4 minutes - kill that connection with a RST, surfacing as intermittent "Connection reset by peer" / blank "API Error:" failures on the final comparison step of long scans (CE-354). The comparison now creates a diff-scan resource (POST /orgs/{org}/diff-scans/from-ids) and polls GET /orgs/{org}/diff-scans/{id}?cached=true with short bounded requests: 202 while the diff is computing, 200 with the result once ready. No request is ever idle long enough to be reaped, and the poll interval backs off 5s -> 30s to stay quota-friendly (each poll costs 1 quota unit). Transient poll failures retry; a 30-minute backstop guards against a diff scan that never completes. Any failure of the new flow (e.g. org tokens missing the diff-scans:create / diff-scans:list / full-scans:list scopes) logs a warning and falls back to the legacy streaming comparison, so the change is transparent to existing users. Requires socketdev>=3.4.0 for diffscans.get query-param/202 support. Co-Authored-By: Claude Fable 5 * Drop ignored omit_license_details param from cached diff-scan polls The API ignores omit_license_details when cached=true - cached diff-scan results always embed license details - so sending the param suggested a lean-response guarantee the polling path doesn't have. Document the caveat instead: if the heavier payload ever gets truncated on a huge dependency tree, JSON parsing fails and the caller already falls back to the legacy streaming comparison, which still requests the lean payload. include_license_details now only governs that fallback call. Flagged by Cursor Bugbot on #284. Co-Authored-By: Claude Fable 5 * Keep duplicate diff scans on cached polling * Require bundled socketdev 3.4.2 release * Stage CLI 2.6.1 * Require socketdev 3.5.0 * Drop ticket references from code comments, workflows, and changelog Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * Align changelog with pinned SDK dependency --------- Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 22 +++ pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/__init__.py | 213 +++++++++++++++++++++++---- tests/core/conftest.py | 21 +++ tests/core/test_diff_scan_polling.py | 122 +++++++++++++++ tests/core/test_sdk_methods.py | 38 +++-- uv.lock | 2 +- 8 files changed, 377 insertions(+), 45 deletions(-) create mode 100644 tests/core/test_diff_scan_polling.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 202c6ed2..22976e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 2.6.1 + +### Changed: scan comparison now polls the diff-scans endpoints + +- Diff mode no longer holds a single idle HTTP connection open while the API + computes the scan comparison. The CLI now creates a diff-scan resource + (`POST /orgs/{org}/diff-scans/from-ids`) and polls + `GET /orgs/{org}/diff-scans/{id}?cached=true` with short, bounded requests + until the comparison is ready (HTTP 200 instead of 202). This fixes + intermittent `Connection reset by peer` failures on the final comparison + step when scans take several minutes to compare and network middleboxes + (e.g. Azure NAT gateways, which default to a 4-minute TCP idle timeout) + reap the idle connection. +- Duplicate scan pairs are resolved after an HTTP 409 and then polled through + the same cached endpoint. This avoids automatically following the API's 302 + duplicate redirect with an uncached, potentially long-lived GET request. +- The change is transparent: no flags or workflow changes are needed. If the + org API token is missing the `diff-scans:create`, `diff-scans:list` or + `full-scans:list` scopes — or the new flow fails for any other reason — the + CLI logs a warning and falls back to the legacy streaming comparison. +- Requires the pinned `socketdev==3.5.0` SDK. + ## 2.6.0 ### Changed: pin all Python dependencies diff --git a/pyproject.toml b/pyproject.toml index 1b49c2ca..c70b629b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.0" +version = "2.6.1" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 2a2ecb9c..312c5053 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.0' +__version__ = '2.6.1' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index a372de4d..7bd4a33e 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -15,7 +15,7 @@ from socketsecurity.config import CliConfig from socketdev import socketdev from socketdev.exceptions import APIFailure -from socketdev.fullscans import FullScanParams, SocketArtifact +from socketdev.fullscans import DiffArtifacts, FullScanParams, SocketArtifact from socketdev.org import Organization from socketdev.repos import RepositoryInfo import copy @@ -92,6 +92,25 @@ FULL_SCAN_UPLOAD_MAX_ATTEMPTS = len(FULL_SCAN_UPLOAD_BACKOFF_SCHEDULE_SECONDS) FULL_SCAN_UPLOAD_BACKOFF_JITTER_SECONDS = 2.0 +# Diff-scan polling policy. The legacy scan comparison (fullscans.stream_diff) holds a +# single HTTP connection open, fully idle, while the backend computes the diff; network +# middleboxes with TCP idle timeouts (notably Azure NAT gateways, which default to +# 4 minutes) kill that connection with a RST, surfacing as an intermittent +# ConnectionResetError on large scans. The diff-scans flow instead creates a +# diff-scan resource and polls its cached endpoint with short bounded requests: the API +# answers 202 while the comparison is still computing and 200 with the result once it is +# ready, so no connection is ever idle long enough to be reaped. +# +# Each poll consumes 1 unit of API quota, so the interval backs off toward +# DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS to stay quota-friendly on comparisons that take +# minutes to compute. The timeout is a backstop against a diff scan that never +# completes; on expiry (or any other failure of this flow) the caller falls back to the +# legacy streaming comparison rather than failing the scan outright. +DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS = 5.0 +DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS = 30.0 +DIFF_SCAN_POLL_BACKOFF_MULTIPLIER = 1.5 +DIFF_SCAN_POLL_TIMEOUT_SECONDS = 30 * 60.0 + def _humanize_alert_type(alert_type: str) -> str: """Convert a camelCase/PascalCase alert type into a Title-Cased label. @@ -1303,6 +1322,120 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in return packages + def get_diff_scan_artifacts( + self, + head_full_scan_id: str, + new_full_scan_id: str + ) -> DiffArtifacts: + """Compare two full scans via the diff-scans endpoints, polling for the result. + + Creates a diff-scan resource from the two full scan IDs, then polls + ``GET /orgs/{org}/diff-scans/{id}?cached=true`` until the API returns the + computed comparison (200) instead of a processing status (202). Unlike the + legacy ``fullscans.stream_diff`` call, no request is ever left idle while + the backend computes, so the comparison survives network idle timeouts. + See the DIFF_SCAN_POLL_* constants for the polling policy. + + Requires an org token with the ``diff-scans:create``, ``diff-scans:list`` + and ``full-scans:list`` scopes; callers are expected to catch failures and + fall back to the legacy streaming comparison. + + Note that cached diff-scan responses always embed per-package license + details (the API ignores ``omit_license_details`` when ``cached=true``), + so unlike the legacy streaming comparison there is no lean-response + option here; see the comment on ``poll_params`` below. + + Args: + head_full_scan_id: The before/base full scan ID + new_full_scan_id: The after/head full scan ID + + Returns: + DiffArtifacts with the added/removed/unchanged/replaced/updated lists + """ + create_params = { + "before": head_full_scan_id, + "after": new_full_scan_id, + "description": f"Socket Security CLI v{__version__} scan comparison", + } + try: + result = self.sdk.diffscans.create_from_ids(self.config.org_slug, create_params) + diff_scan = result.get("diff_scan") or {} + response_summary = result + except APIFailure as error: + if error.status_code != 409: + raise + + # Do not use on_duplicate=redirect here. The SDK follows that 302 + # automatically with a GET that lacks cached=true, which can leave + # the connection idle while an existing diff scan is still computing. + # Resolve the duplicate resource explicitly so every result fetch + # continues through the bounded cached polling path below. + existing = self.sdk.diffscans.list( + self.config.org_slug, + params={ + "before_full_scan_id": head_full_scan_id, + "after_full_scan_id": new_full_scan_id, + "per_page": 1, + }, + ) + matches = existing.get("results") or [] + diff_scan = matches[0] if matches else {} + response_summary = existing + + diff_scan_id = diff_scan.get("id") + if not diff_scan_id: + raise Exception( + "Error creating or resolving diff scan: " + f"unexpected response: {str(response_summary)[:500]}" + ) + artifacts_dict = diff_scan.get("artifacts") + + # cached=true is the polling contract (202 while computing, 200 when + # ready). The API ignores omit_license_details when cached=true - cached + # results always embed license details - so there is no lean-response + # option on this path (unlike stream_diff with + # include_license_details=false, the lean-payload mitigation). If that extra + # payload ever gets a response truncated on a huge dependency tree, + # response.json() fails and the caller falls back to the legacy + # streaming comparison, which still requests the lean payload. + poll_params = {"cached": "true"} + deadline = time.monotonic() + DIFF_SCAN_POLL_TIMEOUT_SECONDS + interval = DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS + while artifacts_dict is None: + try: + response = self.sdk.diffscans.get(self.config.org_slug, diff_scan_id, params=poll_params) + except APIFailure as error: + if not error.is_transient_error(): + raise + # A dropped/timed-out poll is retryable: the diff scan keeps + # computing server-side regardless of what happens to any one poll. + log.warning( + f"Transient error polling diff scan {diff_scan_id} " + f"({type(error).__name__}), retrying in {interval:.0f}s" + ) + response = {"status": "processing"} + if response.get("status") != "processing": + scan = response.get("diff_scan") or {} + if scan.get("artifacts") is None: + raise Exception( + f"Error fetching diff scan {diff_scan_id}: unexpected response: {str(response)[:500]}" + ) + artifacts_dict = scan["artifacts"] + break + if time.monotonic() >= deadline: + raise Exception( + f"Timed out waiting for diff scan {diff_scan_id} after " + f"{DIFF_SCAN_POLL_TIMEOUT_SECONDS:.0f} seconds" + ) + log.debug(f"Diff scan {diff_scan_id} still processing, polling again in {interval:.0f}s") + time.sleep(interval) + interval = min(interval * DIFF_SCAN_POLL_BACKOFF_MULTIPLIER, DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS) + + return DiffArtifacts.from_dict({ + key: artifacts_dict.get(key) or [] + for key in ("added", "removed", "unchanged", "replaced", "updated") + }) + def get_added_and_removed_packages( self, head_full_scan_id: str, @@ -1315,8 +1448,12 @@ def get_added_and_removed_packages( Args: head_full_scan_id: Previous scan (maybe None if first scan) new_full_scan_id: New scan just created - include_license_details: Whether to ask the diff endpoint to embed - per-package license attribution/details in the response. + include_license_details: Whether to ask the *legacy streaming* diff + endpoint to embed per-package license attribution/details in the + response. Only consulted on the fallback path: the primary + diff-scans path always receives embedded license details, since + the API ignores ``omit_license_details`` for cached reads (see + get_diff_scan_artifacts). Defaults to ``False`` on purpose. The diff endpoint exists to compare alerts between two scans; the license fields it can embed @@ -1343,39 +1480,55 @@ def get_added_and_removed_packages( log.info(f"Comparing scans - Head scan ID: {head_full_scan_id}, New scan ID: {new_full_scan_id}") diff_start = time.time() + diff_artifacts = None try: - diff_report = ( - self.sdk.fullscans.stream_diff( - self.config.org_slug, - head_full_scan_id, - new_full_scan_id, - use_types=True, - include_license_details=str(include_license_details).lower() - ).data + diff_artifacts = self.get_diff_scan_artifacts( + head_full_scan_id, + new_full_scan_id ) - except APIFailure as e: - log.error(f"API Error: {e}") - if self.cli_config and self.cli_config.disable_blocking: - sys.exit(0) - sys.exit(1) - except Exception as e: - import traceback - log.error(f"Error getting diff report: {str(e)}") - log.error(f"Stack trace:\n{traceback.format_exc()}") - raise + except Exception as error: + # SDK error messages can span many lines (path + response headers); the + # first line carries the status, which is all the warning needs. + error_summary = str(error).strip().splitlines()[0] if str(error).strip() else "" + log.warning( + f"Diff scan comparison failed with {type(error).__name__}({error_summary}), " + "falling back to the streaming scan comparison" + ) + + if diff_artifacts is None: + try: + diff_artifacts = ( + self.sdk.fullscans.stream_diff( + self.config.org_slug, + head_full_scan_id, + new_full_scan_id, + use_types=True, + include_license_details=str(include_license_details).lower() + ).data.artifacts + ) + except APIFailure as e: + log.error(f"API Error: {e}") + if self.cli_config and self.cli_config.disable_blocking: + sys.exit(0) + sys.exit(1) + except Exception as e: + import traceback + log.error(f"Error getting diff report: {str(e)}") + log.error(f"Stack trace:\n{traceback.format_exc()}") + raise diff_end = time.time() log.info(f"Diff Report Gathered in {diff_end - diff_start:.2f} seconds") log.info("Diff report artifact counts:") - log.info(f"Added: {len(diff_report.artifacts.added)}") - log.info(f"Removed: {len(diff_report.artifacts.removed)}") - log.info(f"Unchanged: {len(diff_report.artifacts.unchanged)}") - log.info(f"Replaced: {len(diff_report.artifacts.replaced)}") - log.info(f"Updated: {len(diff_report.artifacts.updated)}") - - added_artifacts = diff_report.artifacts.added + diff_report.artifacts.updated - removed_artifacts = diff_report.artifacts.removed + diff_report.artifacts.replaced - unchanged_artifacts = diff_report.artifacts.unchanged + log.info(f"Added: {len(diff_artifacts.added)}") + log.info(f"Removed: {len(diff_artifacts.removed)}") + log.info(f"Unchanged: {len(diff_artifacts.unchanged)}") + log.info(f"Replaced: {len(diff_artifacts.replaced)}") + log.info(f"Updated: {len(diff_artifacts.updated)}") + + added_artifacts = diff_artifacts.added + diff_artifacts.updated + removed_artifacts = diff_artifacts.removed + diff_artifacts.replaced + unchanged_artifacts = diff_artifacts.unchanged added_packages: Dict[str, Package] = {} removed_packages: Dict[str, Package] = {} diff --git a/tests/core/conftest.py b/tests/core/conftest.py index 381c2c3f..ae6b10c0 100644 --- a/tests/core/conftest.py +++ b/tests/core/conftest.py @@ -87,6 +87,22 @@ def stream_diff_response(data_dir, load_json): }) +@pytest.fixture +def diff_scan_get_response(data_dir, load_json): + """GET /orgs/{org}/diff-scans/{id} response built from the stream_diff fixture. + + The diff-scans endpoint returns the same artifact shape as the legacy + streaming diff, wrapped in a diff_scan object. + """ + json_data = load_json(data_dir / "fullscans" / "diff" / "stream_diff.json") + return { + "diff_scan": { + "id": "diff-scan-123", + "artifacts": json_data["data"]["artifacts"], + } + } + + @@ -138,6 +154,7 @@ def mock_sdk_with_responses( new_scan_metadata, new_scan_stream, stream_diff_response, + diff_scan_get_response, create_full_scan_response, ): sdk = mock_socket_sdk.return_value @@ -173,4 +190,8 @@ def mock_sdk_with_responses( lambda org_slug, head_id, new_id, **kwargs: stream_diff_response ) + # Diff-scans endpoints (primary scan-comparison path) + sdk.diffscans.create_from_ids.return_value = {"diff_scan": {"id": "diff-scan-123"}} + sdk.diffscans.get.return_value = diff_scan_get_response + return sdk diff --git a/tests/core/test_diff_scan_polling.py b/tests/core/test_diff_scan_polling.py new file mode 100644 index 00000000..be369429 --- /dev/null +++ b/tests/core/test_diff_scan_polling.py @@ -0,0 +1,122 @@ +"""Tests for the diff-scans polling scan comparison. + +The comparison must never hold an idle connection open: it creates a diff-scan +resource and polls the cached endpoint (202 while processing, 200 when ready), +falling back to the legacy streaming diff if the new flow is unavailable. +""" +import pytest +from socketdev.exceptions import APIConnectionError, APIFailure + +import socketsecurity.core as core_module +from socketsecurity.core import Core +from socketsecurity.core.socket_config import SocketConfig + + +@pytest.fixture +def core(mock_sdk_with_responses): + config = SocketConfig(api_key="test_key") + return Core(config=config, sdk=mock_sdk_with_responses) + + +@pytest.fixture +def no_sleep(mocker): + return mocker.patch("socketsecurity.core.time.sleep") + + +def test_polls_until_diff_scan_ready(core, diff_scan_get_response, no_sleep): + """202 processing responses are polled through until the 200 result arrives.""" + processing = {"status": "processing", "id": "diff-scan-123"} + core.sdk.diffscans.get.side_effect = [processing, processing, diff_scan_get_response] + + artifacts = core.get_diff_scan_artifacts("head", "new") + + assert core.sdk.diffscans.get.call_count == 3 + assert no_sleep.call_count == 2 # slept between polls, never during them + assert len(artifacts.added) > 0 + + +def test_poll_interval_backs_off(core, diff_scan_get_response, no_sleep, monkeypatch): + """The poll interval grows toward the max so long comparisons stay quota-friendly.""" + monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS", 4.0) + monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS", 10.0) + processing = {"status": "processing", "id": "diff-scan-123"} + core.sdk.diffscans.get.side_effect = [processing] * 4 + [diff_scan_get_response] + + core.get_diff_scan_artifacts("head", "new") + + waits = [call.args[0] for call in no_sleep.call_args_list] + assert waits == [4.0, 6.0, 9.0, 10.0] # 1.5x backoff, capped at the max + + +def test_transient_poll_error_is_retried(core, diff_scan_get_response, no_sleep): + """A dropped poll doesn't abandon the flow - the diff keeps computing server-side.""" + core.sdk.diffscans.get.side_effect = [APIConnectionError("reset"), diff_scan_get_response] + + artifacts = core.get_diff_scan_artifacts("head", "new") + + assert core.sdk.diffscans.get.call_count == 2 + assert len(artifacts.added) > 0 + + +def test_non_transient_poll_error_raises(core, no_sleep): + """Deterministic API errors (e.g. 403 missing scopes) propagate to the caller.""" + core.sdk.diffscans.get.side_effect = APIFailure("forbidden", status_code=403) + + with pytest.raises(APIFailure): + core.get_diff_scan_artifacts("head", "new") + + +def test_poll_timeout_raises(core, no_sleep, monkeypatch): + """A diff scan that never completes hits the polling backstop.""" + monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_TIMEOUT_SECONDS", 0.0) + core.sdk.diffscans.get.return_value = {"status": "processing", "id": "diff-scan-123"} + + with pytest.raises(Exception, match="Timed out waiting for diff scan"): + core.get_diff_scan_artifacts("head", "new") + + +def test_duplicate_conflict_uses_cached_polling(core, diff_scan_get_response): + """A duplicate is resolved explicitly so the SDK cannot follow an uncached redirect.""" + core.sdk.diffscans.create_from_ids.side_effect = APIFailure( + "duplicate", status_code=409 + ) + core.sdk.diffscans.list.return_value = { + "results": [{"id": "existing-diff-scan"}], + } + + artifacts = core.get_diff_scan_artifacts("head", "new") + + create_params = core.sdk.diffscans.create_from_ids.call_args.args[1] + assert "on_duplicate" not in create_params + core.sdk.diffscans.list.assert_called_once_with( + core.config.org_slug, + params={ + "before_full_scan_id": "head", + "after_full_scan_id": "new", + "per_page": 1, + }, + ) + core.sdk.diffscans.get.assert_called_once_with( + core.config.org_slug, + "existing-diff-scan", + params={"cached": "true"}, + ) + assert len(artifacts.added) > 0 + + +def test_fallback_to_streaming_diff_on_failure(core): + """If the diff-scans flow fails (e.g. token missing the diff-scans scopes), + the comparison falls back to the legacy streaming diff transparently.""" + core.sdk.diffscans.create_from_ids.side_effect = APIFailure("forbidden", status_code=403) + + added, removed, all_packages = core.get_added_and_removed_packages("head", "new") + + core.sdk.fullscans.stream_diff.assert_called_once_with( + core.config.org_slug, + "head", + "new", + use_types=True, + include_license_details="false", + ) + assert "dp3" in added + assert "dp2" in removed diff --git a/tests/core/test_sdk_methods.py b/tests/core/test_sdk_methods.py index 9b1ce449..02967315 100644 --- a/tests/core/test_sdk_methods.py +++ b/tests/core/test_sdk_methods.py @@ -225,19 +225,27 @@ def test_get_added_and_removed_packages(core): """Test getting added and removed packages between two scans""" # Get two different scans to compare added, removed, all_packages = core.get_added_and_removed_packages("head", "new") - - # Verify SDK was called correctly. - # include_license_details defaults to "false": the diff path never consumes - # embedded license data (license artifacts come from the PURL endpoint), so - # requesting it only bloats the response and risks the truncation - # crash on large repos. - core.sdk.fullscans.stream_diff.assert_called_once_with( + + # Verify SDK was called correctly: the comparison goes through the diff-scans + # endpoints (create + poll) rather than the legacy streaming diff, so no + # connection is left idle while the backend computes. + create_args = core.sdk.diffscans.create_from_ids.call_args + assert create_args[0][0] == core.config.org_slug + create_params = create_args[0][1] + assert create_params["before"] == "head" + assert create_params["after"] == "new" + assert "on_duplicate" not in create_params + + # cached=true is the polling contract (202 while computing, 200 when ready). + # No omit_license_details param: the API ignores it for cached reads (cached + # results always embed license details), so sending it would only suggest a + # leanness guarantee this path doesn't have. + core.sdk.diffscans.get.assert_called_once_with( core.config.org_slug, - "head", - "new", - use_types=True, - include_license_details="false", + "diff-scan-123", + params={"cached": "true"}, ) + core.sdk.fullscans.stream_diff.assert_not_called() # Verify the results # Added packages @@ -252,7 +260,13 @@ def test_get_added_and_removed_packages(core): assert "pypi/direct_package_1@1.6.0" in all_packages # Unchanged package is in full package map def test_get_added_and_removed_packages_license_override(core): - """The include_license_details override seam still works when explicitly requested.""" + """include_license_details only governs the legacy fallback path now: the + diff-scans path always receives embedded license details (the API ignores + omit_license_details for cached reads), so the seam must survive through to + the stream_diff call when the primary path is unavailable.""" + from socketdev.exceptions import APIFailure + + core.sdk.diffscans.create_from_ids.side_effect = APIFailure("forbidden", status_code=403) core.get_added_and_removed_packages("head", "new", include_license_details=True) core.sdk.fullscans.stream_diff.assert_called_once_with( diff --git a/uv.lock b/uv.lock index fb5a540f..172c2af4 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.0" +version = "2.6.1" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 29bbc568a49bc5ae02c64630a0cffa9c1d5e1781 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:09:30 -0400 Subject: [PATCH 06/47] Raise failure on SBOM fetch errors (#288) * fix(core): raise on SBOM fetch failure instead of writing empty reports (CE-362) get_sbom_data returned {} when the full-scan stream fetch failed, so report generation continued and produced empty GitLab dependency scanning, license, and SARIF output with exit code 0. Raise APIFailure instead so the failure goes through the CLI's existing API-error handling (exit code 3 by default, still exit 0 with --disable-blocking). Bump the socketdev floor to 3.4.2, the bundled release that adds the missing purl types (e.g. "generic") and per-artifact parse resilience that caused this failure mode. Merge after socketdev 3.4.2 is on PyPI. Co-Authored-By: Claude Fable 5 * chore: lock socketdev 3.4.2 Co-Authored-By: Claude Fable 5 * chore: bump version to 2.5.11 Co-Authored-By: Claude Fable 5 * ci(e2e): retry reachability on empty results, upload diagnostics on failure The e2e-reachability job intermittently fails with 'no components with alerts in .socket.facts.json': the tier-1 reachability backend can return empty results while the CLI reports success (ENG-5093), and the same flake has hit unrelated PRs. - Add a retry-probe hook to the e2e matrix: entries that define it get up to 3 scan attempts, retrying only when the probe says the output looks incomplete. Persistent failures still fail via the validate step. Each retry emits a warning annotation and a step-summary line so flake frequency stays visible. - Add tests/e2e/reach-facts-probe.sh: exits 0 when the facts file has alerted components, non-zero (retry) when empty or missing. - Upload /tmp/e2e-output.log, SARIF/GitLab outputs, and facts files as artifacts when any e2e job fails, so flakes are diagnosable without a re-run. Also bump version to 2.6.2 (2.6.0 and 2.6.1 are being released ahead of this PR). Co-Authored-By: Claude Fable 5 * chore: require socketdev 3.5.0 Co-Authored-By: Claude Fable 5 * Drop ticket references from e2e comments and note the retry hardening in the changelog Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * Move e2e retry changelog entry out and drop remaining ticket reference The e2e retry hardening ships with the dependency pinning PR instead, so its changelog entry moves there. Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> * docs: changelog phrasing tweak Co-Authored-By: Claude Fable 5 --------- Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 13 +++++++++++++ pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/__init__.py | 10 +++++++--- tests/core/test_sdk_methods.py | 20 +++++++++++++++++++- uv.lock | 2 +- 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22976e66..53adb48d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2.6.2 + +### Fixed: SBOM fetch failures no longer produce empty reports + +- `Core.get_sbom_data` now raises `APIFailure` when the full-scan stream fetch + fails, so the run exits through the CLI's API-error handling (exit code 3 by + default; `--disable-blocking` still exits 0) instead of writing empty + GitLab dependency-scanning, license, and SARIF reports. +- The underlying stream-parse failure was fixed in `socketdev` 3.4.2 (already + pinned to `3.5.0`): unrecognized purl types such as `generic` now resolve + instead of raising, and individual unparseable artifacts are skipped rather + than failing the whole response. + ## 2.6.1 ### Changed: scan comparison now polls the diff-scans endpoints diff --git a/pyproject.toml b/pyproject.toml index c70b629b..09ef2181 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.1" +version = "2.6.2" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 312c5053..c4c8bad0 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.1' +__version__ = '2.6.2' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 7bd4a33e..7be24858 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -173,9 +173,13 @@ def get_sbom_data(self, full_scan_id: str) -> Dict[str, SocketArtifact]: """Returns SBOM artifacts for a full scan keyed by artifact ID.""" response = self.sdk.fullscans.stream(self.config.org_slug, full_scan_id, use_types=True) if not response.success: - log.debug(f"Failed to get SBOM data for full-scan {full_scan_id}") - log.debug(response.message) - return {} + # Raise instead of returning {} so a failed fetch surfaces as an + # API error (exit code 3 by default) rather than empty reports. + log.error(f"Failed to get SBOM data for full-scan {full_scan_id}") + log.error(response.message) + raise APIFailure( + f"Failed to get SBOM data for full-scan {full_scan_id}: {response.message}" + ) if not hasattr(response, "artifacts") or not response.artifacts: return {} return response.artifacts diff --git a/tests/core/test_sdk_methods.py b/tests/core/test_sdk_methods.py index 02967315..da0efc62 100644 --- a/tests/core/test_sdk_methods.py +++ b/tests/core/test_sdk_methods.py @@ -1,5 +1,6 @@ import pytest -from socketdev.fullscans import FullScanParams +from socketdev.exceptions import APIFailure +from socketdev.fullscans import FullScanParams, FullScanStreamResponse from socketsecurity.config import CliConfig from socketsecurity.core import Core @@ -277,6 +278,23 @@ def test_get_added_and_removed_packages_license_override(core): include_license_details="true", ) +def test_get_sbom_data_failure_raises(core): + """A failed SBOM stream fetch raises instead of returning {}. + + Returning {} let report generation continue and emit empty results with + exit code 0; raising routes the failure through the CLI's API-error + handling instead. + """ + core.sdk.fullscans.stream.side_effect = None + core.sdk.fullscans.stream.return_value = FullScanStreamResponse.from_dict({ + "success": False, + "status": 200, + "message": "Error parsing stream response", + }) + + with pytest.raises(APIFailure, match="Failed to get SBOM data"): + core.get_sbom_data("head") + def test_empty_alerts_preserved(core): """Test that empty alerts arrays stay as empty arrays and don't become None""" # Get the scan that contains dp2 (which has empty alerts array) diff --git a/uv.lock b/uv.lock index 172c2af4..4a7c16ad 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.1" +version = "2.6.2" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From a993c9d4090a4990d7c1dd8880a147ba473f7bb0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:19:41 -0400 Subject: [PATCH 07/47] ci(deps): bump actions/setup-python from 6.2.0 to 7.0.0 (#293) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.2.0 to 7.0.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/a309ff8b426b58ec0e2a45f0f869d46889d02405...5fda3b95a4ea91299a34e894583c3862153e4b97) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/workflows/e2e-test.yml | 2 +- .github/workflows/package-check.yml | 2 +- .github/workflows/pr-preview.yml | 2 +- .github/workflows/python-tests.yml | 4 ++-- .github/workflows/release.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index a1d2f290..777777be 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -80,7 +80,7 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' diff --git a/.github/workflows/package-check.yml b/.github/workflows/package-check.yml index b2e8409a..d71083a4 100644 --- a/.github/workflows/package-check.yml +++ b/.github/workflows/package-check.yml @@ -23,7 +23,7 @@ jobs: fetch-depth: 1 persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 473a68a0..3a2ab1a9 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -107,7 +107,7 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 77133062..34717226 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -40,7 +40,7 @@ jobs: fetch-depth: 1 persist-credentials: false - name: 🐍 setup python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ env.PYTHON_VERSION }} - name: 🛠️ install deps @@ -76,7 +76,7 @@ jobs: fetch-depth: 1 persist-credentials: false - name: 🐍 setup python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.10" - name: 🚫 verify install is rejected on unsupported python diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5be9e266..1c5faa9a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.13' From dd0835e369e96d5c0fcd7ec9afaaa486ddc23e5d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:33:31 -0400 Subject: [PATCH 08/47] chore(deps): bump twine from 6.2.0 to 7.0.0 in the python-major group (#296) * chore(deps): bump twine from 6.2.0 to 7.0.0 in the python-major group Bumps the python-major group with 1 update: [twine](https://github.com/pypa/twine). Updates `twine` from 6.2.0 to 7.0.0 - [Release notes](https://github.com/pypa/twine/releases) - [Changelog](https://github.com/pypa/twine/blob/main/docs/changelog.rst) - [Commits](https://github.com/pypa/twine/compare/6.2.0...7.0.0) --- updated-dependencies: - dependency-name: twine dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: python-major ... Signed-off-by: dependabot[bot] * Pin dev and test extras to exact versions Runtime dependencies were pinned exactly in 2.6.0; this applies the same policy to the dev and test extras (matching the currently locked versions, including the twine 7.0.0 bump from this PR) so future dependency updates surface in pyproject.toml rather than only in uv.lock. Co-Authored-By: Claude Fable 5 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- pyproject.toml | 20 ++++++++++---------- uv.lock | 32 ++++++++++++++++---------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 09ef2181..d66e4bb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,18 +40,18 @@ classifiers = [ [project.optional-dependencies] test = [ - "pytest>=7.4.0", - "pytest-cov>=4.1.0", - "pytest-mock>=3.12.0", - "pytest-asyncio>=0.23.0", - "pytest-watch >=4.2.0" + "pytest==9.1.1", + "pytest-cov==7.1.0", + "pytest-mock==3.15.1", + "pytest-asyncio==1.4.0", + "pytest-watch==4.2.0" ] dev = [ - "ruff>=0.3.0", - "twine", # for building - "uv>=0.1.0", # for dependency management - "pre-commit", - "hatch" + "ruff==0.16.0", + "twine==7.0.0", # for building + "uv==0.12.0", # for dependency management + "pre-commit==4.6.1", + "hatch==1.17.1" ] [project.scripts] diff --git a/uv.lock b/uv.lock index 4a7c16ad..b9f904ad 100644 --- a/uv.lock +++ b/uv.lock @@ -1200,15 +1200,15 @@ wheels = [ [[package]] name = "rich" -version = "14.2.0" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] @@ -1325,23 +1325,23 @@ requires-dist = [ { name = "brotli", marker = "platform_python_implementation == 'CPython'", specifier = "==1.2.0" }, { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'", specifier = "==1.2.0.1" }, { name = "gitpython", specifier = "==3.1.57" }, - { name = "hatch", marker = "extra == 'dev'" }, + { name = "hatch", marker = "extra == 'dev'", specifier = "==1.17.1" }, { name = "markdown", specifier = "==3.10.2" }, { name = "mdutils", specifier = "==1.8.1" }, { name = "packaging", specifier = "==26.2" }, - { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.6.1" }, { name = "prettytable", specifier = "==3.18.0" }, - { name = "pytest", marker = "extra == 'test'", specifier = ">=7.4.0" }, - { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23.0" }, - { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.1.0" }, - { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.12.0" }, - { name = "pytest-watch", marker = "extra == 'test'", specifier = ">=4.2.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = "==9.1.1" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = "==1.4.0" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = "==7.1.0" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = "==3.15.1" }, + { name = "pytest-watch", marker = "extra == 'test'", specifier = "==4.2.0" }, { name = "python-dotenv", specifier = "==1.2.2" }, { name = "requests", specifier = "==2.34.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.0" }, { name = "socketdev", specifier = "==3.5.0" }, - { name = "twine", marker = "extra == 'dev'" }, - { name = "uv", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, + { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.0" }, ] provides-extras = ["test", "dev"] @@ -1444,7 +1444,7 @@ wheels = [ [[package]] name = "twine" -version = "6.2.0" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "id" }, @@ -1457,9 +1457,9 @@ dependencies = [ { name = "rich" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz", hash = "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", size = 215032, upload-time = "2026-07-27T15:59:00.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl", hash = "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7", size = 43204, upload-time = "2026-07-27T15:58:59.26Z" }, ] [[package]] From 70bbde99db5daacf8db4a08b928dd3f65057c8ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:35:46 -0400 Subject: [PATCH 09/47] ci(deps): bump the github-actions-minor-patch group across 2 directories with 4 updates (#292) Bumps the github-actions-minor-patch group with 1 update in the / directory: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish). Bumps the github-actions-minor-patch group with 3 updates in the /.github/actions/setup-docker directory: [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action), [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) and [docker/login-action](https://github.com/docker/login-action). Updates `pypa/gh-action-pypi-publish` from 1.14.1 to 1.14.2 - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.14.1...dc37677b2e1c63e2034f94d8a5b11f265b73ba33) Updates `docker/setup-qemu-action` from 4.1.0 to 4.2.0 - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/06116385d9baf250c9f4dcb4858b16962ea869c3...96fe6ef7f33517b61c61be40b68a1882f3264fb8) Updates `docker/setup-buildx-action` from 4.1.0 to 4.2.0 - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5...bb05f3f5519dd87d3ba754cc423b652a5edd6d2c) Updates `docker/login-action` from 4.2.0 to 4.6.0 - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...dbcb813823bdd20940b903addbd779551569679f) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-version: 1.14.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions-minor-patch - dependency-name: docker/setup-qemu-action dependency-version: 4.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-minor-patch - dependency-name: docker/setup-buildx-action dependency-version: 4.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-minor-patch - dependency-name: docker/login-action dependency-version: 4.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/actions/setup-docker/action.yml | 6 +++--- .github/workflows/release.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/actions/setup-docker/action.yml b/.github/actions/setup-docker/action.yml index 2eea2fb8..58239ff6 100644 --- a/.github/actions/setup-docker/action.yml +++ b/.github/actions/setup-docker/action.yml @@ -19,10 +19,10 @@ inputs: runs: using: "composite" steps: - - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 if: inputs.enable-qemu == 'true' - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ inputs.dockerhub-username }} password: ${{ inputs.dockerhub-token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c5faa9a..62b2f2b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: - name: Publish to PyPI if: steps.version_check.outputs.pypi_exists != 'true' - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - name: Set up Docker publishing uses: ./.github/actions/setup-docker From 1cf2246b29dc64018bbd39793a052b0499a73566 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:42:57 -0400 Subject: [PATCH 10/47] ci(deps): bump actions/setup-python in /.github/actions/setup-sfw (#295) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.2.0 to 7.0.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/a309ff8b426b58ec0e2a45f0f869d46889d02405...5fda3b95a4ea91299a34e894583c3862153e4b97) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/actions/setup-sfw/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-sfw/action.yml b/.github/actions/setup-sfw/action.yml index 456f90b9..66b54ffc 100644 --- a/.github/actions/setup-sfw/action.yml +++ b/.github/actions/setup-sfw/action.yml @@ -27,7 +27,7 @@ runs: using: "composite" steps: - if: ${{ inputs.python == 'true' || inputs.uv == 'true' }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" From 56181bcfde32b22c4217200e25e1dcef399da0a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:52:35 -0400 Subject: [PATCH 11/47] chore(deps): bump the python-minor-patch group with 3 updates (#294) Bumps the python-minor-patch group with 3 updates: [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/bs4/), [markdown](https://github.com/Python-Markdown/markdown) and [ruff](https://github.com/astral-sh/ruff). Updates `beautifulsoup4` from 4.14.3 to 4.15.0 Updates `markdown` from 3.10.2 to 3.10.3 - [Release notes](https://github.com/Python-Markdown/markdown/releases) - [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md) - [Commits](https://github.com/Python-Markdown/markdown/compare/3.10.2...3.10.3) Updates `ruff` from 0.16.0 to 0.16.1 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.0...0.16.1) --- updated-dependencies: - dependency-name: beautifulsoup4 dependency-version: 4.15.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: markdown dependency-version: 3.10.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: ruff dependency-version: 0.16.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- pyproject.toml | 6 ++--- uv.lock | 60 +++++++++++++++++++++++++------------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d66e4bb7..8275fdb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,8 @@ dependencies = [ "packaging==26.2", "python-dotenv==1.2.2", "socketdev==3.5.0", - "beautifulsoup4==4.14.3", - "markdown==3.10.2", + "beautifulsoup4==4.15.0", + "markdown==3.10.3", "brotli==1.2.0; platform_python_implementation == 'CPython'", "brotlicffi==1.2.0.1; platform_python_implementation != 'CPython'", ] @@ -47,7 +47,7 @@ test = [ "pytest-watch==4.2.0" ] dev = [ - "ruff==0.16.0", + "ruff==0.16.1", "twine==7.0.0", # for building "uv==0.12.0", # for dependency management "pre-commit==4.6.1", diff --git a/uv.lock b/uv.lock index b9f904ad..58516703 100644 --- a/uv.lock +++ b/uv.lock @@ -112,15 +112,15 @@ wheels = [ [[package]] name = "beautifulsoup4" -version = "4.14.3" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] [[package]] @@ -795,11 +795,11 @@ wheels = [ [[package]] name = "markdown" -version = "3.10.2" +version = "3.10.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, ] [[package]] @@ -1213,27 +1213,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, - { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, - { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, - { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, - { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, - { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, - { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, - { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, - { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, - { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, - { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, ] [[package]] @@ -1321,12 +1321,12 @@ dev = [ [package.metadata] requires-dist = [ - { name = "beautifulsoup4", specifier = "==4.14.3" }, + { name = "beautifulsoup4", specifier = "==4.15.0" }, { name = "brotli", marker = "platform_python_implementation == 'CPython'", specifier = "==1.2.0" }, { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'", specifier = "==1.2.0.1" }, { name = "gitpython", specifier = "==3.1.57" }, { name = "hatch", marker = "extra == 'dev'", specifier = "==1.17.1" }, - { name = "markdown", specifier = "==3.10.2" }, + { name = "markdown", specifier = "==3.10.3" }, { name = "mdutils", specifier = "==1.8.1" }, { name = "packaging", specifier = "==26.2" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.6.1" }, @@ -1338,7 +1338,7 @@ requires-dist = [ { name = "pytest-watch", marker = "extra == 'test'", specifier = "==4.2.0" }, { name = "python-dotenv", specifier = "==1.2.2" }, { name = "requests", specifier = "==2.34.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.1" }, { name = "socketdev", specifier = "==3.5.0" }, { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.0" }, From 974f65671108390088abd7bb0dd2698aa2958175 Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:03:24 -0400 Subject: [PATCH 12/47] Bump pinned @coana-tech/cli to 15.10.4 (#291) * Bump pinned @coana-tech/cli to 15.10.4 * Rev version to 2.6.3, consolidating the unpublished 2.6.2 notes 2.6.1 shipped while this PR was in flight, and the 2.6.2 version bump on main was never published. Fold the 2.6.2 changelog entry, the Dependabot updates, and the dev/test dependency pinning into the 2.6.3 entry. Co-Authored-By: Claude Fable 5 * Trim dependency-update specifics in the 2.6.3 changelog entry Co-Authored-By: Claude Fable 5 --------- Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 17 ++++++++++++++++- docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 2 +- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53adb48d..eabbddf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2.6.2 +## 2.6.3 ### Fixed: SBOM fetch failures no longer produce empty reports @@ -13,6 +13,21 @@ instead of raising, and individual unparseable artifacts are skipped rather than failing the whole response. +### Changed: bump pinned @coana-tech/cli to 15.10.4 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.3` to + `15.10.4`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + +### Changed: dependency updates and dev/test dependency pinning + +- Bumped pinned runtime dependencies (`beautifulsoup4` `4.15.0`, + `markdown` `3.10.3`). +- Pinned the `test` and `dev` dependency groups to exact versions, extending + the exact-pinning policy introduced in 2.6.0 to all dependency groups. +- Updated GitHub Actions used in CI workflows. No effect on the installed + CLI. + ## 2.6.1 ### Changed: scan comparison now polls the diff-scans endpoints diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9f4ca88f..7d4f85c0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -275,7 +275,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.3 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.4 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index 8275fdb2..f325c07b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.2" +version = "2.6.3" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index c4c8bad0..88ac8237 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.2' +__version__ = '2.6.3' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 34d79099..d0fdd07e 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -18,7 +18,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.3" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.4" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -55,7 +55,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.3'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.4'). Args: version: Coana CLI version to use. @@ -64,7 +64,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.3'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.4'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index 58516703..ffc1224d 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.2" +version = "2.6.3" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 0d3937d71257f068ec89bfd7ae8a8e43b433fc7f Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:29:33 -0700 Subject: [PATCH 13/47] Bump pinned @coana-tech/cli to 15.10.13 (#300) * Bump pinned @coana-tech/cli to 15.10.13 * Bump pinned GitPython to 3.1.59 GitPython 3.1.57 is affected by six advisories fixed in 3.1.58 (published 2026-08-07) and five more fixed in 3.1.59 (published 2026-08-10). Pin the latest so the pip-audit gate stays green once the newer advisories propagate to the audit databases. None of the affected GitPython APIs are used by this CLI. Co-Authored-By: Claude Fable 5 Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --------- Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 14 ++++++++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 4 ++-- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 10 +++++----- 6 files changed, 26 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eabbddf5..c87837da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2.6.4 + +### Changed: bump pinned @coana-tech/cli to 15.10.13 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.4` to + `15.10.13`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + +### Changed: bump pinned GitPython to 3.1.59 + +- Bumped `GitPython` from `3.1.57` to `3.1.59`, picking up the security fixes + released in GitPython 3.1.58 and 3.1.59. None of the affected GitPython APIs + are used by this CLI. + ## 2.6.3 ### Fixed: SBOM fetch failures no longer produce empty reports diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7d4f85c0..49be8dfa 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -275,7 +275,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.4 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.13 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index f325c07b..313a8fae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,14 +6,14 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.3" +version = "2.6.4" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ "requests==2.34.2", "mdutils==1.8.1", "prettytable==3.18.0", - "GitPython==3.1.57", + "GitPython==3.1.59", "packaging==26.2", "python-dotenv==1.2.2", "socketdev==3.5.0", diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 88ac8237..9d9963d7 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.3' +__version__ = '2.6.4' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index d0fdd07e..37458c3d 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -18,7 +18,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.4" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.13" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -55,7 +55,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.4'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.13'). Args: version: Coana CLI version to use. @@ -64,7 +64,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.4'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.13'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index ffc1224d..8fd80913 100644 --- a/uv.lock +++ b/uv.lock @@ -574,14 +574,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.57" +version = "3.1.59" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/0d/132ed135c871b6bf91adf16a0e43797cd535b81d4973b5d09291c54fc5ee/gitpython-3.1.57.tar.gz", hash = "sha256:c493ec57c0ef6b19743798b6a5af859c71814b524e7e6f97baa2f8e658961488", size = 225898, upload-time = "2026-07-26T07:33:26.351Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/dc/126b28e76b24a9268ba931ad3e012f71ebdadf62fd9f17758f7074bb0b20/gitpython-3.1.59.tar.gz", hash = "sha256:0a1475cfdc38a5bfba1a3e9a4a9da52a39749ecec322b772915c019f94e5b7e4", size = 230445, upload-time = "2026-08-10T12:03:20.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/6e/2139de986d9c7c3ac86f1f8be43858ce90bdfe2f7175e6c80c650ba15242/gitpython-3.1.57-py3-none-any.whl", hash = "sha256:4ccf7d73c10f5c9e76043fbb2675ac5a1b3ff5b41e648f56bcbed5f63792ecaf", size = 217151, upload-time = "2026-07-26T07:33:24.838Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl", hash = "sha256:67a82f537384578643624c8b2c531938a9b82be431663e575dcf638526631d4c", size = 220996, upload-time = "2026-08-10T12:03:18.804Z" }, ] [[package]] @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.3" +version = "2.6.4" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, @@ -1324,7 +1324,7 @@ requires-dist = [ { name = "beautifulsoup4", specifier = "==4.15.0" }, { name = "brotli", marker = "platform_python_implementation == 'CPython'", specifier = "==1.2.0" }, { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'", specifier = "==1.2.0.1" }, - { name = "gitpython", specifier = "==3.1.57" }, + { name = "gitpython", specifier = "==3.1.59" }, { name = "hatch", marker = "extra == 'dev'", specifier = "==1.17.1" }, { name = "markdown", specifier = "==3.10.3" }, { name = "mdutils", specifier = "==1.8.1" }, From 13651d762b7147b751480810e53cb79b9554ea58 Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:42:10 +0200 Subject: [PATCH 14/47] Bump pinned @coana-tech/cli to 15.10.16 (#308) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 2 +- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c87837da..ecd3585d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.6.5 + +### Changed: bump pinned @coana-tech/cli to 15.10.16 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.13` to + `15.10.16`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + ## 2.6.4 ### Changed: bump pinned @coana-tech/cli to 15.10.13 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 49be8dfa..736fe8be 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -275,7 +275,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.13 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.16 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index 313a8fae..48ae5907 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.4" +version = "2.6.5" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 9d9963d7..5875d7cb 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.4' +__version__ = '2.6.5' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 37458c3d..dc9ac74f 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -18,7 +18,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.13" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.16" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -55,7 +55,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.13'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.16'). Args: version: Coana CLI version to use. @@ -64,7 +64,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.13'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.16'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index 8fd80913..156b9936 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.4" +version = "2.6.5" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 699a9a1d471b05cc4c9c8a53fc8b9ed9a046a15e Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:06:16 -0400 Subject: [PATCH 15/47] Reduce scan startup time in large repositories (#301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(core): discover manifests in a single filesystem walk find_files() started a separate recursive rglob traversal for every expanded manifest pattern, so a scan re-walked each root once per pattern and only filtered excluded directories after descending into them. Replace that with one os.walk() per scan root: - Expand and case-fold all active patterns once, then match in memory. - Prune excluded directories, including .git, before descending. - Reject non-manifests on the basename alone (one set lookup plus one compiled glob alternation) before building a relative path or running a path match. - Cache supported manifest patterns per Core instance, but only when the API lookup succeeds, so a transient failure does not pin the run to the smaller local fallback pattern set. - Emit INFO durations for organization setup, pattern retrieval and discovery, with files/directories visited, directories pruned and manifests found. Matching behaviour is unchanged apart from intentionally excluding .git metadata. Adds parity tests against the previous rglob implementation for every built-in ecosystem and pattern, covering case-insensitivity, brace expansion, nested patterns, dot-directories, exclusions, inclusions, symlinks, excluded ecosystems, multiple roots, sorting and deduplication, plus an opt-in benchmark that asserts old/new result equality on a synthetic large-monorepo fixture. Ref: CE-379 * perf(git): fetch only the refs a comparison needs Git.__init__() ran `git fetch --all` on every invocation, pulling every remote branch and tag before changed-file detection even began. Resolve commit and branch metadata locally instead, and for pull-request comparisons prefer refs already present in the checkout, fetching a single base or head ref only when it is missing. Also recognises Buildkite's native BUILDKITE_COMMIT, BUILDKITE_BRANCH, BUILDKITE_PULL_REQUEST and BUILDKITE_PULL_REQUEST_BASE_BRANCH so Buildkite jobs can calculate a complete base-to-head changed-file range without mapping their environment onto GitHub Actions variable names. Buildkite is checked before GitHub because some pipelines deliberately export GitHub-compatible variables. Adds INFO durations for Git initialisation, changed-file detection and each fetch, including the ref requested and why. Existing GitHub Actions, GitLab CI, Bitbucket Pipelines and local behaviour is preserved; tests cover local-ref preference, absence of an unconditional fetch, the targeted-fetch fallback, all four CI providers, and non-PR and detached-HEAD execution. Ref: CE-379 * feat(buildkite): derive GitHub comment context natively `--scm github` read its configuration solely from GITHUB_* variables, so Buildkite users had to shim every one of them to get PR comments. Fall back to Buildkite's own variables when the GITHUB_* equivalents are absent: PR number, commit, branch, checkout path, commit message, build creator, and owner/repository parsed from BUILDKITE_REPO (preferring the pipeline repository over a contributor's fork). Explicit GITHUB_* and PR_NUMBER values still take priority, and GitHub Enterprise remains configurable via GITHUB_API_URL. A running Buildkite PR build maps to the supported `synchronize` comment path, and a non-PR build maps to `push`, so event routing is unchanged. Default-branch detection requires an actual branch name rather than treating two unset variables as a match, which would otherwise mark any build as the default branch and overwrite the repository baseline. Ref: CE-379 * refactor(cli): reuse sub-path discovery results and clarify scan routing The --sub-path routing pre-check walked every selected path to decide whether any manifests existed, then discarded the result so scan creation walked the same paths again. Retain and reuse it. Apply --excluded-ecosystems before the pre-check rather than after, so every find_files() call in a run sees the same ecosystem filter. Add an INFO duration for CLI run registration, and replace the "No Manifest files changed" line with wording that describes the decision being made: no supported manifest was detected in the changed-file set, so a full report is created. Scan-routing semantics are unchanged. Ref: CE-379 * docs(changelog): note faster local scan setup for large repositories Ref: CE-379 * chore(release): bump version to 2.6.5 Bumped via .hooks/sync_version.py so __init__.py, pyproject.toml and uv.lock stay in sync, and moved the changelog entry under a 2.6.5 heading. Ref: CE-379 * fix(ci): build the Docker preview from the checked-out workspace The publish-docker job downloads the built wheel to ./dist, but the build step omitted `context`, so docker/build-push-action used its default Git context. Buildx then cloned the repository as the build context, where ./dist does not exist, and `COPY dist/socketsecurity-*.whl` failed with "lstat /dist: no such file or directory". Set `context: .` so the build uses the workspace the artifact was downloaded into. This also makes the job's existing trust boundary hold as documented: the context is now the default-branch checkout rather than the pull-request ref, so Dockerfile.preview is read from trusted code and the pull request still enters the image only through the built wheel. Pre-existing; the TestPyPI half of the workflow is unaffected. * ci(preview): build Docker previews for arm64 as well as amd64 The preview image was amd64-only while the release and stable images are built for linux/amd64,linux/arm64, so a preview tag could not stand in for socketdev/cli:latest on arm64 hosts without emulation. Match the release arch matrix and enable QEMU so the arm64 layer can be built on an amd64 runner. Previews are opt-in via label, so the extra build time is an acceptable tradeoff for making the tag a drop-in replacement. * perf(diff): tighten diff-scan poll ceiling and make its timing attributable A finished comparison could sit unobserved for up to 30s between polls, which is dead time on every PR job. Lower the ceiling to 10s: a multi-minute comparison costs roughly 2x the polls while cutting worst-case dead time to 10s. Diff scans now log their ID, poll count, and the wait before the final poll at INFO. Previously the ID was debug-only, so a slow comparison in a customer CI log could not be tied back to a server-side diff scan, and there was no way to tell backend comparison time apart from time the result spent ready-but-unpolled. Also document the diff-scans token scopes. A token missing them still completes the scan, silently falling back to the streaming comparison, which differs in both transport and payload (cached diff-scan responses always embed per-package license details; the streaming path requests a lean payload). Ref: CE-379 * feat(diff): log the diff report URL and cover discovery memory PR/MR runs logged the head and new scan IDs but no link to the result, so a CI log gave no way to reach the report. Log the diff report URL where it is computed, so every diff flow gets it rather than only the full-scan-only branches. Also add a regression test asserting manifest discovery's peak allocation stays bounded by the widest single directory and the result set rather than by repository size. Measured against the per-pattern rglob approach this replaced, on a tree of 59,300 files including one 50,000-entry directory: 3.25 MB peak vs 10.72 MB. os.walk keeps a list of names per directory where rglob materialised DirEntry objects and a Path per candidate, so the single-pass walk allocates strictly less. Ref: CE-379 * docs(diff): record verified cached diff-scan param behaviour Probed the live API against an existing diff scan to confirm what the polling path can and cannot ask for: - omit_license_details is ignored when cached=true, as the existing comment said. License fields remain in the response. - omit_unchanged IS honored and removes unchanged artifacts entirely, measured at ~1.1 KB per artifact (225,542 B -> 78,003 B when dropping 135 of 192 artifacts). Record why the CLI still does not send omit_unchanged: unchanged artifacts feed diff.unchanged_alerts, which create_security_comment_gitlab and the FOSSA compat issue list read unconditionally, not only under --strict-blocking. Omitting them would silently shrink those outputs, so this needs proper gating in its own change rather than a param tweak here. Ref: CE-379 * perf(diff): skip unchanged artifacts when no output reads them Cached diff-scan responses embed every unchanged artifact at roughly 1 KB each. On a large dependency tree that is nearly the whole response — measured at ~11 MB for a tree with ~10k unchanged packages — downloaded, deserialised into Package objects and then discarded on every pull request. omit_unchanged is honored by the API (unlike omit_license_details, which cached responses ignore), so request it whenever no enabled output reads that half of the comparison. Verified against the live API through the SDK: 192 artifacts -> 57. Every consumer is behind an opt-in flag, so the gate is centralised in Core._requires_unchanged_artifacts with the reasoning recorded there: - --strict-blocking blocks on pre-existing issues via diff.unchanged_alerts - --enable-gitlab-security includes them in the dependency scanning report - --generate-license enumerates diff.packages, which must list every dependency - --legal-format fossa reports all currently-present issues Diff.to_dict serialises them too but has no callers. When cli_config is absent the caller is unknown, so the full payload is kept. Tests parametrise over every flag in that list so a new reader of diff.unchanged_alerts or diff.packages cannot be added without also updating the gate. The completion log reports omit_unchanged so it is visible whether the optimisation engaged on a given run. Ref: CE-379 * chore(release): bump version to 2.6.6 * fix: always filter diff scan artifacts * fix: preserve directory-only manifest patterns Address peer review feedback by retaining pathlib.rglob trailing-slash semantics, trimming the release notes, and removing redundant implementation commentary. --- .github/workflows/pr-preview.yml | 12 +- CHANGELOG.md | 21 ++ benchmarks/manifest_discovery.py | 101 ++++++ docs/ci-cd.md | 13 + docs/troubleshooting.md | 29 ++ pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/__init__.py | 401 +++++++++++++++++---- socketsecurity/core/git_interface.py | 271 ++++++++++---- socketsecurity/core/scm/github.py | 99 ++++- socketsecurity/core/streaming.py | 6 + socketsecurity/socketcli.py | 42 ++- tests/core/test_diff_scan_polling.py | 152 ++++++++ tests/unit/test_git_interface.py | 251 +++++++++++++ tests/unit/test_github_buildkite_config.py | 173 +++++++++ tests/unit/test_manifest_discovery.py | 349 ++++++++++++++++++ tests/unit/test_streaming.py | 15 +- uv.lock | 2 +- 18 files changed, 1768 insertions(+), 173 deletions(-) create mode 100644 benchmarks/manifest_discovery.py create mode 100644 tests/unit/test_git_interface.py create mode 100644 tests/unit/test_github_buildkite_config.py create mode 100644 tests/unit/test_manifest_discovery.py diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 3a2ab1a9..d3ef1c8b 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -254,17 +254,25 @@ jobs: - name: Set up Docker publishing uses: ./.github/actions/setup-docker with: - enable-qemu: "false" + # QEMU is required to build the arm64 layer on an amd64 runner. + enable-qemu: "true" dockerhub-username: ${{ secrets.DOCKERHUB_USERNAME }} dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push Docker preview uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: + # Build from the checked-out workspace, not the default Git context. The + # wheel is only present here as a downloaded artifact, and this also keeps + # Dockerfile.preview on the default-branch checkout rather than the PR ref. + context: . file: Dockerfile.preview push: true pull: true - platforms: linux/amd64 + # Match the arch matrix of the release and stable images so a preview is a + # drop-in replacement for socketdev/cli:latest on arm64 runners too. The + # arm64 layer builds under emulation, so expect roughly double the runtime. + platforms: linux/amd64,linux/arm64 tags: socketdev/cli:pr-${{ needs.context.outputs.pr_number }} build-args: | SDK_PREVIEW_VERSION=${{ inputs.sdk_preview_version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd3585d..efa4fd2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 2.6.6 + +### Changed: faster local scan setup for large repositories + +- Manifest discovery now uses one filesystem walk per scan root and prunes + excluded directories before descent. +- Pull request scans use local Git refs first and fetch only missing history. + Buildkite pull request metadata is now supported directly. +- Supported manifest patterns are cached per invocation, and discovered + manifests are reused during scan creation. +- Added timings for initialization, Git operations, changed-file detection, + pattern lookup, and manifest discovery. + +### Changed: scan comparisons no longer fetch unused artifacts + +- Scan comparisons omit unchanged artifacts unless an enabled output needs them. +- Diff scans poll more frequently and log identifiers and timing details for + easier troubleshooting. +- Documented the `diff-scans:create`, `diff-scans:list` and `full-scans:list` + token scopes required by the optimized comparison path. + ## 2.6.5 ### Changed: bump pinned @coana-tech/cli to 15.10.16 diff --git a/benchmarks/manifest_discovery.py b/benchmarks/manifest_discovery.py new file mode 100644 index 00000000..abc0e8bb --- /dev/null +++ b/benchmarks/manifest_discovery.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Compare legacy per-pattern rglob discovery with the single-pass walker. + +This is an opt-in developer benchmark, not a timing assertion in the test +suite. It creates a synthetic monorepo so filesystem or CI-agent changes do not +make regular tests flaky. +""" + +import argparse +import tempfile +import time +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +from socketsecurity.core import Core +from socketsecurity.core.socket_config import SocketConfig +from socketsecurity.core.utils import socket_globs + + +def seed_tree(root: Path, directories: int, files_per_directory: int) -> None: + for directory_index in range(directories): + directory = root / "packages" / f"package-{directory_index:05d}" + directory.mkdir(parents=True) + (directory / "package.json").write_text("{}\n", encoding="utf-8") + for file_index in range(files_per_directory): + (directory / f"source-{file_index:03d}.txt").write_text( + "not a manifest\n", + encoding="utf-8", + ) + + # These trees model the expensive directories that the new walker prunes + # before descent rather than visiting once for every manifest pattern. + for excluded in (".git/objects", "node_modules/example", ".venv/site-packages"): + directory = root / excluded + directory.mkdir(parents=True) + for index in range(files_per_directory * 10): + (directory / f"object-{index:05d}").write_text("x", encoding="utf-8") + + +def legacy_discover(root: Path) -> set[str]: + results = set() + excluded_dirs = SocketConfig(api_key="benchmark").excluded_dirs + for ecosystem_patterns in socket_globs.values(): + for details in ecosystem_patterns.values(): + for pattern in Core.expand_brace_pattern(details["pattern"]): + insensitive = Core.to_case_insensitive_regex(pattern) + for candidate in root.rglob(insensitive): + if candidate.is_file() and not Core.is_excluded( + str(candidate), + excluded_dirs, + ): + results.add(candidate.as_posix()) + return results + + +def new_core() -> Core: + core = Core.__new__(Core) + core.config = SocketConfig(api_key="benchmark") + core.cli_config = SimpleNamespace(exclude_paths=None) + core.sdk = MagicMock() + core._supported_patterns = socket_globs + return core + + +def timed(function, root: Path) -> tuple[set[str], float]: + start = time.perf_counter() + results = set(function(root)) + return results, time.perf_counter() - start + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--directories", type=int, default=500) + parser.add_argument("--files-per-directory", type=int, default=20) + args = parser.parse_args() + + with tempfile.TemporaryDirectory(prefix="socket-manifest-benchmark-") as temp: + root = Path(temp) + seed_tree(root, args.directories, args.files_per_directory) + legacy_results, legacy_seconds = timed(legacy_discover, root) + new_results, new_seconds = timed( + lambda path: new_core().find_files(str(path)), + root, + ) + + if legacy_results != new_results: + raise SystemExit( + "Manifest result mismatch: " + f"legacy={len(legacy_results)}, single_pass={len(new_results)}" + ) + + speedup = legacy_seconds / new_seconds if new_seconds else float("inf") + print(f"Manifests: {len(new_results)}") + print(f"Legacy per-pattern rglob: {legacy_seconds:.3f}s") + print(f"Single-pass walk: {new_seconds:.3f}s") + print(f"Speedup: {speedup:.1f}x") + + +if __name__ == "__main__": + main() diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 66193f38..061d18ea 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -81,6 +81,19 @@ steps: SOCKET_SECURITY_API_TOKEN: "${SOCKET_SECURITY_API_TOKEN}" ``` +The CLI reads Buildkite's native `BUILDKITE_COMMIT`, `BUILDKITE_BRANCH`, +`BUILDKITE_PULL_REQUEST`, and `BUILDKITE_PULL_REQUEST_BASE_BRANCH` variables. +For pull-request builds, ensure the checkout contains the base branch and the +checked-out head commit. The CLI uses those local refs first and performs a +targeted fetch only when a required ref or its comparison history is missing; +it does not fetch every remote ref and tag during startup. + +When `--scm github` is used from Buildkite, the CLI also derives GitHub comment +context from `BUILDKITE_REPO`, `BUILDKITE_BUILD_CHECKOUT_PATH`, and the variables +above. Set `GH_API_TOKEN` to a GitHub token with the required repository access. +GitHub Enterprise users should also set `GITHUB_API_URL`; GitHub.com defaults to +`https://api.github.com`. + #### Merge-base baselines in Buildkite (dynamic pipelines) Notes for using `--base-commit-sha` (see the diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 531099cd..9d6d06df 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,5 +1,34 @@ # Troubleshooting +## API token scopes for scan comparisons + +PR/MR runs compare the new scan against the repository's head scan. That comparison +first uses the diff-scans endpoints, which require an organization token with these +scopes in addition to whatever the scan itself needs: + +- `diff-scans:create` +- `diff-scans:list` +- `full-scans:list` + +If the token is missing them the scan still succeeds, so this is easy to miss. The only +signal is a warning, after which the CLI falls back to the older streaming comparison: + +``` +Diff scan comparison failed with APIAccessDenied(Insufficient permissions), falling back to the streaming scan comparison +``` + +Grant the scopes to use the diff-scans path. It polls with short, bounded requests +rather than holding one connection open while the backend computes, which is what lets +large comparisons survive network idle timeouts — notably Azure NAT gateways, which +reap idle connections after four minutes and surface as an intermittent +`ConnectionResetError`. + +The two paths can take noticeably different amounts of time on the same repository, +because cached diff-scan responses always embed per-package license details while the +streaming comparison requests a lean payload. On a large dependency tree, compare the +`Diff scan comparison ready in ...` timing against the `Diff Report Gathered in ...` +total before assuming either path is at fault. + ## Common gotchas - In diff scope, `--strict-blocking` uses a stricter alert set (`new + unchanged`) for blocking checks and diff-based output selection. diff --git a/pyproject.toml b/pyproject.toml index 48ae5907..6910e7cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.5" +version = "2.6.6" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 5875d7cb..4ee959b2 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.5' +__version__ = '2.6.6' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 7be24858..daab5fdd 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -1,3 +1,7 @@ +import copy +import fnmatch +import importlib +import json import logging import os import random @@ -6,10 +10,9 @@ import tarfile import tempfile import time -import json from dataclasses import asdict -from pathlib import Path, PurePath -from typing import Dict, List, Tuple, Set, TYPE_CHECKING, Optional +from pathlib import PurePath +from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Set, Tuple if TYPE_CHECKING: from socketsecurity.config import CliConfig @@ -18,21 +21,15 @@ from socketdev.fullscans import DiffArtifacts, FullScanParams, SocketArtifact from socketdev.org import Organization from socketdev.repos import RepositoryInfo -import copy -from socketsecurity import __version__, USER_AGENT -from socketsecurity.core.classes import ( - Alert, - Diff, - FullScan, - Issue, - Package, - Purl -) + +from socketsecurity import USER_AGENT, __version__ +from socketsecurity.core.classes import Alert, Diff, FullScan, Issue, Package, Purl from socketsecurity.core.exceptions import APIResourceNotFound + +from .resource_utils import check_file_count_against_ulimit from .socket_config import SocketConfig from .utils import socket_globs -from .resource_utils import check_file_count_against_ulimit -import importlib + logging_std = importlib.import_module("logging") @@ -107,7 +104,7 @@ # completes; on expiry (or any other failure of this flow) the caller falls back to the # legacy streaming comparison rather than failing the scan outright. DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS = 5.0 -DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS = 30.0 +DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS = 10.0 DIFF_SCAN_POLL_BACKOFF_MULTIPLIER = 1.5 DIFF_SCAN_POLL_TIMEOUT_SECONDS = 30 * 60.0 @@ -125,6 +122,30 @@ def _humanize_alert_type(alert_type: str) -> str: return " ".join(part[:1].upper() + part[1:] for part in parts if part) +class ManifestPatterns(NamedTuple): + """Manifest patterns prepared once per scan root, case-folded for matching. + + The first three fields are the authoritative matchers used by + Core._matches_manifest_pattern. The candidate_* fields are a prefilter over + basenames alone: a manifest-discovery walk visits every file in the repository + but only a few hundred are manifests, so rejecting a name up front avoids + building a relative path and running a path match for the rest. The prefilter's + globs are pre-compiled into one alternation so the cost per rejected file stays + flat as the API's pattern list grows. + """ + + literal_basenames: Set[str] + basename_globs: List[str] + path_globs: List[str] + candidate_basenames: Set[str] + candidate_basename_regex: Optional["re.Pattern"] + + @property + def is_empty(self) -> bool: + """True when every ecosystem was filtered out, so the walk can be skipped.""" + return not (self.literal_basenames or self.basename_globs or self.path_globs) + + class Core: """Main class for interacting with Socket Security API and processing scan results.""" @@ -146,7 +167,13 @@ def __init__(self, config: SocketConfig, sdk: socketdev, cli_config: Optional['C self.config = config self.sdk = sdk self.cli_config = cli_config + self._supported_patterns: Optional[Dict] = None + org_start_time = time.perf_counter() self.set_org_vars() + log.info( + "Organization initialization completed in " + f"{time.perf_counter() - org_start_time:.2f}s" + ) def set_org_vars(self) -> None: """Sets the main shared configuration variables for organization access.""" @@ -421,6 +448,112 @@ def format_bytes(bytes_value): except Exception as e: log.error(f"Failed to save manifest tar.gz to {output_path}: {e}") + @staticmethod + def _prepare_manifest_patterns( + patterns: Dict, + ecosystems: Optional[List[str]], + excluded_ecosystems: List[str] + ) -> "ManifestPatterns": + """Prepare case-folded manifest patterns for a single filesystem walk. + + Literal basenames are kept in a set for the common fast path. Basename + globs and path-shaped globs are kept separately so the latter retain + pathlib's path-segment-aware matching behavior. The candidate basename + collections are derived here so the walker can reject a file on its name + alone; see ManifestPatterns. + """ + included_ecosystems = set(ecosystems) if ecosystems is not None else None + excluded = set(excluded_ecosystems) + literal_basenames: Set[str] = set() + basename_globs: Set[str] = set() + path_globs: Set[str] = set() + + for ecosystem, ecosystem_patterns in patterns.items(): + if included_ecosystems is not None and ecosystem not in included_ecosystems: + continue + if ecosystem in excluded: + continue + log.debug(f"Scanning ecosystem: {ecosystem}") + for details in ecosystem_patterns.values(): + original_pattern = details["pattern"] + for expanded in Core.expand_brace_pattern(original_pattern): + normalized = expanded.replace("\\", "/").casefold() + if "/" in normalized: + path_globs.add(normalized) + elif any(character in normalized for character in "*?["): + basename_globs.add(normalized) + else: + literal_basenames.add(normalized) + + # PurePath.match compares pattern segments right to left, so a path-shaped glob + # can only match a file whose basename matches the glob's final segment. Folding + # those final segments into the basename prefilter lets the walk skip the path + # match for everything else. A trailing "/" is directory-only under the legacy + # rglob behavior, so its empty final segment intentionally admits no files. + candidate_basenames = set(literal_basenames) + candidate_basename_globs = set(basename_globs) + for pattern in path_globs: + final_segment = pattern.rsplit("/", 1)[-1] + if any(character in final_segment for character in "*?["): + candidate_basename_globs.add(final_segment) + else: + candidate_basenames.add(final_segment) + + return ManifestPatterns( + literal_basenames=literal_basenames, + basename_globs=sorted(basename_globs), + path_globs=sorted(path_globs), + candidate_basenames=candidate_basenames, + candidate_basename_regex=Core._compile_basename_globs(candidate_basename_globs), + ) + + @staticmethod + def _compile_basename_globs(globs: Set[str]) -> Optional["re.Pattern"]: + """Compile basename globs into a single alternation, or None if there are none. + + fnmatch.translate anchors the tail with ``\\Z`` and re.match anchors the head, + so each alternative matches exactly what fnmatch.fnmatchcase would. + """ + if not globs: + return None + return re.compile( + "|".join(f"(?:{fnmatch.translate(glob)})" for glob in sorted(globs)) + ) + + @staticmethod + def _basename_could_match(normalized_name: str, patterns: "ManifestPatterns") -> bool: + """Cheap prefilter: could a file with this basename match any manifest pattern? + + False is authoritative; True still has to be confirmed by + _matches_manifest_pattern against the scan-root-relative path. + """ + if normalized_name in patterns.candidate_basenames: + return True + return ( + patterns.candidate_basename_regex is not None + and patterns.candidate_basename_regex.match(normalized_name) is not None + ) + + @staticmethod + def _matches_manifest_pattern(relative_path: str, patterns: "ManifestPatterns") -> bool: + """Return whether a scan-root-relative path matches a manifest pattern.""" + normalized_path = relative_path.replace("\\", "/").casefold() + basename = normalized_path.rsplit("/", 1)[-1] + if basename in patterns.literal_basenames: + return True + if any(fnmatch.fnmatchcase(basename, pattern) for pattern in patterns.basename_globs): + return True + if not patterns.path_globs: + return False + + candidate = PurePath(normalized_path) + return any(candidate.match(pattern) for pattern in patterns.path_globs) + + @staticmethod + def _matches_excluded_directory(directory_name: str, excluded_dirs: Set[str]) -> bool: + """Match configured directory exclusions, including entries such as ``*.egg-info``.""" + return any(fnmatch.fnmatchcase(directory_name, pattern) for pattern in excluded_dirs) + def find_files(self, path: str, ecosystems: Optional[List[str]] = None) -> List[str]: """ Finds supported manifest files in the given path. @@ -432,8 +565,8 @@ def find_files(self, path: str, ecosystems: Optional[List[str]] = None) -> List[ Returns: List of found manifest file paths. """ - log.debug("Starting Find Files") - start_time = time.time() + log.debug("Starting manifest discovery") + start_time = time.perf_counter() files: Set[str] = set() # Unified --exclude-paths: filter discovered manifests by the same paths/globs that are @@ -447,50 +580,92 @@ def find_files(self, path: str, ecosystems: Optional[List[str]] = None) -> List[ exclude_paths = getattr(self.cli_config, "exclude_paths", None) if self.cli_config else None exclude_regexes = Core.compile_exclude_paths(exclude_paths) if exclude_paths else [] - # Get supported patterns from the API patterns = self.get_supported_patterns() + manifest_patterns = self._prepare_manifest_patterns( + patterns, + ecosystems, + self.config.excluded_ecosystems, + ) - for ecosystem in patterns: - # If ecosystems filter is provided, only include specified ecosystems - if ecosystems is not None and ecosystem not in ecosystems: - continue - if ecosystem in self.config.excluded_ecosystems: - continue - log.debug(f'Scanning ecosystem: {ecosystem}') - ecosystem_patterns = patterns[ecosystem] - for file_name in ecosystem_patterns: - original_pattern = ecosystem_patterns[file_name]["pattern"] - - # Expand brace patterns - expanded_patterns = Core.expand_brace_pattern(original_pattern) - - for pattern in expanded_patterns: - case_insensitive_pattern = Core.to_case_insensitive_regex(pattern) - - log.debug(f"Searching for pattern: {case_insensitive_pattern}") - glob_start = time.time() - - # Use pathlib.Path.rglob() instead of glob.glob() to properly match dotfiles/dotdirs - base_path = Path(path) - glob_files = base_path.rglob(case_insensitive_pattern) - - for glob_file in glob_files: - glob_file_str = str(glob_file) - if not os.path.isfile(glob_file_str): - continue - if Core.is_excluded(glob_file_str, self.config.excluded_dirs): - continue - if exclude_regexes: - rel = os.path.relpath(glob_file_str, path) - if Core.path_matches_exclude_regexes(rel, exclude_regexes): - continue - files.add(glob_file_str.replace("\\", "/")) - - glob_end = time.time() - log.debug(f"Globbing took {glob_end - glob_start:.4f} seconds") + if manifest_patterns.is_empty: + elapsed = time.perf_counter() - start_time + log.info( + "Manifest discovery completed in " + f"{elapsed:.2f}s: root={os.path.abspath(path)}, " + "directories_visited=0, directories_pruned=0, " + "files_visited=0, manifests_found=0" + ) + log.info("Total files found: 0") + return [] + + directories_visited = 0 + directories_pruned = 0 + files_visited = 0 + excluded_dirs = set(self.config.excluded_dirs) + + def handle_walk_error(error: OSError) -> None: + log.debug(f"Unable to inspect path during manifest discovery: {error}") + + for current_root, directory_names, file_names in os.walk( + path, + topdown=True, + followlinks=False, + onerror=handle_walk_error, + ): + directories_visited += 1 + + kept_directories = [] + for directory_name in directory_names: + if directory_name == ".git" or Core._matches_excluded_directory( + directory_name, + excluded_dirs, + ): + directories_pruned += 1 + continue + # Only --exclude-paths needs a scan-root-relative path, so build one + # lazily rather than for every directory in the repository. + if exclude_regexes: + relative_directory = os.path.relpath( + os.path.join(current_root, directory_name), + path, + ) + if Core.path_matches_exclude_regexes(relative_directory, exclude_regexes): + directories_pruned += 1 + continue + kept_directories.append(directory_name) + directory_names[:] = kept_directories + + files_visited += len(file_names) + for file_name in file_names: + # Reject on the basename first: os.walk already hands us the name, so + # non-manifests cost one set lookup instead of a relative path plus a + # path match. Exclusions are then only evaluated for real candidates. + if not Core._basename_could_match(file_name.casefold(), manifest_patterns): + continue + file_path = os.path.join(current_root, file_name) + relative_path = os.path.relpath(file_path, path) + if not Core._matches_manifest_pattern(relative_path, manifest_patterns): + continue + if exclude_regexes and Core.path_matches_exclude_regexes( + relative_path, + exclude_regexes, + ): + continue + if os.path.isfile(file_path): + files.add(file_path.replace("\\", "/")) file_list = sorted(files) file_count = len(file_list) + elapsed = time.perf_counter() - start_time + log.info( + "Manifest discovery completed in " + f"{elapsed:.2f}s: root={os.path.abspath(path)}, " + f"directories_visited={directories_visited}, " + f"directories_pruned={directories_pruned}, " + f"files_visited={files_visited}, manifests_found={file_count}" + ) + # Retain the established count-only message for log consumers while they + # transition to the stage-level timing above. log.info(f"Total files found: {file_count}") # Check if the number of manifest files might exceed ulimit -n @@ -532,19 +707,36 @@ def get_supported_patterns(self) -> Dict: Returns: Dictionary of supported file patterns with 'general' key removed """ + cached_patterns = getattr(self, "_supported_patterns", None) + if cached_patterns is not None: + log.debug("Using cached supported manifest patterns") + return cached_patterns + + start_time = time.perf_counter() response = self.sdk.report.supported() + source = "api" if not response: log.error("Failed to get supported patterns from API") - # Import the old patterns as fallback - from .utils import socket_globs - return socket_globs - - # Remove the 'general' key if it exists - if 'general' in response: - response.pop('general') - - # The response is already in the format we need - return response + response = socket_globs + source = "local-fallback" + + # Do not mutate the SDK response, which may be shared by its own cache. + patterns = { + ecosystem: ecosystem_patterns + for ecosystem, ecosystem_patterns in response.items() + if ecosystem != "general" + } + # Only cache a successful lookup. The local fallback covers far fewer ecosystems + # than the API, so one transient failure must not pin the rest of the run to it — + # has_manifest_files() runs before find_files() and would poison the cache. + if source == "api": + self._supported_patterns = patterns + elapsed = time.perf_counter() - start_time + log.info( + "Supported manifest patterns loaded in " + f"{elapsed:.2f}s: source={source}, ecosystems={len(patterns)}" + ) + return patterns def has_manifest_files(self, files: list) -> bool: """ @@ -627,7 +819,7 @@ def empty_head_scan_file() -> List[str]: temp_path = os.path.join(temp_dir, '.socket.facts.json') # Create the empty file - with open(temp_path, 'w') as f: + with open(temp_path, 'w'): pass # Creates an empty file log.debug(f"Created temporary empty file for baseline scan: {temp_path}") @@ -1392,7 +1584,16 @@ def get_diff_scan_artifacts( "Error creating or resolving diff scan: " f"unexpected response: {str(response_summary)[:500]}" ) - artifacts_dict = diff_scan.get("artifacts") + # Logged at INFO, not debug: this is the only identifier that ties a slow or + # failed comparison in a CI log back to a server-side diff scan, and it is + # needed even when the run later falls back to the streaming comparison. + log.info(f"Diff scan created: id={diff_scan_id}") + + # The create and list endpoints are metadata-only. Always fetch artifacts + # through GET below, even if an unexpected/legacy response happens to embed + # them, so omit_unchanged and the bounded cached-polling contract cannot be + # bypassed by an eager response. + artifacts_dict = None # cached=true is the polling contract (202 while computing, 200 when # ready). The API ignores omit_license_details when cached=true - cached @@ -1402,9 +1603,24 @@ def get_diff_scan_artifacts( # payload ever gets a response truncated on a huge dependency tree, # response.json() fails and the caller falls back to the legacy # streaming comparison, which still requests the lean payload. + # + # Verified against the live API: passing omit_license_details alongside + # cached=true leaves the license fields in the response, but omit_unchanged + # IS honored and drops the unchanged artifacts entirely (~1.1 KB each), so + # it is requested whenever no enabled output reads them. See + # _requires_unchanged_artifacts. poll_params = {"cached": "true"} - deadline = time.monotonic() + DIFF_SCAN_POLL_TIMEOUT_SECONDS + omit_unchanged = not self._requires_unchanged_artifacts() + if omit_unchanged: + poll_params["omit_unchanged"] = "true" + poll_start = time.monotonic() + deadline = poll_start + DIFF_SCAN_POLL_TIMEOUT_SECONDS interval = DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS + # Tracked so the completion log can separate backend compute time from time the + # result spent ready-but-unpolled: the wait before the final poll bounds the + # latter, which is otherwise invisible in a CI log. + polls = 0 + last_interval = 0.0 while artifacts_dict is None: try: response = self.sdk.diffscans.get(self.config.org_slug, diff_scan_id, params=poll_params) @@ -1418,6 +1634,7 @@ def get_diff_scan_artifacts( f"({type(error).__name__}), retrying in {interval:.0f}s" ) response = {"status": "processing"} + polls += 1 if response.get("status") != "processing": scan = response.get("diff_scan") or {} if scan.get("artifacts") is None: @@ -1425,6 +1642,12 @@ def get_diff_scan_artifacts( f"Error fetching diff scan {diff_scan_id}: unexpected response: {str(response)[:500]}" ) artifacts_dict = scan["artifacts"] + log.info( + "Diff scan comparison ready in " + f"{time.monotonic() - poll_start:.2f}s: id={diff_scan_id}, " + f"polls={polls}, wait_before_final_poll={last_interval:.0f}s, " + f"omit_unchanged={str(omit_unchanged).lower()}" + ) break if time.monotonic() >= deadline: raise Exception( @@ -1433,6 +1656,7 @@ def get_diff_scan_artifacts( ) log.debug(f"Diff scan {diff_scan_id} still processing, polling again in {interval:.0f}s") time.sleep(interval) + last_interval = interval interval = min(interval * DIFF_SCAN_POLL_BACKOFF_MULTIPLIER, DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS) return DiffArtifacts.from_dict({ @@ -1440,6 +1664,39 @@ def get_diff_scan_artifacts( for key in ("added", "removed", "unchanged", "replaced", "updated") }) + def _requires_unchanged_artifacts(self) -> bool: + """Whether any enabled output reads the unchanged half of a comparison. + + A cached diff-scan response embeds every unchanged artifact at roughly 1 KB + each, so on a large dependency tree they are almost the entire payload + (~11 MB for a tree of ~10k unchanged packages) even though most runs never + look at them. Every consumer is behind an opt-in flag: + + - ``--strict-blocking`` reads ``diff.unchanged_alerts`` to block on + pre-existing issues (socketcli, output, alert_selection, slack plugin). + - ``--enable-gitlab-security`` includes them in the GitLab dependency + scanning report (Messages.create_security_comment_gitlab). + - ``--generate-license`` enumerates ``diff.packages``, which must list every + dependency, not just the changed ones. + - ``--legal-format fossa`` reports all currently-present issues, matching + FOSSA's point-in-time snapshot semantics. + + ``Diff.to_dict`` also serializes them but has no callers. When cli_config is + absent the caller is unknown, so the full payload is kept. + + Keep this in sync with those consumers; test_unchanged_artifacts_gating + pins the list. + """ + config = self.cli_config + if config is None: + return True + return bool( + getattr(config, "strict_blocking", False) + or getattr(config, "enable_gitlab_security", False) + or getattr(config, "generate_license", False) + or getattr(config, "legal_format", "socket") == "fossa" + ) + def get_added_and_removed_packages( self, head_full_scan_id: str, @@ -1754,6 +2011,10 @@ def create_new_diff( else: diff.diff_url = diff.report_url + # PR/MR runs previously logged only the scan IDs, so a CI log had no link to the + # result. Logged here rather than at each call site so every diff flow gets it. + log.info(f"Diff report URL: {diff.diff_url}") + return diff def create_diff_report( diff --git a/socketsecurity/core/git_interface.py b/socketsecurity/core/git_interface.py index da614063..b3c53bdc 100644 --- a/socketsecurity/core/git_interface.py +++ b/socketsecurity/core/git_interface.py @@ -1,6 +1,7 @@ +import os import re +import time import urllib.parse -import os from git import Repo @@ -12,34 +13,33 @@ class Git: path: str def __init__(self, path: str): + initialization_start = time.perf_counter() self.path = path + self._fetched_ref_commits = {} self.ensure_safe_directory(path) self.repo = Repo(path) assert self.repo self.head = self.repo.head - - # Always fetch all remote refs to ensure branches exist for diffing - try: - self.repo.git.fetch('--all') - log.debug("Fetched all remote refs for diffing.") - except Exception as fetch_error: - log.debug(f"Failed to fetch all remote refs: {fetch_error}") # Use CI environment SHA if available, otherwise fall back to current HEAD commit github_sha = os.getenv('GITHUB_SHA') gitlab_sha = os.getenv('CI_COMMIT_SHA') bitbucket_sha = os.getenv('BITBUCKET_COMMIT') - ci_sha = github_sha or gitlab_sha or bitbucket_sha + buildkite_sha = os.getenv('BUILDKITE_COMMIT') + ci_commits = ( + ("BUILDKITE_COMMIT", buildkite_sha), + ("GITHUB_SHA", github_sha), + ("CI_COMMIT_SHA", gitlab_sha), + ("BITBUCKET_COMMIT", bitbucket_sha), + ) + env_source, ci_sha = next( + ((source, sha) for source, sha in ci_commits if sha), + (None, None), + ) if ci_sha: try: self.commit = self.repo.commit(ci_sha) - if github_sha: - env_source = "GITHUB_SHA" - elif gitlab_sha: - env_source = "CI_COMMIT_SHA" - else: - env_source = "BITBUCKET_COMMIT" log.debug(f"Using commit from {env_source}: {ci_sha}") except Exception as error: log.debug(f"Failed to get commit from CI environment: {error}") @@ -82,13 +82,19 @@ def __init__(self, path: str): # Bitbucket Pipelines variables bitbucket_branch = os.getenv('BITBUCKET_BRANCH') + + # Buildkite branch (the source branch for pull-request builds) + buildkite_branch = os.getenv('BUILDKITE_BRANCH') - # Select CI branch with priority: GitLab -> GitHub -> Bitbucket - ci_branch = gitlab_branch or github_branch or bitbucket_branch + # Prefer the native environment when Buildkite is driving the job. This + # also avoids requiring Buildkite users to emulate GitHub Actions vars. + ci_branch = buildkite_branch or gitlab_branch or github_branch or bitbucket_branch if ci_branch: self.branch = ci_branch - if gitlab_branch: + if buildkite_branch: + env_source = "Buildkite" + elif gitlab_branch: env_source = "GitLab CI" elif github_branch: env_source = "GitHub Actions" @@ -141,40 +147,39 @@ def __init__(self, path: str): self.commit_sha = self.commit.binsha self.commit_message = self.commit.message self.committer = self.commit.committer - # Detect changed files in PR/MR context for GitHub, GitLab, Bitbucket; fallback to git show + + # Detect changed files in PR/MR context, using local refs first and + # fetching only a required ref when the checkout does not contain it. + changed_files_start = time.perf_counter() self.show_files = [] detected = False - # GitHub Actions PR context + detection_source = "single-commit" + github_base_ref = os.getenv('GITHUB_BASE_REF') github_head_ref = os.getenv('GITHUB_HEAD_REF') github_event_name = os.getenv('GITHUB_EVENT_NAME') github_before_sha = os.getenv('GITHUB_EVENT_BEFORE') # previous commit for push github_sha = os.getenv('GITHUB_SHA') # current commit - if github_event_name == 'pull_request' and github_base_ref and github_head_ref: - try: - # Fetch both branches individually - self.repo.git.fetch('origin', github_base_ref) - self.repo.git.fetch('origin', github_head_ref) - # Try remote diff first - diff_range = f"origin/{github_base_ref}...origin/{github_head_ref}" - try: - diff_files = self.repo.git.diff('--name-only', diff_range) - self.show_files = diff_files.splitlines() - log.debug(f"Changed files detected via git diff (GitHub PR remote): {self.show_files}") - detected = True - except Exception as remote_error: - log.debug(f"Remote diff failed: {remote_error}") - # Try local branch diff - local_diff_range = f"{github_base_ref}...{github_head_ref}" - try: - diff_files = self.repo.git.diff('--name-only', local_diff_range) - self.show_files = diff_files.splitlines() - log.debug(f"Changed files detected via git diff (GitHub PR local): {self.show_files}") - detected = True - except Exception as local_error: - log.debug(f"Local diff failed: {local_error}") - except Exception as error: - log.debug(f"Failed to fetch branches or diff for GitHub PR: {error}") + + buildkite_pr = os.getenv('BUILDKITE_PULL_REQUEST') + buildkite_base_ref = os.getenv('BUILDKITE_PULL_REQUEST_BASE_BRANCH') + buildkite_head_ref = os.getenv('BUILDKITE_BRANCH') + if self._is_buildkite_pull_request(buildkite_pr) and buildkite_base_ref: + detected = self._detect_pull_request_changes( + provider="Buildkite", + base_ref=buildkite_base_ref, + head_ref=buildkite_head_ref, + ) + if detected: + detection_source = "buildkite-pr" + elif github_event_name == 'pull_request' and github_base_ref: + detected = self._detect_pull_request_changes( + provider="GitHub", + base_ref=github_base_ref, + head_ref=github_head_ref, + ) + if detected: + detection_source = "github-pr" # Commits to default branch (push events) elif github_event_name == 'push' and github_before_sha and github_sha: try: @@ -182,6 +187,7 @@ def __init__(self, path: str): self.show_files = diff_files.splitlines() log.debug(f"Changed files detected via git diff (GitHub push): {self.show_files}") detected = True + detection_source = "github-push" except Exception as error: log.debug(f"Failed to get changed files via git diff (GitHub push): {error}") elif github_event_name == 'push': @@ -189,6 +195,7 @@ def __init__(self, path: str): self.show_files = self.repo.git.show(self.commit, name_only=True, format="%n").splitlines() log.debug(f"Changed files detected via git show (GitHub push fallback): {self.show_files}") detected = True + detection_source = "github-push-fallback" except Exception as error: log.debug(f"Failed to get changed files via git show (GitHub push fallback): {error}") # GitLab CI Merge Request context @@ -196,15 +203,13 @@ def __init__(self, path: str): gitlab_target = os.getenv('CI_MERGE_REQUEST_TARGET_BRANCH_NAME') gitlab_source = os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') if gitlab_target and gitlab_source: - try: - self.repo.git.fetch('origin', gitlab_target, gitlab_source) - diff_range = f"origin/{gitlab_target}...origin/{gitlab_source}" - diff_files = self.repo.git.diff('--name-only', diff_range) - self.show_files = diff_files.splitlines() - log.debug(f"Changed files detected via git diff (GitLab): {self.show_files}") - detected = True - except Exception as error: - log.debug(f"Failed to get changed files via git diff (GitLab): {error}") + detected = self._detect_pull_request_changes( + provider="GitLab", + base_ref=gitlab_target, + head_ref=gitlab_source, + ) + if detected: + detection_source = "gitlab-mr" # Bitbucket Pipelines PR context if not detected: bitbucket_pr_id = os.getenv('BITBUCKET_PR_ID') @@ -212,15 +217,13 @@ def __init__(self, path: str): bitbucket_dest = os.getenv('BITBUCKET_PR_DESTINATION_BRANCH') # BITBUCKET_BRANCH is the source branch in PR builds if bitbucket_pr_id and bitbucket_source and bitbucket_dest: - try: - self.repo.git.fetch('origin', bitbucket_dest, bitbucket_source) - diff_range = f"origin/{bitbucket_dest}...origin/{bitbucket_source}" - diff_files = self.repo.git.diff('--name-only', diff_range) - self.show_files = diff_files.splitlines() - log.debug(f"Changed files detected via git diff (Bitbucket): {self.show_files}") - detected = True - except Exception as error: - log.debug(f"Failed to get changed files via git diff (Bitbucket): {error}") + detected = self._detect_pull_request_changes( + provider="Bitbucket", + base_ref=bitbucket_dest, + head_ref=bitbucket_source, + ) + if detected: + detection_source = "bitbucket-pr" # Fallback to git show for single commit if not detected: # Check if this is a merge commit first @@ -233,20 +236,132 @@ def __init__(self, path: str): self.show_files = self.repo.git.show(self.commit, name_only=True, format="%n").splitlines() log.debug(f"Changed files detected via git show (merge commit fallback): {self.show_files}") detected = True + detection_source = "merge-commit-fallback" + if detected and detection_source == "single-commit": + detection_source = "merge-diff" else: # Regular single commit self.show_files = self.repo.git.show(self.commit, name_only=True, format="%n").splitlines() log.debug(f"Changed files detected via git show: {self.show_files}") detected = True + detection_source = "single-commit" self.changed_files = [] for item in self.show_files: if item != "": # Use relative path for glob matching self.changed_files.append(item) + + log.info( + "Changed-file detection completed in " + f"{time.perf_counter() - changed_files_start:.2f}s: " + f"source={detection_source}, files={len(self.changed_files)}" + ) # Determine if this commit is on the default branch # This considers both GitHub Actions detached HEAD and regular branch situations self.is_default_branch = self._is_commit_and_branch_default() + log.info( + "Git initialization completed in " + f"{time.perf_counter() - initialization_start:.2f}s" + ) + + @staticmethod + def _is_buildkite_pull_request(pull_request: str | None) -> bool: + return bool(pull_request and pull_request.casefold() != "false") + + def _resolve_ref(self, ref: str | None) -> str | None: + """Resolve a branch, tag, or SHA without accessing the network.""" + if not ref: + return None + if ref in self._fetched_ref_commits: + return self._fetched_ref_commits[ref] + + candidates = [ref] + if not ref.startswith("refs/"): + candidates = [f"origin/{ref}", ref] + for candidate in candidates: + try: + return self.repo.commit(candidate).hexsha + except Exception: + continue + return None + + def _fetch_ref(self, ref: str, reason: str) -> str | None: + """Fetch one required ref and return its commit without broadening scope.""" + if ref in self._fetched_ref_commits: + return self._fetched_ref_commits[ref] + + fetch_start = time.perf_counter() + try: + self.repo.git.fetch("origin", ref) + commit_sha = self.repo.commit("FETCH_HEAD").hexsha + self._fetched_ref_commits[ref] = commit_sha + log.info( + "Git fetch completed in " + f"{time.perf_counter() - fetch_start:.2f}s: " + f"remote=origin, ref={ref}, reason={reason}" + ) + return commit_sha + except Exception as error: + log.info( + "Git fetch failed in " + f"{time.perf_counter() - fetch_start:.2f}s: " + f"remote=origin, ref={ref}, reason={reason}" + ) + log.debug(f"Targeted fetch failed for {ref}: {error}") + return None + + def _detect_pull_request_changes( + self, + provider: str, + base_ref: str, + head_ref: str | None, + ) -> bool: + """Detect a full PR range locally, fetching only refs needed to complete it.""" + base_commit = self._resolve_ref(base_ref) + if base_commit is None: + base_commit = self._fetch_ref(base_ref, f"{provider} pull-request base ref missing") + if base_commit is None: + log.debug(f"Unable to resolve {provider} pull-request base ref: {base_ref}") + return False + + head_commit = self.commit.hexsha + diff_range = f"{base_commit}...{head_commit}" + try: + diff_files = self.repo.git.diff("--name-only", diff_range) + self.show_files = diff_files.splitlines() + log.debug( + f"Changed files detected via local git diff ({provider}): {self.show_files}" + ) + return True + except Exception as local_error: + log.debug(f"Local {provider} pull-request diff failed: {local_error}") + + # A shallow checkout can contain both tips but not their merge base. In + # that case refresh only the two relevant branch histories and retry. + base_commit = self._fetch_ref( + base_ref, + f"{provider} pull-request history incomplete", + ) or base_commit + if head_ref: + self._fetch_ref( + head_ref, + f"{provider} pull-request history incomplete", + ) + + try: + diff_files = self.repo.git.diff( + "--name-only", + f"{base_commit}...{head_commit}", + ) + self.show_files = diff_files.splitlines() + log.debug( + f"Changed files detected after targeted fetch ({provider}): {self.show_files}" + ) + return True + except Exception as retry_error: + log.debug(f"Targeted {provider} pull-request diff failed: {retry_error}") + return False def _is_commit_and_branch_default(self) -> bool: """ @@ -268,9 +383,29 @@ def _is_commit_and_branch_default(self) -> bool: gitlab_mr_branch = os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME') gitlab_default_branch = os.getenv('CI_DEFAULT_BRANCH', '') bitbucket_branch = os.getenv('BITBUCKET_BRANCH') + buildkite_branch = os.getenv('BUILDKITE_BRANCH') + buildkite_pr = os.getenv('BUILDKITE_PULL_REQUEST') + buildkite_default_branch = os.getenv('BUILDKITE_PIPELINE_DEFAULT_BRANCH') + # Handle Buildkite before GitHub because some Buildkite pipelines + # intentionally provide GitHub-compatible environment variables. + if buildkite_branch: + if self._is_buildkite_pull_request(buildkite_pr): + log.debug( + f"Processing Buildkite pull request from branch: {buildkite_branch}, " + "not default branch" + ) + return False + default_branch_name = buildkite_default_branch or self.get_default_branch_name() + is_default = buildkite_branch == default_branch_name + log.debug( + f"Buildkite branch: {buildkite_branch}, Default: {default_branch_name}, " + f"Is default: {is_default}" + ) + return is_default + # Handle GitHub Actions - if github_ref: + elif github_ref: log.debug(f"GitHub ref: {github_ref}") # Handle pull requests - they're not on the default branch @@ -483,7 +618,7 @@ def get_default_branch_name(self) -> str: if f'origin/{branch_name}' in [str(ref) for ref in self.repo.remotes.origin.refs]: log.debug(f"Using fallback default branch: {branch_name}") return branch_name - except: + except Exception: continue # Last fallback: assume 'main' @@ -505,12 +640,12 @@ def is_commit_on_default_branch(self) -> bool: # Try remote branch first default_branch_ref = self.repo.remotes.origin.refs[default_branch] default_branch_commit = default_branch_ref.commit - except: + except Exception: # Fallback to local branch try: default_branch_ref = self.repo.heads[default_branch] default_branch_commit = default_branch_ref.commit - except: + except Exception: log.debug(f"Could not find default branch '{default_branch}' locally or remotely") return False @@ -572,4 +707,4 @@ def ensure_safe_directory(path: str) -> None: else: log.debug(f"{abs_path} already present in git safe.directory config.") except Exception as safe_error: - log.debug(f"Failed to set safe.directory for git: {safe_error}") \ No newline at end of file + log.debug(f"Failed to set safe.directory for git: {safe_error}") diff --git a/socketsecurity/core/scm/github.py b/socketsecurity/core/scm/github.py index 7d5905d2..7504a46c 100644 --- a/socketsecurity/core/scm/github.py +++ b/socketsecurity/core/scm/github.py @@ -1,6 +1,7 @@ import json import os import sys +import urllib.parse from dataclasses import dataclass from git import Optional @@ -34,6 +35,31 @@ class GithubConfig: event_action: Optional[str] headers: dict + @staticmethod + def _repository_from_buildkite() -> tuple[str, str]: + """Return ``(owner, repository)`` from Buildkite's Git repository URL.""" + repository_url = ( + # Comments and statuses belong to the pipeline/base repository, + # not a contributor's fork from BUILDKITE_PULL_REQUEST_REPO. + os.getenv("BUILDKITE_REPO") + or os.getenv("BUILDKITE_PULL_REQUEST_REPO") + or "" + ).strip() + if not repository_url: + return "", "" + + if "://" in repository_url: + repository_path = urllib.parse.urlparse(repository_url).path + elif ":" in repository_url: + # SCP-style SSH URL: git@github.com:owner/repository.git + repository_path = repository_url.split(":", 1)[1] + else: + repository_path = repository_url + parts = repository_path.strip("/").removesuffix(".git").split("/") + if len(parts) < 2: + return "", "" + return parts[-2], parts[-1] + @classmethod def from_env(cls, pr_number: Optional[str] = None) -> 'GithubConfig': """Create config from environment variables with optional overrides""" @@ -42,12 +68,24 @@ def from_env(cls, pr_number: Optional[str] = None) -> 'GithubConfig': log.error("Unable to get Github API Token from GH_API_TOKEN") sys.exit(2) - # Use provided PR number if available, otherwise fall back to env var + is_buildkite = os.getenv("BUILDKITE") == "true" + buildkite_pr = os.getenv("BUILDKITE_PULL_REQUEST") + is_buildkite_pr = bool( + is_buildkite + and buildkite_pr + and buildkite_pr.casefold() != "false" + ) + + # Use explicit/GitHub-compatible values first, then native Buildkite PR context. pr_number = pr_number or os.getenv('PR_NUMBER') + if not pr_number and is_buildkite_pr: + pr_number = buildkite_pr # Add debug logging - sha = os.getenv('GITHUB_SHA', '') - log.debug(f"Loading SHA from GITHUB_SHA: {sha}") + sha = os.getenv('GITHUB_SHA') or ( + os.getenv("BUILDKITE_COMMIT", "") if is_buildkite else "" + ) + log.debug(f"Loading GitHub integration SHA: {sha}") event_action = os.getenv('EVENT_ACTION', None) if not event_action: event_path = os.getenv('GITHUB_EVENT_PATH') @@ -55,29 +93,66 @@ def from_env(cls, pr_number: Optional[str] = None) -> 'GithubConfig': with open(event_path, 'r') as f: event = json.load(f) event_action = event.get('action') + if not event_action and is_buildkite_pr: + # Buildkite provides the current PR state, not the originating + # GitHub webhook action. A running PR build is equivalent to the + # supported synchronize path for comment updates. + event_action = "synchronize" repository = os.getenv('GITHUB_REPOSITORY', '') owner = os.getenv('GITHUB_REPOSITORY_OWNER', '') if '/' in repository: owner = repository.split('/')[0] repository = repository.split('/')[1] + elif is_buildkite: + buildkite_owner, buildkite_repository = cls._repository_from_buildkite() + owner = owner or buildkite_owner + repository = repository or buildkite_repository default_branch_env = os.getenv('DEFAULT_BRANCH') # Consider the variable truthy if it exists and isn't explicitly 'false' - is_default = default_branch_env is not None and default_branch_env.lower() != 'false' + if default_branch_env is not None: + is_default = default_branch_env.lower() != 'false' + elif is_buildkite: + # Require a branch name: comparing two unset variables would otherwise report + # every build as the default branch and overwrite the repository's baseline. + buildkite_branch = os.getenv("BUILDKITE_BRANCH") + is_default = bool( + not is_buildkite_pr + and buildkite_branch + and buildkite_branch == os.getenv("BUILDKITE_PIPELINE_DEFAULT_BRANCH") + ) + else: + is_default = False + + event_name = os.getenv('GITHUB_EVENT_NAME', '') + if not event_name and is_buildkite: + event_name = "pull_request" if is_buildkite_pr else "push" return cls( - sha=os.getenv('GITHUB_SHA', ''), - api_url=os.getenv('GITHUB_API_URL', ''), - ref_type=os.getenv('GITHUB_REF_TYPE', ''), - event_name=os.getenv('GITHUB_EVENT_NAME', ''), - workspace=os.getenv('GITHUB_WORKSPACE', ''), + sha=sha, + api_url=os.getenv('GITHUB_API_URL') or ( + "https://api.github.com" if is_buildkite else "" + ), + ref_type=os.getenv('GITHUB_REF_TYPE') or ( + "branch" if is_buildkite else "" + ), + event_name=event_name, + workspace=os.getenv('GITHUB_WORKSPACE') or ( + os.getenv("BUILDKITE_BUILD_CHECKOUT_PATH", "") if is_buildkite else "" + ), repository=repository, - ref_name=os.getenv('GITHUB_REF_NAME', ''), + ref_name=os.getenv('GITHUB_REF_NAME') or ( + os.getenv("BUILDKITE_BRANCH", "") if is_buildkite else "" + ), default_branch=is_default, is_default_branch=is_default, pr_number=pr_number, pr_name=os.getenv('PR_NAME'), - commit_message=os.getenv('COMMIT_MESSAGE'), - actor=os.getenv('GITHUB_ACTOR', ''), + commit_message=os.getenv('COMMIT_MESSAGE') or ( + os.getenv("BUILDKITE_MESSAGE") if is_buildkite else None + ), + actor=os.getenv('GITHUB_ACTOR') or ( + os.getenv("BUILDKITE_BUILD_CREATOR", "") if is_buildkite else "" + ), env=os.getenv('GITHUB_ENV', ''), token=token, owner=owner, diff --git a/socketsecurity/core/streaming.py b/socketsecurity/core/streaming.py index 20b45eac..e6910b44 100644 --- a/socketsecurity/core/streaming.py +++ b/socketsecurity/core/streaming.py @@ -13,6 +13,7 @@ """ import logging +import time from typing import Optional from .cli_client import CliClient @@ -49,12 +50,17 @@ def set_report_run_id(self, report_run_id: Optional[str]) -> None: self._report_run_id = report_run_id def __enter__(self) -> "StreamingLogs": + registration_start = time.perf_counter() self._run_id = register_cli_run( self._client, client_version=self._client_version, upload_logs=self._upload_logs, ) cli_logger = self._loggers[0] + cli_logger.info( + "CLI run registration completed in " + f"{time.perf_counter() - registration_start:.2f}s" + ) if not self._run_id: cli_logger.debug("server log streaming not active for this run") return self diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 0d8bcccb..24e8e966 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -207,6 +207,8 @@ def main_code(): if dirs_to_include: core.config.excluded_dirs = set(core.config.excluded_dirs) - dirs_to_include log.debug(f"Re-including normally-excluded directories in scan: {sorted(dirs_to_include)}") + if config.excluded_ecosystems: + core.config.excluded_ecosystems = list(config.excluded_ecosystems) # Check for required dependencies if reachability analysis is enabled if config.reach: @@ -292,6 +294,9 @@ def main_code(): facts_file_to_submit = None # Variable to track SBOM files to submit when using --reach-use-only-pregenerated-sboms sbom_files_to_submit = None + # Manifest results retained from the --sub-path routing pre-check. Reusing + # these avoids walking every selected sub-path again during scan creation. + discovered_scan_files = None # Git setup is_repo = False @@ -534,14 +539,18 @@ def main_code(): # Override file checking to look in the scan paths instead # Get manifest files from all scan paths try: - all_scan_files = [] + discovered_scan_files = [] for scan_path in scan_paths: scan_files = core.find_files(scan_path) - all_scan_files.extend(scan_files) - has_supported_files = len(all_scan_files) > 0 - log.debug(f"Found {len(all_scan_files)} manifest files across {len(scan_paths)} scan paths") + discovered_scan_files.extend(scan_files) + has_supported_files = len(discovered_scan_files) > 0 + log.debug( + f"Found {len(discovered_scan_files)} manifest files across " + f"{len(scan_paths)} scan paths" + ) except Exception as e: log.debug(f"Error finding files in scan paths: {e}") + discovered_scan_files = None has_supported_files = False # Case 3: If no supported files or files are empty, force API mode (no PR comments) @@ -564,8 +573,6 @@ def main_code(): org_slug = core.config.org_slug if config.repo_is_public: core.config.repo_visibility = "public" - if config.excluded_ecosystems and len(config.excluded_ecosystems) > 0: - core.config.excluded_ecosystems = config.excluded_ecosystems integration_type = config.integration_type integration_org_slug = config.integration_org_slug or org_slug try: @@ -613,6 +620,12 @@ def main_code(): diff.diff_url = "" diff.report_url = "" + scan_explicit_files = ( + sbom_files_to_submit + if sbom_files_to_submit is not None + else discovered_scan_files + ) + # Handle SCM-specific flows log.debug(f"Flow decision: scm={scm is not None}, force_diff_mode={force_diff_mode}, force_api_mode={force_api_mode}, enable_diff={config.enable_diff}") @@ -684,7 +697,7 @@ def _is_unprocessed(c): log.info("Push initiated flow") if scm.check_event_type() == "diff": log.info("Starting comment logic for PR/MR event") - diff = core.create_new_diff(scan_paths, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar, base_paths=base_paths, explicit_files=sbom_files_to_submit) + diff = core.create_new_diff(scan_paths, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar, base_paths=base_paths, explicit_files=scan_explicit_files) comments = scm.get_comments_for_pr() # FIXME: this overwrites diff.new_alerts, which was previously populated by Core.create_issue_alerts @@ -807,14 +820,14 @@ def _is_unprocessed(c): ) else: log.info("Starting non-PR/MR flow") - diff = core.create_new_diff(scan_paths, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar, base_paths=base_paths, explicit_files=sbom_files_to_submit) + diff = core.create_new_diff(scan_paths, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar, base_paths=base_paths, explicit_files=scan_explicit_files) output_handler.handle_output(diff) elif (config.enable_diff or force_diff_mode) and not force_api_mode: # New logic: --enable-diff or force_diff_mode (from --ignore-commit-files in git repos) forces diff mode log.info("Diff mode enabled without SCM integration") - diff = core.create_new_diff(scan_paths, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar, base_paths=base_paths, explicit_files=sbom_files_to_submit) + diff = core.create_new_diff(scan_paths, params, no_change=should_skip_scan, save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar, base_paths=base_paths, explicit_files=scan_explicit_files) output_handler.handle_output(diff) elif (config.enable_diff or force_diff_mode) and force_api_mode: @@ -834,7 +847,7 @@ def _is_unprocessed(c): save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar, base_paths=base_paths, - explicit_files=sbom_files_to_submit + explicit_files=scan_explicit_files ) log.info(f"Full scan created with ID: {diff.id}") log.info(f"Full scan report URL: {diff.report_url}") @@ -842,7 +855,10 @@ def _is_unprocessed(c): else: if force_api_mode: - log.info("No Manifest files changed, creating Socket Report") + log.info( + "No supported manifest detected in the changed-file set; " + "creating a full Socket report" + ) serializable_params = { key: value if isinstance(value, (int, float, str, list, dict, bool, type(None))) else str(value) for key, value in params.__dict__.items() @@ -855,7 +871,7 @@ def _is_unprocessed(c): save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar, base_paths=base_paths, - explicit_files=sbom_files_to_submit + explicit_files=scan_explicit_files ) log.info(f"Full scan created with ID: {diff.id}") log.info(f"Full scan report URL: {diff.report_url}") @@ -868,7 +884,7 @@ def _is_unprocessed(c): save_files_list_path=config.save_submitted_files_list, save_manifest_tar_path=config.save_manifest_tar, base_paths=base_paths, - explicit_files=sbom_files_to_submit + explicit_files=scan_explicit_files ) output_handler.handle_output(diff) diff --git a/tests/core/test_diff_scan_polling.py b/tests/core/test_diff_scan_polling.py index be369429..d8c0e396 100644 --- a/tests/core/test_diff_scan_polling.py +++ b/tests/core/test_diff_scan_polling.py @@ -104,6 +104,63 @@ def test_duplicate_conflict_uses_cached_polling(core, diff_scan_get_response): assert len(artifacts.added) > 0 +def test_eager_create_artifacts_do_not_bypass_filtered_get(core, diff_scan_get_response): + """Unexpected create artifacts are ignored so the filtered GET remains canonical.""" + from types import SimpleNamespace + + core.cli_config = SimpleNamespace( + strict_blocking=False, + enable_gitlab_security=False, + generate_license=False, + legal_format="socket", + ) + core.sdk.diffscans.create_from_ids.return_value = { + "diff_scan": { + "id": "diff-scan-123", + "artifacts": diff_scan_get_response["diff_scan"]["artifacts"], + } + } + + core.get_diff_scan_artifacts("head", "new") + + core.sdk.diffscans.get.assert_called_once_with( + core.config.org_slug, + "diff-scan-123", + params={"cached": "true", "omit_unchanged": "true"}, + ) + + +def test_eager_list_artifacts_do_not_bypass_filtered_get(core, diff_scan_get_response): + """Unexpected duplicate-list artifacts cannot skip the filtered GET either.""" + from types import SimpleNamespace + + core.cli_config = SimpleNamespace( + strict_blocking=False, + enable_gitlab_security=False, + generate_license=False, + legal_format="socket", + ) + core.sdk.diffscans.create_from_ids.side_effect = APIFailure( + "duplicate", status_code=409 + ) + core.sdk.diffscans.list.return_value = { + "results": [ + { + "id": "existing-diff-scan", + "artifacts": diff_scan_get_response["diff_scan"]["artifacts"], + } + ], + } + + core.get_diff_scan_artifacts("head", "new") + + core.sdk.diffscans.get.assert_called_once_with( + core.config.org_slug, + "existing-diff-scan", + params={"cached": "true", "omit_unchanged": "true"}, + ) + + def test_fallback_to_streaming_diff_on_failure(core): """If the diff-scans flow fails (e.g. token missing the diff-scans scopes), the comparison falls back to the legacy streaming diff transparently.""" @@ -120,3 +177,98 @@ def test_fallback_to_streaming_diff_on_failure(core): ) assert "dp3" in added assert "dp2" in removed + + +def test_completion_log_reports_id_polls_and_final_wait( + core, diff_scan_get_response, no_sleep, caplog, monkeypatch +): + """The completion log must let a CI log separate backend compute time from the + time a finished comparison sat unnoticed between polls.""" + import logging + + monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS", 4.0) + monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS", 6.0) + processing = {"status": "processing", "id": "diff-scan-123"} + core.sdk.diffscans.get.side_effect = [processing, processing, diff_scan_get_response] + + with caplog.at_level(logging.INFO, logger="socketdev"): + core.get_diff_scan_artifacts("head", "new") + + messages = [record.message for record in caplog.records] + assert any("Diff scan created: id=" in message for message in messages) + ready = next(message for message in messages if "Diff scan comparison ready" in message) + assert "polls=3" in ready + # Waits were 4s then 6s (capped); the final poll followed the 6s wait, which is + # the upper bound on how long the result was ready before being observed. + assert "wait_before_final_poll=6s" in ready + + +def test_max_poll_interval_bounds_dead_time_for_ci_budgets(): + """A finished comparison is never left unobserved longer than the max interval.""" + assert core_module.DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS <= 10.0 + assert ( + core_module.DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS + <= core_module.DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS + ) + + +UNCHANGED_ARTIFACT_CONSUMERS = [ + # flag name, value that makes the flag active + ("strict_blocking", True), + ("enable_gitlab_security", True), + ("generate_license", True), + ("legal_format", "fossa"), +] + + +@pytest.mark.parametrize(("flag", "value"), UNCHANGED_ARTIFACT_CONSUMERS) +def test_unchanged_artifacts_gating(core, diff_scan_get_response, flag, value): + """Any output that reads unchanged artifacts must keep them in the response. + + This pins the consumer list in Core._requires_unchanged_artifacts: adding a new + reader of diff.unchanged_alerts or diff.packages without adding it here (and to + that method) would silently ship an empty result to that output. + """ + from types import SimpleNamespace + + defaults = {name: (False if name != "legal_format" else "socket") + for name, _ in UNCHANGED_ARTIFACT_CONSUMERS} + core.cli_config = SimpleNamespace(**{**defaults, flag: value}) + core.sdk.diffscans.get.side_effect = None + core.sdk.diffscans.get.return_value = diff_scan_get_response + + core.get_diff_scan_artifacts("head", "new") + + params = core.sdk.diffscans.get.call_args.kwargs["params"] + assert "omit_unchanged" not in params, f"{flag}={value} still needs unchanged artifacts" + + +def test_unchanged_artifacts_omitted_when_no_output_reads_them(core, diff_scan_get_response): + """With no such flag set, the ~1 KB-per-artifact unchanged half is not fetched.""" + from types import SimpleNamespace + + core.cli_config = SimpleNamespace( + strict_blocking=False, + enable_gitlab_security=False, + generate_license=False, + legal_format="socket", + ) + core.sdk.diffscans.get.side_effect = None + core.sdk.diffscans.get.return_value = diff_scan_get_response + + core.get_diff_scan_artifacts("head", "new") + + params = core.sdk.diffscans.get.call_args.kwargs["params"] + assert params["cached"] == "true" + assert params["omit_unchanged"] == "true" + + +def test_unknown_caller_keeps_full_payload(core, diff_scan_get_response): + """cli_config is optional; without it, do not assume unchanged is unused.""" + core.cli_config = None + core.sdk.diffscans.get.side_effect = None + core.sdk.diffscans.get.return_value = diff_scan_get_response + + core.get_diff_scan_artifacts("head", "new") + + assert "omit_unchanged" not in core.sdk.diffscans.get.call_args.kwargs["params"] diff --git a/tests/unit/test_git_interface.py b/tests/unit/test_git_interface.py new file mode 100644 index 00000000..a22cf634 --- /dev/null +++ b/tests/unit/test_git_interface.py @@ -0,0 +1,251 @@ +import logging +import subprocess +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from socketsecurity.core.git_interface import Git + +CI_ENVIRONMENT_VARIABLES = ( + "BUILDKITE", + "BUILDKITE_BRANCH", + "BUILDKITE_COMMIT", + "BUILDKITE_PIPELINE_DEFAULT_BRANCH", + "BUILDKITE_PULL_REQUEST", + "BUILDKITE_PULL_REQUEST_BASE_BRANCH", + "GITHUB_BASE_REF", + "GITHUB_EVENT_BEFORE", + "GITHUB_EVENT_NAME", + "GITHUB_HEAD_REF", + "GITHUB_REF", + "GITHUB_SHA", + "CI_COMMIT_BRANCH", + "CI_COMMIT_SHA", + "CI_DEFAULT_BRANCH", + "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME", + "CI_MERGE_REQUEST_TARGET_BRANCH_NAME", + "BITBUCKET_BRANCH", + "BITBUCKET_COMMIT", + "BITBUCKET_PR_DESTINATION_BRANCH", + "BITBUCKET_PR_ID", +) + + +@pytest.fixture(autouse=True) +def clear_ci_environment(monkeypatch): + for variable in CI_ENVIRONMENT_VARIABLES: + monkeypatch.delenv(variable, raising=False) + + +def _git(path, *args): + return subprocess.run( + ["git", *args], + cwd=path, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +@pytest.fixture +def pull_request_repo(tmp_path): + path = tmp_path / "repo" + path.mkdir() + _git(path, "init", "-b", "main") + _git(path, "config", "user.name", "Socket Test") + _git(path, "config", "user.email", "socket@example.com") + (path / "README.md").write_text("base\n", encoding="utf-8") + _git(path, "add", "README.md") + _git(path, "commit", "-m", "base") + _git(path, "checkout", "-b", "feature") + (path / "package.json").write_text("{}\n", encoding="utf-8") + _git(path, "add", "package.json") + _git(path, "commit", "-m", "add manifest") + return path + + +@pytest.mark.parametrize( + ("environment", "expected_branch", "expected_source"), + [ + ( + { + "BUILDKITE": "true", + "BUILDKITE_BRANCH": "feature", + "BUILDKITE_PULL_REQUEST": "123", + "BUILDKITE_PULL_REQUEST_BASE_BRANCH": "main", + }, + "feature", + "buildkite-pr", + ), + ( + { + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_BASE_REF": "main", + "GITHUB_HEAD_REF": "feature", + "GITHUB_REF": "refs/pull/123/merge", + }, + "feature", + "github-pr", + ), + ( + { + "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME": "feature", + "CI_MERGE_REQUEST_TARGET_BRANCH_NAME": "main", + }, + "feature", + "gitlab-mr", + ), + ( + { + "BITBUCKET_BRANCH": "feature", + "BITBUCKET_PR_DESTINATION_BRANCH": "main", + "BITBUCKET_PR_ID": "123", + }, + "feature", + "bitbucket-pr", + ), + ], +) +def test_pull_request_context_uses_local_refs_without_fetch( + pull_request_repo, monkeypatch, mocker, caplog, + environment, expected_branch, expected_source, +): + head_sha = _git(pull_request_repo, "rev-parse", "HEAD") + sha_variable = { + "buildkite-pr": "BUILDKITE_COMMIT", + "github-pr": "GITHUB_SHA", + "gitlab-mr": "CI_COMMIT_SHA", + "bitbucket-pr": "BITBUCKET_COMMIT", + }[expected_source] + environment[sha_variable] = head_sha + for name, value in environment.items(): + monkeypatch.setenv(name, value) + + fetch = mocker.patch.object( + Git, + "_fetch_ref", + side_effect=AssertionError("unexpected fetch"), + ) + mocker.patch.object(Git, "ensure_safe_directory") + + with caplog.at_level(logging.INFO, logger="socketdev"): + repository = Git(str(pull_request_repo)) + + assert repository.branch == expected_branch + assert repository.changed_files == ["package.json"] + assert repository.is_default_branch is False + fetch.assert_not_called() + assert any( + f"source={expected_source}" in record.message + for record in caplog.records + ) + assert any( + "Git initialization completed" in record.message + for record in caplog.records + ) + + +def test_buildkite_native_context_wins_over_github_compatibility_shims( + pull_request_repo, monkeypatch, mocker +): + head_sha = _git(pull_request_repo, "rev-parse", "HEAD") + monkeypatch.setenv("BUILDKITE", "true") + monkeypatch.setenv("BUILDKITE_BRANCH", "feature") + monkeypatch.setenv("BUILDKITE_COMMIT", head_sha) + monkeypatch.setenv("BUILDKITE_PULL_REQUEST", "123") + monkeypatch.setenv("BUILDKITE_PULL_REQUEST_BASE_BRANCH", "main") + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request") + monkeypatch.setenv("GITHUB_BASE_REF", "wrong-base") + monkeypatch.setenv("GITHUB_HEAD_REF", "wrong-head") + mocker.patch.object( + Git, + "_fetch_ref", + side_effect=AssertionError("unexpected fetch"), + ) + mocker.patch.object(Git, "ensure_safe_directory") + + repository = Git(str(pull_request_repo)) + + assert repository.changed_files == ["package.json"] + + +def test_regular_initialization_never_fetches_all(pull_request_repo, mocker): + fetch = mocker.patch.object( + Git, + "_fetch_ref", + side_effect=AssertionError("unexpected fetch"), + ) + mocker.patch.object(Git, "ensure_safe_directory") + + repository = Git(str(pull_request_repo)) + + assert repository.commit_str == _git(pull_request_repo, "rev-parse", "HEAD") + assert repository.changed_files == ["package.json"] + fetch.assert_not_called() + + +def test_detached_head_uses_buildkite_branch_and_commit(pull_request_repo, monkeypatch, mocker): + head_sha = _git(pull_request_repo, "rev-parse", "HEAD") + _git(pull_request_repo, "checkout", "--detach", head_sha) + monkeypatch.setenv("BUILDKITE", "true") + monkeypatch.setenv("BUILDKITE_BRANCH", "feature") + monkeypatch.setenv("BUILDKITE_COMMIT", head_sha) + monkeypatch.setenv("BUILDKITE_PULL_REQUEST", "123") + monkeypatch.setenv("BUILDKITE_PULL_REQUEST_BASE_BRANCH", "main") + mocker.patch.object( + Git, + "_fetch_ref", + side_effect=AssertionError("unexpected fetch"), + ) + mocker.patch.object(Git, "ensure_safe_directory") + + repository = Git(str(pull_request_repo)) + + assert repository.commit_str == head_sha + assert repository.branch == "feature" + assert repository.changed_files == ["package.json"] + + +def test_missing_base_ref_fetches_only_that_ref( + pull_request_repo, monkeypatch, mocker, caplog +): + head_sha = _git(pull_request_repo, "rev-parse", "HEAD") + monkeypatch.setenv("BUILDKITE_BRANCH", "feature") + monkeypatch.setenv("BUILDKITE_COMMIT", head_sha) + monkeypatch.setenv("BUILDKITE_PULL_REQUEST", "123") + monkeypatch.setenv("BUILDKITE_PULL_REQUEST_BASE_BRANCH", "remote-main") + mocker.patch.object(Git, "ensure_safe_directory") + base_sha = _git(pull_request_repo, "rev-parse", "main") + fetch = mocker.patch.object(Git, "_fetch_ref", return_value=base_sha) + + with caplog.at_level(logging.INFO, logger="socketdev"): + repository = Git(str(pull_request_repo)) + + fetch.assert_called_once_with( + "remote-main", + "Buildkite pull-request base ref missing", + ) + assert repository.changed_files == ["package.json"] + + +def test_targeted_fetch_never_uses_all(): + repository = Git.__new__(Git) + repository.repo = MagicMock() + repository._fetched_ref_commits = {} + main_sha = "a" * 40 + repository.repo.commit.return_value = SimpleNamespace(hexsha=main_sha) + + result = repository._fetch_ref("main", "test") + + repository.repo.git.fetch.assert_called_once_with("origin", "main") + assert "--all" not in repository.repo.git.fetch.call_args.args + assert result == main_sha + + +@pytest.mark.parametrize( + ("value", "expected"), + [(None, False), ("", False), ("false", False), ("False", False), ("0", True), ("123", True)], +) +def test_buildkite_pull_request_detection(value, expected): + assert Git._is_buildkite_pull_request(value) is expected diff --git a/tests/unit/test_github_buildkite_config.py b/tests/unit/test_github_buildkite_config.py new file mode 100644 index 00000000..23f41507 --- /dev/null +++ b/tests/unit/test_github_buildkite_config.py @@ -0,0 +1,173 @@ +import pytest + +from socketsecurity.core.scm.github import Github, GithubConfig + +CONTEXT_VARIABLES = ( + "BUILDKITE", + "BUILDKITE_BRANCH", + "BUILDKITE_BUILD_CHECKOUT_PATH", + "BUILDKITE_BUILD_CREATOR", + "BUILDKITE_COMMIT", + "BUILDKITE_MESSAGE", + "BUILDKITE_PIPELINE_DEFAULT_BRANCH", + "BUILDKITE_PULL_REQUEST", + "BUILDKITE_PULL_REQUEST_REPO", + "BUILDKITE_REPO", + "DEFAULT_BRANCH", + "EVENT_ACTION", + "GH_API_TOKEN", + "GITHUB_ACTOR", + "GITHUB_API_URL", + "GITHUB_EVENT_NAME", + "GITHUB_EVENT_PATH", + "GITHUB_REF_NAME", + "GITHUB_REF_TYPE", + "GITHUB_REPOSITORY", + "GITHUB_REPOSITORY_OWNER", + "GITHUB_SHA", + "GITHUB_WORKSPACE", + "PR_NUMBER", +) + + +@pytest.fixture(autouse=True) +def clear_context(monkeypatch): + for variable in CONTEXT_VARIABLES: + monkeypatch.delenv(variable, raising=False) + monkeypatch.setenv("GH_API_TOKEN", "test-token") + + +def test_github_config_uses_native_buildkite_pull_request_context(monkeypatch): + values = { + "BUILDKITE": "true", + "BUILDKITE_BRANCH": "feature/socket", + "BUILDKITE_BUILD_CHECKOUT_PATH": "/workspace/repo", + "BUILDKITE_BUILD_CREATOR": "octocat", + "BUILDKITE_COMMIT": "a" * 40, + "BUILDKITE_MESSAGE": "Update dependencies", + "BUILDKITE_PIPELINE_DEFAULT_BRANCH": "main", + "BUILDKITE_PULL_REQUEST": "123", + "BUILDKITE_PULL_REQUEST_REPO": "git@github.com:acme/widgets.git", + "BUILDKITE_REPO": "git@github.com:acme/widgets.git", + } + for name, value in values.items(): + monkeypatch.setenv(name, value) + + config = GithubConfig.from_env() + + assert config.sha == "a" * 40 + assert config.api_url == "https://api.github.com" + assert config.ref_type == "branch" + assert config.event_name == "pull_request" + assert config.event_action == "synchronize" + assert config.workspace == "/workspace/repo" + assert config.owner == "acme" + assert config.repository == "widgets" + assert config.ref_name == "feature/socket" + assert config.pr_number == "123" + assert config.commit_message == "Update dependencies" + assert config.actor == "octocat" + assert config.is_default_branch is False + assert Github(client=object(), config=config).check_event_type() == "diff" + + +def test_buildkite_non_pr_build_uses_push_and_default_branch(monkeypatch): + values = { + "BUILDKITE": "true", + "BUILDKITE_BRANCH": "main", + "BUILDKITE_COMMIT": "b" * 40, + "BUILDKITE_PIPELINE_DEFAULT_BRANCH": "main", + "BUILDKITE_PULL_REQUEST": "false", + "BUILDKITE_REPO": "https://github.com/acme/widgets.git", + } + for name, value in values.items(): + monkeypatch.setenv(name, value) + + config = GithubConfig.from_env() + + assert config.event_name == "push" + assert config.pr_number is None + assert config.owner == "acme" + assert config.repository == "widgets" + assert config.is_default_branch is True + assert Github(client=object(), config=config).check_event_type() == "main" + + +@pytest.mark.parametrize( + "branch_variables", + [ + {}, + {"BUILDKITE_BRANCH": "feature/socket"}, + {"BUILDKITE_PIPELINE_DEFAULT_BRANCH": "main"}, + ], +) +def test_buildkite_default_branch_requires_a_matching_branch_name( + monkeypatch, branch_variables +): + """Absent branch context must not be read as 'this build is the default branch'.""" + monkeypatch.setenv("BUILDKITE", "true") + for name, value in branch_variables.items(): + monkeypatch.setenv(name, value) + + config = GithubConfig.from_env() + + assert config.is_default_branch is False + assert config.default_branch is False + + +def test_explicit_github_values_take_priority_in_buildkite(monkeypatch): + values = { + "BUILDKITE": "true", + "BUILDKITE_BRANCH": "buildkite-branch", + "BUILDKITE_COMMIT": "b" * 40, + "BUILDKITE_PULL_REQUEST": "123", + "BUILDKITE_REPO": "git@github.com:buildkite/repository.git", + "EVENT_ACTION": "opened", + "GITHUB_API_URL": "https://github.example/api/v3", + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_REF_NAME": "github-branch", + "GITHUB_REF_TYPE": "branch", + "GITHUB_REPOSITORY": "github/repository", + "GITHUB_SHA": "c" * 40, + "GITHUB_WORKSPACE": "/github/workspace", + "PR_NUMBER": "456", + } + for name, value in values.items(): + monkeypatch.setenv(name, value) + + config = GithubConfig.from_env() + + assert config.sha == "c" * 40 + assert config.api_url == "https://github.example/api/v3" + assert config.workspace == "/github/workspace" + assert config.owner == "github" + assert config.repository == "repository" + assert config.ref_name == "github-branch" + assert config.pr_number == "456" + assert config.event_action == "opened" + + +@pytest.mark.parametrize( + ("repository_url", "expected"), + [ + ("git@github.com:acme/widgets.git", ("acme", "widgets")), + ("https://github.com/acme/widgets.git", ("acme", "widgets")), + ("ssh://git@github.com/acme/widgets.git", ("acme", "widgets")), + ("", ("", "")), + ("not-a-repository", ("", "")), + ], +) +def test_buildkite_repository_url_parsing(monkeypatch, repository_url, expected): + monkeypatch.setenv("BUILDKITE_REPO", repository_url) + + assert GithubConfig._repository_from_buildkite() == expected + + +def test_buildkite_pipeline_repository_wins_over_pull_request_fork(monkeypatch): + monkeypatch.setenv("BUILDKITE_REPO", "git@github.com:acme/widgets.git") + monkeypatch.setenv( + "BUILDKITE_PULL_REQUEST_REPO", + "git@github.com:contributor/widgets.git", + ) + + assert GithubConfig._repository_from_buildkite() == ("acme", "widgets") diff --git a/tests/unit/test_manifest_discovery.py b/tests/unit/test_manifest_discovery.py new file mode 100644 index 00000000..2bdc2bbd --- /dev/null +++ b/tests/unit/test_manifest_discovery.py @@ -0,0 +1,349 @@ +import logging +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from socketsecurity.core import Core +from socketsecurity.core.socket_config import SocketConfig, default_exclude_dirs +from socketsecurity.core.utils import socket_globs + + +def _make_core(*, patterns=socket_globs, excluded_dirs=None, exclude_paths=None): + core = Core.__new__(Core) + core.config = SocketConfig( + api_key="test-key", + excluded_dirs=set(default_exclude_dirs if excluded_dirs is None else excluded_dirs), + ) + core.cli_config = SimpleNamespace(exclude_paths=exclude_paths) + core.sdk = MagicMock() + core._supported_patterns = patterns + return core + + +def _write_files(root: Path, relative_paths): + for relative_path in relative_paths: + target = root / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("test\n", encoding="utf-8") + + +def _relative_results(root: Path, results): + return {Path(result).relative_to(root).as_posix() for result in results} + + +ALL_PATTERN_EXAMPLES = { + "app.spdx.json", + "bom.json", + "nested/app-cdx.json", + "nested/app-cyclonedx.xml", + "package.json", + "nested/package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "pnpm-lock.yaml", + "pnpm-lock.yml", + "pnpm-workspace.yaml", + "pnpm-workspace.yml", + "bun.lock", + "bun.lockb", + "vlt-lock.json", + "PIPFILE", + "pyproject.toml", + "poetry.lock", + "requirements.txt", + "dev-requirements.txt", + "requirements-dev.txt", + "requirements_test.txt", + "requirements.frozen", + "requirements/base.txt", + "nested/requirements/constraints.txt", + "setup.py", + "go.mod", + "go.sum", + "pom.xml", + "src/Project.CSPROJ", + "Directory.Build.Props", + "build.targets", + "project.nuspec", + "nuget.CONFIG", + "packages.config", + "packages.lock.json", +} + + +def test_all_builtin_manifest_patterns_match_in_one_walk(tmp_path, mocker): + _write_files( + tmp_path, + ALL_PATTERN_EXAMPLES + | { + "README.md", + "requirements/deep/not-a-direct-child.txt", + "src/package.json.backup", + }, + ) + original_walk = os.walk + walk = mocker.patch("socketsecurity.core.os.walk", wraps=original_walk) + + found = _relative_results(tmp_path, _make_core().find_files(str(tmp_path))) + + assert found == ALL_PATTERN_EXAMPLES + walk.assert_called_once() + + +def test_single_walk_matches_legacy_rglob_results_for_builtin_patterns(tmp_path): + _write_files( + tmp_path, + ALL_PATTERN_EXAMPLES + | { + ".hidden/package.json", + "nested/Requirements.TXT", + "src/not-a-manifest.json", + }, + ) + core = _make_core(excluded_dirs=set()) + + legacy_results = set() + for ecosystem_patterns in socket_globs.values(): + for details in ecosystem_patterns.values(): + for expanded in Core.expand_brace_pattern(details["pattern"]): + case_insensitive = Core.to_case_insensitive_regex(expanded) + for result in tmp_path.rglob(case_insensitive): + if result.is_file(): + legacy_results.add(result.as_posix()) + + assert set(core.find_files(str(tmp_path))) == legacy_results + + +def test_directory_only_pattern_does_not_match_same_named_file(tmp_path): + """A trailing slash keeps pathlib.rglob's directory-only semantics.""" + _write_files( + tmp_path, + { + "manifests/package.json", + "nested/manifests", + }, + ) + patterns = { + "test": { + "directory-only": {"pattern": "manifests/"}, + }, + } + + assert _make_core(patterns=patterns).find_files(str(tmp_path)) == [] + + +def test_prunes_git_default_globs_and_exclude_paths_before_descent( + tmp_path, mocker, caplog +): + _write_files( + tmp_path, + { + "package.json", + ".git/objects/package.json", + "node_modules/pkg/package.json", + "generated.egg-info/package.json", + "legacy/nested/package.json", + ".hidden/package.json", + }, + ) + scanned_directories = [] + original_scandir = os.scandir + + def tracking_scandir(path): + scanned_directories.append(Path(path).relative_to(tmp_path).as_posix()) + return original_scandir(path) + + mocker.patch("socketsecurity.core.os.scandir", side_effect=tracking_scandir) + core = _make_core(exclude_paths=["legacy"]) + + with caplog.at_level(logging.INFO, logger="socketdev"): + found = _relative_results(tmp_path, core.find_files(str(tmp_path))) + + assert found == {"package.json", ".hidden/package.json"} + assert ".git" not in scanned_directories + assert "node_modules" not in scanned_directories + assert "generated.egg-info" not in scanned_directories + assert "legacy" not in scanned_directories + assert any( + "directories_pruned=4" in record.message + and "manifests_found=2" in record.message + for record in caplog.records + ) + + +def test_include_dirs_and_excluded_ecosystems_are_preserved(tmp_path): + _write_files( + tmp_path, + { + "build/package.json", + "build/requirements.txt", + "dist/package.json", + }, + ) + core = _make_core(excluded_dirs=set(default_exclude_dirs) - {"build"}) + core.config.excluded_ecosystems = ["npm"] + + found = _relative_results(tmp_path, core.find_files(str(tmp_path))) + + assert found == {"build/requirements.txt"} + + +def test_excluding_every_ecosystem_skips_the_filesystem_walk(tmp_path, mocker): + core = _make_core() + core.config.excluded_ecosystems = list(socket_globs) + walk = mocker.patch( + "socketsecurity.core.os.walk", + side_effect=AssertionError("unexpected walk"), + ) + + assert core.find_files(str(tmp_path)) == [] + walk.assert_not_called() + + +def test_symlinked_file_is_included_but_symlinked_directory_is_not_followed(tmp_path): + if not hasattr(os, "symlink"): + pytest.skip("symlinks are not supported") + + source_file = tmp_path / "source.txt" + source_file.write_text("{}", encoding="utf-8") + source_directory = tmp_path / "external" + _write_files(source_directory, {"package.json"}) + try: + (tmp_path / "package.json").symlink_to(source_file) + (tmp_path / "linked-directory").symlink_to(source_directory, target_is_directory=True) + except OSError as error: + pytest.skip(f"symlinks are unavailable: {error}") + + found = _relative_results(tmp_path, _make_core().find_files(str(tmp_path))) + + assert "package.json" in found + assert "linked-directory/package.json" not in found + assert "external/package.json" in found + + +def test_supported_patterns_are_cached_without_mutating_sdk_response(): + response = { + "general": {"ignored": {"pattern": "ignored"}}, + "npm": {"package.json": {"pattern": "package.json"}}, + } + core = _make_core(patterns=None) + core.sdk.report.supported.return_value = response + + first = core.get_supported_patterns() + second = core.get_supported_patterns() + + assert first is second + assert first == {"npm": {"package.json": {"pattern": "package.json"}}} + assert "general" in response + core.sdk.report.supported.assert_called_once_with() + + +def test_failed_pattern_lookup_is_not_cached(): + """A transient API failure must not pin the run to the smaller local fallback.""" + api_response = {"npm": {"package.json": {"pattern": "package.json"}}} + core = _make_core(patterns=None) + core.sdk.report.supported.side_effect = [None, api_response] + + fallback = core.get_supported_patterns() + assert set(fallback) == set(socket_globs) + + recovered = core.get_supported_patterns() + assert set(recovered) == {"npm"} + # The successful lookup is still cached, so the API is not re-queried again. + assert core.get_supported_patterns() is recovered + assert core.sdk.report.supported.call_count == 2 + + +def test_basename_prefilter_admits_every_supported_manifest(): + """The cheap prefilter must never reject a path the authoritative matcher accepts.""" + patterns = Core._prepare_manifest_patterns(socket_globs, None, []) + + for relative_path in ALL_PATTERN_EXAMPLES: + basename = relative_path.rsplit("/", 1)[-1].casefold() + assert Core._matches_manifest_pattern(relative_path, patterns), relative_path + assert Core._basename_could_match(basename, patterns), relative_path + + +def test_results_are_sorted_and_deduplicated_across_overlapping_patterns(tmp_path): + _write_files(tmp_path, {"z/package.json", "a/package.json"}) + overlapping_patterns = { + "npm": { + "literal": {"pattern": "package.json"}, + "wildcard": {"pattern": "package*.json"}, + } + } + + found = _make_core(patterns=overlapping_patterns).find_files(str(tmp_path)) + + assert found == sorted(found) + assert len(found) == 2 + + +def test_explicit_discovery_results_prevent_a_second_walk(tmp_path): + manifest = tmp_path / "package.json" + manifest.write_text("{}", encoding="utf-8") + core = _make_core() + core.config.org_slug = "example" + core.cli_config = None + core.find_files = MagicMock(side_effect=AssertionError("unexpected second walk")) + core.create_full_scan = MagicMock(return_value=SimpleNamespace(id="scan-123")) + params = MagicMock() + + diff = core.create_full_scan_with_report_url( + [str(tmp_path)], + params, + explicit_files=[manifest.as_posix()], + ) + + core.find_files.assert_not_called() + core.create_full_scan.assert_called_once_with( + [manifest.as_posix()], + params, + base_paths=None, + ) + assert diff.id == "scan-123" + + +def test_core_initialization_logs_organization_timing(caplog): + sdk = MagicMock() + sdk.org.get.return_value = { + "organizations": {"org-id": {"slug": "example"}}, + } + + with caplog.at_level(logging.INFO, logger="socketdev"): + core = Core(SocketConfig(api_key="test-key"), sdk) + + assert core.config.org_slug == "example" + assert any( + "Organization initialization completed" in record.message + for record in caplog.records + ) + + +def test_discovery_does_not_build_a_repository_sized_index(tmp_path): + """Peak memory must stay bounded by the widest directory and the result set, not + by repository size. This is the property that keeps discovery viable on small + runners; the per-pattern rglob approach it replaced allocated strictly more. + """ + import tracemalloc + + wide_directory = tmp_path / "wide" + wide_directory.mkdir() + for index in range(20000): + (wide_directory / f"source{index:05d}.ts").write_text("x", encoding="utf-8") + (tmp_path / "package.json").write_text("{}", encoding="utf-8") + + core = _make_core() + tracemalloc.start() + try: + found = core.find_files(str(tmp_path)) + _, peak_bytes = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + + assert _relative_results(tmp_path, found) == {"package.json"} + # 20k files in one directory; a repo-sized index would be far larger than this. + assert peak_bytes < 8_000_000, f"peak allocation was {peak_bytes / 1e6:.1f} MB" diff --git a/tests/unit/test_streaming.py b/tests/unit/test_streaming.py index 999b44a8..3b9cbd9b 100644 --- a/tests/unit/test_streaming.py +++ b/tests/unit/test_streaming.py @@ -18,14 +18,19 @@ def _make(**overrides): return StreamingLogs(**kwargs) -def test_setup_streaming_is_noop_when_register_fails(): +def test_setup_streaming_is_noop_when_register_fails(caplog): finalize_calls = [] - with patch("socketsecurity.core.streaming.register_cli_run", return_value=None), \ - patch("socketsecurity.core.streaming.finalize_cli_run", side_effect=lambda *a, **k: finalize_calls.append(k)): - with _make(cli_name="t-fail-cli", sdk_name="t-fail-sdk") as streaming: - assert isinstance(streaming, StreamingLogs) + with caplog.at_level(logging.INFO, logger="t-fail-cli"): + with patch("socketsecurity.core.streaming.register_cli_run", return_value=None), \ + patch("socketsecurity.core.streaming.finalize_cli_run", side_effect=lambda *a, **k: finalize_calls.append(k)): + with _make(cli_name="t-fail-cli", sdk_name="t-fail-sdk") as streaming: + assert isinstance(streaming, StreamingLogs) # No run was registered → finalize must not be called. assert finalize_calls == [] + assert any( + "CLI run registration completed" in record.message + for record in caplog.records + ) def test_clean_exit_reports_success(): diff --git a/uv.lock b/uv.lock index 156b9936..17326035 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.5" +version = "2.6.6" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 3b4f8796e32aa71825daa4f943a376a55369d540 Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:10:00 +0200 Subject: [PATCH 16/47] Bump pinned @coana-tech/cli to 15.10.23 (#316) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 2 +- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efa4fd2f..080ed118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.6.7 + +### Changed: bump pinned @coana-tech/cli to 15.10.23 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.16` to + `15.10.23`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + ## 2.6.6 ### Changed: faster local scan setup for large repositories diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 736fe8be..84e6d4a9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -275,7 +275,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.16 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.23 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index 6910e7cc..e2716b3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.6" +version = "2.6.7" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 4ee959b2..6af51dd2 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.6' +__version__ = '2.6.7' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index dc9ac74f..2f66fb27 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -18,7 +18,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.16" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.23" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -55,7 +55,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.16'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.23'). Args: version: Coana CLI version to use. @@ -64,7 +64,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.16'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.23'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index 17326035..f1d5bf59 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.6" +version = "2.6.7" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 5368c08536ca283c3df433f6bda9863d7291081e Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:47:48 +0200 Subject: [PATCH 17/47] Bump pinned @coana-tech/cli to 15.10.25 (#319) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 2 +- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 080ed118..1526c360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.6.8 + +### Changed: bump pinned @coana-tech/cli to 15.10.25 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.23` to + `15.10.25`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + ## 2.6.7 ### Changed: bump pinned @coana-tech/cli to 15.10.23 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 84e6d4a9..c4800841 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -275,7 +275,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.23 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.25 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index e2716b3d..f206c593 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.7" +version = "2.6.8" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 6af51dd2..cb041be8 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.7' +__version__ = '2.6.8' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 2f66fb27..3859b85e 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -18,7 +18,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.23" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.25" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -55,7 +55,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.23'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.25'). Args: version: Coana CLI version to use. @@ -64,7 +64,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.23'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.25'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index f1d5bf59..a1b37042 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.7" +version = "2.6.8" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From fbc21ed5abb950794c653549d4d3c73f9b2a30f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:06:02 -0400 Subject: [PATCH 18/47] chore(deps): bump the python-minor-patch group across 1 directory with 7 updates (#317) Bumps the python-minor-patch group with 7 updates in the / directory: | Package | From | To | | --- | --- | --- | | [packaging](https://github.com/pypa/packaging) | `26.2` | `26.3` | | [python-dotenv](https://github.com/theskumar/python-dotenv) | `1.2.2` | `1.2.3` | | [ruff](https://github.com/astral-sh/ruff) | `0.16.1` | `0.16.3` | | [uv](https://github.com/astral-sh/uv) | `0.12.0` | `0.12.5` | | [pre-commit](https://github.com/pre-commit/pre-commit) | `4.6.1` | `4.6.2` | | [hatch](https://github.com/pypa/hatch) | `1.17.1` | `1.18.0` | | [hatchling](https://github.com/pypa/hatch) | `1.31.0` | `1.32.0` | Updates `packaging` from 26.2 to 26.3 - [Release notes](https://github.com/pypa/packaging/releases) - [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pypa/packaging/compare/26.2...26.3) Updates `python-dotenv` from 1.2.2 to 1.2.3 - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.2...v1.2.3) Updates `ruff` from 0.16.1 to 0.16.3 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.1...0.16.3) Updates `uv` from 0.12.0 to 0.12.5 - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.12.0...0.12.5) Updates `pre-commit` from 4.6.1 to 4.6.2 - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v4.6.1...v4.6.2) Updates `hatch` from 1.17.1 to 1.18.0 - [Release notes](https://github.com/pypa/hatch/releases) - [Commits](https://github.com/pypa/hatch/compare/hatch-v1.17.1...hatch-v1.18.0) Updates `hatchling` from 1.31.0 to 1.32.0 - [Release notes](https://github.com/pypa/hatch/releases) - [Commits](https://github.com/pypa/hatch/compare/hatchling-v1.31.0...hatchling-v1.32.0) --- updated-dependencies: - dependency-name: hatch dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: hatchling dependency-version: 1.32.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: packaging dependency-version: '26.3' dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-minor-patch - dependency-name: pre-commit dependency-version: 4.6.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: python-dotenv dependency-version: 1.2.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: ruff dependency-version: 0.16.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: uv dependency-version: 0.12.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 14 +++--- uv.lock | 124 ++++++++++++++++++++++++------------------------- 2 files changed, 69 insertions(+), 69 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f206c593..d62522c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] requires = [ - "hatchling==1.31.0" + "hatchling==1.32.0" ] build-backend = "hatchling.build" @@ -14,8 +14,8 @@ dependencies = [ "mdutils==1.8.1", "prettytable==3.18.0", "GitPython==3.1.59", - "packaging==26.2", - "python-dotenv==1.2.2", + "packaging==26.3", + "python-dotenv==1.2.3", "socketdev==3.5.0", "beautifulsoup4==4.15.0", "markdown==3.10.3", @@ -47,11 +47,11 @@ test = [ "pytest-watch==4.2.0" ] dev = [ - "ruff==0.16.1", + "ruff==0.16.3", "twine==7.0.0", # for building - "uv==0.12.0", # for dependency management - "pre-commit==4.6.1", - "hatch==1.17.1" + "uv==0.12.5", # for dependency management + "pre-commit==4.6.2", + "hatch==1.18.0" ] [project.scripts] diff --git a/uv.lock b/uv.lock index a1b37042..2a8e2eeb 100644 --- a/uv.lock +++ b/uv.lock @@ -595,7 +595,7 @@ wheels = [ [[package]] name = "hatch" -version = "1.17.1" +version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-zstd", marker = "python_full_version < '3.14'" }, @@ -618,9 +618,9 @@ dependencies = [ { name = "uv" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/5a/7cacce8d33a93e81cda9b4f8623ebf67b73859950af0062e1f2c527f4b34/hatch-1.17.1.tar.gz", hash = "sha256:e5f2f389ececd7e86cd86863e3f921a9024cd04ce6ef6b36d07cd9a6dc37c9cd", size = 5252686, upload-time = "2026-07-08T01:54:37.327Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/fd/634c575b5becad2dfa7135f3ad66d57102cd77d834cf473756d868a808ff/hatch-1.18.0.tar.gz", hash = "sha256:463d214cb068391454be198e58d3cc0846f71e39d68c5568ba650af7781e8ae3", size = 5275084, upload-time = "2026-08-11T05:06:41.513Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/0a/2b4e653186fc85061f0dfde43d602e7e93c08c0d75b23fa3577f9b3f83fd/hatch-1.17.1-py3-none-any.whl", hash = "sha256:cc6c06d302cfa785c35586ab4285c8c28a24b1744addb66344192302f6958083", size = 162405, upload-time = "2026-07-08T01:54:35.452Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b1/d14265cb26c57af1485c01fd95037a35f13748b718617405cdefc6150429/hatch-1.18.0-py3-none-any.whl", hash = "sha256:75874c0905fe76b44fd39585c49bc9bd2b9fee638abb11f1f3d087042e7f9122", size = 171310, upload-time = "2026-08-11T05:06:39.807Z" }, ] [[package]] @@ -714,7 +714,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.13'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -885,11 +885,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -933,7 +933,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.6.1" +version = "4.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -942,9 +942,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/3a/ddb78f32a0814e66b18a099377a106a2dcdce92d86a034d69d65df9b256e/pre_commit-4.6.1.tar.gz", hash = "sha256:03e809865c7d178b9979d06c761fcbfe6808fdaded8581a745bb110e52050421", size = 198646, upload-time = "2026-07-21T20:56:58.225Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/49/bc925106abcdac498074f2cbe6137e94e09f418dd2b7775df5b577dc0313/pre_commit-4.6.1-py2.py3-none-any.whl", hash = "sha256:0e3b2942510d1fb34eec167a3ec57331bf8442122f1153a9fb8b58f5c49b2717", size = 226186, upload-time = "2026-07-21T20:56:57.064Z" }, + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, ] [[package]] @@ -1077,11 +1077,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] [[package]] @@ -1213,27 +1213,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, - { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, - { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, - { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, - { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, - { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, - { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, - { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, - { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, - { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, - { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, - { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, ] [[package]] @@ -1325,23 +1325,23 @@ requires-dist = [ { name = "brotli", marker = "platform_python_implementation == 'CPython'", specifier = "==1.2.0" }, { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'", specifier = "==1.2.0.1" }, { name = "gitpython", specifier = "==3.1.59" }, - { name = "hatch", marker = "extra == 'dev'", specifier = "==1.17.1" }, + { name = "hatch", marker = "extra == 'dev'", specifier = "==1.18.0" }, { name = "markdown", specifier = "==3.10.3" }, { name = "mdutils", specifier = "==1.8.1" }, - { name = "packaging", specifier = "==26.2" }, - { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.6.1" }, + { name = "packaging", specifier = "==26.3" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.6.2" }, { name = "prettytable", specifier = "==3.18.0" }, { name = "pytest", marker = "extra == 'test'", specifier = "==9.1.1" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = "==1.4.0" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = "==7.1.0" }, { name = "pytest-mock", marker = "extra == 'test'", specifier = "==3.15.1" }, { name = "pytest-watch", marker = "extra == 'test'", specifier = "==4.2.0" }, - { name = "python-dotenv", specifier = "==1.2.2" }, + { name = "python-dotenv", specifier = "==1.2.3" }, { name = "requests", specifier = "==2.34.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.1" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.3" }, { name = "socketdev", specifier = "==3.5.0" }, { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, - { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.0" }, + { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.5" }, ] provides-extras = ["test", "dev"] @@ -1494,28 +1494,28 @@ wheels = [ [[package]] name = "uv" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/e7/5a94b658b08c46142cf7bf1d0c432cc7d04375b80f42765633414e7541bd/uv-0.12.0.tar.gz", hash = "sha256:80ba22cae467c6f47d2157ec2b840c032cac709b85ab1300ac4dcfeb29986462", size = 5827380, upload-time = "2026-07-28T18:57:12.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/e9/5663af6b4d90827c008005cfe7926a747688bd408226913d249b9de8492b/uv-0.12.0-py3-none-linux_armv6l.whl", hash = "sha256:11cc7ef5386fe54536cc8921676728a0e5c348cf522c8ee1fa0b81cbafc20cbc", size = 21499556, upload-time = "2026-07-28T18:56:27.552Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8e/b88ae4a3b704f60f8e9dcdef78047c3749077b2f1e884bd387c8e41fe378/uv-0.12.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:074e693e9b2df99f621166b44760abe0d53cd9b0ae96fcbfec5809497925da87", size = 19751720, upload-time = "2026-07-28T18:56:30.623Z" }, - { url = "https://files.pythonhosted.org/packages/a5/7b/15d6865264120bd30c738b4bf63ddff66d087087cadeb2a6b88c6284a446/uv-0.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009758d8fde2da2b90900f5fe863c71d0e1b8b28bbdba59863ceb967973a3735", size = 18117978, upload-time = "2026-07-28T18:56:32.904Z" }, - { url = "https://files.pythonhosted.org/packages/0e/bc/2066cc63e6930e3d5e27c73a9c439418164eafb4f1c24845f17caf63eaea/uv-0.12.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:effc2de9f044e880306f3c52b048bf24ee4fe63429c82dd6509c9a0f3d1b8f0b", size = 20833318, upload-time = "2026-07-28T18:56:35.567Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d6/49fef7e4e3c401540113115846e47094aff7cda86f54ba79477636758e38/uv-0.12.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:e9e660171873f905a6782bf2a5e7515aba1a8e8a5cfce0add68fbe7a22ead8b0", size = 21056599, upload-time = "2026-07-28T18:56:38.117Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b9/cc32f406b5429cbb0f0849938d12a24a33c3bd28b710c8ccd0955c588131/uv-0.12.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53c5c07fafcf620d23faa8f339742806d57cb82122c97544d0f3750f55e2fe36", size = 21100563, upload-time = "2026-07-28T18:56:40.305Z" }, - { url = "https://files.pythonhosted.org/packages/42/ff/36eef4c1624ed371d8367cf96207f35ba81b42b8308688d1acad835432cc/uv-0.12.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b80a1a89aad16c6d84dd96b0c795b44f3824f0765e815af2f93fd05cb4a894cd", size = 21763617, upload-time = "2026-07-28T18:56:42.59Z" }, - { url = "https://files.pythonhosted.org/packages/05/23/b82dbd945c5b8a88ed5dc8c2c001619677ad5aea246318716c773711aef9/uv-0.12.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1e84987c4b4d832796b779ad614e91c1b44ac1ade5163c00654b70881ef53cb", size = 22917937, upload-time = "2026-07-28T18:56:45.546Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/44f5f753fda99820b972251c3be9ca9e56d98f4ced752cea623f19479fa8/uv-0.12.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbb9d9c40e91b6bf5e124230277fe5579ecf685e6de47e61a0eed8af5ffa0cdb", size = 22555435, upload-time = "2026-07-28T18:56:47.882Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ba/bc14d74741b0292edd8e61e87a4bd96f79447a1b9d27e85cda2e8539039b/uv-0.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbff74f884846d794713670faf8abe10db3bd70c43b01e63223f74eb7d958689", size = 21986958, upload-time = "2026-07-28T18:56:50.271Z" }, - { url = "https://files.pythonhosted.org/packages/1d/52/e14f0a91be4b426f18107f63b1b87e99ec671e8907689cf45144a79c4f76/uv-0.12.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c818bb6aead39652e2ad644583fa418ac8d92baf50b4c6f685738bb2598e33bd", size = 20965849, upload-time = "2026-07-28T18:56:52.628Z" }, - { url = "https://files.pythonhosted.org/packages/0d/a6/ef7b436f9983c467b88bacb5ce58620398c7fe7fa86ee67906bfce343201/uv-0.12.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:5fe6cdc82cacc630827f2ec779b91b0d13ff57ff476e41bdcace05cd61261951", size = 21671684, upload-time = "2026-07-28T18:56:54.923Z" }, - { url = "https://files.pythonhosted.org/packages/9d/c8/19086d68078b514be4c266081e11d5530b07d099cb05d011e1fa6a216e10/uv-0.12.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fcf4b6d0807f8f05a7dd8c090f080674e8526db27a0764af2a5a54ab5096c3eb", size = 21798247, upload-time = "2026-07-28T18:56:57.226Z" }, - { url = "https://files.pythonhosted.org/packages/4d/87/571847075bbe2205ec7ae108c17d01742a1251aea9fe5f9cd5da1496922e/uv-0.12.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4be9870fca2952143f33a02347c8da603bbe645283e3e989f038ef7b306b3ecb", size = 20977006, upload-time = "2026-07-28T18:56:59.544Z" }, - { url = "https://files.pythonhosted.org/packages/be/df/d391bc0f5901ff8a0d6285eb433222cacb972b5e5817a420e084ee698894/uv-0.12.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:ed4053e07048ab3561de95c3b686b7983f997cd19d53a265a238103b5dbf258a", size = 22186132, upload-time = "2026-07-28T18:57:02.014Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fe/d440d50811ef913cb035e4c5f346799d9353fd5a8aa8479e57d7efc34692/uv-0.12.0-py3-none-win32.whl", hash = "sha256:bef14df9bec1ee7577fdc5b37d02ad8128574a2eebc130c525255edac051b9a4", size = 19210613, upload-time = "2026-07-28T18:57:04.493Z" }, - { url = "https://files.pythonhosted.org/packages/cb/27/c3da5b9136925ea2bc9209f7cabbfae12fd191f778456ead0f2d6de446a7/uv-0.12.0-py3-none-win_amd64.whl", hash = "sha256:ffdfed09a23e67ef6facf1d4db978a3cd73a886674644131a11a933fd746904a", size = 20005960, upload-time = "2026-07-28T18:57:07.332Z" }, - { url = "https://files.pythonhosted.org/packages/9f/bc/d04df3b6c36be124cb99e7eab59db514ec528f2b5c5ac2ed9fec41fbdc71/uv-0.12.0-py3-none-win_arm64.whl", hash = "sha256:e3d748f526739110dd9e267ecca30604b64a5fe3344f903d348b5a3af1f0a90a", size = 18981523, upload-time = "2026-07-28T18:57:09.743Z" }, +version = "0.12.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/b0/3085b844fe59aa319a3f94a5cca9938fffecc82705aa9c2762a749f7095c/uv-0.12.5.tar.gz", hash = "sha256:442a21d181faae21742aaaf6d2091a0d27755d3eac344061a9a00c90169b7524", size = 7101936, upload-time = "2026-08-14T19:56:57.693Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/4c/6412d4a618230db699118b362ec41c54795f93992b43c53e225bd0213501/uv-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:2bd62134e56af35b9cf017aaf8ae41a605d6501dd49afc35b70b544a45dd8354", size = 23310055, upload-time = "2026-08-14T19:55:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/d76387b388fa21620088b89b9c67f2596a707add585104e0cb5e8abf55f2/uv-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1a06c8bc4d43b5f6c1e3f2ae3d0f6455b07515f762516f95e52e6c0cbccedf15", size = 21401335, upload-time = "2026-08-14T19:55:55.371Z" }, + { url = "https://files.pythonhosted.org/packages/6d/bc/81ab953b7261ae6be40874b1f283a10873871e02eb353d354614dd8da96b/uv-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d87156bc174d94fae890bb7a261e2867140abb9fe1e9de81a5295e582fb9d0f5", size = 19290641, upload-time = "2026-08-14T19:55:58.998Z" }, + { url = "https://files.pythonhosted.org/packages/7d/13/07585043c10e648820bf826474dac46864ce6691da5dc52fee43c5c7523a/uv-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d65b7b3bc3fd28678f62aa7fb5d90f106ad9782c1354af60b6cecdf9ea9ecd9", size = 22245569, upload-time = "2026-08-14T19:56:02.729Z" }, + { url = "https://files.pythonhosted.org/packages/3e/6d/310f8f56f8d001b4000112a09d7b7de80fb2024a90208fabb9ddc457c123/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:712624b62e25c84e5a10fc6aa144d8a81b685fdc067a54a7ca4367d75d2cf791", size = 22745152, upload-time = "2026-08-14T19:56:06.426Z" }, + { url = "https://files.pythonhosted.org/packages/92/da/7922b67eec5ee03e94333c5841b682c335033ee80acac17c3417bd752656/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9656ac7a00fd4314980fb0f790df1c1f3fa9cbcf9af9c6f611b19448b9da687", size = 22787947, upload-time = "2026-08-14T19:56:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/62/55/5dbaed832a4b36809ef8a07c8e56e9fee0dedb0aa0454f6d232b6e468f2c/uv-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:568485b44e848eb3693f85d6b00299ccd8fc4d26902030dbf24f549c276db9ca", size = 23367616, upload-time = "2026-08-14T19:56:13.768Z" }, + { url = "https://files.pythonhosted.org/packages/11/77/baf761d12bb66efb01706e3bbb5926ed0d13cb0a40539a661fcfffd46de4/uv-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd08c82831b0033330f8eeeb0d90f938a4d999f25569bee68a975c736142d795", size = 24586263, upload-time = "2026-08-14T19:56:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a8/76c1031c4834c959bb8a8059c9feabeaa77488ce8b6a3529d6d929ae81cf/uv-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd9ff6154b891146a342c143cd29b330ad97ac6a4b20ff4a99a20a4da84ceca", size = 24160655, upload-time = "2026-08-14T19:56:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/dacc9a0bc8604187a1ba954a3aef8329e4104eb0af772d2c3c634893bd9b/uv-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e195ccf1ed60c8bb24a6447ce306441a4181d54b602407e09bc56e963911c15", size = 23657089, upload-time = "2026-08-14T19:56:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/39/98/e8f9c071622f2cb4072d8b587d27b27d23cf0d3ebf8b3687f5af6030f587/uv-0.12.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:58abfb0f658b39a834307a11223bc170294ea214263b4c99ecc7663720d43544", size = 22379954, upload-time = "2026-08-14T19:56:28.789Z" }, + { url = "https://files.pythonhosted.org/packages/73/95/4c3f060e95f7cbe9177b4ab361f0cbfc4ae22e5a49b22e73eee9f0d0a6ca/uv-0.12.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:6ad2c455f1fe4d2962f6fd7ccb3b1f61c61856681c9d99f40e170b2074353fa3", size = 23318163, upload-time = "2026-08-14T19:56:32.504Z" }, + { url = "https://files.pythonhosted.org/packages/a0/96/ca0497ef8912ef48dbbc9982a8b4212260c34d56bfd0d45fe67b31942121/uv-0.12.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a05b497c2a948c8600f4c831a89852b4d2514b7f561074225cc9edd0cc4811e2", size = 23470437, upload-time = "2026-08-14T19:56:36.525Z" }, + { url = "https://files.pythonhosted.org/packages/60/e7/8bdc37669a6cd2b46a2ec08ccbb58c61395ec84a073e199f5a4a64bb998f/uv-0.12.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7817f8e957960f9ddc452ea353f283c0d6393e2e31b400276485adced5b1f371", size = 22545803, upload-time = "2026-08-14T19:56:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/37/cc/01e39e1dbeb838a6b3c26bf97c867d6f366459b22a38bea691af8c6c94c0/uv-0.12.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:dc14e4f81a99b585a891350c60d1ff4557d54cb3c3c81fa45fd4e0dd512ba752", size = 23874113, upload-time = "2026-08-14T19:56:44.193Z" }, + { url = "https://files.pythonhosted.org/packages/0a/38/9053599a73a351d1cd34195c7a48c1db4d4d51b57b543607fad7ecf9354c/uv-0.12.5-py3-none-win32.whl", hash = "sha256:39bb102766c95571781a7b4c611675ea213e08df5c680f3936279b3c0d1f6c3c", size = 20744641, upload-time = "2026-08-14T19:56:47.689Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f6/a9af9311c7f5640ca2bfcfdedb7aca37fa6d1d9f5c981fb50c5be02b7477/uv-0.12.5-py3-none-win_amd64.whl", hash = "sha256:455c3e57602e2141e66e2f0bf685898c9c5e5a70377d14c9a71554a3baf3ddbf", size = 21621812, upload-time = "2026-08-14T19:56:51.126Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fb/e1266399f755f97a0783de379f2fed6dae0a2a240db32fe5a2eb976fec8a/uv-0.12.5-py3-none-win_arm64.whl", hash = "sha256:bea86f27a027e0e3af908db4bdd4f1ceef3ca2bd47673b5ccca7f550e325b1b4", size = 20381876, upload-time = "2026-08-14T19:56:54.883Z" }, ] [[package]] From e5f6b814b6cc17c9be9e9dd0f3ad5bf74cc8f576 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:59:10 -0400 Subject: [PATCH 19/47] ci(deps): bump actions/setup-node in /.github/actions/setup-sfw (#304) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.4.0 to 7.0.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/actions/setup-sfw/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-sfw/action.yml b/.github/actions/setup-sfw/action.yml index 66b54ffc..11048d80 100644 --- a/.github/actions/setup-sfw/action.yml +++ b/.github/actions/setup-sfw/action.yml @@ -32,7 +32,7 @@ runs: python-version: "3.12" - if: ${{ inputs.node == 'true' }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "20" From 636b4806bd60bfbf7f385016e601f404e312e5c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:01:12 -0400 Subject: [PATCH 20/47] ci(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (#303) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.4.0 to 7.0.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/workflows/e2e-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 777777be..fe763699 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -84,7 +84,7 @@ jobs: with: python-version: '3.12' - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 if: matrix.setup-node == 'true' with: node-version: '20' From 861cf02d3d8500a0dadafa288bf03bdf03539275 Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:42:02 +0200 Subject: [PATCH 21/47] Bump pinned @coana-tech/cli to 15.10.27 (#326) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 4 ++-- 6 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1526c360..74951d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.6.9 + +### Changed: bump pinned @coana-tech/cli to 15.10.27 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.25` to + `15.10.27`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + ## 2.6.8 ### Changed: bump pinned @coana-tech/cli to 15.10.25 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c4800841..f671e8f4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -275,7 +275,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.25 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.27 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index d62522c9..a44e2e71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.8" +version = "2.6.9" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index cb041be8..ba3db034 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.8' +__version__ = '2.6.9' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 3859b85e..37f50ca6 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -18,7 +18,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.25" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.27" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -55,7 +55,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.25'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.27'). Args: version: Coana CLI version to use. @@ -64,7 +64,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.25'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.27'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index 2a8e2eeb..e6eb4ae6 100644 --- a/uv.lock +++ b/uv.lock @@ -714,7 +714,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.8" +version = "2.6.9" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 6a9cd736e96b3d89bbe8985768f10e289b957b90 Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:05:32 +0200 Subject: [PATCH 22/47] Bump pinned @coana-tech/cli to 15.10.28 (#327) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 2 +- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74951d06..d2ded0e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.6.10 + +### Changed: bump pinned @coana-tech/cli to 15.10.28 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.27` to + `15.10.28`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + ## 2.6.9 ### Changed: bump pinned @coana-tech/cli to 15.10.27 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f671e8f4..2737d19a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -275,7 +275,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.27 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.28 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index a44e2e71..6268a8a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.9" +version = "2.6.10" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index ba3db034..464ba34f 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.9' +__version__ = '2.6.10' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 37f50ca6..5f6b1f8f 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -18,7 +18,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.27" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.28" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -55,7 +55,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.27'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.28'). Args: version: Coana CLI version to use. @@ -64,7 +64,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.27'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.28'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index e6eb4ae6..f6cbb12d 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.9" +version = "2.6.10" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 362f98ed84033b53095429d99e274ff225721ad1 Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:27:18 +0200 Subject: [PATCH 23/47] Bump pinned @coana-tech/cli to 15.10.32 (#330) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 2 +- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ded0e5..7f8e72fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.6.11 + +### Changed: bump pinned @coana-tech/cli to 15.10.32 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.28` to + `15.10.32`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + ## 2.6.10 ### Changed: bump pinned @coana-tech/cli to 15.10.28 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2737d19a..6c94daf3 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -275,7 +275,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.28 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.32 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index 6268a8a3..bd003e3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.10" +version = "2.6.11" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 464ba34f..343b65a9 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.10' +__version__ = '2.6.11' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 5f6b1f8f..830a5118 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -18,7 +18,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.28" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.32" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -55,7 +55,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.28'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.32'). Args: version: Coana CLI version to use. @@ -64,7 +64,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.28'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.32'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index f6cbb12d..c112a469 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.10" +version = "2.6.11" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From d6cd45407f016a80151d11fef7f6632a000de71d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:24:22 -0400 Subject: [PATCH 24/47] chore(deps): bump ruff in the python-minor-patch group (#323) Bumps the python-minor-patch group with 1 update: [ruff](https://github.com/astral-sh/ruff). Updates `ruff` from 0.16.3 to 0.16.4 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.3...0.16.4) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 44 ++++++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bd003e3f..ca154c07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ test = [ "pytest-watch==4.2.0" ] dev = [ - "ruff==0.16.3", + "ruff==0.16.4", "twine==7.0.0", # for building "uv==0.12.5", # for dependency management "pre-commit==4.6.2", diff --git a/uv.lock b/uv.lock index c112a469..ad9ab1a8 100644 --- a/uv.lock +++ b/uv.lock @@ -1213,27 +1213,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, - { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, - { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, - { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, - { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, - { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, - { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, - { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, ] [[package]] @@ -1338,7 +1338,7 @@ requires-dist = [ { name = "pytest-watch", marker = "extra == 'test'", specifier = "==4.2.0" }, { name = "python-dotenv", specifier = "==1.2.3" }, { name = "requests", specifier = "==2.34.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.3" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.4" }, { name = "socketdev", specifier = "==3.5.0" }, { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.5" }, From 1d193c7d8a1137465fbf126cc9465adb70f3f654 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:40:33 -0400 Subject: [PATCH 25/47] Don't render `.socket.facts.json` placeholder as blocking package (#321) Scans with no supported manifest files uploaded a zero-byte `.socket.facts.json` placeholder. The API cannot parse that and responds by adding a synthetic `generic/invalid-socket-facts@1.0.0` artifact, which the CLI then reported as a new blocking package with no manifest file and no introducing dependency, failing the run and posting a pull request comment that could not be acted on. - Write an empty but well-formed facts document as the placeholder. - Give each placeholder its own temp directory, so concurrent runs cannot remove each other's file mid-upload. - Filter the `generic/invalid-socket-facts` marker out of full scan and diff artifacts, logging a warning instead. It is a diagnostic, not a dependency. --- CHANGELOG.md | 18 ++ pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/__init__.py | 130 +++++++++-- tests/core/test_facts_compression.py | 2 +- tests/core/test_invalid_facts_marker.py | 278 ++++++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 407 insertions(+), 27 deletions(-) create mode 100644 tests/core/test_invalid_facts_marker.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f8e72fa..074bb8b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 2.6.12 + +### Fixed: unreadable reachability facts no longer report a blocking package + +- Scans with no supported manifest files uploaded a zero-byte `.socket.facts.json` + placeholder. The API cannot parse that, and answers by adding a + `generic/invalid-socket-facts@1.0.0` artifact to the scan, which the CLI then reported + as a new blocking package with no manifest file and no introducing dependency — + failing the run and, on pull requests, leaving a security comment that could not be + acted on. The placeholder is now an empty but well-formed facts document. +- When the API does report `generic/invalid-socket-facts` (a diagnostic for a facts file + it could not parse, not a real dependency), the CLI now excludes it from scan results + and logs a warning instead. It no longer blocks a run, appears in reports, or triggers + a pull request comment. +- Each placeholder is written to its own temporary directory. Two CLI runs sharing a + temporary directory previously used the same path and could remove each other's + placeholder mid-upload. + ## 2.6.11 ### Changed: bump pinned @coana-tech/cli to 15.10.32 diff --git a/pyproject.toml b/pyproject.toml index ca154c07..b85e8b80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.11" +version = "2.6.12" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 343b65a9..c1ba543c 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.11' +__version__ = '2.6.12' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index daab5fdd..a5305bee 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -1,3 +1,4 @@ +import atexit import copy import fnmatch import importlib @@ -6,6 +7,7 @@ import os import random import re +import shutil import sys import tarfile import tempfile @@ -69,6 +71,16 @@ # Stream the facts file in 1 MiB chunks so large files aren't held fully in memory. SOCKET_FACTS_BROTLI_CHUNK_SIZE = 1024 * 1024 +# Placeholder facts document (see empty_head_scan_file). A zero-byte file does not parse, +# and the API answers an unparseable facts file with the marker artifact below. +SOCKET_FACTS_EMPTY_DOCUMENT = '{"components": []}' + +# Synthetic artifact the API adds when an uploaded ``.socket.facts.json`` could not be +# parsed. A diagnostic, not a dependency, so it is dropped from scan results and reported +# as a warning instead. +INVALID_FACTS_MARKER_TYPE = "generic" +INVALID_FACTS_MARKER_NAME = "invalid-socket-facts" + # Full application reachability finalize retry policy. The finalize call links the reachability # scan to the full scan and can fail transiently (network/API blips); a few backoff retries make it robust. TIER1_FINALIZE_MAX_ATTEMPTS = 3 @@ -108,6 +120,17 @@ DIFF_SCAN_POLL_BACKOFF_MULTIPLIER = 1.5 DIFF_SCAN_POLL_TIMEOUT_SECONDS = 30 * 60.0 +# Temp dirs holding placeholder facts files (see Core.empty_head_scan_file). Call sites unlink +# the file itself once the upload finishes; the now-empty directory is removed at process exit +# so a run that raises mid-scan doesn't leak one. +_PLACEHOLDER_FACTS_DIRS: List[str] = [] + + +@atexit.register +def _cleanup_placeholder_facts_dirs() -> None: + for placeholder_dir in _PLACEHOLDER_FACTS_DIRS: + shutil.rmtree(placeholder_dir, ignore_errors=True) + def _humanize_alert_type(alert_type: str) -> str: """Convert a camelCase/PascalCase alert type into a Title-Cased label. @@ -209,13 +232,54 @@ def get_sbom_data(self, full_scan_id: str) -> Dict[str, SocketArtifact]: ) if not hasattr(response, "artifacts") or not response.artifacts: return {} - return response.artifacts + artifacts = { + artifact_id: artifact + for artifact_id, artifact in response.artifacts.items() + if not Core.is_invalid_facts_marker(artifact) + } + Core.warn_if_invalid_facts_marker(len(artifacts) != len(response.artifacts)) + return artifacts def get_sbom_data_list(self, artifacts_dict: Dict[str, SocketArtifact]) -> list[SocketArtifact]: """Converts artifacts dictionary to a list.""" return list(artifacts_dict.values()) + @staticmethod + def is_invalid_facts_marker(artifact) -> bool: + """True for the API's ``generic/invalid-socket-facts`` unparseable-facts marker. + + Treated as a package it becomes a blocking alert with an empty "Introduced by" and + "Manifest File" that no developer can act on, so callers drop it and report the parse + failure through ``warn_if_invalid_facts_marker`` instead. + Matches any version; the API pins it to 1.0.0 but the version carries no meaning. + + Args: + artifact: A ``SocketArtifact`` or diff artifact (anything with ``type``/``name``). + + Returns: + True if the artifact is the marker rather than a real package. + """ + return ( + getattr(artifact, "type", None) == INVALID_FACTS_MARKER_TYPE + and getattr(artifact, "name", None) == INVALID_FACTS_MARKER_NAME + ) + + @staticmethod + def warn_if_invalid_facts_marker(found: bool) -> None: + """Log the parse failure that ``is_invalid_facts_marker`` stands for. + + Dropping the marker silently would hide a real, if non-blocking, problem: the scan ran + without the reachability data it was supposed to carry. + """ + if not found: + return + log.warning( + "Socket could not parse the uploaded .socket.facts.json, so reachability facts " + "were not applied to this scan. Ignoring the " + f"{INVALID_FACTS_MARKER_TYPE}/{INVALID_FACTS_MARKER_NAME} marker returned for it; " + "other scan results are unaffected." + ) def create_sbom_output(self, diff: Diff) -> dict: """Creates CycloneDX output for a given diff.""" @@ -809,20 +873,31 @@ def to_case_insensitive_regex(input_string: str) -> str: @staticmethod def empty_head_scan_file() -> List[str]: """ - Creates a temporary empty file for baseline scans when no head scan exists. - + Creates a temporary placeholder manifest for scans with no manifest files. + + Used for baseline scans when a repository has no head scan yet, and for the new scan + when no supported manifest files were found. The API rejects unsupported filenames, so + the placeholder must be named ``.socket.facts.json`` - which means it must also parse + as a facts document. A zero-byte file does not, and the API answers that by adding a + blocking ``generic/invalid-socket-facts@1.0.0`` artifact to the scan. + + Each call gets its own temp directory. The path used to be a fixed + ``$TMPDIR/.socket.facts.json``, so two runs sharing a temp dir could delete or + truncate each other's placeholder mid-upload. + Returns: - List containing path to a temporary empty file + List containing path to a temporary placeholder facts file """ - # Create a temporary directory and then create our specific filename - temp_dir = tempfile.gettempdir() - temp_path = os.path.join(temp_dir, '.socket.facts.json') - - # Create the empty file - with open(temp_path, 'w'): - pass # Creates an empty file - - log.debug(f"Created temporary empty file for baseline scan: {temp_path}") + # Own directory per call so concurrent runs can't clobber each other's placeholder; + # the basename must stay exactly SOCKET_FACTS_FILENAME to pass the API's validator. + temp_dir = tempfile.mkdtemp(prefix='socket_baseline_') + _PLACEHOLDER_FACTS_DIRS.append(temp_dir) + temp_path = os.path.join(temp_dir, SOCKET_FACTS_FILENAME) + + with open(temp_path, 'w') as f: + f.write(SOCKET_FACTS_EMPTY_DOCUMENT) + + log.debug(f"Created temporary placeholder facts file for baseline scan: {temp_path}") return [temp_path] def finalize_tier1_scan(self, full_scan_id: str, facts_file_path: str) -> bool: @@ -959,7 +1034,7 @@ def _compress_facts_files_for_upload(self, files: List[str]) -> Tuple[List[str], exactly ``.socket.facts.json.br``, so compressing here keeps a large facts file under the server's per-file size cap without changing the stored result. Files whose basename is not exactly ``.socket.facts.json`` are left untouched (the server only - matches that exact name), as are empty placeholder files (e.g. baseline scans). + matches that exact name), as are zero-byte files. Compression never blocks an upload: if it fails for any reason (missing optional ``brotli`` dependency, unwritable directory, etc.) the original plain file is used. @@ -1780,16 +1855,25 @@ def get_added_and_removed_packages( diff_end = time.time() log.info(f"Diff Report Gathered in {diff_end - diff_start:.2f} seconds") + + # Left in, the invalid-socket-facts marker reads as a newly added blocking package. + # Drop it from every bucket before the counts below, which should describe what the + # CLI actually reports on. + marker_found = False + buckets: Dict[str, List] = {} + for name in ("added", "removed", "unchanged", "replaced", "updated"): + bucket = getattr(diff_artifacts, name) + buckets[name] = [a for a in bucket if not Core.is_invalid_facts_marker(a)] + marker_found = marker_found or len(buckets[name]) != len(bucket) + Core.warn_if_invalid_facts_marker(marker_found) + log.info("Diff report artifact counts:") - log.info(f"Added: {len(diff_artifacts.added)}") - log.info(f"Removed: {len(diff_artifacts.removed)}") - log.info(f"Unchanged: {len(diff_artifacts.unchanged)}") - log.info(f"Replaced: {len(diff_artifacts.replaced)}") - log.info(f"Updated: {len(diff_artifacts.updated)}") - - added_artifacts = diff_artifacts.added + diff_artifacts.updated - removed_artifacts = diff_artifacts.removed + diff_artifacts.replaced - unchanged_artifacts = diff_artifacts.unchanged + for name, bucket in buckets.items(): + log.info(f"{name.capitalize()}: {len(bucket)}") + + added_artifacts = buckets["added"] + buckets["updated"] + removed_artifacts = buckets["removed"] + buckets["replaced"] + unchanged_artifacts = buckets["unchanged"] added_packages: Dict[str, Package] = {} removed_packages: Dict[str, Package] = {} diff --git a/tests/core/test_facts_compression.py b/tests/core/test_facts_compression.py index ba04efa4..0e71cdc7 100644 --- a/tests/core/test_facts_compression.py +++ b/tests/core/test_facts_compression.py @@ -99,7 +99,7 @@ def test_compress_for_upload_preserves_directory_prefix(tmp_path): def test_empty_facts_file_is_not_compressed(tmp_path): - """Empty placeholder facts files (e.g. baseline scans) are uploaded as-is.""" + """A zero-byte facts file has nothing to compress and is uploaded as-is.""" core = Core.__new__(Core) empty_facts = _write(str(tmp_path / SOCKET_FACTS_FILENAME), b"") diff --git a/tests/core/test_invalid_facts_marker.py b/tests/core/test_invalid_facts_marker.py new file mode 100644 index 00000000..87b9005c --- /dev/null +++ b/tests/core/test_invalid_facts_marker.py @@ -0,0 +1,278 @@ +"""An unparseable `.socket.facts.json` must not block a run or leave a PR comment. + +When the API cannot parse an uploaded facts file it adds a synthetic +`generic/invalid-socket-facts@1.0.0` artifact carrying a blocking alert, which the CLI then +reports as a newly added blocking package with no manifest and no introducer. The CLI was +also handing the API an unparseable file itself: the placeholder it uploads for scans with +no manifest files was zero bytes. + +These tests cover both the placeholder (`empty_head_scan_file`) and the marker filtering. +""" +import copy +import json +import os + +import pytest +from socketdev.fullscans import FullScanStreamResponse, StreamDiffResponse + +from socketsecurity.core import ( + INVALID_FACTS_MARKER_NAME, + INVALID_FACTS_MARKER_TYPE, + SOCKET_FACTS_FILENAME, + Core, +) +from socketsecurity.core.socket_config import SocketConfig + + +@pytest.fixture +def core(mock_sdk_with_responses): + return Core(config=SocketConfig(api_key="test_key"), sdk=mock_sdk_with_responses) + + +def make_marker_artifact(diff_type="added", artifact_id="invalid-facts-1"): + """The artifact the API returns for an unparseable facts file.""" + return { + "diffType": diff_type, + "type": INVALID_FACTS_MARKER_TYPE, + "name": INVALID_FACTS_MARKER_NAME, + "version": "1.0.0", + "id": artifact_id, + "direct": True, + "manifestFiles": [], + "topLevelAncestors": [], + "license": "", + "licenseDetails": [], + "author": [], + "size": 0, + "score": { + "supplyChain": 0, + "quality": 0, + "maintenance": 0, + "vulnerability": 0, + "license": 0, + "overall": 0, + }, + "scores": { + "supplyChain": 0, + "quality": 0, + "maintenance": 0, + "vulnerability": 0, + "license": 0, + "overall": 0, + }, + "alerts": [ + { + "key": "invalid_facts_alert_1", + "type": "generic", + "severity": "high", + "category": "supplyChainRisk", + "action": "error", + } + ], + } + + +# --- The placeholder the CLI uploads ---------------------------------------------------- + + +def test_empty_head_scan_file_is_parseable_json(): + """The placeholder must parse as a facts document, or the API answers with the marker. + + A zero-byte file (the old behaviour) is what produced the invalid-socket-facts artifact + in the first place. + """ + (path,) = Core.empty_head_scan_file() + + assert os.path.basename(path) == SOCKET_FACTS_FILENAME, ( + "the API rejects unsupported filenames, so the placeholder basename is load-bearing" + ) + with open(path) as f: + assert json.load(f) == {"components": []} + + +def test_empty_head_scan_file_is_unique_per_call(): + """Concurrent runs must not share one placeholder path. + + The path used to be a fixed `$TMPDIR/.socket.facts.json`, so two CLI invocations sharing + a temp dir could delete or truncate each other's placeholder mid-upload. + """ + (first,) = Core.empty_head_scan_file() + (second,) = Core.empty_head_scan_file() + + assert first != second + # Deleting one (what the call sites do after upload) leaves the other intact. + os.unlink(first) + assert os.path.isfile(second) + + +# --- The marker predicate --------------------------------------------------------------- + + +class FakeArtifact: + def __init__(self, type, name): + self.type = type + self.name = name + + +@pytest.mark.parametrize( + "artifact_type,artifact_name,expected", + [ + (INVALID_FACTS_MARKER_TYPE, INVALID_FACTS_MARKER_NAME, True), + ("pypi", "requests", False), + # A real generic package, and a same-named package from another ecosystem, are both + # ordinary dependencies - only the exact type+name pair is the API's marker. + (INVALID_FACTS_MARKER_TYPE, "some-tarball", False), + ("npm", INVALID_FACTS_MARKER_NAME, False), + ], +) +def test_is_invalid_facts_marker(artifact_type, artifact_name, expected): + assert Core.is_invalid_facts_marker(FakeArtifact(artifact_type, artifact_name)) is expected + + +def test_is_invalid_facts_marker_ignores_version(): + """The API pins the marker to 1.0.0 today, but the version carries no meaning.""" + + class Versioned(FakeArtifact): + version = "9.9.9" + + assert Core.is_invalid_facts_marker( + Versioned(INVALID_FACTS_MARKER_TYPE, INVALID_FACTS_MARKER_NAME) + ) + + +# --- Filtering: full-scan SBOM path ------------------------------------------------------- + + +def test_get_sbom_data_drops_marker(core, data_dir, load_json, caplog): + """The marker never reaches packages built from a full scan's SBOM.""" + json_data = load_json(data_dir / "fullscans" / "head_scan" / "stream_scan.json") + artifacts = copy.deepcopy(json_data["artifacts"]) + artifacts["invalid-facts-1"] = make_marker_artifact() + core.sdk.fullscans.stream.side_effect = None + core.sdk.fullscans.stream.return_value = FullScanStreamResponse.from_dict({ + "success": True, + "status": 200, + "artifacts": artifacts, + }) + + with caplog.at_level("WARNING"): + result = core.get_sbom_data("head") + + assert "invalid-facts-1" not in result + assert len(result) == len(json_data["artifacts"]) + assert "could not parse the uploaded .socket.facts.json" in caplog.text + + +def test_get_sbom_data_does_not_warn_without_marker(core, caplog): + """A clean scan produces no facts-parse warning.""" + with caplog.at_level("WARNING"): + core.get_sbom_data("head") + + assert "could not parse the uploaded .socket.facts.json" not in caplog.text + + +# --- Filtering: diff path (the flow that posts the PR comment) --------------------------- + + +def _diff_response_with_marker(data_dir, load_json, buckets=("added",)): + json_data = load_json(data_dir / "fullscans" / "diff" / "stream_diff.json") + artifacts = copy.deepcopy(json_data["data"]["artifacts"]) + for index, bucket in enumerate(buckets): + artifacts[bucket].append( + make_marker_artifact(diff_type=bucket, artifact_id=f"invalid-facts-{index}") + ) + return StreamDiffResponse.from_dict({ + "success": json_data["success"], + "status": json_data["status"], + "data": {**json_data["data"], "artifacts": artifacts}, + }) + + +def test_diff_drops_marker_from_added_packages(core, data_dir, load_json, caplog): + """An added marker yields no package and no blocking alert. + + Left in, it surfaces as `NEW blocking issues: 1` and a PR comment for a package the + developer never added. + """ + core.sdk.fullscans.stream_diff.side_effect = None + core.sdk.fullscans.stream_diff.return_value = _diff_response_with_marker( + data_dir, load_json + ) + # Force the legacy streaming diff so the fixture above is the artifact source. + core.sdk.diffscans.create_from_ids.side_effect = Exception("diff-scans unavailable") + + with caplog.at_level("WARNING"): + added, removed, packages = core.get_added_and_removed_packages("head", "new") + + assert not any( + pkg.name == INVALID_FACTS_MARKER_NAME + for pkg in list(added.values()) + list(removed.values()) + list(packages.values()) + ) + diff = core.create_diff_report(added, removed) + assert not any(alert.pkg_name == INVALID_FACTS_MARKER_NAME for alert in diff.new_alerts) + assert "could not parse the uploaded .socket.facts.json" in caplog.text + + +def test_diff_drops_marker_from_every_bucket(core, data_dir, load_json): + """Removed and unchanged markers are dropped too. + + An unchanged marker would otherwise become an existing violation under + --strict-blocking, and a removed one would show up as a resolved alert. + """ + core.sdk.fullscans.stream_diff.side_effect = None + core.sdk.fullscans.stream_diff.return_value = _diff_response_with_marker( + data_dir, load_json, buckets=("added", "removed", "unchanged") + ) + core.sdk.diffscans.create_from_ids.side_effect = Exception("diff-scans unavailable") + + added, removed, packages = core.get_added_and_removed_packages("head", "new") + + assert not any( + pkg.name == INVALID_FACTS_MARKER_NAME + for pkg in list(added.values()) + list(removed.values()) + list(packages.values()) + ) + + +def test_diff_artifact_counts_exclude_marker(core, data_dir, load_json, caplog): + """The logged counts describe what the CLI reports on, not the raw API response. + + "Added: 1" in a run whose only added artifact was the marker sends whoever reads the log + looking for a package that was never there. + """ + core.sdk.fullscans.stream_diff.side_effect = None + core.sdk.fullscans.stream_diff.return_value = _diff_response_with_marker( + data_dir, load_json + ) + core.sdk.diffscans.create_from_ids.side_effect = Exception("diff-scans unavailable") + unfiltered_added = len( + load_json(data_dir / "fullscans" / "diff" / "stream_diff.json")["data"]["artifacts"][ + "added" + ] + ) + + with caplog.at_level("INFO"): + core.get_added_and_removed_packages("head", "new") + + assert f"Added: {unfiltered_added}" in caplog.text + assert f"Added: {unfiltered_added + 1}" not in caplog.text + + +def test_diff_keeps_real_packages(core, data_dir, load_json): + """Filtering the marker leaves genuine packages untouched.""" + core.sdk.fullscans.stream_diff.side_effect = None + unfiltered = load_json(data_dir / "fullscans" / "diff" / "stream_diff.json") + core.sdk.fullscans.stream_diff.return_value = _diff_response_with_marker( + data_dir, load_json + ) + core.sdk.diffscans.create_from_ids.side_effect = Exception("diff-scans unavailable") + + added, removed, _ = core.get_added_and_removed_packages("head", "new") + + expected_added = len(unfiltered["data"]["artifacts"]["added"]) + len( + unfiltered["data"]["artifacts"]["updated"] + ) + expected_removed = len(unfiltered["data"]["artifacts"]["removed"]) + len( + unfiltered["data"]["artifacts"]["replaced"] + ) + assert len(added) == expected_added + assert len(removed) == expected_removed diff --git a/uv.lock b/uv.lock index ad9ab1a8..8e880d32 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.11" +version = "2.6.12" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 717d0afd8f3f4534672dd47d2da7619b7ec22c45 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:05:48 -0400 Subject: [PATCH 26/47] Fix pull request comment rendering and disable flags (#322) * Fix orphaned tags and empty tables in PR comments A whitespace-only line closes a CommonMark HTML block. Optional sections that rendered as empty left one behind inside the alerts table, so the indented closing tags after it were rendered as a literal code block reading `` instead of markup. - Drop blank lines from generated comment markup and keep indentation below the four spaces that start a code block. - Collapse alert descriptions, suggestions and license findings onto a single line so multi-line API text cannot break the table either. - Replace the comment body with a short confirmation when no alerts are left to report, instead of keeping the caution banner above a table with no rows. The comment marker is preserved so the same comment is updated later. - Apply ignore-all to the pre-2.0.55 Markdown table format. The check was made once per ignore command and an ignore-all comment produces none, so no rows were removed. Bumps to 2.6.8. * Make --disable-security-issue and --disable-overview suppress comments Both flags were checked only after testing whether a comment of that type was already on the pull request, so they suppressed the first post and then updated that comment on every later run. --disable-security-issue in particular kept refreshing an existing comment with the full alerts table. The flags now mean the CLI does not manage that comment at all. An existing comment is left untouched rather than rewritten, since a body claiming no alerts would be inaccurate when reporting is merely switched off. Moves the decision into should_write_comment() so it is covered by tests directly; main_code() had no harness for this block. Bumps to 2.7.0 rather than a patch, since these flags change behavior. --- CHANGELOG.md | 36 ++- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/messages.py | 110 ++++++++- socketsecurity/core/scm_comments.py | 57 ++++- socketsecurity/socketcli.py | 52 ++-- tests/unit/test_pr_comment_rendering.py | 304 ++++++++++++++++++++++++ tests/unit/test_socketcli.py | 45 +++- uv.lock | 2 +- 9 files changed, 566 insertions(+), 44 deletions(-) create mode 100644 tests/unit/test_pr_comment_rendering.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 074bb8b6..21150734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2.6.12 +## 2.7.0 ### Fixed: unreadable reachability facts no longer report a blocking package @@ -18,6 +18,39 @@ temporary directory previously used the same path and could remove each other's placeholder mid-upload. +### Fixed: pull request comments no longer show orphaned tags or an empty table + +- Optional sections that rendered as empty, such as the ignore instructions + suppressed by `--disable-ignore`, left a whitespace-only line in the alerts + table. That line closed the surrounding HTML block, and the indented + `` tags after it were rendered as a literal code block. + Generated comment markup now omits blank lines and stays under the indentation + that starts a code block. +- Alert descriptions, suggestions and license findings are collapsed onto a + single line so multi-line API text cannot break the table markup either. +- When a pull request has no alerts left to report, the security comment is + replaced with a short confirmation instead of keeping the "Caution" banner + above a table with no rows. This happens both when a later commit resolves + every alert and when every alert is ignored by comment. The comment marker is + preserved, so a commit that reintroduces an alert updates the same comment + rather than posting a second one. +- `@SocketSecurity ignore-all` now applies to comments written by CLI versions + before 2.0.55, which use the older Markdown alerts table. The check was made + once per ignore command, and an ignore-all comment produces none, so no rows + were removed. + +### Fixed: `--disable-security-issue` and `--disable-overview` now suppress the comment entirely + +- Both flags were checked only after testing whether a comment of that type was + already on the pull request, so they suppressed the first post and then + updated that comment on every later run. `--disable-security-issue` in + particular kept refreshing an existing comment with the full alerts table. +- The flags now mean the CLI does not manage that comment at all. An existing + comment is left untouched rather than being rewritten, since a body claiming + no alerts would be inaccurate when reporting is merely switched off. +- The decision moved into `should_write_comment()` so it is covered directly by + tests. + ## 2.6.11 ### Changed: bump pinned @coana-tech/cli to 15.10.32 @@ -49,7 +82,6 @@ - Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.23` to `15.10.25`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for engine changes. - ## 2.6.7 ### Changed: bump pinned @coana-tech/cli to 15.10.23 diff --git a/pyproject.toml b/pyproject.toml index b85e8b80..293bbd13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.12" +version = "2.7.0" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index c1ba543c..d72ecc6e 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.12' +__version__ = '2.7.0' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index 319e454b..673dde5c 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -806,6 +806,90 @@ def create_security_comment_gitlab(diff: Diff) -> dict: return gitlab_report + # A blank line terminates a CommonMark HTML block. When that happens inside + # the alerts table the closing tags that follow are no longer treated as + # markup, and because they are indented four or more spaces they render as a + # literal code block containing `` instead. + MAX_HTML_INDENT = 3 + + @staticmethod + def inline_html_text(value) -> str: + """ + Collapses API supplied text onto a single line. + + Alert descriptions and suggestions are interpolated into the comment HTML, + so an embedded newline would otherwise be able to close the surrounding + HTML block early. + + :param value: The value to flatten. ``None`` becomes an empty string. + :return: str - The value with all whitespace runs collapsed to a single space. + """ + if value is None: + return "" + return " ".join(str(value).split()) + + @staticmethod + def normalize_comment_html(comment: str) -> str: + """ + Makes generated comment markup safe for the CommonMark renderers used by + GitHub and GitLab. + + Drops whitespace-only lines (an optional section that rendered as empty + leaves one behind) and caps indentation below the four spaces that would + start an indented code block. Intentional separators - lines that are + genuinely empty - are preserved so markdown blocks still break apart. + + :param comment: str - The generated comment body. + :return: str - The comment body with unrenderable whitespace removed. + """ + lines = [] + for line in comment.split("\n"): + if line and not line.strip(): + continue + stripped = line.lstrip() + indent = min(len(line) - len(stripped), Messages.MAX_HTML_INDENT) + lines.append(" " * indent + stripped) + return "\n".join(lines) + + @staticmethod + def security_comment_no_alerts_template(view_report_url: str = "") -> str: + """ + Generates the body used when there is nothing left to report. + + Alerts raised on an early commit are frequently resolved later in the same + pull request. Rewriting the comment to this body keeps the Socket comment + in place - so a later commit that reintroduces an alert updates it rather + than posting a second comment - without leaving the "Caution" banner above + an empty alerts table. + + :param view_report_url: str - Optional link to the full Socket report. + :return: str - The formatted Markdown/HTML string. + """ + lines = [ + "", + "", + "> **✅ Socket Security** ", + "> No dependency alerts to report. Any alerts previously reported on this " + "pull request have been resolved or ignored.", + ] + if view_report_url: + lines += ["", f"[View full report]({view_report_url})"] + return "\n".join(lines) + "\n" + + @staticmethod + def get_view_report_url(diff: Diff) -> str: + """ + Resolves the report link for a diff, preferring the PR/MR diff view. + + :param diff: Diff - Diff report to pull the URL from. + :return: str - The report URL, or an empty string when neither is set. + """ + if getattr(diff, "diff_url", None): + return diff.diff_url + if getattr(diff, "report_url", None): + return diff.report_url + return "" + @staticmethod def security_comment_template(diff: Diff, config=None) -> str: """ @@ -819,7 +903,7 @@ def security_comment_template(diff: Diff, config=None) -> str: # Group license policy violations by PURL (ecosystem/package@version) license_groups = {} security_alerts = [] - + for alert in diff.new_alerts: if alert.type == "licenseSpdxDisj": purl_key = f"{alert.pkg_type}/{alert.pkg_name}@{alert.pkg_version}" @@ -829,6 +913,13 @@ def security_comment_template(diff: Diff, config=None) -> str: else: security_alerts.append(alert) + view_report_url = Messages.get_view_report_url(diff) + + # Without this the caution banner would sit above a table with no rows, + # which is how a comment looks once every alert it raised is resolved. + if not security_alerts and not license_groups: + return Messages.security_comment_no_alerts_template(view_report_url) + # Start of the comment comment = """ @@ -875,15 +966,15 @@ def security_comment_template(diff: Diff, config=None) -> str:
- {alert.pkg_name}@{alert.pkg_version} - {alert.title} -

Note: {alert.description}

+ {alert.pkg_name}@{alert.pkg_version} - {Messages.inline_html_text(alert.title)} +

Note: {Messages.inline_html_text(alert.description)}

Source: Manifest File

ℹ️ Read more on: This package | This alert | What is known malware?

-

Suggestion: {alert.suggestion}

+

Suggestion: {Messages.inline_html_text(alert.suggestion)}

{ignore_html}
@@ -917,7 +1008,7 @@ def security_comment_template(diff: Diff, config=None) -> str:
    """ for finding in license_findings: - comment += f"
  • {finding}
  • \n" + comment += f"
  • {Messages.inline_html_text(finding)}
  • \n" # Generate proper manifest URL for license violations @@ -944,13 +1035,6 @@ def security_comment_template(diff: Diff, config=None) -> str: """ # Close table - # Use diff_url for PRs, report_url for non-PR scans - view_report_url = "" - if hasattr(diff, 'diff_url') and diff.diff_url: - view_report_url = diff.diff_url - elif hasattr(diff, 'report_url') and diff.report_url: - view_report_url = diff.report_url - comment += f""" @@ -959,7 +1043,7 @@ def security_comment_template(diff: Diff, config=None) -> str: [View full report]({view_report_url}?action=error%2Cwarn) """ - return comment + return Messages.normalize_comment_html(comment) @staticmethod def get_severity_icon(severity: str) -> str: diff --git a/socketsecurity/core/scm_comments.py b/socketsecurity/core/scm_comments.py index 741578eb..7c479b72 100644 --- a/socketsecurity/core/scm_comments.py +++ b/socketsecurity/core/scm_comments.py @@ -1,12 +1,16 @@ import json +import re from requests import Response from socketsecurity.core import log from socketsecurity.core.classes import Comment, Issue +from socketsecurity.core.messages import Messages class Comments: + VIEW_REPORT_PATTERN = re.compile(r"\[View full report\]\(([^)\s]+)\)") + @staticmethod def process_response(response: Response) -> dict: output = {} @@ -84,6 +88,20 @@ def is_heading_line(line) -> bool: is_heading_line = False return is_heading_line + @staticmethod + def extract_report_url(body: str) -> str: + """ + Pulls the Socket report link out of an existing comment body so it can be + carried over when the comment is rewritten. + + :param body: str - The existing comment body. + :return: str - The report URL without its query string, or "" if absent. + """ + match = Comments.VIEW_REPORT_PATTERN.search(body) + if not match: + return "" + return match.group(1).split("?", 1)[0] + @staticmethod def process_security_comment(comment: Comment, comments) -> str: ignore_all, ignore_commands = Comments.get_ignore_options(comments) @@ -102,6 +120,7 @@ def process_original_security_comment( ) -> str: start = False lines = [] + kept_alert = False for line in comment.body_list: line = line.strip() if "start-socket-alerts-table" in line: @@ -114,17 +133,25 @@ def process_original_security_comment( ecosystem = ecosystem.lstrip("[") pkg_name, pkg_version = details.split("@") pkg_name = f"{ecosystem}/{pkg_name}" - ignore = False - for name, version in ignore_commands: - if ignore_all or Comments.is_ignore(pkg_name, pkg_version, name, version): - ignore = True + # ignore_all has to be checked outside the loop: an ignore-all + # comment produces no ignore_commands, so a loop-internal check + # never runs and every row was kept. + ignore = ignore_all or any( + Comments.is_ignore(pkg_name, pkg_version, name, version) + for name, version in ignore_commands + ) if not ignore: + kept_alert = True lines.append(line) elif "end-socket-alerts-table" in line: start = False lines.append(line) else: lines.append(line) + if not kept_alert: + return Messages.security_comment_no_alerts_template( + Comments.extract_report_url("\n".join(comment.body_list)) + ) return "\n".join(lines) @staticmethod @@ -145,17 +172,21 @@ def process_updated_security_comment( """ lines = [] ignore_section = False + kept_alert = False # Whether any alert row survived the ignore commands pkg_name = pkg_version = "" # Track current package and version # Loop through the comment lines for line in comment.body_list: - line = line.strip() + # Match on the stripped line but keep the original, so the markup is + # rewritten with the same indentation it was generated with. + line = line.rstrip("\r") + stripped = line.strip() # Detect the start of an alert section - if line.startswith("\n\n> text") + assert result == "\n\n> text" + + def test_caps_indentation_below_code_block_threshold(self): + result = Messages.normalize_comment_html(" ") + assert result == " " + + def test_preserves_trailing_markdown_line_breaks(self): + result = Messages.normalize_comment_html("> **Caution** ") + assert result == "> **Caution** " + + +# --- inline_html_text --- + +class TestInlineHtmlText: + def test_collapses_newlines(self): + assert Messages.inline_html_text("one\n\ntwo") == "one two" + + def test_handles_none(self): + assert Messages.inline_html_text(None) == "" + + +# --- Generated comment bodies --- + +class TestSecurityCommentTemplateRendering: + def test_security_alert_row_is_render_safe(self): + body = Messages.security_comment_template(_make_diff([_make_alert()]), _FakeConfig()) + assert_html_block_intact(body) + + def test_render_safe_when_ignore_instructions_disabled(self): + """The empty ignore block used to leave a whitespace-only line behind.""" + body = Messages.security_comment_template( + _make_diff([_make_alert()]), _FakeConfig(disable_ignore=True) + ) + assert_html_block_intact(body) + assert "" in body + assert "@SocketSecurity ignore" not in body + + def test_license_row_render_safe_when_ignore_instructions_disabled(self): + body = Messages.security_comment_template( + _make_diff([_make_alert(type="licenseSpdxDisj", title="LGPL-3.0")]), + _FakeConfig(disable_ignore=True), + ) + assert_html_block_intact(body) + assert "License Policy Violation" in body + + def test_multiline_alert_text_is_flattened(self): + body = Messages.security_comment_template( + _make_diff([ + _make_alert(description="Line one.\n\nLine two.", suggestion="Do this.\nThen that.") + ]), + _FakeConfig(), + ) + assert_html_block_intact(body) + assert "

    Note: Line one. Line two.

    " in body + assert "Do this. Then that." in body + + def test_license_finding_text_is_flattened(self): + body = Messages.security_comment_template( + _make_diff([_make_alert(type="licenseSpdxDisj", title="LGPL-3.0\n\nAND MIT")]), + _FakeConfig(), + ) + assert_html_block_intact(body) + assert "
  • LGPL-3.0 AND MIT
  • " in body + + def test_alert_markers_are_preserved(self): + body = Messages.security_comment_template(_make_diff([_make_alert()]), _FakeConfig()) + assert "" in body + assert "" in body + + +class TestSecurityCommentTemplateWithNoAlerts: + def test_no_alerts_omits_the_empty_table(self): + body = Messages.security_comment_template(_make_diff([]), _FakeConfig()) + assert "" not in body + assert "Caution" not in body + assert "No dependency alerts to report" in body + + def test_no_alerts_keeps_the_comment_discoverable(self): + """The marker has to survive so a later commit updates this comment + instead of posting a second one.""" + body = Messages.security_comment_template(_make_diff([]), _FakeConfig()) + found = Comments.check_for_socket_comments({1: _make_comment(body)}) + assert "security" in found + + def test_no_alerts_keeps_the_report_link(self): + body = Messages.security_comment_template(_make_diff([]), _FakeConfig()) + assert "[View full report](https://socket.dev/report/abc)" in body + + def test_no_alerts_without_report_url_omits_the_link(self): + diff = Diff() + diff.id = "test-scan-id" + diff.new_alerts = [] + body = Messages.security_comment_template(diff, _FakeConfig()) + assert "View full report" not in body + + +# --- Ignore round trip --- + +def _security_comment_with(alerts: list, config=None) -> Comment: + body = Messages.security_comment_template(_make_diff(alerts), config or _FakeConfig()) + return _make_comment(body) + + +class TestProcessUpdatedSecurityComment: + def _two_alert_comment(self) -> Comment: + return _security_comment_with([ + _make_alert(), + _make_alert(pkg_name="express", pkg_version="4.18.2", purl="pkg:npm/express@4.18.2"), + ]) + + def test_partial_ignore_keeps_remaining_alert_render_safe(self): + security = self._two_alert_comment() + ignore = _make_comment("SocketSecurity ignore lodash@4.17.21", comment_id=2) + comments = {"security": security, "ignore": [ignore]} + + new_body = Comments.process_security_comment(security, comments) + + assert_html_block_intact(new_body) + assert "start-socket-alert-express@4.18.2" in new_body + assert "start-socket-alert-lodash@4.17.21" not in new_body + + def test_ignore_all_collapses_to_the_no_alerts_body(self): + security = self._two_alert_comment() + ignore = _make_comment("SocketSecurity ignore-all", comment_id=2) + comments = {"security": security, "ignore": [ignore]} + + new_body = Comments.process_security_comment(security, comments) + + assert "
    " not in new_body + assert "No dependency alerts to report" in new_body + assert "[View full report](https://socket.dev/report/abc)" in new_body + + def test_ignoring_every_alert_individually_collapses_too(self): + security = self._two_alert_comment() + comments = { + "security": security, + "ignore": [ + _make_comment("SocketSecurity ignore lodash@4.17.21", comment_id=2), + _make_comment("SocketSecurity ignore express@4.18.2", comment_id=3), + ], + } + + new_body = Comments.process_security_comment(security, comments) + + assert "No dependency alerts to report" in new_body + + def test_no_ignore_commands_leaves_alerts_in_place(self): + security = self._two_alert_comment() + comments = {"security": security, "ignore": []} + + new_body = Comments.process_security_comment(security, comments) + + assert "start-socket-alert-lodash@4.17.21" in new_body + assert "start-socket-alert-express@4.18.2" in new_body + + def test_collapsed_body_is_stable_when_reprocessed(self): + security = self._two_alert_comment() + ignore = _make_comment("SocketSecurity ignore-all", comment_id=2) + comments = {"security": security, "ignore": [ignore]} + + first = Comments.process_security_comment(security, comments) + comments["security"] = _make_comment(first) + second = Comments.process_security_comment(comments["security"], comments) + + assert first == second + + +LEGACY_COMMENT = """ + + +|Alert|Package|Introduced by|Manifest File|CI| +|:---|:---|:---|:---|:---| +|Known Malware|[npm/lodash@4.17.21](https://socket.dev/x)|lodash|package.json|:no_entry_sign:| +|Known Malware|[npm/express@4.18.2](https://socket.dev/y)|express|package.json|:no_entry_sign:| + + +[View full report](https://socket.dev/report/legacy?action=error%2Cwarn) +""" + + +class TestProcessOriginalSecurityComment: + def test_partial_ignore_keeps_remaining_row(self): + security = _make_comment(LEGACY_COMMENT) + comments = { + "security": security, + "ignore": [_make_comment("SocketSecurity ignore npm/lodash@4.17.21", comment_id=2)], + } + + new_body = Comments.process_security_comment(security, comments) + + assert "npm/express@4.18.2" in new_body + assert "npm/lodash@4.17.21" not in new_body + + def test_ignore_all_collapses_to_the_no_alerts_body(self): + security = _make_comment(LEGACY_COMMENT) + comments = { + "security": security, + "ignore": [_make_comment("SocketSecurity ignore-all", comment_id=2)], + } + + new_body = Comments.process_security_comment(security, comments) + + assert "|Alert|Package|" not in new_body + assert "No dependency alerts to report" in new_body + assert "[View full report](https://socket.dev/report/legacy)" in new_body + + +class TestExtractReportUrl: + def test_strips_the_action_filter(self): + url = Comments.extract_report_url( + "[View full report](https://socket.dev/report/abc?action=error%2Cwarn)" + ) + assert url == "https://socket.dev/report/abc" + + def test_returns_empty_when_absent(self): + assert Comments.extract_report_url("no link here") == "" diff --git a/tests/unit/test_socketcli.py b/tests/unit/test_socketcli.py index 39f59f5b..8cae52ba 100644 --- a/tests/unit/test_socketcli.py +++ b/tests/unit/test_socketcli.py @@ -4,7 +4,7 @@ from socketsecurity.core.classes import Diff, Package from socketsecurity import socketcli -from socketsecurity.socketcli import build_license_artifact_payload +from socketsecurity.socketcli import build_license_artifact_payload, should_write_comment # --------------------------------------------------------------------------- @@ -228,3 +228,46 @@ class Config: assert payload["deepDependencies"] == [] assert payload["copyrightsByLicense"] == {} assert payload["licenses"] == {} + + +# --------------------------------------------------------------------------- +# Comment write decision. +# +# --disable-security-issue used to be checked only after the "is there already +# a comment" test, so it suppressed the first post on a pull request and then +# updated that comment with the full alerts table on every later run. +# --------------------------------------------------------------------------- + + +class TestShouldWriteComment: + def test_disabled_never_writes_even_when_a_comment_exists(self): + assert should_write_comment( + disabled=True, has_findings=True, update_existing=True + ) is False + + def test_disabled_never_writes_with_no_existing_comment(self): + assert should_write_comment( + disabled=True, has_findings=True, update_existing=False + ) is False + + def test_disabled_wins_over_findings(self): + """The flag is not conditional on there being nothing to report.""" + assert should_write_comment( + disabled=True, has_findings=False, update_existing=True + ) is False + + def test_findings_are_written(self): + assert should_write_comment( + disabled=False, has_findings=True, update_existing=False + ) is True + + def test_no_findings_refreshes_an_existing_comment(self): + """So a resolved alerts table gets cleared rather than left stale.""" + assert should_write_comment( + disabled=False, has_findings=False, update_existing=True + ) is True + + def test_no_findings_does_not_open_a_new_comment(self): + assert should_write_comment( + disabled=False, has_findings=False, update_existing=False + ) is False diff --git a/uv.lock b/uv.lock index 8e880d32..90f23669 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.12" +version = "2.7.0" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 1eaf52869ccc16a4bd73f748e8d28e0285200fc4 Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:49:56 +0200 Subject: [PATCH 27/47] Bump pinned @coana-tech/cli to 15.10.36 (#335) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 2 +- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21150734..1007a3b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.7.1 + +### Changed: bump pinned @coana-tech/cli to 15.10.36 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.32` to + `15.10.36`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + ## 2.7.0 ### Fixed: unreadable reachability facts no longer report a blocking package diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6c94daf3..51ad6466 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -275,7 +275,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.32 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.36 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index 293bbd13..3dcd5718 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.7.0" +version = "2.7.1" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index d72ecc6e..78220a1f 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.7.0' +__version__ = '2.7.1' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 830a5118..7ddf7c68 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -18,7 +18,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.32" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.36" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -55,7 +55,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.32'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.36'). Args: version: Coana CLI version to use. @@ -64,7 +64,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.32'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.36'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index 90f23669..d0338009 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.7.0" +version = "2.7.1" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 6076acf0df8457bb900dc669cfd4da5ae093a3a4 Mon Sep 17 00:00:00 2001 From: Oskar Haarklou Veileborg Date: Thu, 3 Sep 2026 15:16:03 +0200 Subject: [PATCH 28/47] Stop passing --lazy-mode to the coana CLI (#336) The coana CLI no longer accepts --lazy-mode, so a reachability run started with --reach-lazy-mode failed on an unrecognized argument. --reach-lazy-mode is kept as a hidden no-op, matching how --reach-disable-analysis-splitting was deprecated, so pipelines that still pass it keep working rather than failing argument parsing. Nothing is forwarded to coana, and the flag is dropped from the reachability documentation. No version bump: this is not being released on its own. --- docs/cli-reference.md | 3 +-- socketsecurity/config.py | 4 ++-- socketsecurity/core/tools/reachability.py | 5 ----- socketsecurity/socketcli.py | 1 - 4 files changed, 3 insertions(+), 10 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 51ad6466..0d4d4833 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -156,7 +156,7 @@ socketcli [-h] [--api-token API_TOKEN] [--repo REPO] [--workspace WORKSPACE] [-- [--reach] [--reach-version REACH_VERSION] [--reach-analysis-timeout REACH_ANALYSIS_TIMEOUT] [--reach-analysis-memory-limit REACH_ANALYSIS_MEMORY_LIMIT] [--reach-concurrency REACH_CONCURRENCY] [--reach-ecosystems REACH_ECOSYSTEMS] [--reach-min-severity ] [--reach-skip-cache] [--reach-disable-analytics] [--reach-enable-analysis-splitting] [--reach-detailed-analysis-log-file] - [--reach-lazy-mode] [--reach-use-only-pregenerated-sboms] [--reach-debug] [--reach-disable-external-tool-checks] + [--reach-use-only-pregenerated-sboms] [--reach-debug] [--reach-disable-external-tool-checks] [--reach-output-file REACH_OUTPUT_FILE] [--only-facts-file] [--version] ```` @@ -286,7 +286,6 @@ If you don't want to provide the Socket API Token every time then you can use th | `--reach-disable-analytics` | False | False | Disable analytics collection during reachability analysis | | `--reach-enable-analysis-splitting` | False | False | Enable analysis splitting/bucketing (a legacy performance feature). Splitting is disabled by default. | | `--reach-detailed-analysis-log-file` | False | False | Write a detailed analysis log file; its path is printed to stdout | -| `--reach-lazy-mode` | False | False | Enable lazy mode (experimental performance feature) | | `--reach-use-only-pregenerated-sboms` | False | False | Build the scan only from pre-generated CycloneDX (CDX) and SPDX files in your project (requires --reach) | | `--reach-debug` | False | False | Enable coana debug output (`--debug`) for the analysis, independent of the global `--enable-debug` | | `--reach-disable-external-tool-checks` | False | False | Disable coana's external tool availability checks (passes `--disable-external-tool-checks`) | diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 26542447..0a6a5be4 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -176,7 +176,7 @@ class CliConfig: reach_disable_analysis_splitting: bool = False # Deprecated, kept for backwards compatibility reach_enable_analysis_splitting: bool = False reach_detailed_analysis_log_file: bool = False - reach_lazy_mode: bool = False + reach_lazy_mode: bool = False # Deprecated, kept for backwards compatibility reach_ecosystems: Optional[List[str]] = None reach_exclude_paths: Optional[List[str]] = None reach_skip_cache: bool = False @@ -1091,7 +1091,7 @@ def create_argument_parser() -> argparse.ArgumentParser: "--reach-lazy-mode", dest="reach_lazy_mode", action="store_true", - help="Enable lazy mode for reachability analysis. This is an experimental feature for improving performance" + help=argparse.SUPPRESS # Deprecated, kept for backwards compatibility (no-op) ) reachability_group.add_argument( "--reach-output-file", diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 7ddf7c68..ff409b7b 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -88,7 +88,6 @@ def run_reachability_analysis( disable_analytics: bool = False, enable_analysis_splitting: bool = False, detailed_analysis_log_file: bool = False, - lazy_mode: bool = False, repo_name: Optional[str] = None, branch_name: Optional[str] = None, version: Optional[str] = None, @@ -123,7 +122,6 @@ def run_reachability_analysis( disable_analytics: Disable analytics sharing enable_analysis_splitting: Enable analysis splitting (disabled by default) detailed_analysis_log_file: Print detailed analysis log file path - lazy_mode: Enable lazy mode for analysis repo_name: Repository name branch_name: Branch name version: @coana-tech/cli version to use. None uses the pinned @@ -172,9 +170,6 @@ def run_reachability_analysis( if detailed_analysis_log_file: coana_args.append("--print-analysis-log-file") - if lazy_mode: - coana_args.append("--lazy-mode") - # KEY POINT: Only add manifest tar hash if we have one if tar_hash: coana_args.extend(["--run-without-docker", "--manifests-tar-hash", tar_hash]) diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 0c967ccf..b4844f31 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -450,7 +450,6 @@ def main_code(): disable_analytics=config.reach_disable_analytics or False, enable_analysis_splitting=config.reach_enable_analysis_splitting or False, detailed_analysis_log_file=config.reach_detailed_analysis_log_file or False, - lazy_mode=config.reach_lazy_mode or False, repo_name=None if repo_defaulted else config.repo, branch_name=None if branch_defaulted else config.branch, version=config.reach_version, From 4601aa130c5013fe5961f2d6261dcb9c04dfa485 Mon Sep 17 00:00:00 2001 From: Oskar Haarklou Veileborg Date: Fri, 4 Sep 2026 10:10:39 +0200 Subject: [PATCH 29/47] Make tar_hash required for reachability analysis (#341) run_reachability_analysis() always receives a manifest tar hash from its only caller, so drop the Optional default and the conditional that skipped --run-without-docker/--manifests-tar-hash when it was absent. Pass tar_hash at the test call sites accordingly. --- socketsecurity/core/tools/reachability.py | 10 +++------- socketsecurity/socketcli.py | 14 ++++++-------- tests/unit/test_reachability.py | 16 ++++++++-------- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index ff409b7b..efa5f9c5 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -77,7 +77,7 @@ def run_reachability_analysis( self, org_slug: str, target_directory: str, - tar_hash: Optional[str] = None, + tar_hash: str, output_path: str = ".socket.facts.json", timeout: Optional[str] = None, memory_limit: Optional[str] = None, @@ -151,9 +151,7 @@ def run_reachability_analysis( # Add conditional arguments. timeout/memory_limit are forwarded verbatim; coana owns # unit parsing/validation (e.g. '90s', '8GB'). We coerce to str only for subprocess - # safety — config-file values can arrive as ints via argparse set_defaults — and use - # `is not None` (not truthiness) so an explicit empty string still reaches coana and - # triggers coana's own error, rather than being silently dropped. + # safety — config-file values can arrive as ints via argparse set_defaults. if timeout is not None: coana_args.extend(["--analysis-timeout", str(timeout)]) @@ -170,9 +168,7 @@ def run_reachability_analysis( if detailed_analysis_log_file: coana_args.append("--print-analysis-log-file") - # KEY POINT: Only add manifest tar hash if we have one - if tar_hash: - coana_args.extend(["--run-without-docker", "--manifests-tar-hash", tar_hash]) + coana_args.extend(["--run-without-docker", "--manifests-tar-hash", tar_hash]) if ecosystems: coana_args.extend(["--purl-types"] + ecosystems) diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index b4844f31..1179e915 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -391,14 +391,11 @@ def main_code(): # Find manifest files in scan paths (excluding .socket.facts.json to avoid circular dependency) log.info("Finding manifest files for reachability analysis...") - manifest_files = [] - - # Always find all manifest files for the tar hash upload - for scan_path in scan_paths: - scan_manifests = core.find_files(scan_path) - # Filter out .socket.facts.json files from manifest upload - scan_manifests = [f for f in scan_manifests if not f.endswith('.socket.facts.json')] - manifest_files.extend(scan_manifests) + manifest_files = [ + # Always find all manifest files for the tar hash upload + f for scan_path in scan_paths for f in core.find_files(scan_path) + if not f.endswith('.socket.facts.json') + ] if not manifest_files: log.warning("No manifest files found for reachability analysis") @@ -410,6 +407,7 @@ def main_code(): try: # Get org_slug early (we'll need it) org_slug = core.config.org_slug + assert org_slug # Upload manifest files tar_hash = sdk.uploadmanifests.upload_manifest_files( diff --git a/tests/unit/test_reachability.py b/tests/unit/test_reachability.py index 670fd57f..71a20dc0 100644 --- a/tests/unit/test_reachability.py +++ b/tests/unit/test_reachability.py @@ -44,7 +44,7 @@ def _spawn_mock(analyzer, mocker, returncode=0, **kwargs): completed.returncode = returncode run_mock = mocker.patch.object(reachability.subprocess, "run", return_value=completed) - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", **kwargs) + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123", **kwargs) return run_mock @@ -246,7 +246,7 @@ def fake_run(argv, **_kw): return m mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run) - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", **kwargs) + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123", **kwargs) return calls @@ -285,7 +285,7 @@ def fake_run(argv, **_kw): mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run) with pytest.raises(Exception): - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".") + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123") assert calls[0][0] == "npx" assert all(c[:2] != ["npm", "install"] for c in calls) @@ -313,7 +313,7 @@ def fake_run(argv, **_kw): mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run) with pytest.raises(Exception): - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".") + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123") assert all(c[:2] != ["npm", "install"] for c in calls) @@ -340,7 +340,7 @@ def fake_run(argv, **_kw): mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run) with pytest.raises(Exception): - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".") + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123") assert calls[0][0] == "npx" assert all(c[:2] != ["npm", "install"] for c in calls) @@ -385,8 +385,8 @@ def fake_run(argv, **_kw): return m mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run) - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".") - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".") + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123") + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123") npm_installs = [c for c in calls if c[:2] == ["npm", "install"]] assert len(npm_installs) == 1 # installed once, reused on the second fallback @@ -415,7 +415,7 @@ def fake_run(argv, **_kw): mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run) with pytest.raises(Exception, match="node"): - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".") + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123") def test_build_coana_node_cmd_js_vs_binary(): From eb3e46a3c00d0ebcb081c90ff5683d869fc832b5 Mon Sep 17 00:00:00 2001 From: Oskar Haarklou Veileborg Date: Fri, 4 Sep 2026 13:58:35 +0200 Subject: [PATCH 30/47] Apply ruff autofixes and add a ruff CI job (#342) * Apply ruff autofixes for imports and lint nits Result of `uv run ruff check --fix --unsafe-fixes`: sort and regroup imports (isort), drop unused imports, remove f-string prefixes from strings with no placeholders, drop unused bindings, and simplify `== True`/`== False` assertions to truthiness checks. * Add a ruff job to the unit test workflow Mirrors the checkout and python setup from python-tests. Installs the dev extra rather than test, since that is where the ruff pin lives. --- .github/workflows/python-tests.yml | 20 +++++++++++++++++++ socketsecurity/config.py | 8 +++++--- socketsecurity/core/cli_client.py | 1 + socketsecurity/core/helper/__init__.py | 3 ++- .../core/helper/socket_facts_loader.py | 6 +++--- socketsecurity/core/lazy_file_loader.py | 4 +--- socketsecurity/core/messages.py | 1 + socketsecurity/core/resource_utils.py | 1 - socketsecurity/core/scm/client.py | 1 + socketsecurity/core/scm/gitlab.py | 7 ++++--- socketsecurity/core/socket_config.py | 7 +++---- socketsecurity/core/tools/reachability.py | 13 ++++++------ socketsecurity/output.py | 15 ++++++++------ socketsecurity/plugins/formatters/slack.py | 2 +- socketsecurity/plugins/jira.py | 9 ++++++--- socketsecurity/plugins/manager.py | 2 +- socketsecurity/plugins/slack.py | 13 +++++++----- socketsecurity/plugins/teams.py | 4 +++- socketsecurity/plugins/webhook.py | 4 +++- tests/core/test_diff_alerts.py | 1 - tests/unit/test_cli_config.py | 1 + tests/unit/test_client.py | 12 ++++++----- tests/unit/test_config.py | 8 +++++--- tests/unit/test_disable_ignore.py | 2 -- tests/unit/test_gitlab_auth.py | 3 ++- tests/unit/test_gitlab_auth_fallback.py | 11 +++++----- tests/unit/test_gitlab_commit_status.py | 4 +--- tests/unit/test_gitlab_format.py | 3 +-- tests/unit/test_include_dirs.py | 2 -- tests/unit/test_socketcli.py | 8 +++++--- 30 files changed, 107 insertions(+), 69 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 34717226..f05b27c9 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -67,6 +67,26 @@ jobs: uv export --no-hashes --no-emit-project --format requirements-txt > /tmp/req-audit.txt uvx pip-audit --strict --progress-spinner off --disable-pip --no-deps -r /tmp/req-audit.txt + ruff: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + - name: 🐍 setup python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: 🛠️ install deps + run: | + python -m pip install --upgrade pip + pip install uv + uv sync --extra dev + - name: 🧹 run ruff + run: uv run ruff check + unsupported-python-install: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 0a6a5be4..35904976 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -1,12 +1,14 @@ import argparse +import json import logging import os +import tomllib from dataclasses import asdict, dataclass, field from typing import List, Optional -from socketsecurity import __version__ + from socketdev import INTEGRATION_TYPES, IntegrationType -import json -import tomllib + +from socketsecurity import __version__ def get_plugin_config_from_env(prefix: str) -> dict: diff --git a/socketsecurity/core/cli_client.py b/socketsecurity/core/cli_client.py index bfad0d14..2e941e7a 100644 --- a/socketsecurity/core/cli_client.py +++ b/socketsecurity/core/cli_client.py @@ -6,6 +6,7 @@ import requests from socketsecurity import USER_AGENT + from .exceptions import APIFailure from .socket_config import SocketConfig diff --git a/socketsecurity/core/helper/__init__.py b/socketsecurity/core/helper/__init__.py index ab7d06f7..224f3cc7 100644 --- a/socketsecurity/core/helper/__init__.py +++ b/socketsecurity/core/helper/__init__.py @@ -1,7 +1,8 @@ +import string + import markdown from bs4 import BeautifulSoup, Tag from bs4.element import NavigableString -import string class Helper: diff --git a/socketsecurity/core/helper/socket_facts_loader.py b/socketsecurity/core/helper/socket_facts_loader.py index fd93b9dd..26c1ae25 100644 --- a/socketsecurity/core/helper/socket_facts_loader.py +++ b/socketsecurity/core/helper/socket_facts_loader.py @@ -2,9 +2,9 @@ import json import logging -from pathlib import Path -from typing import Dict, Any, Optional, List from copy import deepcopy +from pathlib import Path +from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) @@ -65,7 +65,7 @@ def load_socket_facts(file_path: str = ".socket.facts.json") -> Optional[Dict[st return None if 'components' not in data: - logger.warning(f"Socket facts file missing 'components' key") + logger.warning("Socket facts file missing 'components' key") return data diff --git a/socketsecurity/core/lazy_file_loader.py b/socketsecurity/core/lazy_file_loader.py index 9127652b..a5bfa15a 100644 --- a/socketsecurity/core/lazy_file_loader.py +++ b/socketsecurity/core/lazy_file_loader.py @@ -2,9 +2,7 @@ Lazy file loading utilities for efficient manifest file processing. """ import logging -from typing import List, Tuple, Union, BinaryIO -from io import BytesIO -import os +from typing import List, Tuple log = logging.getLogger("socketdev") diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index 673dde5c..d968c14b 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -5,6 +5,7 @@ import uuid from datetime import datetime, timezone from pathlib import Path + from mdutils import MdUtils from prettytable import PrettyTable diff --git a/socketsecurity/core/resource_utils.py b/socketsecurity/core/resource_utils.py index b49cc2e9..dc78c1b8 100644 --- a/socketsecurity/core/resource_utils.py +++ b/socketsecurity/core/resource_utils.py @@ -2,7 +2,6 @@ System resource utilities for the Socket Security CLI. """ import logging -import sys # The resource module is only available on Unix-like systems resource_available = False diff --git a/socketsecurity/core/scm/client.py b/socketsecurity/core/scm/client.py index 05757117..08769f3f 100644 --- a/socketsecurity/core/scm/client.py +++ b/socketsecurity/core/scm/client.py @@ -2,6 +2,7 @@ from typing import Dict from socketsecurity import USER_AGENT + from ..cli_client import CliClient diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index b3b3492f..2c3947de 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -2,9 +2,10 @@ import os import sys from dataclasses import dataclass -from typing import Optional, Union +from typing import Optional import requests + from socketsecurity import USER_AGENT from socketsecurity.core import log from socketsecurity.core.classes import Comment @@ -140,7 +141,7 @@ def _request_with_fallback(self, **kwargs): except requests.exceptions.HTTPError as e: # Check if this is an authentication error (401) if e.response and e.response.status_code == 401: - log.debug(f"Authentication failed with initial headers, trying fallback method") + log.debug("Authentication failed with initial headers, trying fallback method") # Determine the fallback headers original_headers = kwargs.get('headers', self.config.headers) @@ -153,7 +154,7 @@ def _request_with_fallback(self, **kwargs): # Re-raise the original exception if it's not an auth error or fallback failed raise - except Exception as e: + except Exception: # Handle other types of exceptions that don't have response attribute raise diff --git a/socketsecurity/core/socket_config.py b/socketsecurity/core/socket_config.py index acf01a2d..1089a530 100644 --- a/socketsecurity/core/socket_config.py +++ b/socketsecurity/core/socket_config.py @@ -1,12 +1,11 @@ +import os from dataclasses import dataclass, field -from typing import Dict, Optional +from typing import List, Optional, Set from urllib.parse import urlparse -from typing import Set, List -import os from socketdev.core.issues import AllIssues -from socketsecurity import __version__ +from socketsecurity import __version__ default_exclude_dirs = { "node_modules", "bower_components", "jspm_packages", # JS/TS diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index efa5f9c5..16ddfb1e 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -1,15 +1,16 @@ -from socketdev import socketdev -from typing import List, Optional, Dict, Any, Final import atexit +import json +import logging import os +import pathlib import platform import shutil import subprocess -import json -import pathlib -import logging import sys import tempfile +from typing import Any, Dict, Final, List, Optional + +from socketdev import socketdev from socketsecurity import __version__ @@ -258,7 +259,7 @@ def run_reachability_analysis( # Extract scan ID from output file scan_id = self._extract_scan_id(output_path) - log.info(f"Reachability analysis completed successfully") + log.info("Reachability analysis completed successfully") if scan_id: log.info(f"Scan ID: {scan_id}") diff --git a/socketsecurity/output.py b/socketsecurity/output.py index 63fe565b..769bfd33 100644 --- a/socketsecurity/output.py +++ b/socketsecurity/output.py @@ -2,18 +2,21 @@ import logging from pathlib import Path from typing import Any, Dict, Optional -from .core.messages import Messages -from .core.classes import Diff, Issue -from .config import CliConfig -from .fossa_compat import build_fossa_report_payload -from socketsecurity.plugins.manager import PluginManager + +from socketdev import socketdev + from socketsecurity.core.alert_selection import ( clone_diff_with_selected_alerts, filter_alerts_by_reachability, load_components_with_alerts, select_diff_alerts, ) -from socketdev import socketdev +from socketsecurity.plugins.manager import PluginManager + +from .config import CliConfig +from .core.classes import Diff, Issue +from .core.messages import Messages +from .fossa_compat import build_fossa_report_payload class OutputHandler: diff --git a/socketsecurity/plugins/formatters/slack.py b/socketsecurity/plugins/formatters/slack.py index adce7eec..1a3ab874 100644 --- a/socketsecurity/plugins/formatters/slack.py +++ b/socketsecurity/plugins/formatters/slack.py @@ -1,8 +1,8 @@ """Slack formatter for Socket Facts (reachability analysis) data.""" import logging -from typing import Dict, Any, List from collections import defaultdict +from typing import Any, Dict, List logger = logging.getLogger(__name__) diff --git a/socketsecurity/plugins/jira.py b/socketsecurity/plugins/jira.py index 7dc6fe0b..9de187f9 100644 --- a/socketsecurity/plugins/jira.py +++ b/socketsecurity/plugins/jira.py @@ -1,9 +1,12 @@ -from .base import Plugin -import requests import base64 -from socketsecurity.core.classes import Diff + +import requests + from socketsecurity.config import CliConfig from socketsecurity.core import log +from socketsecurity.core.classes import Diff + +from .base import Plugin class JiraPlugin(Plugin): diff --git a/socketsecurity/plugins/manager.py b/socketsecurity/plugins/manager.py index b2397d11..9929fa79 100644 --- a/socketsecurity/plugins/manager.py +++ b/socketsecurity/plugins/manager.py @@ -1,4 +1,4 @@ -from . import jira, webhook, slack, teams +from . import jira, slack, teams, webhook PLUGIN_CLASSES = { "jira": jira.JiraPlugin, diff --git a/socketsecurity/plugins/slack.py b/socketsecurity/plugins/slack.py index d5b4b62b..fae3b2c7 100644 --- a/socketsecurity/plugins/slack.py +++ b/socketsecurity/plugins/slack.py @@ -1,22 +1,25 @@ import logging import os + import requests + from socketsecurity.config import CliConfig -from .base import Plugin -from socketsecurity.core.classes import Diff -from socketsecurity.core.messages import Messages from socketsecurity.core.alert_selection import ( clone_diff_with_selected_alerts, filter_alerts_by_reachability, select_diff_alerts, ) +from socketsecurity.core.classes import Diff from socketsecurity.core.helper.socket_facts_loader import ( - load_socket_facts, + convert_to_alerts, get_components_with_vulnerabilities, - convert_to_alerts + load_socket_facts, ) +from socketsecurity.core.messages import Messages from socketsecurity.plugins.formatters.slack import format_socket_facts_for_slack +from .base import Plugin + logger = logging.getLogger(__name__) diff --git a/socketsecurity/plugins/teams.py b/socketsecurity/plugins/teams.py index def9522d..9f347094 100644 --- a/socketsecurity/plugins/teams.py +++ b/socketsecurity/plugins/teams.py @@ -1,6 +1,8 @@ -from .base import Plugin import requests +from .base import Plugin + + class TeamsPlugin(Plugin): def send(self, message, level): if not self.config.get("enabled", False): diff --git a/socketsecurity/plugins/webhook.py b/socketsecurity/plugins/webhook.py index 4793c67f..17857529 100644 --- a/socketsecurity/plugins/webhook.py +++ b/socketsecurity/plugins/webhook.py @@ -1,6 +1,8 @@ -from .base import Plugin import requests +from .base import Plugin + + class WebhookPlugin(Plugin): def send(self, message, level): if not self.config.get("enabled", False): diff --git a/tests/core/test_diff_alerts.py b/tests/core/test_diff_alerts.py index 5455e3c0..9b5be92d 100644 --- a/tests/core/test_diff_alerts.py +++ b/tests/core/test_diff_alerts.py @@ -1,4 +1,3 @@ -import pytest from socketsecurity.core import Core from socketsecurity.core.classes import Issue diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 39447c2c..6aa9bf1a 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -1,4 +1,5 @@ import pytest + from socketsecurity.config import CliConfig diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index e0f62d85..045c66d4 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,9 +1,12 @@ -import pytest from unittest.mock import Mock, patch + +import pytest import requests + from socketsecurity.core.cli_client import CliClient -from socketsecurity.core.socket_config import SocketConfig from socketsecurity.core.exceptions import APIFailure +from socketsecurity.core.socket_config import SocketConfig + @pytest.fixture def config(): @@ -100,14 +103,14 @@ def test_request_ssl_verification(client): client.request("test/path") args, kwargs = mock_request.call_args - assert kwargs['verify'] == True # Default is True + assert kwargs['verify'] # Default is True # Test with SSL verification disabled client.config.allow_unverified_ssl = True client.request("test/path") args, kwargs = mock_request.call_args - assert kwargs['verify'] == False + assert not kwargs['verify'] def test_request_with_payload(client): """Test request with payload data""" @@ -154,7 +157,6 @@ def test_post_telemetry_events_sends_individually(client): def test_post_telemetry_events_continues_on_failure(client): """Test that a failed event does not prevent subsequent events from being sent""" - import json events = [ {"event_kind": "user-action", "artifact_purl": "pkg:npm/foo@1.0.0"}, diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index c1443adc..47bb3d88 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,9 +1,11 @@ +import tomllib from pathlib import Path + import pytest -from unittest.mock import patch -import tomllib -from socketsecurity.core.socket_config import SocketConfig + from socketsecurity.config import CliConfig +from socketsecurity.core.socket_config import SocketConfig + def test_config_default_values(): """Test that config initializes with correct default values""" diff --git a/tests/unit/test_disable_ignore.py b/tests/unit/test_disable_ignore.py index e151a3f4..93f9579f 100644 --- a/tests/unit/test_disable_ignore.py +++ b/tests/unit/test_disable_ignore.py @@ -1,6 +1,5 @@ """Tests for the --disable-ignore flag.""" -import pytest from dataclasses import dataclass from socketsecurity.config import CliConfig @@ -8,7 +7,6 @@ from socketsecurity.core.messages import Messages from socketsecurity.core.scm_comments import Comments - # --- CLI flag parsing tests --- class TestDisableIgnoreFlag: diff --git a/tests/unit/test_gitlab_auth.py b/tests/unit/test_gitlab_auth.py index 3c6d6ddd..42c5e6bc 100644 --- a/tests/unit/test_gitlab_auth.py +++ b/tests/unit/test_gitlab_auth.py @@ -1,7 +1,8 @@ """Tests for GitLab authentication patterns""" import os +from unittest.mock import patch + import pytest -from unittest.mock import patch, MagicMock from socketsecurity import USER_AGENT from socketsecurity.core.scm.gitlab import GitlabConfig diff --git a/tests/unit/test_gitlab_auth_fallback.py b/tests/unit/test_gitlab_auth_fallback.py index d439da29..441f4cb3 100644 --- a/tests/unit/test_gitlab_auth_fallback.py +++ b/tests/unit/test_gitlab_auth_fallback.py @@ -1,9 +1,10 @@ """Integration test demonstrating GitLab authentication fallback""" import os -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + import pytest -from socketsecurity.core.scm.gitlab import Gitlab, GitlabConfig +from socketsecurity.core.scm.gitlab import Gitlab from socketsecurity.socketcli import CliClient @@ -37,7 +38,7 @@ def test_fallback_from_private_token_to_bearer(self): gitlab = Gitlab(client=mock_client) # This should trigger the fallback mechanism - result = gitlab.get_comments_for_pr() + gitlab.get_comments_for_pr() # Verify two requests were made assert mock_client.request.call_count == 2 @@ -79,7 +80,7 @@ def test_fallback_from_bearer_to_private_token(self): gitlab = Gitlab(client=mock_client) # This should trigger the fallback mechanism - result = gitlab.get_comments_for_pr() + gitlab.get_comments_for_pr() # Verify two requests were made assert mock_client.request.call_count == 2 @@ -140,7 +141,7 @@ def test_successful_first_attempt_no_fallback(self): gitlab = Gitlab(client=mock_client) # This should succeed on first try - result = gitlab.get_comments_for_pr() + gitlab.get_comments_for_pr() # Verify only one request was made assert mock_client.request.call_count == 1 diff --git a/tests/unit/test_gitlab_commit_status.py b/tests/unit/test_gitlab_commit_status.py index fc57ed6e..c02aa5b9 100644 --- a/tests/unit/test_gitlab_commit_status.py +++ b/tests/unit/test_gitlab_commit_status.py @@ -1,7 +1,5 @@ """Tests for GitLab commit status integration""" -import os -import pytest -from unittest.mock import patch, MagicMock, call +from unittest.mock import MagicMock, patch from socketsecurity.core.scm.gitlab import Gitlab, GitlabConfig diff --git a/tests/unit/test_gitlab_format.py b/tests/unit/test_gitlab_format.py index 96218e4e..4a1cf0c1 100644 --- a/tests/unit/test_gitlab_format.py +++ b/tests/unit/test_gitlab_format.py @@ -1,8 +1,7 @@ import re -import pytest -from socketsecurity.core.messages import Messages from socketsecurity.core.classes import Diff, Issue +from socketsecurity.core.messages import Messages class TestGitLabFormat: diff --git a/tests/unit/test_include_dirs.py b/tests/unit/test_include_dirs.py index 7261f697..54252875 100644 --- a/tests/unit/test_include_dirs.py +++ b/tests/unit/test_include_dirs.py @@ -6,8 +6,6 @@ import types from unittest.mock import MagicMock -import pytest - from socketsecurity.config import CliConfig from socketsecurity.core import Core from socketsecurity.core.socket_config import ( diff --git a/tests/unit/test_socketcli.py b/tests/unit/test_socketcli.py index 8cae52ba..68a63ec7 100644 --- a/tests/unit/test_socketcli.py +++ b/tests/unit/test_socketcli.py @@ -2,10 +2,12 @@ import pytest -from socketsecurity.core.classes import Diff, Package from socketsecurity import socketcli -from socketsecurity.socketcli import build_license_artifact_payload, should_write_comment - +from socketsecurity.core.classes import Diff, Package +from socketsecurity.socketcli import ( + build_license_artifact_payload, + should_write_comment, +) # --------------------------------------------------------------------------- # Exit-code-on-api-error (flag-only, non-breaking for 2.3.x). From 536d3a59ffa2d42f7229e2a5d421b1bb90c14bc1 Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:52:14 +0200 Subject: [PATCH 31/47] Bump pinned @coana-tech/cli to 15.10.39 (#347) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 2 +- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1007a3b9..40b9cd45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.7.2 + +### Changed: bump pinned @coana-tech/cli to 15.10.39 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.36` to + `15.10.39`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + ## 2.7.1 ### Changed: bump pinned @coana-tech/cli to 15.10.36 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0d4d4833..d661bdbb 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -275,7 +275,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.36 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.39 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index 3dcd5718..c848f2f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.7.1" +version = "2.7.2" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 78220a1f..9a275469 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.7.1' +__version__ = '2.7.2' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 16ddfb1e..f43ce5c8 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -19,7 +19,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.36" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.39" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -56,7 +56,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.36'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.39'). Args: version: Coana CLI version to use. @@ -65,7 +65,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.36'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.39'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index d0338009..03044024 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.7.1" +version = "2.7.2" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 9aebab429dd464d73d9cdc414cdfbe1ffb9dd016 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:37:26 -0400 Subject: [PATCH 32/47] chore(deps): bump brotlicffi from 1.2.0.1 to 1.2.0.2 (#340) Bumps [brotlicffi](https://github.com/python-hyper/brotlicffi) from 1.2.0.1 to 1.2.0.2. - [Changelog](https://github.com/python-hyper/brotlicffi/blob/main/HISTORY.rst) - [Commits](https://github.com/python-hyper/brotlicffi/compare/v1.2.0.1...v1.2.0.2) --- updated-dependencies: - dependency-name: brotlicffi dependency-version: 1.2.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 36 ++++++++++++++++++------------------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c848f2f0..6ddc7222 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "beautifulsoup4==4.15.0", "markdown==3.10.3", "brotli==1.2.0; platform_python_implementation == 'CPython'", - "brotlicffi==1.2.0.1; platform_python_implementation != 'CPython'", + "brotlicffi==1.2.0.2; platform_python_implementation != 'CPython'", ] readme = "README.md" description = "Socket Security CLI for CI/CD" diff --git a/uv.lock b/uv.lock index 03044024..269285da 100644 --- a/uv.lock +++ b/uv.lock @@ -173,27 +173,27 @@ wheels = [ [[package]] name = "brotlicffi" -version = "1.2.0.1" +version = "1.2.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/97/7845739a36828ffe751a1c6b240692f552fd7ecf65026c51326c0a4aa369/brotlicffi-1.2.0.2.tar.gz", hash = "sha256:5e0fbd13644cf1f6015e75fa5e0ad8fdce1048d9c9ff90b0ce826174b249ee35", size = 478755, upload-time = "2026-08-21T17:29:18.415Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, - { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, - { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, - { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" }, - { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" }, - { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, + { url = "https://files.pythonhosted.org/packages/77/a2/edda4f3fc7143434402eacad1e91433fe68ae648c22738eeddb6138638ba/brotlicffi-1.2.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad05ca993234cf947f0ad71b1c8bc0af3d74e0410b1e2c32bb99de0cef6a994b", size = 438789, upload-time = "2026-08-21T17:28:55.708Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/506dc8edabb3cf9339c89f1ecc80a218aa166bb83b9f2e9cc1da67314072/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0636cb5a85f31c36e08953d09a226cb788be900b976f81302895e3cf35d5e707", size = 1541246, upload-time = "2026-08-21T17:28:57.669Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d6/74cee9f9fbea8c42030a81056c64e092030a95bd2756ea83da1d1e8f5f29/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97bae40d45ebc2a6ac7b1c9b30825496a257192194b672ef5869e2df93467f69", size = 1542129, upload-time = "2026-08-21T17:28:59.502Z" }, + { url = "https://files.pythonhosted.org/packages/24/cc/c32630b042ec2a13e8342e6ecb6b9d3531b1be4647b733d6fd365976041c/brotlicffi-1.2.0.2-cp314-cp314t-win32.whl", hash = "sha256:8f3f9bd61293dc48359763e693951393f39656086315067cf97e23e23e8911ab", size = 346840, upload-time = "2026-08-21T17:29:01.085Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0b/83cac3075721fe4c253ea1cc5310cb687c2f7d987e0fd60eb3ed769c24c0/brotlicffi-1.2.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:908add8a9c0eea00f5de799dc6de9f6d205d9ee11afabc7c03d6812c481200e2", size = 386079, upload-time = "2026-08-21T17:29:02.667Z" }, + { url = "https://files.pythonhosted.org/packages/2e/71/c27f24b8334f65f2492601c7764338f156cb904d2ffe0061e6004a76d9cc/brotlicffi-1.2.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d5a8ffa154f16660ab818d78045b55fa6f9970f1ca4c38998766e99c672071cb", size = 438885, upload-time = "2026-08-21T17:29:04.113Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/d8fd1a4d09b7ab563b89380395e09151d2ef1344be31594df6a6987d4028/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec6b1af7b7a8ce788354f2c603651ada0fba166ec31ab879e2eec462a3e6dbf4", size = 1534365, upload-time = "2026-08-21T17:29:05.878Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/076419ed6c2c6aa3eaac6fd6b076502b4be89d50625fcdc513cd4aeca718/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22916101de0e7ff535f2edf54b52a85591853b8ae9a98737643defdd3c063a3a", size = 1536851, upload-time = "2026-08-21T17:29:07.599Z" }, + { url = "https://files.pythonhosted.org/packages/35/dd/31ae9945cbd605339fb51c9a609f7dbb182cd361adeabc1d470142357206/brotlicffi-1.2.0.2-cp39-abi3-win32.whl", hash = "sha256:df1d34c4ad9adbf7f63a6b42f7d0e4dfd259c88141b85145b57abecc1abc3b24", size = 342379, upload-time = "2026-08-21T17:29:09.05Z" }, + { url = "https://files.pythonhosted.org/packages/95/ae/afd54e744df93b51cc29f6a19beccf9998b25743d7177697390de10479d1/brotlicffi-1.2.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:489ca4da3ee65926d72bf01584b61088a9da6bdd1bb01b2040901e1beaffa8f0", size = 379761, upload-time = "2026-08-21T17:29:10.687Z" }, + { url = "https://files.pythonhosted.org/packages/37/da/a5b65a86725d772504a348193cf1fab5ad6410794b422bf81faa17a96a66/brotlicffi-1.2.0.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:cf500bb9e02e1474ced1ecf22f74c568de2816b3627af6352ec51ac5e09e60ee", size = 407459, upload-time = "2026-08-21T17:29:12.385Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c7/a253288e66ee340f2f6320eda7022daa723f2918438d586a59e9c998aa27/brotlicffi-1.2.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbb81489562dd5363bf86d9a8edb0ec8c97049b0819ba4936fc023e8847248bc", size = 406825, upload-time = "2026-08-21T17:29:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/ea8e3d34e1d64c5e5a920bb0c89bf9e92badf973937a60922820395e622d/brotlicffi-1.2.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc7647657e4f3d73eab591910dbecb57d1ecaea7aa3dd04e6d704a2756fe0c59", size = 402903, upload-time = "2026-08-21T17:29:15.524Z" }, + { url = "https://files.pythonhosted.org/packages/4e/17/17c22d48819001ca08cadab63b09b00e0c56a7579478aa7c2623f4280de6/brotlicffi-1.2.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5eb5563173afb92c9111b180349ff17d7c83c79febabadca5de983b552565c3c", size = 378395, upload-time = "2026-08-21T17:29:16.857Z" }, ] [[package]] @@ -714,7 +714,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.13'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -1323,7 +1323,7 @@ dev = [ requires-dist = [ { name = "beautifulsoup4", specifier = "==4.15.0" }, { name = "brotli", marker = "platform_python_implementation == 'CPython'", specifier = "==1.2.0" }, - { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'", specifier = "==1.2.0.1" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'", specifier = "==1.2.0.2" }, { name = "gitpython", specifier = "==3.1.59" }, { name = "hatch", marker = "extra == 'dev'", specifier = "==1.18.0" }, { name = "markdown", specifier = "==3.10.3" }, From f0b8a9fbb096ff94215a19b299dbc09b51b0fbb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:13:23 -0400 Subject: [PATCH 33/47] chore(deps): bump the python-minor-patch group across 1 directory with 2 updates (#339) Bumps the python-minor-patch group with 2 updates in the / directory: [ruff](https://github.com/astral-sh/ruff) and [uv](https://github.com/astral-sh/uv). Updates `ruff` from 0.16.4 to 0.16.5 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.4...0.16.5) Updates `uv` from 0.12.5 to 0.12.8 - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.12.5...0.12.8) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: uv dependency-version: 0.12.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 4 +-- uv.lock | 90 +++++++++++++++++++++++++------------------------- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6ddc7222..c5e4da96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,9 +47,9 @@ test = [ "pytest-watch==4.2.0" ] dev = [ - "ruff==0.16.4", + "ruff==0.16.5", "twine==7.0.0", # for building - "uv==0.12.5", # for dependency management + "uv==0.12.8", # for dependency management "pre-commit==4.6.2", "hatch==1.18.0" ] diff --git a/uv.lock b/uv.lock index 269285da..4248208e 100644 --- a/uv.lock +++ b/uv.lock @@ -1213,27 +1213,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, - { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, - { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, - { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, - { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, - { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, - { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, - { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, - { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, - { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, - { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, ] [[package]] @@ -1338,10 +1338,10 @@ requires-dist = [ { name = "pytest-watch", marker = "extra == 'test'", specifier = "==4.2.0" }, { name = "python-dotenv", specifier = "==1.2.3" }, { name = "requests", specifier = "==2.34.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.4" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.5" }, { name = "socketdev", specifier = "==3.5.0" }, { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, - { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.5" }, + { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.8" }, ] provides-extras = ["test", "dev"] @@ -1494,28 +1494,28 @@ wheels = [ [[package]] name = "uv" -version = "0.12.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/b0/3085b844fe59aa319a3f94a5cca9938fffecc82705aa9c2762a749f7095c/uv-0.12.5.tar.gz", hash = "sha256:442a21d181faae21742aaaf6d2091a0d27755d3eac344061a9a00c90169b7524", size = 7101936, upload-time = "2026-08-14T19:56:57.693Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/4c/6412d4a618230db699118b362ec41c54795f93992b43c53e225bd0213501/uv-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:2bd62134e56af35b9cf017aaf8ae41a605d6501dd49afc35b70b544a45dd8354", size = 23310055, upload-time = "2026-08-14T19:55:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/bd/ec/d76387b388fa21620088b89b9c67f2596a707add585104e0cb5e8abf55f2/uv-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1a06c8bc4d43b5f6c1e3f2ae3d0f6455b07515f762516f95e52e6c0cbccedf15", size = 21401335, upload-time = "2026-08-14T19:55:55.371Z" }, - { url = "https://files.pythonhosted.org/packages/6d/bc/81ab953b7261ae6be40874b1f283a10873871e02eb353d354614dd8da96b/uv-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d87156bc174d94fae890bb7a261e2867140abb9fe1e9de81a5295e582fb9d0f5", size = 19290641, upload-time = "2026-08-14T19:55:58.998Z" }, - { url = "https://files.pythonhosted.org/packages/7d/13/07585043c10e648820bf826474dac46864ce6691da5dc52fee43c5c7523a/uv-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d65b7b3bc3fd28678f62aa7fb5d90f106ad9782c1354af60b6cecdf9ea9ecd9", size = 22245569, upload-time = "2026-08-14T19:56:02.729Z" }, - { url = "https://files.pythonhosted.org/packages/3e/6d/310f8f56f8d001b4000112a09d7b7de80fb2024a90208fabb9ddc457c123/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:712624b62e25c84e5a10fc6aa144d8a81b685fdc067a54a7ca4367d75d2cf791", size = 22745152, upload-time = "2026-08-14T19:56:06.426Z" }, - { url = "https://files.pythonhosted.org/packages/92/da/7922b67eec5ee03e94333c5841b682c335033ee80acac17c3417bd752656/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9656ac7a00fd4314980fb0f790df1c1f3fa9cbcf9af9c6f611b19448b9da687", size = 22787947, upload-time = "2026-08-14T19:56:10.149Z" }, - { url = "https://files.pythonhosted.org/packages/62/55/5dbaed832a4b36809ef8a07c8e56e9fee0dedb0aa0454f6d232b6e468f2c/uv-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:568485b44e848eb3693f85d6b00299ccd8fc4d26902030dbf24f549c276db9ca", size = 23367616, upload-time = "2026-08-14T19:56:13.768Z" }, - { url = "https://files.pythonhosted.org/packages/11/77/baf761d12bb66efb01706e3bbb5926ed0d13cb0a40539a661fcfffd46de4/uv-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd08c82831b0033330f8eeeb0d90f938a4d999f25569bee68a975c736142d795", size = 24586263, upload-time = "2026-08-14T19:56:17.57Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a8/76c1031c4834c959bb8a8059c9feabeaa77488ce8b6a3529d6d929ae81cf/uv-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd9ff6154b891146a342c143cd29b330ad97ac6a4b20ff4a99a20a4da84ceca", size = 24160655, upload-time = "2026-08-14T19:56:21.568Z" }, - { url = "https://files.pythonhosted.org/packages/93/22/dacc9a0bc8604187a1ba954a3aef8329e4104eb0af772d2c3c634893bd9b/uv-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e195ccf1ed60c8bb24a6447ce306441a4181d54b602407e09bc56e963911c15", size = 23657089, upload-time = "2026-08-14T19:56:25.144Z" }, - { url = "https://files.pythonhosted.org/packages/39/98/e8f9c071622f2cb4072d8b587d27b27d23cf0d3ebf8b3687f5af6030f587/uv-0.12.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:58abfb0f658b39a834307a11223bc170294ea214263b4c99ecc7663720d43544", size = 22379954, upload-time = "2026-08-14T19:56:28.789Z" }, - { url = "https://files.pythonhosted.org/packages/73/95/4c3f060e95f7cbe9177b4ab361f0cbfc4ae22e5a49b22e73eee9f0d0a6ca/uv-0.12.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:6ad2c455f1fe4d2962f6fd7ccb3b1f61c61856681c9d99f40e170b2074353fa3", size = 23318163, upload-time = "2026-08-14T19:56:32.504Z" }, - { url = "https://files.pythonhosted.org/packages/a0/96/ca0497ef8912ef48dbbc9982a8b4212260c34d56bfd0d45fe67b31942121/uv-0.12.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a05b497c2a948c8600f4c831a89852b4d2514b7f561074225cc9edd0cc4811e2", size = 23470437, upload-time = "2026-08-14T19:56:36.525Z" }, - { url = "https://files.pythonhosted.org/packages/60/e7/8bdc37669a6cd2b46a2ec08ccbb58c61395ec84a073e199f5a4a64bb998f/uv-0.12.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7817f8e957960f9ddc452ea353f283c0d6393e2e31b400276485adced5b1f371", size = 22545803, upload-time = "2026-08-14T19:56:40.606Z" }, - { url = "https://files.pythonhosted.org/packages/37/cc/01e39e1dbeb838a6b3c26bf97c867d6f366459b22a38bea691af8c6c94c0/uv-0.12.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:dc14e4f81a99b585a891350c60d1ff4557d54cb3c3c81fa45fd4e0dd512ba752", size = 23874113, upload-time = "2026-08-14T19:56:44.193Z" }, - { url = "https://files.pythonhosted.org/packages/0a/38/9053599a73a351d1cd34195c7a48c1db4d4d51b57b543607fad7ecf9354c/uv-0.12.5-py3-none-win32.whl", hash = "sha256:39bb102766c95571781a7b4c611675ea213e08df5c680f3936279b3c0d1f6c3c", size = 20744641, upload-time = "2026-08-14T19:56:47.689Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f6/a9af9311c7f5640ca2bfcfdedb7aca37fa6d1d9f5c981fb50c5be02b7477/uv-0.12.5-py3-none-win_amd64.whl", hash = "sha256:455c3e57602e2141e66e2f0bf685898c9c5e5a70377d14c9a71554a3baf3ddbf", size = 21621812, upload-time = "2026-08-14T19:56:51.126Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fb/e1266399f755f97a0783de379f2fed6dae0a2a240db32fe5a2eb976fec8a/uv-0.12.5-py3-none-win_arm64.whl", hash = "sha256:bea86f27a027e0e3af908db4bdd4f1ceef3ca2bd47673b5ccca7f550e325b1b4", size = 20381876, upload-time = "2026-08-14T19:56:54.883Z" }, +version = "0.12.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/38/53754ae5033d0dbfe01976249e4b1b48539fe5080aeec162bc920f6244d6/uv-0.12.8.tar.gz", hash = "sha256:dc6a191265fed5102d9678958b3a157c81e61246316f07e7d980c3616032f49d", size = 7124914, upload-time = "2026-08-31T22:18:18.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/13/8e6984272962a273323c58113ae390eb709f7a74726009e1bcecbb7fe0fd/uv-0.12.8-py3-none-linux_armv6l.whl", hash = "sha256:e6c617d5674867653738eaec333025142e9de33410955f8f6ae40e2e8ac85e77", size = 22245394, upload-time = "2026-08-31T22:17:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/0d/38/18d7ae5d4b40714b8e2c474bfcac907a8dd27573044b60e808b895a07423/uv-0.12.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:edfb42bc830b4c473653ed5470ac4d967ae2cd40c1b7409ec4c7c8b655d04235", size = 20544653, upload-time = "2026-08-31T22:17:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/84/32/249b0ec1a62916b8e7c2638154b930daaf9eae2df8b0807ad455c8916b7e/uv-0.12.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:842e27b85ec85c32369919b6dc72a4ae0c737b4376165d34a6ebb8329ce9d744", size = 17363409, upload-time = "2026-08-31T22:17:30.309Z" }, + { url = "https://files.pythonhosted.org/packages/62/4c/74ba60fbcda11be5aa5a7f139bc122834ec740d735e155ad9324aff3570a/uv-0.12.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ff4d67a13a3283004e3b00e22c39922c27b0e1929f82f9d6873dc85dc8bf6dc7", size = 21484891, upload-time = "2026-08-31T22:17:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4a/92/6817f8f705bcf0e6b51302a55a899faf2a29d9c3f056cb04f9f133a7192b/uv-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:a8a8129edee5591b09933b8387d3ccc250933a9cb39f98bde89d6e4f920a870d", size = 21731676, upload-time = "2026-08-31T22:17:36.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/df/7ca009329a0b2194ca709f51e1f304b01086e0f99f3f8e0ea81460a193b9/uv-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef0c05b39d8701b4bb8196aeaf2594147d8369a1026b0b731eead41cb1df1884", size = 21749846, upload-time = "2026-08-31T22:17:39.517Z" }, + { url = "https://files.pythonhosted.org/packages/ba/46/1e73455d1c4d8f9eafaac19879ba3fc5b8c0743d1b04d11ad1ac24e1a522/uv-0.12.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddaa3ceb3f877cce42e75084d10ebe53148163aaa4b1d16432c3b952e9c7466c", size = 22469076, upload-time = "2026-08-31T22:17:43.016Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a3/f11be2600c67345bfb694fd7c68dad97d4133d54306d298a2690b21da66b/uv-0.12.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e9b112000bd5ff8e8c97922f03acede7d1f2385b3261bc6a15d6b18871e9a25", size = 23653340, upload-time = "2026-08-31T22:17:46.23Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f9/09e1762b40451227fc944551c7ea62d13f9b29e4965bf23adffeaba0f29a/uv-0.12.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a2f848916a6e5b3111e5d4c9e6ac66d9e38c561da4fa3a4ff51691e3c55974c", size = 23346672, upload-time = "2026-08-31T22:17:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/19/e7/6e1797aec65defdc02a81444c2d8964101876097c461993fa1e14641782e/uv-0.12.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d63d046051d33b36260146df5aef03e9166b30b4546e9b6e554be152b69f9f9", size = 20068890, upload-time = "2026-08-31T22:17:52.225Z" }, + { url = "https://files.pythonhosted.org/packages/62/b5/2662d5da5158e4db444a9e050f8f1eaecea2832311e2da27ac53a5d15ebb/uv-0.12.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7db4fb5d559bab30ea2310db9119cfd44bc438c09f24cb33d6b9a518fd671143", size = 19434317, upload-time = "2026-08-31T22:17:55.008Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0f/fe154c14adc7af8e659784a59f34c2c0dda6462c1427ce6ac5fff2652391/uv-0.12.8-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:dc5990c941a7c064f761f13ddfd9a0de66a0063676e228403e13709e0254094f", size = 22454547, upload-time = "2026-08-31T22:17:58.008Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/8e3bd5733290cd8428db0995d1b9ef633760cf12ae6cdd437eddcae95d93/uv-0.12.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5e30eb88618e8ac315b6511bcd64bf4b40aa0737912ce9c2eb988dc6aa59473b", size = 22575842, upload-time = "2026-08-31T22:18:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/06/3f/c6c2331189bb92f72487d85c94d87af2b888f2fa9e95c4d3163247c2f850/uv-0.12.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8d3bd25b02887fd437fc8b308101c89b651ac43870579bb1a498d139b096582d", size = 21585514, upload-time = "2026-08-31T22:18:03.892Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fa/094b6154e4fb624b3aeeb313fb6888e439e3d65092cf5fcbb4ea1e362c17/uv-0.12.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b8b589f36222ed73f070598d9cd68f7dc706cf5d670a8c469b648287a97a590a", size = 22850849, upload-time = "2026-08-31T22:18:06.887Z" }, + { url = "https://files.pythonhosted.org/packages/e9/aa/c0755ee526db93453b2a5b4079f46ece9819c9e26918be36187888c856c6/uv-0.12.8-py3-none-win32.whl", hash = "sha256:a9ddf59627e0718db0e84b329005462f335fe707723813036322f00133d992fa", size = 19862492, upload-time = "2026-08-31T22:18:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f2/3564ae1d1d37b66dc64e6be52f5e5fe00cfce235b6f60d0b708c6caf7588/uv-0.12.8-py3-none-win_amd64.whl", hash = "sha256:53984d68cddd227e6433b70b510d4b47fe82e658f22f4a6ca416b4e373406393", size = 18084781, upload-time = "2026-08-31T22:18:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b4/724322de5a51ec8c59e3e8d90eeaa8f7c7d0b3b58390805908b87dfd57d7/uv-0.12.8-py3-none-win_arm64.whl", hash = "sha256:71763f479286a9e3285642fba8fca710aea9596d56e72bc247ecdf882e05ebc4", size = 19526211, upload-time = "2026-08-31T22:18:15.521Z" }, ] [[package]] From 86e3c1db580cb56cc8eb5dde6d9e019f07f6aa80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:14:45 -0400 Subject: [PATCH 34/47] ci(deps): bump docker/setup-buildx-action (#338) Bumps the github-actions-minor-patch group with 1 update in the /.github/actions/setup-docker directory: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action). Updates `docker/setup-buildx-action` from 4.2.0 to 4.3.0 - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-version: 4.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/actions/setup-docker/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-docker/action.yml b/.github/actions/setup-docker/action.yml index 58239ff6..66abffdc 100644 --- a/.github/actions/setup-docker/action.yml +++ b/.github/actions/setup-docker/action.yml @@ -21,7 +21,7 @@ runs: steps: - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 if: inputs.enable-qemu == 'true' - - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ inputs.dockerhub-username }} From 9e56d79d0bc7ad2b489051b1793a5cc6734706d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:22:18 -0400 Subject: [PATCH 35/47] chore(deps): bump httpcore2 from 2.9.1 to 2.10.0 (#348) Bumps [httpcore2](https://github.com/pydantic/httpx2) from 2.9.1 to 2.10.0. - [Release notes](https://github.com/pydantic/httpx2/releases) - [Commits](https://github.com/pydantic/httpx2/compare/v2.9.1...v2.10.0) --- updated-dependencies: - dependency-name: httpcore2 dependency-version: 2.10.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/uv.lock b/uv.lock index 4248208e..dd56af47 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.13'", - "python_full_version < '3.13'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "(python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", ] [[package]] @@ -640,31 +641,41 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.9.1" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" }, ] [[package]] name = "httpx2" -version = "2.9.1" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "httpcore2" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, - { name = "truststore" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] From e50b3aa4fd33f02fcf4c25e06b58c6312160e7e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:54:26 -0400 Subject: [PATCH 36/47] chore(deps): bump httpx2 from 2.10.0 to 2.12.0 (#349) Bumps [httpx2](https://github.com/pydantic/httpx2) from 2.10.0 to 2.12.0. - [Release notes](https://github.com/pydantic/httpx2/releases) - [Changelog](https://github.com/pydantic/httpx2/blob/main/src/httpx2/CHANGELOG.md) - [Commits](https://github.com/pydantic/httpx2/compare/v2.10.0...v2.12.0) --- updated-dependencies: - dependency-name: httpx2 dependency-version: 2.12.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/uv.lock b/uv.lock index dd56af47..80dd5713 100644 --- a/uv.lock +++ b/uv.lock @@ -641,20 +641,20 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.10.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, ] [[package]] name = "httpx2" -version = "2.10.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform != 'emscripten'" }, @@ -664,9 +664,9 @@ dependencies = [ { name = "truststore", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, ] [[package]] From f65b6aff5d77c9812fbbbba9278cd6a5d0fad1fa Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:36:35 -0400 Subject: [PATCH 37/47] Improve monorepo scan diagnostics and guidance (#325) * Improve monorepo scan diagnostics * Bump version to 2.6.9 * Bump version to 2.7.0 * docs: document the monorepo scan layout trade-off The mechanics of --sub-path and --workspace-name were documented, but not the choice they force. One combined scan gives a single dashboard entry and no per-component attribution; one scan per component gives attribution, baselines and per-component policy, but adds a repository entry per component, which grows the dashboard's repository list. There is no layout that provides both today. Customers hit this at a dozen-plus components and reasonably assume they have configured something wrong. Naming the trade-off, and adding rules of thumb for picking a side, is cheaper than each of them discovering it. Cross-referenced from the CI/CD guide's independent-workspace pattern, which is the layout that grows the list. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 14 ++ README.md | 4 + docs/ci-cd.md | 233 ++++++++++++++++++++++++++++++++ docs/cli-reference.md | 75 +++++++++- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/__init__.py | 77 +++++++++-- socketsecurity/socketcli.py | 16 +++ tests/core/test_sdk_methods.py | 32 +++++ tests/unit/test_scan_scope.py | 140 +++++++++++++++++++ tests/unit/test_socketcli.py | 14 ++ uv.lock | 2 +- 12 files changed, 592 insertions(+), 19 deletions(-) create mode 100644 tests/unit/test_scan_scope.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 40b9cd45..29dda5bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2.8.0 + +### Changed: improve monorepo scan diagnostics and guidance + +- Added aggregate scan configuration, manifest-count, baseline-selection, and + fallback diagnostics without listing submitted manifest paths. +- Clarified monorepo scan scoping, workspace flags, CI path filters, and timeout + behavior, with a changed-workspace GitHub Actions example. + +### Fixed: apply configured exit codes to API failures + +- Full-scan and streamed-diff API failures now use the configured infrastructure + error exit code instead of the security-finding exit code. + ## 2.7.2 ### Changed: bump pinned @coana-tech/cli to 15.10.39 diff --git a/README.md b/README.md index cda9a407..5f978d59 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,10 @@ value — e.g. a Buildkite code, or `0` to swallow infra errors. Exit `3` is a Socket convention, not an industry standard. +This mapping applies to errors the CLI receives and handles. An external process +supervisor (for example GNU `timeout`) can terminate the CLI before it handles an +error, so the supervisor's exit status (commonly 124 or 137) takes precedence. + ### How these options interact The two flags that affect exit codes can cancel each other out, so the order of diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 061d18ea..968799b5 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -71,6 +71,239 @@ Equivalent JSON: SOCKET_SECURITY_API_TOKEN: ${{ secrets.SOCKET_SECURITY_API_TOKEN }} ``` +#### GitHub Actions: scan changed monorepo workspaces independently + +GitHub Actions `paths` filters only decide whether a workflow starts. They do not +change `socketcli` discovery or upload scope. For a merge gate, it is usually safer +to start a small selector job on every PR update, then create one scan job per +affected logical workspace. This also avoids a required check remaining pending +when GitHub skips the entire workflow because of a top-level path filter. + +This pattern produces one dashboard entry per logical workspace, which is what +gives each component its own alerts, baseline, and policy. It is also the layout +that grows the dashboard's repository list. See +[Choosing a scan layout](cli-reference.md#choosing-a-scan-layout) for when that +trade-off is worth making. + +Define a repository variable named `SOCKET_MONOREPO_WORKSPACES_JSON`. Its value is +an array with one stable workspace name, one or more scan roots, and the path globs +that should select that workspace. Fill these placeholders with the repository's +real layout. A workspace definition selects directory roots; shared root manifests, +lockfiles, and cross-directory path dependencies outside those roots are not included +automatically. + +```json +[ + { + "name": "", + "sub_paths": [""], + "watch_globs": [""] + } +] +``` + +Each `sub_paths` value must be a directory, not an individual manifest or lockfile. +Using `.` includes the entire target path. Do not use this changed-workspace pattern +until the directory boundaries preserve every shared input needed to resolve each +logical graph. If root workspace metadata governs most or all of the repository, a +smaller coverage-preserving split may not be representable with `--sub-path` alone. + +Also define `SOCKETCLI_VERSION` as the exact package version validated for the +workflow. The workflow below logs that version, uses full Git history for reliable +base/head selection, creates one matrix job (and therefore one graph and baseline) +per selected workspace, and fails closed on CLI/API/timeout failures. It uses API +SCM mode plus `--enable-diff` because parallel `--scm github` jobs can race while +updating the same PR comments; the matrix checks and report links are the gate. + +```yaml +name: Socket Security + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] + +permissions: + contents: read + +jobs: + select-workspaces: + runs-on: ubuntu-latest + outputs: + count: ${{ steps.select.outputs.count }} + matrix: ${{ steps.select.outputs.matrix }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - id: select + name: Select changed workspaces + env: + WORKSPACES_JSON: ${{ vars.SOCKET_MONOREPO_WORKSPACES_JSON }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + shell: bash + run: | + python - <<'PY' + import fnmatch + import json + import os + import re + import subprocess + + workspaces = json.loads(os.environ["WORKSPACES_JSON"]) + if not isinstance(workspaces, list): + raise SystemExit("SOCKET_MONOREPO_WORKSPACES_JSON must be a JSON array") + + base = os.environ["BASE_SHA"] + head = os.environ["HEAD_SHA"] + if not base or set(base) == {"0"}: + base = subprocess.check_output( + ["git", "rev-parse", f"{head}^"], text=True + ).strip() + changed_output = subprocess.check_output( + ["git", "diff", "--name-only", "-z", base, head] + ) + changed = [ + item.decode("utf-8", "surrogateescape") + for item in changed_output.split(b"\0") + if item + ] + + selected = [] + for workspace in workspaces: + name = workspace.get("name", "") + sub_paths = workspace.get("sub_paths") or [] + watch_globs = workspace.get("watch_globs") or [] + if not re.fullmatch(r"[A-Za-z0-9._-]+", name): + raise SystemExit(f"Invalid workspace name: {name!r}") + if not sub_paths or any( + not isinstance(path, str) + or path.startswith("/") + or ".." in path.split("/") + for path in sub_paths + ): + raise SystemExit(f"Invalid sub_paths for workspace {name!r}") + if not watch_globs: + watch_globs = [ + pattern + for path in sub_paths + for pattern in ( + ["*"] + if path.strip("/") in ("", ".") + else [path.rstrip("/"), f"{path.rstrip('/')}/*"] + ) + ] + if any( + fnmatch.fnmatchcase(path, pattern) + for path in changed + for pattern in watch_globs + ): + selected.append({"name": name, "sub_paths": sub_paths}) + + matrix = json.dumps({"include": selected}, separators=(",", ":")) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"count={len(selected)}\n") + output.write(f"matrix={matrix}\n") + PY + + scan-workspace: + needs: select-workspaces + if: needs.select-workspaces.outputs.count != '0' + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.select-workspaces.outputs.matrix) }} + name: Socket scan (${{ matrix.name }}) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install pinned Socket CLI + env: + SOCKETCLI_VERSION: ${{ vars.SOCKETCLI_VERSION }} + run: | + python -m pip install "socketsecurity==$SOCKETCLI_VERSION" + socketcli --version + + - name: Scan workspace + env: + SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_SECURITY_API_KEY }} + PR_NUMBER: ${{ github.event.pull_request.number || 0 }} + WORKSPACE_NAME: ${{ matrix.name }} + SUB_PATHS_JSON: ${{ toJSON(matrix.sub_paths) }} + shell: bash + run: | + set +e + args=( + --target-path "$GITHUB_WORKSPACE" + --workspace-name "$WORKSPACE_NAME" + --enable-diff + --pr-number "$PR_NUMBER" + --exit-code-on-api-error 3 + --report-link-file socket-report-link.txt + --summary-file socket-summary.txt + ) + while IFS= read -r sub_path; do + args+=(--sub-path "$sub_path") + done < <(jq -r '.[]' <<<"$SUB_PATHS_JSON") + + socketcli "${args[@]}" 2>&1 | tee socket-output.log + code=${PIPESTATUS[0]} + + { + echo "## Socket scan: $WORKSPACE_NAME" + if [ -s socket-report-link.txt ]; then + echo "[View the report]($(cat socket-report-link.txt))" + fi + if [ -s socket-summary.txt ]; then + echo '```' + cat socket-summary.txt + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" + + exit "$code" + + socket-security: + if: always() + needs: [select-workspaces, scan-workspace] + runs-on: ubuntu-latest + steps: + - name: Enforce matrix result + env: + SELECT_RESULT: ${{ needs.select-workspaces.result }} + SCAN_RESULT: ${{ needs.scan-workspace.result }} + run: | + test "$SELECT_RESULT" = success + [[ "$SCAN_RESULT" = success || "$SCAN_RESULT" = skipped ]] +``` + +Each configuration object may intentionally contain several `sub_paths` when +those directories are one logical dependency graph. To split backend resolution, +use separate objects with different `name` values. Add `--workspace ` only +when the Socket organization requires API workspace association; it is not a scan +scope control. Use `--save-submitted-files-list` in a non-required canary to verify +the exact manifests selected before adopting workspace-level scans as a merge gate. + +The job has an explicit 20-minute total budget. Tune that value from observed +workspace-level latency after the split; a five-minute cap can still be too close +to a slow request plus local startup. The CLI's `--timeout` is different: it +defaults to 1,200 seconds **per API request**. If an operator adds GNU `timeout`, +that process supervisor can terminate the CLI before it maps an error through +`--exit-code-on-api-error`; without `--preserve-status`, GNU reports 124 after its +initial timeout signal or 137 if `SIGKILL` is involved. + ### Buildkite ```yaml diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d661bdbb..e20c34b2 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -53,18 +53,67 @@ Pre-configured workflow files are in [`../workflows/`](../workflows/). > **Note:** If you're looking to associate a scan with a named Socket workspace (e.g. because your repo is identified as `org/repo`), see the [`--workspace` flag](#repository) instead. The `--workspace-name` flag described in this section is an unrelated monorepo feature. -The Socket CLI supports scanning specific workspaces within monorepo structures while preserving git context from the repository root. This is useful for organizations that maintain multiple applications or services in a single repository. +The Socket CLI supports scanning selected directories within a monorepo while preserving git context from the repository root. Scan scope is controlled by `--target-path` and `--sub-path`; CI workflow path filters and the CLI's changed-file detection do not narrow the manifests uploaded after a scan starts. ### Key Features -- **Multiple Sub-paths**: Specify multiple `--sub-path` options to scan different directories within your monorepo -- **Combined Workspace**: All sub-paths are scanned together as a single workspace in Socket +- **Target path**: Supplies repository/Git context and is the discovery root when no `--sub-path` is present +- **Multiple Sub-paths**: Restrict discovery to those directories, but combine every repeated `--sub-path` into one upload and one server-side dependency graph - **Git Context Preserved**: Repository metadata (commits, branches, etc.) comes from the main target-path -- **Workspace Naming**: Use `--workspace-name` to differentiate scans from different parts of your monorepo +- **Workspace Naming**: Use a stable, unique `--workspace-name` for each independently scanned logical workspace; it suffixes the repository slug and therefore gives that workspace its own repository head/baseline + +`--workspace` is different: it sends Socket organization workspace context with the full-scan API request. It does not narrow client-side filesystem discovery, split the upload into independent scans, or change the repository suffix. Backend policy/routing for that workspace remains server-owned. + +> **Performance consequence:** If the goal is smaller independently resolvable graphs, run one CLI invocation per logical workspace, with a distinct `--workspace-name`. Adding several unrelated directories to one command with repeated `--sub-path` flags still asks the backend to resolve one combined graph. + +Normal scan logs include the effective repository and Socket workspace context, +repository-relative discovery roots, aggregate manifest count, and selected baseline. +Individual manifest paths remain opt-in through `--save-submitted-files-list`. + +### Choosing a scan layout + +`--sub-path` and `--workspace-name` support two layouts, and picking between them +is a trade-off rather than a preference. There is no third option today. + +**One combined scan** — a single invocation, no `--workspace-name`, with +`--target-path` at the repository root or several repeated `--sub-path` values +sharing one workspace name: + +- One dashboard entry for the repository, named after the repository +- One server-side dependency graph covering everything that was uploaded +- Alerts are **not** broken out by component, so a finding does not tell you which + part of the monorepo introduced it +- Transitive findings can surface without a clear owning component, because the + combined graph has no component boundaries to attribute them to + +**One scan per component** — a separate invocation per component, each with its +own `--sub-path` and a distinct `--workspace-name`: + +- Per-component alerts, baselines, and policy +- Each component gets its own dependency graph, which is also the faster option + (see the performance note above) +- But `--workspace-name` suffixes the repository slug, so *N* components produce + *N* separate entries in the dashboard's repository list + +The second point is what makes this a real choice: a monorepo with a dozen or more +independently scanned components produces a dozen or more repository entries, which +gets hard to navigate as the list grows. A single consolidated entry that still +preserves per-component attribution is a known request and is not available today. + +Rules of thumb: + +- **Few components, or components that share a release cycle** — use one combined + scan and accept coarser attribution. +- **Many components, or components with different owners or policies** — use + per-component scans and accept the extra dashboard entries. Per-component policy + is only possible in this layout. +- **Components that are genuinely one application** — group them under a single + `--workspace-name`, as in the first example below. Grouping is per logical + application, not per directory. ### Usage Examples -**Scan multiple frontend and backend workspaces:** +**Scan several directories that belong to one logical application:** ```bash socketcli --target-path /path/to/monorepo \ --sub-path frontend \ @@ -89,6 +138,19 @@ This will: - Create a repository in Socket named like `my-repo-mobile-web` - Preserve git context (commits, branch info) from the repository root +**Create independent frontend and backend scans:** +```bash +socketcli --target-path /path/to/monorepo \ + --sub-path frontend \ + --workspace-name frontend + +socketcli --target-path /path/to/monorepo \ + --sub-path backend \ + --workspace-name backend +``` + +These are two full-scan uploads, two server-side graphs, and two repository head/baseline sequences. In CI they can run as separate matrix jobs. See [GitHub Actions: scan changed monorepo workspaces independently](ci-cd.md#github-actions-scan-changed-monorepo-workspaces-independently). + **Generate GitLab Security Dashboard report:** ```bash socketcli --enable-gitlab-security \ @@ -138,6 +200,7 @@ This will simultaneously generate: - Both `--sub-path` and `--workspace-name` must be specified together - `--sub-path` can be used multiple times to include multiple directories +- Repeated `--sub-path` values are combined into one scan; they do not create independent workspace scans - All specified sub-paths must exist within the target-path ## Usage @@ -372,7 +435,7 @@ The launcher can be tuned via the `SOCKET_CLI_COANA_LAUNCHER` environment variab | `--strict-blocking` | False | False | Fail on ANY security policy violations (blocking severity), not just new ones. Only works in diff mode. See [Strict Blocking Mode](#strict-blocking-mode) for details. | | `--enable-diff` | False | False | Enable diff mode even when using `--integration api` (forces diff mode without SCM integration) | | `--scm` | False | api | Source control management type | -| `--timeout` | False | | Timeout in seconds for API requests | +| `--timeout` | False | 1200 | Timeout in seconds for each API request. This is not a total CLI runtime limit and does not limit local discovery, Git, or reachability analysis. | #### Plugins diff --git a/pyproject.toml b/pyproject.toml index c5e4da96..29a9e412 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.7.2" +version = "2.8.0" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 9a275469..a97b4d18 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.7.2' +__version__ = '2.8.0' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index a5305bee..b1b1d65b 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -1154,6 +1154,30 @@ def create_full_scan(self, files: List[str], params: FullScanParams, base_paths: return full_scan + @staticmethod + def _log_scan_configuration( + paths: List[str], + params: FullScanParams, + files: List[str], + manifest_source: str, + base_paths: Optional[List[str]] = None, + ) -> None: + """Log aggregate scan inputs without exposing submitted manifest paths.""" + base_path = base_paths[0] if base_paths else (paths[0] if paths else ".") + absolute_base_path = os.path.abspath(base_path) + relative_roots = [ + os.path.relpath(os.path.abspath(path), absolute_base_path).replace("\\", "/") + for path in paths + ] or ["."] + log.info( + "Scan configuration: " + f"repo={json.dumps(getattr(params, 'repo', None), default=str)} " + f"workspace={json.dumps(getattr(params, 'workspace', None), default=str)} " + f"scan_type={json.dumps(getattr(params, 'scan_type', None), default=str)} " + f"roots={json.dumps(relative_roots, separators=(',', ':'))} " + f"manifests={len(files)} manifest_source={manifest_source}" + ) + def create_full_scan_with_report_url( self, paths: List[str], @@ -1196,6 +1220,14 @@ def create_full_scan_with_report_url( for path in paths: files = self.find_files(path) all_files.extend(files) + + self._log_scan_configuration( + paths, + params, + all_files, + manifest_source="provided" if explicit_files is not None else "discovered", + base_paths=base_paths, + ) # Save submitted files list if requested if save_files_list_path and all_files: @@ -1503,7 +1535,10 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: has no head scan yet (caller creates an empty baseline scan). """ if self.cli_config and self.cli_config.base_scan_id: - log.info(f"Using full scan {self.cli_config.base_scan_id} as diff baseline (--base-scan-id)") + log.info( + "Baseline selected: source=explicit-scan " + f"scan_id={json.dumps(self.cli_config.base_scan_id)}" + ) return self.cli_config.base_scan_id if self.cli_config and self.cli_config.base_commit_sha: @@ -1524,13 +1559,22 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: if self.cli_config.disable_blocking: sys.exit(0) sys.exit(self.cli_config.exit_code_on_api_error) - log.info(f"Using full scan {scan_id} (commit {commit_sha}) as diff baseline (--base-commit-sha)") + log.info( + "Baseline selected: source=explicit-commit " + f"scan_id={json.dumps(scan_id)} commit={json.dumps(commit_sha)}" + ) return scan_id try: - return self.get_head_scan_for_repo(params.repo) + scan_id = self.get_head_scan_for_repo(params.repo) except APIResourceNotFound: return None + if scan_id: + log.info( + "Baseline selected: source=repository-head " + f"scan_id={json.dumps(scan_id)}" + ) + return scan_id @staticmethod def update_package_values(pkg: Package) -> Package: @@ -1830,6 +1874,10 @@ def get_added_and_removed_packages( f"Diff scan comparison failed with {type(error).__name__}({error_summary}), " "falling back to the streaming scan comparison" ) + log.info( + "Diff comparison mode: requested=diff-scan effective=streaming " + f"reason={type(error).__name__}" + ) if diff_artifacts is None: try: @@ -1844,9 +1892,8 @@ def get_added_and_removed_packages( ) except APIFailure as e: log.error(f"API Error: {e}") - if self.cli_config and self.cli_config.disable_blocking: - sys.exit(0) - sys.exit(1) + # API failures are mapped to the configured infrastructure exit code by cli(). + raise except Exception as e: import traceback log.error(f"Error getting diff report: {str(e)}") @@ -1959,6 +2006,14 @@ def create_new_diff( for path in paths: files = self.find_files(path) all_files.extend(files) + + self._log_scan_configuration( + paths, + params, + all_files, + manifest_source="provided" if explicit_files is not None else "discovered", + base_paths=base_paths, + ) # Save submitted files list if requested if save_files_list_path and all_files: @@ -1994,7 +2049,10 @@ def create_new_diff( try: head_full_scan = self.create_full_scan(empty_files, tmp_params, base_paths=base_paths) head_full_scan_id = head_full_scan.id - log.debug(f"Created empty baseline scan: {head_full_scan_id}") + log.info( + "Baseline selected: source=empty " + f"scan_id={json.dumps(head_full_scan_id)}" + ) # Clean up the temporary empty file for temp_file in empty_files: @@ -2030,9 +2088,8 @@ def create_new_diff( os.unlink(temp_file) except OSError: pass - if self.cli_config and self.cli_config.disable_blocking: - sys.exit(0) - sys.exit(1) + # API failures are mapped to the configured infrastructure exit code by cli(). + raise except Exception as e: import traceback log.error(f"Error creating new full scan: {str(e)}") diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 1179e915..9dd3bb1e 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -60,6 +60,12 @@ def _emit_infrastructure_error(message: str, include_traceback: bool = False) -> traceback.print_exc() +def _log_scan_mode_fallback(requested: str, effective: str, reason: str) -> None: + log.info( + f"Scan mode: requested={requested} effective={effective} reason={reason}" + ) + + def build_license_artifact_payload( diff: Diff, legal_format: str = "socket", @@ -851,6 +857,11 @@ def _is_unprocessed(c): # User requested diff mode but no manifest files were detected - this should not happen with new logic # but keeping as a safety net log.warning("--enable-diff was specified but no supported manifest files were detected in the changed files. Falling back to full scan mode.") + _log_scan_mode_fallback( + "diff", + "full", + "no-supported-manifest-in-changed-files", + ) log.info("Creating Socket Report (full scan)") serializable_params = { key: value if isinstance(value, (int, float, str, list, dict, bool, type(None))) else str(value) @@ -872,6 +883,11 @@ def _is_unprocessed(c): else: if force_api_mode: + _log_scan_mode_fallback( + "default", + "full", + "no-supported-manifest-in-changed-files", + ) log.info( "No supported manifest detected in the changed-file set; " "creating a full Socket report" diff --git a/tests/core/test_sdk_methods.py b/tests/core/test_sdk_methods.py index da0efc62..d79f62f3 100644 --- a/tests/core/test_sdk_methods.py +++ b/tests/core/test_sdk_methods.py @@ -306,3 +306,35 @@ def test_empty_alerts_preserved(core): # Check the final package assert head_scan.packages["dp2"].alerts == [] # Should still be empty list + + +def test_repository_head_baseline_log(core, caplog): + with caplog.at_level("INFO", logger="socketdev"): + assert core.resolve_base_full_scan_id(make_full_scan_params()) == "head" + + assert 'Baseline selected: source=repository-head scan_id="head"' in caplog.messages + + +def test_explicit_scan_baseline_log(core, caplog): + core.cli_config = make_cli_config("--base-scan-id", "explicit-base") + + with caplog.at_level("INFO", logger="socketdev"): + assert core.resolve_base_full_scan_id(make_full_scan_params()) == "explicit-base" + + assert 'Baseline selected: source=explicit-scan scan_id="explicit-base"' in caplog.messages + + +def test_explicit_commit_baseline_log(core, caplog): + core.cli_config = make_cli_config("--base-commit-sha", "abc123") + core.sdk.fullscans.get.return_value = { + "results": [{"id": "merge-base-scan"}], + "nextPage": None, + } + + with caplog.at_level("INFO", logger="socketdev"): + assert core.resolve_base_full_scan_id(make_full_scan_params()) == "merge-base-scan" + + assert ( + 'Baseline selected: source=explicit-commit scan_id="merge-base-scan" ' + 'commit="abc123"' + ) in caplog.messages diff --git a/tests/unit/test_scan_scope.py b/tests/unit/test_scan_scope.py new file mode 100644 index 00000000..bedd2c97 --- /dev/null +++ b/tests/unit/test_scan_scope.py @@ -0,0 +1,140 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from socketdev.exceptions import APIFailure +from socketdev.fullscans import FullScanParams + +from socketsecurity.core import Core +from socketsecurity.core.classes import Diff + + +def _core() -> Core: + core = Core.__new__(Core) + core.config = SimpleNamespace(org_slug="example") + core.cli_config = SimpleNamespace( + disable_blocking=False, + exit_code_on_api_error=0, + generate_license=False, + ) + core.sdk = MagicMock() + return core + + +def test_multiple_scan_paths_are_uploaded_as_one_combined_full_scan(caplog): + """Repeated --sub-path roots feed one graph, not independent scans.""" + core = _core() + core.find_files = MagicMock( + side_effect=[ + ["/repo/frontend/package.json"], + ["/repo/backend/requirements.txt"], + ] + ) + core.resolve_base_full_scan_id = MagicMock(return_value="base-scan") + core.create_full_scan = MagicMock(return_value=SimpleNamespace(id="new-scan")) + core.get_added_and_removed_packages = MagicMock(return_value=({}, {}, {})) + core.create_diff_report = MagicMock(return_value=Diff()) + params = FullScanParams(repo="repo-combined", branch="feature", scan_type="socket") + params.include_license_details = True + + with caplog.at_level("INFO", logger="socketdev"): + result = core.create_new_diff( + ["/repo/frontend", "/repo/backend"], + params, + base_paths=["/repo"], + ) + + assert core.find_files.call_args_list == [ + (("/repo/frontend",),), + (("/repo/backend",),), + ] + core.create_full_scan.assert_called_once_with( + ["/repo/frontend/package.json", "/repo/backend/requirements.txt"], + params, + base_paths=["/repo"], + ) + assert result.id == "new-scan" + assert ( + 'Scan configuration: repo="repo-combined" workspace=null ' + 'scan_type="socket" roots=["frontend","backend"] manifests=2 ' + "manifest_source=discovered" + ) in caplog.messages + + +def test_scan_configuration_omits_absolute_and_manifest_paths(caplog): + params = FullScanParams( + repo="repo-service", + branch="feature", + workspace="engineering", + ) + + with caplog.at_level("INFO", logger="socketdev"): + Core._log_scan_configuration( + ["/private/build/repo/service"], + params, + ["/private/build/repo/service/requirements.txt"], + manifest_source="provided", + base_paths=["/private/build/repo"], + ) + + message = caplog.messages[-1] + assert 'workspace="engineering"' in message + assert 'roots=["service"]' in message + assert "manifests=1 manifest_source=provided" in message + assert "/private/build" not in message + assert "requirements.txt" not in message + + +def test_empty_baseline_logs_created_scan_id(caplog): + core = _core() + core.resolve_base_full_scan_id = MagicMock(return_value=None) + core.create_full_scan = MagicMock( + side_effect=[ + SimpleNamespace(id="empty-base"), + SimpleNamespace(id="new-scan"), + ] + ) + core.get_added_and_removed_packages = MagicMock(return_value=({}, {}, {})) + core.create_diff_report = MagicMock(return_value=Diff()) + params = FullScanParams(repo="repo-service", branch="feature") + params.include_license_details = True + + with caplog.at_level("INFO", logger="socketdev"): + core.create_new_diff( + ["/repo/service"], + params, + base_paths=["/repo"], + explicit_files=["/repo/service/requirements.txt"], + ) + + assert 'Baseline selected: source=empty scan_id="empty-base"' in caplog.messages + + +def test_full_scan_api_failure_propagates_for_cli_exit_code_mapping(): + core = _core() + core.resolve_base_full_scan_id = MagicMock(return_value="base-scan") + core.create_full_scan = MagicMock(side_effect=APIFailure("upload failed")) + params = FullScanParams(repo="repo", branch="feature") + params.include_license_details = True + + with pytest.raises(APIFailure, match="upload failed"): + core.create_new_diff( + ["/repo/workspace"], + params, + explicit_files=["/repo/workspace/package.json"], + ) + + +def test_diff_api_failure_propagates_for_cli_exit_code_mapping(caplog): + core = _core() + core.get_diff_scan_artifacts = MagicMock(side_effect=RuntimeError("poll failed")) + core.sdk.fullscans.stream_diff.side_effect = APIFailure("comparison failed") + + with caplog.at_level("INFO", logger="socketdev"): + with pytest.raises(APIFailure, match="comparison failed"): + core.get_added_and_removed_packages("base-scan", "new-scan") + + assert ( + "Diff comparison mode: requested=diff-scan effective=streaming " + "reason=RuntimeError" + ) in caplog.messages diff --git a/tests/unit/test_socketcli.py b/tests/unit/test_socketcli.py index 68a63ec7..d8f661aa 100644 --- a/tests/unit/test_socketcli.py +++ b/tests/unit/test_socketcli.py @@ -100,6 +100,20 @@ def test_emit_infra_error_traceback_gated(monkeypatch, capsys): assert "Traceback" in err and "ValueError: boom" in err +def test_scan_mode_fallback_log_is_structured(caplog): + with caplog.at_level("INFO", logger="socketcli"): + socketcli._log_scan_mode_fallback( + "diff", + "full", + "no-supported-manifest-in-changed-files", + ) + + assert ( + "Scan mode: requested=diff effective=full " + "reason=no-supported-manifest-in-changed-files" + ) in caplog.messages + + def test_build_license_artifact_payload_without_packages_returns_empty_dict(): diff = Diff() diff --git a/uv.lock b/uv.lock index 80dd5713..2eab7d99 100644 --- a/uv.lock +++ b/uv.lock @@ -1293,7 +1293,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.7.2" +version = "2.8.0" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 860b8eac976cced1b0ff6161d2e9bebdd149bf06 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:40:38 -0400 Subject: [PATCH 38/47] Normalize Slack issue severity and bump SDK to `v3.6.0` (#331) * fix(slack): normalize the API's "middle" severity to "medium" Every severity lookup in the Slack reachability formatter is keyed on "medium", but "middle" is what the API sends. A mid-severity finding missed all of them at once: uncounted in the summary, excluded from total_findings so the "and N more" count can go negative, and sorted at the default order of 4 -- below "low" -- so it was truncated out of the message first. Normalized at the point the alert is read rather than by adding a parallel key to each dict, so one canonical spelling flows downstream. The GitLab severity map and the PR comment path already accept both forms; this formatter did not. Co-Authored-By: Claude Opus 5 (1M context) * chore(deps): bump socketdev to 3.6.0 Unblocks the pin now that 3.6.0 is on PyPI. SocketPURL_Type gained ten members -- alpm, chrome, clawhub, edge-extension, firefox-extension, qpkg, socket, swid, vscode and vscode-extension -- and removed none, so artifacts of those types stop falling back to "unknown". No other CLI change is needed: none of the SDK's enum types are imported here, and every severity and type lookup already has a default, so the new members cannot reach an unguarded branch. Co-Authored-By: Claude Opus 5 (1M context) * ci: only floor the version check at the latest published release The check required the PR version to exceed both main and PyPI. Comparing against main forbids the legitimate case where several PRs ship under one unreleased version: the first bumps main, and the rest ride it without bumping again so they stay under a single changelog header. Every such PR failed, and the only way to green it was a throwaway bump that would strand a changelog header on a version that never ships. PyPI is now the floor, since the real invariant is that a release cannot reuse a published version. Main is still a floor in the one direction that matters: a PR may leave the version alone or move it forwards, never back. Every genuine failure the old check caught -- forgetting to bump, reusing a published version, branching from a stale base -- still fails. Also added this workflow to its own paths filter so a change to the check is exercised by the PR that makes it. Co-Authored-By: Claude Opus 5 (1M context) * ci: require pyproject.toml and __init__.py versions to agree The version lives as two hand-maintained literals with nothing deriving one from the other: pyproject.toml is what gets published, and __init__.py is what the CLI reports as its User-Agent. Every comparison in this job read only __init__.py, so bumping that alone passed the check and then published under the old number -- surfacing late, as twine rejecting an existing file, after the merge. Both are now required to match before any other comparison runs. uv.lock carries a third copy, but uv derives it and `uv lock --locked` in python-tests already fails when it drifts, so it needs no check here. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/version-check.yml | 59 +++++++++++--- CHANGELOG.md | 21 +++++ pyproject.toml | 2 +- socketsecurity/plugins/formatters/slack.py | 6 ++ .../unit/test_slack_severity_normalization.py | 76 +++++++++++++++++++ uv.lock | 8 +- 6 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_slack_severity_normalization.py diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index d2ff77ad..292e2980 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -6,6 +6,8 @@ on: - 'socketsecurity/**' - 'pyproject.toml' - 'uv.lock' + # Included so a change to the check itself is exercised by its own PR. + - '.github/workflows/version-check.yml' permissions: contents: read @@ -42,16 +44,37 @@ jobs: export PR_VERSION export MAIN_VERSION - # Compare against both main and latest published PyPI release. + # Compare against the latest published PyPI release. python3 <<'PY' import json import os + import tomllib import urllib.request from packaging import version pr_ver = version.parse(os.environ["PR_VERSION"]) main_ver = version.parse(os.environ["MAIN_VERSION"]) + with open("pyproject.toml", "rb") as fh: + pyproject_ver = version.parse(tomllib.load(fh)["project"]["version"]) + + # The version is two hand-maintained literals with nothing deriving one + # from the other: pyproject.toml is what actually gets published, and + # socketsecurity/__init__.py is what the CLI reports as its User-Agent. + # Every comparison below reads only __init__.py, so bumping that alone + # would pass this job and then publish under the old number -- caught + # late, by twine rejecting an existing file, after the merge. Require + # the two to agree before comparing anything. (uv.lock carries a third + # copy, but uv derives it and `uv lock --locked` in python-tests + # already fails when it drifts.) + if pr_ver != pyproject_ver: + print( + f"❌ Version mismatch inside the PR: pyproject.toml is " + f"{pyproject_ver}, socketsecurity/__init__.py is {pr_ver}. " + f"Bump both." + ) + raise SystemExit(1) + with urllib.request.urlopen("https://pypi.org/pypi/socketsecurity/json") as response: pypi_data = json.load(response) @@ -62,19 +85,37 @@ jobs: published_versions.append(parsed) pypi_ver = max(published_versions) if published_versions else version.parse("0.0.0") - required_floor = max(main_ver, pypi_ver) - if pr_ver <= required_floor: + # The only hard requirement is that the version is ahead of what is + # actually released. Treating main's version as a second floor breaks + # the legitimate case where several PRs share one unreleased release: + # the first bumps main to the new version and the rest ride it without + # bumping again, which is what keeps them under a single changelog + # header. Main is therefore only a floor when this PR moves the + # version -- a change to it must go forwards, never backwards. + if pr_ver <= pypi_ver: print( - f"❌ Version must be greater than main and PyPI! " - f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" + f"❌ Version {pr_ver} is already published on PyPI " + f"(latest release: {pypi_ver}). Bump it." + ) + raise SystemExit(1) + + if pr_ver < main_ver: + print( + f"❌ Version moves backwards: main is {main_ver}, PR is {pr_ver}." ) raise SystemExit(1) - print( - f"✅ Version properly incremented. " - f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" - ) + if pr_ver == main_ver: + print( + f"✅ Riding main's unreleased {pr_ver} " + f"(latest PyPI release: {pypi_ver})." + ) + else: + print( + f"✅ Version properly incremented. " + f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" + ) PY - name: Require uv.lock update when pyproject changes diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dda5bc..ba943fa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,32 @@ - Clarified monorepo scan scoping, workspace flags, CI path filters, and timeout behavior, with a changed-workspace GitHub Actions example. +### Changed: bump socketdev to 3.6.0 + +- Bumped the pinned SDK (`socketdev`) from `3.5.0` to `3.6.0`. Its package-type + enum gained ten members — `alpm`, `chrome`, `clawhub`, `edge-extension`, + `firefox-extension`, `qpkg`, `socket`, `swid`, `vscode` and + `vscode-extension` — so artifacts of those types are now reported under their + own type instead of falling back to `unknown`. + ### Fixed: apply configured exit codes to API failures - Full-scan and streamed-diff API failures now use the configured infrastructure error exit code instead of the security-finding exit code. +### Fixed: mid-severity findings were dropped from the Slack summary + +- The Slack reachability formatter keyed every severity lookup on `medium`, + but the API sends `middle`. A mid-severity finding therefore missed all of + them at once: it was not counted, so the summary always read `Medium: 0`; it + was excluded from `total_findings`, which can drive the "and N more" count + negative; and it sorted at the default order of 4, below `low`, so it was the + first thing truncated when the Slack block limit was reached. +- Severity is now normalized to one spelling when an alert is read, matching + how the GitLab and PR-comment paths already handle both forms. The findings + themselves were always listed; only the counts, ordering and truncation were + wrong. + ## 2.7.2 ### Changed: bump pinned @coana-tech/cli to 15.10.39 diff --git a/pyproject.toml b/pyproject.toml index 29a9e412..a9035118 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "GitPython==3.1.59", "packaging==26.3", "python-dotenv==1.2.3", - "socketdev==3.5.0", + "socketdev==3.6.0", "beautifulsoup4==4.15.0", "markdown==3.10.3", "brotli==1.2.0; platform_python_implementation == 'CPython'", diff --git a/socketsecurity/plugins/formatters/slack.py b/socketsecurity/plugins/formatters/slack.py index 1a3ab874..955b330a 100644 --- a/socketsecurity/plugins/formatters/slack.py +++ b/socketsecurity/plugins/formatters/slack.py @@ -108,6 +108,12 @@ def _extract_alert_info(component: Dict[str, Any], alert: Dict[str, Any]) -> Dic """ props = alert.get('props', {}) or {} severity = str(alert.get('severity') or props.get('severity') or '').lower() + # The API's mid-level severity is "middle"; every lookup in this module is + # keyed on "medium". Normalizing here rather than adding a parallel key to + # each dict keeps one canonical spelling downstream, matching what + # Messages.map_socket_severity_to_gitlab already does. + if severity == 'middle': + severity = 'medium' return { 'cve_id': str(props.get('ghsaId') or props.get('cveId') or alert.get('title') or 'Unknown'), diff --git a/tests/unit/test_slack_severity_normalization.py b/tests/unit/test_slack_severity_normalization.py new file mode 100644 index 00000000..861fe34c --- /dev/null +++ b/tests/unit/test_slack_severity_normalization.py @@ -0,0 +1,76 @@ +"""The Slack formatter keys on "medium"; the API sends "middle". + +Every severity lookup in ``socketsecurity/plugins/formatters/slack.py`` is keyed +on ``medium``, but ``middle`` is what the API actually emits -- it is the value +in the OpenAPI spec's ``SocketIssueSeverity`` and in the SDK enum. Unnormalized, +a mid-severity finding fell through every one of them at once: + +* it was not counted, so the summary always read ``Medium: 0`` +* it was excluded from ``total_findings``, which can drive ``omitted_count`` + negative when mid-severity findings are the ones being displayed +* it sorted at the default order of 4, below ``low``, so it was truncated out of + the message first when the block limit was reached + +Two other call sites already handle both spellings (``Messages.map_socket_ +severity_to_gitlab`` and the GitLab severity map); this formatter did not. +""" + +import unittest + +from socketsecurity.plugins.formatters.slack import ( + SEVERITY_EMOJI, + SEVERITY_ORDER, + _extract_alert_info, + format_socket_facts_for_slack, +) + + +def _component(severity: str) -> dict: + return { + "name": "example-package", + "version": "1.0.0", + "alerts": [{"title": "Example alert", "severity": severity, "props": {}}], + } + + +class TestSeverityNormalization(unittest.TestCase): + def test_middle_normalizes_to_medium(self): + info = _extract_alert_info(_component("middle"), {"severity": "middle"}) + self.assertEqual(info["severity"], "medium") + + def test_middle_gets_the_medium_order_not_the_default(self): + info = _extract_alert_info(_component("middle"), {"severity": "middle"}) + self.assertEqual(info["severity_order"], SEVERITY_ORDER["medium"]) + # Regression: the default of 4 sorted mid-severity below "low". + self.assertLess(info["severity_order"], SEVERITY_ORDER["low"]) + + def test_middle_gets_the_medium_emoji_not_the_fallback(self): + info = _extract_alert_info(_component("middle"), {"severity": "middle"}) + self.assertEqual(info["severity_emoji"], SEVERITY_EMOJI["medium"]) + self.assertNotEqual(info["severity_emoji"], SEVERITY_EMOJI["low"]) + + def test_medium_still_works(self): + info = _extract_alert_info(_component("medium"), {"severity": "medium"}) + self.assertEqual(info["severity"], "medium") + self.assertEqual(info["severity_order"], SEVERITY_ORDER["medium"]) + + def test_middle_findings_are_counted_in_the_summary(self): + result = format_socket_facts_for_slack([_component("middle")]) + self.assertEqual(len(result), 1) + self.assertIn("🟡 Medium: 1", result[0]["summary"]) + + def test_middle_findings_reach_total_findings(self): + # Regression: excluded from the total, omitted_count could go negative. + result = format_socket_facts_for_slack([_component("middle")]) + self.assertEqual(result[0]["total_findings"], 1) + + def test_unrecognized_severity_still_falls_back(self): + info = _extract_alert_info( + _component("brand-new-level"), {"severity": "brand-new-level"} + ) + self.assertEqual(info["severity_order"], 4) + self.assertEqual(info["severity_emoji"], "⚪") + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 2eab7d99..28dea3ad 100644 --- a/uv.lock +++ b/uv.lock @@ -1280,15 +1280,15 @@ wheels = [ [[package]] name = "socketdev" -version = "3.5.0" +version = "3.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/24/0c11290dc7d59e24b7075035c7e1a3ab87fa17a445cebc88cfa6ee98b22c/socketdev-3.5.0.tar.gz", hash = "sha256:a2b20f9b98f73c25f3d2e97a1ae730504509c91219c0b393f28a9230266b3531", size = 195138, upload-time = "2026-08-06T03:47:14.185Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/64/7c44c1b1f739db9c40ea0a3d429d27b1810f43153b0680ec4bfc3aada644/socketdev-3.6.0.tar.gz", hash = "sha256:8453da37520db79735479e0892b74cef8ce5bcba52c99f15ffacc6a70f9ea355", size = 201023, upload-time = "2026-09-09T22:44:01.761Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/75/5a8506a473716740e94f2f01b697909333f966c143d8a0a566d278e6118d/socketdev-3.5.0-py3-none-any.whl", hash = "sha256:780f5841770397035ff87de6181d954b6318cd0a07f6fdd304d1376667f33f68", size = 72027, upload-time = "2026-08-06T03:47:12.773Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c3/e4ac2746b803109eab1fa7b44b02697f365fb418340411df5091e50d4a07/socketdev-3.6.0-py3-none-any.whl", hash = "sha256:fe017aefa72638375bb0fb1697bf5704f64143a5fab634534dfc3e77c851b374", size = 73352, upload-time = "2026-09-09T22:44:00.167Z" }, ] [[package]] @@ -1350,7 +1350,7 @@ requires-dist = [ { name = "python-dotenv", specifier = "==1.2.3" }, { name = "requests", specifier = "==2.34.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.5" }, - { name = "socketdev", specifier = "==3.5.0" }, + { name = "socketdev", specifier = "==3.6.0" }, { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.8" }, ] From 3070e43320c235290c778042f00af2de8730270c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:04:48 -0400 Subject: [PATCH 39/47] chore(deps): bump the python-minor-patch group with 2 updates (#351) Bumps the python-minor-patch group with 2 updates: [ruff](https://github.com/astral-sh/ruff) and [uv](https://github.com/astral-sh/uv). Updates `ruff` from 0.16.5 to 0.16.6 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.5...0.16.6) Updates `uv` from 0.12.8 to 0.12.9 - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.12.8...0.12.9) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: uv dependency-version: 0.12.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 4 +-- uv.lock | 90 +++++++++++++++++++++++++------------------------- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a9035118..da39c80d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,9 +47,9 @@ test = [ "pytest-watch==4.2.0" ] dev = [ - "ruff==0.16.5", + "ruff==0.16.6", "twine==7.0.0", # for building - "uv==0.12.8", # for dependency management + "uv==0.12.9", # for dependency management "pre-commit==4.6.2", "hatch==1.18.0" ] diff --git a/uv.lock b/uv.lock index 28dea3ad..115542a2 100644 --- a/uv.lock +++ b/uv.lock @@ -1224,27 +1224,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, - { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, - { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, - { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, - { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, - { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, - { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, - { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, - { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, - { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] [[package]] @@ -1349,10 +1349,10 @@ requires-dist = [ { name = "pytest-watch", marker = "extra == 'test'", specifier = "==4.2.0" }, { name = "python-dotenv", specifier = "==1.2.3" }, { name = "requests", specifier = "==2.34.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.5" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.6" }, { name = "socketdev", specifier = "==3.6.0" }, { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, - { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.8" }, + { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.9" }, ] provides-extras = ["test", "dev"] @@ -1505,28 +1505,28 @@ wheels = [ [[package]] name = "uv" -version = "0.12.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b1/38/53754ae5033d0dbfe01976249e4b1b48539fe5080aeec162bc920f6244d6/uv-0.12.8.tar.gz", hash = "sha256:dc6a191265fed5102d9678958b3a157c81e61246316f07e7d980c3616032f49d", size = 7124914, upload-time = "2026-08-31T22:18:18.1Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/13/8e6984272962a273323c58113ae390eb709f7a74726009e1bcecbb7fe0fd/uv-0.12.8-py3-none-linux_armv6l.whl", hash = "sha256:e6c617d5674867653738eaec333025142e9de33410955f8f6ae40e2e8ac85e77", size = 22245394, upload-time = "2026-08-31T22:17:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/0d/38/18d7ae5d4b40714b8e2c474bfcac907a8dd27573044b60e808b895a07423/uv-0.12.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:edfb42bc830b4c473653ed5470ac4d967ae2cd40c1b7409ec4c7c8b655d04235", size = 20544653, upload-time = "2026-08-31T22:17:27.429Z" }, - { url = "https://files.pythonhosted.org/packages/84/32/249b0ec1a62916b8e7c2638154b930daaf9eae2df8b0807ad455c8916b7e/uv-0.12.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:842e27b85ec85c32369919b6dc72a4ae0c737b4376165d34a6ebb8329ce9d744", size = 17363409, upload-time = "2026-08-31T22:17:30.309Z" }, - { url = "https://files.pythonhosted.org/packages/62/4c/74ba60fbcda11be5aa5a7f139bc122834ec740d735e155ad9324aff3570a/uv-0.12.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ff4d67a13a3283004e3b00e22c39922c27b0e1929f82f9d6873dc85dc8bf6dc7", size = 21484891, upload-time = "2026-08-31T22:17:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4a/92/6817f8f705bcf0e6b51302a55a899faf2a29d9c3f056cb04f9f133a7192b/uv-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:a8a8129edee5591b09933b8387d3ccc250933a9cb39f98bde89d6e4f920a870d", size = 21731676, upload-time = "2026-08-31T22:17:36.323Z" }, - { url = "https://files.pythonhosted.org/packages/6f/df/7ca009329a0b2194ca709f51e1f304b01086e0f99f3f8e0ea81460a193b9/uv-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef0c05b39d8701b4bb8196aeaf2594147d8369a1026b0b731eead41cb1df1884", size = 21749846, upload-time = "2026-08-31T22:17:39.517Z" }, - { url = "https://files.pythonhosted.org/packages/ba/46/1e73455d1c4d8f9eafaac19879ba3fc5b8c0743d1b04d11ad1ac24e1a522/uv-0.12.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddaa3ceb3f877cce42e75084d10ebe53148163aaa4b1d16432c3b952e9c7466c", size = 22469076, upload-time = "2026-08-31T22:17:43.016Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a3/f11be2600c67345bfb694fd7c68dad97d4133d54306d298a2690b21da66b/uv-0.12.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e9b112000bd5ff8e8c97922f03acede7d1f2385b3261bc6a15d6b18871e9a25", size = 23653340, upload-time = "2026-08-31T22:17:46.23Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f9/09e1762b40451227fc944551c7ea62d13f9b29e4965bf23adffeaba0f29a/uv-0.12.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a2f848916a6e5b3111e5d4c9e6ac66d9e38c561da4fa3a4ff51691e3c55974c", size = 23346672, upload-time = "2026-08-31T22:17:49.145Z" }, - { url = "https://files.pythonhosted.org/packages/19/e7/6e1797aec65defdc02a81444c2d8964101876097c461993fa1e14641782e/uv-0.12.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d63d046051d33b36260146df5aef03e9166b30b4546e9b6e554be152b69f9f9", size = 20068890, upload-time = "2026-08-31T22:17:52.225Z" }, - { url = "https://files.pythonhosted.org/packages/62/b5/2662d5da5158e4db444a9e050f8f1eaecea2832311e2da27ac53a5d15ebb/uv-0.12.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7db4fb5d559bab30ea2310db9119cfd44bc438c09f24cb33d6b9a518fd671143", size = 19434317, upload-time = "2026-08-31T22:17:55.008Z" }, - { url = "https://files.pythonhosted.org/packages/f8/0f/fe154c14adc7af8e659784a59f34c2c0dda6462c1427ce6ac5fff2652391/uv-0.12.8-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:dc5990c941a7c064f761f13ddfd9a0de66a0063676e228403e13709e0254094f", size = 22454547, upload-time = "2026-08-31T22:17:58.008Z" }, - { url = "https://files.pythonhosted.org/packages/77/d9/8e3bd5733290cd8428db0995d1b9ef633760cf12ae6cdd437eddcae95d93/uv-0.12.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5e30eb88618e8ac315b6511bcd64bf4b40aa0737912ce9c2eb988dc6aa59473b", size = 22575842, upload-time = "2026-08-31T22:18:00.959Z" }, - { url = "https://files.pythonhosted.org/packages/06/3f/c6c2331189bb92f72487d85c94d87af2b888f2fa9e95c4d3163247c2f850/uv-0.12.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8d3bd25b02887fd437fc8b308101c89b651ac43870579bb1a498d139b096582d", size = 21585514, upload-time = "2026-08-31T22:18:03.892Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fa/094b6154e4fb624b3aeeb313fb6888e439e3d65092cf5fcbb4ea1e362c17/uv-0.12.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b8b589f36222ed73f070598d9cd68f7dc706cf5d670a8c469b648287a97a590a", size = 22850849, upload-time = "2026-08-31T22:18:06.887Z" }, - { url = "https://files.pythonhosted.org/packages/e9/aa/c0755ee526db93453b2a5b4079f46ece9819c9e26918be36187888c856c6/uv-0.12.8-py3-none-win32.whl", hash = "sha256:a9ddf59627e0718db0e84b329005462f335fe707723813036322f00133d992fa", size = 19862492, upload-time = "2026-08-31T22:18:09.864Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f2/3564ae1d1d37b66dc64e6be52f5e5fe00cfce235b6f60d0b708c6caf7588/uv-0.12.8-py3-none-win_amd64.whl", hash = "sha256:53984d68cddd227e6433b70b510d4b47fe82e658f22f4a6ca416b4e373406393", size = 18084781, upload-time = "2026-08-31T22:18:12.77Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b4/724322de5a51ec8c59e3e8d90eeaa8f7c7d0b3b58390805908b87dfd57d7/uv-0.12.8-py3-none-win_arm64.whl", hash = "sha256:71763f479286a9e3285642fba8fca710aea9596d56e72bc247ecdf882e05ebc4", size = 19526211, upload-time = "2026-08-31T22:18:15.521Z" }, +version = "0.12.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/42/6d2be569fd280ebd982a54ee2e3d3754fedde5cf557fd7f858eb40d161cf/uv-0.12.9.tar.gz", hash = "sha256:55b8920edb2a29eeef31e246fb60d2145985e17f51ad14b26643575b82759302", size = 7134801, upload-time = "2026-09-01T21:57:47.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/4a/208c1ed443a5024bdb83cedf1e09b322e68ccc9f7e11d1f53bb1f0690174/uv-0.12.9-py3-none-linux_armv6l.whl", hash = "sha256:97062b2160a97fcfa45f10bbbe02a9c6ba4f5a50cf4e02c0f2babb80b47c1ea8", size = 22183326, upload-time = "2026-09-01T21:57:01.443Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/7cc3d45951bb01089bf948d91a89c5b433789df8ceeba4f4cbcce0b1ef5c/uv-0.12.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0cb7e8a732d81f25d364de8d6ac69f4d00d56b581918ac242eeaeffb53977588", size = 20481767, upload-time = "2026-09-01T21:57:04.573Z" }, + { url = "https://files.pythonhosted.org/packages/5f/2b/28f22620d336069038e850ff662be979866c4944126d17a19bed738e3dc1/uv-0.12.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0af988f800778913d36f9bf6905eb21c917dccc403bb364ad739bfd383acf491", size = 17338955, upload-time = "2026-09-01T21:57:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/cc/29/649ff920dcd2373a819a4e0d9c1989ae770698e634c277e5a6fbbe19e9b4/uv-0.12.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:cce6e716df290b7f4a31f76ef39866e1dc006e947520a660459dea00b5d55234", size = 21598566, upload-time = "2026-09-01T21:57:09.223Z" }, + { url = "https://files.pythonhosted.org/packages/35/0d/0db7cfdd5bf11ce91c53558449f0e753d3cc578d9e94bd350c10b43f17ef/uv-0.12.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:00ff9f34c04055ebc3e1599ee459f5ae0d42c17cd6a1d8d029f0389e64a1419c", size = 21690729, upload-time = "2026-09-01T21:57:11.866Z" }, + { url = "https://files.pythonhosted.org/packages/69/bf/a63391a9d0f930f5ec23ff6a1daa9f7806a430fe41fb1e3bfb6cb10ef737/uv-0.12.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d4a1d4c067f9e2edf703e181a71db8fc9d7af5fcdbd0053103d0a1260e04ce6", size = 21716103, upload-time = "2026-09-01T21:57:14.407Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4c/4b1fea7c22abac3297fa7fc222f3703c33ffe45aa7553fa9d8bd2b0b70bb/uv-0.12.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cafa85338ce1d27b7caa4aadc83ab21f350e2cd2185462fbb10a232b6e93152e", size = 22340621, upload-time = "2026-09-01T21:57:17.109Z" }, + { url = "https://files.pythonhosted.org/packages/25/57/7ac6fd2b97ea9f608b65bb9b47d686e5d64f8142b190d3c4590d74cf8524/uv-0.12.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbdcb9db8b44d80e3fcebc4205604d52ea145404efb179958560bb2a0157743a", size = 23586367, upload-time = "2026-09-01T21:57:19.494Z" }, + { url = "https://files.pythonhosted.org/packages/ff/87/3ef6bf32db7af74e7c230ef6c06c387d9be2363dfa63eb524065031c010a/uv-0.12.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cfb512d0e284e9fc42c3bbe9b701f430083fca23dbb67a20daeea897fb793069", size = 23279627, upload-time = "2026-09-01T21:57:22.146Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/cd04809c7ad5149faac55160925bd67aab43f803f6c01be1c32aec7d24d9/uv-0.12.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5badfd805fd88bf99b4b4f044f6e8f762f1892cab27477f4427bb473e93dd049", size = 20056367, upload-time = "2026-09-01T21:57:24.49Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/59cddb48c47124428d329ac3ed4ce91ccd8ab757ebeed3d482115d8b82ef/uv-0.12.9-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c0f7e2b46e4c503f9497c0f41bba3570aa233685db814cd011225118f2d72b6a", size = 19383069, upload-time = "2026-09-01T21:57:27.019Z" }, + { url = "https://files.pythonhosted.org/packages/99/a6/22e4650cc9a2d018165fda5a53ca7addedc6d4f083ab7c833acc12fbe1e1/uv-0.12.9-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:445c572202243229043abaeab2fecb1b3691477c71c79feb07f638ce34febd39", size = 22392774, upload-time = "2026-09-01T21:57:29.323Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/68166b0acb7f8ff30316bf74c0e9840c7c6cbc86ee3e236382bb107d42db/uv-0.12.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ce4ad780d3cefe5a8f965f3ddf942c01e71aa37e36f4fc431e72e4ee8ce5f378", size = 22517532, upload-time = "2026-09-01T21:57:31.758Z" }, + { url = "https://files.pythonhosted.org/packages/28/ac/8801d60db574aa15fba53944e0ee2e298605bf9076a5389f5e56ed51206a/uv-0.12.9-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f639f8415f08918fb4ba2022bfd94d039e01c6c1eca28583125d439f7bd635fb", size = 21498480, upload-time = "2026-09-01T21:57:34.402Z" }, + { url = "https://files.pythonhosted.org/packages/ec/47/663bbc05f26c6c518c086da5df1e38fc5b2650fa6768cb36c6418d3f9559/uv-0.12.9-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:5f9325495fa0bf3e71e6b09f72e68b0d4a000067888b48a659536a8c7191a112", size = 22889848, upload-time = "2026-09-01T21:57:37.284Z" }, + { url = "https://files.pythonhosted.org/packages/58/ce/a4b1ae2a80264231a4dd0a070ffd0fe7f2bdf1d78e99d3c0c68ce315262b/uv-0.12.9-py3-none-win32.whl", hash = "sha256:f4ab95475562884fc8e72fbbf0c3da0e025d3bb25e7348544cd02b19bf77f3e1", size = 19858143, upload-time = "2026-09-01T21:57:39.571Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b2/55c7fdbdacb0b110f92804cceea449dfcd5be4354ae1e997aca700145a65/uv-0.12.9-py3-none-win_amd64.whl", hash = "sha256:871e884c3329f69c12bb7426edb9e727745c3bb2db6d9e0538ce0e1bdfb64916", size = 17991226, upload-time = "2026-09-01T21:57:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/85/67/cac985492192ff4a95a76b3854143062f1b3290aca3a404a812b7aadc9cf/uv-0.12.9-py3-none-win_arm64.whl", hash = "sha256:31fddf25d756a51fe33f7831ff697a122628dd3121c37c435c39c678428f937b", size = 19472343, upload-time = "2026-09-01T21:57:44.877Z" }, ] [[package]] From 962306c1d89917ffbebf2fe0c161aee6b97f7863 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:07:07 -0400 Subject: [PATCH 40/47] ci(deps): bump docker/setup-qemu-action (#350) Bumps the github-actions-minor-patch group with 1 update in the /.github/actions/setup-docker directory: [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action). Updates `docker/setup-qemu-action` from 4.2.0 to 4.3.0 - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/96fe6ef7f33517b61c61be40b68a1882f3264fb8...1f40c72289eff860ee54a304f1438e3cff362e0a) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-version: 4.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/actions/setup-docker/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-docker/action.yml b/.github/actions/setup-docker/action.yml index 66abffdc..c5ce0fa3 100644 --- a/.github/actions/setup-docker/action.yml +++ b/.github/actions/setup-docker/action.yml @@ -19,7 +19,7 @@ inputs: runs: using: "composite" steps: - - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 if: inputs.enable-qemu == 'true' - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 From 6864479928348b4d5966ca268d54b51a154e9d8b Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:24:44 -0400 Subject: [PATCH 41/47] Bump pinned @coana-tech/cli to 15.10.40 (#352) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 18 +++++++++--------- 6 files changed, 24 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba943fa8..b6eebb26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2.8.1 + +### Changed: bump pinned @coana-tech/cli to 15.10.40 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.39` to + `15.10.40`. See the + [reachability analysis changelog](https://docs.socket.dev/docs/reachability-analysis-changelog) + for engine changes. + ## 2.8.0 ### Changed: improve monorepo scan diagnostics and guidance diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e20c34b2..f64de267 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -338,7 +338,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.39 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.40 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index da39c80d..33d2d4df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.8.0" +version = "2.8.1" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index a97b4d18..6cf31cd7 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.8.0' +__version__ = '2.8.1' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index f43ce5c8..3290080f 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -19,7 +19,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.39" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.40" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -56,7 +56,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.39'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.40'). Args: version: Coana CLI version to use. @@ -65,7 +65,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.39'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.40'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index 115542a2..69bb3127 100644 --- a/uv.lock +++ b/uv.lock @@ -12,8 +12,8 @@ name = "anyio" version = "4.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "idna", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } wheels = [ @@ -476,7 +476,7 @@ name = "cryptography" version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "(python_full_version != '3.12.*' and platform_python_implementation != 'PyPy') or (platform_python_implementation != 'PyPy' and sys_platform != 'emscripten')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } wheels = [ @@ -644,8 +644,8 @@ name = "httpcore2" version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ @@ -725,7 +725,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "(python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -1252,8 +1252,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" }, + { name = "jeepney", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -1293,7 +1293,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.8.0" +version = "2.8.1" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 2dd3001d9a5456a6b0e18107e1641989e2b969ee Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:35:29 +0200 Subject: [PATCH 42/47] Bump pinned @coana-tech/cli to 15.10.41 (#354) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 2 +- 6 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6eebb26..9b0cf1a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2.8.2 + +### Changed: bump pinned @coana-tech/cli to 15.10.41 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.40` to + `15.10.41`. See the + [reachability analysis changelog](https://docs.socket.dev/docs/reachability-analysis-changelog) + for engine changes. + ## 2.8.1 ### Changed: bump pinned @coana-tech/cli to 15.10.40 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f64de267..2cc3999e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -338,7 +338,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.40 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.41 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index 33d2d4df..5f812f5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.8.1" +version = "2.8.2" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 6cf31cd7..29fd46e0 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.8.1' +__version__ = '2.8.2' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 3290080f..1c1bc43e 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -19,7 +19,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.40" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.41" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -56,7 +56,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.40'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.41'). Args: version: Coana CLI version to use. @@ -65,7 +65,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.40'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.41'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index 69bb3127..446a9241 100644 --- a/uv.lock +++ b/uv.lock @@ -1293,7 +1293,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.8.1" +version = "2.8.2" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 88408dcc702eb0a8d22e6e58f4c39af7668fecb1 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:32:05 -0400 Subject: [PATCH 43/47] Fix GitLab report links, identifiers and locations, and make diff baselines resilient (#337) * fix(gitlab): stabilize report data * chore: bump version to 2.8.1 * fix(gitlab): harden implicit diff baseline resolution The workspace-scoped head scan lookup treated any failed request as "no baseline". The SDK logs and returns {} for every non-200, so a transient API error resolved to None, and create_new_diff answers None by creating an empty baseline scan -- reporting every dependency in the repository as newly added. An absent "results" key now raises APIFailure, and resolve_base_full_scan_id surfaces it the same way a missing --base-commit-sha baseline is surfaced. Selecting the newest scan on the default branch also reintroduced temporary scans, which the repository head pointer had excluded. The empty baseline scan that create_new_diff creates inherits the branch and commit of the run that created it, so a default-branch run whose real scan fails leaves that empty scan as the newest one. Both baseline lookups now skip tmp scans. Also unwrap scan_type before it is URL encoded. FullScanParams types it as a ScanType enum, and urlencode renders a (str, Enum) member as its repr-style name, which would filter on a scan type that does not exist. Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): match snake_case vulnerability ids in report identifiers Issue.props reaches the GitLab formatter from several sources, and core.alert_selection already matches both ghsaId/ghsa_id and cveId/cve_id when deciding reachability. The identifier extractor only read the camelCase spellings, so an alert carrying ghsa_id was selected for the report but emitted with only its socket_alert identifier -- the CVE and GHSA values GitLab dedupes and links on were dropped. Values that are neither a string nor a sequence are now skipped rather than iterated, so a malformed prop cannot raise out of the whole report. Co-Authored-By: Claude Opus 5 (1M context) * test: use a generic package name in the namespace normalization fixture The fixture named a real organization. Public test data should not, so use the reserved com.example namespace instead. Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): use colon-separated Maven coordinates in package links Socket addresses Maven package pages as groupId:artifactId. The CLI emitted the slash-separated form, so every Maven package link 404'd -- the dashboard's Maven handler rejects the slash form outright with "Maven package must have a colon". Removing the enum leak from these URLs fixed how they looked without fixing where they pointed. The separator now follows the ecosystem, via Package.socket_url, which both the full-scan and diff construction paths call. Previously each built its URL inline and they disagreed on namespace handling, so the same package could produce different links depending on which path ran. Purl strings are deliberately left on the slash form for every ecosystem: that is what the purl spec defines and what Socket's purl API consumes. Only the dashboard URL is ecosystem-dependent. Co-Authored-By: Claude Opus 5 (1M context) * docs: tighten comments added by this branch The Maven separator rule was explained in three places and the enum-unwrapping rule in two. Each now has one home: the separator at URL_NAMESPACE_SEPARATORS where it is defined, the enum behavior at each helper that depends on it, stated once rather than narrated. Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): warn when a Maven coordinate has no namespace An ecosystem with its own URL separator cannot be addressed without both halves of the coordinate. A Maven artifact that arrives with no groupId still gets a link so the finding reports, but that link cannot resolve, and previously it was emitted silently. It now logs a warning naming the package. Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): separate namespace and name with a slash, not a colon Reverts the separator introduced two commits ago. It rested on a report that the slash form does not resolve, which has since failed to reproduce: every affected link in that report loads, and the report's own screenshots show a working slash-form link. The defect those links actually exhibit is a namespace and name fused with no separator at all, which yields one path segment that cannot be split back into two. A slash fixes that and matches what the other package construction path has always emitted. The missing-namespace warning is kept and re-aimed: an absent namespace is what produces the unsplittable single segment, so that is the case worth surfacing. Co-Authored-By: Claude Opus 5 (1M context) * chore: bump version to 2.8.2 Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): report a real manifest and real directness in report locations Two defects in the same location block. The manifest path fell back to "unknown" whenever a package had no introducing chain. That happens routinely for a transitive package whose top-level ancestors are absent from the scan's package set, which a diff-scoped run causes by construction. The package records its own manifest files regardless, so those are now used before giving up. Directness was inferred by looking for " > " in the introducing entry, but no producer emits that separator -- get_source_data yields either ("direct", files) or (ancestor_purl, files). Every finding was therefore reported as direct, including transitive ones. It now comes from the package record. The dependency chain was also parsed into a local that was never read, and the docstring advertised a dependency_path key the function never returned. Both are removed rather than wired up, since the GitLab schema expects dependency references rather than a name path. Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): omit an absent identifier url instead of sending null The GitLab dependency-scanning schema types an identifier's url as a string matching ^(https?|ftp)://, so a null fails validation. The socket_alert identifier emitted null whenever an alert carried no url, which invalidates that finding for every consumer that validates the report. Verified against the published schema: a report containing an alert with no url now produces zero validation errors. Co-Authored-By: Claude Opus 5 (1M context) * feat(gitlab): fall back to the nearest scanned ancestor for --base-commit-sha A merge base can have no full scan even when default-branch scanning is configured and running: squash merges and rebases rewrite commits, and a multi-commit push produces one scan for the tip while leaving the commits in between unscanned. Any of those turned every open merge request into a failed pipeline, because a missing baseline was a hard stop with no degraded mode. The requested commit is still preferred. When it has no scan, one listing of recent scans is matched against local first-parent history and the nearest scanned ancestor is used instead, logged at warning with the commit chosen and its distance. Only an unreachable ancestor now fails the run. Both bounds are fixed and neither costs an extra request: the listing is fetched once, and the walk stops at a set depth. Following first parents keeps a merge commit from contributing everything merged into it, and a shallow checkout simply narrows the search rather than breaking it. Co-Authored-By: Claude Opus 5 (1M context) * fix: harden diff baseline resolution --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 27 +++ README.md | 17 +- docs/ci-cd.md | 22 +- docs/cli-reference.md | 16 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/config.py | 6 +- socketsecurity/core/__init__.py | 319 +++++++++++++++++++++++--- socketsecurity/core/classes.py | 70 +++++- socketsecurity/core/messages.py | 104 +++++---- tests/core/test_package_and_alerts.py | 91 +++++++- tests/core/test_sdk_methods.py | 308 ++++++++++++++++++++++++- tests/unit/test_gitlab_format.py | 128 ++++++++++- uv.lock | 2 +- 14 files changed, 980 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0cf1a6..3b8bcdfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 2.8.3 + +### Fixed: GitLab report serialization and workspace baselines + +- Full-scan package identities and Socket links now preserve namespaced packages + when the SDK returns enum-backed ecosystem values. +- Namespaced package links separate the namespace from the name instead of + concatenating them, so Maven links no longer fuse groupId and artifactId into a + single unresolvable path segment. A namespaced package whose namespace is + missing now logs a warning rather than emitting a broken link silently. +- GitLab dependency-scanning reports emit CVE and GHSA identifiers from current + API fields while remaining compatible with legacy CVE data. +- GitLab report findings record the manifest they came from when the package's + introducing chain is unavailable, instead of reporting the location as + `unknown`, and report whether a dependency is direct from the package record + rather than inferring it from a dependency-path string that is never produced. +- `--base-commit-sha` degrades to the nearest scanned ancestor of the requested + commit instead of failing the run, and logs which commit was used and how far + back it is. Squash merges, rebases, and multi-commit pushes all leave a merge + base unscanned even when default-branch scanning is configured correctly. The + lookup follows paginated scan history and the run still fails when no scanned + ancestor is reachable or the exact-commit lookup itself fails. +- Implicit diff baselines are selected from the same workspace, scan type, + repository, and default branch, including when no workspace is supplied. A + baseline lookup that fails is reported as an API error instead of resolving to + an empty baseline, and temporary scans are skipped when selecting one. + ## 2.8.2 ### Changed: bump pinned @coana-tech/cli to 15.10.41 diff --git a/README.md b/README.md index 5f978d59..5221daf1 100644 --- a/README.md +++ b/README.md @@ -44,21 +44,22 @@ socketcli --enable-gitlab-security --gitlab-security-file gl-dependency-scanning ### PR scan diffed against the merge base -By default, PR scans are diffed against the repository's latest head scan. To diff against -the exact commit your PR branched from instead, pass the merge base as the baseline: +By default, PR scans are diffed against the repository's latest matching head scan. To +prefer the commit your PR branched from as the baseline, pass the merge base: ```bash BASE_SHA=$(git merge-base origin/main HEAD) socketcli --pr-number 123 --base-commit-sha "$BASE_SHA" ``` -> **Requirement:** `--base-commit-sha` only works if Socket already has a full scan for that -> exact commit. In practice this means your CI must run `socketcli` on **every commit that -> lands on your default branch** — not just some of them. If merges can land without a scan -> (skipped/canceled builds, `[skip ci]`, path-filtered pipelines), the PR scan will fail with -> exit code 3 rather than silently diff against the wrong baseline. See +> The CLI uses the exact commit's newest matching full scan when one exists. Otherwise, it +> searches up to 100 first-parent commits in the local checkout and uses the nearest scanned +> ancestor, with a warning that the diff is wider than the merge base. Run `socketcli` +> regularly on your default branch and ensure PR checkouts contain enough history for that +> walk. The run fails with the configured API-error exit code only when no scanned ancestor +> is reachable (or when the scan lookup itself fails). See > [`docs/cli-reference.md`](https://github.com/SocketDev/socket-python-cli/blob/main/docs/cli-reference.md) -> for the full requirements and a backfill pattern that makes PR jobs self-sufficient. +> for the full behavior and an optional exact-baseline backfill pattern. A specific full scan ID also works: `--base-scan-id `. diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 968799b5..2051a691 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -347,11 +347,14 @@ generator rather than a static YAML file: BASE_SHA=$(git merge-base "origin/${TARGET}" HEAD) ``` -- **Emit the backfill step conditionally from the generator.** The generator is the - natural place for the "does a baseline scan exist?" check +- **If an exact baseline is required, emit a backfill step conditionally from the + generator.** The generator is the natural place for the "does an exact baseline + scan exist?" check (`GET /orgs/{org}/full-scans?repo=&commit_hash=$BASE_SHA&per_page=1`): only emit the baseline-scan step when it returns nothing. The emitted pipeline then shows - in the UI whether a backfill will run. + in the UI whether a backfill will run. Without a backfill, the CLI automatically + uses the nearest scanned first-parent ancestor within 100 commits and warns that + the diff is wider. - **Keep the backfill inside one command step.** The checkout-base → scan → checkout-PR sequence must not be split across steps — steps can land on different @@ -360,17 +363,18 @@ generator rather than a static YAML file: checkout: `git worktree add /tmp/socket-base "$BASE_SHA"` then `socketcli --target-path /tmp/socket-base --branch "$TARGET" --disable-blocking`. -- **Soft-fail infra errors, not findings.** A missing baseline (or any API error) - exits with code 3 (`--exit-code-on-api-error` to change it); real findings exit 1. +- **Soft-fail infra errors, not findings.** No reachable scanned ancestor (or any API + error) exits with code 3 (`--exit-code-on-api-error` to change it); real findings exit 1. [`soft_fail: [{exit_status: 3}]`](https://buildkite.com/docs/pipelines/configure/step-types/command-step) on the PR scan step keeps infra errors from blocking merges while security findings still do. - **["Cancel intermediate builds"](https://buildkite.com/docs/pipelines/configure/canceling-builds#cancel-running-intermediate-builds) - on the default branch is the main source of baseline gaps.** Canceled builds never - scan their commit, so merge-base lookups for PRs based on those commits fail. The - conditional backfill step above is the remedy; there is no per-step exemption from - build cancellation in Buildkite. If you need strict scan-once semantics for + on the default branch is a common source of exact-baseline gaps.** Canceled builds + never scan their commit, so these PRs fall back to an older scanned ancestor. Use + the conditional backfill step above when an exact merge-base comparison is required; + there is no per-step exemption from build cancellation in Buildkite. If you need + strict scan-once semantics for concurrent backfills of the same merge base, serialize the backfill step with a [concurrency group](https://buildkite.com/docs/pipelines/configure/workflows/controlling-concurrency) keyed on the merge-base SHA. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2cc3999e..2ee42450 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -256,24 +256,22 @@ If you don't want to provide the Socket API Token every time then you can use th | `--commit-message` | False | *auto* | Commit message (auto-detected from git) | | `--commit-sha` | False | *auto* | Commit SHA (auto-detected from git) | | `--base-scan-id` | False | | Full scan ID to diff against, overriding the repository's head scan as the baseline. Mutually exclusive with `--base-commit-sha` | -| `--base-commit-sha`| False | | Commit SHA to diff against, overriding the repository's head scan as the baseline. The most recent full scan for that commit is used; the CLI errors (exit code 3, or `--exit-code-on-api-error`) if no scan exists for it. Mutually exclusive with `--base-scan-id` | +| `--base-commit-sha`| False | | Commit SHA to prefer as the diff baseline, overriding the repository's head scan. The CLI uses its most recent matching full scan or the nearest scanned first-parent ancestor within 100 local commits. It errors (exit code 3, or `--exit-code-on-api-error`) if no scanned ancestor is reachable. Mutually exclusive with `--base-scan-id` | -> **Diffing against the merge base** — by default, PR scans are diffed against the repository's *latest* head scan, which may include newer default-branch commits than your PR branched from. To diff against the exact commit your PR is based on, compute the merge base and pass it as the baseline: +> **Diffing against the merge base** — by default, PR scans are diffed against the repository's latest matching head scan, which may include newer default-branch commits than your PR branched from. To prefer the commit your PR is based on, compute the merge base and pass it as the baseline: > > ```shell > BASE_SHA=$(git merge-base origin/main HEAD) > socketcli --pr-number 123 --base-commit-sha "$BASE_SHA" > ``` > -> **Requirement: a full scan must already exist for the merge-base commit.** `--base-commit-sha` does not create a scan of that commit; it looks up an existing one. That lookup only succeeds if your CI runs `socketcli` on **every commit that lands on your default branch** — every merge and direct push, not just periodic or latest-only scans. Common ways commits slip through without a scan: +> `--base-commit-sha` does not create a scan of that commit. The CLI first looks for the newest non-temporary scan matching the repository, workspace, scan type, and exact commit. If the exact commit was not scanned, it walks up to 100 first-parent commits from that SHA in the local checkout and uses the nearest matching scanned ancestor. It logs a warning with the selected commit and distance because this produces a wider diff than the merge base. > -> - CI settings that cancel or skip intermediate builds when newer commits land (e.g. Buildkite's ["cancel intermediate builds"](https://buildkite.com/docs/pipelines/configure/canceling-builds#cancel-running-intermediate-builds)) -> - `[skip ci]` commits, path-filtered pipelines, or failed/canceled scan steps -> - merge-base commits that predate your Socket rollout +> Run `socketcli` regularly on the default branch so recent ancestors have scans. PR checkouts must also retain the merge base and enough first-parent history; shallow clones can shorten the search. Gaps are expected when CI cancels intermediate builds, commits use `[skip ci]`, pipelines are path-filtered, or the merge base predates your Socket rollout. > -> If no scan exists for the commit, the CLI **fails** (exit code 3, or your `--exit-code-on-api-error` value; exit 0 with `--disable-blocking`) instead of silently falling back to the head scan — a wrong baseline would misreport which alerts the PR introduces. Don't adopt this flag without default-branch scan coverage in place; you'll fail PR builds on lookup misses. +> If no scanned ancestor is reachable within the local 100-commit walk, the CLI **fails** (exit code 3, or your `--exit-code-on-api-error` value; exit 0 with `--disable-blocking`) instead of silently falling back to the repository head. API or permission failures also fail rather than being treated as a missing exact scan. > -> **Backfill pattern** — if your default-branch coverage has gaps, the PR job can create the missing baseline itself before scanning: +> **Optional exact-baseline backfill** — if the wider ancestor fallback is not acceptable, the PR job can create the missing exact baseline before scanning: > > ```shell > BASE_SHA=$(git merge-base origin/main HEAD) @@ -285,7 +283,7 @@ If you don't want to provide the Socket API Token every time then you can use th > socketcli --pr-number 123 --base-commit-sha "$BASE_SHA" > ``` > -> Run the baseline step with `--disable-blocking` (findings on the default branch must not fail the PR job) and an explicit `--branch`, since branch auto-detection is unreliable at a detached HEAD. +> Run the baseline step with `--disable-blocking` (findings on the default branch must not fail the PR job) and an explicit `--branch`, since branch auto-detection is unreliable at a detached HEAD. Without this step, the CLI automatically uses the nearest scanned ancestor. > > Buildkite users with dynamically generated pipelines: see [Merge-base baselines in Buildkite](ci-cd.md#merge-base-baselines-in-buildkite-dynamic-pipelines) for generation-time vs. step-time guidance. diff --git a/pyproject.toml b/pyproject.toml index 5f812f5f..1c30bc61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.8.2" +version = "2.8.3" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 29fd46e0..c5cb4808 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.8.2' +__version__ = '2.8.3' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 35904976..ecd2f287 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -587,9 +587,9 @@ def create_argument_parser() -> argparse.ArgumentParser: metavar="", default=None, help="Commit SHA to diff the new scan against, overriding the repository's head " - "scan as the baseline. The most recent full scan matching this commit (e.g. " - "the merge base from 'git merge-base origin/main HEAD') is used; the CLI " - "errors if no scan exists for it. Mutually exclusive with --base-scan-id." + "scan as the baseline. The CLI uses the most recent matching full scan, or " + "the nearest scanned first-parent ancestor within 100 local commits when " + "the commit itself was not scanned. Mutually exclusive with --base-scan-id." ) # Path and File options diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index b1b1d65b..c5f3f52c 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -14,10 +14,11 @@ import time from dataclasses import asdict from pathlib import PurePath -from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Set, Tuple +from typing import TYPE_CHECKING, Dict, Iterator, List, NamedTuple, Optional, Set, Tuple if TYPE_CHECKING: from socketsecurity.config import CliConfig +from git import Repo from socketdev import socketdev from socketdev.exceptions import APIFailure from socketdev.fullscans import DiffArtifacts, FullScanParams, SocketArtifact @@ -51,6 +52,17 @@ _HUMANIZE_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])") +# How many full scans to request when resolving a diff baseline. The newest scan is +# usually the one we want, but temporary scans have to be skipped (see +# Core.newest_persisted_scan_id), so a single result is not enough. +SCAN_LOOKUP_PAGE_SIZE = 10 + +# Bounds on the search for a scanned ancestor when the requested baseline commit has +# no full scan of its own. Full-scan listing pages are matched against bounded local +# history, so the local walk cannot grow without limit. +ANCESTOR_SCAN_LOOKUP_LIMIT = 100 +ANCESTOR_WALK_MAX_DEPTH = 100 + # Reachability facts-file upload compression. # # The Socket full-scan endpoint transparently brotli-decompresses any multipart part @@ -1463,19 +1475,229 @@ def get_repo_info(self, repo_slug: str, default_branch: str = "socket-default-br return response.data - def get_head_scan_for_repo(self, repo_slug: str) -> str: + def get_head_scan_for_repo( + self, + repo_slug: str, + workspace: Optional[str] = None, + scan_type: Optional[str] = None, + ) -> Optional[str]: """ Gets the head scan ID for a repository. + Without a workspace or scan type this is the repository's head scan pointer. + That pointer tracks a single scan for the whole repository rather than one per + workspace or scan type, so scoped runs instead take the newest matching scan + on the default branch. + Args: repo_slug: Repository slug to get head scan for + workspace: Socket workspace the scan belongs to, if any + scan_type: Socket scan type to match, if any Returns: Head scan ID if it exists, None otherwise + + Raises: + APIFailure: If the scoped scan lookup fails. A failed lookup must not + be reported as "no baseline": the caller answers that by creating an + empty baseline scan, which reports every dependency in the repository + as newly added. """ repo_info = self.get_repo_info(repo_slug) + if workspace or scan_type: + query_params = { + "repo": repo_slug, + "branch": repo_info.default_branch, + "sort": "created_at", + "direction": "desc", + "per_page": SCAN_LOOKUP_PAGE_SIZE, + } + if workspace: + query_params["workspace"] = workspace + if scan_type: + query_params["scan_type"] = Core.query_param_value(scan_type) + for results in self._full_scan_result_pages( + query_params, + f"Failed to list matching full scans for repo {repo_slug}", + ): + scan_id = Core.newest_persisted_scan_id(results) + if scan_id: + return scan_id + return None return repo_info.head_full_scan_id if repo_info.head_full_scan_id else None + @staticmethod + def query_param_value(value): + """ + Unwraps an enum member so it survives URL encoding. + + The SDK types several query params as str-backed enums (ScanType, + IntegrationType). urlencode calls str(), which renders a (str, Enum) member + as "ScanType.SOCKET_TIER1" -- a filter value the API does not recognize. + """ + return getattr(value, "value", value) + + @staticmethod + def newest_persisted_scan_id(results: List[dict]) -> Optional[str]: + """ + Returns the newest scan ID from a full scan listing, skipping temporary scans. + + create_new_diff creates an empty ``tmp`` scan when a repository has no baseline + yet, and that scan inherits the branch and commit of the run that created it. + If the real scan then fails, the empty scan is left behind as the newest scan + for that branch/commit; selecting it as a baseline would report every + dependency as newly added. + + Args: + results: Full scan listing results, newest first + + Returns: + Newest non-temporary scan ID, or None if the listing has none + """ + for result in results or []: + if not isinstance(result, dict) or result.get("tmp"): + continue + scan_id = result.get("id") + if scan_id: + return scan_id + return None + + def _full_scan_result_pages( + self, + query_params: dict, + failure_message: str, + ) -> Iterator[List[dict]]: + """Yields successful full-scan listing pages and rejects failed lookups.""" + request_params = dict(query_params) + seen_pages = {str(request_params.get("page", 1))} + per_page = int(request_params.get("per_page", 30)) + + while True: + response = self.sdk.fullscans.get(self.config.org_slug, request_params) + results = response.get("results") if isinstance(response, dict) else None + if results is None: + # The SDK logs and returns {} for any non-200, so a present results + # key is the only signal that the request itself succeeded. + raise APIFailure(failure_message) + yield results + + next_page = response.get("nextPage") + # The API has historically returned nextPage=1 for a short final page. + if len(results) < per_page or next_page in (None, 0, "0", False, ""): + return + + page_key = str(next_page) + if page_key in seen_pages: + raise APIFailure( + f"{failure_message}: full-scan pagination repeated page {next_page}" + ) + seen_pages.add(page_key) + request_params = {**query_params, "page": next_page} + + def first_parent_commits(self, start_commit_sha: str, max_count: int) -> List[str]: + """ + Lists a commit and its first-parent ancestors, newest first. + + Follows only first parents so a merge commit contributes the branch's own + history rather than everything merged into it. A shallow checkout simply + yields fewer commits, which narrows the search rather than failing it. + + Args: + start_commit_sha: Commit to walk back from, included in the result + max_count: Maximum number of commits to return + + Returns: + Commit SHAs, newest first. Empty when the repository or commit is + unavailable locally. + """ + target_path = self.cli_config.target_path if self.cli_config else None + if not target_path: + return [] + try: + repo = Repo(target_path) + output = repo.git.rev_list( + "--first-parent", + f"--max-count={max_count}", + start_commit_sha, + ) + except Exception as error: + log.debug(f"Unable to walk history back from {start_commit_sha}: {error}") + return [] + return [line.strip() for line in output.splitlines() if line.strip()] + + def find_baseline_scan_for_ancestor( + self, + repo_slug: str, + commit_sha: str, + workspace: Optional[str] = None, + scan_type: Optional[str] = None, + ) -> Tuple[Optional[str], Optional[str], int]: + """ + Finds the nearest ancestor of a commit that does have a full scan. + + Used when --base-commit-sha names a commit that was never scanned. Squash + merges and rebases rewrite commits, and a multi-commit push produces one scan + for the tip, so a merge base can be unscanned even when default-branch + scanning is configured correctly. Diffing against a slightly older ancestor + is a wider diff; failing outright is no diff at all. + + Scan listing pages are matched against local first-parent history. All pages + are considered because scans from other branches and reruns can fill newer + pages without covering the nearest candidate ancestors. + + Args: + repo_slug: Repository slug the scan belongs to + commit_sha: Commit that has no full scan of its own + workspace: Socket workspace the scan belongs to, if any + scan_type: Socket scan type to match, if any + + Returns: + (scan_id, ancestor_commit_sha, commits_back), or (None, None, 0) when no + scanned ancestor is reachable. + """ + ancestors = self.first_parent_commits(commit_sha, ANCESTOR_WALK_MAX_DEPTH) + if not ancestors: + return None, None, 0 + + query_params = { + "repo": repo_slug, + "sort": "created_at", + "direction": "desc", + "per_page": ANCESTOR_SCAN_LOOKUP_LIMIT, + } + if workspace: + query_params["workspace"] = workspace + if scan_type: + query_params["scan_type"] = Core.query_param_value(scan_type) + + scans_by_commit = {} + ancestor_set = set(ancestors) + for results in self._full_scan_result_pages( + query_params, + f"Failed to list ancestor full scans for repo {repo_slug}", + ): + for result in results: + if not isinstance(result, dict) or result.get("tmp"): + continue + result_commit = result.get("commit_hash") + scan_id = result.get("id") + # Newest first, so the first scan seen for a commit is the one to keep. + if ( + result_commit in ancestor_set + and scan_id + and result_commit not in scans_by_commit + ): + scans_by_commit[result_commit] = scan_id + + if not scans_by_commit: + return None, None, 0 + + for distance, ancestor in enumerate(ancestors): + scan_id = scans_by_commit.get(ancestor) + if scan_id: + return scan_id, ancestor, distance + return None, None, 0 + def get_full_scan_id_by_commit( self, repo_slug: str, @@ -1504,31 +1726,32 @@ def get_full_scan_id_by_commit( "commit_hash": commit_sha, "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, } if workspace: query_params["workspace"] = workspace if scan_type: - query_params["scan_type"] = scan_type + query_params["scan_type"] = Core.query_param_value(scan_type) - response = self.sdk.fullscans.get( - self.config.org_slug, - query_params, - ) - results = response.get("results") if isinstance(response, dict) else None - if not results: - return None - return results[0].get("id") + for results in self._full_scan_result_pages( + query_params, + f"Failed to list full scans for commit {commit_sha} in repo {repo_slug}", + ): + scan_id = Core.newest_persisted_scan_id(results) + if scan_id: + return scan_id + return None def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: """ Resolves the baseline full scan ID to diff a new scan against. Priority: --base-scan-id (used verbatim), then --base-commit-sha (newest - full scan for that commit), then the repository's current head scan. A - --base-commit-sha with no matching full scan is a hard error rather than a - silent fallback to the head scan, because diffing against the wrong - baseline silently misreports which alerts a PR introduces. + full scan for that commit, or its nearest scanned first-parent ancestor), + then the repository's current matching head scan. A --base-commit-sha with + no reachable scanned ancestor is a hard error rather than a silent fallback + to the head scan, because diffing against the wrong baseline silently + misreports which alerts a PR introduces. Returns: Full scan ID to use as the diff baseline, or None when the repository @@ -1549,26 +1772,61 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: workspace=params.workspace, scan_type=params.scan_type, ) + baseline_source = "explicit-commit" + baseline_commit = commit_sha if scan_id is None: - log.error( - f"No full scan found for commit {commit_sha} in repo {params.repo} " - "(--base-commit-sha). Ensure a scan was created for that commit " - "(e.g. the CLI runs on default-branch pushes), or pass " - "--base-scan-id instead." + scan_id, ancestor_sha, commits_back = self.find_baseline_scan_for_ancestor( + params.repo, + commit_sha, + workspace=params.workspace, + scan_type=params.scan_type, ) - if self.cli_config.disable_blocking: - sys.exit(0) - sys.exit(self.cli_config.exit_code_on_api_error) + if scan_id: + baseline_source = "explicit-commit-ancestor" + baseline_commit = ancestor_sha + log.warning( + f"No full scan for commit {commit_sha} (--base-commit-sha). " + f"Diffing against its nearest scanned ancestor {ancestor_sha}, " + f"{commits_back} commit(s) earlier, so the diff is wider than " + "the merge base." + ) + else: + log.error( + f"No full scan found for commit {commit_sha} in repo {params.repo} " + "(--base-commit-sha), and no scanned ancestor within " + f"{ANCESTOR_WALK_MAX_DEPTH} commits of it. Ensure a scan was " + "created for that commit (e.g. the CLI runs on default-branch " + "pushes), or pass --base-scan-id instead." + ) + if self.cli_config.disable_blocking: + sys.exit(0) + sys.exit(self.cli_config.exit_code_on_api_error) log.info( - "Baseline selected: source=explicit-commit " - f"scan_id={json.dumps(scan_id)} commit={json.dumps(commit_sha)}" + f"Baseline selected: source={baseline_source} " + f"scan_id={json.dumps(scan_id)} commit={json.dumps(baseline_commit)}" ) return scan_id try: - scan_id = self.get_head_scan_for_repo(params.repo) + scan_id = self.get_head_scan_for_repo( + params.repo, + workspace=params.workspace, + scan_type=params.scan_type, + ) except APIResourceNotFound: return None + except APIFailure as error: + # Returning None would make the caller create an empty baseline scan, + # reporting every dependency as newly added, so fail loudly like the + # --base-commit-sha path above. + log.error( + f"Failed to resolve the matching head scan for repo {params.repo}: {error}" + ) + if self.cli_config is None: + raise + if self.cli_config.disable_blocking: + sys.exit(0) + sys.exit(self.cli_config.exit_code_on_api_error) if scan_id: log.info( "Baseline selected: source=repository-head " @@ -1578,12 +1836,11 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: @staticmethod def update_package_values(pkg: Package) -> Package: + pkg.type = Package.normalize_type(pkg.type) pkg.purl = f"{pkg.name}@{pkg.version}" - pkg.url = f"https://socket.dev/{pkg.type}/package" if pkg.namespace: pkg.purl = f"{pkg.namespace}/{pkg.purl}" - pkg.url += f"/{pkg.namespace}" - pkg.url += f"/{pkg.name}/overview/{pkg.version}" + pkg.url = Package.socket_url(pkg.type, pkg.namespace, pkg.name, pkg.version) return pkg def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: int = 5000) -> dict: @@ -2407,6 +2664,8 @@ def add_package_alerts_to_collection(self, package: Package, alerts_collection: suggestion=props.suggestion, next_step_title=props.nextStepTitle, introduced_by=introduced_by, + manifest_files=package.manifestFiles or [], + direct=bool(package.direct), purl=package.purl, url=package.url ) diff --git a/socketsecurity/core/classes.py b/socketsecurity/core/classes.py index db145221..2a19c2bb 100644 --- a/socketsecurity/core/classes.py +++ b/socketsecurity/core/classes.py @@ -1,4 +1,5 @@ import json +import logging from dataclasses import dataclass, field from typing import Dict, List, Optional, TypedDict @@ -11,6 +12,13 @@ SocketScore, ) +log = logging.getLogger("socketdev") + +# Ecosystems whose package pages cannot be addressed by name alone. A Maven +# coordinate is a groupId plus an artifactId; with no namespace the URL collapses to +# one path segment that cannot be split back into two, and the page does not resolve. +NAMESPACE_REQUIRED_TYPES = frozenset({"maven"}) + __all__ = [ "Report", "Score", @@ -142,6 +150,43 @@ class Package(): licenseAttrib: Optional[List] = None + @staticmethod + def normalize_type(package_type) -> str: + """ + Unwraps the SDK's str-backed SocketPURL_Type enum to its value. + + str(SocketPURL_Type.MAVEN) is "SocketPURL_Type.MAVEN", not "maven", so any + enum member reaching an f-string leaks the class name into user-facing output. + """ + return getattr(package_type, "value", package_type) + + @staticmethod + def socket_url(package_type, namespace: Optional[str], name: str, version: str) -> str: + """ + Builds the socket.dev package overview URL for a package. + + Namespace and name are separate path segments, the same form purl strings use. + + Args: + package_type: Ecosystem, as a string or SocketPURL_Type member + namespace: Package namespace (Maven groupId, npm scope), if any + name: Package name + version: Package version + + Returns: + Package overview URL on socket.dev + """ + package_type = Package.normalize_type(package_type) + namespace = (namespace or "").strip("/") + if not namespace and package_type in NAMESPACE_REQUIRED_TYPES: + # The link is still emitted so the finding reports, but it cannot resolve. + log.warning( + f"{package_type} package {name}@{version} has no namespace, so its " + "Socket link collapses to a single path segment and will not resolve" + ) + package_path = "/".join(part for part in (namespace, name) if part) + return f"https://socket.dev/{package_type}/package/{package_path}/overview/{version}" + @classmethod def from_socket_artifact(cls, data: dict) -> "Package": """ @@ -153,18 +198,16 @@ def from_socket_artifact(cls, data: dict) -> "Package": Returns: New Package instance """ - purl = f"{data['type']}/" - namespace = data.get("namespace") - if namespace: - purl += f"{namespace}@" - purl += f"{data['name']}@{data['version']}" - base_url = "https://socket.dev" - url = f"{base_url}/{data['type']}/package/{namespace or ''}{data['name']}/overview/{data['version']}" + package_type = Package.normalize_type(data["type"]) + namespace = (data.get("namespace") or "").strip("/") + package_path = "/".join(part for part in (namespace, data["name"]) if part) + purl = f"{package_type}/{package_path}@{data['version']}" + url = Package.socket_url(package_type, namespace, data["name"], data["version"]) return cls( id=data["id"], name=data["name"], version=data["version"], - type=data["type"], + type=package_type, release=data.get("release"), diffType=data.get("diffType"), score=data["score"], @@ -179,7 +222,7 @@ def from_socket_artifact(cls, data: dict) -> "Package": artifact=data.get("artifact"), purl=purl, url=url, - namespace=namespace + namespace=namespace or None ) @classmethod @@ -274,6 +317,11 @@ class Issue: manifests: str url: str purl: str + # The package's own manifest files, independent of how it was introduced. A + # transitive package whose ancestors are absent from the scan has no + # introduced_by chain, but its manifest is still known. + manifest_files: list + direct: bool def __init__(self, **kwargs): if kwargs: @@ -282,6 +330,10 @@ def __init__(self, **kwargs): if hasattr(self, "created_at"): self.created_at = self.created_at.strip(" (Coordinated Universal Time)") + if not hasattr(self, "manifest_files"): + self.manifest_files = [] + if not hasattr(self, "direct"): + self.direct = False if not hasattr(self, "manifests"): self.manifests = "" if not hasattr(self, "suggestion"): diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index d968c14b..14a6f829 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -648,32 +648,48 @@ def extract_identifiers_gitlab(alert: Issue) -> list: """ identifiers = [] - # Primary identifier: Socket alert type - identifiers.append({ + # Primary identifier: Socket alert type. The GitLab schema types identifier + # url as a string matching ^(https?|ftp)://, so an absent url is omitted + # rather than sent as null, which fails validation for the whole finding. + socket_identifier = { "type": "socket_alert", "name": f"Socket {alert.type}", "value": alert.type, - "url": alert.url if hasattr(alert, 'url') and alert.url else None - }) - - # Extract CVE identifiers from props - if hasattr(alert, 'props') and alert.props: - if 'cve' in alert.props: - cves = alert.props['cve'] - if isinstance(cves, list): - for cve in cves: - identifiers.append({ - "type": "cve", - "name": cve, - "value": cve, - "url": f"https://cve.mitre.org/cgi-bin/cvename.cgi?name={cve}" - }) - elif isinstance(cves, str): + } + alert_url = getattr(alert, "url", None) + if alert_url: + socket_identifier["url"] = alert_url + identifiers.append(socket_identifier) + + props = getattr(alert, "props", None) or {} + # Alerts reach Issue.props from several sources, so both spellings of each + # field are in play; core.alert_selection matches on the same pair. "cve" is + # the older spelling and still appears in some payloads. + identifier_fields = ( + (("cveId", "cve_id", "cve"), "cve", "https://nvd.nist.gov/vuln/detail/"), + (("ghsaId", "ghsa_id"), "ghsa", "https://github.com/advisories/"), + ) + seen = set() + for fields, identifier_type, url_prefix in identifier_fields: + for field in fields: + values = props.get(field) + if isinstance(values, str): + values = [values] + elif not isinstance(values, (list, tuple)): + continue + for value in values: + if not isinstance(value, str) or not value.strip(): + continue + value = value.strip() + identifier_key = (identifier_type, value.upper()) + if identifier_key in seen: + continue + seen.add(identifier_key) identifiers.append({ - "type": "cve", - "name": cves, - "value": cves, - "url": f"https://cve.mitre.org/cgi-bin/cvename.cgi?name={cves}" + "type": identifier_type, + "name": value, + "value": value, + "url": f"{url_prefix}{value}" }) return identifiers @@ -686,37 +702,35 @@ def extract_location_gitlab(alert: Issue) -> dict: GitLab location requires: - file: path to manifest file - dependency: package name and version - - dependency_path (optional): dependency chain """ - # Get manifest file from introduced_by or manifests attribute - manifest_file = "unknown" - dependency_path = [] - is_direct = True - - if hasattr(alert, 'introduced_by') and alert.introduced_by: - if isinstance(alert.introduced_by, list) and len(alert.introduced_by) > 0: - first_entry = alert.introduced_by[0] - if isinstance(first_entry, (list, tuple)) and len(first_entry) >= 2: - dependency_path_str = first_entry[0] - manifest_file = first_entry[1].split(';')[0] if ';' in first_entry[1] else first_entry[1] - - # Parse dependency path - if ' > ' in dependency_path_str: - dependency_path = dependency_path_str.split(' > ') - # If there's a chain, it's transitive (not direct) - is_direct = len(dependency_path) <= 1 - - elif hasattr(alert, 'manifests') and alert.manifests: - manifest_file = alert.manifests.split(';')[0] + manifest_file = "" + + introduced_by = getattr(alert, "introduced_by", None) + if isinstance(introduced_by, list) and introduced_by: + first_entry = introduced_by[0] + if isinstance(first_entry, (list, tuple)) and len(first_entry) >= 2: + manifest_file = (first_entry[1] or "").split(";")[0] + + if not manifest_file: + manifest_file = (getattr(alert, "manifests", "") or "").split(";")[0] + + if not manifest_file: + # A transitive package whose ancestors are not in this scan has no + # introduced_by chain, but the package still records its own manifest. + for entry in getattr(alert, "manifest_files", None) or []: + candidate = entry.get("file") if isinstance(entry, dict) else None + if candidate: + manifest_file = candidate + break location = { - "file": manifest_file, + "file": manifest_file or "unknown", "dependency": { "package": { "name": alert.pkg_name }, "version": alert.pkg_version, - "direct": is_direct + "direct": bool(getattr(alert, "direct", False)) } } diff --git a/tests/core/test_package_and_alerts.py b/tests/core/test_package_and_alerts.py index 171eae77..0e096621 100644 --- a/tests/core/test_package_and_alerts.py +++ b/tests/core/test_package_and_alerts.py @@ -1,8 +1,9 @@ -from dataclasses import dataclass +from dataclasses import asdict, dataclass from unittest.mock import Mock import pytest from socketdev import socketdev +from socketdev.fullscans import SocketArtifact from socketsecurity.core import Core, _humanize_alert_type from socketsecurity.core.classes import Issue, Package @@ -104,6 +105,93 @@ def test_create_packages_dict_basic(self, core): assert pkg.version == "1.0.0" assert pkg.transitives == 0 + def test_full_scan_package_normalizes_enum_type_and_namespace_url(self): + artifact = SocketArtifact.from_dict({ + "id": "pkg:maven/com.example/example-core@1.2.3", + "type": "maven", + "namespace": "com.example", + "name": "example-core", + "version": "1.2.3", + "direct": True, + "topLevelAncestors": [], + "manifestFiles": [{"file": "pom.xml"}], + "alerts": [], + }) + + package = Package.from_socket_artifact(asdict(artifact)) + + assert package.type == "maven" + assert package.purl == "maven/com.example/example-core@1.2.3" + assert package.url == ( + "https://socket.dev/maven/package/com.example/example-core/overview/1.2.3" + ) + + def test_maven_package_url_separates_group_and_artifact(self): + """groupId and artifactId are distinct path segments, not one fused string""" + artifact = SocketArtifact.from_dict({ + "id": "pkg:maven/org.apache.logging.log4j/log4j-api@2.17.2", + "type": "maven", + "namespace": "org.apache.logging.log4j", + "name": "log4j-api", + "version": "2.17.2", + "direct": True, + "topLevelAncestors": [], + "manifestFiles": [{"file": "pom.xml"}], + "alerts": [], + }) + + package = Package.from_socket_artifact(asdict(artifact)) + + assert package.url == ( + "https://socket.dev/maven/package/org.apache.logging.log4j/log4j-api" + "/overview/2.17.2" + ) + # The purl keeps the "/" form, which is what the purl spec and the purl API want. + assert package.purl == "maven/org.apache.logging.log4j/log4j-api@2.17.2" + + def test_non_maven_package_url_keeps_slash_separator(self): + """npm scopes and Go module paths stay slash-delimited""" + scoped_npm = Package.socket_url("npm", "@babel", "core", "7.0.0") + assert scoped_npm == "https://socket.dev/npm/package/@babel/core/overview/7.0.0" + + unscoped = Package.socket_url("nuget", None, "newtonsoft.json", "6.0.8") + assert unscoped == "https://socket.dev/nuget/package/newtonsoft.json/overview/6.0.8" + + def test_maven_package_without_namespace_warns(self, caplog): + """A Maven coordinate missing its groupId cannot produce a resolvable link""" + with caplog.at_level("WARNING", logger="socketdev"): + url = Package.socket_url("maven", None, "orphan-artifact", "1.0.0") + + assert url == "https://socket.dev/maven/package/orphan-artifact/overview/1.0.0" + assert "orphan-artifact@1.0.0" in caplog.text + assert "no namespace" in caplog.text + + def test_namespaced_maven_package_does_not_warn(self, caplog): + """The warning is for missing data, not for every Maven package""" + with caplog.at_level("WARNING", logger="socketdev"): + Package.socket_url("maven", "com.example", "artifact", "1.0.0") + + assert caplog.text == "" + + def test_diff_path_builds_the_same_maven_url_as_the_full_scan_path(self): + """Both package construction paths must agree, or links break on only some runs""" + package = Package( + id="pkg:maven/com.google.code.gson/gson@2.8.6", + type="maven", + name="gson", + version="2.8.6", + namespace="com.google.code.gson", + score={}, + alerts=[], + topLevelAncestors=[], + ) + + package = Core.update_package_values(package) + + assert package.url == ( + "https://socket.dev/maven/package/com.google.code.gson/gson/overview/2.8.6" + ) + def test_create_packages_dict_with_transitives(self, core): """Test package dictionary creation with transitive dependencies""" mock_artifacts = [ @@ -340,4 +428,3 @@ def test_empty_input_returns_empty_string(self): def test_handles_acronyms_conservatively(self): """Adjacent capitals are kept together: SQLInjection -> 'SQL Injection'.""" assert _humanize_alert_type("SQLInjection") == "SQL Injection" - diff --git a/tests/core/test_sdk_methods.py b/tests/core/test_sdk_methods.py index d79f62f3..bf439a3b 100644 --- a/tests/core/test_sdk_methods.py +++ b/tests/core/test_sdk_methods.py @@ -1,9 +1,9 @@ import pytest from socketdev.exceptions import APIFailure -from socketdev.fullscans import FullScanParams, FullScanStreamResponse +from socketdev.fullscans import FullScanParams, FullScanStreamResponse, ScanType from socketsecurity.config import CliConfig -from socketsecurity.core import Core +from socketsecurity.core import ANCESTOR_SCAN_LOOKUP_LIMIT, SCAN_LOOKUP_PAGE_SIZE, Core from socketsecurity.core.socket_config import SocketConfig @@ -63,6 +63,106 @@ def test_get_head_scan_for_repo_no_head(core, mock_sdk_with_responses): head_scan_id = core.get_head_scan_for_repo("no-head") assert head_scan_id is None + +def test_get_head_scan_for_repo_scopes_workspace_to_default_branch( + core, mock_sdk_with_responses +): + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [{"id": "workspace-head"}], + "nextPage": None, + } + + head_scan_id = core.get_head_scan_for_repo( + "test", + workspace="customer-a", + scan_type="socket_tier1", + ) + + assert head_scan_id == "workspace-head" + mock_sdk_with_responses.fullscans.get.assert_called_once_with( + core.config.org_slug, + { + "repo": "test", + "workspace": "customer-a", + "branch": "main", + "sort": "created_at", + "direction": "desc", + "per_page": SCAN_LOOKUP_PAGE_SIZE, + "scan_type": "socket_tier1", + }, + ) + + +def test_get_head_scan_for_repo_scopes_scan_type_without_workspace( + core, mock_sdk_with_responses +): + """Reachability and standard scans must not share one unscoped head pointer""" + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [{"id": "standard-head"}], + "nextPage": None, + } + + head_scan_id = core.get_head_scan_for_repo("test", scan_type="socket") + + assert head_scan_id == "standard-head" + mock_sdk_with_responses.fullscans.get.assert_called_once_with( + core.config.org_slug, + { + "repo": "test", + "branch": "main", + "sort": "created_at", + "direction": "desc", + "per_page": SCAN_LOOKUP_PAGE_SIZE, + "scan_type": "socket", + }, + ) + + +def test_get_head_scan_for_repo_workspace_lookup_failure_raises(core, mock_sdk_with_responses): + """A failed listing is not the same as an empty one and must not resolve to None""" + mock_sdk_with_responses.fullscans.get.return_value = {} + + with pytest.raises(APIFailure): + core.get_head_scan_for_repo("test", workspace="customer-a") + + +def test_get_head_scan_for_repo_workspace_no_scans_yet(core, mock_sdk_with_responses): + """An empty listing is a real answer: the workspace has no baseline yet""" + mock_sdk_with_responses.fullscans.get.return_value = {"results": [], "nextPage": None} + + assert core.get_head_scan_for_repo("test", workspace="customer-a") is None + + +def test_get_head_scan_for_repo_skips_temporary_scans(core, mock_sdk_with_responses): + """A leftover empty tmp scan must not be picked up as the baseline""" + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [ + {"id": "leftover-tmp-scan", "tmp": True}, + {"id": "workspace-head", "tmp": False}, + ], + "nextPage": None, + } + + assert core.get_head_scan_for_repo("test", workspace="customer-a") == "workspace-head" + + +def test_get_head_scan_for_repo_normalizes_enum_scan_type(core, mock_sdk_with_responses): + """ScanType members must be sent as their value, not their repr-style name""" + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [{"id": "workspace-head"}], + "nextPage": None, + } + + core.get_head_scan_for_repo( + "test", + workspace="customer-a", + scan_type=ScanType.SOCKET_TIER1, + ) + + query_params = mock_sdk_with_responses.fullscans.get.call_args.args[1] + assert query_params["scan_type"] == "socket_tier1" + + def test_get_full_scan_id_by_commit(core, mock_sdk_with_responses): """Looks up the newest full scan for a repo + commit via the list endpoint""" mock_sdk_with_responses.fullscans.get.return_value = { @@ -80,7 +180,7 @@ def test_get_full_scan_id_by_commit(core, mock_sdk_with_responses): "commit_hash": "abc123", "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, }, ) @@ -107,24 +207,105 @@ def test_get_full_scan_id_by_commit_scopes_to_workspace_and_scan_type(core, mock "commit_hash": "abc123", "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, "workspace": "customer-a", "scan_type": "socket_tier1", }, ) +def test_get_full_scan_id_by_commit_skips_temporary_scans(core, mock_sdk_with_responses): + """A tmp scan carries the commit hash of the run that created it, so skip it too""" + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [ + {"id": "leftover-tmp-scan", "commit_hash": "abc123", "tmp": True}, + {"id": "base-scan-id", "commit_hash": "abc123"}, + ], + "nextPage": None, + } + + assert core.get_full_scan_id_by_commit("test", "abc123") == "base-scan-id" + + def test_get_full_scan_id_by_commit_not_found(core, mock_sdk_with_responses): - """No scan for the commit returns None (empty results and SDK error dict)""" + """A successful empty listing means the commit has no scan""" mock_sdk_with_responses.fullscans.get.return_value = {"results": [], "nextPage": None} assert core.get_full_scan_id_by_commit("test", "abc123") is None + +def test_get_full_scan_id_by_commit_lookup_failure_raises(core, mock_sdk_with_responses): + """An SDK error dict must not trigger fallback to an older ancestor""" mock_sdk_with_responses.fullscans.get.return_value = {} - assert core.get_full_scan_id_by_commit("test", "abc123") is None + with pytest.raises(APIFailure): + core.get_full_scan_id_by_commit("test", "abc123") + def test_resolve_base_full_scan_id_defaults_to_head_scan(core): - """Without base overrides the repository head scan is the baseline""" - assert core.resolve_base_full_scan_id(make_full_scan_params()) == "head" + """Without base overrides the matching scan type's head scan is the baseline""" + core.sdk.fullscans.get.return_value = { + "results": [{"id": "standard-head"}], + "nextPage": None, + } + + assert core.resolve_base_full_scan_id(make_full_scan_params()) == "standard-head" + + +def test_resolve_base_full_scan_id_scopes_head_to_workspace(core): + core.sdk.fullscans.get.return_value = { + "results": [{"id": "workspace-head"}], + "nextPage": None, + } + + params = make_full_scan_params(workspace="customer-a", scan_type="socket_tier1") + + assert core.resolve_base_full_scan_id(params) == "workspace-head" + core.sdk.fullscans.get.assert_called_once_with( + core.config.org_slug, + { + "repo": "test", + "workspace": "customer-a", + "branch": "main", + "sort": "created_at", + "direction": "desc", + "per_page": SCAN_LOOKUP_PAGE_SIZE, + "scan_type": "socket_tier1", + }, + ) + +def test_resolve_base_full_scan_id_workspace_lookup_failure_exits(core): + """A failed workspace lookup fails the run instead of diffing against an empty scan""" + core.cli_config = make_cli_config() + core.sdk.fullscans.get.return_value = {} + + params = make_full_scan_params(workspace="customer-a") + + with pytest.raises(SystemExit) as exc_info: + core.resolve_base_full_scan_id(params) + assert exc_info.value.code == core.cli_config.exit_code_on_api_error + + +def test_resolve_base_full_scan_id_workspace_lookup_failure_disable_blocking(core): + """--disable-blocking keeps the failed lookup from failing the build""" + core.cli_config = make_cli_config("--disable-blocking") + core.sdk.fullscans.get.return_value = {} + + params = make_full_scan_params(workspace="customer-a") + + with pytest.raises(SystemExit) as exc_info: + core.resolve_base_full_scan_id(params) + assert exc_info.value.code == 0 + + +def test_resolve_base_full_scan_id_workspace_lookup_failure_without_cli_config(core): + """Library callers with no CliConfig see the APIFailure rather than a process exit""" + core.cli_config = None + core.sdk.fullscans.get.return_value = {} + + params = make_full_scan_params(workspace="customer-a") + + with pytest.raises(APIFailure): + core.resolve_base_full_scan_id(params) + def test_resolve_base_full_scan_id_uses_base_scan_id(core): """--base-scan-id is used verbatim, without touching the repo endpoint""" @@ -152,12 +333,109 @@ def test_resolve_base_full_scan_id_uses_base_commit_sha(core): "commit_hash": "abc123", "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, "workspace": "customer-a", "scan_type": "socket_tier1", }, ) +def test_resolve_base_full_scan_id_falls_back_to_scanned_ancestor(core, monkeypatch): + """An unscanned merge base degrades to the nearest scanned ancestor""" + core.cli_config = make_cli_config("--base-commit-sha", "unscanned-sha") + core.sdk.fullscans.get.side_effect = [ + {"results": [], "nextPage": None}, # exact commit + {"results": [ # recent scans + {"id": "tmp-scan", "commit_hash": "ancestor-1", "tmp": True}, + {"id": "ancestor-scan", "commit_hash": "ancestor-2"}, + ], "nextPage": None}, + ] + monkeypatch.setattr( + Core, "first_parent_commits", + lambda self, sha, depth: ["unscanned-sha", "ancestor-1", "ancestor-2"], + ) + + params = make_full_scan_params() + assert core.resolve_base_full_scan_id(params) == "ancestor-scan" + + +def test_find_baseline_scan_for_ancestor_paginates_and_selects_nearest( + core, monkeypatch +): + """Reruns can fill page one while a closer scanned ancestor is on page two""" + first_page = [ + {"id": "farther-scan", "commit_hash": "ancestor-2"}, + *[ + {"id": f"unrelated-{index}", "commit_hash": f"other-{index}"} + for index in range(ANCESTOR_SCAN_LOOKUP_LIMIT - 1) + ], + ] + core.sdk.fullscans.get.side_effect = [ + {"results": first_page, "nextPage": 2}, + { + "results": [{"id": "nearest-scan", "commit_hash": "ancestor-1"}], + "nextPage": 0, + }, + ] + monkeypatch.setattr( + Core, + "first_parent_commits", + lambda self, sha, depth: ["unscanned-sha", "ancestor-1", "ancestor-2"], + ) + + assert core.find_baseline_scan_for_ancestor( + "test", + "unscanned-sha", + scan_type="socket", + ) == ("nearest-scan", "ancestor-1", 1) + assert core.sdk.fullscans.get.call_args_list[1].args[1]["page"] == 2 + + +def test_resolve_base_full_scan_id_exact_lookup_failure_does_not_fallback( + core, monkeypatch +): + core.cli_config = make_cli_config("--base-commit-sha", "abc123") + core.sdk.fullscans.get.return_value = {} + fallback_calls = [] + monkeypatch.setattr( + Core, + "find_baseline_scan_for_ancestor", + lambda *args, **kwargs: fallback_calls.append((args, kwargs)), + ) + + with pytest.raises(APIFailure): + core.resolve_base_full_scan_id(make_full_scan_params()) + assert fallback_calls == [] + + +def test_resolve_base_full_scan_id_ancestor_fallback_skips_temporary_scans(core, monkeypatch): + """A tmp scan on an ancestor is not a usable baseline either""" + core.cli_config = make_cli_config("--base-commit-sha", "unscanned-sha") + core.sdk.fullscans.get.side_effect = [ + {"results": [], "nextPage": None}, + {"results": [{"id": "tmp-scan", "commit_hash": "ancestor-1", "tmp": True}], "nextPage": None}, + ] + monkeypatch.setattr( + Core, "first_parent_commits", + lambda self, sha, depth: ["unscanned-sha", "ancestor-1"], + ) + + with pytest.raises(SystemExit): + core.resolve_base_full_scan_id(make_full_scan_params()) + + +def test_resolve_base_full_scan_id_ancestor_fallback_needs_local_history(core, monkeypatch): + """Without local history there is nothing to match scans against""" + core.cli_config = make_cli_config("--base-commit-sha", "unscanned-sha") + core.sdk.fullscans.get.side_effect = [ + {"results": [], "nextPage": None}, + {"results": [{"id": "ancestor-scan", "commit_hash": "ancestor-2"}], "nextPage": None}, + ] + monkeypatch.setattr(Core, "first_parent_commits", lambda self, sha, depth: []) + + with pytest.raises(SystemExit): + core.resolve_base_full_scan_id(make_full_scan_params()) + + def test_resolve_base_full_scan_id_commit_sha_not_found_exits(core): """A --base-commit-sha with no scan is a hard error (exit_code_on_api_error)""" core.cli_config = make_cli_config("--base-commit-sha", "abc123") @@ -309,10 +587,18 @@ def test_empty_alerts_preserved(core): def test_repository_head_baseline_log(core, caplog): + core.sdk.fullscans.get.return_value = { + "results": [{"id": "standard-head"}], + "nextPage": None, + } + with caplog.at_level("INFO", logger="socketdev"): - assert core.resolve_base_full_scan_id(make_full_scan_params()) == "head" + assert core.resolve_base_full_scan_id(make_full_scan_params()) == "standard-head" - assert 'Baseline selected: source=repository-head scan_id="head"' in caplog.messages + assert ( + 'Baseline selected: source=repository-head scan_id="standard-head"' + in caplog.messages + ) def test_explicit_scan_baseline_log(core, caplog): diff --git a/tests/unit/test_gitlab_format.py b/tests/unit/test_gitlab_format.py index 4a1cf0c1..b817e341 100644 --- a/tests/unit/test_gitlab_format.py +++ b/tests/unit/test_gitlab_format.py @@ -86,7 +86,10 @@ def test_identifier_extraction_with_cve(self): type="vulnerability", severity="critical", title="Known CVE", - props={"cve": ["CVE-2024-5678", "CVE-2024-9012"]}, + props={ + "cveId": ["CVE-2024-5678", "CVE-2024-9012"], + "ghsaId": "GHSA-1234-5678-9012", + }, pkg_type="npm", key="test-key", purl="pkg:npm/vulnerable-pkg@2.0.0" @@ -96,15 +99,17 @@ def test_identifier_extraction_with_cve(self): report = Messages.create_security_comment_gitlab(diff) vuln = report["vulnerabilities"][0] - # Should have socket_alert identifier + 2 CVE identifiers - assert len(vuln["identifiers"]) >= 3 + # Should have socket_alert identifier + CVE and GHSA identifiers + assert len(vuln["identifiers"]) == 4 cve_identifiers = [i for i in vuln["identifiers"] if i["type"] == "cve"] assert len(cve_identifiers) == 2 assert any(i["value"] == "CVE-2024-5678" for i in cve_identifiers) assert any(i["value"] == "CVE-2024-9012" for i in cve_identifiers) + ghsa_identifiers = [i for i in vuln["identifiers"] if i["type"] == "ghsa"] + assert ghsa_identifiers[0]["value"] == "GHSA-1234-5678-9012" def test_identifier_extraction_with_single_cve_string(self): - """Test single CVE identifier as string""" + """Legacy CVE property remains supported""" diff = Diff() diff.id = "test-scan-id" diff.diff_url = "https://socket.dev/test" @@ -129,8 +134,63 @@ def test_identifier_extraction_with_single_cve_string(self): assert len(cve_identifiers) == 1 assert cve_identifiers[0]["value"] == "CVE-2024-1111" + def test_identifier_extraction_deduplicates_legacy_and_current_cve_fields(self): + issue = Issue( + pkg_name="vulnerable-pkg", + pkg_version="2.0.0", + type="vulnerability", + severity="high", + title="Duplicate CVE", + props={"cve": "CVE-2024-1111", "cveId": "CVE-2024-1111"}, + pkg_type="npm", + key="test-key", + purl="pkg:npm/vulnerable-pkg@2.0.0", + ) + + identifiers = Messages.extract_identifiers_gitlab(issue) + + assert [item["value"] for item in identifiers].count("CVE-2024-1111") == 1 + + def test_identifier_extraction_supports_snake_case_props(self): + """Alerts can reach Issue.props with snake_case vulnerability ids""" + issue = Issue( + pkg_name="vulnerable-pkg", + pkg_version="2.0.0", + type="vulnerability", + severity="high", + title="Snake case ids", + props={"cve_id": "CVE-2024-2222", "ghsa_id": "GHSA-2222-3333-4444"}, + pkg_type="npm", + key="test-key", + purl="pkg:npm/vulnerable-pkg@2.0.0", + ) + + identifiers = Messages.extract_identifiers_gitlab(issue) + + by_type = {item["type"]: item for item in identifiers} + assert by_type["cve"]["value"] == "CVE-2024-2222" + assert by_type["ghsa"]["value"] == "GHSA-2222-3333-4444" + + def test_identifier_extraction_ignores_unusable_prop_values(self): + """Malformed props must not take down the whole report""" + issue = Issue( + pkg_name="vulnerable-pkg", + pkg_version="2.0.0", + type="vulnerability", + severity="high", + title="Malformed props", + props={"cveId": 1234, "ghsaId": None}, + pkg_type="npm", + key="test-key", + purl="pkg:npm/vulnerable-pkg@2.0.0", + ) + + identifiers = Messages.extract_identifiers_gitlab(issue) + + assert [item["type"] for item in identifiers] == ["socket_alert"] + def test_dependency_chain_handling_transitive(self): - """Test transitive dependency path is captured""" + """Directness comes from the package record, not from parsing a path string""" diff = Diff() diff.id = "test-scan-id" diff.diff_url = "https://socket.dev/test" @@ -144,6 +204,7 @@ def test_dependency_chain_handling_transitive(self): introduced_by=[ ["top-level > intermediate > transitive-dep", "package.json"] ], + direct=False, pkg_type="npm", key="test-key", purl="pkg:npm/transitive-dep@1.5.0" @@ -171,6 +232,7 @@ def test_dependency_chain_handling_direct(self): introduced_by=[ ["direct-dep", "package.json"] ], + direct=True, pkg_type="npm", key="test-key", purl="pkg:npm/direct-dep@3.0.0" @@ -182,6 +244,62 @@ def test_dependency_chain_handling_direct(self): assert vuln["location"]["dependency"]["direct"] is True + def test_location_file_falls_back_to_the_package_manifest(self): + """A package with no introduced_by chain still knows its own manifest""" + issue = Issue( + pkg_name="transitive-dep", + pkg_version="1.5.0", + type="malware", + severity="critical", + title="Malware Found", + introduced_by=[], + manifest_files=[{"file": "services/api/pom.xml"}], + direct=False, + pkg_type="maven", + key="test-key", + purl="pkg:maven/org.example/transitive-dep@1.5.0", + ) + + location = Messages.extract_location_gitlab(issue) + + assert location["file"] == "services/api/pom.xml" + assert location["dependency"]["direct"] is False + + def test_location_file_is_unknown_only_when_nothing_is_known(self): + """The unknown placeholder is a last resort, not the first answer""" + issue = Issue( + pkg_name="orphan", + pkg_version="1.0.0", + type="malware", + severity="critical", + title="Malware Found", + introduced_by=[], + pkg_type="npm", + key="test-key", + purl="pkg:npm/orphan@1.0.0", + ) + + assert Messages.extract_location_gitlab(issue)["file"] == "unknown" + + def test_identifier_url_is_omitted_rather_than_null(self): + """GitLab types identifier url as a string; null fails schema validation""" + issue = Issue( + pkg_name="nourl-pkg", + pkg_version="1.0.0", + type="malware", + severity="critical", + title="Malware", + pkg_type="npm", + key="test-key", + purl="pkg:npm/nourl-pkg@1.0.0", + ) + + identifiers = Messages.extract_identifiers_gitlab(issue) + + # An absent key is correct; a present-but-null value is what breaks validation. + assert all("url" not in i or i["url"] for i in identifiers) + assert "url" not in identifiers[0] + def test_severity_mapping(self): """Test all Socket severities map to GitLab severities""" severity_tests = [ diff --git a/uv.lock b/uv.lock index 446a9241..469230d6 100644 --- a/uv.lock +++ b/uv.lock @@ -1293,7 +1293,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.8.2" +version = "2.8.3" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From a96f0549d048d1cdbb0613a7e81d8af072632d26 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:37:07 -0400 Subject: [PATCH 44/47] Preserve pull request context, full-scan SCM branch pipelines, and gate ignore commands (#302) * feat(output): show patched versions in security findings * feat(ci): preserve pull request context in scan metadata * chore(release): bump version to 2.9.0 2.8.0 and 2.8.1 shipped from main while this branch was open, so the original 2.8.0 bump here is dead. This branch changes the behavior of existing flags rather than only fixing them -- --pr-number gains auto-detection, --scm github|gitlab implies --integration, and SCM branch pipelines switch from diff scans to full scans and stop returning a blocking exit code -- so it takes the minor bump per the repo's semver standard, not a patch. Co-Authored-By: Claude Opus 5 (1M context) * refactor: share one git remote parser between Buildkite consumers The GitHub comment adapter and pull request link construction each parsed BUILDKITE_REPO independently. Consolidate on socketsecurity.core.git_remote, which also reports the remote host (needed for self-hosted GitHub Enterprise and GitLab) and preserves nested GitLab subgroup paths. Co-Authored-By: Claude Opus 5 (1M context) * fix(ci): apply the pull request link to an already-compared scan pair external_href is only honored while a diff scan is being created, so a re-run over the same before/after pair left the Dashboard report with no link back to its pull request. Send on_duplicate=update alongside it, which applies the link to the existing diff scan and answers 200 with the same envelope as a create. The 409-and-resolve path is retained for runs with no pull request context and for deployments that predate on_duplicate=update. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): make per-alert ignores round trip * fix(comments): preserve dependency change types * fix(ci): use full scans outside pull requests * docs: update release notes for comment fixes * fix(ci): restrict SCM diffs to pull requests * fix(scans): keep the package list on full scans create_full_scan_with_report_url only fetched SBOM data when an alert-bearing output format was enabled, so --generate-license and --legal-format fossa saw an empty diff.packages and wrote an attribution file with zero packages. That is the list they enumerate, as _requires_unchanged_artifacts already documents for the comparison path. Fetch the SBOM for them too, and enrich it through the PURL endpoint the way the comparison path does. The full scan's package map is keyed by artifact id while get_license_text_via_purl keys off ecosystem/name@version, so pass a purl-keyed view over the same Package objects. Alert consolidation stays behind its own gate, so an alert-only run does not pay for the license lookup and a license-only run does not build an alert list. Co-Authored-By: Claude Opus 5 (1M context) * fix(ci): keep branch pipelines out of pull request handling Two ways an SCM branch build could still be treated like a pull request: Buildkite always sets BUILDKITE_PULL_REQUEST, to the string "false" on a branch build, so the documented --pr-number "$BUILDKITE_PULL_REQUEST" form delivers a truthy non-numeric value. resolve_pull_request_context read it as no PR but only wrote the normalized number back when one was found, so GithubConfig still saw "false", check_event_type returned "diff" for a push, and comment lookups went to issues/false/comments. Canonicalize config.pr_number before any adapter reads it. A branch run creating a full scan then blocked on diff.new_alerts, which a full scan cannot fill meaningfully: empty with no alert-bearing output format enabled, and every alert in the scan rather than the newly introduced ones with one. The exit code therefore depended on which output format was requested. Treat these runs the way a run with no supported manifest files is already treated and skip blocking, leaving pull request pipelines to enforce policy. Move the scan-type decision into create_scm_scan, which returns the diff and whether it came from a comparison, so the branch is exercised by tests rather than only its predicate. Document both the scan-type table and the blocking consequence in the CI/CD guide. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): stop reading an npm scope as an ecosystem Ignore matching strips the ecosystem off a command so an ecosystem-qualified reply still matches the bare package name parsed out of a start-socket-alert marker. It stripped any leading path segment, and a scope sits in the same position, so "ignore @types/node@*" also suppressed alerts for a package named node. Only strip a leading segment that cannot be a scope. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): keep the diff badge where artwork exists Labelling every dependency overview row with bold text dropped the badge from added rows, which is the only category the overview rendered before. The badge host publishes diff-added.svg and diff-updated.svg but nothing for removed or replaced, so look the badge up per change type and fall back to the text label only where there is no image to render. Co-Authored-By: Claude Opus 5 (1M context) * refactor(config): scope the config-file defaults dict to its block normalized_defaults has no reader outside the branch that fills it. Co-Authored-By: Claude Opus 5 (1M context) * docs: correct the release notes for branch pipeline scans The entry still described the intermediate behavior where explicit diff flags opted a non-PR run into comparison mode; the detected event type has been authoritative since that was reverted. Record the blocking and license consequences alongside it, plus the ignore and overview fixes. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): stop legacy comment updates crashing on scoped names process_original_security_comment split the package cell on every "@", so a scoped name carrying its own "@" unpacked into three values and raised an uncaught ValueError. Same bug class this branch already fixed one function over in process_updated_security_comment, just left in its sibling. Split from the right, and pass the ecosystem through as pkg_type rather than pre-concatenating it onto the package name. That makes the two comment formats agree: both now accept an ignore command for a scoped package in either the ecosystem-qualified or the bare form, where the legacy path previously matched only the qualified one. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): require write access to ignore an alert An @SocketSecurity ignore command suppresses a security finding, but the CLI honored one from any commenter. Comment.author_association was carried on the dataclass and never read, so nothing on the path from comment to suppressed alert asked whether the author could push to the repository. A drive-by ignore-all on an open pull request silenced every finding on it. Gate the ignore bucket in check_for_socket_comments, the one place every consumer goes through. A rejected command is logged with its author and is also absent from the ignore telemetry, which should record what was acted on. GitHub returns author_association with every comment, so the check is free and definitive: OWNER, MEMBER and COLLABORATOR only. GitLab notes carry no equivalent, so project membership is read once per run, and only when an ignore command is actually present. members/all is used rather than a per-user lookup because it answers non-membership with a 200 and an absent id -- CliClient collapses every HTTP error into APIFailure without a status code, so a per-user 404, exactly the outsider case, would be indistinguishable from a token that cannot read the endpoint and would have to fail open. When membership genuinely cannot be read -- a CI_JOB_TOKEN typically cannot -- the command is honored and a warning names the author, so this does not silently break pipelines already relying on ignore commands. Documented alongside the token requirement to get enforcement. Co-Authored-By: Claude Opus 5 (1M context) * fix(ci): validate CI-supplied server URLs before building a link GITHUB_SERVER_URL and CI_SERVER_URL were composed into the pull request link verbatim, while the sibling repository URLs read from the same environment already went through a scheme/netloc check. The result is sent to the API as a diff scan's external_href, so route all of them through one validator. Standard runners set these themselves, so this is defense in depth rather than a live hole. An unusable value now falls back to github.com for GitHub; GitLab has no public default host, so the link is dropped and the scan keeps its number. Co-Authored-By: Claude Opus 5 (1M context) * docs: correct the add_purl_capabilities docstring The loop covers updated_packages as well as new_packages; the docstring still described only the latter. Co-Authored-By: Claude Opus 5 (1M context) * docs: record the review fixes in the 2.9.0 release notes Co-Authored-By: Claude Opus 5 (1M context) * docs: rewrite branch comments for the reader, not the author Sweep of every comment this branch adds, against the fourth-wall skill: - A test docstring stated the scan type "(since 2.8.0)", which was already wrong after the renumber to 2.9.0 and would rot again on the next one. Version stamps in comments describe a debut rather than the behavior. - Two docstrings narrated the failure the old parser produced instead of the invariant that makes rsplit correct. A scoped name carrying its own "@" is the whole reason; the traceback it used to raise is not. - The "do NOT use on_duplicate=redirect" landmine was explained twice, in full, at both call sites. Kept at the 409 fallback, where the temptation to add it lives; the create site now just says what update does. - A test section header justified its own design to a reviewer ("swapping the call back ... fails them"). Restated as what the test actually pins. - "out of this branch" in the remote-URL regex reads as a git branch in this repo; it means the regex case. 642 passed, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): parse legacy alert rows defensively Each row of the legacy comment table was unpacked through four consecutive splits with no bounds checks: five cells, then the markdown link, then the ecosystem, then the version. The row comes back from the provider's API, so a cell carrying an extra "|", a package cell that is not a link, or a name with no version raised out of the comment rewrite and ended the run before it reported status. A scoped package name in Socket's own table reached the same place with nobody doing anything unusual. parse_alert_table_row returns None instead of raising for any row it cannot read, and an unreadable row keeps its alert reported -- the safe direction, since a row that cannot be parsed cannot be evaluated against the ignore commands either. Also pins change-type preservation against the real artifact conversion rather than a stubbed field. The existing test assigned diffType by hand, so it would have passed whether or not the conversion populated it; the new one runs real DiffArtifact objects through both response shapes, and fails if the field is dropped. Co-Authored-By: Claude Opus 5 (1M context) * feat(comments): add --ignore-authorization The write-access gate had no escape hatch, and its GitLab behavior when project membership cannot be read -- honor the command with a warning -- was the one deliberate weakness in it. Both are now a choice: enforce (default) require write access; honor with a warning where the provider cannot report it strict reject in that case instead off perform no check enforce closes the hole wherever the provider can answer without breaking a pipeline whose token cannot read membership, which is why it is the default. strict closes it everywhere and will fail those pipelines. off restores the prior behavior for anyone who needs comment-driven ignores from unverified authors. Threaded through the adapter constructors as a keyword argument with a default, so existing call sites keep working. With off the predicate is never handed to check_for_socket_comments at all, so nothing is filtered and no rejection is logged, rather than a gate that silently approves everything. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): escape repository-derived values when rendering comments Manifest paths and sources are file paths inside the scanned repository, so anyone who can open a pull request controls them: a directory named with link or tag syntax, holding a manifest, put that markup into a comment posted by a trusted integration. Alert text comes from the API. Neither is markup the CLI authored, so both are escaped where they are interpolated -- text nodes with html.escape, href and src with quotes escaped too, since an unescaped quote closes the attribute and everything after it reads as more attributes. The alert markers are the exception: they are read back verbatim when a comment is rewritten, so they cannot be escaped without breaking the ignore round trip. They instead lose only the ability to terminate the comment early. plain and raw styles are untouched. Slack, Jira and the console do not render HTML, and escaping there would show entities to a human. Round-trip tests render a comment with each hostile path and feed it back through the parser, because the renderer and the parser are two halves of one loop: an escaping choice the parser cannot read would silently stop ignores working. Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): make the authentication fallback actually run _get_auth_headers guesses between Bearer and PRIVATE-TOKEN from the shape of the token, and retries once under the other scheme on a 401 so a wrong guess does not fail the run. That retry has never executed. Three things had to line up and none of them did: - The retry caught requests.exceptions.HTTPError, but CliClient translates every requests error into APIFailure before it gets there. - CliClient discarded the HTTP status, so even a caught failure could not be identified as a 401. is_transient_error was equally blind for the same reason. - There are two APIFailure classes -- the CLI's own and the SDK's -- and they were independent Exception subclasses. CliClient raises the CLI's; every handler in socketsecurity.core imports the SDK's. None of those eight handlers has ever caught a CliClient failure. The CLI's APIFailure now subclasses the SDK's, so a handler written against either catches both, and the status code travels with the exception. The two tests covering the fallback were skipped rather than fixed, with a reason that no longer described the failure -- the constructor they blamed is used by the two passing tests in the same file. They now drive the exception the way CliClient actually raises it, and fail if any of the three links above is broken again. Co-Authored-By: Claude Opus 5 (1M context) * fix(review): address authorization and full-scan findings * fix(comments): close ignore authorization gaps --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 121 +++++++- docs/ci-cd.md | 147 ++++++++- docs/cli-reference.md | 38 ++- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/config.py | 50 ++- socketsecurity/core/__init__.py | 163 +++++++--- socketsecurity/core/alert_selection.py | 2 + socketsecurity/core/classes.py | 14 + socketsecurity/core/cli_client.py | 7 +- socketsecurity/core/exceptions.py | 14 +- socketsecurity/core/git_remote.py | 43 +++ socketsecurity/core/messages.py | 213 +++++++++---- socketsecurity/core/pull_request.py | 144 +++++++++ socketsecurity/core/scm/github.py | 112 +++++-- socketsecurity/core/scm/gitlab.py | 149 +++++++-- socketsecurity/core/scm_comments.py | 123 ++++++-- socketsecurity/output.py | 12 + socketsecurity/socketcli.py | 191 ++++++++++-- tests/core/test_diff_generation.py | 49 ++- tests/core/test_diff_scan_polling.py | 57 +++- tests/core/test_full_scan_outputs.py | 128 ++++++++ tests/unit/test_cli_config.py | 54 ++++ tests/unit/test_client.py | 53 ++++ tests/unit/test_dependency_overview.py | 23 ++ tests/unit/test_disable_ignore.py | 37 +++ tests/unit/test_git_remote.py | 41 +++ tests/unit/test_gitlab_auth_fallback.py | 55 ++-- tests/unit/test_ignore_authorization.py | 267 ++++++++++++++++ tests/unit/test_ignore_telemetry_filtering.py | 18 ++ tests/unit/test_messages.py | 81 +++++ tests/unit/test_pr_comment_rendering.py | 201 ++++++++++++ tests/unit/test_pull_request_context.py | 293 ++++++++++++++++++ tests/unit/test_socketcli.py | 96 ++++++ uv.lock | 2 +- workflows/buildkite.yml | 21 +- 36 files changed, 2747 insertions(+), 276 deletions(-) create mode 100644 socketsecurity/core/git_remote.py create mode 100644 socketsecurity/core/pull_request.py create mode 100644 tests/core/test_full_scan_outputs.py create mode 100644 tests/unit/test_git_remote.py create mode 100644 tests/unit/test_ignore_authorization.py create mode 100644 tests/unit/test_messages.py create mode 100644 tests/unit/test_pull_request_context.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b8bcdfd..cd66c2ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,125 @@ # Changelog +## 2.9.0 + +### Added: patched versions in human-readable security output + +- The native console alert table now includes a `Patched Version` column, + populated from `props.firstPatchedVersionIdentifier` when the API provides it. +- GitHub pull request and GitLab merge request security comments now show the + patched version in each applicable alert's details. + +### Fixed: CLI scans retain pull request context in the Socket Dashboard + +- Pull request numbers are detected from standard GitHub Actions, GitLab CI, + and Azure Pipelines environments when `--pr-number` is not supplied. An + explicitly supplied value, including `0`, remains authoritative. +- The Buildkite workflow and CI/CD guide now forward `BUILDKITE_PULL_REQUEST` + explicitly and document provider selection for Dashboard PR association. With + `--integration github` or `--integration gitlab`, the repository slug and host + for the link are read from `BUILDKITE_REPO`, covering self-hosted installations. +- `--scm github` and `--scm gitlab` now imply the matching scan integration + unless `--integration` is explicitly supplied. +- Diff scans include the detected pull request or merge request URL as their + external link, allowing Dashboard reports to retain their CI change context. + Re-running a comparison over an already-compared scan pair now applies the + link to the existing diff scan instead of leaving that report unassociated. +- A `--pr-number` value that is not a positive integer is now normalized to `0` + before the GitHub adapter reads it, so Buildkite's `false` on a branch build no + longer makes that build look like a pull request event. + +### Changed: GitHub and GitLab branch pipelines create full scans + +- With `--scm github` or `--scm gitlab`, only pull request and merge request + events create diff scans. Every other pipeline, including default-branch + pushes, creates a full scan. The detected event type is authoritative: + `--enable-diff` and `--ignore-commit-files` no longer opt an SCM branch run + into comparison mode. +- Those runs no longer set a blocking exit code. A full scan has no baseline, so + it cannot distinguish newly introduced alerts from pre-existing ones; the CLI + now behaves as if `--disable-blocking` was supplied, matching how it already + treats a run with no supported manifest files. Pull request and merge request + pipelines are unaffected and still block. +- `--generate-license` and `--legal-format fossa` fetch the package list on this + path, so attribution files generated from a branch pipeline are complete rather + than empty. +- Console-only full scans link to the Socket report and state that findings were + not fetched for console output instead of presenting an empty local alert list + as "No issues found." +- License enrichment keeps the package namespace in PURL requests and response + matching, so scoped npm packages and namespaced Maven packages receive their + license details. + +### Changed: `@SocketSecurity ignore` requires write access + +- An ignore command suppresses a security alert, but the CLI honored one from any + commenter, including a drive-by comment from someone with no access to the + repository. Commands are now accepted only from an author with write access. +- On GitHub this is read from the effective repository permission and cached per + commenter for the run. Write, maintain, or admin access is required; relationship + labels such as `MEMBER` and `COLLABORATOR` are not treated as permissions. +- A 404 from GitHub's collaborator-permission endpoint is treated as a definitive + denial rather than an unreadable permission, so the default `enforce` policy does + not honor ignore commands from users outside the repository. +- GitLab notes carry no equivalent field, so project membership is read once per + run (only when an ignore command is present) and Developer or above is required. + If that lookup cannot be answered — a `CI_JOB_TOKEN` generally cannot read the + members API — the command is still honored and a warning names the author, so + enabling this does not silently break pipelines that relied on ignore commands. + Use a `GITLAB_TOKEN` with API read access to get enforcement. +- A rejected command is logged and is also absent from the ignore telemetry, which + records what was acted on. No acknowledgement reaction is added to a comment that + was not honored. +- `--ignore-authorization` selects the policy: `enforce` (default) requires write + access and honors the command with a warning where the provider cannot report it, + `strict` rejects it in that case instead, and `off` performs no check. + +### Fixed: GitLab authentication fallback never ran + +- When a GitLab token's type cannot be inferred from its shape, the CLI guesses + between Bearer and PRIVATE-TOKEN and retries once under the other scheme on a + 401. That retry never happened: the retry caught `requests.exceptions.HTTPError`, + but the HTTP client translates every request error into `APIFailure` first, so a + misclassified token failed the run instead of falling back. +- API failures raised by the CLI's HTTP client now carry their HTTP status code. + Without it a 401 was indistinguishable from any other failure, and + `is_transient_error` could not classify one either. +- The CLI's `APIFailure` now subclasses the SDK exception of the same name. They + were independent types, so an `except APIFailure` importing the SDK's — which is + what every handler in `socketsecurity.core` does — did not catch a failure raised + by the HTTP client. + +### Fixed: pull request and merge request comment accuracy + +- Per-alert ignore instructions now use ecosystem-qualified package names and + accept scoped packages while remaining compatible with older bare-name replies. + A leading npm scope is no longer mistaken for an ecosystem, so + `ignore @types/node@*` no longer also ignores the package named `node`. +- Ignore telemetry uses the same package matcher as alert suppression, so legacy + bare-name commands generate an event for the alert they suppress. +- Dependency overviews preserve added, updated, removed, and replaced package + classifications instead of presenting updates as new dependencies. Added and + updated rows keep their diff badge; removed and replaced, which have no + published badge, use a text label. +- Shared security comment copy no longer describes GitLab merge request output + as Socket for GitHub. +- Updating a security comment in the legacy table format no longer raises on a + malformed row. Each row was unpacked through four consecutive splits with no + bounds checks, so a cell carrying an extra `|`, a package cell that is not a + markdown link, or a name with no version ended the run before it reported + status — and a scoped package name in Socket's own table was enough to trigger + it. Rows are now parsed defensively, and a row that cannot be read keeps its + alert reported. Ignore commands for a scoped package are accepted there in both + the ecosystem-qualified and bare forms. +- Server URLs read from `GITHUB_SERVER_URL` and `CI_SERVER_URL` are validated as + http(s) URLs before being composed into a diff scan's external link, matching + the check already applied to the other repository URLs read from CI. +- Repository-derived values are escaped before they are rendered into a pull + request or merge request comment. Manifest paths and sources are file paths from + the scanned repository, and alert text comes from the API; neither is markup the + CLI authored, so both are now escaped at the point they are interpolated. The + alert markers can no longer be terminated early by a package name. Slack, Jira + and console output are unchanged, since none of them render HTML. ## 2.8.3 ### Fixed: GitLab report serialization and workspace baselines @@ -26,7 +146,6 @@ repository, and default branch, including when no workspace is supplied. A baseline lookup that fails is reported as an API error instead of resolving to an empty baseline, and temporary scans are skipped when selecting one. - ## 2.8.2 ### Changed: bump pinned @coana-tech/cli to 15.10.41 diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 2051a691..253c27ca 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -2,6 +2,10 @@ Use this guide for pipeline-focused CLI usage across platforms. +The shell commands in the recommended patterns are CI-provider neutral. Buildkite +pipeline equivalents and provider-specific considerations are called out alongside +the relevant guidance below. + ## Recommended patterns ### Dashboard-style reachable SARIF @@ -27,6 +31,27 @@ socketcli \ --strict-blocking ``` +### Buildkite: retain SARIF as a build artifact + +Either recommended pattern can run directly in a Buildkite command step. When the +scan writes SARIF, add +[`artifact_paths`](https://buildkite.com/docs/pipelines/configure/artifacts#upload-artifacts-with-a-command-step) +so developers can download the report from the build after the command finishes: + +```yaml +steps: + - label: ":socket: Socket reachable diff" + command: | + socketcli \ + --reach \ + --sarif-file results.sarif \ + --sarif-scope diff \ + --sarif-reachability reachable \ + --strict-blocking + artifact_paths: + - "results.sarif" +``` + ## Config file usage in CI Use `--config .socketcli.toml` or `--config .socketcli.json` to keep pipeline commands small. @@ -60,6 +85,9 @@ Equivalent JSON: } ``` +The Buildkite examples below use the same checked-in `.socketcli.toml` file; no +Buildkite-specific config-file format is required. + ## Platform examples ### GitHub Actions @@ -306,14 +334,33 @@ initial timeout signal or 137 if `SIGKILL` is involved. ### Buildkite +This example assumes a GitHub-hosted repository. Change +`SOCKET_SCM_INTEGRATION` to `gitlab` for a GitLab-hosted repository, or `api` +when provider association is not wanted. The doubled dollar signs defer +Buildkite variable expansion until the command runs on an agent. + ```yaml +env: + SOCKET_SCM_INTEGRATION: "github" + steps: - label: "Socket scan" - command: "socketcli --config .socketcli.toml --target-path ." - env: - SOCKET_SECURITY_API_TOKEN: "${SOCKET_SECURITY_API_TOKEN}" + command: | + socketcli \ + --config .socketcli.toml \ + --target-path . \ + --integration "$${SOCKET_SCM_INTEGRATION:-api}" \ + --pr-number "$${BUILDKITE_PULL_REQUEST:-0}" + secrets: + - SOCKET_SECURITY_API_TOKEN ``` +The `secrets` block expects a +[Buildkite secret](https://buildkite.com/docs/pipelines/security/secrets/buildkite-secrets) +named `SOCKET_SECURITY_API_TOKEN`. If your organization uses an external secrets +plugin or an agent hook instead, remove that block and inject the same environment +variable through your existing mechanism. Do not store the token in pipeline YAML. + The CLI reads Buildkite's native `BUILDKITE_COMMIT`, `BUILDKITE_BRANCH`, `BUILDKITE_PULL_REQUEST`, and `BUILDKITE_PULL_REQUEST_BASE_BRANCH` variables. For pull-request builds, ensure the checkout contains the base branch and the @@ -321,11 +368,12 @@ checked-out head commit. The CLI uses those local refs first and performs a targeted fetch only when a required ref or its comparison history is missing; it does not fetch every remote ref and tag during startup. -When `--scm github` is used from Buildkite, the CLI also derives GitHub comment -context from `BUILDKITE_REPO`, `BUILDKITE_BUILD_CHECKOUT_PATH`, and the variables -above. Set `GH_API_TOKEN` to a GitHub token with the required repository access. -GitHub Enterprise users should also set `GITHUB_API_URL`; GitHub.com defaults to -`https://api.github.com`. +When `--scm github` is used from Buildkite, the CLI also posts GitHub PR comments. +It identifies the repository from `BUILDKITE_REPO` and takes the rest of the build +context from `BUILDKITE_BUILD_CHECKOUT_PATH` and the variables above — see +[Buildkite PR context](#buildkite-pr-context). Set `GH_API_TOKEN` to a GitHub token +with the required repository access. GitHub Enterprise users should also set +`GITHUB_API_URL`; GitHub.com defaults to `https://api.github.com`. #### Merge-base baselines in Buildkite (dynamic pipelines) @@ -389,6 +437,18 @@ socket_scan: SOCKET_SECURITY_API_TOKEN: $SOCKET_SECURITY_API_TOKEN ``` +### Azure Pipelines + +```yaml +- script: | + socketcli \ + --integration azure \ + --enable-diff \ + --target-path "$(Build.SourcesDirectory)" + env: + SOCKET_SECURITY_API_TOKEN: $(SOCKET_SECURITY_API_TOKEN) +``` + ### Bitbucket Pipelines ```yaml @@ -399,6 +459,69 @@ pipelines: - socketcli --config .socketcli.toml --target-path . ``` +## Scan type by pipeline + +With `--scm github` or `--scm gitlab`, the detected event decides the scan type: + +| Event | Scan | Blocks the build | +|:------|:-----|:-----------------| +| Pull request / merge request | Diff scan against the repository's baseline | Yes, on newly introduced alerts | +| Any other pipeline, including default-branch pushes | Full scan | No | + +A full scan has no baseline, so it cannot tell a newly introduced alert from one +that was already there. Rather than block on a number that would mean something +different depending on which output format was enabled, those runs behave as if +`--disable-blocking` was supplied and report through the Dashboard instead. This +matches how the CLI already treats a run with no supported manifest files. + +The event type is authoritative once `--scm` is set: `--enable-diff` and +`--ignore-commit-files` do not turn a branch pipeline into a comparison. To diff +a branch build, drop `--scm` and use `--enable-diff` with `--integration`, which +runs the comparison without the PR comment adapter. + +`--generate-license` and `--legal-format fossa` work on both paths; a full scan +fetches the package list for them. + +## Pull request and Dashboard association + +The CLI sends the resolved pull request number with each full scan and attaches +the pull request URL to diff scans so the Socket Dashboard can associate the +report with its originating change. If `--pr-number` is supplied, it wins; +passing `--pr-number 0` explicitly disables automatic association. Any value that +is not a positive integer, including Buildkite's `false`, means no pull request. + +Without an explicit value, the CLI recognizes: + +- GitHub Actions: `PR_NUMBER`, then the PR number in `GITHUB_REF`. +- GitLab CI: `CI_MERGE_REQUEST_IID`. +- Azure Pipelines: `SYSTEM_PULLREQUEST_PULLREQUESTNUMBER` for GitHub-hosted + repositories, otherwise `SYSTEM_PULLREQUEST_PULLREQUESTID` for Azure Repos. + +### Buildkite PR context + +Buildkite is SCM-provider neutral, so the CLI does not infer a provider or consume +its PR variable automatically. Pass Buildkite's +[`BUILDKITE_PULL_REQUEST`](https://buildkite.com/docs/pipelines/configure/environment-variables#BUILDKITE_PULL_REQUEST) +value to +`--pr-number` and identify the repository host with `--integration`, as shown in +the Buildkite platform example above. Buildkite sets `BUILDKITE_PULL_REQUEST` to +`false` outside PR builds; the CLI treats that value as no PR. + +Use `--integration github` for GitHub-hosted repositories and `--integration gitlab` +for GitLab-hosted ones. The CLI identifies the repository from +[`BUILDKITE_REPO`](https://buildkite.com/docs/pipelines/configure/environment-variables#BUILDKITE_REPO), +taking both the slug and the host from it, so github.com, GitLab.com, and self-hosted +installations all build a correct pull request or merge request link without extra +configuration. That same value identifies the repository for GitHub PR comments when +`--scm github` is set. `CI_PROJECT_URL` still overrides the derived GitLab project URL. +Keep `--scm api` unless you also intend to configure an existing GitHub or GitLab +comment adapter and its provider token. + +`--scm github` and `--scm gitlab` also imply the matching scan integration for +Dashboard metadata unless `--integration` was explicitly supplied. PR comments +remain limited to the existing GitHub and GitLab SCM adapters; Azure receives +console output and Dashboard association but does not post a PR comment. + ## Workflow templates Prebuilt examples in this repo: @@ -415,3 +538,11 @@ Prebuilt examples in this repo: - `--sarif-grouping alert` currently applies to `--sarif-scope full`. - Diff-based SARIF can validly be empty when there are no matching net-new alerts. - Keep API tokens in secret stores (`SOCKET_SECURITY_API_TOKEN`), not in config files. +- In Buildkite pipeline YAML, follow its + [runtime interpolation](https://buildkite.com/docs/pipelines/configure/environment-variables#runtime-variable-interpolation) + guidance and use `$$` for variables that must expand when the command runs rather + than when the pipeline is uploaded. +- Security findings with `props.firstPatchedVersionIdentifier` show that value in + the console table, including native Buildkite job logs, and in GitHub/GitLab + security comments when that SCM adapter is configured. Findings without a known + patched release leave the console cell blank and omit the comment field. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2ee42450..ae85a275 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -238,7 +238,7 @@ If you don't want to provide the Socket API Token every time then you can use th | `--repo` | False | *auto* | Repository name in owner/repo format (auto-detected from git remote) | | `--workspace` | False | | The Socket workspace to associate the scan with (e.g. `my-org` in `my-org/my-repo`). See note below. | | `--repo-is-public` | False | False | If set, flags a new repository creation as public. Defaults to false. | -| `--integration` | False | api | Integration type (api, github, gitlab, azure, bitbucket) | +| `--integration` | False | api | Integration type (api, github, gitlab, azure, bitbucket). When omitted, `--scm github` or `--scm gitlab` implies the matching integration. | | `--owner` | False | | Name of the integration owner, defaults to the socket organization slug | | `--branch` | False | *auto* | Branch name (auto-detected from git) | | `--committers` | False | *auto* | Committer(s) to filter by (auto-detected from git commit) | @@ -252,7 +252,7 @@ If you don't want to provide the Socket API Token every time then you can use th #### Pull Request and Commit | Parameter | Required | Default | Description | |:-----------------|:---------|:--------|:-----------------------------------------------| -| `--pr-number` | False | "0" | Pull request number | +| `--pr-number` | False | *auto* | Pull request number. Auto-detected in GitHub Actions, GitLab CI, and Azure Pipelines; explicitly passing `0` disables detection. | | `--commit-message` | False | *auto* | Commit message (auto-detected from git) | | `--commit-sha` | False | *auto* | Commit SHA (auto-detected from git) | | `--base-scan-id` | False | | Full scan ID to diff against, overriding the repository's head scan as the baseline. Mutually exclusive with `--base-commit-sha` | @@ -429,7 +429,8 @@ The launcher can be tuned via the `SOCKET_CLI_COANA_LAUNCHER` environment variab |:-------------------------|:---------|:--------|:----------------------------------------------------------------------| | `--ignore-commit-files` | False | False | Ignore commit files | | `--disable-blocking` | False | False | Non-blocking CI mode: the CLI always exits **0**, even when blocking alerts are present (including with `--strict-blocking`). Also exits 0 on uncaught runtime errors and Socket API failures, so the job is treated as successful while findings and errors are still logged. Takes precedence over `--strict-blocking`. | -| `--disable-ignore` | False | False | Disable support for `@SocketSecurity ignore` commands in PR comments. When set, alerts cannot be suppressed via comments and ignore instructions are hidden from comment output. | +| `--disable-ignore` | False | False | Disable support for `@SocketSecurity ignore` commands in PR comments. When set, alerts cannot be suppressed via comments and ignore instructions are hidden from comment output. See [Who can ignore an alert](#who-can-ignore-an-alert). | +| `--ignore-authorization` | False | enforce | Who may suppress alerts with `@SocketSecurity ignore`. `enforce` requires write access and honors the command with a warning when the provider cannot report it; `strict` rejects it in that case; `off` honors any commenter. See [Who can ignore an alert](#who-can-ignore-an-alert). | | `--strict-blocking` | False | False | Fail on ANY security policy violations (blocking severity), not just new ones. Only works in diff mode. See [Strict Blocking Mode](#strict-blocking-mode) for details. | | `--enable-diff` | False | False | Enable diff mode even when using `--integration api` (forces diff mode without SCM integration) | | `--scm` | False | api | Source control management type | @@ -688,6 +689,37 @@ The CLI uses intelligent default branch detection with the following priority: Both `--default-branch` and `--pending-head` parameters are automatically synchronized to ensure consistent behavior. +## Who can ignore an alert + +`@SocketSecurity ignore /@` and +`@SocketSecurity ignore-all` suppress security findings, so the CLI honors them +only from a commenter with write access to the repository. A command from anyone +else is skipped, logged with the author's name, and the alerts it named stay +reported. `--disable-ignore` turns the feature off entirely. + +| Provider | How access is determined | If it cannot be determined | +|:---------|:-------------------------|:---------------------------| +| GitHub | Effective repository permission, read once per commenter per run. Write, maintain, or admin access is honored. | The command is honored and a warning is logged. | +| GitLab | Project membership, read once per run when an ignore command is present. Developer (30) or above is honored. | The command is honored and a warning is logged. | + +The GitHub check needs a token that can read repository metadata. GitLab notes +carry no permission field, so that check needs a `GITLAB_TOKEN` that can read +`GET /projects/:id/members/all`. A `CI_JOB_TOKEN` generally cannot. + +`--ignore-authorization` decides what happens when access cannot be determined: + +| Value | Verified write access | Access cannot be determined | +|:------|:----------------------|:----------------------------| +| `enforce` (default) | Honored | Honored, with a warning naming the author | +| `strict` | Honored | Rejected | +| `off` | Honored | Honored, no check performed | + +`enforce` closes the hole wherever the provider can answer, without breaking a +pipeline whose token cannot read membership. `strict` closes it everywhere, at the +cost of failing those pipelines. `off` restores the prior behavior and should be +paired with `--disable-ignore` unless you specifically need comment-driven ignores +from unverified authors. + ## GitLab Token Configuration GitLab token/auth behavior and CI examples are documented in [`ci-cd.md`](ci-cd.md). diff --git a/pyproject.toml b/pyproject.toml index 1c30bc61..334ff1b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.8.3" +version = "2.9.0" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index c5cb4808..ce3e70ab 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.8.3' +__version__ = '2.9.0' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index ecd2f287..bb2ad009 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -115,6 +115,7 @@ class CliConfig: branch: str = "" committers: Optional[List[str]] = None pr_number: str = "0" + pr_number_explicit: bool = False commit_message: Optional[str] = None default_branch: bool = False target_path: str = "./" @@ -143,6 +144,7 @@ class CliConfig: ignore_commit_files: bool = False disable_blocking: bool = False disable_ignore: bool = False + ignore_authorization: str = "enforce" # Tri-state log-upload preference: True = --upload-logs, False = --no-upload-logs, # None = neither (server-side override decides). upload_logs: Optional[bool] = None @@ -219,6 +221,17 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': parser.set_defaults(**normalized_defaults) args = parser.parse_args(args_list) + integration_explicit = hasattr(args, "integration") + pr_number_explicit = hasattr(args, "pr_number") + + integration_type = getattr(args, "integration", "api") + pr_number = getattr(args, "pr_number", "0") + if ( + not integration_explicit and + integration_type == "api" and + args.scm in ("github", "gitlab") + ): + integration_type = args.scm if args.reach_exclude_paths: logging.warning( @@ -262,7 +275,8 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'repo': args.repo, 'branch': args.branch, 'committers': args.committers, - 'pr_number': args.pr_number, + 'pr_number': pr_number, + 'pr_number_explicit': pr_number_explicit, 'commit_message': commit_message, 'default_branch': args.default_branch, 'target_path': os.path.expanduser(args.target_path), @@ -292,9 +306,10 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'ignore_commit_files': args.ignore_commit_files, 'disable_blocking': args.disable_blocking, 'disable_ignore': args.disable_ignore, + 'ignore_authorization': args.ignore_authorization, 'upload_logs': args.upload_logs, 'strict_blocking': args.strict_blocking, - 'integration_type': args.integration, + 'integration_type': integration_type, 'pending_head': args.pending_head, 'timeout': args.timeout, 'exit_code_on_api_error': args.exit_code_on_api_error, @@ -519,8 +534,12 @@ def create_argument_parser() -> argparse.ArgumentParser: "--integration", choices=INTEGRATION_TYPES, metavar="", - help="Integration type of api, github, gitlab, azure, or bitbucket. Defaults to api", - default="api" + help=( + "Integration type of api, github, gitlab, azure, or bitbucket. " + "Defaults to api; --scm github/gitlab implies the matching integration " + "when this option is omitted" + ), + default=argparse.SUPPRESS ) integration_group.add_argument( "--owner", @@ -535,13 +554,17 @@ def create_argument_parser() -> argparse.ArgumentParser: "--pr-number", dest="pr_number", metavar="", - help="Pull request number", - default="0" + help=( + "Pull request number. Auto-detected in supported CI environments when omitted; " + "pass 0 explicitly to disable detection" + ), + default=argparse.SUPPRESS ) pr_group.add_argument( "--pr_number", dest="pr_number", - help=argparse.SUPPRESS + help=argparse.SUPPRESS, + default=argparse.SUPPRESS ) pr_group.add_argument( "--commit-message", @@ -703,6 +726,19 @@ def create_argument_parser() -> argparse.ArgumentParser: action="store_true", help="If true, the new scan will be set as the branch's head scan" ) + config_group.add_argument( + "--ignore-authorization", + dest="ignore_authorization", + choices=["enforce", "strict", "off"], + default="enforce", + help=( + "Who may suppress alerts with @SocketSecurity ignore comments. " + "'enforce' (default) requires write access, and honors the command with " + "a warning when the provider cannot report the commenter's access. " + "'strict' rejects the command in that case instead. " + "'off' honors a command from any commenter." + ) + ) config_group.add_argument( "--pending_head", dest="pending_head", diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index c5f3f52c..fe49bceb 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -1291,6 +1291,7 @@ def create_full_scan_with_report_url( diff.report_url = f"{base_socket}/{self.config.org_slug}/sbom/{new_full_scan.id}" diff.diff_url = diff.report_url diff.id = new_full_scan.id + diff.is_full_scan = True needs_alerts = ( self.cli_config is not None @@ -1300,30 +1301,43 @@ def create_full_scan_with_report_url( or self.cli_config.enable_sarif ) ) + # --generate-license (and --legal-format fossa, which it gates) enumerates + # diff.packages rather than the alert list, so a full scan has to carry the + # package map even when no alert-bearing output format is enabled. Without + # this, an SCM branch pipeline writes an attribution file with zero packages. + # Keep in sync with _requires_unchanged_artifacts, which lists the same + # consumers for the comparison path. + needs_license_artifacts = ( + self.cli_config is not None and self.cli_config.generate_license + ) - if needs_alerts: - log.info("Output format requires alerts, fetching SBOM data for full scan") + if needs_alerts or needs_license_artifacts: + log.info("Output format requires SBOM data, fetching it for the full scan") sbom_start = time.time() sbom_artifacts_dict = self.get_sbom_data(new_full_scan.id) sbom_artifacts = self.get_sbom_data_list(sbom_artifacts_dict) packages = self._create_packages_dict_without_license_text(sbom_artifacts) + if needs_license_artifacts: + packages = self._add_license_details(packages) diff.packages = packages - all_alerts_collection: Dict[str, List[Issue]] = {} - for package_id, package in packages.items(): - self.add_package_alerts_to_collection( - package=package, - alerts_collection=all_alerts_collection, - packages=packages - ) + if needs_alerts: + all_alerts_collection: Dict[str, List[Issue]] = {} + for package_id, package in packages.items(): + self.add_package_alerts_to_collection( + package=package, + alerts_collection=all_alerts_collection, + packages=packages + ) - consolidated: Set[str] = set() - for alert_key, alerts in all_alerts_collection.items(): - for alert in alerts: - alert_str = f"{alert.purl},{alert.type}" - if (alert.error or alert.warn) and alert_str not in consolidated: - diff.new_alerts.append(alert) - consolidated.add(alert_str) + consolidated: Set[str] = set() + for alert_key, alerts in all_alerts_collection.items(): + for alert in alerts: + alert_str = f"{alert.purl},{alert.type}" + if (alert.error or alert.warn) and alert_str not in consolidated: + diff.new_alerts.append(alert) + consolidated.add(alert_str) + diff.alerts_fetched = True sbom_end = time.time() log.info( @@ -1335,6 +1349,29 @@ def create_full_scan_with_report_url( return diff + def _add_license_details(self, packages: dict[str, Package]) -> dict[str, Package]: + """Populate licenseAttrib/licenseDetails on a full scan's package map. + + get_license_text_via_purl keys off ``ecosystem/name@version`` because that is + what the PURL endpoint echoes back, while a full scan's package map is keyed + by artifact id. Build a purl-keyed view over the same Package objects so the + enrichment lands on the map the caller keeps. + """ + batch_size = self.cli_config.max_purl_batch_size if self.cli_config else 5000 + packages_by_purl = {} + for package in packages.values(): + qualified_name = package.name + if package.namespace: + qualified_name = f"{package.namespace.strip('/')}/{qualified_name}" + packages_by_purl[ + f"{package.type}/{qualified_name}@{package.version}" + ] = package + self.get_license_text_via_purl( + packages_by_purl, + batch_size=batch_size, + ) + return packages + def get_full_scan(self, full_scan_id: str) -> FullScan: """ Get a FullScan object for an existing full scan including sbom_artifacts and packages. @@ -1884,6 +1921,9 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in for result in results: ecosystem = result["type"] name = result["name"] + namespace = (result.get("namespace") or "").strip("/") + if namespace and not name.startswith(f"{namespace}/"): + name = f"{namespace}/{name}" package_version = result["version"] licenseDetails = result.get("licenseDetails") licenseAttrib = result.get("licenseAttrib") @@ -1897,7 +1937,8 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in def get_diff_scan_artifacts( self, head_full_scan_id: str, - new_full_scan_id: str + new_full_scan_id: str, + external_href: Optional[str] = None ) -> DiffArtifacts: """Compare two full scans via the diff-scans endpoints, polling for the result. @@ -1920,6 +1961,8 @@ def get_diff_scan_artifacts( Args: head_full_scan_id: The before/base full scan ID new_full_scan_id: The after/head full scan ID + external_href: Optional pull request or merge request URL to associate + with the diff scan in the Socket Dashboard Returns: DiffArtifacts with the added/removed/unchanged/replaced/updated lists @@ -1929,6 +1972,13 @@ def get_diff_scan_artifacts( "after": new_full_scan_id, "description": f"Socket Security CLI v{__version__} scan comparison", } + if external_href: + create_params["external_href"] = external_href + # external_href is only honored while a diff scan is being created, + # so a re-run over an already-compared scan pair needs + # on_duplicate=update to apply the link to the existing resource. It + # answers 200 with the same {"diff_scan": ...} envelope as a create. + create_params["on_duplicate"] = "update" try: result = self.sdk.diffscans.create_from_ids(self.config.org_slug, create_params) diff_scan = result.get("diff_scan") or {} @@ -1937,11 +1987,13 @@ def get_diff_scan_artifacts( if error.status_code != 409: raise - # Do not use on_duplicate=redirect here. The SDK follows that 302 - # automatically with a GET that lacks cached=true, which can leave - # the connection idle while an existing diff scan is still computing. - # Resolve the duplicate resource explicitly so every result fetch - # continues through the bounded cached polling path below. + # Reached when there is no pull request context to attach, and on + # deployments that answer 409 regardless. Do NOT switch this to + # on_duplicate=redirect: the SDK follows that 302 automatically with + # a GET that lacks cached=true, which can leave the connection idle + # while an existing diff scan is still computing. Resolve the + # duplicate explicitly so every result fetch continues through the + # bounded cached polling path below. existing = self.sdk.diffscans.list( self.config.org_slug, params={ @@ -2077,7 +2129,8 @@ def get_added_and_removed_packages( self, head_full_scan_id: str, new_full_scan_id: str, - include_license_details: bool = False + include_license_details: bool = False, + external_href: Optional[str] = None ) -> Tuple[Dict[str, Package], Dict[str, Package], Dict[str, Package]]: """ Get packages that were added and removed between scans. @@ -2110,6 +2163,8 @@ def get_added_and_removed_packages( is retained as an explicit override seam, not wired to the ``--exclude-license-details`` user flag (which still governs the human-facing dashboard report URL). + external_href: Optional pull request or merge request URL to associate + with the primary diff-scan resource Returns: Tuple of (added_packages, removed_packages) dictionaries @@ -2121,7 +2176,8 @@ def get_added_and_removed_packages( try: diff_artifacts = self.get_diff_scan_artifacts( head_full_scan_id, - new_full_scan_id + new_full_scan_id, + external_href=external_href, ) except Exception as error: # SDK error messages can span many lines (path + response headers); the @@ -2237,7 +2293,8 @@ def create_new_diff( save_files_list_path: Optional[str] = None, save_manifest_tar_path: Optional[str] = None, base_paths: Optional[List[str]] = None, - explicit_files: Optional[List[str]] = None + explicit_files: Optional[List[str]] = None, + external_href: Optional[str] = None ) -> Diff: """Create a new diff using the Socket SDK. @@ -2249,6 +2306,8 @@ def create_new_diff( save_manifest_tar_path: Optional path to save manifest files tar.gz archive base_paths: List of base paths for the scan (optional) explicit_files: Optional list of explicit files to use instead of discovering files + external_href: Optional pull request or merge request URL to associate + with the diff scan """ log.debug(f"starting create_new_diff with no_change: {no_change}") if no_change: @@ -2383,7 +2442,8 @@ def create_new_diff( ) = self.get_added_and_removed_packages( head_full_scan_id, new_full_scan.id, - include_license_details=False + include_license_details=False, + external_href=external_href, ) # Separate unchanged packages from added/removed for --strict-blocking support @@ -2447,16 +2507,22 @@ def create_diff_report( alerts_in_removed_packages: Dict[str, List[Issue]] = {} alerts_in_unchanged_packages: Dict[str, List[Issue]] = {} - seen_new_packages = set() - seen_removed_packages = set() + seen_packages = { + "added": set(), + "updated": set(), + "removed": set(), + "replaced": set(), + } for package_id, package in added_packages.items(): purl = self.create_purl(package_id, added_packages) base_purl = f"{purl.ecosystem}/{purl.name}@{purl.version}" - if (not direct_only or package.direct) and base_purl not in seen_new_packages: - diff.new_packages.append(purl) - seen_new_packages.add(base_purl) + change_type = "updated" if package.diffType == "updated" else "added" + target = diff.updated_packages if change_type == "updated" else diff.new_packages + if (not direct_only or package.direct) and base_purl not in seen_packages[change_type]: + target.append(purl) + seen_packages[change_type].add(base_purl) self.add_package_alerts_to_collection( package=package, @@ -2468,9 +2534,11 @@ def create_diff_report( purl = self.create_purl(package_id, removed_packages) base_purl = f"{purl.ecosystem}/{purl.name}@{purl.version}" - if (not direct_only or package.direct) and base_purl not in seen_removed_packages: - diff.removed_packages.append(purl) - seen_removed_packages.add(base_purl) + change_type = "replaced" if package.diffType == "replaced" else "removed" + target = diff.replaced_packages if change_type == "replaced" else diff.removed_packages + if (not direct_only or package.direct) and base_purl not in seen_packages[change_type]: + target.append(purl) + seen_packages[change_type].add(base_purl) self.add_package_alerts_to_collection( package=package, @@ -2595,23 +2663,24 @@ def get_source_data(package: Package, packages: dict) -> list: @staticmethod def add_purl_capabilities(diff: Diff) -> None: """ - Adds capability information to each package in the diff's new_packages list. + Adds capability information to the diff's added and updated packages. + + Both lists are walked because an updated package is still newly present at + its new version, so its capabilities are as relevant as an added one's. Args: diff: Diff object to update with capability information """ - new_packages = [] - for purl in diff.new_packages: - if purl.id in diff.new_capabilities: - new_purl = Purl( - **{**purl.__dict__, - "capabilities": diff.new_capabilities[purl.id]} - ) - new_packages.append(new_purl) - else: - new_packages.append(purl) - - diff.new_packages = new_packages + for attribute in ("new_packages", "updated_packages"): + packages = [] + for purl in getattr(diff, attribute): + if purl.id in diff.new_capabilities: + purl = Purl( + **{**purl.__dict__, + "capabilities": diff.new_capabilities[purl.id]} + ) + packages.append(purl) + setattr(diff, attribute, packages) def add_package_alerts_to_collection(self, package: Package, alerts_collection: dict, packages: dict) -> dict: """ diff --git a/socketsecurity/core/alert_selection.py b/socketsecurity/core/alert_selection.py index ae5b4772..132be294 100644 --- a/socketsecurity/core/alert_selection.py +++ b/socketsecurity/core/alert_selection.py @@ -31,7 +31,9 @@ def clone_diff_with_selected_alerts(diff: Diff, selected_alerts: List[Issue]) -> removed_alerts=[], diff_url=getattr(diff, "diff_url", ""), new_packages=getattr(diff, "new_packages", []), + updated_packages=getattr(diff, "updated_packages", []), removed_packages=getattr(diff, "removed_packages", []), + replaced_packages=getattr(diff, "replaced_packages", []), packages=getattr(diff, "packages", {}), ) selected_diff.id = getattr(diff, "id", "") diff --git a/socketsecurity/core/classes.py b/socketsecurity/core/classes.py index 2a19c2bb..fd8fabcd 100644 --- a/socketsecurity/core/classes.py +++ b/socketsecurity/core/classes.py @@ -559,7 +559,9 @@ class Diff: """ new_packages: list[Purl] + updated_packages: list[Purl] removed_packages: list[Purl] + replaced_packages: list[Purl] packages: dict[str, Package] new_capabilities: Dict[str, List[str]] new_alerts: list[Issue] @@ -570,6 +572,8 @@ class Diff: report_url: str diff_url: str new_scan_id: str + is_full_scan: bool + alerts_fetched: bool def __init__(self, **kwargs): if kwargs: @@ -577,8 +581,12 @@ def __init__(self, **kwargs): setattr(self, key, value) if not hasattr(self, "new_packages"): self.new_packages = [] + if not hasattr(self, "updated_packages"): + self.updated_packages = [] if not hasattr(self, "removed_packages"): self.removed_packages = [] + if not hasattr(self, "replaced_packages"): + self.replaced_packages = [] if not hasattr(self, "new_alerts"): self.new_alerts = [] if not hasattr(self, "unchanged_alerts"): @@ -587,6 +595,10 @@ def __init__(self, **kwargs): self.removed_alerts = [] if not hasattr(self, "new_capabilities"): self.new_capabilities = {} + if not hasattr(self, "is_full_scan"): + self.is_full_scan = False + if not hasattr(self, "alerts_fetched"): + self.alerts_fetched = False def __str__(self): return json.dumps(self.__dict__) @@ -600,8 +612,10 @@ def to_dict(self) -> dict: """ return { "new_packages": [p.to_dict() for p in self.new_packages], + "updated_packages": [p.to_dict() for p in self.updated_packages], "new_capabilities": self.new_capabilities, "removed_packages": [p.to_dict() for p in self.removed_packages], + "replaced_packages": [p.to_dict() for p in self.replaced_packages], "new_alerts": [alert.__dict__ for alert in self.new_alerts], "unchanged_alerts": [alert.__dict__ for alert in self.unchanged_alerts] if hasattr(self, "unchanged_alerts") else [], "removed_alerts": [alert.__dict__ for alert in self.removed_alerts] if hasattr(self, "removed_alerts") else [], diff --git a/socketsecurity/core/cli_client.py b/socketsecurity/core/cli_client.py index 2e941e7a..405a7443 100644 --- a/socketsecurity/core/cli_client.py +++ b/socketsecurity/core/cli_client.py @@ -56,7 +56,12 @@ def request( except requests.exceptions.RequestException as e: logger.error(f"API request failed: {str(e)}") - raise APIFailure(f"Request failed: {str(e)}") + # Carry the status forward. Callers that need to react to a specific + # code -- the GitLab auth fallback to the other token scheme, and + # APIFailure.is_transient_error -- have no other way to recover it + # once the requests exception has been translated. + status_code = e.response.status_code if e.response is not None else None + raise APIFailure(f"Request failed: {str(e)}", status_code=status_code) from e def post_telemetry_events(self, org_slug: str, events: List[Dict]) -> None: """Post telemetry events one at a time to the v0 telemetry API. Fire-and-forget — logs errors but never raises.""" diff --git a/socketsecurity/core/exceptions.py b/socketsecurity/core/exceptions.py index 03e69b87..b2112ee0 100644 --- a/socketsecurity/core/exceptions.py +++ b/socketsecurity/core/exceptions.py @@ -1,3 +1,5 @@ +from socketdev.exceptions import APIFailure as SdkAPIFailure + __all__ = [ "APIFailure", "APIKeyMissing", @@ -18,8 +20,14 @@ class APIKeyMissing(Exception): pass -class APIFailure(Exception): - """Raised when there is an error using the API""" +class APIFailure(SdkAPIFailure): + """Raised when there is an error using the API. + + Subclasses the SDK's exception of the same name so a handler written against + either one catches both. A separate Exception subclass would bypass an + ``except APIFailure`` importing the SDK's -- which every handler in + socketsecurity.core does -- and would not carry the SDK class's status code. + """ pass @@ -39,4 +47,4 @@ class APIResourceNotFound(Exception): class RequestTimeoutExceeded(Exception): """Raised when access is denied to the API""" - pass \ No newline at end of file + pass diff --git a/socketsecurity/core/git_remote.py b/socketsecurity/core/git_remote.py new file mode 100644 index 00000000..eb5b9c02 --- /dev/null +++ b/socketsecurity/core/git_remote.py @@ -0,0 +1,43 @@ +"""Parsing for git remote URLs. + +CI systems that are not tied to a single SCM expose the checkout URL rather than +an ``owner/repo`` slug (Buildkite's ``BUILDKITE_REPO``, for example). Both the +GitHub comment adapter and pull request context resolution need to recover the +slug from it, so the parsing lives here rather than in either caller. +""" +import re +from typing import Optional, Tuple +from urllib.parse import urlparse + +# git@host:owner/repo - the scp-like syntax urlparse cannot handle. The negative +# lookahead keeps scheme-prefixed URLs (https://, ssh://) out of this case. +_SCP_LIKE_REMOTE = re.compile(r"^(?:[^@/]+@)?([^:/]+):(?!//)(.+)$") + + +def parse_git_remote(value: Optional[str]) -> Tuple[Optional[str], Optional[str]]: + """Split a git remote URL into its host and its repository path. + + Returns ``(host, path)``, or ``(None, None)`` when the value is not a usable + remote. The path is returned whole rather than as ``owner``/``repo`` because + GitLab projects can be nested under subgroups; callers that only want the + last two segments can split it themselves. ``host`` is ``None`` for a bare + ``owner/repo`` path, which carries no host to report. + """ + if not value: + return None, None + url = value.strip().rstrip("/") + if url.endswith(".git"): + url = url[:-4] + + match = _SCP_LIKE_REMOTE.match(url) + if match: + return match.group(1), match.group(2).strip("/") + + parsed = urlparse(url) + if parsed.scheme in ("http", "https", "ssh", "git") and parsed.hostname: + return parsed.hostname, parsed.path.strip("/") + + # A bare owner/repo path, with no scheme and nothing to infer a host from. + if "/" in url: + return None, url.strip("/") + return None, None diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index 14a6f829..4017a9f8 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -4,6 +4,7 @@ import re import uuid from datetime import datetime, timezone +from html import escape from pathlib import Path from mdutils import MdUtils @@ -15,6 +16,13 @@ class Messages: + @staticmethod + def get_patched_version(alert: Issue) -> str: + """Return the first patched version exposed by an alert, if any.""" + props = getattr(alert, "props", {}) or {} + value = props.get("firstPatchedVersionIdentifier") + return str(value) if value not in (None, "") else "" + @staticmethod def map_severity_to_sarif(severity: str) -> str: """ @@ -843,6 +851,37 @@ def inline_html_text(value) -> str: return "" return " ".join(str(value).split()) + @staticmethod + def html_text(value) -> str: + """Flatten a value onto one line and escape it for an HTML text node. + + Manifest paths and sources come from the customer's repository, so any PR + author controls them: a directory named ``![x](https://host/p.png)`` or + carrying a raw tag would otherwise render as that markup inside a comment + posted by a trusted integration. Alert text comes from the API and is + escaped for the same reason, since neither is markup the CLI authored. + """ + return escape(Messages.inline_html_text(value)) + + @staticmethod + def html_attr(value) -> str: + """Escape a value for an HTML attribute, quotes included. + + Used for href and src, where an unescaped quote closes the attribute and + everything after it is read as more attributes. + """ + return escape(Messages.inline_html_text(value), quote=True) + + @staticmethod + def comment_marker_text(value) -> str: + """Neutralize an HTML comment terminator inside a marker value. + + The alert markers carry the package name so the comment can be rewritten + later, and the parser reads them back verbatim -- so this cannot escape the + value, only stop it ending the comment early. + """ + return str(value or "").replace("-->", "-->").replace("
    @@ -963,39 +1002,48 @@ def security_comment_template(diff: Diff, config=None) -> str: severity_icon = Messages.get_severity_icon(alert.severity) action = "Block" if alert.error else "Warn" details_open = "" + patched_version = Messages.get_patched_version(alert) + patched_version_html = ( + "

    Patched version: " + f"{Messages.html_text(patched_version)}

    " + if patched_version else "" + ) # Generate proper manifest URL manifest_url = Messages.get_manifest_file_url(diff, alert.manifests, config) + pkg_label = Messages.html_text(f"{alert.pkg_name}@{alert.pkg_version}") + pkg_marker = Messages.comment_marker_text(f"{alert.pkg_name}@{alert.pkg_version}") # Generate a table row for each alert ignore_html = ( f"

    Mark as acceptable risk: To ignore this alert only in this pull request, reply with:
    " - f"@SocketSecurity ignore {alert.pkg_name}@{alert.pkg_version}
    " + f"@SocketSecurity ignore {Messages.html_text(alert.pkg_type)}/{pkg_label}
    " f"Or ignore all future alerts with:
    " f"@SocketSecurity ignore-all

    " ) if show_ignore else "" comment += f""" - + - + """ # Add license policy violation entries grouped by PURL @@ -1006,24 +1054,31 @@ def security_comment_template(diff: Diff, config=None) -> str: # Use orange diamond for license policy violations license_icon = "🔶" + license_label = Messages.html_text( + f"{first_alert.pkg_name}@{first_alert.pkg_version}" + ) + license_marker = Messages.comment_marker_text( + f"{first_alert.pkg_name}@{first_alert.pkg_version}" + ) + # Build license findings list license_findings = [] for alert in alerts: license_findings.append(alert.title) comment += f""" - + - + """ # Close table @@ -1246,6 +1301,22 @@ def create_remove_line(diff: Diff, md: MdUtils) -> MdUtils: md.new_line(removed_line) return md + # Change types the shared badge host publishes an image for. Removed and + # replaced have no artwork, so they fall back to a bold text label rather than + # rendering a broken image; added and updated render the available badges. + DIFF_BADGES = { + "Added": "diff-added.svg", + "Updated": "diff-updated.svg", + } + + @staticmethod + def get_diff_badge(change: str, package_url: str) -> str: + """Return the Dependency Overview cell marking how a package changed.""" + badge = Messages.DIFF_BADGES.get(change) + if not badge: + return f"**{change}**" + return f"[![{change}](https://github-app-statics.socket.dev/{badge})]({package_url})" + @staticmethod def create_added_table(diff: Diff, md: MdUtils) -> MdUtils: """ @@ -1267,51 +1338,58 @@ def create_added_table(diff: Diff, md: MdUtils) -> MdUtils: num_of_overview_columns = len(overview_table) count = 0 - for added in diff.new_packages: - added: Purl # Ensure `added` has scores and relevant attributes. - - package_url = f"[{added.purl}]({added.url})" - diff_badge = f"[![+](https://github-app-statics.socket.dev/diff-added.svg)]({added.url})" - - # Scores dynamically converted to badge URLs and linked - def score_to_badge(score): - score_percent = int(score * 100) # Convert to integer percentage - return f"[![{score_percent}](https://github-app-statics.socket.dev/score-{score_percent}.svg)]({added.url})" - - def get_score_for_badge(score_name: str) -> float: - scores = getattr(added, "scores", None) - if isinstance(scores, dict): - raw_score = scores.get(score_name) - else: - raw_score = getattr(scores, score_name, None) if scores is not None else None - - if raw_score is None: - return 1.0 - - score = float(raw_score) - if score > 1: - score = score / 100 - return max(0.0, min(score, 1.0)) - - # Generate badges for each score type - supply_chain_risk_badge = score_to_badge(get_score_for_badge("supplyChain")) - vulnerability_badge = score_to_badge(get_score_for_badge("vulnerability")) - quality_badge = score_to_badge(get_score_for_badge("quality")) - maintenance_badge = score_to_badge(get_score_for_badge("maintenance")) - license_badge = score_to_badge(get_score_for_badge("license")) - - # Add the row for this package - row = [ - diff_badge, - package_url, - supply_chain_risk_badge, - vulnerability_badge, - quality_badge, - maintenance_badge, - license_badge - ] - overview_table.extend(row) - count += 1 # Count total packages + changes = ( + ("Added", diff.new_packages), + ("Updated", diff.updated_packages), + ("Removed", diff.removed_packages), + ("Replaced", diff.replaced_packages), + ) + for change, packages in changes: + for package in packages: + package: Purl + + package_url = f"[{package.purl}]({package.url})" + diff_badge = Messages.get_diff_badge(change, package.url) + + # Scores dynamically converted to badge URLs and linked + def score_to_badge(score): + score_percent = int(score * 100) # Convert to integer percentage + return f"[![{score_percent}](https://github-app-statics.socket.dev/score-{score_percent}.svg)]({package.url})" + + def get_score_for_badge(score_name: str) -> float: + scores = getattr(package, "scores", None) + if isinstance(scores, dict): + raw_score = scores.get(score_name) + else: + raw_score = getattr(scores, score_name, None) if scores is not None else None + + if raw_score is None: + return 1.0 + + score = float(raw_score) + if score > 1: + score = score / 100 + return max(0.0, min(score, 1.0)) + + # Generate badges for each score type + supply_chain_risk_badge = score_to_badge(get_score_for_badge("supplyChain")) + vulnerability_badge = score_to_badge(get_score_for_badge("vulnerability")) + quality_badge = score_to_badge(get_score_for_badge("quality")) + maintenance_badge = score_to_badge(get_score_for_badge("maintenance")) + license_badge = score_to_badge(get_score_for_badge("license")) + + # Add the row for this package + row = [ + diff_badge, + package_url, + supply_chain_risk_badge, + vulnerability_badge, + quality_badge, + maintenance_badge, + license_badge + ] + overview_table.extend(row) + count += 1 # Calculate total rows for table num_of_overview_rows = count + 1 # Include header row @@ -1346,6 +1424,7 @@ def create_console_security_alert_table(diff: Diff) -> PrettyTable: [ "Alert", "Package", + "Patched Version", "url", "Introduced by", "Manifest File", @@ -1366,6 +1445,7 @@ def create_console_security_alert_table(diff: Diff) -> PrettyTable: row = [ alert.title, alert.purl, + Messages.get_patched_version(alert), alert.url, source_str, manifest_str, @@ -1381,8 +1461,11 @@ def create_sources(alert: Issue, style="md") -> tuple[str, str]: for source, manifest in alert.introduced_by: if style == "md": - add_str = f"
  • {manifest}
  • " - source_str = f"
  • {source}
  • " + # These land in rendered Markdown, where an unescaped path is read + # as markup. plain and raw are consumed by Slack, Jira and the + # console, which do not render HTML, so they stay verbatim. + add_str = f"
  • {Messages.html_text(manifest)}
  • " + source_str = f"
  • {Messages.html_text(source)}
  • " elif style == "plain": add_str = f"• {manifest}" source_str = f"• {source}" diff --git a/socketsecurity/core/pull_request.py b/socketsecurity/core/pull_request.py new file mode 100644 index 00000000..2dcf77f1 --- /dev/null +++ b/socketsecurity/core/pull_request.py @@ -0,0 +1,144 @@ +import re +from dataclasses import dataclass +from typing import Mapping, Optional +from urllib.parse import urlparse + +from socketsecurity.core.git_remote import parse_git_remote + + +@dataclass(frozen=True) +class PullRequestContext: + number: int = 0 + url: Optional[str] = None + + +def parse_pull_request_number(value) -> int: + """Coerce a configured or CI-supplied pull request number to a positive int. + + Anything that is not a positive integer means "no pull request", including the + literal ``false`` that Buildkite puts in ``BUILDKITE_PULL_REQUEST`` on non-PR + builds. Callers that hand the value on to a comment adapter should store this + result rather than the raw string, which is truthy. + """ + try: + parsed = int(value) + except (TypeError, ValueError): + return 0 + return parsed if parsed > 0 else 0 + + +def _http_url(value: Optional[str]) -> Optional[str]: + """Return ``value`` if it is an http(s) URL with a host, else ``None``. + + Every URL fragment read out of the CI environment goes through here before it + is composed into a link, because the result is sent to the API as a diff scan's + ``external_href``. Standard runners set these variables themselves, so this is + defense in depth rather than a live hole. + """ + if not value: + return None + url = value.strip().rstrip("/") + parsed = urlparse(url) + return url if parsed.scheme in ("http", "https") and parsed.netloc else None + + +def _repository_url(value: Optional[str]) -> Optional[str]: + if not value: + return None + url = value.strip().rstrip("/") + if url.endswith(".git"): + url = url[:-4] + return _http_url(url) + + +def _github_number(env: Mapping[str, str]) -> int: + number = parse_pull_request_number(env.get("PR_NUMBER")) + if number: + return number + match = re.match(r"^refs/pull/(\d+)/", env.get("GITHUB_REF", "")) + return parse_pull_request_number(match.group(1)) if match else 0 + + +def _github_url(number: int, repo: Optional[str], env: Mapping[str, str]) -> Optional[str]: + remote_host, remote_path = parse_git_remote(env.get("BUILDKITE_REPO")) + # config.repo is only ever a bare repository name, so it cannot produce a + # slug on its own; it is kept last for callers that pass a full owner/repo. + repository = env.get("GITHUB_REPOSITORY") or remote_path or repo + if not repository or "/" not in repository: + return None + server = ( + _http_url(env.get("GITHUB_SERVER_URL")) + or (_http_url(f"https://{remote_host}") if remote_host else None) + or "https://github.com" + ) + return f"{server}/{repository.strip('/')}/pull/{number}" + + +def _gitlab_url(number: int, repo: Optional[str], env: Mapping[str, str]) -> Optional[str]: + project_url = _repository_url(env.get("CI_PROJECT_URL")) + if not project_url: + remote_host, remote_path = parse_git_remote(env.get("BUILDKITE_REPO")) + project_path = env.get("CI_PROJECT_PATH") or remote_path or repo + server = ( + _http_url(env.get("CI_SERVER_URL")) + or (_http_url(f"https://{remote_host}") if remote_host else None) + ) + if server and project_path and "/" in project_path: + project_url = f"{server}/{project_path.strip('/')}" + return f"{project_url}/-/merge_requests/{number}" if project_url else None + + +def _azure_url(number: int, env: Mapping[str, str], github_pr: bool) -> Optional[str]: + repository_url = _repository_url( + env.get("BUILD_REPOSITORY_URI") or + env.get("SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI") + ) + if not repository_url: + return None + github_pr = github_pr or "github" in urlparse(repository_url).netloc.lower() + path = "pull" if github_pr else "pullrequest" + return f"{repository_url}/{path}/{number}" + + +def resolve_pull_request_context( + integration_type: str, + configured_number, + repo: Optional[str], + *, + configured_explicit: bool = False, + env: Optional[Mapping[str, str]] = None, +) -> PullRequestContext: + """Resolve PR metadata without making provider API calls. + + Explicit CLI/config values win, including an explicit zero used to disable + association. Otherwise the provider's standard CI environment is used. + """ + environment = env or {} + provider = str(integration_type or "api").lower() + number = parse_pull_request_number(configured_number) + + if not configured_explicit and not number: + if provider == "github": + number = _github_number(environment) + elif provider == "gitlab": + number = parse_pull_request_number(environment.get("CI_MERGE_REQUEST_IID")) + elif provider == "azure": + number = ( + parse_pull_request_number(environment.get("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER")) or + parse_pull_request_number(environment.get("SYSTEM_PULLREQUEST_PULLREQUESTID")) + ) + + if not number: + return PullRequestContext() + + if provider == "github": + url = _github_url(number, repo, environment) + elif provider == "gitlab": + url = _gitlab_url(number, repo, environment) + elif provider == "azure": + github_pr = bool(environment.get("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER")) + url = _azure_url(number, environment, github_pr) + else: + url = None + + return PullRequestContext(number=number, url=url) diff --git a/socketsecurity/core/scm/github.py b/socketsecurity/core/scm/github.py index 7504a46c..dcfa5607 100644 --- a/socketsecurity/core/scm/github.py +++ b/socketsecurity/core/scm/github.py @@ -1,7 +1,6 @@ import json import os import sys -import urllib.parse from dataclasses import dataclass from git import Optional @@ -9,6 +8,8 @@ from socketsecurity import USER_AGENT from socketsecurity.core import log from socketsecurity.core.classes import Comment +from socketsecurity.core.exceptions import APIFailure +from socketsecurity.core.git_remote import parse_git_remote from socketsecurity.core.scm_comments import Comments from socketsecurity.socketcli import CliClient @@ -38,24 +39,12 @@ class GithubConfig: @staticmethod def _repository_from_buildkite() -> tuple[str, str]: """Return ``(owner, repository)`` from Buildkite's Git repository URL.""" - repository_url = ( - # Comments and statuses belong to the pipeline/base repository, - # not a contributor's fork from BUILDKITE_PULL_REQUEST_REPO. - os.getenv("BUILDKITE_REPO") - or os.getenv("BUILDKITE_PULL_REQUEST_REPO") - or "" - ).strip() - if not repository_url: - return "", "" - - if "://" in repository_url: - repository_path = urllib.parse.urlparse(repository_url).path - elif ":" in repository_url: - # SCP-style SSH URL: git@github.com:owner/repository.git - repository_path = repository_url.split(":", 1)[1] - else: - repository_path = repository_url - parts = repository_path.strip("/").removesuffix(".git").split("/") + # Comments and statuses belong to the pipeline/base repository, not a + # contributor's fork from BUILDKITE_PULL_REQUEST_REPO. + _, repository_path = parse_git_remote( + os.getenv("BUILDKITE_REPO") or os.getenv("BUILDKITE_PULL_REQUEST_REPO") + ) + parts = repository_path.split("/") if repository_path else [] if len(parts) < 2: return "", "" return parts[-2], parts[-1] @@ -166,9 +155,21 @@ def from_env(cls, pr_number: Optional[str] = None) -> 'GithubConfig': class Github: - def __init__(self, client: CliClient, config: Optional[GithubConfig] = None): + WRITE_PERMISSIONS = frozenset({"write", "maintain", "admin"}) + + def __init__( + self, + client: CliClient, + config: Optional[GithubConfig] = None, + ignore_authorization: str = "enforce", + ): self.config = config or GithubConfig.from_env() self.client = client + self.ignore_authorization = ignore_authorization + # Permission is stable for the duration of one CLI run. Cache both + # positive and negative answers so several ignore comments by the same + # author do not each make an API request. + self._ignore_permission_cache: dict[str, Optional[bool]] = {} if not self.config.token: log.error("Unable to get Github API Token") @@ -236,7 +237,76 @@ def get_comments_for_pr(self) -> dict: else: log.error(raw_comments) - return Comments.check_for_socket_comments(comments) + gate = None if self.ignore_authorization == "off" else self.is_ignore_authorized + return Comments.check_for_socket_comments(comments, gate) + + def is_ignore_authorized(self, comment: Comment) -> bool: + """Whether a commenter may suppress alerts with @SocketSecurity ignore. + + ``author_association`` describes a social relationship to the repository, + not the author's role: an organization member or outside collaborator can + still have read-only access. Ask GitHub for the effective repository + permission instead, and cache the answer for subsequent comments. + """ + author = Comments.comment_author_name(comment) + if author == "an unknown user": + permission = None + elif author in self._ignore_permission_cache: + permission = self._ignore_permission_cache[author] + else: + path = ( + f"repos/{self.config.owner}/{self.config.repository}/" + f"collaborators/{author}/permission" + ) + try: + response = self.client.request( + path=path, + headers=self.config.headers, + base_url=self.config.api_url, + ) + result = response.json() + if not isinstance(result, dict) or not isinstance( + result.get("permission"), str + ): + log.warning("Unexpected GitHub repository permission response") + permission = None + else: + permission = ( + result["permission"].casefold() in self.WRITE_PERMISSIONS + ) + except APIFailure as error: + if getattr(error, "status_code", None) == 404: + # The repository was readable when its comments were listed, + # so a missing collaborator permission is a definitive denial. + permission = False + else: + log.warning( + "Could not read GitHub repository permission for " + f"{author}: {error}" + ) + permission = None + except Exception as error: + log.warning( + f"Could not read GitHub repository permission for {author}: {error}" + ) + permission = None + self._ignore_permission_cache[author] = permission + + if permission is not None: + return permission + if self.ignore_authorization == "strict": + log.warning( + f"Rejecting @SocketSecurity ignore from {author}: GitHub repository " + "permission could not be read and --ignore-authorization is strict." + ) + return False + log.warning( + f"Honoring @SocketSecurity ignore from {author} without verifying write " + "access: GitHub repository permission could not be read. Use a token " + "with repository metadata access, or --ignore-authorization strict to " + "reject instead." + ) + return True def add_socket_comments( self, diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index 2c3947de..d1fd1119 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -5,6 +5,7 @@ from typing import Optional import requests +from socketdev.exceptions import APIFailure from socketsecurity import USER_AGENT from socketsecurity.core import log @@ -126,37 +127,54 @@ def _get_auth_headers(token: str) -> dict: } class Gitlab: - def __init__(self, client: CliClient, config: Optional[GitlabConfig] = None): + # GitLab access levels: 30 Developer, 40 Maintainer, 50 Owner. Reporter (20) + # and Guest (10) cannot push, so they cannot suppress an alert either. + MIN_IGNORE_ACCESS_LEVEL = 30 + # Bounded so a project with a very large membership cannot stall a scan. Past + # the cap the answer is "undetermined", handled the same as a failed lookup. + MEMBER_PAGE_SIZE = 100 + MEMBER_PAGE_LIMIT = 10 + + def __init__( + self, + client: CliClient, + config: Optional[GitlabConfig] = None, + ignore_authorization: str = "enforce", + ): self.config = config or GitlabConfig.from_env() self.client = client + self.ignore_authorization = ignore_authorization + # None until the first ignore comment forces a lookup; stays None when the + # members API cannot be read, which is the "undetermined" state. + self._member_access: Optional[dict] = None + self._member_lookup_attempted = False def _request_with_fallback(self, **kwargs): - """ - Make a request with automatic fallback between Bearer and PRIVATE-TOKEN authentication. - This provides robustness when the initial token type detection is incorrect. + """Request with one retry under the other GitLab auth scheme on a 401. + + _get_auth_headers guesses between Bearer and PRIVATE-TOKEN from the shape of + the token, and the guess can be wrong for tokens that do not match a known + pattern. Rather than fail the run, try the other scheme once. + + Catches APIFailure, not requests.exceptions.HTTPError: CliClient translates + every requests error into APIFailure, which does not inherit from HTTPError, + so catching the latter here never fired and the fallback never ran. """ try: - # Try the initial request with the configured headers return self.client.request(**kwargs) - except requests.exceptions.HTTPError as e: - # Check if this is an authentication error (401) - if e.response and e.response.status_code == 401: - log.debug("Authentication failed with initial headers, trying fallback method") - - # Determine the fallback headers - original_headers = kwargs.get('headers', self.config.headers) - fallback_headers = self._get_fallback_headers(original_headers) - - if fallback_headers and fallback_headers != original_headers: - log.debug("Retrying request with fallback authentication method") - kwargs['headers'] = fallback_headers - return self.client.request(**kwargs) - - # Re-raise the original exception if it's not an auth error or fallback failed - raise - except Exception: - # Handle other types of exceptions that don't have response attribute - raise + except APIFailure as error: + if error.status_code != 401: + raise + + log.debug("Authentication failed with initial headers, trying fallback method") + original_headers = kwargs.get('headers', self.config.headers) + fallback_headers = self._get_fallback_headers(original_headers) + if not fallback_headers or fallback_headers == original_headers: + raise + + log.debug("Retrying request with fallback authentication method") + kwargs['headers'] = fallback_headers + return self.client.request(**kwargs) def _get_fallback_headers(self, original_headers: dict) -> dict: """ @@ -256,7 +274,88 @@ def get_comments_for_pr(self) -> dict: comment.body_list = comment.body.split("\n") else: log.error(raw_comments) - return Comments.check_for_socket_comments(comments) + gate = None if self.ignore_authorization == "off" else self.is_ignore_authorized + return Comments.check_for_socket_comments(comments, gate) + + def _load_member_access(self) -> Optional[dict]: + """Map project member user id -> access level, or None if unreadable. + + ``members/all`` is used rather than a per-user lookup because it answers + non-membership with a 200 and an absent id. CliClient collapses every HTTP + error into APIFailure without a status code, so a per-user 404 -- exactly + the outsider case this guards against -- would be indistinguishable from a + token that cannot read the endpoint, and would have to fail open. + """ + if self._member_lookup_attempted: + return self._member_access + self._member_lookup_attempted = True + if not self.config.mr_project_id: + return None + + access: dict = {} + for page in range(1, Gitlab.MEMBER_PAGE_LIMIT + 1): + path = ( + f"projects/{self.config.mr_project_id}/members/all" + f"?per_page={Gitlab.MEMBER_PAGE_SIZE}&page={page}" + ) + try: + response = self._request_with_fallback( + path=path, + headers=self.config.headers, + base_url=self.config.api_url + ) + members = response.json() + except Exception as error: + log.warning(f"Could not read GitLab project members: {error}") + return None + if not isinstance(members, list): + log.warning("Unexpected GitLab project members response") + return None + for member in members: + if isinstance(member, dict) and member.get("id") is not None: + access[member["id"]] = member.get("access_level") or 0 + if len(members) < Gitlab.MEMBER_PAGE_SIZE: + self._member_access = access + return access + + log.warning( + f"GitLab project has more than {Gitlab.MEMBER_PAGE_SIZE * Gitlab.MEMBER_PAGE_LIMIT} " + "members; cannot confirm ignore-command authorization" + ) + return None + + def is_ignore_authorized(self, comment: Comment) -> bool: + """Whether a commenter may suppress alerts with @SocketSecurity ignore. + + GitLab notes carry no permission field, so this costs one members lookup + per run (cached, and only when an ignore command is actually present). + + When membership can be read the answer is definitive. When it cannot -- a + CI_JOB_TOKEN generally cannot read the members API -- the command is + honored and a warning is logged, preserving compatibility for pipelines + that rely on ignore commands. Set a token with API read access to get + enforcement. + """ + access = self._load_member_access() + if access is None: + author = Comments.comment_author_name(comment) + if self.ignore_authorization == "strict": + log.warning( + f"Rejecting @SocketSecurity ignore from {author}: GitLab project " + "membership could not be read and --ignore-authorization is strict." + ) + return False + log.warning( + f"Honoring @SocketSecurity ignore from {author} without verifying " + "write access: GitLab project membership could not be read. Use a " + "token with API read access, or --ignore-authorization strict to " + "reject instead." + ) + return True + + author = getattr(comment, "author", None) or {} + user_id = author.get("id") + return access.get(user_id, 0) >= Gitlab.MIN_IGNORE_ACCESS_LEVEL def add_socket_comments( self, diff --git a/socketsecurity/core/scm_comments.py b/socketsecurity/core/scm_comments.py index 7c479b72..3ef0e3a6 100644 --- a/socketsecurity/core/scm_comments.py +++ b/socketsecurity/core/scm_comments.py @@ -1,5 +1,6 @@ import json import re +from typing import Callable, Optional from requests import Response @@ -11,6 +12,12 @@ class Comments: VIEW_REPORT_PATTERN = re.compile(r"\[View full report\]\(([^)\s]+)\)") + @staticmethod + def comment_author_name(comment: Comment) -> str: + """Best-effort display name for a comment author, across providers.""" + user = getattr(comment, "user", None) or getattr(comment, "author", None) or {} + return user.get("login") or user.get("username") or "an unknown user" + @staticmethod def process_response(response: Response) -> dict: output = {} @@ -37,10 +44,10 @@ def remove_alerts(comments: dict, new_alerts: list) -> list: if ignore_all: break else: - full_name = f"{alert.pkg_type}/{alert.pkg_name}" - purl = (full_name, alert.pkg_version) - purl_star = (full_name, "*") - if purl in ignore_commands or purl_star in ignore_commands: + if any( + Comments.is_ignore(alert.pkg_name, alert.pkg_version, name, version, alert.pkg_type) + for name, version in ignore_commands + ): log.info(f"Alerts for {alert.pkg_name}@{alert.pkg_version} ignored") else: log.info(f"Adding alert {alert.type} for {alert.pkg_name}@{alert.pkg_version}") @@ -66,8 +73,10 @@ def get_ignore_options(comments: dict) -> [bool, list]: ignore_all = True else: command = command.lstrip("ignore").strip() - name, version = command.split("@") - data = (name, version) + name, separator, version = command.rpartition("@") + if not separator or not name or not version: + raise ValueError("Expected package@version") + data = (name.strip(), version.strip()) ignore_commands.append(data) except Exception as error: log.error(f"Unable to process ignore command for {comment}") @@ -75,11 +84,30 @@ def get_ignore_options(comments: dict) -> [bool, list]: return ignore_all, ignore_commands @staticmethod - def is_ignore(pkg_name: str, pkg_version: str, name: str, version: str) -> bool: - result = False - if pkg_name == name and (pkg_version == version or version == "*"): - result = True - return result + def is_ignore( + pkg_name: str, pkg_version: str, name: str, version: str, + pkg_type: str = "" + ) -> bool: + """Match an alert's package against one parsed ignore command. + + Generated commands are ecosystem-qualified (``npm/lodash@4.17.21``) but + replies typed by hand, and commands written by older CLI versions, use the + bare package name, so both have to match. + + Callers that parse the package out of a ``start-socket-alert`` marker have no + pkg_type to compare against and instead strip the ecosystem off the command. + An npm scope looks the same as an ecosystem prefix there, so only strip when + the leading segment cannot be one: without the guard, + ``ignore @types/node@*`` would also silently ignore alerts for a package + literally named ``node``. + """ + package_names = {pkg_name} + if pkg_type: + package_names.add(f"{pkg_type}/{pkg_name}") + target_names = {name} + if not pkg_type and "/" in name and not name.startswith("@"): + target_names.add(name.split("/", 1)[1]) + return bool(package_names & target_names) and (pkg_version == version or version == "*") @staticmethod def is_heading_line(line) -> bool: @@ -112,6 +140,33 @@ def process_security_comment(comment: Comment, comments) -> str: return new_body + @staticmethod + def parse_alert_table_row(line: str) -> Optional[tuple[str, str, str]]: + """Pull ``(ecosystem, package, version)`` out of a legacy alert table row. + + Returns None for any row that does not have the expected shape rather than + raising. The row comes back from the provider's API, so its contents are + outside this process's control. Malformed cells must not interrupt status + reporting. A row that cannot be read is a row whose alert stays reported. + """ + cells = line.strip().lstrip("|").rstrip("|").split("|") + if len(cells) != 5: + return None + package = cells[1] + if "](" not in package: + return None + details = package.split("](", 1)[0].lstrip("[") + if "/" not in details: + return None + ecosystem, remainder = details.split("/", 1) + if "@" not in remainder: + return None + # Split from the right: a scoped name carries its own "@". + pkg_name, pkg_version = remainder.rsplit("@", 1) + if not pkg_name or not pkg_version: + return None + return ecosystem, pkg_name, pkg_version + @staticmethod def process_original_security_comment( comment: Comment, @@ -127,19 +182,21 @@ def process_original_security_comment( start = True lines.append(line) elif start and "end-socket-alerts-table" not in line and not Comments.is_heading_line(line) and line != '': - title, package, introduced_by, manifest, ci = line.lstrip("|").rstrip("|").split("|") - details, _ = package.split("](") - ecosystem, details = details.split("/", 1) - ecosystem = ecosystem.lstrip("[") - pkg_name, pkg_version = details.split("@") - pkg_name = f"{ecosystem}/{pkg_name}" + parsed = Comments.parse_alert_table_row(line) # ignore_all has to be checked outside the loop: an ignore-all # comment produces no ignore_commands, so a loop-internal check # never runs and every row was kept. - ignore = ignore_all or any( - Comments.is_ignore(pkg_name, pkg_version, name, version) - for name, version in ignore_commands - ) + if parsed is None: + # An unparseable row cannot be evaluated against the ignore + # commands, so keep it: leaving an alert reported is the safe + # direction, and the comment body is not ours to discard. + ignore = ignore_all + else: + ecosystem, pkg_name, pkg_version = parsed + ignore = ignore_all or any( + Comments.is_ignore(pkg_name, pkg_version, name, version, ecosystem) + for name, version in ignore_commands + ) if not ignore: kept_alert = True lines.append(line) @@ -187,7 +244,7 @@ def process_updated_security_comment( # Extract package name and version from the comment try: start_marker = stripped[len("" in body assert "" in body + def test_copy_is_provider_neutral(self): + body = Messages.security_comment_template( + _make_diff([_make_alert()]), _FakeConfig(scm="gitlab") + ) + assert "Socket for GitHub" not in body + assert "Learn more about [Socket]" in body + class TestSecurityCommentTemplateWithNoAlerts: def test_no_alerts_omits_the_empty_table(self): @@ -232,6 +241,23 @@ def test_ignoring_every_alert_individually_collapses_too(self): assert "No dependency alerts to report" in new_body + def test_qualified_scoped_package_ignore_matches_comment_marker(self): + security = _security_comment_with([ + _make_alert( + pkg_name="@socketsecurity/example", + purl="pkg:npm/@socketsecurity/example@4.17.21", + ) + ]) + comments = { + "security": security, + "ignore": [_make_comment( + "SocketSecurity ignore npm/@socketsecurity/example@4.17.21", + comment_id=2, + )], + } + + assert "No dependency alerts to report" in Comments.process_security_comment(security, comments) + def test_no_ignore_commands_leaves_alerts_in_place(self): security = self._two_alert_comment() comments = {"security": security, "ignore": []} @@ -265,6 +291,17 @@ def test_collapsed_body_is_stable_when_reprocessed(self): [View full report](https://socket.dev/report/legacy?action=error%2Cwarn) """ +SCOPED_LEGACY_COMMENT = """ + + +|Alert|Package|Introduced by|Manifest File|CI| +|:---|:---|:---|:---|:---| +|Known Malware|[npm/@socketsecurity/example@1.0.0](https://socket.dev/z)|example|package.json|:no_entry_sign:| + + +[View full report](https://socket.dev/report/legacy?action=error%2Cwarn) +""" + class TestProcessOriginalSecurityComment: def test_partial_ignore_keeps_remaining_row(self): @@ -292,6 +329,27 @@ def test_ignore_all_collapses_to_the_no_alerts_body(self): assert "No dependency alerts to report" in new_body assert "[View full report](https://socket.dev/report/legacy)" in new_body + def test_scoped_package_row_does_not_raise(self): + """A scoped name carries its own "@", so the split must come from the right.""" + security = _make_comment(SCOPED_LEGACY_COMMENT) + comments = {"security": security, "ignore": []} + + new_body = Comments.process_security_comment(security, comments) + + assert "npm/@socketsecurity/example@1.0.0" in new_body + + def test_scoped_package_row_is_ignorable_both_ways(self): + for command in ( + "SocketSecurity ignore npm/@socketsecurity/example@1.0.0", + "SocketSecurity ignore @socketsecurity/example@1.0.0", + ): + security = _make_comment(SCOPED_LEGACY_COMMENT) + comments = {"security": security, "ignore": [_make_comment(command, comment_id=2)]} + + new_body = Comments.process_security_comment(security, comments) + + assert "No dependency alerts to report" in new_body, command + class TestExtractReportUrl: def test_strips_the_action_filter(self): @@ -302,3 +360,146 @@ def test_strips_the_action_filter(self): def test_returns_empty_when_absent(self): assert Comments.extract_report_url("no link here") == "" + + +# --- Escaping repo-derived values --------------------------------------------- +# +# Manifest paths and sources are file paths inside the customer's repository, so +# anyone who can open a pull request controls them: a directory named +# `![x](https://host/p.png)` holding a manifest puts that markup into a comment +# posted by a trusted integration. GitHub and GitLab sanitize comment HTML, so the +# exposure is external resource loading, phishing links and content spoofing +# rather than script execution. + + +@dataclass +class _RepoConfig(_FakeConfig): + """A config that reaches the branch which embeds the path verbatim. + + Without repo/branch, get_manifest_file_url returns "" or a percent-encoded + Socket link, and the path never lands in the comment -- so a test using the + bare config asserts nothing. + """ + repo: str = "acme/widgets" + branch: str = "main" + + +HOSTILE_PATHS = { + "image": "![x](https://evil.example/p.png)/package.json", + "link": "[click me](https://evil.example)/package.json", + "raw_tag": "/package.json", + "backtick": "`code`/package.json", + "pipe": "a|b/package.json", + "quote": 'a" onmouseover="x/package.json', + "comment_close": "x-->y/package.json", +} + + +def _rendered_with_path(path: str) -> str: + return Messages.security_comment_template( + _make_diff([_make_alert(manifests=path)]), _RepoConfig() + ) + + +def test_the_hostile_path_actually_reaches_the_comment(): + """Guards the fixture itself: if the path stops being rendered, the escaping + tests below would pass while asserting nothing.""" + body = _rendered_with_path("sentinel-path/package.json") + + assert "sentinel-path" in body + + +@pytest.mark.parametrize("name,path", sorted(HOSTILE_PATHS.items())) +def test_hostile_manifest_path_cannot_introduce_markup(name, path): + """In the rendered comment the path only ever lands inside an href, where + Markdown is inert. The property that matters there is that the value cannot + open a tag or close the attribute -- see create_sources for the context where + Markdown itself is live.""" + body = _rendered_with_path(path) + + rendered = [ln for ln in body.split("\n") if "Manifest File" in ln][0] + value = rendered.split('href="', 1)[1].split('"', 1)[0] + + for char in ("<", ">", '"'): + assert char not in value, f"{char!r} survived into the href: {value!r}" + assert_html_block_intact(body) + + +def test_quote_in_a_path_cannot_escape_the_href(): + body = _rendered_with_path('a" onmouseover="x/package.json') + + assert """ in body + assert 'href="https://github.com/acme/widgets/blob/main/a" ' not in body + + +def test_hostile_package_name_cannot_close_the_alert_marker(): + body = Messages.security_comment_template( + _make_diff([_make_alert(pkg_name="evil-->x")]), _FakeConfig() + ) + + # Exactly the terminator the CLI wrote, and no stray one inside the value. + for line in body.split("\n"): + if "socket-alert-" in line: + assert line.count("-->") == 1, line + + +def test_alert_text_from_the_api_is_escaped(): + body = Messages.security_comment_template( + _make_diff([_make_alert(description="")]), _FakeConfig() + ) + + assert "
    {action} - {alert.severity} + {Messages.html_attr(alert.severity)}
    - {alert.pkg_name}@{alert.pkg_version} - {Messages.inline_html_text(alert.title)} -

    Note: {Messages.inline_html_text(alert.description)}

    -

    Source: Manifest File

    + {pkg_label} - {Messages.html_text(alert.title)} +

    Note: {Messages.html_text(alert.description)}

    + {patched_version_html} +

    Source: Manifest File

    ℹ️ Read more on: - This package | - This alert | + This package | + This alert | What is known malware?

    -

    Suggestion: {Messages.inline_html_text(alert.suggestion)}

    +

    Suggestion: {Messages.html_text(alert.suggestion)}

    {ignore_html}
    {action} {license_icon}
    - {first_alert.pkg_name}@{first_alert.pkg_version} has a License Policy Violation. + {license_label} has a License Policy Violation.

    License findings:

      """ for finding in license_findings: - comment += f"
    • {Messages.inline_html_text(finding)}
    • \n" + comment += f"
    • {Messages.html_text(finding)}
    • \n" # Generate proper manifest URL for license violations @@ -1031,13 +1086,13 @@ def security_comment_template(diff: Diff, config=None) -> str: license_ignore_html = ( f"

      Mark the package as acceptable risk: To ignore this alert only in this pull request, reply with the comment " - f"@SocketSecurity ignore {first_alert.pkg_name}@{first_alert.pkg_version}. " + f"@SocketSecurity ignore {Messages.html_text(first_alert.pkg_type)}/{license_label}. " f"You can also ignore all packages with @SocketSecurity ignore-all. " f"To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

      " ) if show_ignore else "" comment += f"""
    -

    From: Manifest File

    -

    ℹ️ Read more on: This package | What is a license policy violation?

    +

    From: Manifest File

    +

    ℹ️ Read more on: This package | What is a license policy violation?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: Find a package that does not violate your license policy or adjust your policy to allow this package's license.

    @@ -1046,7 +1101,7 @@ def security_comment_template(diff: Diff, config=None) -> str: