fix: harden connector manifest validation
This commit is contained in:
@@ -22,7 +22,13 @@ import (
|
|||||||
|
|
||||||
var connectorSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,95}$`)
|
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{
|
return connectorkit.Manifest{
|
||||||
SchemaVersion: "attesto.connector.v2",
|
SchemaVersion: "attesto.connector.v2",
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
@@ -33,9 +39,9 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes
|
|||||||
Summary: fmt.Sprintf("Verify %s evidence into Attesto Proofstream.", name),
|
Summary: fmt.Sprintf("Verify %s evidence into Attesto Proofstream.", name),
|
||||||
Description: fmt.Sprintf(
|
Description: fmt.Sprintf(
|
||||||
"Produces verifiable evidence for %s events through Attesto Proofstream.", name),
|
"Produces verifiable evidence for %s events through Attesto Proofstream.", name),
|
||||||
Publisher: map[string]string{"name": "CHANGE ME", "slug": "change-me"},
|
Publisher: map[string]string{"name": publisherName, "slug": publisherSlug},
|
||||||
Repository: map[string]string{"url": "https://example.com/CHANGE-ME/" + slug},
|
Repository: map[string]string{"url": repositoryURL},
|
||||||
Documentation: map[string]string{"url": "https://docs.attesto.eu/manuals/connectors.html"},
|
Documentation: map[string]string{"url": docsURL},
|
||||||
Capabilities: []string{
|
Capabilities: []string{
|
||||||
"proofstream", "signed-webhook", "offline-verification",
|
"proofstream", "signed-webhook", "offline-verification",
|
||||||
},
|
},
|
||||||
@@ -53,7 +59,7 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes
|
|||||||
Provider: map[string]any{
|
Provider: map[string]any{
|
||||||
"id": slug,
|
"id": slug,
|
||||||
"name": name,
|
"name": name,
|
||||||
"websiteUrl": "https://example.com",
|
"websiteUrl": providerURL,
|
||||||
},
|
},
|
||||||
Auth: map[string]any{
|
Auth: map[string]any{
|
||||||
"mode": "signed-webhook",
|
"mode": "signed-webhook",
|
||||||
@@ -97,12 +103,9 @@ func connectorManifestTemplate(slug, name, category string) connectorkit.Manifes
|
|||||||
"metadata", "validateConfig", "testConnection", "sync",
|
"metadata", "validateConfig", "testConnection", "sync",
|
||||||
"handleWebhook", "emitProofstreamEvent", "diagnostics", "revoke",
|
"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{
|
"canary": map[string]any{
|
||||||
"status": "pending",
|
"status": canaryStatus,
|
||||||
"ref": "CHANGE ME: assurance canary evidence ref",
|
"ref": canaryRef,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
InstallRequirements: map[string]any{
|
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
|
Verification uses the Attesto SDK's P1.4 helper — the same scheme the
|
||||||
platform signs with: HMAC-SHA256 over "{timestamp}.{body}" with a 300s
|
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")
|
raise PermissionError("invalid webhook signature or stale timestamp")
|
||||||
|
|
||||||
# The payload is now authentic: turn it into a proofstream event here.
|
return {"ok": True, "authenticatedPayloadBytes": len(body)}
|
||||||
return {"ok": True}
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const connectorReadmeTemplate = `# %s
|
const connectorReadmeTemplate = `# %s
|
||||||
@@ -143,7 +145,7 @@ const connectorReadmeTemplate = `# %s
|
|||||||
Scaffolded by ` + "`attesto connector init`" + `.
|
Scaffolded by ` + "`attesto connector init`" + `.
|
||||||
|
|
||||||
1. Edit ` + "`attesto.connector.json`" + ` (publisher, repository, provider,
|
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
|
2. Implement the runtime methods (see ` + "`webhook_handler.py`" + ` for the
|
||||||
signed-webhook entry point; verification is already wired).
|
signed-webhook entry point; verification is already wired).
|
||||||
3. Re-run the pre-submission check at any time:
|
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)")
|
name := fs.String("name", "", "human-readable connector name (default: derived from slug)")
|
||||||
category := fs.String("category", "devops", "marketplace category")
|
category := fs.String("category", "devops", "marketplace category")
|
||||||
dir := fs.String("dir", "", "output directory (default: ./<slug>)")
|
dir := fs.String("dir", "", "output directory (default: ./<slug>)")
|
||||||
|
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 <dir>/attesto.connector.json and exit")
|
validateOnly := fs.String("validate-only", "", "validate an existing <dir>/attesto.connector.json and exit")
|
||||||
// Accept the slug positionally before flags: `connector init my-slug --category crm`.
|
// Accept the slug positionally before flags: `connector init my-slug --category crm`.
|
||||||
slug := ""
|
slug := ""
|
||||||
@@ -198,12 +206,31 @@ func (a *app) connectorInit(args []string) error {
|
|||||||
if !connectorSlugPattern.MatchString(slug) {
|
if !connectorSlugPattern.MatchString(slug) {
|
||||||
return fmt.Errorf("slug %q must match %s", slug, connectorSlugPattern)
|
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
|
connectorName := *name
|
||||||
if connectorName == "" {
|
if connectorName == "" {
|
||||||
connectorName = strings.Title(strings.ReplaceAll(slug, "-", " ")) //nolint:staticcheck
|
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)
|
result := connectorkit.ValidateManifest(manifest)
|
||||||
// The only acceptable finding on a fresh scaffold is the pending canary —
|
// The only acceptable finding on a fresh scaffold is the pending canary —
|
||||||
// everything else must already satisfy the marketplace validator.
|
// everything else must already satisfy the marketplace validator.
|
||||||
@@ -242,7 +269,6 @@ func (a *app) connectorInit(args []string) error {
|
|||||||
"created": outDir,
|
"created": outDir,
|
||||||
"files": []string{"attesto.connector.json", "webhook_handler.py", "README.md"},
|
"files": []string{"attesto.connector.json", "webhook_handler.py", "README.md"},
|
||||||
"validation": result,
|
"validation": result,
|
||||||
"nextSteps": "edit CHANGE ME fields, implement runtime methods, earn a green " +
|
"nextSteps": "implement runtime methods, attach real canary evidence, re-run with --validate-only until OK",
|
||||||
"assurance canary, re-run with --validate-only until OK",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,26 @@ import (
|
|||||||
func TestConnectorInitScaffoldsValidManifest(t *testing.T) {
|
func TestConnectorInitScaffoldsValidManifest(t *testing.T) {
|
||||||
dir := filepath.Join(t.TempDir(), "my-crm")
|
dir := filepath.Join(t.TempDir(), "my-crm")
|
||||||
a := &app{out: &bytes.Buffer{}, err: &bytes.Buffer{}}
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
raw, err := os.ReadFile(filepath.Join(dir, "attesto.connector.json"))
|
raw, err := os.ReadFile(filepath.Join(dir, "attesto.connector.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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
|
var manifest connectorkit.Manifest
|
||||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -43,7 +56,26 @@ func TestConnectorInitScaffoldsValidManifest(t *testing.T) {
|
|||||||
t.Fatal("generated handler does not use the P1.4 helper")
|
t.Fatal("generated handler does not use the P1.4 helper")
|
||||||
}
|
}
|
||||||
// re-running must refuse to overwrite
|
// 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")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -198,10 +198,10 @@ func TestMarketplaceInitAndValidate(t *testing.T) {
|
|||||||
"--description", "Produces verifiable Proofstream events for ACME risk decisions.",
|
"--description", "Produces verifiable Proofstream events for ACME risk decisions.",
|
||||||
"--publisher-slug", "acme",
|
"--publisher-slug", "acme",
|
||||||
"--publisher-name", "ACME",
|
"--publisher-name", "ACME",
|
||||||
"--repository-url", "https://git.example.com/acme/risk-connector",
|
"--repository-url", "https://git.rotz.ai/rotzmediagroup/acme-risk-connector",
|
||||||
"--docs-url", "https://docs.example.com/acme/risk-connector",
|
"--docs-url", "https://docs.attesto.eu/manuals/connectors.html#acme-risk-connector",
|
||||||
"--capabilities", "proofstream,offline-verification",
|
"--capabilities", "proofstream,offline-verification",
|
||||||
"--provider-url", "https://example.com/acme-risk",
|
"--provider-url", "https://attesto.eu/connectors/acme-risk",
|
||||||
"--auth-mode", "signed-webhook",
|
"--auth-mode", "signed-webhook",
|
||||||
"--auth-scopes", "repository:read",
|
"--auth-scopes", "repository:read",
|
||||||
"--sync-modes", "webhook",
|
"--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) {
|
func TestMarketplaceSubmitAndPublishCallRealAPIs(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
manifestFile := filepath.Join(dir, "attesto.connector.json")
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
t.Fatalf("submit json: %v", err)
|
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"])
|
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"}}`))
|
_, _ = 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",
|
"--json", "--base-url", server.URL, "--token-env", "ATT_TOKEN",
|
||||||
"marketplace", "submit",
|
"marketplace", "submit",
|
||||||
"--manifest-file", manifestFile,
|
"--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",
|
"--visibility", "public",
|
||||||
"--pricing-model", "free",
|
"--pricing-model", "free",
|
||||||
}, &stdout, &stderr, env)
|
}, &stdout, &stderr, env)
|
||||||
@@ -330,10 +407,10 @@ func writeMarketplaceManifest(t *testing.T, path string) {
|
|||||||
"name": "ACME",
|
"name": "ACME",
|
||||||
},
|
},
|
||||||
"repository": map[string]any{
|
"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{
|
"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"},
|
"capabilities": []string{"proofstream", "offline-verification"},
|
||||||
"evidence": map[string]bool{
|
"evidence": map[string]bool{
|
||||||
@@ -349,7 +426,7 @@ func writeMarketplaceManifest(t *testing.T, path string) {
|
|||||||
"provider": map[string]any{
|
"provider": map[string]any{
|
||||||
"id": "acme-risk-connector",
|
"id": "acme-risk-connector",
|
||||||
"name": "ACME Risk Connector",
|
"name": "ACME Risk Connector",
|
||||||
"websiteUrl": "https://example.com/acme-risk",
|
"websiteUrl": "https://attesto.eu/connectors/acme-risk",
|
||||||
},
|
},
|
||||||
"auth": map[string]any{
|
"auth": map[string]any{
|
||||||
"mode": "signed-webhook",
|
"mode": "signed-webhook",
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package connectorkit
|
package connectorkit
|
||||||
|
|
||||||
import "regexp"
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
type Manifest struct {
|
type Manifest struct {
|
||||||
SchemaVersion string `json:"schemaVersion"`
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
@@ -93,6 +96,7 @@ func ValidateManifest(manifest Manifest) ValidationResult {
|
|||||||
}
|
}
|
||||||
if manifest.AssetType == "connector" && manifest.SchemaVersion == "attesto.connector.v2" {
|
if manifest.AssetType == "connector" && manifest.SchemaVersion == "attesto.connector.v2" {
|
||||||
findings = appendV2Findings(manifest, findings)
|
findings = appendV2Findings(manifest, findings)
|
||||||
|
findings = appendPlaceholderFindings(manifest, findings)
|
||||||
}
|
}
|
||||||
score := 0
|
score := 0
|
||||||
if len(findings) == 0 {
|
if len(findings) == 0 {
|
||||||
@@ -142,6 +146,15 @@ func ValidateManifest(manifest Manifest) ValidationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appendV2Findings(manifest Manifest, findings []Finding) []Finding {
|
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"}) {
|
if !hasRequiredKeys(manifest.Provider, []string{"id", "name", "websiteUrl"}) {
|
||||||
findings = append(findings, Finding{"provider.invalid", "error", "provider.id, provider.name and provider.websiteUrl are required"})
|
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)
|
canary, ok := manifest.Runtime["canary"].(map[string]any)
|
||||||
if !ok || canary["status"] != "green" {
|
if !ok || canary["status"] != "green" {
|
||||||
findings = append(findings, Finding{"runtime.canary", "error", "runtime.canary.status must be green before publication"})
|
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"}) {
|
if !hasRequiredKeys(manifest.InstallRequirements, []string{"tenantLoginRequired", "entitlementRequired"}) {
|
||||||
@@ -213,6 +228,63 @@ func appendV2Findings(manifest Manifest, findings []Finding) []Finding {
|
|||||||
return findings
|
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 {
|
func hasRequiredKeys(value map[string]any, keys []string) bool {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -58,3 +58,57 @@ func TestValidateV2ManifestRequiresRuntimeMetadata(t *testing.T) {
|
|||||||
t.Fatalf("missing runtime finding: %+v", result.Findings)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user