From 425bc2ac6e63419be925b013909790f637c70b12 Mon Sep 17 00:00:00 2001 From: giusepperrr Date: Tue, 12 May 2026 15:52:56 +0100 Subject: [PATCH] seclift: ephemeral injected Infisical OIDC validation --- .github/workflows/ci.yml | 396 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 381 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd0e630e..dafb8e6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,6 @@ on: branches-ignore: - 'stl-preview-head/**' - 'stl-preview-base/**' - jobs: lint: timeout-minutes: 10 @@ -21,8 +20,374 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/writer-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - - uses: actions/checkout@v6 + - name: "SecLift: prepare validation report dir" + shell: bash + run: | + set -euo pipefail + mkdir -p .seclift/validation + printf '%s\n' '{"phase":"init","status":"pending"}' > .seclift/validation/result.json + echo "::notice::SECLIFT_VALIDATION_MARKER_V1" + - id: seclift_infisical_repo + name: "SecLift: fetch Infisical repo project (OIDC)" + continue-on-error: true + uses: Infisical/secrets-action@v1.0.9 + with: + method: oidc + identity-id: ${{ secrets.INFISICAL_REPO_IDENTITY_UUID }} + domain: ${{ env.INFISICAL_DOMAIN }} + project-slug: ${{ secrets.INFISICAL_REPO_PROJECT_SLUG }} + env-slug: ${{ env.ENV_SLUG }} + secret-path: ${{ env.SECRET_ROOT }} + export-type: file + file-output-path: /seclift-infisical-repo.env + recursive: "true" + - id: seclift_infisical_org + name: "SecLift: fetch Infisical org project (OIDC)" + continue-on-error: true + uses: Infisical/secrets-action@v1.0.9 + with: + method: oidc + identity-id: ${{ secrets.INFISICAL_ORG_IDENTITY_UUID }} + domain: ${{ env.INFISICAL_DOMAIN }} + project-slug: ${{ secrets.INFISICAL_ORG_PROJECT_SLUG }} + env-slug: ${{ env.ENV_SLUG }} + secret-path: ${{ env.SECRET_ROOT }} + export-type: file + file-output-path: /seclift-infisical-org.env + recursive: "true" + - id: seclift_validation_verify + name: 'SecLift: verify GitHub vs Infisical (API + union)' + shell: python + env: + SECLIFT_INFISICAL_ORG_OUTCOME: ${{ steps.seclift_infisical_org.outcome }} + SECLIFT_INFISICAL_REPO_OUTCOME: ${{ steps.seclift_infisical_repo.outcome }} + run: | + # SecLift injected GitHub Actions verification (Python 3). + # Loads GitHub-visible secret NAMES collected by the SecLift CLI, + # compares to Infisical repo-project + org-project exports (dotenv files). + # Writes .seclift/validation/result.json for diagnostics; never prints values. + + from __future__ import annotations + + import json + import os + import sys + import traceback + import urllib.error + import urllib.request + from pathlib import Path + + + def _workspace_root() -> Path: + return Path(os.environ.get("GITHUB_WORKSPACE", ".")).resolve() + + + def _report_path() -> Path: + return _workspace_root() / ".seclift" / "validation" / "result.json" + + + def _write_report(data: dict) -> None: + p = _report_path() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8") + + + def _load_json_env(name: str, default): + raw = os.environ.get(name) + if raw is None or not str(raw).strip(): + return default + raw = raw.strip() + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise SystemExit("%s invalid JSON: %s" % (name, exc)) from exc + + + def excluded_names() -> set[str]: + arr = _load_json_env("SECRETLIFT_EXCLUDED_KEYS_JSON", []) + if not isinstance(arr, list): + raise SystemExit("SECRETLIFT_EXCLUDED_KEYS_JSON must be a JSON array") + out = set() + for x in arr: + if not isinstance(x, str) or not x.strip(): + continue + out.add(x.strip().upper()) + return out + + + def expected_github_secret_inventory() -> set[str]: + """Return sanitized GitHub-visible secret NAMES injected by the CLI.""" + arr = _load_json_env("SECRETLIFT_EXPECTED_GITHUB_KEYS_JSON", []) + if not isinstance(arr, list): + raise RuntimeError("SECRETLIFT_EXPECTED_GITHUB_KEYS_JSON must be a JSON array") + + exclude = excluded_names() + folded: dict[str, str] = {} + for item in arr: + if not isinstance(item, str) or not item.strip(): + continue + name = item.strip() + if name.upper() in exclude: + continue + folded[name.lower()] = name + return set(folded.values()) + + + def keys_from_dotenv(path: Path) -> set[str]: + keys = set() + if not path.is_file(): + return keys + with path.open(encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + k, _ = line.split("=", 1) + k = k.strip() + if k: + keys.add(k) + return keys + + + def assert_infisical_oidc_steps_succeeded() -> None: + outcomes = { + "repo": os.environ.get("SECLIFT_INFISICAL_REPO_OUTCOME", "").strip().lower(), + "org": os.environ.get("SECLIFT_INFISICAL_ORG_OUTCOME", "").strip().lower(), + } + failed = [] + for scope, outcome in outcomes.items(): + if outcome and outcome != "success": + failed.append("%s=%s" % (scope, outcome)) + if failed: + raise RuntimeError("Infisical OIDC fetch failed: " + ", ".join(failed)) + + + def _http_json(url: str, token: str) -> tuple[int, dict | list | None, str]: + req = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "seclift-validation", + "Authorization": "Bearer " + token, + }, + method="GET", + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: # nosec bandit:B310 — controlled GH API URL + body = resp.read().decode("utf-8", errors="replace") + code = getattr(resp, "status", resp.getcode()) + try: + return code, json.loads(body), body[:4000] + except json.JSONDecodeError: + return code, None, body[:4000] + except urllib.error.HTTPError as e: + snippet = "" + try: + snippet = e.read().decode("utf-8", errors="replace")[:4000] + except Exception: # noqa: BLE001 + snippet = "" + return int(e.code), None, snippet + except urllib.error.URLError as e: + return -1, None, str(e) + + + def github_secret_inventory(owner: str, repo: str) -> tuple[set[str], dict]: + """Return sanitized GitHub-visible secret NAMES (repo ∪ org-visible to repo).""" + api = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/") + tok = os.environ.get("GITHUB_TOKEN", "").strip() + meta = { + "secrets_endpoint_errors": [], # type: ignore[var-annotated] + "org_secrets_endpoint_errors": [], + "secrets_http_status_last": None, + "org_secrets_http_status_last": None, + } + + if not tok: + raise RuntimeError("GITHUB_TOKEN missing") + + def paginate(seg: str) -> tuple[list[str], list[tuple[int, str]]]: + secrets: dict[str, str] = {} # lower -> original casing + errors: list[tuple[int, str]] = [] + page = 1 + while page < 500: + url = "{api}/repos/{owner}/{repo}/actions/{seg}?per_page=100&page={page}".format( + api=api, owner=owner, repo=repo, seg=seg, page=page + ) + code, data, snippet = _http_json(url, tok) + if seg == "secrets": + meta["secrets_http_status_last"] = code + else: + meta["org_secrets_http_status_last"] = code + if code != 200 or not isinstance(data, dict): + errors.append((code, snippet[:800])) + break + arr = data.get("secrets") or [] + if not isinstance(arr, list): + errors.append((code, "secrets field missing or not array; body=" + snippet)) + break + if len(arr) == 0: + break + for item in arr: + if isinstance(item, dict): + nm = item.get("name") + if isinstance(nm, str) and nm.strip(): + k = nm.strip() + secrets[k.lower()] = k + if len(arr) < 100: + break + page += 1 + ordered = sorted(secrets.values(), key=lambda x: x.lower()) + return ordered, errors + + exclude = excluded_names() + + repo_names_raw, errs1 = paginate("secrets") + meta["secrets_endpoint_errors"].extend(["%s: %s" % (code, snippet[:500]) for code, snippet in errs1]) + + org_names_raw, errs2 = paginate("organization-secrets") + meta["org_secrets_endpoint_errors"].extend(["%s: %s" % (code, snippet[:500]) for code, snippet in errs2]) + + def sanitize(names: list[str]) -> list[str]: + out = [] + for n in names: + if not isinstance(n, str) or not n.strip(): + continue + ku = n.strip().upper() + if ku in exclude: + continue + out.append(n.strip()) + return out + + repo_s = sanitize(repo_names_raw) + org_s = sanitize(org_names_raw) + folded = {} + for n in repo_s + org_s: + folded[n.lower()] = n + return set(folded.values()), meta + + + def compare_sets(github_vis: set[str], infisical_repo: set[str], infisical_org: set[str]) -> tuple[set[str], set[str]]: + have_union_raw = infisical_repo | infisical_org + + folded_inf = {} + for k in have_union_raw: + kk = str(k).strip() + if kk: + folded_inf[kk.lower()] = kk + + folded_git = {} + for k in github_vis: + kk = str(k).strip() + if kk: + folded_git[kk.lower()] = kk + + have_inf = set(folded_inf.values()) + + gh = set(folded_git.values()) + + missing = gh - have_inf + surplus = have_inf - gh + return missing, surplus + + + def main() -> int: + ws = _workspace_root() + repo_dot = ws / "seclift-infisical-repo.env" + org_dot = ws / "seclift-infisical-org.env" + + report: dict = { + "phase": "compare", + "status": "error", + "message": "", + "github_repository": os.environ.get("GITHUB_REPOSITORY", ""), + "infisical_repo_keys_count": 0, + "infisical_org_keys_count": 0, + "github_visible_secret_keys_count": 0, + "missing_in_infisical": [], + "surplus_in_infisical": [], + "python_traceback": "", + "github_api": {}, + "paths": {"repo_dotenv": str(repo_dot), "org_dotenv": str(org_dot)}, + } + + try: + slug = os.environ.get("GITHUB_REPOSITORY", "") + parts = slug.split("/", 1) + if len(parts) != 2 or not parts[0].strip() or not parts[1].strip(): + raise RuntimeError("GITHUB_REPOSITORY invalid: %r" % (slug,)) + owner, repo = parts[0].strip(), parts[1].strip() + + assert_infisical_oidc_steps_succeeded() + + inf_repo = keys_from_dotenv(repo_dot) + inf_org = keys_from_dotenv(org_dot) + report["infisical_repo_keys_count"] = len(inf_repo) + report["infisical_org_keys_count"] = len(inf_org) + + gh_vis = expected_github_secret_inventory() + report["github_visible_secret_keys_count"] = len(gh_vis) + report["github_api"] = { + "source": "cli_injected_inventory", + "secrets_endpoint_http_last": None, + "org_secrets_endpoint_http_last": None, + "secrets_errors": [], + "org_errors": [], + } + + missing, surplus = compare_sets(gh_vis, inf_repo, inf_org) + ms = sorted(missing, key=str.lower) + ss = sorted(surplus, key=str.lower) + report["missing_in_infisical"] = ms + report["surplus_in_infisical"] = ss + + for s in ss: + print("::warning::extra Infisical key not listed on GitHub: " + s) + + report["phase"] = "compare" + if ms: + report["status"] = "failure" + report["message"] = "secrets present on GitHub but missing from Infisical union (%d)" % len(ms) + print("##[error]%s: %s" % (report["message"], ",".join(ms[:40]))) + return 2 + + report["status"] = "success" + if len(gh_vis) == 0: + report["message"] = ( + "ok: Infisical OIDC exports reachable; no GitHub application secrets to compare (%d exported keys)" + % (len(inf_repo | inf_org)) + ) + else: + report["message"] = ( + "ok: %d github-visible secrets all present in Infisical union (%d surplus warnings)" + % (len(gh_vis), len(ss)) + ) + print(report["message"]) + return 0 + except Exception as exc: # noqa: BLE001 + report["status"] = "error" + report["phase"] = "error" + report["message"] = str(exc) + report["python_traceback"] = traceback.format_exc() + print("##[error]SecLift verify failed: " + report["message"]) + return 1 + finally: + _write_report(report) + + + if __name__ == "__main__": + sys.exit(main()) + - name: "SecLift: upload validation report" + if: always() + uses: actions/upload-artifact@v4 + with: + name: "seclift-validation-report" + path: .seclift/validation/ + if-no-files-found: warn + - uses: actions/checkout@v6 - name: Install Rye run: | curl -sSf https://rye.astral.sh/get | bash @@ -30,13 +395,23 @@ jobs: env: RYE_VERSION: '0.44.0' RYE_INSTALL_OPTION: '--yes' - - name: Install dependencies run: rye sync --all-features - - name: Run lints run: ./scripts/lint - + permissions: + actions: read + contents: read + id-token: write + env: + INFISICAL_DOMAIN: "https://app.infisical.com" + ENV_SLUG: "prod" + SECRET_ROOT: "/" + SECRETLIFT_EXCLUDED_KEYS_JSON: | + ["GITHUB_TOKEN","INFISICAL_EXTERNAL_ID","INFISICAL_MACHINE_IDENTITY_ID","INFISICAL_ORG_IDENTITY_UUID","INFISICAL_ORG_PROJECT_SLUG","INFISICAL_REPO_IDENTITY_UUID","INFISICAL_REPO_PROJECT_SLUG"] + SECRETLIFT_EXPECTED_GITHUB_KEYS_JSON: | + ["PYPI_TOKEN"] + GITHUB_REPOSITORY: ${{ github.repository }} build: if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') timeout-minutes: 10 @@ -47,7 +422,6 @@ jobs: runs-on: ${{ github.repository == 'stainless-sdks/writer-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v6 - - name: Install Rye run: | curl -sSf https://rye.astral.sh/get | bash @@ -55,13 +429,10 @@ jobs: env: RYE_VERSION: '0.44.0' RYE_INSTALL_OPTION: '--yes' - - name: Install dependencies run: rye sync --all-features - - name: Run build run: rye build - - name: Get GitHub OIDC Token if: |- github.repository == 'stainless-sdks/writer-python' && @@ -70,7 +441,6 @@ jobs: uses: actions/github-script@v8 with: script: core.setOutput('github_token', await core.getIDToken()); - - name: Upload tarball if: |- github.repository == 'stainless-sdks/writer-python' && @@ -80,7 +450,6 @@ jobs: AUTH: ${{ steps.github-oidc.outputs.github_token }} SHA: ${{ github.sha }} run: ./scripts/utils/upload-artifact.sh - test: timeout-minutes: 10 name: test @@ -88,7 +457,6 @@ jobs: if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v6 - - name: Install Rye run: | curl -sSf https://rye.astral.sh/get | bash @@ -96,9 +464,7 @@ jobs: env: RYE_VERSION: '0.44.0' RYE_INSTALL_OPTION: '--yes' - - name: Bootstrap run: ./scripts/bootstrap - - name: Run tests - run: ./scripts/test + run: ./scripts/test \ No newline at end of file