Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 84 additions & 10 deletions .github/workflows/read-only-vm-metadata-diagnostic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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
Expand All @@ -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 }}
Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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:
Expand All @@ -330,22 +380,22 @@ 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:
project_id: ${{ steps.metadata.outputs.gcp_project_id }}
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
Expand All @@ -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
Expand All @@ -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)"
Expand All @@ -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}"
Expand All @@ -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
Expand Down
87 changes: 87 additions & 0 deletions scripts/match_gateway_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading