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..5434078 --- /dev/null +++ b/.github/workflows/core-tool-watch.yml @@ -0,0 +1,148 @@ +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). +# When the token 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:` 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 + + - 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..ccecd7c --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,335 @@ +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, 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) 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. +# +# 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. + +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. 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. + 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" + + # 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 + - docker-smoke-app-tests + if: always() + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - 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: | + 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 + + # 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..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.github.com/repos/{repo}/releases/latest", token) + return data.get("tag_name") + except Exception as exc: # noqa: BLE001 + print(f" ! GitHub latest-release lookup failed for {repo}: {exc}", file=sys.stderr) + return None + + +def _pypi_latest(package: str) -> Optional[str]: + try: + data = _get_json(f"https://pypi.org/pypi/{package}/json") + return data.get("info", {}).get("version") + except Exception as exc: # noqa: BLE001 + print(f" ! PyPI latest lookup failed for {package}: {exc}", file=sys.stderr) + return None + + +# โ”€โ”€ 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]]