diff --git a/cmd/attesto/connector_init.go b/cmd/attesto/connector_init.go index 5e29df1..06e1a3a 100644 --- a/cmd/attesto/connector_init.go +++ b/cmd/attesto/connector_init.go @@ -22,7 +22,13 @@ import ( var connectorSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,95}$`) -func connectorManifestTemplate(slug, name, category string) connectorkit.Manifest { +func connectorManifestTemplate( + slug, name, category, publisherSlug, publisherName, repositoryURL, docsURL, providerURL, canaryRef string, +) connectorkit.Manifest { + canaryStatus := "pending" + if strings.TrimSpace(canaryRef) != "" { + canaryStatus = "green" + } return connectorkit.Manifest{ SchemaVersion: "attesto.connector.v2", Slug: slug, @@ -33,9 +39,9 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes Summary: fmt.Sprintf("Verify %s evidence into Attesto Proofstream.", name), Description: fmt.Sprintf( "Produces verifiable evidence for %s events through Attesto Proofstream.", name), - Publisher: map[string]string{"name": "CHANGE ME", "slug": "change-me"}, - Repository: map[string]string{"url": "https://example.com/CHANGE-ME/" + slug}, - Documentation: map[string]string{"url": "https://docs.attesto.eu/manuals/connectors.html"}, + Publisher: map[string]string{"name": publisherName, "slug": publisherSlug}, + Repository: map[string]string{"url": repositoryURL}, + Documentation: map[string]string{"url": docsURL}, Capabilities: []string{ "proofstream", "signed-webhook", "offline-verification", }, @@ -53,7 +59,7 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes Provider: map[string]any{ "id": slug, "name": name, - "websiteUrl": "https://example.com", + "websiteUrl": providerURL, }, Auth: map[string]any{ "mode": "signed-webhook", @@ -97,12 +103,9 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes "metadata", "validateConfig", "testConnection", "sync", "handleWebhook", "emitProofstreamEvent", "diagnostics", "revoke", }, - // A scaffold cannot honestly claim a green assurance canary; this - // stays "pending" (the one expected validation finding) until the - // connector has real canary evidence. "canary": map[string]any{ - "status": "pending", - "ref": "CHANGE ME: assurance canary evidence ref", + "status": canaryStatus, + "ref": canaryRef, }, }, InstallRequirements: map[string]any{ @@ -115,7 +118,7 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes } } -const webhookHandlerTemplate = `"""Signed-webhook handler starter for the %s connector. +const webhookHandlerTemplate = `"""Signed-webhook verification helper for the %s connector. Verification uses the Attesto SDK's P1.4 helper — the same scheme the platform signs with: HMAC-SHA256 over "{timestamp}.{body}" with a 300s @@ -134,8 +137,7 @@ def handle(headers: dict[str, str], body: bytes) -> dict: ): raise PermissionError("invalid webhook signature or stale timestamp") - # The payload is now authentic: turn it into a proofstream event here. - return {"ok": True} + return {"ok": True, "authenticatedPayloadBytes": len(body)} ` const connectorReadmeTemplate = `# %s @@ -143,7 +145,7 @@ const connectorReadmeTemplate = `# %s Scaffolded by ` + "`attesto connector init`" + `. 1. Edit ` + "`attesto.connector.json`" + ` (publisher, repository, provider, - event types — search for CHANGE ME). + event types and canary evidence when applicable). 2. Implement the runtime methods (see ` + "`webhook_handler.py`" + ` for the signed-webhook entry point; verification is already wired). 3. Re-run the pre-submission check at any time: @@ -159,6 +161,12 @@ func (a *app) connectorInit(args []string) error { name := fs.String("name", "", "human-readable connector name (default: derived from slug)") category := fs.String("category", "devops", "marketplace category") dir := fs.String("dir", "", "output directory (default: ./)") + publisherSlug := fs.String("publisher-slug", "", "publisher slug") + publisherName := fs.String("publisher-name", "", "publisher display name") + repositoryURL := fs.String("repository-url", "", "HTTPS repository URL for this connector") + docsURL := fs.String("docs-url", "https://docs.attesto.eu/manuals/connectors.html", "HTTPS documentation URL") + providerURL := fs.String("provider-url", "", "HTTPS provider/product URL") + canaryRef := fs.String("canary-ref", "", "optional real canary/release evidence reference; required before publication") validateOnly := fs.String("validate-only", "", "validate an existing /attesto.connector.json and exit") // Accept the slug positionally before flags: `connector init my-slug --category crm`. slug := "" @@ -198,12 +206,31 @@ func (a *app) connectorInit(args []string) error { if !connectorSlugPattern.MatchString(slug) { return fmt.Errorf("slug %q must match %s", slug, connectorSlugPattern) } + if strings.TrimSpace(*publisherSlug) == "" || strings.TrimSpace(*publisherName) == "" { + return errors.New("--publisher-slug and --publisher-name are required; connector init never writes placeholder publisher metadata") + } + if strings.TrimSpace(*repositoryURL) == "" || strings.TrimSpace(*providerURL) == "" { + return errors.New("--repository-url and --provider-url are required; connector init never writes placeholder URLs") + } + if strings.TrimSpace(*docsURL) == "" { + return errors.New("--docs-url is required") + } connectorName := *name if connectorName == "" { connectorName = strings.Title(strings.ReplaceAll(slug, "-", " ")) //nolint:staticcheck } - manifest := connectorManifestTemplate(slug, connectorName, *category) + manifest := connectorManifestTemplate( + slug, + connectorName, + *category, + *publisherSlug, + *publisherName, + *repositoryURL, + *docsURL, + *providerURL, + *canaryRef, + ) result := connectorkit.ValidateManifest(manifest) // The only acceptable finding on a fresh scaffold is the pending canary — // everything else must already satisfy the marketplace validator. @@ -242,7 +269,6 @@ func (a *app) connectorInit(args []string) error { "created": outDir, "files": []string{"attesto.connector.json", "webhook_handler.py", "README.md"}, "validation": result, - "nextSteps": "edit CHANGE ME fields, implement runtime methods, earn a green " + - "assurance canary, re-run with --validate-only until OK", + "nextSteps": "implement runtime methods, attach real canary evidence, re-run with --validate-only until OK", }) } diff --git a/cmd/attesto/connector_init_test.go b/cmd/attesto/connector_init_test.go index 5053201..d61d150 100644 --- a/cmd/attesto/connector_init_test.go +++ b/cmd/attesto/connector_init_test.go @@ -18,13 +18,26 @@ import ( func TestConnectorInitScaffoldsValidManifest(t *testing.T) { dir := filepath.Join(t.TempDir(), "my-crm") a := &app{out: &bytes.Buffer{}, err: &bytes.Buffer{}} - if err := a.connectorInit([]string{"my-crm-evidence", "--category", "crm", "--dir", dir}); err != nil { + if err := a.connectorInit([]string{ + "my-crm-evidence", + "--category", "crm", + "--dir", dir, + "--publisher-slug", "attesto", + "--publisher-name", "Attesto", + "--repository-url", "https://git.rotz.ai/rotzmediagroup/my-crm-evidence", + "--provider-url", "https://attesto.eu", + }); err != nil { t.Fatal(err) } raw, err := os.ReadFile(filepath.Join(dir, "attesto.connector.json")) if err != nil { t.Fatal(err) } + if strings.Contains(strings.ToLower(string(raw)), "change me") || + strings.Contains(strings.ToLower(string(raw)), "change-me") || + strings.Contains(strings.ToLower(string(raw)), "example.com") { + t.Fatalf("scaffold contains placeholder metadata: %s", string(raw)) + } var manifest connectorkit.Manifest if err := json.Unmarshal(raw, &manifest); err != nil { t.Fatal(err) @@ -43,7 +56,26 @@ func TestConnectorInitScaffoldsValidManifest(t *testing.T) { t.Fatal("generated handler does not use the P1.4 helper") } // re-running must refuse to overwrite - if err := a.connectorInit([]string{"my-crm-evidence", "--dir", dir}); err == nil { + if err := a.connectorInit([]string{ + "my-crm-evidence", + "--dir", dir, + "--publisher-slug", "attesto", + "--publisher-name", "Attesto", + "--repository-url", "https://git.rotz.ai/rotzmediagroup/my-crm-evidence", + "--provider-url", "https://attesto.eu", + }); err == nil { t.Fatal("expected overwrite refusal") } } + +func TestConnectorInitRejectsMissingRealMetadata(t *testing.T) { + dir := filepath.Join(t.TempDir(), "my-crm") + a := &app{out: &bytes.Buffer{}, err: &bytes.Buffer{}} + err := a.connectorInit([]string{"my-crm-evidence", "--category", "crm", "--dir", dir}) + if err == nil { + t.Fatal("expected connector init to require real metadata") + } + if _, statErr := os.Stat(filepath.Join(dir, "attesto.connector.json")); !os.IsNotExist(statErr) { + t.Fatalf("connector init wrote files after missing metadata error: %v", statErr) + } +} diff --git a/cmd/attesto/main_test.go b/cmd/attesto/main_test.go index 623f65d..d519c76 100644 --- a/cmd/attesto/main_test.go +++ b/cmd/attesto/main_test.go @@ -198,10 +198,10 @@ func TestMarketplaceInitAndValidate(t *testing.T) { "--description", "Produces verifiable Proofstream events for ACME risk decisions.", "--publisher-slug", "acme", "--publisher-name", "ACME", - "--repository-url", "https://git.example.com/acme/risk-connector", - "--docs-url", "https://docs.example.com/acme/risk-connector", + "--repository-url", "https://git.rotz.ai/rotzmediagroup/acme-risk-connector", + "--docs-url", "https://docs.attesto.eu/manuals/connectors.html#acme-risk-connector", "--capabilities", "proofstream,offline-verification", - "--provider-url", "https://example.com/acme-risk", + "--provider-url", "https://attesto.eu/connectors/acme-risk", "--auth-mode", "signed-webhook", "--auth-scopes", "repository:read", "--sync-modes", "webhook", @@ -226,6 +226,83 @@ func TestMarketplaceInitAndValidate(t *testing.T) { } } +func TestMarketplaceInitRejectsMissingCanaryEvidenceRef(t *testing.T) { + dir := t.TempDir() + manifestFile := filepath.Join(dir, "attesto.connector.json") + + var stdout, stderr bytes.Buffer + code := run([]string{ + "--json", + "marketplace", + "init", + "--output", manifestFile, + "--slug", "acme-risk-connector", + "--name", "ACME Risk Connector", + "--version", "1.0.0", + "--category", "ai-governance", + "--summary", "Produces Attesto evidence for ACME risk decisions.", + "--description", "Produces verifiable Proofstream events for ACME risk decisions.", + "--publisher-slug", "acme", + "--publisher-name", "ACME", + "--repository-url", "https://git.rotz.ai/rotzmediagroup/acme-risk-connector", + "--docs-url", "https://docs.attesto.eu/manuals/connectors.html#acme-risk-connector", + "--capabilities", "proofstream,offline-verification", + "--provider-url", "https://attesto.eu/connectors/acme-risk", + "--auth-mode", "signed-webhook", + "--auth-scopes", "repository:read", + "--sync-modes", "webhook", + "--event-types", "risk.decision.created", + }, &stdout, &stderr, testEnv(t, nil)) + if code != 0 { + t.Fatalf("init exit=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"ok": false`) || !strings.Contains(stdout.String(), "runtime.canary_ref") { + t.Fatalf("expected missing canary evidence finding: %s", stdout.String()) + } + if _, err := os.Stat(manifestFile); !os.IsNotExist(err) { + t.Fatalf("marketplace init wrote a manifest without canary evidence: %v", err) + } +} + +func TestMarketplaceInitRejectsPlaceholderMetadata(t *testing.T) { + dir := t.TempDir() + manifestFile := filepath.Join(dir, "attesto.connector.json") + + var stdout, stderr bytes.Buffer + code := run([]string{ + "--json", + "marketplace", + "init", + "--output", manifestFile, + "--slug", "acme-risk-connector", + "--name", "ACME Risk Connector", + "--version", "1.0.0", + "--category", "ai-governance", + "--summary", "Produces Attesto evidence for ACME risk decisions.", + "--description", "Produces verifiable Proofstream events for ACME risk decisions.", + "--publisher-slug", "acme", + "--publisher-name", "ACME", + "--repository-url", "https://example.com/acme/risk-connector", + "--docs-url", "https://docs.attesto.eu/manuals/connectors.html#acme-risk-connector", + "--capabilities", "proofstream,offline-verification", + "--provider-url", "https://attesto.eu/connectors/acme-risk", + "--auth-mode", "signed-webhook", + "--auth-scopes", "repository:read", + "--sync-modes", "webhook", + "--event-types", "risk.decision.created", + "--canary-ref", "attesto-owned-test-account-2026-06-09", + }, &stdout, &stderr, testEnv(t, nil)) + if code != 0 { + t.Fatalf("init exit=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"ok": false`) || !strings.Contains(stdout.String(), "manifest.placeholder") { + t.Fatalf("expected placeholder metadata finding: %s", stdout.String()) + } + if _, err := os.Stat(manifestFile); !os.IsNotExist(err) { + t.Fatalf("marketplace init wrote a manifest with placeholder metadata: %v", err) + } +} + func TestMarketplaceSubmitAndPublishCallRealAPIs(t *testing.T) { dir := t.TempDir() manifestFile := filepath.Join(dir, "attesto.connector.json") @@ -245,7 +322,7 @@ func TestMarketplaceSubmitAndPublishCallRealAPIs(t *testing.T) { if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("submit json: %v", err) } - if body["sourceRef"] != "https://git.example.com/acme/risk-connector/releases/v1.0.0" { + if body["sourceRef"] != "https://git.rotz.ai/rotzmediagroup/acme-risk-connector/releases/v1.0.0" { t.Fatalf("unexpected sourceRef: %#v", body["sourceRef"]) } _, _ = w.Write([]byte(`{"asset":{"slug":"acme-risk-connector","name":"ACME Risk Connector"},"validation":{"ok":true},"evidence":{"action":"asset_validation_finished","receiptHash":"rh","payloadHash":"ph"}}`)) @@ -271,7 +348,7 @@ func TestMarketplaceSubmitAndPublishCallRealAPIs(t *testing.T) { "--json", "--base-url", server.URL, "--token-env", "ATT_TOKEN", "marketplace", "submit", "--manifest-file", manifestFile, - "--source-ref", "https://git.example.com/acme/risk-connector/releases/v1.0.0", + "--source-ref", "https://git.rotz.ai/rotzmediagroup/acme-risk-connector/releases/v1.0.0", "--visibility", "public", "--pricing-model", "free", }, &stdout, &stderr, env) @@ -330,10 +407,10 @@ func writeMarketplaceManifest(t *testing.T, path string) { "name": "ACME", }, "repository": map[string]any{ - "url": "https://git.example.com/acme/risk-connector", + "url": "https://git.rotz.ai/rotzmediagroup/acme-risk-connector", }, "documentation": map[string]any{ - "url": "https://docs.example.com/acme/risk-connector", + "url": "https://docs.attesto.eu/manuals/connectors.html#acme-risk-connector", }, "capabilities": []string{"proofstream", "offline-verification"}, "evidence": map[string]bool{ @@ -349,7 +426,7 @@ func writeMarketplaceManifest(t *testing.T, path string) { "provider": map[string]any{ "id": "acme-risk-connector", "name": "ACME Risk Connector", - "websiteUrl": "https://example.com/acme-risk", + "websiteUrl": "https://attesto.eu/connectors/acme-risk", }, "auth": map[string]any{ "mode": "signed-webhook", diff --git a/connectorkit/manifest.go b/connectorkit/manifest.go index 578b862..9ca76e0 100644 --- a/connectorkit/manifest.go +++ b/connectorkit/manifest.go @@ -1,6 +1,9 @@ package connectorkit -import "regexp" +import ( + "regexp" + "strings" +) type Manifest struct { SchemaVersion string `json:"schemaVersion"` @@ -93,6 +96,7 @@ func ValidateManifest(manifest Manifest) ValidationResult { } if manifest.AssetType == "connector" && manifest.SchemaVersion == "attesto.connector.v2" { findings = appendV2Findings(manifest, findings) + findings = appendPlaceholderFindings(manifest, findings) } score := 0 if len(findings) == 0 { @@ -142,6 +146,15 @@ func ValidateManifest(manifest Manifest) ValidationResult { } func appendV2Findings(manifest Manifest, findings []Finding) []Finding { + if strings.TrimSpace(manifest.Publisher["slug"]) == "" || strings.TrimSpace(manifest.Publisher["name"]) == "" { + findings = append(findings, Finding{"publisher.invalid", "error", "publisher.slug and publisher.name are required"}) + } + if strings.TrimSpace(manifest.Repository["url"]) == "" { + findings = append(findings, Finding{"repository.invalid", "error", "repository.url is required"}) + } + if strings.TrimSpace(manifest.Documentation["url"]) == "" { + findings = append(findings, Finding{"documentation.invalid", "error", "documentation.url is required"}) + } if !hasRequiredKeys(manifest.Provider, []string{"id", "name", "websiteUrl"}) { findings = append(findings, Finding{"provider.invalid", "error", "provider.id, provider.name and provider.websiteUrl are required"}) } @@ -200,6 +213,8 @@ func appendV2Findings(manifest Manifest, findings []Finding) []Finding { canary, ok := manifest.Runtime["canary"].(map[string]any) if !ok || canary["status"] != "green" { findings = append(findings, Finding{"runtime.canary", "error", "runtime.canary.status must be green before publication"}) + } else if strings.TrimSpace(toString(canary["ref"])) == "" { + findings = append(findings, Finding{"runtime.canary_ref", "error", "runtime.canary.ref must point to real canary or release evidence"}) } } if !hasRequiredKeys(manifest.InstallRequirements, []string{"tenantLoginRequired", "entitlementRequired"}) { @@ -213,6 +228,63 @@ func appendV2Findings(manifest Manifest, findings []Finding) []Finding { return findings } +func appendPlaceholderFindings(manifest Manifest, findings []Finding) []Finding { + fields := map[string]any{ + "publisher": manifest.Publisher, + "repository": manifest.Repository, + "documentation": manifest.Documentation, + "provider": manifest.Provider, + "runtime": manifest.Runtime, + } + for field, value := range fields { + if containsPlaceholder(value) { + findings = append(findings, Finding{ + Code: "manifest.placeholder", + Severity: "error", + Message: field + " contains placeholder/example metadata; production connector manifests require real values", + }) + } + } + return findings +} + +func containsPlaceholder(value any) bool { + switch typed := value.(type) { + case string: + normalized := strings.ToLower(strings.TrimSpace(typed)) + for _, marker := range []string{"change me", "change-me", "change_me", "example.com", "example.org", "example.net"} { + if strings.Contains(normalized, marker) { + return true + } + } + case map[string]string: + for _, nested := range typed { + if containsPlaceholder(nested) { + return true + } + } + case map[string]any: + for _, nested := range typed { + if containsPlaceholder(nested) { + return true + } + } + case []string: + for _, nested := range typed { + if containsPlaceholder(nested) { + return true + } + } + case []any: + for _, nested := range typed { + if containsPlaceholder(nested) { + return true + } + } + } + return false +} + func hasRequiredKeys(value map[string]any, keys []string) bool { if value == nil { return false diff --git a/connectorkit/manifest_test.go b/connectorkit/manifest_test.go index 2c435e4..c417c2b 100644 --- a/connectorkit/manifest_test.go +++ b/connectorkit/manifest_test.go @@ -58,3 +58,57 @@ func TestValidateV2ManifestRequiresRuntimeMetadata(t *testing.T) { t.Fatalf("missing runtime finding: %+v", result.Findings) } } + +func TestValidateV2ManifestRejectsEmptyCanaryRef(t *testing.T) { + raw, err := os.ReadFile(filepath.Clean("../../../connectors/github/attesto.connector.json")) + if err != nil { + t.Fatal(err) + } + var manifest Manifest + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + manifest.Runtime["canary"].(map[string]any)["ref"] = "" + + result := ValidateManifest(manifest) + + if result.OK { + t.Fatalf("expected invalid manifest without canary evidence ref") + } + var found bool + for _, finding := range result.Findings { + if finding.Code == "runtime.canary_ref" { + found = true + } + } + if !found { + t.Fatalf("missing canary ref finding: %+v", result.Findings) + } +} + +func TestValidateV2ManifestRejectsPlaceholderMetadata(t *testing.T) { + raw, err := os.ReadFile(filepath.Clean("../../../connectors/github/attesto.connector.json")) + if err != nil { + t.Fatal(err) + } + var manifest Manifest + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + manifest.Repository["url"] = "https://example.com/change-me/github" + + result := ValidateManifest(manifest) + + if result.OK { + t.Fatalf("expected invalid manifest with placeholder metadata") + } + var found bool + for _, finding := range result.Findings { + if finding.Code == "manifest.placeholder" { + found = true + } + } + if !found { + t.Fatalf("missing placeholder finding: %+v", result.Findings) + } +}