diff --git a/.github/workflows/adversarial-fuzz.yml b/.github/workflows/adversarial-fuzz.yml index ce6e739..1300641 100644 --- a/.github/workflows/adversarial-fuzz.yml +++ b/.github/workflows/adversarial-fuzz.yml @@ -7,7 +7,9 @@ on: - "internal/scan/**" - "internal/config/**" - "internal/adversarial/**" + - "internal/connectors/**" - "internal/runtimefuzz/**" + - "docs/connectors.md" - "testdata/adversarial/**" - "testdata/fuzz/**" - ".github/workflows/adversarial-fuzz.yml" diff --git a/README.md b/README.md index 25f6d13..b8d915f 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ The alpha includes ten explainable rules: ## Current limitations - Detection is heuristic and should be reviewed by a human. -- The alpha scans local repositories and configuration files; GitHub, GitLab, Docker and Kubernetes connectors are not implemented yet. +- The alpha scans local repositories and configuration files. A read-only connector metadata layer now normalizes GitHub, GitLab, Docker and Kubernetes discovery payloads; network clients and credential resolution remain outside the local scan path. - No runtime proxy, IAM remediation, hosted control plane, dashboard or compliance certification is included. - JSON and SARIF are stable report formats for static and runtime findings. Runtime snapshots can be compared with `agentctl runtime-diff` and exported as CSV or HTML; issue export is planned. diff --git a/docs/cloud-audit-policy.md b/docs/cloud-audit-policy.md new file mode 100644 index 0000000..60efed6 --- /dev/null +++ b/docs/cloud-audit-policy.md @@ -0,0 +1,7 @@ +# Cloud audit correlation and draft policies + +The cloud audit package normalizes metadata-only events from AWS, GCP, and Azure into a common event shape. It accepts provider-exported JSON and extracts timestamp, principal, action, resource, environment, agent identity, and success state. It does not resolve credentials, call cloud APIs, read payloads, or retrieve secret values. + +The policy package creates a `policy-draft.v1` object from runtime usage and normalized cloud resources. Drafts contain observed targets and operations, cloud resources correlated by stable agent ID, and an explicit `review_required: true` marker. Draft generation never blocks traffic, changes IAM, writes cloud policies, or enables enforcement automatically. + +A future orchestration layer may load cloud events from CloudTrail, GCP Audit Logs, or Azure Activity Logs. That layer must make credentials and retention explicit, keep raw event payloads out of findings, and pass only normalized events into correlation. diff --git a/docs/connectors.md b/docs/connectors.md new file mode 100644 index 0000000..d4ce4c0 --- /dev/null +++ b/docs/connectors.md @@ -0,0 +1,18 @@ +# Read-only connector metadata + +The connector package defines a small normalization boundary for external discovery systems. It accepts already-fetched JSON metadata and returns a bounded `Metadata` object for inventory enrichment. + +Supported kinds are GitHub repositories, GitLab projects, Docker image inspect metadata, and Kubernetes workload metadata. The package deliberately does not create network clients, resolve credentials, execute provider CLIs, or read Kubernetes Secret values. Those responsibilities belong to an explicit orchestration layer that can enforce user-selected credentials and audit access. + +The normalized object contains only source locator, repository/project/image/workload identity, revision, namespace, labels, and annotations. Label and annotation keys or values containing secret, token, password, API key, authorization, or credential markers are discarded. Values are length-bounded and unsupported or malformed JSON is rejected. + +A future connector implementation should satisfy: + +```go +type Connector interface { + Kind() Kind + Normalize(payload []byte) (Metadata, error) +} +``` + +The local scan path remains deterministic and network-free. Connector discovery should be performed before scanning and passed into inventory as an explicit, reviewable input. diff --git a/internal/cloudaudit/audit.go b/internal/cloudaudit/audit.go new file mode 100644 index 0000000..96c841c --- /dev/null +++ b/internal/cloudaudit/audit.go @@ -0,0 +1,114 @@ +package cloudaudit + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +type Provider string + +const ( + AWS Provider = "aws" + GCP Provider = "gcp" + Azure Provider = "azure" +) + +type Event struct { + Timestamp time.Time `json:"timestamp"` + Provider Provider `json:"provider"` + AgentID string `json:"agent_id,omitempty"` + Principal string `json:"principal,omitempty"` + Action string `json:"action"` + Resource string `json:"resource"` + Environment string `json:"environment,omitempty"` + Success bool `json:"success"` +} + +type Options struct { + MaxEvents int + MaxLineBytes int +} + +func (o Options) withDefaults() Options { + if o.MaxEvents <= 0 { + o.MaxEvents = 1000000 + } + if o.MaxLineBytes <= 0 { + o.MaxLineBytes = 1 << 20 + } + return o +} + +func Normalize(provider Provider, payload []byte) (Event, error) { + var value struct { + Timestamp string `json:"timestamp"` + EventTime string `json:"eventTime"` + AgentID string `json:"agent_id"` + Principal string `json:"principal"` + UserIdentity struct { + ARN string `json:"arn"` + Email string `json:"email"` + } `json:"userIdentity"` + Action string `json:"action"` + EventName string `json:"eventName"` + MethodName string `json:"methodName"` + Resource string `json:"resource"` + ResourceName string `json:"resourceName"` + Environment string `json:"environment"` + Success *bool `json:"success"` + ErrorCode string `json:"errorCode"` + } + if err := json.Unmarshal(payload, &value); err != nil { + return Event{}, fmt.Errorf("decode cloud audit event: %w", err) + } + if provider != AWS && provider != GCP && provider != Azure { + return Event{}, fmt.Errorf("unsupported cloud provider %q", provider) + } + timestamp := value.Timestamp + if timestamp == "" { + timestamp = value.EventTime + } + parsed, err := time.Parse(time.RFC3339, timestamp) + if err != nil { + return Event{}, fmt.Errorf("cloud audit timestamp: %w", err) + } + action := value.Action + if action == "" { + action = value.EventName + } + if action == "" { + action = value.MethodName + } + resource := value.Resource + if resource == "" { + resource = value.ResourceName + } + if strings.TrimSpace(action) == "" || strings.TrimSpace(resource) == "" { + return Event{}, fmt.Errorf("cloud audit action and resource are required") + } + principal := value.Principal + if principal == "" { + principal = value.UserIdentity.ARN + } + if principal == "" { + principal = value.UserIdentity.Email + } + success := true + if value.Success != nil { + success = *value.Success + } + if value.ErrorCode != "" { + success = false + } + return Event{Timestamp: parsed.UTC(), Provider: provider, AgentID: bounded(value.AgentID, 512), Principal: bounded(principal, 512), Action: bounded(action, 512), Resource: bounded(resource, 1024), Environment: bounded(value.Environment, 128), Success: success}, nil +} + +func bounded(value string, max int) string { + value = strings.TrimSpace(value) + if len(value) > max { + return value[:max] + } + return value +} diff --git a/internal/cloudaudit/audit_test.go b/internal/cloudaudit/audit_test.go new file mode 100644 index 0000000..0f8a908 --- /dev/null +++ b/internal/cloudaudit/audit_test.go @@ -0,0 +1,24 @@ +package cloudaudit + +import ( + "testing" +) + +func TestNormalizeCloudAuditEvent(t *testing.T) { + event, err := Normalize(AWS, []byte(`{"eventTime":"2026-01-02T03:04:05Z","userIdentity":{"arn":"arn:aws:iam::123:role/support"},"eventName":"PutItem","resource":"arn:aws:dynamodb:us-east-1:123:table/customers","errorCode":""}`)) + if err != nil { + t.Fatalf("Normalize() error = %v", err) + } + if event.Action != "PutItem" || event.Resource == "" || !event.Success { + t.Fatalf("unexpected event: %+v", event) + } +} + +func TestNormalizeRejectsUnsupportedAndIncomplete(t *testing.T) { + if _, err := Normalize("oracle", []byte(`{}`)); err == nil { + t.Fatal("unsupported provider accepted") + } + if _, err := Normalize(GCP, []byte(`{"timestamp":"2026-01-02T03:04:05Z"}`)); err == nil { + t.Fatal("incomplete event accepted") + } +} diff --git a/internal/connectors/connectors.go b/internal/connectors/connectors.go new file mode 100644 index 0000000..115842c --- /dev/null +++ b/internal/connectors/connectors.go @@ -0,0 +1,236 @@ +package connectors + +import ( + "encoding/json" + "fmt" + "strings" +) + +type Kind string + +const ( + GitHub Kind = "github" + GitLab Kind = "gitlab" + Docker Kind = "docker" + Kubernetes Kind = "kubernetes" +) + +type Source struct { + Kind Kind `json:"kind"` + Name string `json:"name"` + Locator string `json:"locator"` +} +type Metadata struct { + Source Source `json:"source"` + Repository string `json:"repository,omitempty"` + Project string `json:"project,omitempty"` + Image string `json:"image,omitempty"` + Namespace string `json:"namespace,omitempty"` + Workload string `json:"workload,omitempty"` + Revision string `json:"revision,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +type Connector interface { + Kind() Kind + Normalize([]byte) (Metadata, error) +} + +type Registry struct{ connectors map[Kind]Connector } + +func NewRegistry() Registry { + return Registry{connectors: map[Kind]Connector{GitHub: githubConnector{}, GitLab: gitlabConnector{}, Docker: dockerConnector{}, Kubernetes: kubernetesConnector{}}} +} +func (r Registry) Normalize(kind Kind, payload []byte) (Metadata, error) { + connector, ok := r.connectors[kind] + if !ok { + return Metadata{}, fmt.Errorf("unsupported connector %q", kind) + } + return connector.Normalize(payload) +} + +func normalizeString(value string, max int) (string, error) { + value = strings.TrimSpace(value) + if len(value) > max { + return "", fmt.Errorf("metadata value exceeds %d bytes", max) + } + return value, nil +} +func normalizeMap(values map[string]string) map[string]string { + if len(values) == 0 { + return nil + } + result := make(map[string]string, len(values)) + for key, value := range values { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key != "" && len(key) <= 256 && len(value) <= 1024 && !sensitiveKey(key) && !sensitiveKey(value) { + result[key] = value + } + } + if len(result) == 0 { + return nil + } + return result +} + +func sensitiveKey(value string) bool { + normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", ".", "").Replace(value)) + for _, part := range []string{"secret", "token", "password", "apikey", "authorization", "credential"} { + if strings.Contains(normalized, part) { + return true + } + } + return false +} + +func decode(data []byte, target any) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("decode connector metadata: %w", err) + } + if len(raw) == 0 { + return fmt.Errorf("connector metadata must be a JSON object") + } + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("decode connector metadata: %w", err) + } + return nil +} + +type githubConnector struct{} + +func (githubConnector) Kind() Kind { return GitHub } +func (githubConnector) Normalize(data []byte) (Metadata, error) { + var value struct { + FullName string `json:"full_name"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` + DefaultBranch string `json:"default_branch"` + Topics []string `json:"topics"` + } + if err := decode(data, &value); err != nil { + return Metadata{}, err + } + repo, err := normalizeString(value.FullName, 512) + if err != nil { + return Metadata{}, err + } + if repo == "" { + repo, err = normalizeString(value.Name, 512) + if err != nil { + return Metadata{}, err + } + } + locator, err := normalizeString(value.HTMLURL, 1024) + if err != nil { + return Metadata{}, err + } + revision, err := normalizeString(value.DefaultBranch, 128) + if err != nil { + return Metadata{}, err + } + return Metadata{Source: Source{Kind: GitHub, Name: "GitHub repository", Locator: locator}, Repository: repo, Revision: revision, Labels: normalizeMap(mapFromList(value.Topics))}, nil +} + +type gitlabConnector struct{} + +func (gitlabConnector) Kind() Kind { return GitLab } +func (gitlabConnector) Normalize(data []byte) (Metadata, error) { + var value struct { + PathWithNamespace string `json:"path_with_namespace"` + Name string `json:"name"` + WebURL string `json:"web_url"` + DefaultBranch string `json:"default_branch"` + Topics []string `json:"topics"` + } + if err := decode(data, &value); err != nil { + return Metadata{}, err + } + project, err := normalizeString(value.PathWithNamespace, 512) + if err != nil { + return Metadata{}, err + } + if project == "" { + project, err = normalizeString(value.Name, 512) + if err != nil { + return Metadata{}, err + } + } + locator, err := normalizeString(value.WebURL, 1024) + if err != nil { + return Metadata{}, err + } + revision, err := normalizeString(value.DefaultBranch, 128) + if err != nil { + return Metadata{}, err + } + return Metadata{Source: Source{Kind: GitLab, Name: "GitLab project", Locator: locator}, Project: project, Revision: revision, Labels: normalizeMap(mapFromList(value.Topics))}, nil +} + +type dockerConnector struct{} + +func (dockerConnector) Kind() Kind { return Docker } +func (dockerConnector) Normalize(data []byte) (Metadata, error) { + var value struct { + RepoTags []string `json:"RepoTags"` + Config struct { + Labels map[string]string `json:"Labels"` + } `json:"Config"` + } + if err := decode(data, &value); err != nil { + return Metadata{}, err + } + image := "" + if len(value.RepoTags) > 0 { + image = value.RepoTags[0] + } + image, err := normalizeString(image, 1024) + if err != nil { + return Metadata{}, err + } + return Metadata{Source: Source{Kind: Docker, Name: "Docker image", Locator: image}, Image: image, Labels: normalizeMap(value.Config.Labels)}, nil +} + +type kubernetesConnector struct{} + +func (kubernetesConnector) Kind() Kind { return Kubernetes } +func (kubernetesConnector) Normalize(data []byte) (Metadata, error) { + var value struct { + Kind string `json:"kind"` + Metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` + } `json:"metadata"` + } + if err := decode(data, &value); err != nil { + return Metadata{}, err + } + workload, err := normalizeString(value.Metadata.Name, 512) + if err != nil { + return Metadata{}, err + } + namespace, err := normalizeString(value.Metadata.Namespace, 256) + if err != nil { + return Metadata{}, err + } + kind, err := normalizeString(value.Kind, 128) + if err != nil { + return Metadata{}, err + } + return Metadata{Source: Source{Kind: Kubernetes, Name: kind + " workload", Locator: namespace + "/" + workload}, Namespace: namespace, Workload: workload, Labels: normalizeMap(value.Metadata.Labels), Annotations: normalizeMap(value.Metadata.Annotations)}, nil +} + +func mapFromList(values []string) map[string]string { + result := make(map[string]string, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + result[value] = "true" + } + } + return result +} diff --git a/internal/connectors/connectors_test.go b/internal/connectors/connectors_test.go new file mode 100644 index 0000000..3cdc74d --- /dev/null +++ b/internal/connectors/connectors_test.go @@ -0,0 +1,45 @@ +package connectors + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestRegistryNormalizesMetadataOnly(t *testing.T) { + tests := []struct { + kind Kind + payload string + want string + }{ + {GitHub, `{"full_name":"HlinorAI/agent-control-plane","html_url":"https://github.com/HlinorAI/agent-control-plane","default_branch":"main","topics":["agents"]}`, "HlinorAI/agent-control-plane"}, + {GitLab, `{"path_with_namespace":"group/project","web_url":"https://gitlab.example/group/project","default_branch":"main"}`, "group/project"}, + {Docker, `{"RepoTags":["registry.example/agent:1.2.3"],"Config":{"Labels":{"owner":"security","secret":"must-not-be-read"}}}`, "registry.example/agent:1.2.3"}, + {Kubernetes, `{"kind":"Deployment","metadata":{"name":"support-agent","namespace":"production","labels":{"app":"support"},"annotations":{"owner":"security"}},"spec":{"env":[{"name":"TOKEN","value":"must-not-be-read"}]}}`, "production/support-agent"}, + } + registry := NewRegistry() + for _, test := range tests { + metadata, err := registry.Normalize(test.kind, []byte(test.payload)) + if err != nil { + t.Fatalf("Normalize(%s) error = %v", test.kind, err) + } + if metadata.Source.Locator != test.want && metadata.Repository != test.want && metadata.Project != test.want && metadata.Image != test.want { + t.Fatalf("Normalize(%s) locator mismatch: %+v", test.kind, metadata) + } + if strings.Contains(string(mustJSON(metadata)), "must-not-be-read") { + t.Fatalf("Normalize(%s) retained secret-like payload", test.kind) + } + } +} + +func TestRegistryRejectsUnsupportedAndMalformedInput(t *testing.T) { + registry := NewRegistry() + if _, err := registry.Normalize("aws", []byte(`{}`)); err == nil { + t.Fatal("unsupported connector accepted") + } + if _, err := registry.Normalize(GitHub, []byte(`[]`)); err == nil { + t.Fatal("array payload accepted") + } +} + +func mustJSON(value Metadata) []byte { payload, _ := json.Marshal(value); return payload } diff --git a/internal/policy/draft.go b/internal/policy/draft.go new file mode 100644 index 0000000..27cd6aa --- /dev/null +++ b/internal/policy/draft.go @@ -0,0 +1,58 @@ +package policy + +import ( + "sort" + "strings" + + "github.com/HlinorAI/agent-control-plane/internal/cloudaudit" + "github.com/HlinorAI/agent-control-plane/internal/runtime" +) + +type Draft struct { + SchemaVersion string `json:"schema_version"` + AgentID string `json:"agent_id"` + Environment string `json:"environment,omitempty"` + AllowedTargets []string `json:"allowed_targets,omitempty"` + AllowedActions []string `json:"allowed_actions,omitempty"` + CloudResources []string `json:"cloud_resources,omitempty"` + ReviewRequired bool `json:"review_required"` +} + +func FromRuntime(agent runtime.AgentSummary, cloud []cloudaudit.Event) Draft { + draft := Draft{SchemaVersion: "policy-draft.v1", AgentID: agent.AgentID, ReviewRequired: true} + for _, environment := range agent.Environments { + if strings.EqualFold(environment, "production") || strings.EqualFold(environment, "prod") { + draft.Environment = "production" + break + } + if draft.Environment == "" { + draft.Environment = environment + } + } + draft.AllowedTargets = append([]string(nil), agent.Targets...) + for operation := range agent.Operations { + draft.AllowedActions = append(draft.AllowedActions, operation) + } + for _, event := range cloud { + if event.AgentID == agent.AgentID { + draft.CloudResources = appendUnique(draft.CloudResources, event.Resource) + } + } + sort.Strings(draft.AllowedTargets) + sort.Strings(draft.AllowedActions) + sort.Strings(draft.CloudResources) + return draft +} + +func appendUnique(values []string, value string) []string { + value = strings.TrimSpace(value) + if value == "" { + return values + } + for _, current := range values { + if current == value { + return values + } + } + return append(values, value) +} diff --git a/internal/policy/draft_test.go b/internal/policy/draft_test.go new file mode 100644 index 0000000..1d4e64c --- /dev/null +++ b/internal/policy/draft_test.go @@ -0,0 +1,19 @@ +package policy + +import ( + "testing" + "time" + + "github.com/HlinorAI/agent-control-plane/internal/cloudaudit" + "github.com/HlinorAI/agent-control-plane/internal/runtime" +) + +func TestFromRuntimeCreatesReviewableDraft(t *testing.T) { + draft := FromRuntime(runtime.AgentSummary{AgentID: "a1", Targets: []string{"crm.search"}, Environments: []string{"production"}, Operations: map[string]int{"tool_call": 3}}, []cloudaudit.Event{{AgentID: "a1", Timestamp: time.Now(), Action: "GetItem", Resource: "arn:aws:dynamodb:table/customers"}}) + if draft.SchemaVersion != "policy-draft.v1" || !draft.ReviewRequired || draft.Environment != "production" { + t.Fatalf("unexpected draft: %+v", draft) + } + if len(draft.CloudResources) != 1 || draft.CloudResources[0] == "" { + t.Fatalf("cloud resource missing: %+v", draft) + } +}