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/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/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 }