Skip to content

Commit cd1e292

Browse files
authored
Merge pull request #364 from StackVista/STAC-25540-credfree-private-index
STAC-25540: fetch private-index wheels, then drop the credential
2 parents b1715b4 + e6d160f commit cd1e292

4 files changed

Lines changed: 212 additions & 54 deletions

File tree

.github/scripts/select-checks.sh

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@
2727
#
2828
# The split is a security boundary, not a convenience. The credential-free suites
2929
# run on GitHub-hosted runners with no secrets in scope at all, so a fork PR can
30-
# run them safely. The private-index suites need a registry password written to
31-
# ~/.netrc, which any test code executing afterwards can read, so they run behind
32-
# a protected GitHub Environment that requires a human approval first (STAC-25463
33-
# review). Keeping them in one matrix would hand that credential to every suite.
30+
# run them safely. The private-index suites need a registry password, and any test
31+
# code executing alongside it could read it, so they run in a separate job that
32+
# downloads the private packages into a local wheelhouse and destroys the
33+
# credential before the suite starts (STAC-25463 review, STAC-25540). Keeping them
34+
# in one matrix would hand that credential to every suite.
3435

3536
set -euo pipefail
3637

.github/workflows/checks-tests.yml

Lines changed: 54 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,22 @@ name: Check tests
7171
# least-privilege by construction; this repo's *publishing* role is still
7272
# deferred, per the note above.
7373
#
74-
# Scoping it to a single step is not enough, because the script writes it to
75-
# ~/.netrc and the suite's own test code runs afterwards in the same job and can
76-
# read it. So the suites that need it are split into their own job,
77-
# `check-tests-private-index`, behind the `private-package-index` GitHub
78-
# Environment. Required reviewers there mean the credential is released only
79-
# after a human approves that specific run. The remaining suites never see it.
74+
# Scoping it to a single step was not enough on its own, because the old setup
75+
# script wrote it to ~/.netrc and the suite's own test code runs afterwards in
76+
# the same job and could read it. So the suites that need it are split into
77+
# their own job, `check-tests-private-index`, and that job now downloads the
78+
# private packages into a local wheelhouse and deletes the credential before any
79+
# repository code runs (STAC-25540). The remaining suites never see it.
80+
#
81+
# This replaced the `private-package-index` GitHub Environment, whose required
82+
# reviewers released the credential only after a human approved each run. It
83+
# worked, but SHARED_PATHS covers the CI files, so it fired on roughly one
84+
# commit in six and blocked authors on their own pull requests for no gain the
85+
# wheelhouse does not also provide.
8086
#
8187
# Residual, and deliberately left alone here: the underlying secrets are still
8288
# repo- and org-level, so a pull request that edits this workflow could add a
83-
# new reference outside the gated job. That change is visible in the diff and is
89+
# new reference outside that step. That change is visible in the diff and is
8490
# what review and the Zizmor audit are for. Making them environment-only secrets
8591
# would remove even that path, but the org-level password is shared with other
8692
# repos (stackstate-agent among them), so it needs its own change.
@@ -298,26 +304,35 @@ jobs:
298304
check-tests-private-index:
299305
name: Check tests, private index (${{ matrix.check }})
300306
# Isolated from `check-tests` because this is the only job that handles a
301-
# credential. `setup_artifact_registry.sh` writes the GitLab Package Registry
302-
# password into a 0600 ~/.netrc, and everything that runs afterwards -- the
303-
# suite's own tests, its tox environment, its transitive dependencies -- can
304-
# read that file. Test code is PR-authored, so the credential is effectively
305-
# exposed to whoever opens the pull request.
307+
# credential at all: vsphere pins a package that resolves solely from the
308+
# private GitLab Package Registry.
309+
#
310+
# The credential is confined to a single step (STAC-25540). It is written,
311+
# used for one fixed pip download, and destroyed before any repository code
312+
# runs; the suite then installs from the resulting local wheelhouse with
313+
# nothing to authenticate against. That replaces the protected GitHub
314+
# Environment this job used to sit behind, which answered the same STAC-25463
315+
# review finding by requiring a human to release every run -- and, because
316+
# SHARED_PATHS covers the CI files, fired on roughly one commit in six and
317+
# blocked authors on their own pull requests.
318+
#
319+
# The fork guard stays here, and only here. A fork PR gets no Actions secrets
320+
# on a public repository, so it could not fetch the wheel in any case; it
321+
# skips this job and still gets a full credential-free verdict from the other
322+
# suites, so nothing silently reports green without tests.
306323
#
307-
# Two things contain that (STAC-25463 review):
308-
# * `environment:` puts the job behind a protected GitHub Environment with
309-
# required reviewers, so the credential is only released after a human has
310-
# approved this specific run, having seen the diff.
311-
# * The fork guard stays here, and only here. A fork PR skips this job and
312-
# still gets a full credential-free verdict from the other suites, so
313-
# nothing silently reports green without tests.
324+
# Residual risk, stated plainly: a pull request that edits this workflow can
325+
# still reach the secret, because `pull_request` runs the PR's own copy of the
326+
# workflow. That is true of every secret in every repository and is contained
327+
# by review and branch protection, not by CI. What changed is that the
328+
# credential is no longer readable by ordinary test code and its dependency
329+
# tree, which needed no workflow edit at all.
314330
if: >-
315331
${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
316332
&& needs.select-checks.outputs.private_checks != '[]' }}
317333
needs: select-checks
318334
runs-on: ubuntu-latest
319335
timeout-minutes: 45
320-
environment: private-package-index
321336
strategy:
322337
fail-fast: false
323338
matrix:
@@ -345,37 +360,33 @@ jobs:
345360
git config --global --add safe.directory '*'
346361
source .setup-scripts/setup_env.sh
347362
348-
- name: Configure the private PyPI index (GitLab Package Registry)
363+
- name: Fetch private-index wheels and revoke the credential
349364
env:
350365
GITLAB_PACKAGE_REGISTRY_PYPI_SIMPLE_URL: ${{ vars.GITLAB_PACKAGE_REGISTRY_PYPI_SIMPLE_URL }}
351366
GITLAB_PACKAGE_REGISTRY_USER: ${{ secrets.GITLAB_PACKAGE_REGISTRY_USER }}
352367
GITLAB_PACKAGE_REGISTRY_READONLY_PASSWORD: ${{ secrets.GITLAB_PACKAGE_REGISTRY_READONLY_PASSWORD }}
353-
# Mirrors what the GitLab `.linux_test` anchor did immediately before
354-
# `checksdev test`. The script writes ~/.pip/pip.conf and a 0600 ~/.netrc,
355-
# keeping credentials out of the index URL.
368+
# The only step in this workflow with a secret in scope. The script writes
369+
# ~/.netrc, downloads one fixed package set, deletes the netrc, and leaves
370+
# ~/.pip/pip.conf pointing at a local wheelhouse. Everything after it --
371+
# checksdev, tox, the suite's tests and their dependency tree -- runs with
372+
# no credential on disk and no authenticated index configured.
356373
#
357-
# Unguarded, unlike the earlier revision: this job only runs for suites
358-
# that cannot resolve without the private index, so a missing credential
359-
# is a hard configuration error and must fail loudly rather than warn and
360-
# let pip fall through to public PyPI.
374+
# It replaces setup_artifact_registry.sh here, which left the netrc in
375+
# place for the rest of the job (STAC-25463 review P1, STAC-25540). That
376+
# script is untouched and still serves the GitLab pipeline definitions.
361377
#
362-
# NOTE: writing these under $HOME is only half the job. tox drops every
363-
# variable absent from a testenv's `passenv`, HOME included, and pip then
364-
# resolves `~` from the passwd database rather than the environment. Because
365-
# a container job here runs with HOME=/github/home instead of the account
366-
# default, a testenv that installs from this index must list HOME in its
367-
# passenv or it will silently read a pip.conf that was never written and fall
368-
# back to public PyPI alone -- with no auth error to show for it. See
369-
# vsphere/tox.ini.
378+
# The wheelhouse lives in RUNNER_TEMP rather than the workspace so it
379+
# cannot be mistaken for repository content or swept into a build.
380+
#
381+
# NOTE: pip.conf is read from $HOME, so tox must still pass HOME into the
382+
# testenv. tox drops every variable absent from `passenv`, and pip then
383+
# resolves `~` from the passwd database rather than the environment --
384+
# which points at the wrong home in a container job, where HOME is
385+
# /github/home. Without it the suite silently falls back to public PyPI
386+
# and installs the 0.0.1 placeholder. See vsphere/tox.ini.
370387
run: |
371388
set -eo pipefail
372-
if [ -z "${GITLAB_PACKAGE_REGISTRY_PYPI_SIMPLE_URL}" ] \
373-
|| [ -z "${GITLAB_PACKAGE_REGISTRY_USER}" ] \
374-
|| [ -z "${GITLAB_PACKAGE_REGISTRY_READONLY_PASSWORD}" ]; then
375-
echo "::error title=Private PyPI index not configured::This suite installs from the GitLab Package Registry, but its credentials are not available to this job."
376-
exit 1
377-
fi
378-
.setup-scripts/setup_artifact_registry.sh
389+
.setup-scripts/fetch_private_wheels.sh "${RUNNER_TEMP}/private-wheels"
379390
380391
- name: checksdev test ${{ matrix.check }}
381392
env:
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env bash
2+
# Makes the packages that exist only in the private GitLab Package Registry
3+
# available to a local wheelhouse, and destroys the credential before returning.
4+
#
5+
# Why this exists (STAC-25540): vsphere pins vsphere-automation-sdk==1.82.0, an
6+
# unmodified upstream VMware wheel that VMware withdrew from public PyPI. We
7+
# self-host it in the GitLab Package Registry only because that org was private;
8+
# public PyPI now serves a 0.0.1 placeholder squatting the name.
9+
#
10+
# The predecessor, setup_artifact_registry.sh, left a 0600 ~/.netrc in place for
11+
# the remainder of the job, so every later step -- the tox environment, the
12+
# suite's own tests, their transitive dependencies -- could read the password.
13+
# Test code is PR-authored, which made that credential effectively readable by
14+
# whoever opened the pull request (STAC-25463 review, P1).
15+
#
16+
# Here the credential exists only for the duration of one pip invocation whose
17+
# package set is fixed below, and pip is then pointed at the resulting wheelhouse
18+
# so the rest of the job resolves offline with nothing to authenticate against.
19+
set -euo pipefail
20+
21+
WHEELHOUSE_ARG="${1:-}"
22+
if [ -z "${WHEELHOUSE_ARG}" ]; then
23+
echo "usage: ${0##*/} <wheelhouse-dir>" >&2
24+
exit 2
25+
fi
26+
27+
# Absolute: pip.conf's find-links is resolved against the working directory of
28+
# whichever process reads it, and tox runs pip from the suite directory.
29+
mkdir -p "${WHEELHOUSE_ARG}"
30+
WHEELHOUSE="$(cd "${WHEELHOUSE_ARG}" && pwd)"
31+
32+
# Hardcoded on purpose, and deliberately not read from the working tree. While
33+
# the credential is on disk, a pull request must not be able to redirect pip at a
34+
# package of its choosing.
35+
PRIVATE_REQUIREMENTS=(
36+
"vsphere-automation-sdk==1.82.0"
37+
)
38+
39+
for var in GITLAB_PACKAGE_REGISTRY_PYPI_SIMPLE_URL GITLAB_PACKAGE_REGISTRY_USER GITLAB_PACKAGE_REGISTRY_READONLY_PASSWORD; do
40+
if [ -z "${!var:-}" ]; then
41+
echo "::error title=Private PyPI index not configured::${var} is not available to this job, but this suite cannot resolve without the private index."
42+
exit 1
43+
fi
44+
done
45+
46+
NETRC="${HOME}/.netrc"
47+
PIP_CONF_DIR="${HOME}/.pip"
48+
49+
revoke_credential() {
50+
rm -f "${NETRC}"
51+
}
52+
# Covers the error paths too: a failed download must not leave the password on a
53+
# disk that PR-authored test code goes on to run against.
54+
trap revoke_credential EXIT
55+
56+
# Hostname only; the simple URL carries a path after the first '/'.
57+
NETRC_HOST="${GITLAB_PACKAGE_REGISTRY_PYPI_SIMPLE_URL%%/*}"
58+
59+
umask 077
60+
cat > "${NETRC}" <<EOF
61+
machine ${NETRC_HOST}
62+
login ${GITLAB_PACKAGE_REGISTRY_USER}
63+
password ${GITLAB_PACKAGE_REGISTRY_READONLY_PASSWORD}
64+
EOF
65+
66+
# A system interpreter, never the toolchain virtualenv: that venv is built by
67+
# repository code, so invoking its pip would put a PR-controlled executable
68+
# directly in the path of the credential.
69+
#
70+
# Resolved rather than hardcoded, because BCI images do not agree on a path:
71+
# bci/python:3.13 ships /usr/bin/python3.13 and no /usr/bin/python3 at all, while
72+
# `python3` on PATH is a /usr/local/bin shim. The workspace check below is the
73+
# part that actually matters -- it is what makes "system" a guarantee rather than
74+
# an assumption, whatever PATH happens to hold.
75+
PYTHON=""
76+
for candidate in /usr/bin/python3.13 /usr/bin/python3 /usr/local/bin/python3 "$(command -v python3 2>/dev/null || true)"; do
77+
if [ -n "${candidate}" ] && [ -x "${candidate}" ]; then
78+
PYTHON="${candidate}"
79+
break
80+
fi
81+
done
82+
if [ -z "${PYTHON}" ]; then
83+
echo "::error title=No system interpreter::Could not locate a python3 to download with."
84+
exit 1
85+
fi
86+
if [ -n "${GITHUB_WORKSPACE:-}" ]; then
87+
PYTHON_DIR="$(cd "$(dirname "${PYTHON}")" && pwd)"
88+
case "${PYTHON_DIR}/" in
89+
"${GITHUB_WORKSPACE%/}/"*)
90+
echo "::error title=Refusing a workspace interpreter::Resolved python3 at ${PYTHON}, which is inside the checkout and therefore PR-controlled."
91+
exit 1
92+
;;
93+
esac
94+
fi
95+
96+
echo "→ Downloading private-index packages into ${WHEELHOUSE}"
97+
printf ' %s\n' "${PRIVATE_REQUIREMENTS[@]}"
98+
echo " using ${PYTHON}"
99+
100+
# --only-binary=:all: matters as much as the interpreter choice. Downloading an
101+
# sdist executes its setup.py, so allowing one would hand arbitrary upstream code
102+
# a process with the registry password readable at ~/.netrc.
103+
"${PYTHON}" -m pip download \
104+
--disable-pip-version-check \
105+
--no-cache-dir \
106+
--only-binary=:all: \
107+
--extra-index-url "https://${GITLAB_PACKAGE_REGISTRY_PYPI_SIMPLE_URL}" \
108+
--dest "${WHEELHOUSE}" \
109+
"${PRIVATE_REQUIREMENTS[@]}"
110+
111+
revoke_credential
112+
trap - EXIT
113+
114+
if [ -f "${NETRC}" ]; then
115+
echo "::error title=Credential not revoked::${NETRC} still exists after download; refusing to continue."
116+
exit 1
117+
fi
118+
119+
# A silent miss here would fall through to public PyPI and install the 0.0.1
120+
# placeholder, which fails much later and far less legibly.
121+
if ! find "${WHEELHOUSE}" -maxdepth 1 -iname 'vsphere_automation_sdk-*.whl' | grep -q .; then
122+
echo "::error title=Private wheel missing::vsphere-automation-sdk was not downloaded into ${WHEELHOUSE}."
123+
exit 1
124+
fi
125+
126+
# Replaces the extra-index-url that setup_artifact_registry.sh used to write.
127+
# Nothing after this point authenticates anywhere: the private packages resolve
128+
# from the local wheelhouse, and everything else still comes from public PyPI.
129+
mkdir -p "${PIP_CONF_DIR}"
130+
cat > "${PIP_CONF_DIR}/pip.conf" <<EOF
131+
[global]
132+
find-links = ${WHEELHOUSE}
133+
EOF
134+
135+
echo " --------------------------------------------- "
136+
echo "Wheelhouse contents:"
137+
ls -1 "${WHEELHOUSE}"
138+
echo
139+
echo "Pip configuration:"
140+
cat "${PIP_CONF_DIR}/pip.conf"
141+
echo "Credential revoked; no ${NETRC} remains."
142+
echo " --------------------------------------------- "

vsphere/tox.ini

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ deps =
1414
setuptools<78
1515
-e../stackstate_checks_base[deps]
1616
-rrequirements-dev.txt
17-
; vsphere-automation-sdk is pinned to a build that only exists in the private
18-
; GitLab package index, so `pip install -r requirements.in` below has to see the
19-
; ~/.pip/pip.conf and ~/.netrc that .setup-scripts/setup_artifact_registry.sh
20-
; writes. tox drops every variable not listed in passenv, and pip then resolves
21-
; `~` from the passwd database instead of the environment -- which silently points
22-
; at the wrong home whenever HOME is not the account's default, as in a GitHub
23-
; Actions container job where HOME is /github/home. Hence HOME below.
17+
; vsphere-automation-sdk is pinned to a version that only exists in our private
18+
; GitLab package index, so `pip install -r requirements.in` below cannot resolve
19+
; from public PyPI alone. CI downloads it ahead of time into a local wheelhouse
20+
; and points ~/.pip/pip.conf at it with find-links, which this testenv has to be
21+
; able to read: tox drops every variable not listed in passenv, and pip then
22+
; resolves `~` from the passwd database instead of the environment -- which
23+
; silently points at the wrong home whenever HOME is not the account's default,
24+
; as in a GitHub Actions container job where HOME is /github/home. Without HOME
25+
; below, pip reads a pip.conf that was never written, falls back to public PyPI,
26+
; and installs the 0.0.1 placeholder squatting the name with no error to show for
27+
; it. See .setup-scripts/fetch_private_wheels.sh.
2428
passenv =
2529
DOCKER*
2630
COMPOSE*

0 commit comments

Comments
 (0)