From e5001397f3c7c060c67660e6575a2e0337fc414f Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:46:32 +0800 Subject: [PATCH] Support protected parallel metadata reads for remaining Gateways Co-Authored-By: Codex --- .../read-only-vm-metadata-diagnostic.yml | 94 ++++++++++-- scripts/match_gateway_metadata.py | 87 ++++++++++++ tests/test_gateway_workflow_resolve.cjs | 115 ++++++++++++++- tests/test_inspect_gateway_connections.sh | 4 +- tests/test_match_gateway_metadata.py | 134 ++++++++++++++++++ .../test_read_only_vm_metadata_diagnostic.sh | 15 +- 6 files changed, 430 insertions(+), 19 deletions(-) diff --git a/.github/workflows/read-only-vm-metadata-diagnostic.yml b/.github/workflows/read-only-vm-metadata-diagnostic.yml index 7a3de90..41c0b3e 100644 --- a/.github/workflows/read-only-vm-metadata-diagnostic.yml +++ b/.github/workflows/read-only-vm-metadata-diagnostic.yml @@ -27,6 +27,11 @@ on: required: true default: false type: boolean + remaining_gateways: + description: Read VM metadata for protected inventory targets other than the already completed target + required: true + default: false + type: boolean concurrency: group: read-only-gateway-vm-metadata-${{ inputs.match_current_gateway && 'match-current' || inputs.target }} @@ -52,8 +57,9 @@ jobs: INSPECT_CONNECTIONS: ${{ inputs.inspect_connections }} INSPECT_SSH_POLICY: ${{ inputs.inspect_ssh_policy }} INSPECT_SSH_KEY_BINDING: ${{ inputs.inspect_ssh_key_binding }} + REMAINING_GATEWAYS: ${{ inputs.remaining_gateways }} MATCH_TARGETS_JSON: ${{ inputs.match_current_gateway && secrets.IB_GATEWAY_TARGETS_JSON || '' }} - MATCHED_INDEX: ${{ (inputs.inspect_connections || inputs.inspect_ssh_policy) && secrets.IB_GATEWAY_MATCHED_INDEX || '' }} + MATCHED_INDEX: ${{ (inputs.inspect_connections || inputs.inspect_ssh_policy || inputs.remaining_gateways) && 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 @@ -64,10 +70,15 @@ jobs: const inspectConnections = process.env.INSPECT_CONNECTIONS === "true"; const inspectSshPolicy = process.env.INSPECT_SSH_POLICY === "true"; const inspectSshKeyBinding = process.env.INSPECT_SSH_KEY_BINDING === "true"; + const remainingGateways = process.env.REMAINING_GATEWAYS === "true"; if ((inspectConnections || inspectSshPolicy || inspectSshKeyBinding) && !matchMode) { core.setFailed("Passive inspection requires protected current-gateway matching"); return; } + if (remainingGateways && (!matchMode || inspectConnections || inspectSshPolicy || inspectSshKeyBinding)) { + core.setFailed("Remaining gateway metadata inspection requires protected match-only mode"); + return; + } if (inspectSshKeyBinding && !inspectSshPolicy) { core.setFailed("SSH key binding requires SSH policy inspection"); return; @@ -137,6 +148,25 @@ jobs: core.setOutput("matrix", JSON.stringify({include: [{target_index: selectedIndex, inspect_connections: inspectConnections, inspect_ssh_policy: inspectSshPolicy}]})); return; } + if (remainingGateways) { + const matchedIndex = parseProtectedGatewayIndex(process.env.MATCHED_INDEX); + if (matchedIndex === undefined) { + core.setFailed("Protected matched target index is unavailable"); + return; + } + const remainingTargets = targets.filter((_, targetIndex) => targetIndex !== matchedIndex); + if (remainingTargets.some((target) => + !/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(target.gcp_project_id) || + !/^[a-z]([-a-z0-9]*[a-z0-9])?$/.test(target.gce_instance_name) || + !/^[a-z]+(?:-[a-z]+)*[0-9]+-[a-z]$/.test(target.gce_zone))) { + core.setFailed("Protected remaining target metadata configuration is incomplete"); + return; + } + core.setOutput("matrix", JSON.stringify({include: targets + .map((_, target_index) => ({target_index})) + .filter(({target_index}) => target_index !== matchedIndex)})); + return; + } core.setOutput("matrix", JSON.stringify({include: targets.map((_, target_index) => ({target_index}))})); return; } @@ -158,8 +188,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 strategy: - fail-fast: true - max-parallel: 1 + fail-fast: ${{ !inputs.remaining_gateways }} + max-parallel: ${{ inputs.remaining_gateways && 3 || 1 }} matrix: ${{ fromJson(needs.resolve.outputs.matrix) }} permissions: contents: read @@ -177,8 +207,9 @@ jobs: INSPECT_CONNECTIONS: ${{ inputs.inspect_connections }} INSPECT_SSH_POLICY: ${{ inputs.inspect_ssh_policy }} INSPECT_SSH_KEY_BINDING: ${{ inputs.inspect_ssh_key_binding }} + REMAINING_GATEWAYS: ${{ inputs.remaining_gateways }} MATCH_TARGETS_JSON: ${{ inputs.match_current_gateway && secrets.IB_GATEWAY_TARGETS_JSON || '' }} - MATCHED_INDEX: ${{ (inputs.inspect_connections || inputs.inspect_ssh_policy) && secrets.IB_GATEWAY_MATCHED_INDEX || '' }} + MATCHED_INDEX: ${{ (inputs.inspect_connections || inputs.inspect_ssh_policy || inputs.remaining_gateways) && 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 }} @@ -191,6 +222,11 @@ jobs: const inspectConnections = process.env.INSPECT_CONNECTIONS === "true"; const inspectSshPolicy = process.env.INSPECT_SSH_POLICY === "true"; const inspectSshKeyBinding = process.env.INSPECT_SSH_KEY_BINDING === "true"; + const remainingGateways = process.env.REMAINING_GATEWAYS === "true"; + if (remainingGateways && (!matchMode || inspectConnections || inspectSshPolicy || inspectSshKeyBinding)) { + core.setFailed("Remaining gateway metadata inspection requires protected match-only mode"); + 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"); @@ -231,7 +267,21 @@ jobs: core.setFailed("Protected matched target index is unavailable"); return; } + if (remainingGateways) { + const matchedIndex = parseProtectedGatewayIndex(process.env.MATCHED_INDEX); + if (matchedIndex === undefined || matchedIndex === targetIndex) { + core.setFailed("Resolved gateway target is not an eligible remaining target"); + return; + } + } target = targets[targetIndex]; + if (remainingGateways && + (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(String(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 || "")))) { + core.setFailed("Protected remaining target metadata configuration is incomplete"); + return; + } } else { target = targets[targetIndex]; const crypto = require("crypto"); @@ -319,7 +369,7 @@ jobs: - id: auth_filtered name: Authenticate to Google Cloud for passive inspection - if: ${{ inputs.inspect_connections || inputs.inspect_ssh_policy }} + if: ${{ inputs.inspect_connections || inputs.inspect_ssh_policy || inputs.remaining_gateways }} continue-on-error: true uses: google-github-actions/auth@v3 with: @@ -330,14 +380,14 @@ jobs: - id: auth name: Authenticate to Google Cloud - if: ${{ !inputs.inspect_connections && !inputs.inspect_ssh_policy }} + if: ${{ !inputs.inspect_connections && !inputs.inspect_ssh_policy && !inputs.remaining_gateways }} 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 && !inputs.inspect_ssh_policy }} + if: ${{ !inputs.inspect_connections && !inputs.inspect_ssh_policy && !inputs.remaining_gateways }} id: gcloud_setup uses: google-github-actions/setup-gcloud@v3 with: @@ -345,7 +395,7 @@ jobs: version: '>= 416.0.0' - name: Set up gcloud for passive inspection - if: ${{ (inputs.inspect_connections || inputs.inspect_ssh_policy) && steps.auth_filtered.outcome == 'success' }} + if: ${{ (inputs.inspect_connections || inputs.inspect_ssh_policy || inputs.remaining_gateways) && steps.auth_filtered.outcome == 'success' }} id: gcloud_setup_filtered continue-on-error: true uses: google-github-actions/setup-gcloud@v3 @@ -356,10 +406,16 @@ jobs: NODE_OPTIONS: --require=${{ github.workspace }}/scripts/filter_github_action_auth_logs.cjs - name: Stop if protected cloud access is unavailable - if: ${{ (inputs.inspect_connections || inputs.inspect_ssh_policy) && (steps.auth_filtered.outcome != 'success' || steps.gcloud_setup_filtered.outcome != 'success') }} + if: ${{ (inputs.inspect_connections || inputs.inspect_ssh_policy || inputs.remaining_gateways) && (steps.auth_filtered.outcome != 'success' || steps.gcloud_setup_filtered.outcome != 'success') }} + env: + TARGET_INDEX: ${{ matrix.target_index }} run: | if [ "${{ inputs.inspect_ssh_policy }}" = "true" ]; then echo "SSH_POLICY=blocked reason=cloud_access_unavailable" + elif [ "${{ inputs.remaining_gateways }}" = "true" ]; then + echo "GATEWAY_VM_DIAGNOSTIC_STATUS=UNKNOWN" + echo "GATEWAY_VM_DIAGNOSTIC_TARGET_INDEX=${TARGET_INDEX}" + echo "GATEWAY_VM_DIAGNOSTIC_FAILURE_CLASS=CLOUD_ACCESS_UNAVAILABLE" else echo "GATEWAY_CONNECTION_INSPECTION=blocked reason=cloud_access_unavailable" fi @@ -373,7 +429,8 @@ jobs: INSPECT_SSH_POLICY: ${{ inputs.inspect_ssh_policy }} 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 || '' }} + REMAINING_GATEWAYS: ${{ inputs.remaining_gateways }} + IB_GATEWAY_EXPECTED_HOST: ${{ inputs.match_current_gateway && !inputs.remaining_gateways && secrets.IB_GATEWAY_EXPECTED_HOST || '' }} run: | set -euo pipefail metadata_file="$(mktemp)" @@ -383,6 +440,12 @@ jobs: --zone "${GCE_ZONE}" \ --format=json >"${metadata_file}" 2>&1; then if [ "${MATCH_CURRENT_GATEWAY}" = "true" ]; then + if [ "${REMAINING_GATEWAYS}" = "true" ]; then + echo "GATEWAY_VM_DIAGNOSTIC_STATUS=UNKNOWN" + echo "GATEWAY_VM_DIAGNOSTIC_TARGET_INDEX=${TARGET_INDEX}" + python3 scripts/classify_gcloud_metadata_failure.py --input "${metadata_file}" + exit 1 + fi echo "GATEWAY_IP_MATCH_STATUS=unknown" echo "GATEWAY_IP_MATCH_INDEX=${TARGET_INDEX}" python3 scripts/classify_gcloud_metadata_failure.py --input "${metadata_file}" @@ -393,6 +456,17 @@ jobs: exit 1 fi if [ "${MATCH_CURRENT_GATEWAY}" = "true" ]; then + if [ "${REMAINING_GATEWAYS}" = "true" ]; then + if EXPECTED_GCP_PROJECT_ID="${GCP_PROJECT_ID}" \ + EXPECTED_GCE_ZONE="${GCE_ZONE}" \ + EXPECTED_GCE_INSTANCE_NAME="${GCE_INSTANCE_NAME}" \ + python3 scripts/match_gateway_metadata.py \ + --metadata-file "${metadata_file}" --target-index "${TARGET_INDEX}" \ + --verify-target-identity; then + exit 0 + fi + exit 1 + fi if match_output="$(python3 scripts/match_gateway_metadata.py \ --metadata-file "${metadata_file}" \ --target-index "${TARGET_INDEX}")"; then diff --git a/scripts/match_gateway_metadata.py b/scripts/match_gateway_metadata.py index 1444991..9fb4f38 100644 --- a/scripts/match_gateway_metadata.py +++ b/scripts/match_gateway_metadata.py @@ -6,8 +6,10 @@ import ipaddress import json import os +import re from pathlib import Path from typing import Any +from urllib.parse import urlsplit _PRIVATE_V4_NETWORKS = tuple( @@ -48,12 +50,97 @@ def compare_gateway_host(metadata: Any, expected_host: Any) -> str: return "match" if expected in addresses else "no_match" +def _compute_resource_path(value: Any) -> str | None: + if not isinstance(value, str): + return None + try: + parsed = urlsplit(value) + port = parsed.port + except ValueError: + return None + if ( + parsed.scheme != "https" + or parsed.hostname != "www.googleapis.com" + or parsed.username is not None + or parsed.password is not None + or port is not None + or parsed.query + or parsed.fragment + ): + return None + return parsed.path + + +def compare_gateway_target_identity( + metadata: Any, expected_project: Any, expected_zone: Any, expected_instance: Any +) -> tuple[str, str]: + """Verify a describe response is the exact protected VM resource, then classify state.""" + if ( + not isinstance(expected_project, str) + or not re.fullmatch(r"[a-z][a-z0-9-]{4,28}[a-z0-9]", expected_project) + or not isinstance(expected_zone, str) + or not re.fullmatch(r"[a-z]+(?:-[a-z]+)*[0-9]+-[a-z]", expected_zone) + or not isinstance(expected_instance, str) + or not re.fullmatch(r"[a-z]([-a-z0-9]*[a-z0-9])?", expected_instance) + or not isinstance(metadata, dict) + ): + return "UNKNOWN", "METADATA_IDENTITY_UNVERIFIED" + + expected_zone_path = f"/compute/v1/projects/{expected_project}/zones/{expected_zone}" + expected_instance_path = f"{expected_zone_path}/instances/{expected_instance}" + if ( + metadata.get("name") != expected_instance + or _compute_resource_path(metadata.get("zone")) != expected_zone_path + or _compute_resource_path(metadata.get("selfLink")) != expected_instance_path + ): + return "UNKNOWN", "METADATA_IDENTITY_UNVERIFIED" + + status = metadata.get("status") + known_states = { + "PROVISIONING", + "STAGING", + "RUNNING", + "STOPPING", + "SUSPENDING", + "SUSPENDED", + "REPAIRING", + "TERMINATED", + } + if not isinstance(status, str) or status not in known_states: + return "UNKNOWN", "METADATA_STATUS_UNKNOWN" + return ("VM_RUNNING" if status == "RUNNING" else "VM_NOT_RUNNING"), "NONE" + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--metadata-file", required=True, type=Path) parser.add_argument("--target-index", required=True, type=int) + parser.add_argument("--verify-target-identity", action="store_true") args = parser.parse_args(argv) + if args.verify_target_identity: + status = "UNKNOWN" + failure_class = "METADATA_IDENTITY_UNVERIFIED" + metadata = None + try: + metadata = json.loads(args.metadata_file.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + failure_class = "METADATA_INVALID" + if not 0 <= args.target_index < 4: + failure_class = "METADATA_IDENTITY_UNVERIFIED" + elif failure_class != "METADATA_INVALID": + status, failure_class = compare_gateway_target_identity( + metadata, + os.environ.get("EXPECTED_GCP_PROJECT_ID"), + os.environ.get("EXPECTED_GCE_ZONE"), + os.environ.get("EXPECTED_GCE_INSTANCE_NAME"), + ) + print(f"GATEWAY_VM_DIAGNOSTIC_STATUS={status}") + print(f"GATEWAY_VM_DIAGNOSTIC_TARGET_INDEX={args.target_index if 0 <= args.target_index < 4 else 'unknown'}") + if failure_class != "NONE": + print(f"GATEWAY_VM_DIAGNOSTIC_FAILURE_CLASS={failure_class}") + return 0 if status != "UNKNOWN" else 1 + status = "unknown" if 0 <= args.target_index < 4: expected = os.environ.get("IB_GATEWAY_EXPECTED_HOST") diff --git a/tests/test_gateway_workflow_resolve.cjs b/tests/test_gateway_workflow_resolve.cjs index bffe29e..29d244a 100644 --- a/tests/test_gateway_workflow_resolve.cjs +++ b/tests/test_gateway_workflow_resolve.cjs @@ -17,13 +17,22 @@ for (const line of scriptLines) { resolveScriptLines.push(line.startsWith(' ') ? line.slice(12) : ''); } const resolveScript = resolveScriptLines.join('\n'); +const metadataBlock = workflow.slice(workflow.indexOf('name: Resolve masked VM metadata')); +const metadataScriptStart = metadataBlock.indexOf('script: |'); +assert.notEqual(metadataScriptStart, -1); +const metadataScriptLines = []; +for (const line of metadataBlock.slice(metadataScriptStart + 'script: |'.length).replace(/^\n/, '').split('\n')) { + if (line.trim() && !line.startsWith(' ')) break; + metadataScriptLines.push(line.startsWith(' ') ? line.slice(12) : ''); +} +const metadataScript = metadataScriptLines.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 && !inputs\.inspect_ssh_policy \}\}/); +assert.match(ordinarySetup, /if: \$\{\{ !inputs\.inspect_connections && !inputs\.inspect_ssh_policy && !inputs\.remaining_gateways \}\}/); 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 \|\| inputs\.inspect_ssh_policy\) && steps\.auth_filtered\.outcome == 'success' \}\}/); +assert.match(inspectedSetup, /if: \$\{\{ \(inputs\.inspect_connections \|\| inputs\.inspect_ssh_policy \|\| inputs\.remaining_gateways\) && 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'/); @@ -44,7 +53,7 @@ function runResolve(envValues) { const originalEnv = { ...process.env }; for (const key of [ 'GITHUB_WORKSPACE', 'MATCH_CURRENT_GATEWAY', 'INSPECT_CONNECTIONS', 'INSPECT_SSH_POLICY', 'MATCH_TARGETS_JSON', - 'MATCHED_INDEX', 'LEGACY_TARGETS_JSON', 'SELECTED_TARGET', + 'INSPECT_SSH_KEY_BINDING', 'REMAINING_GATEWAYS', 'MATCHED_INDEX', 'LEGACY_TARGETS_JSON', 'SELECTED_TARGET', ]) delete process.env[key]; Object.assign(process.env, { GITHUB_WORKSPACE: repoRoot }, envValues); const result = { outputs: {}, failure: null }; @@ -63,6 +72,32 @@ function runResolve(envValues) { return result; } +function runMetadataResolve(envValues) { + const originalEnv = { ...process.env }; + for (const key of [ + 'GITHUB_WORKSPACE', 'MATCH_CURRENT_GATEWAY', 'INSPECT_CONNECTIONS', 'INSPECT_SSH_POLICY', + 'INSPECT_SSH_KEY_BINDING', 'REMAINING_GATEWAYS', 'MATCH_TARGETS_JSON', 'MATCHED_INDEX', + 'LEGACY_TARGETS_JSON', 'SELECTED_TARGET', 'TARGET_INDEX', 'TARGET_DIGEST', + ]) delete process.env[key]; + Object.assign(process.env, { GITHUB_WORKSPACE: repoRoot }, envValues); + const result = { outputs: {}, exports: {}, secrets: [], failure: null }; + const core = { + setOutput(name, value) { result.outputs[name] = value; }, + setFailed(message) { result.failure = message; }, + setSecret(value) { result.secrets.push(value); }, + exportVariable(name, value) { result.exports[name] = value; }, + }; + try { + new Function('core', 'require', metadataScript)(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', @@ -85,6 +120,80 @@ const matchOnly = runResolve({ assert.equal(matchOnly.failure, null); assert.deepEqual(JSON.parse(matchOnly.outputs.matrix).include.map((item) => item.target_index), [0, 1, 2, 3]); +for (let matchedIndex = 0; matchedIndex < 4; matchedIndex += 1) { + const remaining = runResolve({ + MATCH_CURRENT_GATEWAY: 'true', + INSPECT_CONNECTIONS: 'false', + INSPECT_SSH_POLICY: 'false', + INSPECT_SSH_KEY_BINDING: 'false', + REMAINING_GATEWAYS: 'true', + MATCH_TARGETS_JSON: JSON.stringify(targets), + MATCHED_INDEX: JSON.stringify({ target_index: matchedIndex }), + }); + assert.equal(remaining.failure, null); + const remainingIndices = JSON.parse(remaining.outputs.matrix).include.map((item) => item.target_index); + assert.equal(remainingIndices.length, 3); + assert.deepEqual(remainingIndices, [0, 1, 2, 3].filter((index) => index !== matchedIndex)); +} + +for (const matchedIndex of ['', 'bad', '{"target_index":4}', '1']) { + const remaining = runResolve({ + MATCH_CURRENT_GATEWAY: 'true', + INSPECT_CONNECTIONS: 'false', + INSPECT_SSH_POLICY: 'false', + REMAINING_GATEWAYS: 'true', + MATCH_TARGETS_JSON: JSON.stringify(targets), + MATCHED_INDEX: matchedIndex, + }); + assert.equal(remaining.failure, 'Protected matched target index is unavailable'); + assert.equal(remaining.outputs.matrix, undefined); +} + +for (const invalidMode of [ + { MATCH_CURRENT_GATEWAY: 'false', INSPECT_CONNECTIONS: 'false', INSPECT_SSH_POLICY: 'false', REMAINING_GATEWAYS: 'true' }, + { MATCH_CURRENT_GATEWAY: 'true', INSPECT_CONNECTIONS: 'true', INSPECT_SSH_POLICY: 'false', REMAINING_GATEWAYS: 'true' }, + { MATCH_CURRENT_GATEWAY: 'true', INSPECT_CONNECTIONS: 'false', INSPECT_SSH_POLICY: 'true', REMAINING_GATEWAYS: 'true' }, + { MATCH_CURRENT_GATEWAY: 'true', INSPECT_CONNECTIONS: 'false', INSPECT_SSH_POLICY: 'false', INSPECT_SSH_KEY_BINDING: 'true', REMAINING_GATEWAYS: 'true' }, +]) { + const remaining = runResolve({ ...invalidMode, MATCH_TARGETS_JSON: JSON.stringify(targets), MATCHED_INDEX: '{"target_index":2}' }); + assert.equal(remaining.failure, 'Remaining gateway metadata inspection requires protected match-only mode'); + assert.equal(remaining.outputs.matrix, undefined); +} + +const remainingDiagnose = runMetadataResolve({ + MATCH_CURRENT_GATEWAY: 'true', + INSPECT_CONNECTIONS: 'false', + INSPECT_SSH_POLICY: 'false', + INSPECT_SSH_KEY_BINDING: 'false', + REMAINING_GATEWAYS: 'true', + MATCH_TARGETS_JSON: JSON.stringify(targets), + MATCHED_INDEX: '{"target_index":2}', + TARGET_INDEX: '1', +}); +assert.equal(remainingDiagnose.failure, null); +assert.equal(remainingDiagnose.exports.TARGET_INDEX, '1'); +const matchedDiagnose = runMetadataResolve({ + MATCH_CURRENT_GATEWAY: 'true', + INSPECT_CONNECTIONS: 'false', + INSPECT_SSH_POLICY: 'false', + INSPECT_SSH_KEY_BINDING: 'false', + REMAINING_GATEWAYS: 'true', + MATCH_TARGETS_JSON: JSON.stringify(targets), + MATCHED_INDEX: '{"target_index":2}', + TARGET_INDEX: '2', +}); +assert.equal(matchedDiagnose.failure, 'Resolved gateway target is not an eligible remaining target'); +const combinedDiagnose = runMetadataResolve({ + MATCH_CURRENT_GATEWAY: 'true', + INSPECT_CONNECTIONS: 'true', + INSPECT_SSH_POLICY: 'false', + REMAINING_GATEWAYS: 'true', + MATCH_TARGETS_JSON: JSON.stringify(targets), + MATCHED_INDEX: '{"target_index":2}', + TARGET_INDEX: '1', +}); +assert.equal(combinedDiagnose.failure, 'Remaining gateway metadata inspection requires protected match-only mode'); + const inspect = runResolve({ MATCH_CURRENT_GATEWAY: 'true', INSPECT_CONNECTIONS: 'true', diff --git a/tests/test_inspect_gateway_connections.sh b/tests/test_inspect_gateway_connections.sh index 9e51f4a..a6a15f7 100644 --- a/tests/test_inspect_gateway_connections.sh +++ b/tests/test_inspect_gateway_connections.sh @@ -78,7 +78,7 @@ grep -Fxq 'GATEWAY_CONNECTION_INSPECTION=blocked reason=configuration_invalid' " grep -Fq 'inspect_connections:' "${workflow_file}" grep -A3 -Fq 'inspect_connections:' "${workflow_file}" grep -Fq 'default: false' "${workflow_file}" -grep -Fq '(inputs.inspect_connections || inputs.inspect_ssh_policy) && secrets.IB_GATEWAY_MATCHED_INDEX' "${workflow_file}" +grep -Fq '(inputs.inspect_connections || inputs.inspect_ssh_policy || inputs.remaining_gateways) && secrets.IB_GATEWAY_MATCHED_INDEX' "${workflow_file}" grep -Fq 'parseProtectedGatewayIndex(process.env.MATCHED_INDEX)' "${workflow_file}" grep -Fq 'Passive inspection requires protected current-gateway matching' "${workflow_file}" grep -Fq 'Protected matched target connection configuration is incomplete' "${workflow_file}" @@ -92,7 +92,7 @@ grep -Fq 'scripts/classify_gateway_ssh_failure.py' "${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 || inputs.inspect_ssh_policy) && secrets.IB_GATEWAY_MATCHED_INDEX' "${workflow_file}" +grep -Fq 'MATCHED_INDEX: ${{ (inputs.inspect_connections || inputs.inspect_ssh_policy || inputs.remaining_gateways) && 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}" diff --git a/tests/test_match_gateway_metadata.py b/tests/test_match_gateway_metadata.py index 033b439..cee5535 100644 --- a/tests/test_match_gateway_metadata.py +++ b/tests/test_match_gateway_metadata.py @@ -19,7 +19,141 @@ def _metadata(*addresses: str) -> dict[str, object]: return {"networkInterfaces": [{"networkIP": address} for address in addresses]} +def _target_metadata(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "name": "mock-gateway-a", + "zone": "https://www.googleapis.com/compute/v1/projects/mock-project-a/zones/us-central1-a", + "selfLink": "https://www.googleapis.com/compute/v1/projects/mock-project-a/zones/us-central1-a/instances/mock-gateway-a", + "status": "RUNNING", + "networkInterfaces": [{"networkIP": "10.1.2.3"}], + } + payload.update(overrides) + return payload + + class GatewayMetadataMatchTests(unittest.TestCase): + def test_exact_compute_resource_identity_and_closed_status(self): + self.assertEqual( + MODULE.compare_gateway_target_identity( + _target_metadata(), "mock-project-a", "us-central1-a", "mock-gateway-a" + ), + ("VM_RUNNING", "NONE"), + ) + stopped = _target_metadata(status="TERMINATED") + self.assertEqual( + MODULE.compare_gateway_target_identity( + stopped, "mock-project-a", "us-central1-a", "mock-gateway-a" + ), + ("VM_NOT_RUNNING", "NONE"), + ) + + def test_identity_mismatch_or_untrusted_uri_fails_closed(self): + cases = [ + (_target_metadata(), "other-project", "us-central1-a", "mock-gateway-a"), + (_target_metadata(), "mock-project-a", "us-east1-b", "mock-gateway-a"), + (_target_metadata(), "mock-project-a", "us-central1-a", "other-gateway"), + (_target_metadata(zone="https://attacker.invalid/compute/v1/projects/mock-project-a/zones/us-central1-a"), + "mock-project-a", "us-central1-a", "mock-gateway-a"), + (_target_metadata(selfLink="https://www.googleapis.com/compute/v1/projects/other-project/zones/us-central1-a/instances/mock-gateway-a"), + "mock-project-a", "us-central1-a", "mock-gateway-a"), + (_target_metadata(selfLink="https://www.googleapis.com/compute/v1/projects/mock-project-a/zones/us-central1-a/instances/mock-gateway-a?redirect=1"), + "mock-project-a", "us-central1-a", "mock-gateway-a"), + (_target_metadata(status="UNKNOWN_STATE"), "mock-project-a", "us-central1-a", "mock-gateway-a"), + (None, "mock-project-a", "us-central1-a", "mock-gateway-a"), + ] + for payload, project, zone, instance in cases: + with self.subTest(project=project, zone=zone, instance=instance): + status, reason = MODULE.compare_gateway_target_identity(payload, project, zone, instance) + self.assertEqual(status, "UNKNOWN") + self.assertIn(reason, {"METADATA_IDENTITY_UNVERIFIED", "METADATA_STATUS_UNKNOWN"}) + + def test_identity_cli_outputs_only_closed_status_index_and_failure_class(self): + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as temp_dir: + metadata_file = Path(temp_dir) / "metadata.json" + metadata_file.write_text(json.dumps(_target_metadata()), encoding="utf-8") + environment = { + **os.environ, + "EXPECTED_GCP_PROJECT_ID": "mock-project-a", + "EXPECTED_GCE_ZONE": "us-central1-a", + "EXPECTED_GCE_INSTANCE_NAME": "mock-gateway-a", + } + completed = subprocess.run( + [sys.executable, str(SCRIPT), "--metadata-file", str(metadata_file), "--target-index", "3", "--verify-target-identity"], + env=environment, + capture_output=True, + text=True, + check=True, + ) + + self.assertEqual( + completed.stdout.splitlines(), + ["GATEWAY_VM_DIAGNOSTIC_STATUS=VM_RUNNING", "GATEWAY_VM_DIAGNOSTIC_TARGET_INDEX=3"], + ) + for private_fixture_value in ("mock-project-a", "mock-gateway-a", "10.1.2.3", "selfLink", "networkInterfaces"): + self.assertNotIn(private_fixture_value, completed.stdout + completed.stderr) + + def test_identity_cli_mismatch_is_unknown_without_metadata_leak(self): + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as temp_dir: + metadata_file = Path(temp_dir) / "metadata.json" + metadata_file.write_text(json.dumps(_target_metadata()), encoding="utf-8") + completed = subprocess.run( + [sys.executable, str(SCRIPT), "--metadata-file", str(metadata_file), "--target-index", "1", "--verify-target-identity"], + env={ + **os.environ, + "EXPECTED_GCP_PROJECT_ID": "mock-project-b", + "EXPECTED_GCE_ZONE": "us-central1-a", + "EXPECTED_GCE_INSTANCE_NAME": "mock-gateway-a", + }, + capture_output=True, + text=True, + ) + + self.assertEqual(completed.returncode, 1) + self.assertEqual( + completed.stdout.splitlines(), + [ + "GATEWAY_VM_DIAGNOSTIC_STATUS=UNKNOWN", + "GATEWAY_VM_DIAGNOSTIC_TARGET_INDEX=1", + "GATEWAY_VM_DIAGNOSTIC_FAILURE_CLASS=METADATA_IDENTITY_UNVERIFIED", + ], + ) + self.assertNotIn("mock-project-a", completed.stdout + completed.stderr) + self.assertNotIn("mock-gateway-a", completed.stdout + completed.stderr) + self.assertNotIn("10.1.2.3", completed.stdout + completed.stderr) + + def test_identity_cli_invalid_json_uses_fixed_failure_category(self): + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as temp_dir: + metadata_file = Path(temp_dir) / "metadata.json" + metadata_file.write_text('{"private":"fixture-value"', encoding="utf-8") + completed = subprocess.run( + [sys.executable, str(SCRIPT), "--metadata-file", str(metadata_file), "--target-index", "0", "--verify-target-identity"], + env={ + **os.environ, + "EXPECTED_GCP_PROJECT_ID": "mock-project-a", + "EXPECTED_GCE_ZONE": "us-central1-a", + "EXPECTED_GCE_INSTANCE_NAME": "mock-gateway-a", + }, + capture_output=True, + text=True, + ) + + self.assertEqual(completed.returncode, 1) + self.assertEqual( + completed.stdout.splitlines(), + [ + "GATEWAY_VM_DIAGNOSTIC_STATUS=UNKNOWN", + "GATEWAY_VM_DIAGNOSTIC_TARGET_INDEX=0", + "GATEWAY_VM_DIAGNOSTIC_FAILURE_CLASS=METADATA_INVALID", + ], + ) + self.assertNotIn("fixture-value", completed.stdout + completed.stderr) + def test_actual_metadata_payload_validation(self): cases = [ (_metadata("10.1.2.3"), "10.1.2.3", "match"), diff --git a/tests/test_read_only_vm_metadata_diagnostic.sh b/tests/test_read_only_vm_metadata_diagnostic.sh index 9a38016..5cb1fea 100755 --- a/tests/test_read_only_vm_metadata_diagnostic.sh +++ b/tests/test_read_only_vm_metadata_diagnostic.sh @@ -8,9 +8,11 @@ test -f "$workflow_file" grep -Fq 'name: Read-Only Gateway VM Metadata Diagnostic' "$workflow_file" grep -Fq 'workflow_dispatch:' "$workflow_file" grep -Fq 'match_current_gateway:' "$workflow_file" +grep -Fq 'remaining_gateways:' "$workflow_file" grep -Fq 'inspect_ssh_policy:' "$workflow_file" grep -A3 -Fq 'match_current_gateway:' "$workflow_file" grep -Fq 'default: false' "$workflow_file" +grep -A3 -Fq 'remaining_gateways:' "$workflow_file" ! grep -Fq 'schedule:' "$workflow_file" ! grep -Fq 'push:' "$workflow_file" grep -Fq 'One gateway target from IB_GATEWAY_TARGETS_JSON' "$workflow_file" @@ -21,7 +23,7 @@ grep -Fq 'gcloud compute instances describe' "$workflow_file" grep -Fq 'uses: actions/checkout@v6' "$workflow_file" grep -Fq 'persist-credentials: false' "$workflow_file" grep -Fq 'GATEWAY_VM_DIAGNOSTIC_STATUS=VM_RUNNING' "$workflow_file" -test "$(grep -Fc 'scripts/classify_gcloud_metadata_failure.py' "$workflow_file")" -eq 4 +test "$(grep -Fc 'scripts/classify_gcloud_metadata_failure.py' "$workflow_file")" -eq 5 grep -Fq 'GATEWAY_VM_DIAGNOSTIC_FAILURE_CLASS=' "$repo_dir/scripts/classify_gcloud_metadata_failure.py" grep -Fq 'GATEWAY_VM_DIAGNOSTIC_LIMIT=NO_GATEWAY_OR_CONTAINER_HEALTH_ASSERTION' "$workflow_file" for forbidden in \ @@ -43,9 +45,14 @@ done grep -Fq 'GCP_PROJECT_ID: ${{ steps.metadata.outputs.gcp_project_id }}' "$workflow_file" grep -Fq "MATCH_TARGETS_JSON: \${{ inputs.match_current_gateway && secrets.IB_GATEWAY_TARGETS_JSON || '' }}" "$workflow_file" grep -Fq "LEGACY_TARGETS_JSON: \${{ !inputs.match_current_gateway && vars.IB_GATEWAY_TARGETS_JSON || '' }}" "$workflow_file" -grep -Fq "IB_GATEWAY_EXPECTED_HOST: \${{ inputs.match_current_gateway && secrets.IB_GATEWAY_EXPECTED_HOST || '' }}" "$workflow_file" -grep -Fq 'fail-fast: true' "$workflow_file" -grep -Fq 'max-parallel: 1' "$workflow_file" +grep -Fq "IB_GATEWAY_EXPECTED_HOST: \${{ inputs.match_current_gateway && !inputs.remaining_gateways && secrets.IB_GATEWAY_EXPECTED_HOST || '' }}" "$workflow_file" +grep -Fq 'fail-fast: ${{ !inputs.remaining_gateways }}' "$workflow_file" +grep -Fq 'max-parallel: ${{ inputs.remaining_gateways && 3 || 1 }}' "$workflow_file" +grep -Fq 'Remaining gateway metadata inspection requires protected match-only mode' "$workflow_file" +grep -Fq 'Resolved gateway target is not an eligible remaining target' "$workflow_file" +grep -Fq -- '--verify-target-identity' "$workflow_file" +grep -Fq 'GATEWAY_VM_DIAGNOSTIC_TARGET_INDEX=' "$workflow_file" +grep -Fq 'inputs.remaining_gateways' "$workflow_file" grep -Fq 'scripts/match_gateway_metadata.py' "$workflow_file" grep -Fq 'targets.map((_, target_index) => ({target_index}))' "$workflow_file" test "$(grep -Fc 'gcloud compute instances describe' "$workflow_file")" -eq 2