[seclift] ephemeral Infisical OIDC validation #3847
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: ci-e2e | |
| on: | |
| push: | |
| branches: [dev, master] | |
| pull_request: | |
| branches: [dev, master] | |
| jobs: | |
| tests: | |
| runs-on: ubuntu-latest-4-cores | |
| strategy: | |
| matrix: | |
| browser: ["chromium", "firefox", "webkit"] | |
| steps: | |
| - 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 = "<no body>" | |
| 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@v4 | |
| - name: Install poetry | |
| run: pipx install poetry | |
| - name: Set up Python 3.11.8 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11.8" | |
| cache: 'poetry' | |
| - name: Use Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: "22.x" | |
| cache: npm | |
| - name: install python3 environment | |
| run: poetry install --with build | |
| - name: install ci dependencies and generate code | |
| run: poetry run alfred install.ci | |
| - name: Build UI | |
| run: npm run build | |
| - name: Install E2E browsers | |
| run: npm run e2e:setup ${{ matrix.browser }} | |
| - name: Run E2E tests | |
| run: poetry run alfred ci --e2e=${{ matrix.browser }} | |
| 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: | | |
| ["AGENT_MANAGER_PAT","DOCS_WORKFLOW_TOKEN","PYPI_TOKEN","SLACK_WEBHOOK_DOC"] | |
| GITHUB_REPOSITORY: ${{ github.repository }} | |
| timeout-minutes: 15 |