mirror of
https://github.com/BishopFox/cloudfox.git
synced 2026-11-10 17:06:52 -10:00
1060 lines
35 KiB
Go
1060 lines
35 KiB
Go
package computeengineservice
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
|
"github.com/BishopFox/cloudfox/internal/gcp/sdk"
|
|
"google.golang.org/api/compute/v1"
|
|
)
|
|
|
|
type ComputeEngineService struct {
|
|
session *gcpinternal.SafeSession
|
|
}
|
|
|
|
// New creates a new ComputeEngineService (legacy - uses ADC directly)
|
|
func New() *ComputeEngineService {
|
|
return &ComputeEngineService{}
|
|
}
|
|
|
|
// NewWithSession creates a ComputeEngineService with a SafeSession for managed authentication
|
|
func NewWithSession(session *gcpinternal.SafeSession) *ComputeEngineService {
|
|
return &ComputeEngineService{session: session}
|
|
}
|
|
|
|
// ServiceAccountInfo contains service account details for an instance
|
|
type ServiceAccountInfo struct {
|
|
Email string `json:"email"`
|
|
Scopes []string `json:"scopes"`
|
|
}
|
|
|
|
// IAMBinding represents a single IAM role binding
|
|
type IAMBinding struct {
|
|
Role string `json:"role"`
|
|
Member string `json:"member"`
|
|
}
|
|
|
|
// InstanceType represents the type/manager of an instance
|
|
type InstanceType string
|
|
|
|
const (
|
|
InstanceTypeStandalone InstanceType = "Standalone" // Regular VM
|
|
InstanceTypeGKE InstanceType = "GKE" // GKE node
|
|
InstanceTypeMIG InstanceType = "MIG" // Managed Instance Group
|
|
InstanceTypeDataproc InstanceType = "Dataproc" // Dataproc cluster node
|
|
InstanceTypeDataflow InstanceType = "Dataflow" // Dataflow worker
|
|
InstanceTypeComposer InstanceType = "Composer" // Cloud Composer worker
|
|
InstanceTypeNotebooks InstanceType = "Notebooks" // Vertex AI Workbench / AI Platform Notebooks
|
|
InstanceTypeBatchJob InstanceType = "Batch" // Cloud Batch job
|
|
InstanceTypeCloudRun InstanceType = "CloudRun" // Cloud Run (Jobs) execution environment
|
|
InstanceTypeFilestore InstanceType = "Filestore" // Filestore instance
|
|
InstanceTypeSQLProxy InstanceType = "CloudSQL" // Cloud SQL Proxy
|
|
InstanceTypeAppEngine InstanceType = "AppEngine" // App Engine Flex
|
|
)
|
|
|
|
// ComputeEngineInfo contains instance metadata and security-relevant configuration
|
|
type ComputeEngineInfo struct {
|
|
// Basic info
|
|
Name string `json:"name"`
|
|
ID string `json:"id"`
|
|
Zone string `json:"zone"`
|
|
State string `json:"state"`
|
|
ProjectID string `json:"projectID"`
|
|
InstanceType InstanceType `json:"instanceType"` // Type of instance (GKE, MIG, Dataproc, etc.)
|
|
|
|
// Network configuration
|
|
ExternalIP string `json:"externalIP"`
|
|
InternalIP string `json:"internalIP"`
|
|
NetworkInterfaces []*compute.NetworkInterface `json:"networkInterfaces"`
|
|
CanIPForward bool `json:"canIpForward"` // Can forward packets (router/NAT)
|
|
|
|
// Service accounts and scopes
|
|
ServiceAccounts []ServiceAccountInfo `json:"serviceAccounts"`
|
|
HasDefaultSA bool `json:"hasDefaultSA"` // Uses default compute SA
|
|
HasCloudScopes bool `json:"hasCloudScopes"` // Has cloud-platform or other broad scopes
|
|
|
|
// Security configuration
|
|
DeletionProtection bool `json:"deletionProtection"` // Protected against deletion
|
|
ShieldedVM bool `json:"shieldedVM"` // Shielded VM enabled
|
|
SecureBoot bool `json:"secureBoot"` // Secure Boot enabled
|
|
VTPMEnabled bool `json:"vtpmEnabled"` // vTPM enabled
|
|
IntegrityMonitoring bool `json:"integrityMonitoring"` // Integrity monitoring enabled
|
|
ConfidentialVM bool `json:"confidentialVM"` // Confidential computing enabled
|
|
|
|
// Instance metadata
|
|
MachineType string `json:"machineType"`
|
|
Tags *compute.Tags `json:"tags"`
|
|
Labels map[string]string `json:"labels"`
|
|
|
|
// Metadata security
|
|
HasStartupScript bool `json:"hasStartupScript"` // Has startup script in metadata
|
|
HasSSHKeys bool `json:"hasSSHKeys"` // Has SSH keys in metadata
|
|
BlockProjectSSHKeys bool `json:"blockProjectSSHKeys"` // Blocks project-wide SSH keys
|
|
OSLoginEnabled bool `json:"osLoginEnabled"` // OS Login enabled
|
|
OSLogin2FAEnabled bool `json:"osLogin2FAEnabled"` // OS Login 2FA enabled
|
|
SerialPortEnabled bool `json:"serialPortEnabled"` // Serial port access enabled
|
|
|
|
// Pentest-specific fields: actual content extraction
|
|
StartupScriptContent string `json:"startupScriptContent"` // Actual startup script content
|
|
StartupScriptURL string `json:"startupScriptURL"` // URL to startup script if remote
|
|
SSHKeys []string `json:"sshKeys"` // Extracted SSH keys
|
|
CustomMetadata []string `json:"customMetadata"` // Other custom metadata keys
|
|
RawMetadata map[string]string `json:"rawMetadata"` // Full raw metadata key-value pairs
|
|
SensitiveMetadata []SensitiveItem `json:"sensitiveMetadata"` // Detected sensitive items in metadata
|
|
|
|
// Disk encryption
|
|
BootDiskEncryption string `json:"bootDiskEncryption"` // "Google-managed", "CMEK", or "CSEK"
|
|
BootDiskKMSKey string `json:"bootDiskKMSKey"` // KMS key for CMEK
|
|
|
|
// Timestamps
|
|
CreationTimestamp string `json:"creationTimestamp"`
|
|
LastStartTimestamp string `json:"lastStartTimestamp"`
|
|
LastSnapshotDate string `json:"lastSnapshotDate"` // Most recent snapshot date for any attached disk
|
|
|
|
// IAM bindings
|
|
IAMBindings []IAMBinding `json:"iamBindings"`
|
|
}
|
|
|
|
// ProjectMetadataInfo contains project-level metadata security info
|
|
type ProjectMetadataInfo struct {
|
|
ProjectID string `json:"projectId"`
|
|
HasProjectSSHKeys bool `json:"hasProjectSSHKeys"`
|
|
ProjectSSHKeys []string `json:"projectSSHKeys"`
|
|
HasProjectStartupScript bool `json:"hasProjectStartupScript"`
|
|
ProjectStartupScript string `json:"projectStartupScript"`
|
|
OSLoginEnabled bool `json:"osLoginEnabled"`
|
|
OSLogin2FAEnabled bool `json:"osLogin2FAEnabled"`
|
|
SerialPortEnabled bool `json:"serialPortEnabled"`
|
|
CustomMetadataKeys []string `json:"customMetadataKeys"`
|
|
RawMetadata map[string]string `json:"rawMetadata"`
|
|
SensitiveMetadata []SensitiveItem `json:"sensitiveMetadata"`
|
|
}
|
|
|
|
// InstanceIAMInfo contains IAM policy info for an instance
|
|
type InstanceIAMInfo struct {
|
|
InstanceName string `json:"instanceName"`
|
|
Zone string `json:"zone"`
|
|
ProjectID string `json:"projectId"`
|
|
ComputeAdmins []string `json:"computeAdmins"` // compute.admin or owner
|
|
InstanceAdmins []string `json:"instanceAdmins"` // compute.instanceAdmin
|
|
SSHUsers []string `json:"sshUsers"` // compute.osLogin or osAdminLogin
|
|
MetadataSetters []string `json:"metadataSetters"` // compute.instances.setMetadata
|
|
}
|
|
|
|
// getService returns a compute service, using session if available
|
|
func (ces *ComputeEngineService) getService(ctx context.Context) (*compute.Service, error) {
|
|
if ces.session != nil {
|
|
return sdk.CachedGetComputeService(ctx, ces.session)
|
|
}
|
|
return compute.NewService(ctx)
|
|
}
|
|
|
|
// getInstanceIAMBindings retrieves all IAM bindings for an instance
|
|
func (ces *ComputeEngineService) getInstanceIAMBindings(service *compute.Service, projectID, zone, instanceName string) []IAMBinding {
|
|
ctx := context.Background()
|
|
|
|
policy, err := service.Instances.GetIamPolicy(projectID, zone, instanceName).Context(ctx).Do()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var bindings []IAMBinding
|
|
for _, binding := range policy.Bindings {
|
|
if binding == nil {
|
|
continue
|
|
}
|
|
for _, member := range binding.Members {
|
|
bindings = append(bindings, IAMBinding{
|
|
Role: binding.Role,
|
|
Member: member,
|
|
})
|
|
}
|
|
}
|
|
|
|
return bindings
|
|
}
|
|
|
|
// Retrieves instances from all regions and zones for a project without using concurrency.
|
|
func (ces *ComputeEngineService) Instances(projectID string) ([]ComputeEngineInfo, error) {
|
|
ctx := context.Background()
|
|
computeService, err := ces.getService(ctx)
|
|
if err != nil {
|
|
return nil, gcpinternal.ParseGCPError(err, "compute.googleapis.com")
|
|
}
|
|
|
|
// Use AggregatedList to get all instances across all zones in one call
|
|
// This only requires compute.instances.list permission (not compute.regions.list)
|
|
var instanceInfos []ComputeEngineInfo
|
|
|
|
req := computeService.Instances.AggregatedList(projectID)
|
|
err = req.Pages(ctx, func(page *compute.InstanceAggregatedList) error {
|
|
for scopeName, scopedList := range page.Items {
|
|
if scopedList.Instances == nil {
|
|
continue
|
|
}
|
|
// Extract zone from scope name (format: "zones/us-central1-a")
|
|
zone := ""
|
|
if strings.HasPrefix(scopeName, "zones/") {
|
|
zone = strings.TrimPrefix(scopeName, "zones/")
|
|
}
|
|
|
|
for _, instance := range scopedList.Instances {
|
|
info := ComputeEngineInfo{
|
|
Name: instance.Name,
|
|
ID: fmt.Sprintf("%v", instance.Id),
|
|
Zone: zone,
|
|
State: instance.Status,
|
|
InstanceType: detectInstanceType(instance),
|
|
ExternalIP: getExternalIP(instance),
|
|
InternalIP: getInternalIP(instance),
|
|
NetworkInterfaces: instance.NetworkInterfaces,
|
|
CanIPForward: instance.CanIpForward,
|
|
Tags: instance.Tags,
|
|
Labels: instance.Labels,
|
|
ProjectID: projectID,
|
|
DeletionProtection: instance.DeletionProtection,
|
|
CreationTimestamp: instance.CreationTimestamp,
|
|
LastStartTimestamp: instance.LastStartTimestamp,
|
|
}
|
|
|
|
// Parse machine type (extract just the type name)
|
|
info.MachineType = getMachineTypeName(instance.MachineType)
|
|
|
|
// Parse service accounts and scopes
|
|
info.ServiceAccounts, info.HasDefaultSA, info.HasCloudScopes = parseServiceAccounts(instance.ServiceAccounts, projectID)
|
|
|
|
// Parse shielded VM config
|
|
if instance.ShieldedInstanceConfig != nil {
|
|
info.ShieldedVM = true
|
|
info.SecureBoot = instance.ShieldedInstanceConfig.EnableSecureBoot
|
|
info.VTPMEnabled = instance.ShieldedInstanceConfig.EnableVtpm
|
|
info.IntegrityMonitoring = instance.ShieldedInstanceConfig.EnableIntegrityMonitoring
|
|
}
|
|
|
|
// Parse confidential VM config
|
|
if instance.ConfidentialInstanceConfig != nil {
|
|
info.ConfidentialVM = instance.ConfidentialInstanceConfig.EnableConfidentialCompute
|
|
}
|
|
|
|
// Parse metadata for security-relevant items including content
|
|
if instance.Metadata != nil {
|
|
metaResult := parseMetadataFull(instance.Metadata)
|
|
info.HasStartupScript = metaResult.HasStartupScript
|
|
info.HasSSHKeys = metaResult.HasSSHKeys
|
|
info.BlockProjectSSHKeys = metaResult.BlockProjectSSHKeys
|
|
info.OSLoginEnabled = metaResult.OSLoginEnabled
|
|
info.OSLogin2FAEnabled = metaResult.OSLogin2FA
|
|
info.SerialPortEnabled = metaResult.SerialPortEnabled
|
|
info.StartupScriptContent = metaResult.StartupScriptContent
|
|
info.StartupScriptURL = metaResult.StartupScriptURL
|
|
info.SSHKeys = metaResult.SSHKeys
|
|
info.CustomMetadata = metaResult.CustomMetadata
|
|
info.RawMetadata = metaResult.RawMetadata
|
|
// Mark source for sensitive items
|
|
for i := range metaResult.SensitiveItems {
|
|
metaResult.SensitiveItems[i].Source = "instance"
|
|
}
|
|
info.SensitiveMetadata = metaResult.SensitiveItems
|
|
}
|
|
|
|
// Parse boot disk encryption
|
|
info.BootDiskEncryption, info.BootDiskKMSKey = parseBootDiskEncryption(instance.Disks)
|
|
|
|
// Get last snapshot date for this instance's disks
|
|
info.LastSnapshotDate = ces.getLastSnapshotForDisks(computeService, projectID, instance.Disks)
|
|
|
|
// Fetch IAM bindings for this instance (may fail silently if no permission)
|
|
info.IAMBindings = ces.getInstanceIAMBindings(computeService, projectID, zone, instance.Name)
|
|
|
|
instanceInfos = append(instanceInfos, info)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, gcpinternal.ParseGCPError(err, "compute.googleapis.com")
|
|
}
|
|
|
|
return instanceInfos, nil
|
|
}
|
|
|
|
// Returns the zone from a GCP URL string with the zone in it
|
|
func getZoneNameFromURL(zoneURL string) string {
|
|
splits := strings.Split(zoneURL, "/")
|
|
return splits[len(splits)-1]
|
|
}
|
|
|
|
// getExternalIP extracts the external IP address from an instance if available.
|
|
func getExternalIP(instance *compute.Instance) string {
|
|
for _, iface := range instance.NetworkInterfaces {
|
|
for _, accessConfig := range iface.AccessConfigs {
|
|
if accessConfig.NatIP != "" {
|
|
return accessConfig.NatIP
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// getInternalIP extracts the internal IP address from an instance.
|
|
func getInternalIP(instance *compute.Instance) string {
|
|
if len(instance.NetworkInterfaces) > 0 {
|
|
return instance.NetworkInterfaces[0].NetworkIP
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// getMachineTypeName extracts the machine type name from a full URL
|
|
func getMachineTypeName(machineTypeURL string) string {
|
|
parts := strings.Split(machineTypeURL, "/")
|
|
if len(parts) > 0 {
|
|
return parts[len(parts)-1]
|
|
}
|
|
return machineTypeURL
|
|
}
|
|
|
|
// parseServiceAccounts extracts service account info and checks for security concerns
|
|
func parseServiceAccounts(sas []*compute.ServiceAccount, projectID string) ([]ServiceAccountInfo, bool, bool) {
|
|
var accounts []ServiceAccountInfo
|
|
hasDefaultSA := false
|
|
hasCloudScopes := false
|
|
|
|
defaultSAPattern := fmt.Sprintf("%[email protected]", projectID)
|
|
|
|
for _, sa := range sas {
|
|
info := ServiceAccountInfo{
|
|
Email: sa.Email,
|
|
Scopes: sa.Scopes,
|
|
}
|
|
accounts = append(accounts, info)
|
|
|
|
// Check if using default compute service account
|
|
if strings.Contains(sa.Email, "[email protected]") ||
|
|
strings.HasSuffix(sa.Email, defaultSAPattern) {
|
|
hasDefaultSA = true
|
|
}
|
|
|
|
// Check for broad scopes
|
|
for _, scope := range sa.Scopes {
|
|
if scope == "https://www.googleapis.com/auth/cloud-platform" ||
|
|
scope == "https://www.googleapis.com/auth/compute" ||
|
|
scope == "https://www.googleapis.com/auth/devstorage.full_control" ||
|
|
scope == "https://www.googleapis.com/auth/devstorage.read_write" {
|
|
hasCloudScopes = true
|
|
}
|
|
}
|
|
}
|
|
|
|
return accounts, hasDefaultSA, hasCloudScopes
|
|
}
|
|
|
|
// SensitiveItem represents a potentially sensitive metadata item
|
|
type SensitiveItem struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
Type string `json:"type"` // password, api-key, token, credential, connection-string, secret, env-var
|
|
Source string `json:"source"` // instance, project, or specific like "instance:user-data"
|
|
MetadataKey string `json:"metadataKey"` // The metadata key where this was found (e.g., user-data, startup-script)
|
|
}
|
|
|
|
// MetadataParseResult contains all parsed metadata fields
|
|
type MetadataParseResult struct {
|
|
HasStartupScript bool
|
|
HasSSHKeys bool
|
|
BlockProjectSSHKeys bool
|
|
OSLoginEnabled bool
|
|
OSLogin2FA bool
|
|
SerialPortEnabled bool
|
|
StartupScriptContent string
|
|
StartupScriptURL string
|
|
SSHKeys []string
|
|
CustomMetadata []string
|
|
RawMetadata map[string]string
|
|
SensitiveItems []SensitiveItem
|
|
}
|
|
|
|
// parseMetadata checks instance metadata for security-relevant settings
|
|
func parseMetadata(metadata *compute.Metadata) (hasStartupScript, hasSSHKeys, blockProjectSSHKeys, osLoginEnabled, osLogin2FA, serialPortEnabled bool) {
|
|
result := parseMetadataFull(metadata)
|
|
return result.HasStartupScript, result.HasSSHKeys, result.BlockProjectSSHKeys,
|
|
result.OSLoginEnabled, result.OSLogin2FA, result.SerialPortEnabled
|
|
}
|
|
|
|
// sensitivePatterns maps key name patterns to secret types
|
|
// These are checked with contains matching, so they should be specific enough to avoid false positives
|
|
var sensitivePatterns = map[string]string{
|
|
// Passwords - high confidence patterns that end with PASSWORD/PASSWD/PWD
|
|
"_PASSWORD": "password",
|
|
"_PASSWD": "password",
|
|
"_PWD": "password",
|
|
"_PASS": "password",
|
|
|
|
// Secrets - patterns that explicitly contain SECRET
|
|
"_SECRET": "secret",
|
|
"SECRET_KEY": "secret",
|
|
"APP_SECRET": "secret",
|
|
"JWT_SECRET": "secret",
|
|
|
|
// API Keys - explicit API key patterns
|
|
"API_KEY": "api-key",
|
|
"APIKEY": "api-key",
|
|
"_APIKEY": "api-key",
|
|
"API_SECRET": "api-key",
|
|
|
|
// Tokens - explicit token patterns (must have _TOKEN suffix or TOKEN_ prefix to be specific)
|
|
"_TOKEN": "token",
|
|
"TOKEN_": "token",
|
|
"ACCESS_TOKEN": "token",
|
|
"AUTH_TOKEN": "token",
|
|
"BEARER_": "token",
|
|
|
|
// Private keys
|
|
"PRIVATE_KEY": "credential",
|
|
"PRIVATEKEY": "credential",
|
|
"_PRIVKEY": "credential",
|
|
|
|
// Connection strings - explicit patterns
|
|
"CONNECTION_STRING": "connection-string",
|
|
"DATABASE_URL": "connection-string",
|
|
"MONGODB_URI": "connection-string",
|
|
"_CONN_STR": "connection-string",
|
|
|
|
// Cloud provider credentials - very specific patterns
|
|
"AWS_SECRET_ACCESS_KEY": "credential",
|
|
"AWS_SESSION_TOKEN": "credential",
|
|
"AZURE_CLIENT_SECRET": "credential",
|
|
"GOOGLE_CREDENTIALS": "credential",
|
|
|
|
// OAuth - specific patterns
|
|
"CLIENT_SECRET": "credential",
|
|
"CONSUMER_SECRET": "credential",
|
|
"OAUTH_SECRET": "credential",
|
|
}
|
|
|
|
// detectSensitiveType checks if a key name matches sensitive patterns
|
|
func detectSensitiveType(key string) string {
|
|
keyUpper := strings.ToUpper(key)
|
|
for pattern, secretType := range sensitivePatterns {
|
|
if strings.Contains(keyUpper, pattern) {
|
|
return secretType
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// parseMetadataFull extracts all metadata including content
|
|
func parseMetadataFull(metadata *compute.Metadata) MetadataParseResult {
|
|
result := MetadataParseResult{
|
|
RawMetadata: make(map[string]string),
|
|
}
|
|
if metadata == nil || metadata.Items == nil {
|
|
return result
|
|
}
|
|
|
|
// Known metadata keys to exclude from custom metadata
|
|
knownKeys := map[string]bool{
|
|
"startup-script": true,
|
|
"startup-script-url": true,
|
|
"ssh-keys": true,
|
|
"sshKeys": true,
|
|
"block-project-ssh-keys": true,
|
|
"enable-oslogin": true,
|
|
"enable-oslogin-2fa": true,
|
|
"serial-port-enable": true,
|
|
"google-compute-default-zone": true,
|
|
"google-compute-default-region": true,
|
|
}
|
|
|
|
for _, item := range metadata.Items {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
|
|
// Store all raw metadata (except ssh-keys which go to separate loot)
|
|
if item.Value != nil && item.Key != "ssh-keys" && item.Key != "sshKeys" {
|
|
result.RawMetadata[item.Key] = *item.Value
|
|
}
|
|
|
|
// Check ALL metadata keys for sensitive patterns (not just custom ones)
|
|
if item.Value != nil {
|
|
if sensitiveType := detectSensitiveType(item.Key); sensitiveType != "" {
|
|
result.SensitiveItems = append(result.SensitiveItems, SensitiveItem{
|
|
Key: item.Key,
|
|
Value: *item.Value,
|
|
Type: sensitiveType,
|
|
MetadataKey: item.Key, // The key itself is the metadata key
|
|
})
|
|
}
|
|
// Also scan metadata VALUES for embedded env vars (e.g., VAR=value patterns)
|
|
valueItems := extractSensitiveFromScript(*item.Value, "metadata-value:"+item.Key)
|
|
result.SensitiveItems = append(result.SensitiveItems, valueItems...)
|
|
}
|
|
|
|
switch item.Key {
|
|
case "startup-script":
|
|
result.HasStartupScript = true
|
|
if item.Value != nil {
|
|
result.StartupScriptContent = *item.Value
|
|
// Check startup script for sensitive patterns (env vars inside script)
|
|
sensitiveItems := extractSensitiveFromScript(*item.Value, "startup-script")
|
|
result.SensitiveItems = append(result.SensitiveItems, sensitiveItems...)
|
|
}
|
|
case "startup-script-url":
|
|
result.HasStartupScript = true
|
|
if item.Value != nil {
|
|
result.StartupScriptURL = *item.Value
|
|
}
|
|
case "ssh-keys", "sshKeys":
|
|
result.HasSSHKeys = true
|
|
if item.Value != nil {
|
|
// Parse SSH keys - format is "user:ssh-rsa KEY comment"
|
|
lines := strings.Split(*item.Value, "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" {
|
|
result.SSHKeys = append(result.SSHKeys, line)
|
|
}
|
|
}
|
|
}
|
|
case "block-project-ssh-keys":
|
|
if item.Value != nil && *item.Value == "true" {
|
|
result.BlockProjectSSHKeys = true
|
|
}
|
|
case "enable-oslogin":
|
|
if item.Value != nil && strings.ToLower(*item.Value) == "true" {
|
|
result.OSLoginEnabled = true
|
|
}
|
|
case "enable-oslogin-2fa":
|
|
if item.Value != nil && strings.ToLower(*item.Value) == "true" {
|
|
result.OSLogin2FA = true
|
|
}
|
|
case "serial-port-enable":
|
|
if item.Value != nil && *item.Value == "true" {
|
|
result.SerialPortEnabled = true
|
|
}
|
|
default:
|
|
// Track custom metadata keys
|
|
if !knownKeys[item.Key] {
|
|
result.CustomMetadata = append(result.CustomMetadata, item.Key)
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// extractSensitiveFromScript scans content for sensitive variable assignments
|
|
// Focuses on explicit VAR=value patterns to minimize false positives
|
|
// source format: "metadata-value:KEY_NAME" or "startup-script" or "project-startup-script"
|
|
func extractSensitiveFromScript(content, source string) []SensitiveItem {
|
|
var items []SensitiveItem
|
|
seen := make(map[string]bool) // Deduplicate findings
|
|
|
|
// Parse the metadata key from the source
|
|
metadataKey := source
|
|
if strings.HasPrefix(source, "metadata-value:") {
|
|
metadataKey = strings.TrimPrefix(source, "metadata-value:")
|
|
}
|
|
|
|
lines := strings.Split(content, "\n")
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
|
|
// Skip empty lines and comments
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
// Pattern 1: Shell style - export VAR=value or VAR=value
|
|
if strings.Contains(line, "=") {
|
|
// Handle export statements and YAML list items
|
|
testLine := strings.TrimPrefix(line, "export ")
|
|
testLine = strings.TrimPrefix(testLine, "- ")
|
|
testLine = strings.TrimPrefix(testLine, "| ")
|
|
testLine = strings.TrimSpace(testLine)
|
|
|
|
parts := strings.SplitN(testLine, "=", 2)
|
|
if len(parts) == 2 {
|
|
key := strings.TrimSpace(parts[0])
|
|
value := strings.TrimSpace(parts[1])
|
|
// Remove quotes from value
|
|
value = strings.Trim(value, "\"'`")
|
|
// Clean up key
|
|
key = strings.TrimLeft(key, "- |>")
|
|
key = strings.TrimSpace(key)
|
|
|
|
// Only consider valid variable names with actual values
|
|
if isValidVarName(key) && len(value) >= 3 && !isPlaceholderValue(value) {
|
|
if sensitiveType := detectSensitiveType(key); sensitiveType != "" {
|
|
dedupeKey := key + ":" + value
|
|
if !seen[dedupeKey] {
|
|
seen[dedupeKey] = true
|
|
items = append(items, SensitiveItem{
|
|
Key: key,
|
|
Value: value,
|
|
Type: sensitiveType,
|
|
MetadataKey: metadataKey,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pattern 2: YAML style "key: value" - only for direct assignments
|
|
if strings.Contains(line, ": ") && !strings.HasPrefix(line, "#") && !strings.Contains(line, "=") {
|
|
parts := strings.SplitN(line, ": ", 2)
|
|
if len(parts) == 2 {
|
|
key := strings.TrimSpace(parts[0])
|
|
value := strings.TrimSpace(parts[1])
|
|
// Clean up key
|
|
key = strings.TrimLeft(key, "- ")
|
|
key = strings.TrimSpace(key)
|
|
// Remove quotes from value
|
|
value = strings.Trim(value, "\"'`")
|
|
|
|
// Skip YAML block indicators and empty values
|
|
if value != "" && value != "|" && value != ">" && len(value) >= 3 && !isPlaceholderValue(value) {
|
|
if sensitiveType := detectSensitiveType(key); sensitiveType != "" {
|
|
dedupeKey := key + ":" + value
|
|
if !seen[dedupeKey] {
|
|
seen[dedupeKey] = true
|
|
items = append(items, SensitiveItem{
|
|
Key: key,
|
|
Value: value,
|
|
Type: sensitiveType,
|
|
MetadataKey: metadataKey,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pattern 3: JSON style "key": "value"
|
|
if strings.Contains(line, "\":") {
|
|
parts := strings.SplitN(line, "\":", 2)
|
|
if len(parts) == 2 {
|
|
keyPart := parts[0]
|
|
if idx := strings.LastIndex(keyPart, "\""); idx >= 0 {
|
|
key := keyPart[idx+1:]
|
|
value := strings.TrimSpace(parts[1])
|
|
value = strings.Trim(value, " ,\"'`")
|
|
|
|
if len(value) >= 3 && !isPlaceholderValue(value) {
|
|
if sensitiveType := detectSensitiveType(key); sensitiveType != "" {
|
|
dedupeKey := key + ":" + value
|
|
if !seen[dedupeKey] {
|
|
seen[dedupeKey] = true
|
|
items = append(items, SensitiveItem{
|
|
Key: key,
|
|
Value: value,
|
|
Type: sensitiveType,
|
|
MetadataKey: metadataKey,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
// isPlaceholderValue checks if a value looks like a placeholder rather than a real secret
|
|
func isPlaceholderValue(value string) bool {
|
|
valueLower := strings.ToLower(value)
|
|
placeholders := []string{
|
|
"xxx", "your_", "your-", "<your", "changeme", "replace", "example",
|
|
"${", "$(", // Variable references
|
|
"todo", "fixme", "placeholder",
|
|
"none", "null", "nil", "empty",
|
|
"true", "false",
|
|
}
|
|
for _, p := range placeholders {
|
|
if strings.Contains(valueLower, p) {
|
|
return true
|
|
}
|
|
}
|
|
// Also skip if it's just a simple word without special chars (likely not a real secret)
|
|
if len(value) < 8 && !strings.ContainsAny(value, "0123456789!@#$%^&*()-_=+[]{}|;:,.<>?/~`") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isValidVarName checks if a string looks like a valid variable name
|
|
func isValidVarName(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
// Variable names typically start with letter or underscore
|
|
first := s[0]
|
|
if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') {
|
|
return false
|
|
}
|
|
// Rest can be alphanumeric or underscore
|
|
for _, c := range s[1:] {
|
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// detectInstanceType determines the type of instance based on labels and name patterns
|
|
func detectInstanceType(instance *compute.Instance) InstanceType {
|
|
if instance == nil {
|
|
return InstanceTypeStandalone
|
|
}
|
|
|
|
labels := instance.Labels
|
|
name := instance.Name
|
|
|
|
// Check labels first (most reliable)
|
|
if labels != nil {
|
|
// GKE nodes have goog-gke-node label
|
|
if _, ok := labels["goog-gke-node"]; ok {
|
|
return InstanceTypeGKE
|
|
}
|
|
// Also check for gke-cluster label
|
|
if _, ok := labels["gke-cluster"]; ok {
|
|
return InstanceTypeGKE
|
|
}
|
|
|
|
// Dataproc nodes have goog-dataproc-cluster-name label
|
|
if _, ok := labels["goog-dataproc-cluster-name"]; ok {
|
|
return InstanceTypeDataproc
|
|
}
|
|
|
|
// Dataflow workers have goog-dataflow-job-id label
|
|
if _, ok := labels["goog-dataflow-job-id"]; ok {
|
|
return InstanceTypeDataflow
|
|
}
|
|
|
|
// Cloud Composer workers have goog-composer-environment label
|
|
if _, ok := labels["goog-composer-environment"]; ok {
|
|
return InstanceTypeComposer
|
|
}
|
|
|
|
// Vertex AI Workbench / AI Platform Notebooks
|
|
if _, ok := labels["goog-notebooks-instance"]; ok {
|
|
return InstanceTypeNotebooks
|
|
}
|
|
// Also check for workbench label
|
|
if _, ok := labels["goog-workbench-instance"]; ok {
|
|
return InstanceTypeNotebooks
|
|
}
|
|
|
|
// Cloud Batch jobs have goog-batch-job-uid label
|
|
if _, ok := labels["goog-batch-job-uid"]; ok {
|
|
return InstanceTypeBatchJob
|
|
}
|
|
|
|
// App Engine Flex instances
|
|
if _, ok := labels["goog-appengine-version"]; ok {
|
|
return InstanceTypeAppEngine
|
|
}
|
|
if _, ok := labels["gae_app"]; ok {
|
|
return InstanceTypeAppEngine
|
|
}
|
|
}
|
|
|
|
// Check name patterns as fallback
|
|
// GKE node names typically follow pattern: gke-{cluster}-{pool}-{hash}
|
|
if strings.HasPrefix(name, "gke-") {
|
|
return InstanceTypeGKE
|
|
}
|
|
|
|
// Dataproc nodes: {cluster}-m (master) or {cluster}-w-{n} (worker)
|
|
if strings.Contains(name, "-m") || strings.Contains(name, "-w-") {
|
|
// This is too generic, rely on labels instead
|
|
}
|
|
|
|
// Check for created-by metadata which indicates MIG
|
|
if instance.Metadata != nil {
|
|
for _, item := range instance.Metadata.Items {
|
|
if item != nil && item.Key == "created-by" && item.Value != nil {
|
|
if strings.Contains(*item.Value, "instanceGroupManagers") {
|
|
return InstanceTypeMIG
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return InstanceTypeStandalone
|
|
}
|
|
|
|
// parseBootDiskEncryption checks the boot disk encryption type
|
|
func parseBootDiskEncryption(disks []*compute.AttachedDisk) (encryptionType, kmsKey string) {
|
|
encryptionType = "Google-managed"
|
|
|
|
for _, disk := range disks {
|
|
if disk == nil || !disk.Boot {
|
|
continue
|
|
}
|
|
|
|
if disk.DiskEncryptionKey != nil {
|
|
if disk.DiskEncryptionKey.KmsKeyName != "" {
|
|
encryptionType = "CMEK"
|
|
kmsKey = disk.DiskEncryptionKey.KmsKeyName
|
|
} else if disk.DiskEncryptionKey.Sha256 != "" {
|
|
encryptionType = "CSEK"
|
|
}
|
|
}
|
|
break // Only check boot disk
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// getLastSnapshotForDisks gets the most recent snapshot date for any of the given disks
|
|
func (ces *ComputeEngineService) getLastSnapshotForDisks(service *compute.Service, projectID string, disks []*compute.AttachedDisk) string {
|
|
ctx := context.Background()
|
|
|
|
// Collect all disk names from the instance
|
|
diskNames := make(map[string]bool)
|
|
for _, disk := range disks {
|
|
if disk == nil || disk.Source == "" {
|
|
continue
|
|
}
|
|
// Extract disk name from source URL
|
|
// Format: projects/{project}/zones/{zone}/disks/{diskName}
|
|
parts := strings.Split(disk.Source, "/")
|
|
if len(parts) > 0 {
|
|
diskNames[parts[len(parts)-1]] = true
|
|
}
|
|
}
|
|
|
|
if len(diskNames) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// List all snapshots in the project and find ones matching our disks
|
|
var latestSnapshot string
|
|
req := service.Snapshots.List(projectID)
|
|
err := req.Pages(ctx, func(page *compute.SnapshotList) error {
|
|
for _, snapshot := range page.Items {
|
|
if snapshot == nil || snapshot.SourceDisk == "" {
|
|
continue
|
|
}
|
|
// Extract disk name from source disk URL
|
|
parts := strings.Split(snapshot.SourceDisk, "/")
|
|
if len(parts) > 0 {
|
|
diskName := parts[len(parts)-1]
|
|
if diskNames[diskName] {
|
|
// Compare timestamps - keep the most recent
|
|
if latestSnapshot == "" || snapshot.CreationTimestamp > latestSnapshot {
|
|
latestSnapshot = snapshot.CreationTimestamp
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
// Silently fail - user may not have permission to list snapshots
|
|
return ""
|
|
}
|
|
|
|
return latestSnapshot
|
|
}
|
|
|
|
// FormatScopes formats service account scopes for display
|
|
func FormatScopes(scopes []string) string {
|
|
if len(scopes) == 0 {
|
|
return "-"
|
|
}
|
|
|
|
// Shorten scope URLs for display
|
|
var shortScopes []string
|
|
for _, scope := range scopes {
|
|
// Extract the scope name from the URL
|
|
parts := strings.Split(scope, "/")
|
|
if len(parts) > 0 {
|
|
shortScopes = append(shortScopes, parts[len(parts)-1])
|
|
}
|
|
}
|
|
return strings.Join(shortScopes, ", ")
|
|
}
|
|
|
|
// GetProjectMetadata retrieves project-level compute metadata
|
|
func (ces *ComputeEngineService) GetProjectMetadata(projectID string) (*ProjectMetadataInfo, error) {
|
|
ctx := context.Background()
|
|
computeService, err := ces.getService(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
project, err := computeService.Projects.Get(projectID).Do()
|
|
if err != nil {
|
|
return nil, gcpinternal.ParseGCPError(err, "compute.googleapis.com")
|
|
}
|
|
|
|
info := &ProjectMetadataInfo{
|
|
ProjectID: projectID,
|
|
RawMetadata: make(map[string]string),
|
|
}
|
|
|
|
if project.CommonInstanceMetadata != nil {
|
|
for _, item := range project.CommonInstanceMetadata.Items {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
|
|
// Store all raw metadata (except ssh-keys which go to separate loot)
|
|
if item.Value != nil && item.Key != "ssh-keys" && item.Key != "sshKeys" {
|
|
info.RawMetadata[item.Key] = *item.Value
|
|
}
|
|
|
|
// Check ALL metadata keys for sensitive patterns
|
|
if item.Value != nil {
|
|
if sensitiveType := detectSensitiveType(item.Key); sensitiveType != "" {
|
|
info.SensitiveMetadata = append(info.SensitiveMetadata, SensitiveItem{
|
|
Key: item.Key,
|
|
Value: *item.Value,
|
|
Type: sensitiveType,
|
|
Source: "project",
|
|
MetadataKey: item.Key,
|
|
})
|
|
}
|
|
// Also scan metadata VALUES for embedded env vars (e.g., VAR=value patterns)
|
|
valueItems := extractSensitiveFromScript(*item.Value, "metadata-value:"+item.Key)
|
|
for i := range valueItems {
|
|
valueItems[i].Source = "project"
|
|
}
|
|
info.SensitiveMetadata = append(info.SensitiveMetadata, valueItems...)
|
|
}
|
|
|
|
switch item.Key {
|
|
case "ssh-keys", "sshKeys":
|
|
info.HasProjectSSHKeys = true
|
|
if item.Value != nil {
|
|
lines := strings.Split(*item.Value, "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" {
|
|
info.ProjectSSHKeys = append(info.ProjectSSHKeys, line)
|
|
}
|
|
}
|
|
}
|
|
case "startup-script":
|
|
info.HasProjectStartupScript = true
|
|
if item.Value != nil {
|
|
info.ProjectStartupScript = *item.Value
|
|
// Check startup script for sensitive patterns (env vars inside script)
|
|
sensitiveItems := extractSensitiveFromScript(*item.Value, "project-startup-script")
|
|
for i := range sensitiveItems {
|
|
sensitiveItems[i].Source = "project"
|
|
}
|
|
info.SensitiveMetadata = append(info.SensitiveMetadata, sensitiveItems...)
|
|
}
|
|
case "enable-oslogin":
|
|
if item.Value != nil && strings.ToLower(*item.Value) == "true" {
|
|
info.OSLoginEnabled = true
|
|
}
|
|
case "enable-oslogin-2fa":
|
|
if item.Value != nil && strings.ToLower(*item.Value) == "true" {
|
|
info.OSLogin2FAEnabled = true
|
|
}
|
|
case "serial-port-enable":
|
|
if item.Value != nil && *item.Value == "true" {
|
|
info.SerialPortEnabled = true
|
|
}
|
|
default:
|
|
// Track other custom metadata keys
|
|
if !isKnownMetadataKey(item.Key) {
|
|
info.CustomMetadataKeys = append(info.CustomMetadataKeys, item.Key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// isKnownMetadataKey checks if a metadata key is a known system key
|
|
func isKnownMetadataKey(key string) bool {
|
|
knownKeys := map[string]bool{
|
|
"ssh-keys": true,
|
|
"sshKeys": true,
|
|
"startup-script": true,
|
|
"startup-script-url": true,
|
|
"block-project-ssh-keys": true,
|
|
"enable-oslogin": true,
|
|
"enable-oslogin-2fa": true,
|
|
"serial-port-enable": true,
|
|
"google-compute-default-zone": true,
|
|
"google-compute-default-region": true,
|
|
"google-compute-enable-logging": true,
|
|
"google-compute-enable-ssh-agent": true,
|
|
}
|
|
return knownKeys[key]
|
|
}
|
|
|
|
// GetInstanceIAMPolicy retrieves IAM policy for a specific instance
|
|
func (ces *ComputeEngineService) GetInstanceIAMPolicy(projectID, zone, instanceName string) (*InstanceIAMInfo, error) {
|
|
ctx := context.Background()
|
|
computeService, err := ces.getService(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
policy, err := computeService.Instances.GetIamPolicy(projectID, zone, instanceName).Do()
|
|
if err != nil {
|
|
return nil, gcpinternal.ParseGCPError(err, "compute.googleapis.com")
|
|
}
|
|
|
|
info := &InstanceIAMInfo{
|
|
InstanceName: instanceName,
|
|
Zone: zone,
|
|
ProjectID: projectID,
|
|
}
|
|
|
|
for _, binding := range policy.Bindings {
|
|
if binding == nil {
|
|
continue
|
|
}
|
|
|
|
switch binding.Role {
|
|
case "roles/compute.admin", "roles/owner":
|
|
info.ComputeAdmins = append(info.ComputeAdmins, binding.Members...)
|
|
case "roles/compute.instanceAdmin", "roles/compute.instanceAdmin.v1":
|
|
info.InstanceAdmins = append(info.InstanceAdmins, binding.Members...)
|
|
case "roles/compute.osLogin", "roles/compute.osAdminLogin":
|
|
info.SSHUsers = append(info.SSHUsers, binding.Members...)
|
|
}
|
|
|
|
// Check for specific permissions via custom roles (more complex detection)
|
|
if strings.HasPrefix(binding.Role, "projects/") || strings.HasPrefix(binding.Role, "organizations/") {
|
|
// Custom role - would need to check permissions, but we note the binding
|
|
info.InstanceAdmins = append(info.InstanceAdmins, binding.Members...)
|
|
}
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// InstancesWithMetadata retrieves instances with full metadata content
|
|
func (ces *ComputeEngineService) InstancesWithMetadata(projectID string) ([]ComputeEngineInfo, *ProjectMetadataInfo, error) {
|
|
instances, err := ces.Instances(projectID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
projectMeta, err := ces.GetProjectMetadata(projectID)
|
|
if err != nil {
|
|
// Don't fail if we can't get project metadata
|
|
projectMeta = &ProjectMetadataInfo{ProjectID: projectID}
|
|
}
|
|
|
|
return instances, projectMeta, nil
|
|
}
|