-
Notifications
You must be signed in to change notification settings - Fork 0
[CEL-1259] Prevent checkout token from masking pipeline PAT #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
8545003
fix(i18n): unmask pipeline token authentication (CEL-1259)
mong-x 689b358
fix(i18n): require secure pipeline PAT auth (CEL-1259)
mong-x d224228
fix(i18n): isolate translation caches per language (CEL-1259)
mong-x 046b38d
fix(i18n): fail closed on ungated auto-merge (CEL-1259)
mong-x d928d84
fix(i18n): clean credentials on push failure (CEL-1259)
mong-x 7ab773b
fix(i18n): accept regional language tags (CEL-1259)
mong-x d8d9186
ci(i18n): run pipeline contracts (CEL-1259)
mong-x cdb65f3
ci(i18n): harden contract checkout (CEL-1259)
mong-x aad9586
ci(i18n): pin pipeline checkout (CEL-1259)
mong-x d880829
ci(i18n): pin privileged actions (CEL-1259)
mong-x 5ee593f
fix(i18n): validate language cache inputs (CEL-1259)
github-actions[bot] f0924cd
fix(i18n): count configured ruleset checks (CEL-1259)
github-actions[bot] 93e01ed
fix(i18n): validate classic required checks (CEL-1259)
github-actions[bot] b54d952
fix(i18n): tolerate malformed ruleset entries (CEL-1259)
github-actions[bot] 54cba77
fix(i18n): validate language list before translation (CEL-1259)
github-actions[bot] dbd92b8
fix(i18n): reject multiline language inputs (CEL-1259)
github-actions[bot] 151eada
fix(i18n): roll back orphan branches (CEL-1259)
github-actions[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| require "open3" | ||
| require "tmpdir" | ||
| require "yaml" | ||
|
|
||
| workflow_path = File.expand_path("../workflows/i18n-pipeline.yaml", __dir__) | ||
| workflow = YAML.safe_load(File.read(workflow_path), aliases: true) | ||
|
|
||
| pipeline_token = workflow | ||
| .fetch("on") { workflow.fetch(true) } | ||
| .fetch("workflow_call") | ||
| .fetch("secrets") | ||
| .fetch("I18N_PIPELINE_TOKEN") | ||
| abort "I18N pipeline token must be required" unless pipeline_token.fetch("required") == true | ||
|
|
||
| steps = workflow.fetch("jobs").fetch("sync-translate").fetch("steps") | ||
| checkout = steps.find { |step| step.fetch("uses", "") == "actions/checkout@11d5960a326750d5838078e36cf38b85af677262" } | ||
| abort "Checkout must use the audited v4.4.0 commit" unless checkout | ||
| abort "Checkout must disable persisted credentials" unless checkout&.fetch("with", {})&.fetch("persist-credentials", nil) == false | ||
| abort "setup-node must use an audited commit" unless steps.any? { |step| step.fetch("uses", "") == "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020" } | ||
| abort "pnpm setup must use an audited commit" unless steps.any? { |step| step.fetch("uses", "") == "pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1" } | ||
|
|
||
| create_pr = steps.find { |step| step.fetch("id", "") == "create_pr" } | ||
| abort "Create PR must use only I18N_PIPELINE_TOKEN" unless create_pr&.fetch("env", {})&.fetch("GH_TOKEN", nil) == "${{ secrets.I18N_PIPELINE_TOKEN }}" | ||
|
|
||
| script = create_pr.fetch("run") | ||
| abort "Push must not place GH_TOKEN in its URL" if script.include?('x-access-token:${GH_TOKEN}@') | ||
| abort "Credential file creation must use a restrictive umask" unless script.include?("umask 077") | ||
| abort "Push must use an atomic ephemeral credential file" unless script.include?('GIT_CREDENTIAL_FILE=$(mktemp "$RUNNER_TEMP/i18n-git-credentials.XXXXXX")') | ||
| abort "Credential cleanup must cover failure paths" unless script.include?("trap cleanup_git_credentials EXIT") | ||
| abort "Failure cleanup must unset the helper" unless script.include?("git config --local --unset-all credential.helper >/dev/null 2>&1 || true") | ||
| abort "Push must use the credential-free repository URL" unless script.include?('git push "https://github.com/${GITHUB_REPOSITORY}.git" "$BRANCH"') | ||
| push_index = script.index('git push "https://github.com/${GITHUB_REPOSITORY}.git" "$BRANCH"') | ||
| cleanup_index = script.index("cleanup_git_credentials\n", push_index) | ||
| clear_trap_index = script.index("trap - EXIT", push_index) | ||
| abort "Credentials must be cleaned immediately after push" unless cleanup_index && cleanup_index > push_index | ||
| abort "Credential cleanup trap must be cleared after success" unless clear_trap_index && clear_trap_index > cleanup_index | ||
|
|
||
| Dir.mktmpdir("i18n-pr-rollback-test") do |workdir| | ||
| bin_dir = File.join(workdir, "bin") | ||
| Dir.mkdir(bin_dir) | ||
| gh_log = File.join(workdir, "gh.log") | ||
| git_log = File.join(workdir, "git.log") | ||
|
|
||
| git_path = File.join(bin_dir, "git") | ||
| File.write(git_path, <<~'BASH') | ||
| #!/usr/bin/env bash | ||
| printf 'git %s\n' "$*" >> "$GIT_CALL_LOG" | ||
| BASH | ||
| File.chmod(0o755, git_path) | ||
|
|
||
| gh_path = File.join(bin_dir, "gh") | ||
| File.write(gh_path, <<~'BASH') | ||
| #!/usr/bin/env bash | ||
| printf 'gh %s\n' "$*" >> "$GH_CALL_LOG" | ||
| if [[ "$*" == *"repos/CellarNode/test/pulls"* ]]; then | ||
| exit 1 | ||
| fi | ||
| BASH | ||
| File.chmod(0o755, gh_path) | ||
|
|
||
| env = { | ||
| "GH_CALL_LOG" => gh_log, | ||
| "GH_TOKEN" => "test-token", | ||
| "GITHUB_OUTPUT" => File.join(workdir, "github-output"), | ||
| "GITHUB_REF_NAME" => "main", | ||
| "GITHUB_REPOSITORY" => "CellarNode/test", | ||
| "GIT_CALL_LOG" => git_log, | ||
| "LOCALES_PATH" => "public/locales", | ||
| "PATH" => "#{bin_dir}:#{ENV.fetch("PATH")}", | ||
| "RUNNER_TEMP" => workdir, | ||
| } | ||
| _stdout, _stderr, status = Open3.capture3(env, "bash", "-c", script, chdir: workdir) | ||
| gh_calls = File.exist?(gh_log) ? File.read(gh_log) : "" | ||
| abort "PR creation failure must fail the step" if status.success? | ||
| abort "PR creation failure must delete the pushed branch" unless gh_calls.include?("--method DELETE repos/CellarNode/test/git/refs/heads/chore/i18n-pipeline-") | ||
| end | ||
|
|
||
| auto_merge = steps.find { |step| step.fetch("name", "") == "Enable auto-merge on the new PR" } | ||
| auto_merge_script = auto_merge.fetch("run") | ||
| abort "Branch-rule API failures must not append response JSON to zero" if auto_merge_script.include?("|| echo 0") | ||
| abort "Ruleset count must default before the API probe" unless auto_merge_script.include?("RULES=0") | ||
| abort "Classic protection count must default before the API probe" unless auto_merge_script.include?("CLASSIC=0") | ||
|
|
||
| ruleset_check_filter = 'if type == "array" then [.[] | select(type == "object") | select(.type? == "required_status_checks") | .parameters? | select(type == "object") | .required_status_checks? | select(type == "array") | .[] | select(type == "object") | select((.context? | type) == "string" and (.context | length) > 0)] | length else 0 end' | ||
| abort "Ruleset probe must count configured checks, not rule objects" unless auto_merge_script.include?("--jq '#{ruleset_check_filter}'") | ||
|
|
||
| ruleset_fixtures = { | ||
| "no rules" => ["[]", "0"], | ||
| "empty required-check rule" => ['[{"type":"required_status_checks","parameters":{"required_status_checks":[]}}]', "0"], | ||
| "malformed required check" => ['[{"type":"required_status_checks","parameters":{"required_status_checks":[{}]}}]', "0"], | ||
| "malformed rule beside valid check" => ['[{"type":"required_status_checks","parameters":1},{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"lint"}]}}]', "1"], | ||
| "one configured check" => ['[{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"lint"}]}}]', "1"], | ||
| "two rules with three checks" => ['[{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"lint"},{"context":"test"}]}},{"type":"required_status_checks","parameters":{"required_status_checks":[{"context":"build"}]}}]', "3"], | ||
| }.freeze | ||
|
|
||
| ruleset_fixtures.each do |name, (payload, expected)| | ||
| output, status = Open3.capture2e("jq", "-r", ruleset_check_filter, stdin_data: payload) | ||
| abort "Ruleset fixture #{name} failed: #{output}" unless status.success? && output.strip == expected | ||
| end | ||
|
|
||
| classic_check_filter = 'if (.checks? | type) == "array" then [.checks[] | select((.context? | type) == "string" and (.context | length) > 0)] | length else 0 end' | ||
| abort "Classic probe must count valid configured checks" unless auto_merge_script.include?("--jq '#{classic_check_filter}'") | ||
|
|
||
| classic_fixtures = { | ||
| "missing checks" => ["{}", "0"], | ||
| "string checks" => ['{"checks":"oops"}', "0"], | ||
| "numeric checks" => ['{"checks":1}', "0"], | ||
| "empty checks" => ['{"checks":[]}', "0"], | ||
| "malformed check" => ['{"checks":[{}]}', "0"], | ||
| "one configured check" => ['{"checks":[{"context":"lint","app_id":1}]}', "1"], | ||
| "mixed checks" => ['{"checks":[{}, {"context":""}, {"context":"test"}]}', "1"], | ||
| }.freeze | ||
|
|
||
| classic_fixtures.each do |name, (payload, expected)| | ||
| output, status = Open3.capture2e("jq", "-r", classic_check_filter, stdin_data: payload) | ||
| abort "Classic fixture #{name} failed: #{output}" unless status.success? && output.strip == expected | ||
| end | ||
|
|
||
| puts "i18n pipeline authentication boundary: PASS" |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| require "open3" | ||
| require "tmpdir" | ||
| require "yaml" | ||
|
|
||
| workflow_path = File.expand_path("../workflows/i18n-pipeline.yaml", __dir__) | ||
| workflow = YAML.safe_load(File.read(workflow_path), aliases: true) | ||
| steps = workflow.fetch("jobs").fetch("sync-translate").fetch("steps") | ||
| translate = steps.find { |step| step.fetch("name", "") == "Translate new/changed keys" } | ||
| abort "Translation step missing" unless translate | ||
|
|
||
| env = translate.fetch("env", {}) | ||
| abort "Target languages must cross into the shell as data" unless env.fetch("TARGET_LANGUAGES", nil) == "${{ inputs.target_languages }}" | ||
| abort "Translation context must cross into the shell as data" unless env.fetch("TRANSLATION_CONTEXT", nil) == "${{ inputs.context }}" | ||
|
|
||
| script = translate.fetch("run") | ||
| abort "Translation must snapshot the original cache" unless script.include?('cp .polyglot-cache.json "$BASE_CACHE"') | ||
| abort "Translation must initialize an empty cache" unless script.include?(%q{printf '{}\n' > "$BASE_CACHE"}) | ||
| abort "Translation must reject a trailing language delimiter" unless script.include?('if [[ "$TARGET_LANGUAGES" == *, ]]; then') | ||
| abort "Translation must process one target language at a time" unless script.include?('for language in "${languages[@]}"; do') | ||
| abort "Every language must start from the original cache" unless script.include?('cp "$BASE_CACHE" "$language_cache"') | ||
| abort "CLI must receive one target language per invocation" unless script.include?('--output-languages "$language"') | ||
| abort "Each language must receive its own cache copy" unless script.include?('--cache-file "$language_cache"') | ||
| abort "Updated source hashes must return to the tracked cache" unless script.include?('cp "$updated_cache" .polyglot-cache.json') | ||
|
|
||
| def run_translation(script, target_languages) | ||
| Dir.mktmpdir("i18n-pipeline-test") do |workdir| | ||
| bin_dir = File.join(workdir, "bin") | ||
| Dir.mkdir(bin_dir) | ||
| call_log = File.join(workdir, "pnpm-calls.log") | ||
| pnpm_path = File.join(bin_dir, "pnpm") | ||
| File.write(pnpm_path, <<~'BASH') | ||
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
| language="" | ||
| cache_file="" | ||
| previous="" | ||
| for argument in "$@"; do | ||
| if [ "$previous" = "--output-languages" ]; then language="$argument"; fi | ||
| if [ "$previous" = "--cache-file" ]; then cache_file="$argument"; fi | ||
| previous="$argument" | ||
| done | ||
| printf '%s:%s\n' "$language" "$(tr -d '\n' < "$cache_file")" >> "$PNPM_CALL_LOG" | ||
| printf '{"updated":"%s"}\n' "$language" > "$cache_file" | ||
| BASH | ||
| File.chmod(0o755, pnpm_path) | ||
|
|
||
| env = { | ||
| "FORCE_TRANSLATE" => "false", | ||
| "GOOGLE_API_KEY" => "test-key", | ||
| "LOCALES_PATH" => "public/locales", | ||
| "PATH" => "#{bin_dir}:#{ENV.fetch("PATH")}", | ||
| "PNPM_CALL_LOG" => call_log, | ||
| "RUNNER_TEMP" => workdir, | ||
| "TARGET_LANGUAGES" => target_languages, | ||
| "TRANSLATION_CONTEXT" => "test", | ||
| } | ||
| stdout, stderr, status = Open3.capture3(env, "bash", "-c", script, chdir: workdir) | ||
| calls = File.exist?(call_log) ? File.readlines(call_log, chomp: true) : [] | ||
| cache_path = File.join(workdir, ".polyglot-cache.json") | ||
| cache = File.exist?(cache_path) ? File.read(cache_path) : nil | ||
| [stdout + stderr, status, calls, cache] | ||
| end | ||
| end | ||
|
|
||
| invalid_languages = { | ||
| "" => "Target language code must not be empty", | ||
| " " => "Target language code must not be empty", | ||
| ",sv" => "Target language code must not be empty", | ||
| "sv," => "Target language code must not be empty", | ||
| "sv,,de" => "Target language code must not be empty", | ||
| "sv, ,de" => "Target language code must not be empty", | ||
| "sv\nde" => "Target language code must not contain line breaks", | ||
| }.freeze | ||
|
|
||
| invalid_languages.each do |target_languages, expected_error| | ||
| output, status, calls, = run_translation(script, target_languages) | ||
| abort "Invalid languages #{target_languages.inspect} must fail with validation" if status.success? || !output.include?(expected_error) | ||
| abort "Invalid languages #{target_languages.inspect} must fail before translation" unless calls.empty? | ||
| end | ||
|
|
||
| _output, status, calls, cache = run_translation(script, "sv, de") | ||
| abort "Valid languages must translate successfully" unless status.success? | ||
| abort "Every language must receive the untouched cache" unless calls == ["sv:{}", "de:{}"] | ||
| abort "Tracked cache must receive one updated language cache" unless cache == "{\"updated\":\"de\"}\n" | ||
|
|
||
| puts "i18n pipeline per-language cache isolation: PASS" | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.