diff --git a/.github/workflows/core-tool-watch.yml b/.github/workflows/core-tool-watch.yml index 31ba618..9283690 100644 --- a/.github/workflows/core-tool-watch.yml +++ b/.github/workflows/core-tool-watch.yml @@ -1,19 +1,21 @@ 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 +# Supply-chain / malware watch for the core tools that Socket Basics +# orchestrates, including Socket's SDK and both of its CLI implementations. +# Several ship as binaries / container images / GitHub releases that +# Dependabot cannot cleanly track. 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. +# - schedule / workflow_dispatch / main push → mode=watch: discover latest +# versions, analyze BOTH pinned and latest, and reconcile the tracking issue. +# Running watch mode after a merge prevents the issue from retaining the +# pre-merge pins until the next weekly schedule. +# - pull_request 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). @@ -41,6 +43,7 @@ on: pull_request: paths: - "Dockerfile" + - "Dockerfile.heavy" - "app_tests/Dockerfile" - "pyproject.toml" - "uv.lock" @@ -50,6 +53,7 @@ on: branches: [main] paths: - "Dockerfile" + - "Dockerfile.heavy" - "app_tests/Dockerfile" - "pyproject.toml" - "uv.lock" @@ -78,7 +82,8 @@ jobs: environment: socket-firewall permissions: contents: read - issues: write # upsert the drift tracking issue on scheduled runs + issues: write # reconcile the drift tracking issue on default-branch runs + packages: read # inspect tags on the repo-authorized private GHCR Trivy package steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -117,12 +122,12 @@ 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 + # PRs guard proposed pins without mutating issues. Every default-branch + # event reconciles upstream state and the canonical tracking issue. + if [ "$EVENT" = "pull_request" ]; then echo "mode=build" >> "$GITHUB_OUTPUT" + else + echo "mode=watch" >> "$GITHUB_OUTPUT" fi - name: Run core-tool supply-chain analysis @@ -159,28 +164,56 @@ 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}" + # Reuse the newest labeled issue even after it has been closed. This + # keeps one canonical history instead of creating a duplicate every + # time drift disappears and later returns. + existing="$(gh issue list --label core-tool-drift --state all --limit 100 \ + --json number --jq 'sort_by(.number) | last | .number // empty' 2>/dev/null || true)" - 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..51112b5 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -7,7 +7,10 @@ name: publish-docker # 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) +# +# GitHub releases are intentionally human-authored after the images publish. +# Maintainers may use GitHub's generated release notes as a starting point, but +# this workflow does not create or edit the release itself. # # Tag convention: # v2.0.0 — immutable exact release (floating major tags intentionally not published) @@ -307,27 +310,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