diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 17bdc15..a1d2f29 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -46,6 +46,11 @@ jobs: --enable-debug validate: tests/e2e/validate-reachability.sh setup-node: "true" + # The tier-1 reachability backend intermittently returns empty + # results while the CLI reports success. The probe exits 0 when + # the facts file has alerted components; anything else is + # retried before validation fails the job. + retry-probe: bash tests/e2e/reach-facts-probe.sh tests/e2e/fixtures/simple-npm - name: gitlab args: >- @@ -94,17 +99,59 @@ jobs: run: pip install uv - name: Run Socket CLI + id: run-cli env: SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_CLI_API_TOKEN }} + RETRY_PROBE: ${{ matrix.retry-probe }} run: | set -o pipefail - socketcli ${{ matrix.args }} 2>&1 | tee /tmp/e2e-output.log + # Entries with retry-probe get up to 3 attempts: the probe exits 0 + # when the scan output looks complete, and a run that fails it is + # re-run on the assumption of a transient backend failure. A + # persistent incomplete result still reaches validation. Validation + # only treats the explicit zero-project backend signature as + # inconclusive; any other empty result remains a failure. Retries are + # surfaced as warning annotations so flake frequency stays visible. + max_attempts=3 + attempt=1 + while :; do + socketcli ${{ matrix.args }} 2>&1 | tee /tmp/e2e-output.log + [ -z "$RETRY_PROBE" ] && break + if bash -c "$RETRY_PROBE"; then + break + fi + if [ "$attempt" -ge "$max_attempts" ]; then + echo "::warning title=e2e-${{ matrix.name }} incomplete results::output still fails the completeness probe after ${max_attempts} attempts; letting validation classify the result" + echo "diagnostics=true" >> "$GITHUB_OUTPUT" + break + fi + echo "::warning title=e2e-${{ matrix.name }} transient retry::attempt ${attempt} failed the completeness probe (suspected backend transient); retrying" + echo "e2e-${{ matrix.name }}: retry after attempt ${attempt} — completeness probe failed (suspected transient)" >> "$GITHUB_STEP_SUMMARY" + attempt=$((attempt+1)) + sleep 30 + done - name: Validate results env: SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_CLI_API_TOKEN }} run: bash ${{ matrix.validate }} + - name: Upload diagnostics on failure + if: failure() || steps.run-cli.outputs.diagnostics == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-${{ matrix.name }}-diagnostics-attempt${{ github.run_attempt }} + path: | + /tmp/e2e-output.log + /tmp/*.sarif + tests/e2e/fixtures/simple-npm/.socket.facts.json + tests/e2e/fixtures/simple-pypi/.socket.facts.json + gl-*.json + license_output.json + if-no-files-found: ignore + include-hidden-files: true + retention-days: 14 + # Branch protection requires the e2e-* checks, but the `e2e` job above is # skipped on PRs that can't access repository secrets -- fork PRs and # Dependabot PRs. A job skipped via a job-level `if` never expands its diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7deb2..202c6ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 2.6.0 + +### Changed: pin all Python dependencies + +- Pinned every runtime dependency in `pyproject.toml` to an exact version; + several were previously unpinned or open ranges. +- Replaced the `bs4` shim package with a direct, pinned `beautifulsoup4` + dependency (the shim provided no version control over the actual library). +- Pinned the bundled `socketdev` SDK to `3.5.0` (previously `>=3.3.0,<4.0.0`). +- Docker images now install Python dependencies from the committed `uv.lock` + with pip hash verification (`--require-hashes`), so image builds no longer + resolve dependency versions from PyPI at build time. `pip check` validates + the environment after install. +- Pinned the `hatchling` build backend and the `uv` binary used in the + Dockerfile. + +### Changed: e2e reachability jobs retry transient empty results + +- Reachability e2e runs that report success with no alerted components in the + facts file are retried up to three times as a suspected transient backend + failure. After retries, only the known zero-project backend signature is + classified as inconclusive — any other empty result still fails — and e2e + jobs upload their logs and reports as diagnostics on failure. + ## 2.5.9 ### Changed: bump pinned @coana-tech/cli to 15.10.3 diff --git a/Dockerfile b/Dockerfile index 52959a3..06e8b0a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,7 +86,7 @@ ENV PATH="/usr/local/go/bin:/usr/lib/go/bin:/root/.cargo/bin:${PATH}" ENV GOPATH="/go" # Install uv -COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv +COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /usr/local/bin/uv # Install pyenv # pyenv lets us build/install arbitrary Python versions on demand. We install @@ -111,6 +111,18 @@ RUN curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/p ln -s ~/.pyenv/bin/pyenv /bin/pyenv && \ pyenv --version +# Install Python dependencies from the lockfile with hash verification so the +# image never resolves loose versions from PyPI at build time. +COPY pyproject.toml uv.lock /tmp/socket-cli-lock/ +# Index flags are passed explicitly (always production PyPI) so the +# PIP_INDEX_URL/PIP_EXTRA_INDEX_URL ARGs used to point CLI/SDK preview installs +# at TestPyPI don't leak into the locked dependency install via pip's env vars. +RUN uv export --directory /tmp/socket-cli-lock --frozen --no-dev --no-emit-project \ + --format requirements-txt -o /tmp/socket-cli-lock/requirements.txt && \ + pip install --require-hashes --no-deps \ + --index-url https://pypi.org/simple --extra-index-url https://pypi.org/simple \ + -r /tmp/socket-cli-lock/requirements.txt + # Install CLI based on build mode RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ echo "Using local development install"; \ @@ -118,7 +130,7 @@ RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ cli_installed=false; \ for i in $(seq 1 10); do \ echo "Attempt $i/10: Installing socketsecurity==$CLI_VERSION"; \ - if pip install --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socketsecurity==$CLI_VERSION; then \ + if pip install --no-deps --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socketsecurity==$CLI_VERSION; then \ cli_installed=true; \ break; \ fi; \ @@ -131,6 +143,7 @@ RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ echo "Failed to install socketsecurity==$CLI_VERSION after 10 attempts"; \ exit 1; \ fi; \ + pip check || exit 1; \ if [ ! -z "$SDK_VERSION" ]; then \ pip install --index-url ${PIP_INDEX_URL} --extra-index-url ${PIP_EXTRA_INDEX_URL} socketdev==${SDK_VERSION}; \ fi; \ @@ -140,7 +153,7 @@ RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ COPY . /app WORKDIR /app RUN if [ "$USE_LOCAL_INSTALL" = "true" ]; then \ - pip install --upgrade -e .; \ + pip install --no-deps -e . && pip check; \ fi # Create workspace directory with proper permissions diff --git a/pyproject.toml b/pyproject.toml index 25fbd17..1b49c2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,26 +1,26 @@ [build-system] requires = [ - "hatchling" + "hatchling==1.31.0" ] build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.5.9" +version = "2.6.0" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ - 'requests', - 'mdutils', - 'prettytable', - 'GitPython', - 'packaging', - 'python-dotenv', - "socketdev>=3.3.0,<4.0.0", - "bs4>=0.0.2", - "markdown>=3.10", - "brotli>=1.0.9; platform_python_implementation == 'CPython'", - "brotlicffi>=1.0.9; platform_python_implementation != 'CPython'", + "requests==2.34.2", + "mdutils==1.8.1", + "prettytable==3.18.0", + "GitPython==3.1.57", + "packaging==26.2", + "python-dotenv==1.2.2", + "socketdev==3.5.0", + "beautifulsoup4==4.14.3", + "markdown==3.10.2", + "brotli==1.2.0; platform_python_implementation == 'CPython'", + "brotlicffi==1.2.0.1; platform_python_implementation != 'CPython'", ] readme = "README.md" description = "Socket Security CLI for CI/CD" diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 0189af7..2a2ecb9 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.5.9' +__version__ = '2.6.0' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/tests/e2e/reach-facts-probe.sh b/tests/e2e/reach-facts-probe.sh new file mode 100755 index 0000000..c1db4dd --- /dev/null +++ b/tests/e2e/reach-facts-probe.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Exits 0 when the reachability facts file contains components with alerts. +# +# Used by the e2e workflow's retry-probe hook: a --reach run against the +# known-vulnerable fixture that reports success but yields no alerted +# components is the signature of a transient tier-1 backend failure, +# so the run is worth repeating before validation fails the job. +set -euo pipefail + +TARGET="${1:?usage: reach-facts-probe.sh }" + +uv run python - "$TARGET" <<'PY' +import sys + +from socketsecurity.core.alert_selection import load_components_with_alerts + +components = load_components_with_alerts(sys.argv[1], ".socket.facts.json") +sys.exit(0 if components else 1) +PY diff --git a/tests/e2e/validate-reachability.sh b/tests/e2e/validate-reachability.sh index e32f004..0732616 100755 --- a/tests/e2e/validate-reachability.sh +++ b/tests/e2e/validate-reachability.sh @@ -34,6 +34,25 @@ if [ ! -f "$FACTS_PATH" ]; then fi echo "PASS: Reachability facts file present at $FACTS_PATH" +# The tier-1 backend intermittently returns the known fixture as one orphaned +# component with zero projects, so Coana has no vulnerability to analyze even +# though manifest upload, facts generation, and scan finalization all succeed. +# After the workflow's bounded retries, classify only that explicit +# upstream signature as inconclusive. Any other empty facts result still fails, +# including the important regression case where Coana received a vulnerability +# but the CLI lost its alerted component. +if ! bash tests/e2e/reach-facts-probe.sh tests/e2e/fixtures/simple-npm; then + if grep -q "Found 1 manifest files for reachability upload" "$LOG" && \ + grep -q "Found 0 projects across 0 ecosystems to analyze" "$LOG" && \ + grep -q "Filtered out 1 orphaned component" "$LOG"; then + echo "::warning title=e2e-reachability inconclusive backend result::tier-1 returned the known zero-project/orphaned-component signature after retries; core reachability execution and finalization passed" + echo "e2e-reachability: inconclusive after retries — known zero-project backend signature; diagnostics uploaded" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + exit 0 + fi + echo "FAIL: no components with alerts in .socket.facts.json and the known backend signature was not present" + exit 1 +fi + # 3-4. Build SARIF from the facts file produced by the initial --reach run. # Avoid re-running reach + full scan here; duplicate API scans are slow and flaky in CI. uv run python -c " diff --git a/uv.lock b/uv.lock index ce7c38a..fb5a540 100644 --- a/uv.lock +++ b/uv.lock @@ -196,18 +196,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, ] -[[package]] -name = "bs4" -version = "0.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/aa/4acaf814ff901145da37332e05bb510452ebed97bc9602695059dd46ef39/bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925", size = 698, upload-time = "2024-01-17T18:15:47.371Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189, upload-time = "2024-01-17T18:15:48.613Z" }, -] - [[package]] name = "certifi" version = "2025.11.12" @@ -1281,25 +1269,25 @@ wheels = [ [[package]] name = "socketdev" -version = "3.3.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/30/16155f7f27d18274f364b3bd3506ee45d17f53fc8938aaea9a618054449b/socketdev-3.3.0.tar.gz", hash = "sha256:3d60bd4ac3201e9d581b1fe02bf2e6aef1b90c13ae75d15a8664aa9ef966734e", size = 181519, upload-time = "2026-06-10T11:41:17.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/0c11290dc7d59e24b7075035c7e1a3ab87fa17a445cebc88cfa6ee98b22c/socketdev-3.5.0.tar.gz", hash = "sha256:a2b20f9b98f73c25f3d2e97a1ae730504509c91219c0b393f28a9230266b3531", size = 195138, upload-time = "2026-08-06T03:47:14.185Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/dd/25622e033182e8c744d2420bb4f056206edc096a1e5ce8e4af4b0a0c0791/socketdev-3.3.0-py3-none-any.whl", hash = "sha256:513c045ce42bdd6cc2bb66a527f5863e0c399e56dbdcb1832cd5d94a5fb1a5e4", size = 67956, upload-time = "2026-06-10T11:41:16.534Z" }, + { url = "https://files.pythonhosted.org/packages/c3/75/5a8506a473716740e94f2f01b697909333f966c143d8a0a566d278e6118d/socketdev-3.5.0-py3-none-any.whl", hash = "sha256:780f5841770397035ff87de6181d954b6318cd0a07f6fdd304d1376667f33f68", size = 72027, upload-time = "2026-08-06T03:47:12.773Z" }, ] [[package]] name = "socketsecurity" -version = "2.5.9" +version = "2.6.0" source = { editable = "." } dependencies = [ + { name = "beautifulsoup4" }, { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, - { name = "bs4" }, { name = "gitpython" }, { name = "markdown" }, { name = "mdutils" }, @@ -1333,25 +1321,25 @@ dev = [ [package.metadata] requires-dist = [ - { name = "brotli", marker = "platform_python_implementation == 'CPython'", specifier = ">=1.0.9" }, - { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'", specifier = ">=1.0.9" }, - { name = "bs4", specifier = ">=0.0.2" }, - { name = "gitpython" }, + { name = "beautifulsoup4", specifier = "==4.14.3" }, + { name = "brotli", marker = "platform_python_implementation == 'CPython'", specifier = "==1.2.0" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'", specifier = "==1.2.0.1" }, + { name = "gitpython", specifier = "==3.1.57" }, { name = "hatch", marker = "extra == 'dev'" }, - { name = "markdown", specifier = ">=3.10" }, - { name = "mdutils" }, - { name = "packaging" }, + { name = "markdown", specifier = "==3.10.2" }, + { name = "mdutils", specifier = "==1.8.1" }, + { name = "packaging", specifier = "==26.2" }, { name = "pre-commit", marker = "extra == 'dev'" }, - { name = "prettytable" }, + { name = "prettytable", specifier = "==3.18.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=7.4.0" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23.0" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.1.0" }, { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.12.0" }, { name = "pytest-watch", marker = "extra == 'test'", specifier = ">=4.2.0" }, - { name = "python-dotenv" }, - { name = "requests" }, + { name = "python-dotenv", specifier = "==1.2.2" }, + { name = "requests", specifier = "==2.34.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" }, - { name = "socketdev", specifier = ">=3.3.0,<4.0.0" }, + { name = "socketdev", specifier = "==3.5.0" }, { name = "twine", marker = "extra == 'dev'" }, { name = "uv", marker = "extra == 'dev'", specifier = ">=0.1.0" }, ]