Files
heicode-mananger/heicode/controller/aws_resource_discovery.go
T
chenchenandClaude Opus 4.8 df8fa80eba fix(discovery): address #5 review — no secret inheritance, GCP URL, tagged-only scope
回应 Fasthei 复审(PR #52 CHANGES_REQUESTED):
1. 发现出的子资源不再继承账号 secret_ref:抽出 DB 无关的 applyDiscoveredResourceFields,
   恒置 SecretRef=""(避免账号级凭据引用经 resourceToResponse / grant manifest 扩散到每个
   VM/S3/DB)。加 TestApplyDiscoveredResourceFields_NoSecretInheritance。
3. GCP searchAllResources URL 修正:gcpSearchAllResourcesURL 不再 PathEscape 整个 scope
   (slash 是路径模板一部分),生成 /v1/projects/<id>:searchAllResources。加 TestGCPSearchAllResourcesURL。
2. AWS 覆盖范围据实声明:Resource Groups Tagging GetResources 仅覆盖 tagged/曾 tagged 资源,
   不覆盖未打标签 EC2/RDS/S3;注释明确「tagged resources only」,#5 全量发现诉求不据此关闭。

controller 全套测试通过,go build/vet 干净。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:29:57 +08:00

303 lines
9.8 KiB
Go

package controller
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
// 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), 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.
type awsCloudDiscoveryProvider struct{}
func (awsCloudDiscoveryProvider) name() string { return "aws" }
type awsDiscoveryCredentials struct {
AccessKeyID string
SecretAccessKey string
SessionToken string
Region string
}
func (c awsDiscoveryCredentials) validate() error {
if strings.TrimSpace(c.AccessKeyID) == "" {
return errors.New("AWS access_key_id required")
}
if strings.TrimSpace(c.SecretAccessKey) == "" {
return errors.New("AWS secret_access_key required")
}
if strings.TrimSpace(c.Region) == "" {
return errors.New("AWS region required")
}
return nil
}
func (awsCloudDiscoveryProvider) discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error) {
// region may live on the binding's external_id when not in creds.
if mapString(creds, "region") == "" && strings.TrimSpace(account.ExternalId) != "" {
creds["region"] = account.ExternalId
}
return discoverAWS(creds, time.Now(), &http.Client{Timeout: 20 * time.Second})
}
// hmacSHA256 / sha256Hex — SigV4 primitives.
func hmacSHA256(key, data []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(data)
return h.Sum(nil)
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// awsSigV4Authorization computes the SigV4 Authorization header value + x-amz-date
// for the given request. signHeaders are the headers (besides host/x-amz-date) to
// include in the signature (e.g. content-type, x-amz-target). Pure & deterministic
// given t — verified against AWS's official "get-vanilla" test vector.
func awsSigV4Authorization(method, rawURL string, signHeaders map[string]string, payload []byte,
cred awsDiscoveryCredentials, service string, t time.Time) (authorization, amzDate string, err error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", "", err
}
amzDate = t.UTC().Format("20060102T150405Z")
dateStamp := t.UTC().Format("20060102")
// Canonical headers must include host + x-amz-date. Lowercase names, trim values, sort.
headers := map[string]string{}
for k, v := range signHeaders {
headers[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
}
headers["host"] = u.Host
headers["x-amz-date"] = amzDate
if strings.TrimSpace(cred.SessionToken) != "" {
headers["x-amz-security-token"] = strings.TrimSpace(cred.SessionToken)
}
names := make([]string, 0, len(headers))
for k := range headers {
names = append(names, k)
}
sort.Strings(names)
var canonicalHeaders strings.Builder
for _, n := range names {
canonicalHeaders.WriteString(n + ":" + headers[n] + "\n")
}
signedHeaders := strings.Join(names, ";")
canonicalURI := u.EscapedPath()
if canonicalURI == "" {
canonicalURI = "/"
}
// Canonical query string: sort by key, RFC3986-encoded.
canonicalQuery := canonicalizeQuery(u.Query())
payloadHash := sha256Hex(payload)
canonicalRequest := method + "\n" + canonicalURI + "\n" + canonicalQuery + "\n" +
canonicalHeaders.String() + "\n" + signedHeaders + "\n" + payloadHash
credentialScope := dateStamp + "/" + cred.Region + "/" + service + "/aws4_request"
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex([]byte(canonicalRequest))
kDate := hmacSHA256([]byte("AWS4"+cred.SecretAccessKey), []byte(dateStamp))
kRegion := hmacSHA256(kDate, []byte(cred.Region))
kService := hmacSHA256(kRegion, []byte(service))
kSigning := hmacSHA256(kService, []byte("aws4_request"))
signature := hex.EncodeToString(hmacSHA256(kSigning, []byte(stringToSign)))
authorization = fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
cred.AccessKeyID, credentialScope, signedHeaders, signature)
return authorization, amzDate, nil
}
func canonicalizeQuery(values url.Values) string {
if len(values) == 0 {
return ""
}
keys := make([]string, 0, len(values))
for k := range values {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
vs := append([]string(nil), values[k]...)
sort.Strings(vs)
for _, v := range vs {
parts = append(parts, awsURIEncode(k, true)+"="+awsURIEncode(v, true))
}
}
return strings.Join(parts, "&")
}
// awsURIEncode is RFC3986 percent-encoding per SigV4 rules.
func awsURIEncode(s string, encodeSlash bool) string {
var b strings.Builder
for _, c := range []byte(s) {
switch {
case (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '-' || c == '_' || c == '.' || c == '~':
b.WriteByte(c)
case c == '/' && !encodeSlash:
b.WriteByte(c)
default:
b.WriteString(fmt.Sprintf("%%%02X", c))
}
}
return b.String()
}
// awsResourceTagMapping is one entry of the Resource Groups Tagging API response.
type awsResourceTagMapping struct {
ResourceARN string `json:"ResourceARN"`
Tags []struct {
Key string `json:"Key"`
Value string `json:"Value"`
} `json:"Tags"`
}
type awsGetResourcesResponse struct {
ResourceTagMappingList []awsResourceTagMapping `json:"ResourceTagMappingList"`
PaginationToken string `json:"PaginationToken"`
}
// discoverAWS enumerates the account's tagged resources via the Resource Groups
// Tagging API (paginated). Real implementation (no mock).
func discoverAWS(creds map[string]any, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
cred := awsDiscoveryCredentials{
AccessKeyID: mapString(creds, "access_key_id"),
SecretAccessKey: mapString(creds, "secret_access_key"),
SessionToken: mapString(creds, "session_token"),
Region: mapString(creds, "region"),
}
if err := cred.validate(); err != nil {
return nil, err
}
endpoint := fmt.Sprintf("https://tagging.%s.amazonaws.com/", cred.Region)
const target = "ResourceGroupsTaggingAPI_20170126.GetResources"
out := []discoveredCloudResource{}
paginationToken := ""
for page := 0; page < 50; page++ {
bodyMap := map[string]any{"ResourcesPerPage": 100}
if paginationToken != "" {
bodyMap["PaginationToken"] = paginationToken
}
body, err := common.Marshal(bodyMap)
if err != nil {
return nil, err
}
signHeaders := map[string]string{
"content-type": "application/x-amz-json-1.1",
"x-amz-target": target,
}
auth, amzDate, err := awsSigV4Authorization(http.MethodPost, endpoint, signHeaders, body, cred, "tagging", now)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
req.Header.Set("X-Amz-Target", target)
req.Header.Set("X-Amz-Date", amzDate)
req.Header.Set("Authorization", auth)
if strings.TrimSpace(cred.SessionToken) != "" {
req.Header.Set("X-Amz-Security-Token", cred.SessionToken)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("AWS GetResources request failed: %w", err)
}
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("AWS GetResources failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var payload awsGetResourcesResponse
if err := common.Unmarshal(raw, &payload); err != nil {
return nil, err
}
for _, m := range payload.ResourceTagMappingList {
if d, ok := awsARNToResource(m.ResourceARN); ok {
tags := map[string]any{}
for _, t := range m.Tags {
tags[t.Key] = t.Value
}
d.Metadata = map[string]any{"region": cred.Region, "tags": tags}
out = append(out, d)
}
}
paginationToken = strings.TrimSpace(payload.PaginationToken)
if paginationToken == "" {
break
}
}
return out, nil
}
// awsARNToResource parses arn:partition:service:region:account:resource into a
// normalized resource. NativeType is CloudFormation-style (AWS::<Service>::<Type>)
// so classifyCloudResourceType maps it to vm/database/blob consistently.
func awsARNToResource(arn string) (discoveredCloudResource, bool) {
arn = strings.TrimSpace(arn)
if !strings.HasPrefix(arn, "arn:") {
return discoveredCloudResource{}, false
}
parts := strings.SplitN(arn, ":", 6)
if len(parts) < 6 {
return discoveredCloudResource{}, false
}
service := parts[2]
region := parts[3]
resource := parts[5]
// resource may be "type/id", "type:id", or just "id" (e.g. s3 bucket).
resType, resName := "", resource
if i := strings.IndexAny(resource, "/:"); i >= 0 {
resType = resource[:i]
resName = resource[i+1:]
}
native := "AWS::" + strings.ToUpper(service)
if resType != "" {
native += "::" + resType
} else if strings.EqualFold(service, "s3") {
native += "::Bucket"
}
name := resName
if name == "" {
name = arn
}
return discoveredCloudResource{
ExternalId: arn,
Name: name,
NativeType: native,
Location: region,
BindingScope: "aws:" + arn,
}, true
}