Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/adversarial-fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 18 additions & 0 deletions docs/connectors.md
Original file line number Diff line number Diff line change
@@ -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.
236 changes: 236 additions & 0 deletions internal/connectors/connectors.go
Original file line number Diff line number Diff line change
@@ -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
}
45 changes: 45 additions & 0 deletions internal/connectors/connectors_test.go
Original file line number Diff line number Diff line change
@@ -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 }
Loading