fix(ci): close the CodeQL cache-poisoning Highs in the release workflows

All 19 open actions/cache-poisoning/poisonable-step alerts (#88-#106) sit in
release.yml, release-candidate.yml and release-artifacts.yml: jobs interpolated
the caller SHA into ref/caches and let setup-node's implicit npm cache key on
run identity. Now caller source_sha is pinned against github.sha by a pin job
(refusing any retarget), checkout and build identity use GITHUB_SHA through
env indirection instead of template interpolation, rust-cache keys use stable
prefix-key values, and setup-node's implicit package cache is off where it
cannot be keyed safely.

Also carries the CodeQL #107 fix from the same lane: catalog_models_dev.py
prints remote limit values as numbers/null/redacted only and drops query and
fragment material from source URLs before logging.

Harvested from #5401 (workflow+script files; CHANGELOG edit intentionally
excluded here and lands with the release branch; GHSA advisory text split to
a later PR as advised).

No-Issue: CodeQL alert remediation (alerts #88-#107); no single user-facing issue tracks these

Signed-off-by: Hunter Bown <hunter@hmbown.com>
This commit is contained in:
CodeWhale Bot
2026-08-16 22:51:53 -07:00
parent d03260ec29
commit f9519454ca
6 changed files with 231 additions and 52 deletions

View File

@@ -120,7 +120,13 @@ assert.doesNotMatch(candidate, /^ (push|pull_request|schedule):/m);
assert.match(candidate, /uses: \.\/\.github\/workflows\/release-artifacts\.yml/);
assert.match(candidate, /source_sha: \$\{\{ needs\.resolve\.outputs\.sha \}\}/);
assert.match(candidate, /^ web:\n/m);
assert.match(candidate, /ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/);
assert.doesNotMatch(
candidate,
/ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/,
"candidate jobs must checkout GITHUB_SHA, not interpolate the dispatch SHA into ref",
);
assert.match(candidate, /cache-dependency-path: web\/package-lock\.json/);
assert.match(candidate, /package-manager-cache: false/);
assert.match(candidate, /working-directory: web/);
for (const command of [
"npm ci",
@@ -259,14 +265,51 @@ assert.match(artifacts, /codew-windows-arm64\.exe/);
assert.match(artifacts, /CodeWhaleSetup\.exe/);
assert.match(artifacts, /assemble-release-assets\.js --verify release-assets/);
assert.match(artifacts, /CODEWHALE_SMOKE_ASSETS_DIR/);
assert.match(artifacts, /^ pin:\n/m);
assert.match(artifacts, /Require source_sha equals github\.sha/);
assert.doesNotMatch(
artifacts,
/ref: \$\{\{ inputs\.source_sha \}\}/,
"artifact jobs must checkout GITHUB_SHA, not interpolate the caller SHA into ref",
);
assert.match(artifacts, /prefix-key: v1-\$\{\{ runner\.os \}\}-\$\{\{ runner\.arch \}\}-stable/);
assert.equal(
(artifacts.match(/package-manager-cache: false/g) || []).length,
2,
"assemble and smoke must disable setup-node's implicit npm cache",
);
const bundleStep = namedStep(artifacts, "Create and checksum platform archives");
assert.match(bundleStep, /git show -s --format=%ct "\$\{\{ inputs\.source_sha \}\}"/);
assert.match(bundleStep, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
assert.match(bundleStep, /git show -s --format=%ct "\$\{SOURCE_SHA\}"/);
assert.match(
bundleStep,
/SOURCE_DATE_EPOCH="\$\{source_date_epoch\}"[\s\\]+bash scripts\/release\/create-release-bundles\.sh artifacts bundles/,
);
assert.doesNotMatch(bundleStep, /inputs\.source_sha/);
assert.doesNotMatch(bundleStep, /\bdate\b/, "bundle timestamps must come from the pinned source commit, not wall-clock time");
const rustCacheBlocks = [...artifacts.matchAll(/uses: Swatinem\/rust-cache@[\s\S]*?(?=\n - )/g)].map(
(match) => match[0],
);
assert.ok(rustCacheBlocks.length >= 1, "shared artifact workflow must pin rust-cache");
for (const block of rustCacheBlocks) {
assert.doesNotMatch(block, /github\.(event|ref|sha)|inputs\./);
}
const parity = release.match(/\n parity:\n([\s\S]*?)\n artifacts:\n/);
assert.ok(parity, "public release must retain a parity job");
assert.doesNotMatch(
parity[1],
/ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/,
"parity must checkout GITHUB_SHA after resolve, not interpolate the tag SHA into ref",
);
assert.match(parity[1], /prefix-key: v1-\$\{\{ runner\.os \}\}-\$\{\{ runner\.arch \}\}-stable/);
const parityRustCache = [...parity[1].matchAll(/uses: Swatinem\/rust-cache@[\s\S]*?(?=\n - )/g)].map(
(match) => match[0],
);
assert.equal(parityRustCache.length, 1, "parity must pin exactly one rust-cache");
assert.doesNotMatch(parityRustCache[0], /github\.(event|ref|sha)|inputs\./);
assert.equal(allReleaseAssetNames().length, 34);
assert.match(release, /^ artifacts:\n/m);
assert.match(release, /uses: \.\/\.github\/workflows\/release-artifacts\.yml/);

View File

@@ -24,9 +24,31 @@ env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
DEEPSEEK_BUILD_SHA: ${{ inputs.source_sha }}
# Build identity is the trusted workflow SHA. Callers pass source_sha only
# so `pin` can refuse a mismatch; it must not retarget checkout or caches.
DEEPSEEK_BUILD_SHA: ${{ github.sha }}
jobs:
pin:
name: Pin caller SHA to this run
runs-on: ubuntu-latest
steps:
- name: Require source_sha equals github.sha
env:
SOURCE_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
if [[ "${#SOURCE_SHA}" -ne 40 || "${SOURCE_SHA}" =~ [^0-9a-fA-F] ]]; then
echo "::error::source_sha must be a full 40-character commit SHA." >&2
exit 1
fi
expected="$(printf '%s' "${SOURCE_SHA}" | tr '[:upper:]' '[:lower:]')"
actual="$(printf '%s' "${GITHUB_SHA}" | tr '[:upper:]' '[:lower:]')"
if [[ "${actual}" != "${expected}" ]]; then
echo "::error::Reusable workflow SHA ${actual} does not match source_sha ${SOURCE_SHA}." >&2
exit 1
fi
build:
name: Build ${{ matrix.platform }}
# FreeBSD is a source-build target validated via `cargo check --target x86_64-unknown-freebsd -p codewhale-cli --locked`
@@ -93,17 +115,34 @@ jobs:
shim_artifact: codew-windows-arm64.exe
tui_artifact: codewhale-tui-windows-arm64.exe
runs-on: ${{ matrix.os }}
needs: pin
steps:
# No ref: — GITHUB_SHA only. CodeQL treats workflow_call checkout-with-ref
# and any ref named *sha* as an untrusted checkout (cache-poisoning).
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.source_sha }}
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master 2026-07-18
with:
toolchain: stable
targets: ${{ matrix.target }}
# Privileged artifact builds check out inputs.source_sha. Do not
# restore or save rust-cache / sccache here — those keys are shared
# with default-branch CI (CodeQL #95#96, #104#106).
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
{
echo "SCCACHE_GHA_ENABLED=true"
echo "RUSTC_WRAPPER=sccache"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1"
} >> "${GITHUB_ENV}"
# Restore after the trusted lockfile is on disk. Key is OS + arch +
# explicit stable toolchain + rust-cache's Cargo.lock / rust-toolchain
# hash. Never interpolate github.event, github.ref, github.sha, or inputs.
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
cache-bin: false
prefix-key: v1-${{ runner.os }}-${{ runner.arch }}-stable
- name: Build static Linux binaries (musl)
if: endsWith(matrix.target, '-unknown-linux-musl')
shell: bash
@@ -244,19 +283,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.source_sha }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: artifacts
pattern: '*'
- name: Create and checksum platform archives
shell: bash
env:
SOURCE_SHA: ${{ github.sha }}
run: |
set -euo pipefail
source_date_epoch="$(git show -s --format=%ct "${{ inputs.source_sha }}")"
source_date_epoch="$(git show -s --format=%ct "${SOURCE_SHA}")"
if [[ ! "${source_date_epoch}" =~ ^[0-9]+$ ]]; then
echo "Could not read a Unix timestamp for source commit ${{ inputs.source_sha }}" >&2
echo "Could not read a Unix timestamp for source commit ${SOURCE_SHA}" >&2
exit 1
fi
SOURCE_DATE_EPOCH="${source_date_epoch}" \
@@ -278,8 +317,6 @@ jobs:
runs-on: windows-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.source_sha }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: artifacts
@@ -320,11 +357,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.source_sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
package-manager-cache: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: intermediate-artifacts
@@ -346,11 +382,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.source_sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
package-manager-cache: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: codewhale-release-assets
@@ -371,7 +406,7 @@ jobs:
{
echo "### Release artifact candidate"
echo ""
echo "- Source: \`${{ inputs.source_sha }}\`"
echo "- Source: \`${{ github.sha }}\`"
echo "- Version metadata: \`${{ inputs.version }}\`"
echo "- Inventory: 7 targets / 34 files (single binary; 7 legacy alias assets)"
echo "- Publication: none (Actions artifact \`codewhale-release-assets\` only)"

View File

@@ -31,6 +31,7 @@ jobs:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
package-manager-cache: false
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # stable 2026-07-18
with:
toolchain: stable
@@ -82,15 +83,14 @@ jobs:
run:
working-directory: web
steps:
# resolve already proved expected_sha equals GITHUB_SHA. Do not
# interpolate that SHA into checkout or the npm cache key.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
# No npm cache: this job checkouts needs.resolve.outputs.sha from
# workflow_dispatch (CodeQL #88#94). Lockfile-keyed caches would
# still be writable from that checkout into the default branch.
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install web dependencies
run: npm ci
- name: Check public facts drift

View File

@@ -116,17 +116,27 @@ jobs:
needs: resolve
runs-on: ubuntu-latest
steps:
# resolve already proved GITHUB_SHA equals the tag commit. Do not
# interpolate needs.resolve.outputs.sha into checkout or cache keys —
# CodeQL treats a *sha* ref as an untrusted checkout on workflow_dispatch
# (default-branch cache write).
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master 2026-07-18
with:
toolchain: stable
components: clippy, rustfmt
# Privileged release jobs check out a resolved SHA (tag or
# workflow_dispatch). Do not restore or save GitHub Actions caches
# here: a shared rust-cache / sccache key would let that checkout
# write into the default-branch cache (CodeQL #88#103).
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
{
echo "SCCACHE_GHA_ENABLED=true"
echo "RUSTC_WRAPPER=sccache"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1"
} >> "${GITHUB_ENV}"
- name: Install Linux system dependencies
run: |
for i in 1 2 3 4 5; do
@@ -135,6 +145,13 @@ jobs:
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
# Restore after the trusted lockfile is on disk. Key is OS + arch +
# explicit stable toolchain + rust-cache's Cargo.lock / rust-toolchain
# hash. Never interpolate github.event, github.ref, github.sha, or inputs.
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
cache-bin: false
prefix-key: v1-${{ runner.os }}-${{ runner.arch }}-stable
- name: Format check
run: cargo fmt --all -- --check
- name: Compile check
@@ -416,6 +433,7 @@ jobs:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
package-manager-cache: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: codewhale-release-assets

View File

@@ -46,22 +46,6 @@ def die(msg: str, code: int = 1) -> None:
raise SystemExit(code)
def _safe_catalog_path(path: str) -> str:
"""Catalog id path only — never a URL, token, or raw JSON value."""
return "".join(ch if ch.isalnum() or ch in ".-_/" else "?" for ch in path)[:200]
def _safe_source_label(source: str) -> str:
"""Log scheme + host (or file name), never query strings or credentials."""
if source.startswith("file:"):
return "file:<local>"
if source.startswith("url:"):
rest = source[4:]
host = rest.split("://", 1)[-1].split("/", 1)[0].split("@")[-1]
return f"url:https://{host}/" if host else "url:<redacted>"
return "source:<redacted>"
def load_json_bytes(raw: bytes, source: str) -> Any:
try:
text = raw.decode("utf-8")
@@ -186,6 +170,34 @@ def public_models_dev_document(data: dict[str, Any]) -> dict[str, Any]:
return out
def public_source_label(source: str) -> str:
"""Log a catalog origin without query/fragment (tokens live there)."""
if source.startswith("url:"):
url = source[4:]
for sep in ("?", "#"):
url = url.split(sep, 1)[0]
return f"url:{url}"
return source
def public_limit_value(value: Any) -> str:
"""Format a catalog limit for logs. Never print credential-shaped strings.
Remote catalog JSON is tainted for clear-text-logging rules. Only numeric
limits are meaningful here; anything else (including token-shaped strings)
is replaced with a constant so the raw value cannot reach stdout.
"""
if isinstance(value, bool):
return "redacted"
if value is None:
return "null"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
return format(value, ".6g")
return "redacted"
def catalog_stats(data: dict[str, Any]) -> str:
models = data.get("models") or {}
providers = data.get("providers") or {}
@@ -338,9 +350,11 @@ def _collect_limit_drift(
bundled = seed_limit.get(field)
upstream = upstream_limit.get(field)
if bundled != upstream:
# Log only the catalog path and field name. Never print raw
# upstream/bundled values — CodeQL #107 (clear-text logging).
drift.append(f"{_safe_catalog_path(path)}: limit.{field} differs")
drift.append(
f"{path}: limit.{field} "
f"bundled={public_limit_value(bundled)} "
f"upstream={public_limit_value(upstream)}"
)
def cmd_drift(args: argparse.Namespace) -> None:
@@ -406,12 +420,12 @@ def cmd_drift(args: argparse.Namespace) -> None:
drift,
)
print(f"bundled seed: {seed_path.name}")
print(f"upstream: {_safe_source_label(source)}")
print(f"bundled seed: {seed_path}")
print(f"upstream: {public_source_label(source)}")
if missing_upstream:
print("removed upstream (bundled id no longer present):")
for path in missing_upstream:
print(f" - {_safe_catalog_path(path)}")
print(f" - {path}")
if drift:
print(f"limit drift detected ({len(drift)}):")
for path in drift:

View File

@@ -99,6 +99,75 @@ class CatalogModelsDevScriptTests(unittest.TestCase):
self.assertIn("disk writes are intentionally unsupported", proc.stderr)
self.assertFalse(target.exists(), "refresh must remain dry-run only")
def test_public_limit_value_never_echoes_tokens(self) -> None:
sys.path.insert(0, str(ROOT / "scripts"))
import catalog_models_dev as mod # type: ignore
self.assertEqual(mod.public_limit_value(128000), "128000")
self.assertEqual(mod.public_limit_value(None), "null")
self.assertEqual(mod.public_limit_value("sk-this-is-a-token"), "redacted")
self.assertEqual(mod.public_limit_value({"authorization": "Bearer secret"}), "redacted")
self.assertEqual(mod.public_limit_value(True), "redacted")
def test_public_source_label_strips_query_string(self) -> None:
sys.path.insert(0, str(ROOT / "scripts"))
import catalog_models_dev as mod # type: ignore
self.assertEqual(
mod.public_source_label("url:https://models.dev/catalog.json?token=sk-leak"),
"url:https://models.dev/catalog.json",
)
self.assertEqual(mod.public_source_label("file:/tmp/catalog.json"), "file:/tmp/catalog.json")
def test_drift_does_not_print_token_shaped_upstream_limits(self) -> None:
with tempfile.TemporaryDirectory() as td:
seed = Path(td) / "seed.json"
upstream = Path(td) / "upstream.json"
seed.write_text(
json.dumps(
{
"models": {
"demo": {"limit": {"context": 1000, "output": 100}},
},
"providers": {},
}
),
encoding="utf-8",
)
upstream.write_text(
json.dumps(
{
"models": {
"demo": {
"limit": {
"context": 1000,
"output": "sk-this-is-a-token",
}
},
},
"providers": {},
"token": "sk-header-token",
}
),
encoding="utf-8",
)
env = os.environ.copy()
env["CODEWHALE_MODELS_DEV_PATH"] = str(upstream)
proc = subprocess.run(
[sys.executable, str(SCRIPT), "drift", "--seed", str(seed)],
cwd=ROOT,
capture_output=True,
text=True,
check=False,
env=env,
)
combined = f"{proc.stdout}\n{proc.stderr}"
self.assertNotEqual(proc.returncode, 0)
self.assertNotIn("sk-this-is-a-token", combined)
self.assertNotIn("sk-header-token", combined)
self.assertIn("redacted", combined)
self.assertIn("limit.output", combined)
if __name__ == "__main__":
unittest.main()