diff --git a/.github/scripts/release-workflows.test.js b/.github/scripts/release-workflows.test.js index c03a1e8ba..2896a0c75 100755 --- a/.github/scripts/release-workflows.test.js +++ b/.github/scripts/release-workflows.test.js @@ -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/); diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index cea9dd2d4..ca20c2448 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -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)" diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index dc80152fe..b1291ee3b 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90ec53c6b..7e4621926 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/scripts/catalog_models_dev.py b/scripts/catalog_models_dev.py index 6e69e953d..960ec83db 100755 --- a/scripts/catalog_models_dev.py +++ b/scripts/catalog_models_dev.py @@ -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:" - 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:" - return "source:" - - 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: diff --git a/scripts/catalog_models_dev_test.py b/scripts/catalog_models_dev_test.py index 412ad30e0..5bf72a65c 100755 --- a/scripts/catalog_models_dev_test.py +++ b/scripts/catalog_models_dev_test.py @@ -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()