Cloud/GCP

[GCP] VM기준으로 방화벽 내용을 조회 하는 스크립트

달빛궁전- 2025. 4. 19. 12:33
 
GCP는 네트워크 태그 기반으로 방화벽을 제공합니다.
태그를 조회하여 적용된 것들을 GUI상에서 볼 수는 있지만, VM을 기반으로 연결된 방화벽 정책을 한번에 보여주는 GCP 기본 기능은 없습니다.
AWS는 VM을 선택시 해당 VM에 적용된 시큐리티 그룹을 한번에 볼 수 있습니다.
신규 구축이 아닌 기존 운영하는 곳에서 정보 수집시 방화벽이 VM별로 어떻게 설정되어 있는지 한번에 조회할 필요가 있어 스크립트를 통해 하는 방안을 작성하였습니다.
 
2가지 방안으로 작성하였습니다.
프로젝트 별 수집하는 방안
조직에서 프로젝트별로 수집하는 방안
 
프로젝트 기반이며, 입력 후 해당 프로젝트에 있는 VM의 리스트 조회 → 해당 리스트의 있는 vm에 네트워크 태그를 조회하여, 인그레스, 이그레스별로 해당 태그 정책을 정리합니다.

 

#!/bin/bash

# --- 설정 ---
# 프로젝트 ID를 입력받음
read -p "대상 프로젝트 ID를 입력하세요: " TARGET_PROJECT_ID

# TARGET_PROJECT_ID 입력 확인
if [[ -z "$TARGET_PROJECT_ID" ]]; then
  echo "오류: 프로젝트 ID가 입력되지 않았습니다."
  exit 1
fi
echo "사용할 프로젝트 ID: $TARGET_PROJECT_ID" # 입력된 값 확인
# --- 설정 끝 ---

# 출력 파일 이름 설정 (입력받은 프로젝트 ID 사용)
VM_LIST_FILE="vm_list_project_${TARGET_PROJECT_ID}.tsv"
OUTPUT_FILE="vm_firewall_report_project_${TARGET_PROJECT_ID}.csv" # CSV 형식

echo "프로젝트 '$TARGET_PROJECT_ID'의 VM 정보 및 태그 가져오는 중..."
# 1단계: 프로젝트 범위로 VM 리스트 생성
gcloud asset search-all-resources \
    --scope=projects/$TARGET_PROJECT_ID \
    --asset-types="compute.googleapis.com/Instance" \
    --format="json" | jq -r '
        .[] | [
            (.name | split ("/") | last),              # 0: VM 이름
            .location,                               # 1: Zone (Location)
            .createTime,                             # 2: 생성 시간
            (.parentFullResourceName | split ("/") | last), # 3: 프로젝트 ID
            try (.additionalAttributes.networkInterfaces[0].network | split("/") | last) catch "N/A", # 4: 네트워크 이름
            (.additionalAttributes.internalIPs | join(";")), # 5: 내부 IP (세미콜론 구분)
            (.networkTags | join(";"))                # 6: 네트워크 태그 (세미콜론 구분)
        ] | @tsv
    ' > "$VM_LIST_FILE"

# VM 리스트 파일 생성 확인
if [[ ! -s "$VM_LIST_FILE" ]]; then
    echo "오류: 프로젝트 '$TARGET_PROJECT_ID'에서 VM을 찾을 수 없거나 정보를 가져오는 데 실패했습니다."
    exit 1
fi

echo "VM별 방화벽 규칙 상세 정보 조회 중 (프로젝트: $TARGET_PROJECT_ID)..."
# CSV 헤더 수정 (상세 정보 컬럼 추가)
echo "VM_Name,Project_ID,Network_Name,Network_Tags,Ingress_Rule_Details,Egress_Rule_Details" > "$OUTPUT_FILE"

# 생성된 VM 리스트 파일을 한 줄씩 읽기 (탭으로 구분)
while IFS=$'\t' read -r vm_name location create_time project_id network_name_from_asset internal_ips network_tags; do
    # 에러 발생시 처리
    if [[ "$project_id" != "$TARGET_PROJECT_ID" ]]; then
        echo "경고: VM '$vm_name'의 프로젝트 ID '$project_id'가 대상 프로젝트 '$TARGET_PROJECT_ID'와 다릅니다. 건너<0xEB><0x8>니다."
        continue
    fi

    echo "-----------------------------------------"
    echo "Processing VM: $vm_name"

    # --- 네트워크 URI 직접 조회 ---
    echo "  네트워크 URI 직접 조회 시도..."
    network_uri=$(gcloud compute instances describe "$vm_name" --zone="$location" --project="$project_id" --format='value(networkInterfaces[0].network)' 2>/dev/null)
    gcloud_exit_code=$?
    network_name="N/A"
    if [[ $gcloud_exit_code -ne 0 ]] || [[ -z "$network_uri" ]]; then
         echo "  오류 또는 빈 결과: $vm_name 의 네트워크 URI를 직접 조회하지 못했습니다."
         network_uri="N/A"
    else
         network_name=$(basename "$network_uri")
         echo "  네트워크 URI 조회 성공: $network_uri (Name: $network_name)"
    fi
    # --- 네트워크 URI 조회 끝 ---

    # 방화벽 규칙 상세 정보 조회 로직 시작
    ingress_details="N/A" # 기본값
    egress_details="N/A"  # 기본값

    # 네트워크 태그가 있고, 네트워크 URI가 확인된 경우에만 방화벽 조회
    if [[ -n "$network_tags" ]] && [[ "$network_tags" != "null" ]] && [[ "$network_uri" != "N/A" ]]; then
        IFS=';' read -ra tags_array <<< "$network_tags"
        tag_filter_part=""
        valid_tags_found=false
        for tag in "${tags_array[@]}"; do
            if [[ -n "$tag" ]]; then
                valid_tags_found=true
                if [[ -n "$tag_filter_part" ]]; then tag_filter_part+=" OR "; fi
                tag_filter_part+="targetTags.list():'$tag'"
            fi
        done

        if [[ "$valid_tags_found" = true ]]; then
            echo "  유효한 태그 발견 ($network_tags). 방화벽 규칙 상세 정보 조회 실행..."

            # --- Ingress 규칙 상세 정보 조회 ---
            ingress_filter="network='$network_uri' AND direction=INGRESS AND ($tag_filter_part)"
            echo "  Ingress Filter: $ingress_filter"
            ingress_json=$(gcloud compute firewall-rules list --project="$TARGET_PROJECT_ID" --filter="$ingress_filter" --format="json")
            gcloud_ingress_exit_code=$?

            if [[ $gcloud_ingress_exit_code -eq 0 ]] && [[ -n "$ingress_json" ]] && [[ "$ingress_json" != "[]" ]]; then
                # jq 를 사용하여 필요한 정보 추출 및 가공
                ingress_details=$(echo "$ingress_json" | jq -r '
                    map(
                        "Name:" + .name +
                        "; Src:" + (.sourceRanges | join(",") // "any") +
                        "; Allow:" + ([.allowed[]? | .IPProtocol + (if .ports then ":" + (.ports | join(",")) else "" end)] | join(",") // "all") +
                        "; Priority:" + (.priority|tostring)
                    ) | join(" | ") # 여러 규칙 정보를 " | " 문자로 연결
                ')
                if [[ -z "$ingress_details" ]]; then ingress_details="None Found (JSON Parsing Error?)"; fi
            else
                # gcloud 명령 실패 또는 결과 없음
                 if [[ $gcloud_ingress_exit_code -ne 0 ]]; then
                     echo "  ERROR: Ingress firewall list command failed (Exit code: $gcloud_ingress_exit_code)"
                     ingress_details="ERROR_LISTING_RULES"
                 else
                    ingress_details="None Found"
                 fi
            fi
            # --- Ingress 처리 끝 ---

            # --- Egress 규칙 상세 정보 조회 ---
            egress_filter="network='$network_uri' AND direction=EGRESS AND ($tag_filter_part)"
            echo "  Egress Filter: $egress_filter"
            egress_json=$(gcloud compute firewall-rules list --project="$TARGET_PROJECT_ID" --filter="$egress_filter" --format="json")
            gcloud_egress_exit_code=$?

            if [[ $gcloud_egress_exit_code -eq 0 ]] && [[ -n "$egress_json" ]] && [[ "$egress_json" != "[]" ]]; then
                # jq 를 사용하여 필요한 정보 추출 및 가공
                egress_details=$(echo "$egress_json" | jq -r '
                    map(
                        "Name:" + .name +
                        "; Dest:" + (.destinationRanges | join(",") // "any") + # Egress는 destinationRanges 사용
                        "; Allow:" + ([.allowed[]? | .IPProtocol + (if .ports then ":" + (.ports | join(",")) else "" end)] | join(",") // "all") +
                        "; Priority:" + (.priority|tostring)
                    ) | join(" | ") # 여러 규칙 정보를 " | " 문자로 연결
                ')
                 if [[ -z "$egress_details" ]]; then egress_details="None Found (JSON Parsing Error?)"; fi
            else
                # gcloud 명령 실패 또는 결과 없음
                 if [[ $gcloud_egress_exit_code -ne 0 ]]; then
                     echo "  ERROR: Egress firewall list command failed (Exit code: $gcloud_egress_exit_code)"
                     egress_details="ERROR_LISTING_RULES"
                 else
                    egress_details="None Found"
                 fi
            fi
            # --- Egress 처리 끝 ---
        else
             echo "  유효한 태그를 찾을 수 없음."
             ingress_details="No Valid Tags Found"
             egress_details="No Valid Tags Found"
        fi
    elif [[ "$network_uri" == "N/A" ]]; then
        echo "  네트워크 URI가 N/A 이므로 방화벽 조회를 종료합니다.."
        ingress_details="Network Unknown"
        egress_details="Network Unknown"
    else
        echo "  네트워크 태그가 없으므로 방화벽 조회를 종료합니다."
        ingress_details="No Tags"
        egress_details="No Tags"
    fi
    echo "  조회된 Ingress 규칙 상세: $ingress_details"
    echo "  조회된 Egress 규칙 상세: $egress_details"

    # 결과 CSV 파일에 추가 (상세 정보 컬럼 포함, 네트워크 이름은 basename 사용)
    printf "\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"\n" \
        "$vm_name" "$project_id" "$network_name" "$network_tags" "$ingress_details" "$egress_details" >> "$OUTPUT_FILE"

done < "$VM_LIST_FILE"

echo "-----------------------------------------" # 구분선 추가
echo "완료! 결과가 $OUTPUT_FILE 에 저장되었습니다."
rm "$VM_LIST_FILE" # 임시 파일 삭제

 

동작예시

 

 
  • 아래와 같이 iap 네트워크 태그가 적용된 2개의 정책이 있습니다.
    각 네트워크도 다릅니다.
 
 
스크립트 실행시
해당 프로젝트에 있는 VM별로 방화벽 정책을 확인할 수 있습니다. 
 
 
해당 내용은 csv 파일로 내보내기되며, 아래와 같이 VM명, 프로젝트, 네트워크, 네트워크태그, 각 룰셋의 세부내용 (출발지, 포트, 우선순위)를 직관적으로 확인할 수 있습니다.
 
 
  • 조직레벨에서 수집하기
    프로젝트가 몇 개 없다면 위와 같이 진행해도 무방하나, 폴더, 프로젝트가 많은 경우 조직ID를 입력 후 전체 프로젝트를 조회하는 것이 더 효율적입니다.
    gcloud와 쉘스크립트를 통한 방법입니다.

    2025.04.18 기준 테스트시 폴더 하위에 있는 프로젝트는 적용되지 않아 수정하였습니다.
    gcloud projects list -> gcloud asset search-all-resources 로 변경하여 조직내 모든 프로젝트를 조회하도록 변경했습니다.

    조회 성공시 아래와 같이 출력됩니다.

    #!/bin/bash
    
    # ==============================================================================
    # Script: GCP Organization Firewall Report (VM-based) - 최종 수정 버전 v3
    # Description:
    #   조직 내 모든 활성 프로젝트(폴더 포함)를 검색하여 VM과 연관된 방화벽 규칙 보고
    #   Zone 추출 로직 개선, Dir 필드 제거, 규칙 줄바꿈 적용됨.
    # Requirements: jq, gcloud, 권한(Cloud Asset Viewer 등), API(Asset, Compute 등)
    # ==============================================================================
    
    # --- Initial Checks ---
    if ! command -v jq &> /dev/null; then
        echo "오류: 'jq'가 설치되어 있지 않습니다. 설치해주세요."
        exit 1
    fi
    echo "jq 확인 완료."
    
    # --- 설정 ---
    read -p "대상 조직 ID를 입력하세요 (예: 1234567890): " ORGANIZATION_ID
    if [[ -z "$ORGANIZATION_ID" ]]; then echo "오류: 조직 ID 미입력."; exit 1; fi
    echo "사용할 조직 ID: $ORGANIZATION_ID"
    
    # --- 파일 이름 설정 ---
    PROJECT_VM_LIST_FILE="vm_list_temp_${ORGANIZATION_ID}.tsv"
    ORG_OUTPUT_FILE="org_firewall_report_${ORGANIZATION_ID}_$(date +%Y%m%d_%H%M%S).csv"
    
    # --- 프로젝트 목록 조회 (Asset Inventory) ---
    echo "조직 '$ORGANIZATION_ID' 내 모든 활성 프로젝트 목록 가져오는 중 (폴더 포함)..."
    project_list_json=$(gcloud asset search-all-resources \
        --scope="organizations/$ORGANIZATION_ID" \
        --asset-types="cloudresourcemanager.googleapis.com/Project" \
        --query='state="ACTIVE"' \
        --format="json" --quiet)
    
    if [[ $? -ne 0 ]]; then
        echo "오류: 프로젝트 목록 조회 실패. Cloud Asset API 활성화 및 권한 확인 필요."
        rm "$PROJECT_VM_LIST_FILE" 2>/dev/null
        exit 1
    fi
    if [[ -z "$project_list_json" ]] || [[ "$(echo "$project_list_json" | jq 'length')" -eq 0 ]]; then
        echo "정보: 활성 프로젝트 없음."
        echo "VM_Name,Project_ID,Project_Name,Network_Name,Network_Tags,Ingress_Rule_Details,Egress_Rule_Details" > "$ORG_OUTPUT_FILE"
        echo "빈 보고서 파일 '$ORG_OUTPUT_FILE' 생성됨."
        rm "$PROJECT_VM_LIST_FILE" 2>/dev/null
        exit 0
    fi
    
    # --- 프로젝트 정보 파싱 ---
    declare -A project_names
    while IFS= read -r project_json || [[ -n "$project_json" ]]; do
        if [[ -z "$project_json" ]]; then continue; fi
        p_id=$(echo "$project_json" | jq -r '.additionalAttributes.projectId // empty')
        p_name=$(echo "$project_json" | jq -r '.displayName // empty')
        state=$(echo "$project_json" | jq -r '.state // empty')
        if [[ "$state" != "ACTIVE" ]] || [[ -z "$p_id" ]]; then continue; fi
        if [[ -z "$p_name" ]]; then p_name="$p_id"; fi
        if [[ -z "${project_names[$p_id]}" ]]; then project_names["$p_id"]="$p_name"; fi
    done < <(echo "$project_list_json" | jq -c '.[]')
    
    if [[ ${#project_names[@]} -eq 0 ]]; then
        echo "정보: 처리할 활성 프로젝트 없음 (파싱/필터링 결과)."
        echo "VM_Name,Project_ID,Project_Name,Network_Name,Network_Tags,Ingress_Rule_Details,Egress_Rule_Details" > "$ORG_OUTPUT_FILE"
        echo "빈 보고서 파일 '$ORG_OUTPUT_FILE' 생성됨."
        rm "$PROJECT_VM_LIST_FILE" 2>/dev/null
        exit 0
    fi
    
    echo "조회 및 필터링된 활성 프로젝트 수: ${#project_names[@]}"
    echo "조직 전체 VM별 방화벽 규칙 상세 정보 조회 시작..."
    
    # --- CSV 헤더 작성 ---
    echo "VM_Name,Project_ID,Project_Name,Network_Name,Network_Tags,Ingress_Rule_Details,Egress_Rule_Details" > "$ORG_OUTPUT_FILE"
    echo "VM_Name,Project_ID,Project_Name,Network_Name,Network_Tags,Ingress_Rule_Details,Egress_Rule_Details" # 화면 출력
    
    # --- 메인 루프 (프로젝트별 처리) ---
    processed_project_count=0
    total_projects=${#project_names[@]}
    for project_id in "${!project_names[@]}"; do
        project_name="${project_names[$project_id]}"
        ((processed_project_count++))
        echo "========================================="
        echo "Processing Project [$processed_project_count/$total_projects]: $project_name ($project_id)"
        echo "========================================="
    
        # [1/4] Compute API 확인
        echo "  [1/4] Checking Compute Engine API status..."
        if ! gcloud services list --project="$project_id" --enabled --filter="NAME:compute.googleapis.com" --format='value(NAME)' --quiet 2>/dev/null | grep -q "compute.googleapis.com"; then
            echo "  정보: Compute Engine API 비활성화 또는 조회 권한 없음."
            sleep 0.5; continue
        fi
        echo "  Compute Engine API 확인됨."
    
        # [2/4] VM 목록 조회 (Asset Inventory)
        echo "  [2/4] Fetching VM list..."
        vm_list_json=$(gcloud asset search-all-resources \
            --scope=projects/$project_id \
            --asset-types="compute.googleapis.com/Instance" \
            --format="json" --quiet 2>/dev/null)
        if [[ $? -ne 0 ]]; then
            echo "  오류: VM 목록 조회 실패. 권한(cloudasset.assets.searchAllResources) 확인 필요." >&2
            sleep 0.5; continue
        fi
        # VM 목록 파싱하여 임시 파일에 저장
        echo "$vm_list_json" | jq -r '
            .[] | select(.state == "RUNNING" or .state == "TERMINATED") |
            [
                (.name | split ("/") | last),                     # VM 이름
                .location,                                       # Location (Zone 정보 포함 가능)
                (.parentFullResourceName | split ("/") | last),   # 프로젝트 ID 검증용
                (.additionalAttributes.internalIPs // [] | join(";")), # 내부 IP
                (.networkTags // [] | join(";"))                 # 네트워크 태그
            ] | @tsv
        ' > "$PROJECT_VM_LIST_FILE"
    
        if [[ ! -s "$PROJECT_VM_LIST_FILE" ]]; then
             echo "  정보: 처리할 VM (RUNNING/TERMINATED) 없음."
             sleep 0.5; continue
        fi
        echo "  VM 목록 임시 저장 완료 ($(wc -l < "$PROJECT_VM_LIST_FILE") 개 VM)."
    
        # [3/4] VM별 네트워크/방화벽 조회
        echo "  [3/4] Processing VMs for network/firewall rules..."
        while IFS=$'\t' read -r vm_name location proj_id_check internal_ips network_tags || [[ -n "$vm_name" ]]; do
            # 빈 줄 건너뛰기
            if [[ -z "$vm_name" ]]; then continue; fi
            # 프로젝트 ID 재확인
            if [[ "$proj_id_check" != "$project_id" ]]; then
                echo "  경고: VM '$vm_name' 프로젝트 ID 불일치 ($proj_id_check != $project_id)." >&2; continue
            fi
            echo "-----------------------------------------"
            echo "    Processing VM: $vm_name"
    
            # 변수 초기화
            network_uri="N/A"; network_name="N/A"; ingress_details="N/A"; egress_details="N/A"; zone=""
    
            # --- Zone 정보 추출 로직 개선 ---
            if [[ "$location" == *"/zones/"* ]]; then
                zone=$(basename "$location")
                echo "      Zone extracted from path: $zone"
            elif [[ -n "$location" ]] && [[ "$location" =~ ^[a-z]+[0-9]?-[a-z]+[0-9]+-[a-z]$ ]]; then
                zone="$location"
                echo "      Using location directly as zone: $zone"
            else
                echo "      경고: Location ('$location')에서 Zone 정보를 식별할 수 없습니다."
            fi
            # --- Zone 정보 추출 로직 끝 ---
    
            # Zone 정보가 성공적으로 추출되었는지 최종 확인
            if [[ -z "$zone" ]]; then
                echo "      오류: 최종 Zone 정보를 확정할 수 없어 네트워크/방화벽 조회를 중단합니다." >&2
                network_uri="N/A (Zone Unknown)"
                network_name="N/A (Zone Unknown)"
                ingress_details="N/A (Zone Unknown)"
                egress_details="N/A (Zone Unknown)"
            else
                # --- 네트워크 URI 조회 (gcloud compute instances describe 사용) ---
                echo "      Querying network URI using zone: $zone"
                temp_network_uri=$(gcloud compute instances describe "$vm_name" --zone="$zone" --project="$project_id" --format='value(networkInterfaces[0].network)' 2>/dev/null)
                gcloud_describe_exit_code=$?
    
                if [[ $gcloud_describe_exit_code -eq 0 ]] && [[ -n "$temp_network_uri" ]]; then
                     network_uri="$temp_network_uri"
                     network_name=$(basename "$network_uri") # URI에서 네트워크 이름만 추출
                     echo "      Network URI found: $network_name"
                else
                     echo "      경고: VM '$vm_name' (Zone: $zone)의 네트워크 URI를 조회하지 못했습니다 (Exit Code: $gcloud_describe_exit_code). VM 상태 또는 권한(compute.instances.get)을 확인하세요." >&2
                     network_uri="N/A (Describe Failed)"
                     network_name="N/A (Describe Failed)"
                     ingress_details="N/A (Describe Failed)"
                     egress_details="N/A (Describe Failed)"
                fi
                # --- 네트워크 URI 조회 끝 ---
            fi # Zone 확인 끝
    
            # --- 방화벽 규칙 조회 (네트워크 URI가 정상이고, 태그가 있을 때만) ---
            if [[ -n "$network_tags" ]] && [[ "$network_tags" != "null" ]] && [[ "$network_uri" != N/A* ]]; then
                echo "      VM Tags: [$network_tags]. Querying firewall rules..."
                # 태그 필터 생성
                IFS=';' read -ra tags_array <<< "$network_tags"
                tag_filter_part=""; valid_tags_found=false
                for tag in "${tags_array[@]}"; do
                    if [[ -n "$tag" ]]; then
                        valid_tags_found=true
                        if [[ -n "$tag_filter_part" ]]; then tag_filter_part+=" OR "; fi
                        escaped_tag=$(echo "$tag" | sed "s/'/'\\\\''/g; s/\"/\\\\\"/g")
                        tag_filter_part+="targetTags.list():\"$escaped_tag\""
                    fi
                done
    
                # 유효한 태그가 있을 경우 방화벽 조회
                if [[ "$valid_tags_found" = true ]]; then
                    # --- Ingress 조회 (수정됨: Dir 제거, join("\n") 사용) ---
                    ingress_filter="network=\"$network_uri\" AND direction=INGRESS AND ($tag_filter_part)"
                    ingress_json=$(gcloud compute firewall-rules list --project="$project_id" --filter="$ingress_filter" --format="json" --quiet 2>/dev/null)
                    if [[ $? -ne 0 ]]; then
                        echo "        오류: Ingress 방화벽 규칙 조회 실패. 권한(compute.firewalls.list) 확인 필요." >&2
                        ingress_details="ERROR_LISTING_RULES"
                    elif [[ -n "$ingress_json" ]] && [[ "$ingress_json" != "[]" ]]; then
                        # jq: map 각 규칙을 포맷팅하고, 결과를 개행문자(\n)로 연결
                        ingress_details=$(echo "$ingress_json" | jq -r 'map(
                            "Name:"+.name+
                            "; Src:"+(.sourceRanges | join(",") // "any")+
                            "; Action:"+(if .denied then "DENY" else "ALLOW" end)+
                            "; Proto:"+(.allowed // .denied | map(.IPProtocol + (if .ports then ":" + (.ports | join(",")) else "" end)) | join(",") // "all")+
                            "; Priority:"+(.priority|tostring)+
                            "; Enabled:"+(if .disabled then "false" else "true" end)
                            )|join("\n")' 2>/dev/null) # join 구분자를 "\n"으로 변경
                        if [[ $? -ne 0 ]] || [[ -z "$ingress_details" ]]; then ingress_details="Matched Rules Found (Parsing Error)"; fi
                    else
                        ingress_details="None Found Matching Tags"
                    fi
    
                    # --- Egress 조회 (수정됨: Dir 제거, join("\n") 사용) ---
                    egress_filter="network=\"$network_uri\" AND direction=EGRESS AND ($tag_filter_part)"
                    egress_json=$(gcloud compute firewall-rules list --project="$project_id" --filter="$egress_filter" --format="json" --quiet 2>/dev/null)
                     if [[ $? -ne 0 ]]; then
                         echo "        오류: Egress 방화벽 규칙 조회 실패. 권한(compute.firewalls.list) 확인 필요." >&2
                         egress_details="ERROR_LISTING_RULES"
                     elif [[ -n "$egress_json" ]] && [[ "$egress_json" != "[]" ]]; then
                        # jq: map 각 규칙을 포맷팅하고, 결과를 개행문자(\n)로 연결
                        egress_details=$(echo "$egress_json" | jq -r 'map(
                            "Name:"+.name+
                            "; Dest:"+(.destinationRanges | join(",") // "any")+
                            "; Action:"+(if .denied then "DENY" else "ALLOW" end)+
                            "; Proto:"+(.allowed // .denied | map(.IPProtocol + (if .ports then ":" + (.ports | join(",")) else "" end)) | join(",") // "all")+
                            "; Priority:"+(.priority|tostring)+
                            "; Enabled:"+(if .disabled then "false" else "true" end)
                            )|join("\n")' 2>/dev/null) # join 구분자를 "\n"으로 변경
                        if [[ $? -ne 0 ]] || [[ -z "$egress_details" ]]; then egress_details="Matched Rules Found (Parsing Error)"; fi
                    else
                        egress_details="None Found Matching Tags"
                    fi
                else
                     echo "      No valid tags found in tag field."
                     ingress_details="No Valid Tags Found"; egress_details="No Valid Tags Found"
                fi
            elif [[ "$network_uri" == N/A* ]]; then
                 echo "      Skipping firewall rule check due to network lookup failure."
            else
                # 네트워크 URI는 정상이지만 태그가 없는 경우
                echo "      VM has no network tags. Skipping firewall rule check."
                ingress_details="No Tags on VM"; egress_details="No Tags on VM"
            fi # 방화벽 규칙 조회 끝
    
            # [4/4] 결과 CSV 파일에 추가 (printf와 >> 사용)
            # printf는 %s 내의 개행문자(\n)를 그대로 유지하여 큰따옴표로 감싸진 필드 내 줄바꿈을 만듦
            printf "\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"\n" \
                "$(echo "$vm_name" | sed 's/"/""/g')" \
                "$(echo "$project_id" | sed 's/"/""/g')" \
                "$(echo "$project_name" | sed 's/"/""/g')" \
                "$(echo "$network_name" | sed 's/"/""/g')" \
                "$(echo "$network_tags" | sed 's/"/""/g')" \
                "$(echo "$ingress_details" | sed 's/"/""/g')" \
                "$(echo "$egress_details" | sed 's/"/""/g')" >> "$ORG_OUTPUT_FILE"
    
            sleep 0.1 # API 할당량 및 부하 감소
    
        done < "$PROJECT_VM_LIST_FILE" # 현재 프로젝트의 VM 목록 처리 완료
        echo "  [4/4] Project $project_name ($project_id) VM processing finished."
    
    done # 모든 프로젝트 처리 완료 (외부 루프 끝)
    
    # --- 최종 정리 ---
    echo "========================================="
    echo "모든 프로젝트 처리 완료! 최종 결과가 '$ORG_OUTPUT_FILE' 에 저장되었습니다."
    if rm "$PROJECT_VM_LIST_FILE" 2>/dev/null; then
        echo "임시 파일 ($PROJECT_VM_LIST_FILE) 삭제 완료."
    else
        echo "정보: 임시 파일 ($PROJECT_VM_LIST_FILE) 삭제 불가 또는 없음."
    fi
    echo "스크립트 실행 완료."
    exit 0
     
최종 조회 겨로가