#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>
269 lines
11 KiB
Go
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) // 补齐未打标签资源
|
|
}
|