diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9aab98f..5b2ddba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,10 @@ jobs: bash tests/test_workflow_shared_config.sh bash tests/test_docker_compose_ports.sh bash tests/test_read_only_vm_metadata_diagnostic.sh + bash tests/test_inspect_gateway_connections.sh + node tests/test_filter_github_action_auth_logs.cjs + node tests/test_parse_protected_gateway_index.cjs + node tests/test_gateway_workflow_resolve.cjs python3 -m unittest discover -s tests -p 'test_match_gateway_metadata.py' docker-build: diff --git a/.github/workflows/read-only-vm-metadata-diagnostic.yml b/.github/workflows/read-only-vm-metadata-diagnostic.yml index 58c4d06..c59f0fa 100644 --- a/.github/workflows/read-only-vm-metadata-diagnostic.yml +++ b/.github/workflows/read-only-vm-metadata-diagnostic.yml @@ -12,6 +12,11 @@ on: required: true default: false type: boolean + inspect_connections: + description: Passively inspect container and socket state for the protected matched target + required: true + default: false + type: boolean concurrency: group: read-only-gateway-vm-metadata-${{ inputs.match_current_gateway && 'match-current' || inputs.target }} @@ -20,20 +25,35 @@ concurrency: jobs: resolve: runs-on: ubuntu-latest + permissions: + contents: read outputs: matrix: ${{ steps.resolve.outputs.matrix }} steps: + - name: Checkout protected-index parser + uses: actions/checkout@v6 + with: + persist-credentials: false + - id: resolve name: Resolve one gateway target env: MATCH_CURRENT_GATEWAY: ${{ inputs.match_current_gateway }} + INSPECT_CONNECTIONS: ${{ inputs.inspect_connections }} MATCH_TARGETS_JSON: ${{ inputs.match_current_gateway && secrets.IB_GATEWAY_TARGETS_JSON || '' }} + MATCHED_INDEX: ${{ inputs.inspect_connections && secrets.IB_GATEWAY_MATCHED_INDEX || '' }} LEGACY_TARGETS_JSON: ${{ !inputs.match_current_gateway && vars.IB_GATEWAY_TARGETS_JSON || '' }} SELECTED_TARGET: ${{ inputs.target }} uses: actions/github-script@v8 with: script: | + const { parseProtectedGatewayIndex } = require(`${process.env.GITHUB_WORKSPACE}/scripts/parse_protected_gateway_index.cjs`); const matchMode = process.env.MATCH_CURRENT_GATEWAY === "true"; + const inspectConnections = process.env.INSPECT_CONNECTIONS === "true"; + if (inspectConnections && !matchMode) { + core.setFailed("Connection inspection requires protected current-gateway matching"); + return; + } const raw = matchMode ? process.env.MATCH_TARGETS_JSON : process.env.LEGACY_TARGETS_JSON; if (!raw || !raw.trim()) { core.setFailed(matchMode ? "Protected gateway target inventory is required" : "IB_GATEWAY_TARGETS_JSON is required"); @@ -67,6 +87,23 @@ jobs: core.setFailed("Protected gateway target inventory is invalid"); return; } + if (inspectConnections) { + const selectedIndex = parseProtectedGatewayIndex(process.env.MATCHED_INDEX); + if (selectedIndex === undefined) { + core.setFailed("Protected matched target index is unavailable"); + return; + } + const target = targets[selectedIndex]; + if (!target || !/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(target.gce_user || "") || + !/^[A-Za-z0-9_-]+$/.test(target.ssh_private_key_secret_name || "") || + !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(target.container_name || "") || + !["paper", "live"].includes(target.mode)) { + core.setFailed("Protected matched target connection configuration is incomplete"); + return; + } + core.setOutput("matrix", JSON.stringify({include: [{target_index: selectedIndex, inspect_connections: true}]})); + return; + } core.setOutput("matrix", JSON.stringify({include: targets.map((_, target_index) => ({target_index}))})); return; } @@ -104,7 +141,9 @@ jobs: name: Resolve masked VM metadata env: MATCH_CURRENT_GATEWAY: ${{ inputs.match_current_gateway }} + INSPECT_CONNECTIONS: ${{ inputs.inspect_connections }} MATCH_TARGETS_JSON: ${{ inputs.match_current_gateway && secrets.IB_GATEWAY_TARGETS_JSON || '' }} + MATCHED_INDEX: ${{ inputs.inspect_connections && secrets.IB_GATEWAY_MATCHED_INDEX || '' }} LEGACY_TARGETS_JSON: ${{ !inputs.match_current_gateway && vars.IB_GATEWAY_TARGETS_JSON || '' }} SELECTED_TARGET: ${{ inputs.target }} TARGET_INDEX: ${{ matrix.target_index }} @@ -112,7 +151,9 @@ jobs: uses: actions/github-script@v8 with: script: | + const { parseProtectedGatewayIndex } = require(`${process.env.GITHUB_WORKSPACE}/scripts/parse_protected_gateway_index.cjs`); const matchMode = process.env.MATCH_CURRENT_GATEWAY === "true"; + const inspectConnections = process.env.INSPECT_CONNECTIONS === "true"; const raw = matchMode ? process.env.MATCH_TARGETS_JSON : process.env.LEGACY_TARGETS_JSON; if (!raw || !raw.trim()) { core.setFailed(matchMode ? "Protected gateway target inventory is required" : "IB_GATEWAY_TARGETS_JSON is required"); @@ -148,6 +189,11 @@ jobs: core.setFailed("Protected gateway target inventory is invalid"); return; } + const matchedIndex = inspectConnections ? parseProtectedGatewayIndex(process.env.MATCHED_INDEX) : undefined; + if (inspectConnections && (matchedIndex === undefined || matchedIndex !== targetIndex)) { + core.setFailed("Protected matched target index is unavailable"); + return; + } target = targets[targetIndex]; } else { target = targets[targetIndex]; @@ -168,6 +214,34 @@ jobs: GCE_INSTANCE_NAME: target.gce_instance_name, GCE_ZONE: target.gce_zone, }; + if (inspectConnections) { + const requiredConnectionValues = { + GCE_USER: target.gce_user, + SSH_PRIVATE_KEY_SECRET_NAME: target.ssh_private_key_secret_name, + IB_GATEWAY_CONTAINER_NAME: target.container_name, + IB_GATEWAY_MODE: target.mode, + }; + if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(String(target.gcp_project_id || "")) || + !/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(String(target.gcp_secret_project_id || target.gcp_project_id || "")) || + !/^[a-z]([-a-z0-9]*[a-z0-9])?$/.test(String(target.gce_instance_name || "")) || + !/^[a-z]+(?:-[a-z]+)*[0-9]+-[a-z]$/.test(String(target.gce_zone || "")) || + !/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(String(requiredConnectionValues.GCE_USER || "")) || + !/^[A-Za-z0-9_-]+$/.test(String(requiredConnectionValues.SSH_PRIVATE_KEY_SECRET_NAME || "")) || + !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(String(requiredConnectionValues.IB_GATEWAY_CONTAINER_NAME || "")) || + !["paper", "live"].includes(requiredConnectionValues.IB_GATEWAY_MODE)) { + core.setFailed("Protected matched target connection configuration is incomplete"); + return; + } + for (const [name, value] of Object.entries(requiredConnectionValues)) { + core.setSecret(String(value)); + if (name === "SSH_PRIVATE_KEY_SECRET_NAME") core.setOutput(name.toLowerCase(), String(value)); + else core.exportVariable(name, String(value)); + } + // Match the existing Gateway mode contract in scripts/wait_for_ib_gateway_ready.sh. + core.exportVariable("IB_GATEWAY_API_PORT", requiredConnectionValues.IB_GATEWAY_MODE === "live" ? "4001" : "4002"); + core.setSecret(String(target.gcp_secret_project_id || target.gcp_project_id)); + core.setOutput("gcp_secret_project_id", String(target.gcp_secret_project_id || target.gcp_project_id)); + } for (const [name, value] of Object.entries(values)) { if (!value) { core.setFailed("Gateway metadata is incomplete"); @@ -183,21 +257,55 @@ jobs: core.exportVariable("MATCH_CURRENT_GATEWAY", matchMode ? "true" : "false"); core.exportVariable("TARGET_INDEX", String(targetIndex)); - - name: Authenticate to Google Cloud + - id: auth_filtered + name: Authenticate to Google Cloud for passive inspection + if: ${{ inputs.inspect_connections }} + continue-on-error: true + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ steps.metadata.outputs.gcp_workload_identity_provider }} + service_account: ${{ steps.metadata.outputs.gcp_workload_identity_service_account }} + env: + NODE_OPTIONS: --require=${{ github.workspace }}/scripts/filter_github_action_auth_logs.cjs + + - id: auth + name: Authenticate to Google Cloud + if: ${{ !inputs.inspect_connections }} uses: google-github-actions/auth@v3 with: workload_identity_provider: ${{ steps.metadata.outputs.gcp_workload_identity_provider }} service_account: ${{ steps.metadata.outputs.gcp_workload_identity_service_account }} - name: Set up gcloud + if: ${{ !inputs.inspect_connections }} + id: gcloud_setup uses: google-github-actions/setup-gcloud@v3 with: project_id: ${{ steps.metadata.outputs.gcp_project_id }} version: '>= 416.0.0' - - name: Read VM metadata without connecting + - name: Set up gcloud for passive inspection + if: ${{ inputs.inspect_connections && steps.auth_filtered.outcome == 'success' }} + id: gcloud_setup_filtered + continue-on-error: true + uses: google-github-actions/setup-gcloud@v3 + with: + project_id: ${{ steps.metadata.outputs.gcp_project_id }} + version: '>= 416.0.0' + env: + NODE_OPTIONS: --require=${{ github.workspace }}/scripts/filter_github_action_auth_logs.cjs + + - name: Stop if protected cloud access is unavailable + if: ${{ inputs.inspect_connections && (steps.auth_filtered.outcome != 'success' || steps.gcloud_setup_filtered.outcome != 'success') }} + run: | + echo "GATEWAY_CONNECTION_INSPECTION=blocked reason=cloud_access_unavailable" + exit 1 + + - id: metadata_check + name: Read VM metadata without connecting env: GCP_PROJECT_ID: ${{ steps.metadata.outputs.gcp_project_id }} + INSPECT_CONNECTIONS: ${{ inputs.inspect_connections }} MATCH_CURRENT_GATEWAY: ${{ inputs.match_current_gateway }} TARGET_INDEX: ${{ matrix.target_index }} IB_GATEWAY_EXPECTED_HOST: ${{ inputs.match_current_gateway && secrets.IB_GATEWAY_EXPECTED_HOST || '' }} @@ -220,10 +328,21 @@ jobs: exit 1 fi if [ "${MATCH_CURRENT_GATEWAY}" = "true" ]; then - python3 scripts/match_gateway_metadata.py \ + if match_output="$(python3 scripts/match_gateway_metadata.py \ --metadata-file "${metadata_file}" \ - --target-index "${TARGET_INDEX}" - exit $? + --target-index "${TARGET_INDEX}")"; then + match_exit=0 + else + match_exit=$? + fi + printf '%s\n' "${match_output}" + match_status="$(printf '%s\n' "${match_output}" | sed -n 's/^GATEWAY_IP_MATCH_STATUS=//p')" + printf 'gateway_ip_match_status=%s\n' "${match_status}" >> "${GITHUB_OUTPUT}" + if [ "${INSPECT_CONNECTIONS}" = "true" ] && [ "${match_status}" != "match" ]; then + echo "GATEWAY_CONNECTION_INSPECTION=blocked reason=metadata_mismatch" + exit 1 + fi + exit "${match_exit}" fi VM_METADATA_FILE="${metadata_file}" python3 - <<'PY' import json @@ -237,3 +356,108 @@ jobs: print("GATEWAY_VM_DIAGNOSTIC_STATUS=VM_NOT_RUNNING") print("GATEWAY_VM_DIAGNOSTIC_LIMIT=NO_GATEWAY_OR_CONTAINER_HEALTH_ASSERTION") PY + + - name: Read SSH key into a restricted temporary file + if: ${{ inputs.inspect_connections && steps.metadata_check.outputs.gateway_ip_match_status == 'match' }} + env: + SSH_PRIVATE_KEY_SECRET_NAME: ${{ steps.metadata.outputs.ssh_private_key_secret_name }} + GCP_PROJECT_ID: ${{ steps.metadata.outputs.gcp_secret_project_id }} + run: | + set -euo pipefail + key_file="$(mktemp "${RUNNER_TEMP}/gateway-ssh-key.XXXXXX")" + error_file="$(mktemp "${RUNNER_TEMP}/gateway-secret-error.XXXXXX")" + key_handed_off=false + cleanup_secret_fetch() { + rm -f "${error_file}" + if [ "${key_handed_off}" != "true" ]; then rm -f "${key_file}"; fi + } + trap cleanup_secret_fetch EXIT + echo "SSH_KEY_FILE=${key_file}" >> "${GITHUB_ENV}" + chmod 600 "${key_file}" + if ! gcloud secrets versions access latest \ + --secret="${SSH_PRIVATE_KEY_SECRET_NAME}" \ + --project="${GCP_PROJECT_ID}" >"${key_file}" 2>"${error_file}"; then + echo "GATEWAY_CONNECTION_INSPECTION=blocked reason=credential_unavailable" + exit 1 + fi + if [ ! -s "${key_file}" ]; then + echo "GATEWAY_CONNECTION_INSPECTION=blocked reason=credential_unavailable" + exit 1 + fi + chmod 600 "${key_file}" + key_handed_off=true + + - name: Inspect Gateway connections through one IAP SSH session + if: ${{ inputs.inspect_connections && steps.metadata_check.outputs.gateway_ip_match_status == 'match' }} + env: + GCP_PROJECT_ID: ${{ steps.metadata.outputs.gcp_project_id }} + TARGET_INDEX: ${{ matrix.target_index }} + run: | + set -euo pipefail + known_hosts="$(mktemp "${RUNNER_TEMP}/gateway-known-hosts.XXXXXX")" + output_file="$(mktemp "${RUNNER_TEMP}/gateway-inspection-output.XXXXXX")" + error_file="$(mktemp "${RUNNER_TEMP}/gateway-ssh-error.XXXXXX")" + trap 'rm -f "${known_hosts}" "${output_file}" "${error_file}" "${SSH_KEY_FILE}"' EXIT + chmod 600 "${known_hosts}" "${output_file}" "${error_file}" + target_alias="gateway-target-${TARGET_INDEX}" + # The temporary SSH host-key record is bound to this exact IAP target alias. + proxy_command="gcloud compute start-iap-tunnel '${GCE_INSTANCE_NAME}' 22 --listen-on-stdin --project='${GCP_PROJECT_ID}' --zone='${GCE_ZONE}' --quiet" + remote_command="bash -s -- '${IB_GATEWAY_CONTAINER_NAME}' '${IB_GATEWAY_API_PORT}'" + if timeout 75 ssh \ + -i "${SSH_KEY_FILE}" \ + -o IdentitiesOnly=yes \ + -o BatchMode=yes \ + -o ConnectionAttempts=1 \ + -o ConnectTimeout=20 \ + -o StrictHostKeyChecking=accept-new \ + -o "UserKnownHostsFile=${known_hosts}" \ + -o "HostKeyAlias=${target_alias}" \ + -o "ProxyCommand=${proxy_command}" \ + "${GCE_USER}@${target_alias}" "${remote_command}" \ + < scripts/inspect_gateway_connections.sh >"${output_file}" 2>"${error_file}"; then + ssh_status=0 + else + ssh_status=$? + fi + python3 - "${output_file}" "${ssh_status}" <<'PY' + import re + import sys + from pathlib import Path + + lines = Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines() + ssh_status = int(sys.argv[2]) + if len(lines) == 1 and re.fullmatch(r"GATEWAY_CONNECTION_INSPECTION=blocked reason=[a-z_]+", lines[0]): + print(lines[0]) + raise SystemExit(1) + patterns = { + "GATEWAY_CONNECTION_INSPECTION": r"GATEWAY_CONNECTION_INSPECTION=observed", + "GATEWAY_CONTAINER_RUNNING": r"GATEWAY_CONTAINER_RUNNING=(true|false)", + "GATEWAY_API_LISTENER": r"GATEWAY_API_LISTENER=(true|false)", + "GATEWAY_ESTABLISHED_CONNECTION_COUNT": r"GATEWAY_ESTABLISHED_CONNECTION_COUNT=[0-9]+", + "GATEWAY_CONNECTION_OBSERVED_AT_UTC": r"GATEWAY_CONNECTION_OBSERVED_AT_UTC=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", + "GATEWAY_CONNECTION_INSPECTION_LIMIT": r"GATEWAY_CONNECTION_INSPECTION_LIMIT=OBSERVATION_ONLY_NO_AUTH_OR_CONCURRENCY_ASSERTION", + } + names = [line.split("=", 1)[0] for line in lines] + if ssh_status != 0: + print("GATEWAY_CONNECTION_INSPECTION=blocked reason=ssh_unavailable") + raise SystemExit(1) + if len(lines) != len(patterns) or set(names) != set(patterns) or len(set(names)) != len(names) or any( + not re.fullmatch(patterns.get(name, ""), line) for name, line in zip(names, lines) + ): + print("GATEWAY_CONNECTION_INSPECTION=blocked reason=remote_response_invalid") + raise SystemExit(1) + for line in lines: + print(line) + PY + + - name: Remove temporary SSH key + if: ${{ always() && inputs.inspect_connections }} + env: + SSH_KEY_FILE: ${{ env.SSH_KEY_FILE }} + run: | + set -euo pipefail + if [ -n "${SSH_KEY_FILE:-}" ]; then rm -f -- "${SSH_KEY_FILE}"; fi + for key_file in "${RUNNER_TEMP}"/gateway-ssh-key.*; do + [ -e "${key_file}" ] || continue + rm -f -- "${key_file}" + done diff --git a/scripts/filter_github_action_auth_logs.cjs b/scripts/filter_github_action_auth_logs.cjs new file mode 100644 index 0000000..12f5012 --- /dev/null +++ b/scripts/filter_github_action_auth_logs.cjs @@ -0,0 +1,58 @@ +'use strict'; + +const rawStdoutWrite = process.stdout.write.bind(process.stdout); +const rawStderrWrite = process.stderr.write.bind(process.stderr); +let stdoutRemainder = ''; + +function callbackFrom(encoding, callback) { + if (typeof encoding === 'function') return encoding; + return typeof callback === 'function' ? callback : null; +} + +function acceptedMaskCommands(value, flush = false) { + stdoutRemainder += value; + let output = ''; + while (true) { + const newline = stdoutRemainder.indexOf('\n'); + if (newline < 0) break; + const line = stdoutRemainder.slice(0, newline + 1); + stdoutRemainder = stdoutRemainder.slice(newline + 1); + if (line.startsWith('::add-mask::')) output += line; + } + if (flush && stdoutRemainder.startsWith('::add-mask::')) output += `${stdoutRemainder}\n`; + if (flush) stdoutRemainder = ''; + return output; +} + +process.stdout.write = function filteredStdoutWrite(chunk, encoding, callback) { + const done = callbackFrom(encoding, callback); + const text = Buffer.isBuffer(chunk) || chunk instanceof Uint8Array + ? Buffer.from(chunk).toString(typeof encoding === 'string' ? encoding : 'utf8') + : String(chunk); + const allowed = acceptedMaskCommands(text); + if (!allowed) { + if (done) process.nextTick(done); + return true; + } + return rawStdoutWrite(allowed, 'utf8', done || undefined); +}; + +process.stderr.write = function filteredStderrWrite(chunk, encoding, callback) { + const done = callbackFrom(encoding, callback); + if (done) process.nextTick(done); + return true; +}; + +process.on('uncaughtException', () => { + if (process.exitCode === undefined || process.exitCode === 0) process.exitCode = 1; +}); + +process.on('unhandledRejection', () => { + if (process.exitCode === undefined || process.exitCode === 0) process.exitCode = 1; +}); + +process.on('exit', (code) => { + const pendingMask = acceptedMaskCommands('', true); + if (pendingMask) rawStdoutWrite(pendingMask); + if (code !== 0) rawStderrWrite('GCP_AUTH_ACTION_STATUS=failed\n'); +}); diff --git a/scripts/inspect_gateway_connections.sh b/scripts/inspect_gateway_connections.sh new file mode 100644 index 0000000..e2221ef --- /dev/null +++ b/scripts/inspect_gateway_connections.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +container_name="${1:-}" +api_port="${2:-}" + +if [[ ! "${container_name}" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || [[ ! "${api_port}" =~ ^[0-9]{1,5}$ ]] || (( api_port < 1 || api_port > 65535 )); then + echo "GATEWAY_CONNECTION_INSPECTION=blocked reason=configuration_invalid" + exit 1 +fi + +container_state="" +if ! container_state="$(sudo -n docker inspect --format '{{.State.Running}} {{.State.Pid}}' "${container_name}" 2>/dev/null)"; then + echo "GATEWAY_CONNECTION_INSPECTION=blocked reason=container_inspection_failed" + exit 1 +fi + +running="${container_state%% *}" +container_pid="${container_state#* }" +if [[ "${running}" != "true" && "${running}" != "false" ]] || [[ ! "${container_pid}" =~ ^[0-9]+$ ]]; then + echo "GATEWAY_CONNECTION_INSPECTION=blocked reason=container_state_invalid" + exit 1 +fi +if [[ "${running}" == "true" ]] && (( container_pid <= 0 )); then + echo "GATEWAY_CONNECTION_INSPECTION=blocked reason=container_pid_invalid" + exit 1 +fi + +listener_output="" +if [[ "${running}" == "true" ]] && ! listener_output="$(sudo -n nsenter -t "${container_pid}" -n ss -H -ltn sport = ":${api_port}" 2>/dev/null)"; then + echo "GATEWAY_CONNECTION_INSPECTION=blocked reason=socket_inspection_failed" + exit 1 +fi + +established_output="" +if [[ "${running}" == "true" ]] && ! established_output="$(sudo -n nsenter -t "${container_pid}" -n ss -H -tn state established sport = ":${api_port}" 2>/dev/null)"; then + echo "GATEWAY_CONNECTION_INSPECTION=blocked reason=socket_inspection_failed" + exit 1 +fi + +listener_count=0 +established_count=0 +if [[ "${running}" == "true" ]]; then + if [[ -n "${listener_output}" ]]; then + listener_count="$(printf '%s\n' "${listener_output}" | wc -l | tr -d '[:space:]')" + fi + if [[ -n "${established_output}" ]]; then + established_count="$(printf '%s\n' "${established_output}" | wc -l | tr -d '[:space:]')" + fi +fi + +case "${listener_count}:${established_count}" in + *[!0-9:]* | :* | *:) echo "GATEWAY_CONNECTION_INSPECTION=blocked reason=socket_result_invalid"; exit 1 ;; +esac + +observed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +echo "GATEWAY_CONNECTION_INSPECTION=observed" +echo "GATEWAY_CONTAINER_RUNNING=${running}" +if (( listener_count > 0 )); then + echo "GATEWAY_API_LISTENER=true" +else + echo "GATEWAY_API_LISTENER=false" +fi +echo "GATEWAY_ESTABLISHED_CONNECTION_COUNT=${established_count}" +echo "GATEWAY_CONNECTION_OBSERVED_AT_UTC=${observed_at}" +echo "GATEWAY_CONNECTION_INSPECTION_LIMIT=OBSERVATION_ONLY_NO_AUTH_OR_CONCURRENCY_ASSERTION" diff --git a/scripts/parse_protected_gateway_index.cjs b/scripts/parse_protected_gateway_index.cjs new file mode 100644 index 0000000..c583bd2 --- /dev/null +++ b/scripts/parse_protected_gateway_index.cjs @@ -0,0 +1,22 @@ +'use strict'; + +function parseProtectedGatewayIndex(raw) { + if (typeof raw !== 'string' || !/^\s*\{\s*"target_index"\s*:\s*(0|[1-3])\s*\}\s*$/.test(raw)) { + return undefined; + } + + let payload; + try { + payload = JSON.parse(raw); + } catch { + return undefined; + } + if (!payload || Array.isArray(payload) || Object.keys(payload).length !== 1 || + !Object.hasOwn(payload, 'target_index') || !Number.isInteger(payload.target_index) || + payload.target_index < 0 || payload.target_index > 3) { + return undefined; + } + return payload.target_index; +} + +module.exports = { parseProtectedGatewayIndex }; diff --git a/tests/test_filter_github_action_auth_logs.cjs b/tests/test_filter_github_action_auth_logs.cjs new file mode 100644 index 0000000..40ebd63 --- /dev/null +++ b/tests/test_filter_github_action_auth_logs.cjs @@ -0,0 +1,76 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '..'); +const preload = path.join(repoRoot, 'scripts', 'filter_github_action_auth_logs.cjs'); +const syntheticCode = [ + "process.stdout.write('raw sdk diagnostic synthetic-token\\n');", + "process.stdout.write('::add-mask::synthetic-token\\n');", + "process.stdout.write('::error::raw sdk setup failure synthetic-token\\n');", + "console.error('raw sdk error synthetic-token');", +].join('\n'); + +function run(code, nodeOptions, extraEnv = {}) { + const env = { ...process.env }; + if (nodeOptions === undefined) delete env.NODE_OPTIONS; + else env.NODE_OPTIONS = nodeOptions; + Object.assign(env, extraEnv); + return spawnSync(process.execPath, ['-e', code], { encoding: 'utf8', env }); +} + +const defaultResult = run(`${syntheticCode}\nprocess.exitCode = 0;`); +assert.equal(defaultResult.status, 0); +assert.match(defaultResult.stdout, /raw sdk diagnostic synthetic-token/); +assert.match(defaultResult.stdout, /::add-mask::synthetic-token/); +assert.match(defaultResult.stderr, /raw sdk error synthetic-token/); + +const filteredSuccess = run(`${syntheticCode}\nprocess.exitCode = 0;`, `--require=${preload}`); +assert.equal(filteredSuccess.status, 0); +assert.equal(filteredSuccess.stdout, '::add-mask::synthetic-token\n'); +assert.equal(filteredSuccess.stderr, ''); + +const filteredFailure = run(`${syntheticCode}\nprocess.exitCode = 17;`, `--require=${preload}`); +assert.equal(filteredFailure.status, 17); +assert.equal(filteredFailure.stdout, '::add-mask::synthetic-token\n'); +assert.equal(filteredFailure.stderr, 'GCP_AUTH_ACTION_STATUS=failed\n'); +assert.doesNotMatch(filteredFailure.stderr, /raw sdk|synthetic-token/); + +const filteredThrow = run("throw new Error('synthetic-token');", `--require=${preload}`); +assert.notEqual(filteredThrow.status, 0); +assert.equal(filteredThrow.stdout, ''); +assert.equal(filteredThrow.stderr, 'GCP_AUTH_ACTION_STATUS=failed\n'); + +const filteredRejection = run("Promise.reject(new Error('synthetic-rejection-token'));", `--require=${preload}`); +assert.notEqual(filteredRejection.status, 0); +assert.equal(filteredRejection.stdout, ''); +assert.equal(filteredRejection.stderr, 'GCP_AUTH_ACTION_STATUS=failed\n'); + +const fileCommandDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateway-auth-filter-')); +const envFile = path.join(fileCommandDir, 'env'); +const outputFile = path.join(fileCommandDir, 'output'); +const pathFile = path.join(fileCommandDir, 'path'); +for (const filename of [envFile, outputFile, pathFile]) fs.writeFileSync(filename, ''); +const fileCommands = run([ + "const fs = require('node:fs');", + "fs.appendFileSync(process.env.GITHUB_ENV, 'FILTER_TEST_ENV=present\\n');", + "fs.appendFileSync(process.env.GITHUB_OUTPUT, 'filter_test_output=present\\n');", + "fs.appendFileSync(process.env.GITHUB_PATH, '/tmp/filter-test-bin\\n');", + "process.stdout.write('ordinary tool setup log\\n');", +].join('\n'), `--require=${preload}`, { + GITHUB_ENV: envFile, + GITHUB_OUTPUT: outputFile, + GITHUB_PATH: pathFile, +}); +assert.equal(fileCommands.status, 0); +assert.equal(fileCommands.stdout, ''); +assert.equal(fs.readFileSync(envFile, 'utf8'), 'FILTER_TEST_ENV=present\n'); +assert.equal(fs.readFileSync(outputFile, 'utf8'), 'filter_test_output=present\n'); +assert.equal(fs.readFileSync(pathFile, 'utf8'), '/tmp/filter-test-bin\n'); +fs.rmSync(fileCommandDir, { recursive: true, force: true }); + +console.log('PASS: auth action log filter preserves add-mask, fixed failure status, and default behavior'); diff --git a/tests/test_gateway_workflow_resolve.cjs b/tests/test_gateway_workflow_resolve.cjs new file mode 100644 index 0000000..58eb629 --- /dev/null +++ b/tests/test_gateway_workflow_resolve.cjs @@ -0,0 +1,102 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '..'); +const workflowPath = path.join(repoRoot, '.github', 'workflows', 'read-only-vm-metadata-diagnostic.yml'); +const workflow = fs.readFileSync(workflowPath, 'utf8'); +const resolveBlock = workflow.slice(workflow.indexOf('name: Resolve one gateway target')); +const scriptStart = resolveBlock.indexOf('script: |'); +assert.notEqual(scriptStart, -1); +const scriptLines = resolveBlock.slice(scriptStart + 'script: |'.length).replace(/^\n/, '').split('\n'); +const resolveScriptLines = []; +for (const line of scriptLines) { + if (line.trim() && !line.startsWith(' ')) break; + resolveScriptLines.push(line.startsWith(' ') ? line.slice(12) : ''); +} +const resolveScript = resolveScriptLines.join('\n'); +assert.ok(workflow.includes(' resolve:\n runs-on: ubuntu-latest\n permissions:\n contents: read\n')); +assert.match(resolveBlock, /uses: actions\/checkout@v6\n\s+with:\n\s+persist-credentials: false/); +const ordinarySetup = workflow.slice(workflow.indexOf('- name: Set up gcloud\n'), workflow.indexOf('- name: Set up gcloud for passive inspection\n')); +assert.match(ordinarySetup, /if: \$\{\{ !inputs\.inspect_connections \}\}/); +assert.doesNotMatch(ordinarySetup, /NODE_OPTIONS/); +const inspectedSetup = workflow.slice(workflow.indexOf('- name: Set up gcloud for passive inspection\n'), workflow.indexOf('- name: Stop if protected cloud access is unavailable\n')); +assert.match(inspectedSetup, /if: \$\{\{ inputs\.inspect_connections && steps\.auth_filtered\.outcome == 'success' \}\}/); +assert.match(inspectedSetup, /NODE_OPTIONS: --require=\$\{\{ github\.workspace \}\}\/scripts\/filter_github_action_auth_logs\.cjs/); +assert.match(workflow, /steps\.gcloud_setup_filtered\.outcome != 'success'/); + +const targets = Array.from({ length: 4 }, (_, index) => ({ + name: `gateway-${index}`, + gcp_project_id: `mock-project-${index}`, + gcp_workload_identity_provider: `mock-provider-${index}`, + gcp_workload_identity_service_account: `mock-service-${index}`, + gce_instance_name: `mock-gateway-${index}`, + gce_zone: 'us-central1-a', + gce_user: 'mock-user', + ssh_private_key_secret_name: 'mock-ssh-key', + container_name: `mock-container-${index}`, + mode: index === 2 ? 'live' : 'paper', +})); + +function runResolve(envValues) { + const originalEnv = { ...process.env }; + for (const key of [ + 'GITHUB_WORKSPACE', 'MATCH_CURRENT_GATEWAY', 'INSPECT_CONNECTIONS', 'MATCH_TARGETS_JSON', + 'MATCHED_INDEX', 'LEGACY_TARGETS_JSON', 'SELECTED_TARGET', + ]) delete process.env[key]; + Object.assign(process.env, { GITHUB_WORKSPACE: repoRoot }, envValues); + const result = { outputs: {}, failure: null }; + const core = { + setOutput(name, value) { result.outputs[name] = value; }, + setFailed(message) { result.failure = message; }, + }; + try { + new Function('core', 'require', resolveScript)(core, require); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); + } + return result; +} + +const legacy = runResolve({ + MATCH_CURRENT_GATEWAY: 'false', + INSPECT_CONNECTIONS: 'false', + LEGACY_TARGETS_JSON: JSON.stringify(Object.fromEntries(targets.map(({ name, ...target }) => [name, target]))), + SELECTED_TARGET: 'gateway-2', +}); +assert.equal(legacy.failure, null); +const legacyMatrix = JSON.parse(legacy.outputs.matrix).include; +assert.equal(legacyMatrix.length, 1); +assert.equal(legacyMatrix[0].target_index, 2); +assert.match(legacyMatrix[0].target_digest, /^[a-f0-9]{64}$/); + +const matchOnly = runResolve({ + MATCH_CURRENT_GATEWAY: 'true', + INSPECT_CONNECTIONS: 'false', + MATCH_TARGETS_JSON: JSON.stringify(targets), +}); +assert.equal(matchOnly.failure, null); +assert.deepEqual(JSON.parse(matchOnly.outputs.matrix).include.map((item) => item.target_index), [0, 1, 2, 3]); + +const inspect = runResolve({ + MATCH_CURRENT_GATEWAY: 'true', + INSPECT_CONNECTIONS: 'true', + MATCH_TARGETS_JSON: JSON.stringify(targets), + MATCHED_INDEX: '{"target_index":2}', +}); +assert.equal(inspect.failure, null); +assert.deepEqual(JSON.parse(inspect.outputs.matrix).include, [{ target_index: 2, inspect_connections: true }]); + +const badBinding = runResolve({ + MATCH_CURRENT_GATEWAY: 'false', + INSPECT_CONNECTIONS: 'true', + LEGACY_TARGETS_JSON: JSON.stringify(targets), +}); +assert.equal(badBinding.failure, 'Connection inspection requires protected current-gateway matching'); + +console.log('PASS: actual workflow resolve step loads parser and preserves legacy/match/inspect routing'); diff --git a/tests/test_inspect_gateway_connections.sh b/tests/test_inspect_gateway_connections.sh new file mode 100644 index 0000000..5e899f0 --- /dev/null +++ b/tests/test_inspect_gateway_connections.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_dir="$(cd "$(dirname "$0")/.." && pwd)" +workflow_file="${repo_dir}/.github/workflows/read-only-vm-metadata-diagnostic.yml" +script_file="${repo_dir}/scripts/inspect_gateway_connections.sh" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "${tmp_dir}"' EXIT + +cat >"${tmp_dir}/sudo" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +[[ "${1:-}" == "-n" ]] || exit 90 +shift +if [[ "${1:-}" == "docker" ]]; then + [[ "${MOCK_DOCKER_FAIL:-false}" != "true" ]] || exit 11 + printf '%s %s\n' "${MOCK_RUNNING:-true}" "${MOCK_PID:-12345}" + exit 0 +fi +if [[ "${1:-}" == "nsenter" ]]; then + [[ -z "${MOCK_TRACE_FILE:-}" ]] || printf '%s\n' "$*" >> "${MOCK_TRACE_FILE}" + shift + [[ "${1:-}" == "-t" ]] || exit 91 + [[ "${MOCK_PID:-12345}" == "${2:-}" ]] || exit 92 + shift 3 + [[ "${1:-}" == "ss" ]] || exit 93 + shift + if [[ "${MOCK_SS_FAIL:-}" == "listener" && "${1:-}" == "-H" && "${2:-}" == "-ltn" ]]; then exit 12; fi + if [[ "${MOCK_SS_FAIL:-}" == "established" && "${1:-}" == "-H" && "${2:-}" == "-tn" ]]; then exit 13; fi + if [[ "${1:-}" == "-H" && "${2:-}" == "-ltn" && "${3:-}" == "sport" && "${4:-}" == "=" && "${5:-}" == ":4002" ]]; then + printf '%s' "${MOCK_LISTENER_OUTPUT:-}" + exit 0 + fi + if [[ "${1:-}" == "-H" && "${2:-}" == "-tn" && "${3:-}" == "state" && "${4:-}" == "established" && "${5:-}" == "sport" && "${6:-}" == "=" && "${7:-}" == ":4002" ]]; then + printf '%s' "${MOCK_ESTABLISHED_OUTPUT:-}" + exit 0 + fi +fi +exit 94 +SH +chmod +x "${tmp_dir}/sudo" + +run_check() { + env PATH="${tmp_dir}:${PATH}" "$@" bash "${script_file}" synthetic-container 4002 +} + +trace_file="${tmp_dir}/ss-arguments" +output="$(run_check MOCK_RUNNING=true MOCK_PID=54321 MOCK_TRACE_FILE="${trace_file}" \ + MOCK_LISTENER_OUTPUT=$'LISTEN 0 128 0.0.0.0:4002 0.0.0.0:*\n' \ + MOCK_ESTABLISHED_OUTPUT=$'ESTAB 0 0 10.20.30.40:4002 10.90.80.70:32100\nESTAB 0 0 10.20.30.40:4002 10.90.80.71:32101\n')" +grep -Fxq 'GATEWAY_CONNECTION_INSPECTION=observed' <<<"${output}" +grep -Fxq 'GATEWAY_CONTAINER_RUNNING=true' <<<"${output}" +grep -Fxq 'GATEWAY_API_LISTENER=true' <<<"${output}" +grep -Fxq 'GATEWAY_ESTABLISHED_CONNECTION_COUNT=2' <<<"${output}" +grep -Fxq 'GATEWAY_CONNECTION_INSPECTION_LIMIT=OBSERVATION_ONLY_NO_AUTH_OR_CONCURRENCY_ASSERTION' <<<"${output}" +grep -Eq '^GATEWAY_CONNECTION_OBSERVED_AT_UTC=[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$' <<<"${output}" +! grep -Eq '54321|10\.20\.30\.40|10\.90\.80\.' <<<"${output}" +grep -Fq 'ss -H -ltn sport = :4002' "${trace_file}" +grep -Fq 'ss -H -tn state established sport = :4002' "${trace_file}" + +output="$(run_check MOCK_RUNNING=true MOCK_PID=321 MOCK_LISTENER_OUTPUT='' MOCK_ESTABLISHED_OUTPUT='')" +grep -Fxq 'GATEWAY_API_LISTENER=false' <<<"${output}" +grep -Fxq 'GATEWAY_ESTABLISHED_CONNECTION_COUNT=0' <<<"${output}" + +output="$(run_check MOCK_RUNNING=false MOCK_PID=0)" +grep -Fxq 'GATEWAY_CONTAINER_RUNNING=false' <<<"${output}" +grep -Fxq 'GATEWAY_API_LISTENER=false' <<<"${output}" + +if run_check MOCK_DOCKER_FAIL=true >"${tmp_dir}/failure"; then exit 1; fi +grep -Fxq 'GATEWAY_CONNECTION_INSPECTION=blocked reason=container_inspection_failed' "${tmp_dir}/failure" +if run_check MOCK_RUNNING=true MOCK_PID=321 MOCK_SS_FAIL=listener >"${tmp_dir}/failure"; then exit 1; fi +grep -Fxq 'GATEWAY_CONNECTION_INSPECTION=blocked reason=socket_inspection_failed' "${tmp_dir}/failure" +if run_check MOCK_RUNNING=true MOCK_PID=321 MOCK_SS_FAIL=established >"${tmp_dir}/failure"; then exit 1; fi +grep -Fxq 'GATEWAY_CONNECTION_INSPECTION=blocked reason=socket_inspection_failed' "${tmp_dir}/failure" +if env PATH="${tmp_dir}:${PATH}" bash "${script_file}" 'unsafe;name' 4002 >"${tmp_dir}/failure"; then exit 1; fi +grep -Fxq 'GATEWAY_CONNECTION_INSPECTION=blocked reason=configuration_invalid' "${tmp_dir}/failure" + +grep -Fq 'inspect_connections:' "${workflow_file}" +grep -A3 -Fq 'inspect_connections:' "${workflow_file}" +grep -Fq 'default: false' "${workflow_file}" +grep -Fq 'inputs.inspect_connections && secrets.IB_GATEWAY_MATCHED_INDEX' "${workflow_file}" +grep -Fq 'parseProtectedGatewayIndex(process.env.MATCHED_INDEX)' "${workflow_file}" +grep -Fq 'Connection inspection requires protected current-gateway matching' "${workflow_file}" +grep -Fq 'Protected matched target connection configuration is incomplete' "${workflow_file}" +grep -Fq 'steps.metadata_check.outputs.gateway_ip_match_status == '\''match'\''' "${workflow_file}" +grep -Fq 'gcloud compute start-iap-tunnel' "${workflow_file}" +grep -Fq 'ss -H -tn state established sport = ":${api_port}"' "${script_file}" +grep -Fq 'gcloud secrets versions access latest' "${workflow_file}" +grep -Fq 'chmod 600 "${key_file}"' "${workflow_file}" +grep -Fq 'trap cleanup_secret_fetch EXIT' "${workflow_file}" +grep -Fq 'if: ${{ always() && inputs.inspect_connections }}' "${workflow_file}" +grep -Fq 'rm -f -- "${SSH_KEY_FILE}"' "${workflow_file}" +grep -Fq 'MATCHED_INDEX: ${{ inputs.inspect_connections && secrets.IB_GATEWAY_MATCHED_INDEX' "${workflow_file}" +grep -Fq 'NODE_OPTIONS: --require=${{ github.workspace }}/scripts/filter_github_action_auth_logs.cjs' "${workflow_file}" +test "$(grep -Fc 'NODE_OPTIONS: --require=${{ github.workspace }}/scripts/filter_github_action_auth_logs.cjs' "${workflow_file}")" -eq 2 +grep -Fq "steps.gcloud_setup_filtered.outcome != 'success'" "${workflow_file}" +grep -Fq 'GCP_AUTH_ACTION_STATUS=failed' "${repo_dir}/scripts/filter_github_action_auth_logs.cjs" +! grep -Fq 'gcloud compute ssh' "${workflow_file}" +! grep -Fq 'ssh-keygen' "${workflow_file}" +! grep -Fq 'docker exec' "${script_file}" +! grep -Fq 'docker logs' "${script_file}" +! grep -Fq '4001' "${script_file}" +! grep -Fq '4002' "${script_file}" + +echo "PASS: passive Gateway inspection status, failures, counts, and privacy" diff --git a/tests/test_parse_protected_gateway_index.cjs b/tests/test_parse_protected_gateway_index.cjs new file mode 100644 index 0000000..3cf46c1 --- /dev/null +++ b/tests/test_parse_protected_gateway_index.cjs @@ -0,0 +1,28 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { parseProtectedGatewayIndex } = require('../scripts/parse_protected_gateway_index.cjs'); + +for (let index = 0; index <= 3; index += 1) { + assert.equal(parseProtectedGatewayIndex(`{"target_index":${index}}`), index); +} +assert.equal(parseProtectedGatewayIndex(' { "target_index" : 2 } '), 2); + +for (const invalid of [ + '', + '0', + '2', + '{"target_index":4}', + '{"target_index":-1}', + '{"target_index":1.5}', + '{"target_index":"1"}', + '{"target_index":1,"target_index":2}', + '{"target_index":1,"extra":true}', + '{"index":1}', + '{"target_index":01}', + 'not-json', +]) { + assert.equal(parseProtectedGatewayIndex(invalid), undefined, `accepted invalid payload: ${invalid}`); +} + +console.log('PASS: protected matched-index payload is strict JSON with one index in 0..3'); diff --git a/tests/test_read_only_vm_metadata_diagnostic.sh b/tests/test_read_only_vm_metadata_diagnostic.sh index 7edfaaa..41620bc 100755 --- a/tests/test_read_only_vm_metadata_diagnostic.sh +++ b/tests/test_read_only_vm_metadata_diagnostic.sh @@ -26,7 +26,6 @@ grep -Fq 'GATEWAY_VM_DIAGNOSTIC_LIMIT=NO_GATEWAY_OR_CONTAINER_HEALTH_ASSERTION' for forbidden in \ 'gcloud compute ssh' \ 'gcloud compute scp' \ - 'gcloud secrets versions' \ 'gcloud compute instances reset' \ 'docker ' \ 'systemctl ' \