diff --git a/heicode/controller/aws_resource_discovery.go b/heicode/controller/aws_resource_discovery.go index 12033d5a..d66df7ab 100644 --- a/heicode/controller/aws_resource_discovery.go +++ b/heicode/controller/aws_resource_discovery.go @@ -20,8 +20,12 @@ import ( // AWS resource discovery (#5). Pure-Go AWS Signature V4 (no AWS SDK), mirroring // the REST approach of azure_resource_discovery.go. Uses the Resource Groups -// Tagging API GetResources (AWS JSON 1.1) to enumerate the account's tagged -// resources across services, then normalizes ARNs to discoveredCloudResource. +// Tagging API GetResources (AWS JSON 1.1), then normalizes ARNs to discoveredCloudResource. +// +// ⚠️ 覆盖范围(#5 复审 #2,据实声明):GetResources 仅返回**已打标签或曾打标签**的资源, +// **不覆盖完全未打标签的 EC2/RDS/S3 等**。因此本适配器是「**tagged resources only**」的发现, +// 不等于账号内全量资源;补全需后续加服务级 List/Describe(DescribeInstances / DescribeDBInstances / +// ListBuckets 等)。在此之前 #5 的「全量发现」诉求不应据本适配器关闭。 // // Creds (from cloud_account metadata/secret): access_key_id, secret_access_key, // region, optional session_token. diff --git a/heicode/controller/cloud_resource_discovery.go b/heicode/controller/cloud_resource_discovery.go index 321d6a97..374e5ac4 100644 --- a/heicode/controller/cloud_resource_discovery.go +++ b/heicode/controller/cloud_resource_discovery.go @@ -85,60 +85,17 @@ func upsertDiscoveredCloudResources(account model.ResourceBinding, provider stri if strings.TrimSpace(d.ExternalId) == "" { continue } - metadata := map[string]any{} - for k, v := range d.Metadata { - metadata[k] = v - } - metadata["source_account_id"] = account.Id - metadata["classified_type"] = classifyCloudResourceType(d.NativeType) - if strings.TrimSpace(d.NativeType) != "" { - metadata["native_type"] = d.NativeType - } - if strings.TrimSpace(d.Location) != "" { - metadata["location"] = d.Location - } - metadataJSON, err := marshalResourceJSON(metadata) - if err != nil { - return nil, err - } - permissionScopeJSON, err := marshalResourceJSON(map[string]any{"actions": []string{provider + ":read"}}) - if err != nil { - return nil, err - } - constraintsJSON, err := marshalResourceJSON(unmarshalResourceJSON(account.Constraints)) - if err != nil { - return nil, err - } - name := strings.TrimSpace(d.Name) - if name == "" { - name = d.ExternalId - } - bindingScope := strings.TrimSpace(d.BindingScope) - if bindingScope == "" { - bindingScope = fmt.Sprintf("%s:%s", provider, d.ExternalId) - } - var resource model.ResourceBinding - err = model.DB.Where( + err := model.DB.Where( "user_id = ? AND resource_type = ? AND provider = ? AND external_id = ?", account.UserId, "cloud_resource", provider, d.ExternalId, ).First(&resource).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } - resource.UserId = account.UserId - resource.TenantId = account.TenantId - resource.ProjectId = account.ProjectId - resource.BindingScope = bindingScope - resource.Name = name - resource.ResourceType = "cloud_resource" - resource.Provider = provider - resource.ExternalId = d.ExternalId - resource.SecretRef = account.SecretRef - resource.Metadata = metadataJSON - resource.PermissionScope = permissionScopeJSON - resource.Constraints = constraintsJSON - resource.Status = "active" + if err := applyDiscoveredResourceFields(&resource, account, provider, d); err != nil { + return nil, err + } if resource.Id == 0 { if err := model.DB.Create(&resource).Error; err != nil { return nil, err @@ -151,6 +108,59 @@ func upsertDiscoveredCloudResources(account model.ResourceBinding, provider stri return items, nil } +// applyDiscoveredResourceFields 把一条归一化发现结果写入 ResourceBinding 字段(DB 无关,可单测)。 +// 关键安全约束(#5 复审 #1):**SecretRef 恒为空**——发现出的子资源绝不继承云账号 secret_ref, +// 否则会把账号级凭据引用扩散到每个 VM/S3/DB 并经 resourceToResponse / grant manifest 下发给 agent。 +// 发现是只读清单;如需 agent 读取某资源,应另行 grant/审批/最小权限 secret。 +func applyDiscoveredResourceFields(resource *model.ResourceBinding, account model.ResourceBinding, provider string, d discoveredCloudResource) error { + metadata := map[string]any{} + for k, v := range d.Metadata { + metadata[k] = v + } + metadata["source_account_id"] = account.Id + metadata["classified_type"] = classifyCloudResourceType(d.NativeType) + if strings.TrimSpace(d.NativeType) != "" { + metadata["native_type"] = d.NativeType + } + if strings.TrimSpace(d.Location) != "" { + metadata["location"] = d.Location + } + metadataJSON, err := marshalResourceJSON(metadata) + if err != nil { + return err + } + permissionScopeJSON, err := marshalResourceJSON(map[string]any{"actions": []string{provider + ":read"}}) + if err != nil { + return err + } + constraintsJSON, err := marshalResourceJSON(unmarshalResourceJSON(account.Constraints)) + if err != nil { + return err + } + name := strings.TrimSpace(d.Name) + if name == "" { + name = d.ExternalId + } + bindingScope := strings.TrimSpace(d.BindingScope) + if bindingScope == "" { + bindingScope = fmt.Sprintf("%s:%s", provider, d.ExternalId) + } + resource.UserId = account.UserId + resource.TenantId = account.TenantId + resource.ProjectId = account.ProjectId + resource.BindingScope = bindingScope + resource.Name = name + resource.ResourceType = "cloud_resource" + resource.Provider = provider + resource.ExternalId = d.ExternalId + resource.SecretRef = "" // 见上:绝不继承账号 secret_ref + resource.Metadata = metadataJSON + resource.PermissionScope = permissionScopeJSON + resource.Constraints = constraintsJSON + resource.Status = "active" + return nil +} + // DiscoverCloudResources: POST /api/resources/:id/discover-cloud — provider-agnostic // discovery dispatcher (#5). Routes by the account's provider to the matching // adapter (azure/aws/gcp), then upserts via the unified path. diff --git a/heicode/controller/cloud_resource_discovery_test.go b/heicode/controller/cloud_resource_discovery_test.go index be19029c..cd2e0ee3 100644 --- a/heicode/controller/cloud_resource_discovery_test.go +++ b/heicode/controller/cloud_resource_discovery_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/heicode/manager/model" "github.com/stretchr/testify/require" ) @@ -78,6 +79,39 @@ func TestAWSSigV4_VanillaVector(t *testing.T) { require.Contains(t, auth, "Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31") } +// #5 复审 #3:GCP searchAllResources URL —— scope 的 slash 不得被 %2F 转义。 +func TestGCPSearchAllResourcesURL(t *testing.T) { + u := gcpSearchAllResourcesURL("my-proj", "") + require.Equal(t, "https://cloudasset.googleapis.com/v1/projects/my-proj:searchAllResources?pageSize=500", u) + require.NotContains(t, u, "%2F", "scope slash 不应被转义") + + u2 := gcpSearchAllResourcesURL("my-proj", "tok en/+") + require.Contains(t, u2, "&pageToken=tok+en%2F%2B") +} + +// #5 复审 #1:发现出的资源**绝不继承**账号 secret_ref;元数据带 classified_type,不含账号凭据引用。 +func TestApplyDiscoveredResourceFields_NoSecretInheritance(t *testing.T) { + account := model.ResourceBinding{ + Id: 42, UserId: 7, ResourceType: "cloud_account", Provider: "aws", + SecretRef: "azkv://heicode-kv.vault.azure.net/secrets/aws-keys", + } + d := discoveredCloudResource{ + ExternalId: "arn:aws:ec2:us-east-1:123:instance/i-1", + Name: "i-1", NativeType: "AWS::EC2::Instance", Location: "us-east-1", + Metadata: map[string]any{"region": "us-east-1"}, + } + var r model.ResourceBinding + require.NoError(t, applyDiscoveredResourceFields(&r, account, "aws", d)) + + require.Equal(t, "", r.SecretRef, "发现出的子资源绝不继承账号 secret_ref") + require.Equal(t, "cloud_resource", r.ResourceType) + require.Equal(t, "aws", r.Provider) + require.Equal(t, 7, r.UserId) + require.NotContains(t, r.Metadata, "azkv://", "metadata 不得含账号凭据引用") + require.Contains(t, r.Metadata, "\"classified_type\":\"vm\"") + require.Contains(t, r.PermissionScope, "aws:read") +} + // #5: GCP SA JWT —— RS256 断言可被对应公钥验签,且 claims 正确。 func TestBuildGCPAssertion_RS256Roundtrip(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 2048) diff --git a/heicode/controller/gcp_resource_discovery.go b/heicode/controller/gcp_resource_discovery.go index 537f451f..4078558e 100644 --- a/heicode/controller/gcp_resource_discovery.go +++ b/heicode/controller/gcp_resource_discovery.go @@ -162,6 +162,17 @@ type gcpSearchResponse struct { NextPageToken string `json:"nextPageToken"` } +// gcpSearchAllResourcesURL 构造 Cloud Asset Inventory searchAllResources 端点。 +// #5 复审 #3:scope 里的 slash 是路径模板的一部分,**不能** PathEscape 成 %2F —— 正确形如 +// /v1/projects/:searchAllResources。projectID 仍按单段转义,pageToken 走 query 转义。 +func gcpSearchAllResourcesURL(projectID, pageToken string) string { + endpoint := "https://cloudasset.googleapis.com/v1/projects/" + url.PathEscape(projectID) + ":searchAllResources?pageSize=500" + if strings.TrimSpace(pageToken) != "" { + endpoint += "&pageToken=" + url.QueryEscape(pageToken) + } + return endpoint +} + func discoverGCP(creds map[string]any, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) { cred := gcpDiscoveryCredentials{ ClientEmail: mapString(creds, "client_email"), @@ -177,15 +188,10 @@ func discoverGCP(creds map[string]any, now time.Time, httpClient *http.Client) ( return nil, err } - scope := "projects/" + cred.ProjectID - base := "https://cloudasset.googleapis.com/v1/" + url.PathEscape(scope) + ":searchAllResources" out := []discoveredCloudResource{} pageToken := "" for page := 0; page < 50; page++ { - endpoint := base + "?pageSize=500" - if pageToken != "" { - endpoint += "&pageToken=" + url.QueryEscape(pageToken) - } + endpoint := gcpSearchAllResourcesURL(cred.ProjectID, pageToken) req, err := http.NewRequest(http.MethodGet, endpoint, nil) if err != nil { return nil, err