diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 59b25e343..b3b1b26c7 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -210,6 +210,9 @@ jobs: if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then fail_unavailable "Noema app token exchange unavailable: OIDC request environment is missing." fi + if [ -z "${GITHUB_WORKFLOW_REF:-}" ]; then + fail_unavailable "Noema app token exchange unavailable: workflow identity is missing." + fi request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" separator="&" @@ -242,11 +245,28 @@ jobs: fail_unavailable "Noema app token exchange unavailable: app token request did not complete." fi - app_token="$(jq -r '.token // empty' <<<"$token_response")" + if ! jq -e \ + --arg target_repository "$TARGET_REPOSITORY" \ + --arg workflow_ref "$GITHUB_WORKFLOW_REF" ' + .ok == true + and (.data | type == "object") + and (.data.token | type == "string" and length > 0) + and .data.repository == $target_repository + and .data.workflow_ref == $workflow_ref + and (.data.token_expires_at | type == "string" and length > 0) + and ( + (try (.data.token_expires_at | fromdateiso8601) catch null) as $expires_at + | ($expires_at | type == "number") and $expires_at > now + ) + and (.trace_id | type == "string" and length > 0) + ' >/dev/null <<<"$token_response"; then + fail_unavailable "Noema app token exchange unavailable: response envelope was invalid." + fi + + app_token="$(jq -r '.data.token' <<<"$token_response")" if [ -z "$app_token" ]; then fail_unavailable "Noema app token exchange unavailable: app token response was empty." fi - echo "::add-mask::$app_token" echo "token=$app_token" >>"$GITHUB_OUTPUT" diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b0ef8d44..e1aba2305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,11 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Consume Noema's stable OIDC exchange `data.token` envelope instead of the + nonexistent top-level `token`, and fail closed unless the response is bound + to the requested repository, exact executing workflow ref, non-expired token + timestamp, and trace identifier before masking and exporting the credential. + - Publish only the sanitized cumulative Strix report tree, avoiding a later copy of relative scanner output that could reintroduce known internal warning text into uploaded security evidence. diff --git a/docs/doctoring/noema-oidc-exchange-envelope.md b/docs/doctoring/noema-oidc-exchange-envelope.md new file mode 100644 index 000000000..3b5ae4874 --- /dev/null +++ b/docs/doctoring/noema-oidc-exchange-envelope.md @@ -0,0 +1,99 @@ +# Noema OIDC exchange response-envelope contract + +검토 기준일: **2026-08-24** + +## 문제 + +중앙 `noema-review.yml`의 OIDC credential 경로는 Noema `/exchange` 성공 응답에서 top-level `.token`을 읽고 있었습니다. 그러나 Noema의 공개 API 안정성 계약은 성공 값을 다음과 같이 `data` object 아래에 둡니다. + +```json +{ + "ok": true, + "data": { + "token": "ghs_...", + "repository": "ContextualWisdomLab/example", + "workflow_ref": "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + "token_expires_at": "2026-08-07T12:00:00Z" + }, + "trace_id": "..." +} +``` + +따라서 provider가 token을 정상 발급해도 consumer가 `.token`을 조회하면 빈 값이 되어 중앙 reviewer가 항상 실패했습니다. 이 결함은 credential이 없는 것처럼 보이지만 실제 원인은 provider/consumer schema 불일치입니다. + +## 결정 + +OIDC consumer는 token field 하나만 permissive하게 조회하지 않고 다음 전체 contract를 fail closed로 검증합니다. + +1. top-level `ok`가 정확히 `true`여야 합니다. +2. `data`가 JSON object여야 합니다. +3. `data.token`이 비어 있지 않은 string이어야 합니다. +4. `data.repository`가 요청한 `TARGET_REPOSITORY`와 정확히 같아야 합니다. +5. Actions가 제공한 `GITHUB_WORKFLOW_REF`가 존재하고, + `data.workflow_ref`가 그 실행 workflow ref와 정확히 같아야 합니다. +6. `data.token_expires_at`가 RFC 3339 UTC timestamp로 해석 가능하고 현재 + 시각보다 뒤여야 합니다. +7. top-level `trace_id`가 비어 있지 않은 string이어야 합니다. +8. 검증된 뒤에만 `data.token`을 추출하고 즉시 GitHub Actions mask를 적용합니다. +9. malformed response를 진단할 때 raw response나 token 값을 출력하지 않습니다. + +이 변경은 Noema의 reviewer App, PAT fallback, LLM provider, +`NVIDIA_NIM_API_KEY`, repository permission 또는 merge authority를 변경하지 +않습니다. OIDC path가 이미 발행된 stable response envelope를 정확히 소비하도록 +고치는 interoperability repair입니다. Noema producer는 원래 OIDC assertion의 +audience, repository, workflow ref 및 source SHA를 검증하고 제한된 GitHub App +installation token을 발행합니다. 중앙 consumer는 그 assertion을 다시 검증한다고 +주장하지 않고, producer가 반환한 repository, workflow ref, expiry 및 trace binding을 +검증합니다. + +## 표준 근거 + +RFC 8259는 JSON object를 name/value member의 집합으로 정의하고, member name이 고유할 때 구현 간 mapping agreement가 가능하다고 설명합니다. 또한 networked JSON text는 UTF-8을 사용해야 하며 parser가 size·depth·string length 제한을 둘 수 있음을 명시합니다. 이 변경은 shell의 loose field lookup 대신 object shape와 typed member를 명시적으로 검사하여 producer/consumer가 같은 mapping을 사용하도록 합니다. + +NIST SP 800-218 SSDF Version 1.1은 소프트웨어 생산자가 vulnerability의 근본 원인을 줄이고 소비자·구매자와 공통 보안 언어로 소통할 수 있도록 secure-development practices를 SDLC에 통합할 것을 권고합니다. 현재 finalized baseline은 v1.1이며, Rev. 1 / SSDF Version 1.2는 2025년 12월 공개된 initial public draft입니다. 이 변경은 실제 integration failure를 회귀 계약으로 고정하고 permissive fallback 대신 명시적 failure evidence를 남긴다는 점에서 해당 원칙을 적용합니다. + +RFC 6749 places an OAuth access token at the top-level `access_token` member +(Hardt, 2012). Noema's public exchange instead wraps the GitHub App token under +`data.token` with repository, workflow, expiry, and trace evidence. NIST SP +800-63C-4 requires relying parties to validate assertion audience and time +windows and to preserve replay resistance (Temoshok et al., 2025). The Noema +producer performs the assertion validation; this consumer accepts the returned +credential only when its stable envelope is bound to this exact repository and +executing workflow and remains unexpired. Reading `.token` as if the response +were RFC 6749 instead treats a schema mismatch as a missing secret and discards +the binding evidence. + +## 회귀 계약 + +- workflow가 `.token // empty`를 사용하지 않습니다. +- `jq -e`가 stable envelope, target repository, exact executing workflow ref, + future expiry 및 trace identifier를 검증합니다. +- 추출 경로는 `.data.token`입니다. +- malformed envelope는 `response envelope was invalid`로 실패합니다. +- raw response는 diagnostic output으로 반사하지 않습니다. +- token은 output 기록 전에 `::add-mask::` 처리됩니다. + +## 롤백과 호환성 + +롤백은 top-level `.token`으로 되돌리는 것이 아니라, provider의 실제 stable envelope가 변경되었다는 독립적으로 검증된 근거가 있을 때 producer와 consumer 계약을 같은 변경에서 함께 갱신하는 방식으로 수행합니다. 기존 GitHub App 및 PAT credential 경로는 이 OIDC schema repair와 독립적으로 유지되며, standalone product repositories는 중앙 reviewer의 내부 response parsing에 런타임 결합되지 않습니다. + +## References (APA 7th) + +Bray, T. (2017). *The JavaScript Object Notation (JSON) data interchange format* (RFC 8259). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 + +ContextualWisdomLab. (2026). *Noema API specification* [Computer software +documentation]. GitHub. +https://github.com/ContextualWisdomLab/noema/blob/main/docs/api-spec.md + +Hardt, D. (Ed.). (2012). *The OAuth 2.0 authorization framework* (RFC 6749). +Internet Engineering Task Force. https://doi.org/10.17487/RFC6749 + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure Software Development Framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-218 + +National Institute of Standards and Technology. (2025, December 17). *Secure Software Development Framework (SSDF) version 1.2 is available for public comment*. https://www.nist.gov/news-events/news/2025/12/secure-software-development-framework-ssdf-version-12-available-public + +Temoshok, D., Richer, J., Choong, Y.-Y., Fenton, J., Lefkovitz, N., +Regenscheid, A., & Galluzzo, R. (2025). *Digital identity guidelines: +Federation and assertions* (NIST Special Publication 800-63C-4). National +Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-63c-4 diff --git a/tests/test_noema_oidc_exchange_contract.py b/tests/test_noema_oidc_exchange_contract.py new file mode 100644 index 000000000..7cbaf38c3 --- /dev/null +++ b/tests/test_noema_oidc_exchange_contract.py @@ -0,0 +1,171 @@ +"""Regression contracts for the Noema OIDC exchange consumer.""" + +import json +import os +import subprocess +from datetime import UTC, datetime, timedelta +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "noema-review.yml" + + +def workflow_step(workflow: str, name: str) -> str: + """Return one named workflow step without parsing untrusted YAML tags.""" + marker = f" - name: {name}\n" + start = workflow.index(marker) + try: + end = workflow.index("\n - name:", start + len(marker)) + except ValueError: + end = len(workflow) + return workflow[start:end] + + +def workflow_run_script(workflow: str, name: str) -> str: + """Return the executable shell body from one named workflow step.""" + step = workflow_step(workflow, name) + marker = " run: |\n" + body = step.split(marker, maxsplit=1)[1] + return "\n".join(line.removeprefix(" ") for line in body.splitlines()) + + +def run_exchange_script( + tmp_path: Path, token_response: dict[str, object] +) -> subprocess.CompletedProcess[str]: + """Execute the production exchange shell with a deterministic fake transport.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + script = workflow_run_script(workflow, "Exchange Noema app token through OIDC") + fake_bin = tmp_path / "bin" + fake_bin.mkdir(exist_ok=True) + fake_curl = fake_bin / "curl" + fake_curl.write_text( + """#!/usr/bin/env python3 +import os +import sys + +if "audience=" in sys.argv[-1]: + print('{"value":"synthetic-oidc-assertion"}') +else: + print(os.environ["FAKE_TOKEN_RESPONSE"]) +""", + encoding="utf-8", + ) + fake_curl.chmod(0o755) + github_output = tmp_path / "github-output" + github_output.unlink(missing_ok=True) + environment = os.environ.copy() + environment.update( + { + "PATH": f"{fake_bin}{os.pathsep}{environment['PATH']}", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "synthetic-request-token", + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://actions.invalid/id-token", + "OIDC_AUDIENCE": "synthetic-noema-review", + "TOKEN_EXCHANGE_URL": "https://noema.invalid/exchange", + "TARGET_REPOSITORY": "ExampleOrg/example-repository", + "GITHUB_WORKFLOW_REF": ( + "ExampleOrg/control-plane/.github/workflows/" + "noema-review.yml@refs/heads/main" + ), + "GITHUB_OUTPUT": str(github_output), + "FAKE_TOKEN_RESPONSE": json.dumps(token_response), + } + ) + return subprocess.run( + ["bash", "-c", script], + check=False, + capture_output=True, + env=environment, + text=True, + ) + + +def test_oidc_exchange_consumes_noema_standard_success_envelope() -> None: + """Require the central reviewer to consume Noema's stable data envelope.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + exchange = workflow_step(workflow, "Exchange Noema app token through OIDC") + + assert ".token // empty" not in exchange + assert "Noema app token exchange unavailable: response envelope was invalid." in exchange + assert 'if [ -z "${GITHUB_WORKFLOW_REF:-}" ]; then' in exchange + assert '--arg target_repository "$TARGET_REPOSITORY"' in exchange + assert '--arg workflow_ref "$GITHUB_WORKFLOW_REF"' in exchange + assert ".ok == true" in exchange + assert "(.data | type == \"object\")" in exchange + assert "(.data.token | type == \"string\" and length > 0)" in exchange + assert ".data.repository == $target_repository" in exchange + assert ".data.workflow_ref == $workflow_ref" in exchange + assert "(.data.token_expires_at | type == \"string\" and length > 0)" in exchange + assert "fromdateiso8601" in exchange + assert "$expires_at > now" in exchange + assert "(.trace_id | type == \"string\" and length > 0)" in exchange + assert 'app_token="$(jq -r \'.data.token\' <<<"$token_response")"' in exchange + + +def test_oidc_exchange_keeps_token_out_of_diagnostics() -> None: + """Require envelope failures to avoid reflecting raw credential material.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + exchange = workflow_step(workflow, "Exchange Noema app token through OIDC") + + assert 'echo "$token_response"' not in exchange + assert 'printf "%s" "$token_response"' not in exchange + mask = 'echo "::add-mask::$app_token"' + output = 'echo "token=$app_token" >>"$GITHUB_OUTPUT"' + assert mask in exchange + assert output in exchange + assert exchange.index(mask) < exchange.index(output) + + +def test_oidc_exchange_accepts_only_exact_live_producer_binding(tmp_path: Path) -> None: + """Exercise the production shell against realistic valid and invalid envelopes.""" + repository = "ExampleOrg/example-repository" + workflow_ref = ( + "ExampleOrg/control-plane/.github/workflows/" + "noema-review.yml@refs/heads/main" + ) + future_expiry = (datetime.now(UTC) + timedelta(hours=1)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + valid = { + "ok": True, + "data": { + "token": "synthetic-app-token", + "repository": repository, + "workflow_ref": workflow_ref, + "token_expires_at": future_expiry, + }, + "trace_id": "synthetic-trace-id", + } + + accepted = run_exchange_script(tmp_path, valid) + + assert accepted.returncode == 0, accepted.stdout + accepted.stderr + assert "::add-mask::synthetic-app-token" in accepted.stdout + assert (tmp_path / "github-output").read_text(encoding="utf-8") == ( + "token=synthetic-app-token\n" + ) + + invalid_responses = [ + {"ok": True, "token": "synthetic-app-token"}, + {**valid, "data": {**valid["data"], "repository": "ExampleOrg/other"}}, + { + **valid, + "data": {**valid["data"], "workflow_ref": "ExampleOrg/other/workflow"}, + }, + { + **valid, + "data": { + **valid["data"], + "token_expires_at": "2000-01-01T00:00:00Z", + }, + }, + {**valid, "data": {**valid["data"], "token_expires_at": "not-a-time"}}, + {key: value for key, value in valid.items() if key != "trace_id"}, + ] + + for invalid in invalid_responses: + rejected = run_exchange_script(tmp_path, invalid) + diagnostic = rejected.stdout + rejected.stderr + assert rejected.returncode != 0 + assert "response envelope was invalid" in diagnostic + assert "synthetic-app-token" not in diagnostic + assert not (tmp_path / "github-output").exists()