Files
heicode-mananger/heicode/controller/cloud_resource_discovery_test.go
T
chenchenandClaude Opus 4.8 8529340a40 feat(discovery): AWS full-account resource discovery — EC2/RDS/S3/Lambda List/Describe (#62)
#5 的 AWS 适配器只走 Resource Groups Tagging API GetResources,仅覆盖「已打标签」资源,
不含未打标签的 EC2/RDS/S3 等(代码已据实声明,#5 全量诉求未据此关闭)。本次补齐:

- 新增服务级发现(aws_resource_discovery_services.go):EC2 DescribeInstances、
  RDS DescribeDBInstances、S3 ListBuckets、Lambda ListFunctions,均复用已通过 AWS
  get-vanilla 向量验证的 SigV4 签名(awsSigV4Authorization),无 AWS SDK 依赖。
- discoverAWS 改为编排:tagged + 各服务级结果按 ARN 合并去重(tagged 优先保留,标签信息更全);
  原 tagged 逻辑保留为 discoverAWSTagged。
- 失败降级:单服务调用失败(如缺该服务读权限)只记日志并跳过,不让整次发现失败;仅当
  tagged 报错且无任何结果时才抛原始错误,保证凭据/区域问题可见。
- 区域:EC2/RDS/Lambda 用账号配置 region;S3 ListBuckets 全局(us-east-1 签名)。跨 region
  扫描不在本次范围(单 region),后续可在 #62 跟踪。

安全:沿用 applyDiscoveredResourceFields 既有约束——发现出的资源 SecretRef 恒空,绝不继承
账号 secret_ref;凭据仍走 cloud_account secret 链路,不落代码/日志。

影响面:仅 HM 内部云资源发现;不涉及 Client、AM、agent_swarm、计费、审计字段、密钥写入。
测试:parseEC2Instances/parseRDSInstances/parseS3Buckets/parseLambdaFunctions(真实响应
样本 XML/JSON)+ mergeDedupeByExternalID(去重/优先/空 id);SigV4 向量与既有发现测试不变;
go build ./... 与 controller 测试全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:38:17 +08:00

269 lines
11 KiB
Go

package controller
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"strings"
"testing"
"time"
"github.com/heicode/manager/model"
"github.com/stretchr/testify/require"
)
// #5: 跨云类别分类 vm/database/blob 一致。
func TestClassifyCloudResourceType(t *testing.T) {
cases := map[string]string{
// vm
"Microsoft.Compute/virtualMachines": "vm",
"AWS::EC2::Instance": "vm",
"compute.googleapis.com/Instance": "vm",
// database
"Microsoft.Sql/servers/databases": "database",
"AWS::RDS::DBInstance": "database",
"sqladmin.googleapis.com/Instance": "database",
"AWS::DynamoDB::Table": "database",
// blob / object storage
"Microsoft.Storage/storageAccounts": "blob",
"AWS::S3::Bucket": "blob",
"storage.googleapis.com/Bucket": "blob",
// fallback
"Microsoft.Network/virtualNetworks": "cloud_resource",
"AWS::IAM::Role": "cloud_resource",
}
for native, want := range cases {
require.Equal(t, want, classifyCloudResourceType(native), "classify %s", native)
}
}
// #5: ARN 解析 → 归一化 + 正确分类。
func TestAWSARNToResource(t *testing.T) {
ec2, ok := awsARNToResource("arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123")
require.True(t, ok)
require.Equal(t, "us-east-1", ec2.Location)
require.Equal(t, "i-0abc123", ec2.Name)
require.Equal(t, "vm", classifyCloudResourceType(ec2.NativeType))
s3, ok := awsARNToResource("arn:aws:s3:::my-bucket")
require.True(t, ok)
require.Equal(t, "my-bucket", s3.Name)
require.Equal(t, "blob", classifyCloudResourceType(s3.NativeType))
rds, ok := awsARNToResource("arn:aws:rds:eu-west-1:123:db:prod-pg")
require.True(t, ok)
require.Equal(t, "database", classifyCloudResourceType(rds.NativeType))
_, ok = awsARNToResource("not-an-arn")
require.False(t, ok)
}
// #5: SigV4 实现正确性 —— 对齐 AWS 官方 "get-vanilla" 测试向量。
func TestAWSSigV4_VanillaVector(t *testing.T) {
cred := awsDiscoveryCredentials{
AccessKeyID: "AKIDEXAMPLE",
SecretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
Region: "us-east-1",
}
tm := time.Date(2015, 8, 30, 12, 36, 0, 0, time.UTC)
auth, amzDate, err := awsSigV4Authorization("GET", "https://example.amazonaws.com/", map[string]string{}, []byte(""), cred, "service", tm)
require.NoError(t, err)
require.Equal(t, "20150830T123600Z", amzDate)
require.Contains(t, auth, "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request")
require.Contains(t, auth, "SignedHeaders=host;x-amz-date")
// 官方向量期望签名
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)
require.NoError(t, err)
der, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
pemStr := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
cred := gcpDiscoveryCredentials{
ClientEmail: "svc@proj.iam.gserviceaccount.com",
PrivateKey: pemStr,
ProjectID: "proj",
}
tm := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
jwt, err := buildGCPAssertion(cred, "https://www.googleapis.com/auth/cloud-platform", "https://oauth2.googleapis.com/token", tm)
require.NoError(t, err)
parts := strings.Split(jwt, ".")
require.Len(t, parts, 3)
// 验签:signingInput = header.payload
signingInput := parts[0] + "." + parts[1]
digest := sha256.Sum256([]byte(signingInput))
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
require.NoError(t, err)
require.NoError(t, rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], sig), "RS256 签名应可被公钥验证")
// header alg + claims
hdr, err := base64.RawURLEncoding.DecodeString(parts[0])
require.NoError(t, err)
require.Contains(t, string(hdr), "RS256")
claims, err := base64.RawURLEncoding.DecodeString(parts[1])
require.NoError(t, err)
require.Contains(t, string(claims), "svc@proj.iam.gserviceaccount.com")
require.Contains(t, string(claims), "cloud-platform")
}
// #62: EC2 DescribeInstances XML 解析 —— 含 Name 标签 / ownerId / nextToken;ARN 由 region+owner+id 组装。
func TestParseEC2Instances(t *testing.T) {
body := []byte(`<?xml version="1.0"?>
<DescribeInstancesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
<reservationSet>
<item>
<ownerId>123456789012</ownerId>
<instancesSet>
<item>
<instanceId>i-0abc123</instanceId>
<instanceType>t3.micro</instanceType>
<instanceState><name>running</name></instanceState>
<placement><availabilityZone>us-east-1a</availabilityZone></placement>
<tagSet>
<item><key>Name</key><value>web-1</value></item>
<item><key>env</key><value>prod</value></item>
</tagSet>
</item>
<item>
<instanceId>i-0def456</instanceId>
<instanceType>t3.small</instanceType>
</item>
</instancesSet>
</item>
</reservationSet>
<nextToken>NEXT==</nextToken>
</DescribeInstancesResponse>`)
out, next, err := parseEC2Instances(body, "us-east-1")
require.NoError(t, err)
require.Equal(t, "NEXT==", next)
require.Len(t, out, 2)
require.Equal(t, "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123", out[0].ExternalId)
require.Equal(t, "web-1", out[0].Name) // Name 标签优先
require.Equal(t, "AWS::EC2::Instance", out[0].NativeType)
require.Equal(t, "vm", classifyCloudResourceType(out[0].NativeType))
require.Equal(t, "i-0def456", out[1].Name) // 无 Name 标签回退 instanceId
}
// #62: RDS DescribeDBInstances XML 解析 —— ARN 直接取 DBInstanceArn;无 ARN 跳过;Marker 续页。
func TestParseRDSInstances(t *testing.T) {
body := []byte(`<DescribeDBInstancesResponse xmlns="http://rds.amazonaws.com/doc/2014-10-31/">
<DescribeDBInstancesResult>
<Marker>m2</Marker>
<DBInstances>
<DBInstance>
<DBInstanceArn>arn:aws:rds:eu-west-1:123456789012:db:prod-pg</DBInstanceArn>
<DBInstanceIdentifier>prod-pg</DBInstanceIdentifier>
<Engine>postgres</Engine>
<DBInstanceStatus>available</DBInstanceStatus>
</DBInstance>
<DBInstance>
<DBInstanceIdentifier>no-arn</DBInstanceIdentifier>
<Engine>mysql</Engine>
</DBInstance>
</DBInstances>
</DescribeDBInstancesResult>
</DescribeDBInstancesResponse>`)
out, marker, err := parseRDSInstances(body, "eu-west-1")
require.NoError(t, err)
require.Equal(t, "m2", marker)
require.Len(t, out, 1) // 无 ARN 的被跳过
require.Equal(t, "arn:aws:rds:eu-west-1:123456789012:db:prod-pg", out[0].ExternalId)
require.Equal(t, "prod-pg", out[0].Name)
require.Equal(t, "database", classifyCloudResourceType(out[0].NativeType))
}
// #62: S3 ListBuckets XML 解析 —— ARN 为 arn:aws:s3:::<name>,全局无 region。
func TestParseS3Buckets(t *testing.T) {
body := []byte(`<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Owner><ID>x</ID></Owner>
<Buckets>
<Bucket><Name>my-bucket</Name><CreationDate>2024-01-01T00:00:00Z</CreationDate></Bucket>
<Bucket><Name>logs-bucket</Name></Bucket>
</Buckets>
</ListAllMyBucketsResult>`)
out, err := parseS3Buckets(body)
require.NoError(t, err)
require.Len(t, out, 2)
require.Equal(t, "arn:aws:s3:::my-bucket", out[0].ExternalId)
require.Equal(t, "my-bucket", out[0].Name)
require.Equal(t, "blob", classifyCloudResourceType(out[0].NativeType))
require.Empty(t, out[0].Location)
}
// #62: Lambda ListFunctions JSON 解析 —— ARN 取 FunctionArn;NextMarker 续页。
func TestParseLambdaFunctions(t *testing.T) {
body := []byte(`{"Functions":[
{"FunctionArn":"arn:aws:lambda:us-east-1:123456789012:function:fn-a","FunctionName":"fn-a","Runtime":"go1.x"},
{"FunctionArn":"arn:aws:lambda:us-east-1:123456789012:function:fn-b","FunctionName":"fn-b","Runtime":"python3.12"}
],"NextMarker":"mk"}`)
out, next, err := parseLambdaFunctions(body, "us-east-1")
require.NoError(t, err)
require.Equal(t, "mk", next)
require.Len(t, out, 2)
require.Equal(t, "arn:aws:lambda:us-east-1:123456789012:function:fn-a", out[0].ExternalId)
require.Equal(t, "fn-a", out[0].Name)
require.Equal(t, "AWS::Lambda::Function", out[0].NativeType)
}
// #62: 合并去重 —— 先到先得(tagged 优先保留),空 ExternalId 丢弃,顺序稳定。
func TestMergeDedupeByExternalID(t *testing.T) {
tagged := []discoveredCloudResource{
{ExternalId: "arn:a", Name: "tagged-a", Metadata: map[string]any{"tags": map[string]any{"k": "v"}}},
{ExternalId: "arn:b", Name: "tagged-b"},
}
ec2 := []discoveredCloudResource{
{ExternalId: "arn:a", Name: "ec2-a"}, // 与 tagged 重复 → 保留 tagged
{ExternalId: "arn:c", Name: "ec2-c"}, // 未打标签的新资源
{ExternalId: "", Name: "empty"}, // 空 id 丢弃
}
merged := mergeDedupeByExternalID(tagged, ec2)
require.Len(t, merged, 3)
require.Equal(t, "tagged-a", merged[0].Name) // tagged 优先
require.NotNil(t, merged[0].Metadata["tags"]) // 保留更丰富的 tagged 元数据
require.Equal(t, "tagged-b", merged[1].Name)
require.Equal(t, "ec2-c", merged[2].Name) // 补齐未打标签资源
}