diff --git a/.github/release.yml b/.github/release.yml index 8c5ea46..917137d 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -1,6 +1,4 @@ -# Configures GitHub's auto-generated release notes categories. -# Used by `gh release create --generate-notes` in publish-docker.yml, -# and visible in the GitHub "Generate release notes" UI when drafting releases. +# Configures categories for GitHub's manual "Generate release notes" UI. # https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes changelog: diff --git a/.github/workflows/core-tool-watch.yml b/.github/workflows/core-tool-watch.yml index 31ba618..7348cd3 100644 --- a/.github/workflows/core-tool-watch.yml +++ b/.github/workflows/core-tool-watch.yml @@ -1,46 +1,15 @@ 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. +# Watches pinned core tools for version drift and supply-chain findings. on: schedule: - # Mondays 07:00 UTC, after the weekly Dependabot run. - cron: "0 7 * * 1" workflow_dispatch: pull_request: paths: - "Dockerfile" + - "Dockerfile.heavy" - "app_tests/Dockerfile" - "pyproject.toml" - "uv.lock" @@ -50,6 +19,7 @@ on: branches: [main] paths: - "Dockerfile" + - "Dockerfile.heavy" - "app_tests/Dockerfile" - "pyproject.toml" - "uv.lock" @@ -60,9 +30,6 @@ 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 @@ -70,28 +37,19 @@ 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 + issues: write + packages: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout scan environment + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main path: .scan-env @@ -103,11 +61,7 @@ jobs: 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. + - name: 🛠️ Sync scan environment run: | python -m pip install --upgrade pip uv uv sync --locked --project .scan-env --no-install-project @@ -117,25 +71,21 @@ jobs: 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 + if [ "$EVENT" = "pull_request" ]; then echo "mode=build" >> "$GITHUB_OUTPUT" + else + echo "mode=watch" >> "$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 }} + MODE: ${{ steps.mode.outputs.mode }} run: | uv run --project .scan-env --no-sync python scripts/check_core_tools.py \ - --mode "${{ steps.mode.outputs.mode }}" \ + --mode "$MODE" \ --summary-file core-tools-report.md \ --json-out core-tools-report.json \ --github-output "$GITHUB_OUTPUT" \ @@ -159,28 +109,57 @@ jobs: 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' }} + - name: Reconcile drift tracking issue + if: ${{ always() && steps.mode.outputs.mode == 'watch' }} env: GH_TOKEN: ${{ github.token }} + DRIFT: ${{ steps.scan.outputs.drift }} + DISCOVERY_COMPLETE: ${{ steps.scan.outputs.discovery_complete }} run: | + if [ ! -s core-tools-report.md ] \ + || { [ "$DRIFT" != "true" ] && [ "$DRIFT" != "false" ]; } \ + || { [ "$DISCOVERY_COMPLETE" != "true" ] && [ "$DISCOVERY_COMPLETE" != "false" ]; }; then + echo "::warning::Skipping issue reconciliation because the scan did not produce a complete report." + exit 0 + fi + 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)" + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + existing="$(gh issue list --label core-tool-drift --state open --limit 1 \ + --json number --jq '.[0].number // empty')" + if [ -z "$existing" ]; then + existing="$(gh issue list --label core-tool-drift --state closed --limit 1 \ + --json number --jq '.[0].number // empty')" + fi - if [ -n "$existing" ]; then + if [ "$DRIFT" = "true" ] && [ -n "$existing" ]; then + state="$(gh issue view "$existing" --json state --jq '.state')" 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 + if [ "$state" = "CLOSED" ]; then + gh issue reopen "$existing" \ + --comment "Drift re-detected by [run #${GITHUB_RUN_ID}](${run_url}); body updated." + else + gh issue comment "$existing" \ + --body "Drift re-detected by [run #${GITHUB_RUN_ID}](${run_url}); body updated." + fi + elif [ "$DRIFT" = "true" ]; then gh issue create \ --title "$title" \ --label core-tool-drift \ --body-file core-tools-report.md + elif [ "$DISCOVERY_COMPLETE" = "true" ] && [ -n "$existing" ]; then + state="$(gh issue view "$existing" --json state --jq '.state')" + gh issue edit "$existing" --body-file core-tools-report.md + if [ "$state" = "OPEN" ]; then + gh issue close "$existing" \ + --comment "No core tool version drift remains as of [run #${GITHUB_RUN_ID}](${run_url}); body updated with the reconciled pins." + fi + elif [ -n "$existing" ]; then + gh issue edit "$existing" --body-file core-tools-report.md + gh issue comment "$existing" \ + --body "[Run #${GITHUB_RUN_ID}](${run_url}) refreshed the report, but latest-version discovery was incomplete; issue state was left unchanged." fi diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 1b46f20..d3d3741 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -1,26 +1,6 @@ name: publish-docker -# Builds, tests, and publishes multi-arch socket-basics image variants -# (linux/amd64 + linux/arm64) to GHCR and Docker Hub. -# -# Flow: -# resolve-version -# → build-test-push (matrix: image variant + native arch, pushes by digest) -# → merge-manifests (assembles per-image per-arch digests into manifest lists) -# → create-release (tag pushes only) -# -# Tag convention: -# v2.0.0 — immutable exact release (floating major tags intentionally not published) -# All image variants publish to the single socket-basics repository per -# registry, distinguished by tag suffix: 2.0.0 (main), 2.0.0-heavy (heavy). -# See docs/github-action.md → "Pinning strategies" for the full rationale. -# -# Required secrets — scoped to the `publish` environment (deployment policy: -# branch `main` + tags `v*`), not repo-level: -# DOCKERHUB_USERNAME — Docker Hub account name (also the registry namespace) -# DOCKERHUB_TOKEN — Docker Hub access token (read/write) -# Publishing jobs bind `environment: publish` to resolve them; the reusable -# pipeline binds it only in push mode (see _docker-pipeline.yml). +# Publishes multi-architecture images to GHCR and Docker Hub. on: push: @@ -29,21 +9,18 @@ on: workflow_dispatch: inputs: tag: - description: "Full git tag to publish (e.g. v2.0.3 or 2.0.3). Must exist in the repo." + description: "Existing release tag to publish." required: true -# Default: deny everything. Each job below grants only what it needs. permissions: contents: read concurrency: group: publish-docker-${{ github.ref }} - cancel-in-progress: false # never cancel an in-flight publish + cancel-in-progress: false jobs: - # ── Job 1: Resolve version ───────────────────────────────────────────────── - # Computes a clean semver string (no v prefix) consumed by downstream jobs. resolve-version: runs-on: ubuntu-latest outputs: @@ -59,11 +36,11 @@ jobs: REPO_URL: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git run: | if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - RAW="${INPUT_TAG#refs/tags/}" # full tag as provided (e.g. 2.0.3 or v2.0.3) + RAW="${INPUT_TAG#refs/tags/}" else - RAW="$REF_NAME" # e.g. v2.0.3 + RAW="$REF_NAME" fi - CLEAN="${RAW#v}" # strip leading v if present → 2.0.3 + CLEAN="${RAW#v}" if [[ ! "$CLEAN" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Invalid release tag: $CLEAN" >&2 exit 1 @@ -92,10 +69,6 @@ jobs: ref: ${{ steps.version.outputs.ref }} persist-credentials: false - # Guard: the release source must agree with the tag. The pre-commit - # version-check hook was removed in #46 with no automated replacement, - # which let v2.1.0 ship with version files still at 2.0.3. This is the - # release-time gate: mismatches fail here, before anything is built. - name: 🔎 Verify source versions match release tag env: VERSION: ${{ steps.version.outputs.clean }} @@ -118,16 +91,12 @@ jobs: fi echo "✅ version.py, pyproject.toml, and action.yml all agree on $VERSION" - # ── Job 2: Build → test → push by digest (per image + arch) ──────────────── - # Each matrix entry runs the full build/smoke/integration pipeline on a - # native runner for its target arch and pushes the resulting image by digest - # to both registries. The digest is exported as an artifact for the merge job. build-test-push: name: publish (${{ matrix.image }}, ${{ matrix.arch }}) needs: resolve-version permissions: contents: read - packages: write # push images to GHCR + packages: write strategy: fail-fast: false matrix: @@ -152,9 +121,6 @@ jobs: check_set: heavy arch: arm64 runs_on: ubuntu-24.04-arm - # zizmor: ignore[secrets-inherit] — required: environment-scoped secrets - # cannot be passed via an explicit workflow-call mapping (they only resolve - # on the environment-bound job inside the called same-repo workflow). uses: ./.github/workflows/_docker-pipeline.yml # zizmor: ignore[secrets-inherit] with: name: ${{ matrix.image }} @@ -163,39 +129,23 @@ jobs: check_set: ${{ matrix.check_set }} runs_on: ${{ matrix.runs_on }} arch_label: ${{ matrix.arch }} - # All variants publish to the single socket-basics repository on each - # registry; variants are distinguished by tag suffix (e.g. -heavy), never - # by a separate repository. push_name: socket-basics push: true version: ${{ needs.resolve-version.outputs.version }} ref: ${{ needs.resolve-version.outputs.ref }} - # Environment secrets can't be passed from a workflow-call job (no - # `environment:` allowed here); inherit lets the reusable workflow's - # environment-bound job resolve them itself. secrets: inherit - # ── Job 3: Merge per-arch digests into a multi-arch manifest list ────────── - # Tags: exact immutable version (X.Y.Z / X.Y.Z-heavy) plus the floating - # `latest` / `latest-heavy` convenience aliases. Version tags are immutable - # registry-side (Docker Hub immutable-tag rule: ^\d+\.\d+\.\d+(-heavy)?$); - # `latest` deliberately floats. Consumers needing reproducibility should pin - # the exact version or digest (docs/github-action.md → "Pinning strategies"). - # Floating MAJOR tags (v2 → latest v2.x.y) remain intentionally omitted. merge-manifests: name: merge-manifests (${{ matrix.variant }}) needs: [resolve-version, build-test-push] permissions: contents: read packages: write - environment: publish # Docker Hub secrets are environment-scoped + environment: publish runs-on: ubuntu-latest strategy: fail-fast: false matrix: - # Both variants live in the single socket-basics repository per registry, - # distinguished by tag suffix (2.2.0 vs 2.2.0-heavy). `variant` selects - # the per-arch digest artifacts produced by build-test-push. include: - variant: socket-basics tag_suffix: "" @@ -232,22 +182,11 @@ jobs: images: | ghcr.io/socketdev/socket-basics ${{ secrets.DOCKERHUB_USERNAME }}/socket-basics - # `latest` (and `latest-heavy` via the variant suffix) float to the - # newest release; exact version tags stay immutable registry-side. - # The variant suffix yields X.Y.Z for the main image, X.Y.Z-heavy for heavy. flavor: | latest=true - # onlatest applies the variant suffix to the latest alias too — - # without it both variants would publish a bare, racing `latest` - # and `latest-heavy` would never exist. suffix=${{ matrix.tag_suffix }},onlatest=true tags: | - # Tag push (vX.Y.Z) → exact immutable version tag + latest alias. type=semver,pattern={{version}} - # workflow_dispatch re-publish → use the version input directly. - # NOTE: re-pushing an already-published version tag is rejected by - # the registry's immutable-tag rule — dispatch mode is for recovery - # when tags never landed (it also repoints `latest`). type=raw,value=${{ needs.resolve-version.outputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }} - name: 🧬 Create multi-arch manifest list @@ -266,8 +205,6 @@ jobs: ls -la exit 1 fi - # Each by-digest push from the matrix step landed blobs in BOTH - # registries, so per-registry imagetools-create only writes manifests. for image in "$GHCR_IMAGE" "$DH_IMAGE"; do tag_args=() while IFS= read -r tag; do @@ -307,27 +244,3 @@ jobs: fi done done - - # ── Job 4: Create GitHub release ─────────────────────────────────────────── - # Runs once after the manifest is published (not for workflow_dispatch - # re-publishes — those don't create new releases). - # Generates categorised release notes from merged PR labels (.github/release.yml). - # CHANGELOG updates are intentionally human-authored in the release PR so this - # workflow never needs to push commits to the protected default branch. - create-release: - needs: [resolve-version, merge-manifests] - if: github.ref_type == 'tag' - permissions: - contents: write # create GitHub release - runs-on: ubuntu-latest - steps: - - name: 📝 Create GitHub release with auto-generated notes - env: - GH_TOKEN: ${{ github.token }} - REF_NAME: ${{ github.ref_name }} - run: | - gh release create "$REF_NAME" \ - --title "$REF_NAME" \ - --generate-notes \ - --verify-tag \ - || echo "Release already exists (re-run scenario) — skipping creation" diff --git a/Dockerfile b/Dockerfile index 9447fa4..337d515 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ ARG UV_VERSION=0.12.1 # # NOT Dependabot-trackable (no official Docker image with a stable binary path): ARG OPENGREP_VERSION=v1.26.0 +ARG SOCKET_NPM_CLI_VERSION=1.1.154 # # NOT Dependabot-trackable — Socket-built Trivy, rebuilt from unmodified upstream # source and published by Socket's own release pipeline. Pinned by digest; both @@ -61,8 +62,9 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ curl git wget ca-certificates RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y nodejs +ARG SOCKET_NPM_CLI_VERSION RUN --mount=type=cache,target=/root/.npm \ - npm install -g socket + npm install -g "socket@${SOCKET_NPM_CLI_VERSION}" # Python project files COPY socket_basics /socket-basics/socket_basics @@ -81,6 +83,7 @@ ARG BUILD_DATE=unknown ARG TRIVY_VERSION ARG TRUFFLEHOG_VERSION ARG OPENGREP_VERSION +ARG SOCKET_NPM_CLI_VERSION LABEL org.opencontainers.image.title="Socket Basics" \ org.opencontainers.image.source="https://github.com/SocketDev/socket-basics" \ org.opencontainers.image.version="${SOCKET_BASICS_VERSION}" \ @@ -88,7 +91,8 @@ LABEL org.opencontainers.image.title="Socket Basics" \ org.opencontainers.image.revision="${VCS_REF}" \ com.socket.trivy-version="${TRIVY_VERSION}" \ com.socket.trufflehog-version="${TRUFFLEHOG_VERSION}" \ - com.socket.opengrep-version="${OPENGREP_VERSION}" + com.socket.opengrep-version="${OPENGREP_VERSION}" \ + com.socket.npm-cli-version="${SOCKET_NPM_CLI_VERSION}" ENV PATH="/socket-basics/.venv/bin:/root/.opengrep/cli/latest:/usr/local/bin:$PATH" diff --git a/Dockerfile.heavy b/Dockerfile.heavy index a60d1db..9f76e44 100644 --- a/Dockerfile.heavy +++ b/Dockerfile.heavy @@ -3,7 +3,8 @@ ARG PYTHON_VERSION=3.12 ARG TRUFFLEHOG_VERSION=3.96.0 ARG UV_VERSION=0.12.1 ARG OPENGREP_VERSION=v1.26.0 -ARG SOCKET_CLI_VERSION=2.6.3 +ARG SOCKET_NPM_CLI_VERSION=1.1.154 +ARG SOCKET_PYTHON_CLI_VERSION=2.6.3 # Socket-built Trivy, pinned by digest — see the note in ./Dockerfile. ARG TRIVY_IMAGE=ghcr.io/socketdev/trivy:0.73.0@sha256:e3d9d5f10250cb73b0ea9446ae1191c0f2da2f5e6173eac08a840b1812f02e0b @@ -35,18 +36,19 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ curl git wget ca-certificates RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y nodejs +ARG SOCKET_NPM_CLI_VERSION RUN --mount=type=cache,target=/root/.npm \ - npm install -g socket + npm install -g "socket@${SOCKET_NPM_CLI_VERSION}" COPY socket_basics /socket-basics/socket_basics COPY pyproject.toml README.md LICENSE uv.lock /socket-basics/ ENV UV_LINK_MODE=copy -ARG SOCKET_CLI_VERSION +ARG SOCKET_PYTHON_CLI_VERSION RUN --mount=type=cache,target=/root/.cache/uv \ pip install -e . \ && uv sync --frozen --no-dev \ - && pip install --no-cache-dir "socketsecurity==${SOCKET_CLI_VERSION}" + && pip install --no-cache-dir "socketsecurity==${SOCKET_PYTHON_CLI_VERSION}" COPY scripts/docker-heavy-entrypoint.sh /usr/local/bin/docker-heavy-entrypoint.sh RUN chmod +x /usr/local/bin/docker-heavy-entrypoint.sh @@ -56,12 +58,15 @@ ARG VCS_REF=unknown ARG BUILD_DATE=unknown ARG TRUFFLEHOG_VERSION ARG OPENGREP_VERSION +ARG SOCKET_NPM_CLI_VERSION +ARG SOCKET_PYTHON_CLI_VERSION LABEL org.opencontainers.image.title="Socket Basics Heavy" \ org.opencontainers.image.source="https://github.com/SocketDev/socket-basics" \ org.opencontainers.image.version="${SOCKET_BASICS_VERSION}" \ org.opencontainers.image.created="${BUILD_DATE}" \ org.opencontainers.image.revision="${VCS_REF}" \ - com.socket.cli-version="${SOCKET_CLI_VERSION}" \ + com.socket.python-cli-version="${SOCKET_PYTHON_CLI_VERSION}" \ + com.socket.npm-cli-version="${SOCKET_NPM_CLI_VERSION}" \ com.socket.trufflehog-version="${TRUFFLEHOG_VERSION}" \ com.socket.opengrep-version="${OPENGREP_VERSION}" diff --git a/app_tests/Dockerfile b/app_tests/Dockerfile index 4146998..04d0be7 100644 --- a/app_tests/Dockerfile +++ b/app_tests/Dockerfile @@ -12,6 +12,8 @@ ARG UV_VERSION=0.12.1 # NOT Dependabot-trackable (no official Docker image with a stable binary path): ARG GOSEC_VERSION=v2.28.0 ARG OPENGREP_VERSION=v1.26.0 +ARG SOCKET_NPM_CLI_VERSION=1.1.154 +ARG SOCKET_PYTHON_CLI_VERSION=2.6.3 # # NOT Dependabot-trackable — Socket-built Trivy, pinned by digest; updated by # Socket's trivy-dist release process. See the note in the root ./Dockerfile. @@ -83,18 +85,22 @@ RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ && ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx # System deps + ESLint + Socket CLI (npm now available from node stage above) +ARG SOCKET_NPM_CLI_VERSION RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update && apt-get install -y --no-install-recommends \ curl git wget ca-certificates libatomic1 RUN --mount=type=cache,target=/root/.npm \ npm install -g eslint eslint-plugin-security \ - @typescript-eslint/parser @typescript-eslint/eslint-plugin socket + @typescript-eslint/parser @typescript-eslint/eslint-plugin \ + "socket@${SOCKET_NPM_CLI_VERSION}" # Bandit + socketsecurity via uv ENV UV_LINK_MODE=copy +ARG SOCKET_PYTHON_CLI_VERSION RUN --mount=type=cache,target=/root/.cache/uv \ - uv tool install bandit && uv tool install socketsecurity + uv tool install bandit \ + && uv tool install "socketsecurity==${SOCKET_PYTHON_CLI_VERSION}" ENV PATH="/root/.local/bin:$PATH" # NOTE: the legacy socket-security-tools runner (src/, entrypoint.sh) predates diff --git a/scripts/check_core_tools.py b/scripts/check_core_tools.py index b9ab002..23e7aeb 100644 --- a/scripts/check_core_tools.py +++ b/scripts/check_core_tools.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 -"""Supply-chain watch for the four core OSS tools bundled by Socket Basics. +"""Supply-chain watch for the 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 +Socket Basics is a thin orchestration layer over several security tools. +Several ship as binaries / container images / GitHub releases that Dependabot +cannot cleanly track. This script closes that gap: it discovers the latest 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 @@ -13,8 +12,10 @@ 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 + - trivy (container scanner) pin: Dockerfile ARG TRIVY_IMAGE + - socket_sdk (Socket Python SDK) pin: uv.lock / pyproject.toml + - socket_python_cli pin: Dockerfile ARG SOCKET_PYTHON_CLI_VERSION + - socket_npm_cli pin: Dockerfile ARG SOCKET_NPM_CLI_VERSION Two modes (the caller picks via flags): @@ -60,9 +61,13 @@ 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"] +# The three published/test images pin core tools independently, so scoring must +# cover every version pinned across all of them. +DOCKERFILES = [ + REPO_ROOT / "Dockerfile", + REPO_ROOT / "Dockerfile.heavy", + REPO_ROOT / "app_tests" / "Dockerfile", +] UV_LOCK = REPO_ROOT / "uv.lock" # Alert types treated as fail-worthy on a pinned version: outright compromise @@ -169,6 +174,46 @@ def _pypi_latest(package: str) -> Optional[str]: return None +def _npm_latest(package: str) -> Optional[str]: + try: + data = _get_json(f"https://registry.npmjs.org/{package}/latest") + return data.get("version") + except Exception as exc: # noqa: BLE001 + print(f" ! npm latest lookup failed for {package}: {exc}", file=sys.stderr) + return None + + +def _ghcr_latest(org: str, package: str) -> Optional[str]: + """Newest stable semver tag on an organization-owned GHCR package.""" + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if not token: + print( + f" ! GHCR latest-version lookup skipped for {org}/{package}: no GitHub token", + file=sys.stderr, + ) + return None + try: + versions = _get_json( + f"https://api.github.com/orgs/{org}/packages/container/{package}/versions" + "?per_page=100", + token, + ) + tags = [ + tag + for version in versions + for tag in version.get("metadata", {}).get("container", {}).get("tags", []) + ] + stable_versions = [] + for tag in tags: + match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", tag) + if match: + stable_versions.append((tuple(map(int, match.groups())), tag)) + return max(stable_versions)[1] if stable_versions else None + except Exception as exc: # noqa: BLE001 + print(f" ! GHCR latest-version lookup failed for {org}/{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) @@ -197,6 +242,28 @@ def _read_dockerfile_args(name: str) -> list[str]: return versions +def _read_docker_image_versions(name: str) -> list[str]: + """Read image tag versions from a digest-pinned Dockerfile ARG. + + Trivy's actual build input is TRIVY_IMAGE. Reading its tag instead of the + informational TRIVY_VERSION label pin prevents the watcher from blessing a + stale or mismatched Socket-built image. + """ + versions: list[str] = [] + for dockerfile in DOCKERFILES: + if not dockerfile.exists(): + continue + match = re.search(rf"^ARG\s+{re.escape(name)}=(.+)$", dockerfile.read_text(), re.MULTILINE) + if not match: + continue + image = match.group(1).strip() + without_digest = image.split("@", 1)[0] + tag = without_digest.rsplit(":", 1)[1] if ":" in without_digest else "" + if tag and tag not in versions: + versions.append(tag) + 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(): @@ -254,18 +321,39 @@ def build_tools() -> list[Tool]: ), Tool( key="trivy", - label="Trivy (container scanner)", - read_pinned=lambda: _read_dockerfile_args("TRIVY_VERSION"), - discover_latest=lambda: _github_latest_release("aquasecurity/trivy"), + label="Trivy (Socket trivy-dist)", + read_pinned=lambda: _read_docker_image_versions("TRIVY_IMAGE"), + discover_latest=lambda: _ghcr_latest("SocketDev", "trivy"), + # The Socket distribution is rebuilt from unmodified upstream + # source. Score that Go module while release discovery follows the + # Socket-controlled artifact that Basics actually consumes. purl=lambda v: f"pkg:golang/github.com/aquasecurity/trivy@{_ensure_v(v)}", + note="Release drift follows the Socket-built ghcr.io/socketdev/trivy package " + "(produced by SocketDev/trivy-dist and mirrored privately to Docker Hub), not " + "Aqua's release feed. Socket scoring uses the corresponding upstream Go module " + "because trivy-dist rebuilds that source without modification.", ), Tool( - key="socketdev", - label="Socket SCA (socketdev SDK)", + key="socket_sdk", + label="Socket SDK (socket-sdk-python)", read_pinned=lambda: _read_locked_versions("socketdev"), discover_latest=lambda: _pypi_latest("socketdev"), purl=lambda v: f"pkg:pypi/socketdev@{_strip_v(v)}", ), + Tool( + key="socket_python_cli", + label="Socket Python CLI (socket-python-cli)", + read_pinned=lambda: _read_dockerfile_args("SOCKET_PYTHON_CLI_VERSION"), + discover_latest=lambda: _pypi_latest("socketsecurity"), + purl=lambda v: f"pkg:pypi/socketsecurity@{_strip_v(v)}", + ), + Tool( + key="socket_npm_cli", + label="Socket npm CLI (socket-cli)", + read_pinned=lambda: _read_dockerfile_args("SOCKET_NPM_CLI_VERSION"), + discover_latest=lambda: _npm_latest("socket"), + purl=lambda v: f"pkg:npm/socket@{_strip_v(v)}", + ), ] @@ -321,14 +409,17 @@ def analyze_purls(purls: list[str], token: str) -> dict[str, dict[str, Any]]: # pendingScan/notFound rows instead of dropping them. These are first-class # typed params as of socketdev 3.4.2 (previously passed as stringly-typed # query-string kwargs); see CE-360. - results = client.purl.post( - license="false", - components=components, - poll=True, - timeout_sec=120, - alerts=True, - **kwargs, - ) or [] + results = ( + client.purl.post( + license="false", + components=components, + poll=True, + timeout_sec=120, + alerts=True, + **kwargs, + ) + or [] + ) if not results: raise RuntimeError( f"Socket purl API returned no results for {len(purls)} PURLs " @@ -394,9 +485,14 @@ def _match_analysis(analyses: dict[str, dict[str, Any]], purl: str) -> dict[str, # ── report rendering ──────────────────────────────────────────────────────── -def render_markdown(tools: list[Tool], token_present: bool) -> str: +def render_markdown(tools: list[Tool], token_present: bool, discovery_complete: bool = True) -> str: lines: list[str] = [] lines.append("## Core tool supply-chain watch\n") + if not discovery_complete: + lines.append( + "> **Latest-version discovery incomplete** — at least one release feed " + "could not be read. This report must not be used to resolve the drift issue.\n" + ) if not token_present: lines.append( "> **Socket analysis skipped** — no `SOCKET_API_TOKEN` present. " @@ -459,9 +555,13 @@ def verdict(version: Optional[str]) -> str: 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( + "--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( + "--github-output", help="Write drift/malware outputs here (e.g. GITHUB_OUTPUT)" + ) parser.add_argument( "--fail-on-malware", action="store_true", @@ -576,7 +676,8 @@ def main() -> int: } findings.append(tool_finding) - markdown = render_markdown(tools, token_present) + discovery_complete = args.mode != "watch" or all(t.latest is not None for t in tools) + markdown = render_markdown(tools, token_present, discovery_complete) print("\n" + markdown) if args.summary_file: @@ -588,6 +689,7 @@ def main() -> int: json.dumps( { "mode": args.mode, + "discovery_complete": discovery_complete, "token_present": token_present, "scoring_error": scoring_error, "unverified": unverified, @@ -605,6 +707,7 @@ def main() -> int: 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") + fh.write(f"discovery_complete={'true' if discovery_complete else 'false'}\n") if args.fail_on_malware: if any_malware or any_critical: @@ -628,7 +731,8 @@ def main() -> int: if pending: print( "::error::Socket analysis still pending after the bounded poll for pinned " - "coordinate(s): " + "; ".join(pending) + "coordinate(s): " + + "; ".join(pending) + ". Failing closed -- re-run later, or investigate Socket ingestion if it persists.", file=sys.stderr, ) diff --git a/tests/test_check_core_tools.py b/tests/test_check_core_tools.py new file mode 100644 index 0000000..1d1b38c --- /dev/null +++ b/tests/test_check_core_tools.py @@ -0,0 +1,75 @@ +import re + +from scripts import check_core_tools + + +def test_trivy_release_discovery_uses_socket_ghcr_package(monkeypatch): + packages = [] + + def fake_ghcr_latest(org, package): + packages.append((org, package)) + return "0.73.0" + + monkeypatch.setattr(check_core_tools, "_ghcr_latest", fake_ghcr_latest) + + trivy = next(tool for tool in check_core_tools.build_tools() if tool.key == "trivy") + + assert trivy.discover_latest() == "0.73.0" + assert packages == [("SocketDev", "trivy")] + + +def test_ghcr_latest_ignores_floating_and_prerelease_tags(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "test-token") + monkeypatch.setattr( + check_core_tools, + "_get_json", + lambda *_args: [ + {"metadata": {"container": {"tags": ["latest", "0.73.0"]}}}, + {"metadata": {"container": {"tags": ["v0.74.0-rc.1"]}}}, + {"metadata": {"container": {"tags": ["v0.72.0"]}}}, + ], + ) + + assert check_core_tools._ghcr_latest("SocketDev", "trivy") == "0.73.0" + + +def test_every_socket_tool_is_named_and_pinned_unambiguously(): + tools = {tool.key: tool for tool in check_core_tools.build_tools()} + + assert tools["socket_sdk"].label == "Socket SDK (socket-sdk-python)" + assert tools["socket_python_cli"].label == "Socket Python CLI (socket-python-cli)" + assert tools["socket_npm_cli"].label == "Socket npm CLI (socket-cli)" + + # Multiple images may carry a tool, but they must all agree on one exact pin. + assert len(tools["socket_sdk"].read_pinned()) == 1 + assert len(tools["socket_python_cli"].read_pinned()) == 1 + assert len(tools["socket_npm_cli"].read_pinned()) == 1 + + +def test_socket_cli_installs_are_version_pinned(): + for dockerfile in check_core_tools.DOCKERFILES: + contents = dockerfile.read_text() + if "npm install -g" in contents: + assert '"socket@${SOCKET_NPM_CLI_VERSION}"' in contents + assert re.search(r"^ARG SOCKET_NPM_CLI_VERSION=\d+\.\d+\.\d+$", contents, re.MULTILINE) + + app_tests = (check_core_tools.REPO_ROOT / "app_tests" / "Dockerfile").read_text() + assert 'uv tool install "socketsecurity==${SOCKET_PYTHON_CLI_VERSION}"' in app_tests + assert re.search(r"^ARG SOCKET_PYTHON_CLI_VERSION=\d+\.\d+\.\d+$", app_tests, re.MULTILINE) + + +def test_trivy_pin_comes_from_socket_image_tag(): + tools = {tool.key: tool for tool in check_core_tools.build_tools()} + + assert tools["trivy"].read_pinned() == ["0.73.0"] + assert all( + "ARG TRIVY_IMAGE=ghcr.io/socketdev/trivy:" in dockerfile.read_text() + for dockerfile in check_core_tools.DOCKERFILES + ) + + +def test_incomplete_discovery_report_cannot_be_mistaken_for_no_drift(): + report = check_core_tools.render_markdown([], token_present=False, discovery_complete=False) + + assert "Latest-version discovery incomplete" in report + assert "must not be used to resolve the drift issue" in report diff --git a/tests/test_core_tool_watch_workflow.py b/tests/test_core_tool_watch_workflow.py new file mode 100644 index 0000000..949889e --- /dev/null +++ b/tests/test_core_tool_watch_workflow.py @@ -0,0 +1,16 @@ +from pathlib import Path + + +WORKFLOW = Path(__file__).parents[1] / ".github" / "workflows" / "core-tool-watch.yml" + + +def test_issue_reconciliation_prefers_an_open_issue() -> None: + workflow = WORKFLOW.read_text() + reconcile = workflow[workflow.index("- name: Reconcile drift tracking issue") :] + + open_lookup = reconcile.index("--state open --limit 1") + closed_fallback = reconcile.index('if [ -z "$existing" ]; then') + closed_lookup = reconcile.index("--state closed --limit 1") + + assert open_lookup < closed_fallback < closed_lookup + assert "--state all" not in reconcile