diff --git a/docs/reference/api/openapi.yaml b/docs/reference/api/openapi.yaml index 61cae2da7..13558c8e8 100644 --- a/docs/reference/api/openapi.yaml +++ b/docs/reference/api/openapi.yaml @@ -1015,6 +1015,114 @@ components: commit: "abc123def456" timestamp: "2023-12-01T10:30:00Z" pipelineId: "build-789" + io.modelcontextprotocol.registry/security-scan: + type: array + description: "Optional, scanner-neutral security scan receipts. Each entry is an evidence-scoped receipt, not a server-level safety property: a 'clean' verdict means clean only under the named scanner version, rule set, policy profile, and scanned artifact digest, for the listed scan_scope. Receipts are produced by any scanner; the registry does not endorse any particular one. Clients MUST NOT render 'clean' unless the receipt binds to the current package/artifact digest (scanned_artifact_digest) and the displayed claim names the covered scan_scope, because a dependency or package scan can be clean while handler-side validation remains unassessed." + items: + $ref: '#/components/schemas/SecurityScanReceipt' + + SecurityScanReceipt: + description: "A scanner-neutral, evidence-scoped security scan receipt. Binds a verdict to the exact scanner, rule set, policy profile, and scanned artifact digest that produced it, so downstream tools can reject stale or unjoinable reports without the registry trusting any single scanner. Capability posture (what authority a server declares or exposes) is intentionally out of scope here and may be modeled as a separate sibling extension; a receipt answers only 'what evidence was checked'." + type: object + required: + - scanner + - scanned_artifact_digest + - scan_scope + - verdict + - scanned_at + - attestation + properties: + scanner: + type: string + description: "Identifier of the scanner that produced this receipt. An open string so any community scanner can populate it; the registry does not endorse any particular scanner." + example: "example-scanner" + scanner_version: + type: string + description: "Version of the scanner that produced this receipt." + example: "1.2.3" + rule_set_ref: + type: string + description: "Reference to the rule set used, ideally digest-pinned so the receipt is reproducible. An open string; any ruleset reference is valid." + example: "example-ruleset@sha256:..." + policy_profile: + type: string + description: "Identifier of the policy profile applied during the scan (the thresholds and gating decisions under which the verdict was computed)." + example: "default-mcp-registry-v1" + scanned_artifact_ref: + type: string + description: "Human-meaningful reference to the artifact that was scanned, such as a package URL or image reference." + example: "pkg:npm/example/server@1.0.0" + scanned_artifact_digest: + type: string + description: "Digest of the exact bytes that were scanned, as 'algorithm:hex'. This binds the verdict to a specific artifact: clients MUST compare it against the current package/artifact digest before trusting a 'clean' verdict, and treat a mismatch as inconclusive." + pattern: "^[a-z0-9]+:[a-f0-9]+$" + example: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + scan_scope: + type: array + description: "What was actually evaluated, not what the server is capable of doing. Listing the covered surfaces lets clients see, machine-readably, whether handler-side validation was assessed. A receipt covering only 'dependency' and 'package' leaves handler-side authority unassessed even when the verdict is 'clean'. Values are open strings; well-known values include 'dependency', 'package', and 'handler-validation'." + minItems: 1 + items: + type: string + example: + - "dependency" + - "package" + - "handler-validation" + verdict: + type: string + description: "Outcome of the scan under the named scanner, rule set, policy profile, artifact digest, and scope. 'inconclusive' is first-class: a report can fail to bind to the current artifact, use an unsupported package type, or omit handler-side checks, and those MUST NOT collapse into 'clean' or 'findings'." + enum: + - "clean" + - "warnings" + - "findings" + - "inconclusive" + example: "clean" + inconclusive_reason: + type: string + description: "Machine-readable reason a verdict is 'inconclusive', so clients can act without parsing scanner-specific prose. Required when verdict is 'inconclusive'." + enum: + - "artifact_digest_mismatch" + - "unsupported_package_type" + - "scope_excludes_handler_validation" + - "evidence_unavailable" + - "stale_scan" + example: "scope_excludes_handler_validation" + scanned_at: + type: string + format: date-time + description: "Timestamp when the scan was performed." + example: "2026-06-28T00:00:00Z" + freshness_expires_at: + type: string + format: date-time + description: "Timestamp after which the receipt should be treated as stale. Clients SHOULD reject or down-rank receipts past this time; a client that surfaces a stale receipt SHOULD treat it as inconclusive with reason 'stale_scan'." + example: "2026-07-28T00:00:00Z" + evidence_ref: + type: string + format: uri + description: "URL to the full scan report or evidence backing this receipt." + example: "https://example.org/report.json" + evidence_digest: + type: string + description: "Digest of the evidence at evidence_ref, as 'algorithm:hex', giving clients a verifiability hook without requiring a signature." + pattern: "^[a-z0-9]+:[a-f0-9]+$" + example: "sha256:7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069" + attestation: + type: string + description: "Who asserts the receipt. 'publisher-asserted' is self-asserted by the publisher (for example, carried under the publisher-provided extension); 'registry-attested' and 'third-party-attested' are asserted by the registry or an independent party. Signatures are intentionally left out of this version; attestation plus evidence_digest carry verifiability first." + enum: + - "publisher-asserted" + - "registry-attested" + - "third-party-attested" + example: "publisher-asserted" + additionalProperties: true + allOf: + - if: + properties: + verdict: + const: "inconclusive" + then: + required: + - inconclusive_reason ServerResponse: description: API response format with separated server data and registry metadata diff --git a/docs/reference/server-json/CHANGELOG.md b/docs/reference/server-json/CHANGELOG.md index c55ef4179..120377766 100644 --- a/docs/reference/server-json/CHANGELOG.md +++ b/docs/reference/server-json/CHANGELOG.md @@ -6,6 +6,14 @@ Changes to the server.json schema and format. This section tracks changes that are in development and not yet released. The draft schema is available at [`server.schema.json`](./draft/server.schema.json) in this repository. +### Added + +#### Optional `io.modelcontextprotocol.registry/security-scan` Extension Metadata + +A new optional `_meta` extension, `io.modelcontextprotocol.registry/security-scan`, defines an array of scanner-neutral security scan receipts. Each receipt is evidence-scoped: it binds a `verdict` (`clean`, `warnings`, `findings`, or `inconclusive`) to a specific `scanner`, `rule_set_ref`, `policy_profile`, and `scanned_artifact_digest`, with an explicit `scan_scope` recording what was evaluated. `inconclusive` is first-class and pairs with a machine-readable `inconclusive_reason`. The `attestation` field distinguishes `publisher-asserted`, `registry-attested`, and `third-party-attested` receipts. Signatures are intentionally out of scope for this version. + +This field is additive and optional; existing `server.json` documents remain valid. See the [format specification](./generic-server-json.md#security-scan-receipts-iomodelcontextprotocolregistrysecurity-scan) for details. + ### Changed #### Transport URL Pattern Now Accepts Template Variables diff --git a/docs/reference/server-json/draft/server.schema.json b/docs/reference/server-json/draft/server.schema.json index e3aeab6a6..b1643041e 100644 --- a/docs/reference/server-json/draft/server.schema.json +++ b/docs/reference/server-json/draft/server.schema.json @@ -402,6 +402,138 @@ ], "type": "object" }, + "SecurityScanReceipt": { + "additionalProperties": true, + "allOf": [ + { + "if": { + "properties": { + "verdict": { + "const": "inconclusive" + } + } + }, + "then": { + "required": [ + "inconclusive_reason" + ] + } + } + ], + "description": "A scanner-neutral, evidence-scoped security scan receipt. Binds a verdict to the exact scanner, rule set, policy profile, and scanned artifact digest that produced it, so downstream tools can reject stale or unjoinable reports without the registry trusting any single scanner. Capability posture (what authority a server declares or exposes) is intentionally out of scope here and may be modeled as a separate sibling extension; a receipt answers only 'what evidence was checked'.", + "properties": { + "attestation": { + "description": "Who asserts the receipt. 'publisher-asserted' is self-asserted by the publisher (for example, carried under the publisher-provided extension); 'registry-attested' and 'third-party-attested' are asserted by the registry or an independent party. Signatures are intentionally left out of this version; attestation plus evidence_digest carry verifiability first.", + "enum": [ + "publisher-asserted", + "registry-attested", + "third-party-attested" + ], + "example": "publisher-asserted", + "type": "string" + }, + "evidence_digest": { + "description": "Digest of the evidence at evidence_ref, as 'algorithm:hex', giving clients a verifiability hook without requiring a signature.", + "example": "sha256:7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069", + "pattern": "^[a-z0-9]+:[a-f0-9]+$", + "type": "string" + }, + "evidence_ref": { + "description": "URL to the full scan report or evidence backing this receipt.", + "example": "https://example.org/report.json", + "format": "uri", + "type": "string" + }, + "freshness_expires_at": { + "description": "Timestamp after which the receipt should be treated as stale. Clients SHOULD reject or down-rank receipts past this time; a client that surfaces a stale receipt SHOULD treat it as inconclusive with reason 'stale_scan'.", + "example": "2026-07-28T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "inconclusive_reason": { + "description": "Machine-readable reason a verdict is 'inconclusive', so clients can act without parsing scanner-specific prose. Required when verdict is 'inconclusive'.", + "enum": [ + "artifact_digest_mismatch", + "unsupported_package_type", + "scope_excludes_handler_validation", + "evidence_unavailable", + "stale_scan" + ], + "example": "scope_excludes_handler_validation", + "type": "string" + }, + "policy_profile": { + "description": "Identifier of the policy profile applied during the scan (the thresholds and gating decisions under which the verdict was computed).", + "example": "default-mcp-registry-v1", + "type": "string" + }, + "rule_set_ref": { + "description": "Reference to the rule set used, ideally digest-pinned so the receipt is reproducible. An open string; any ruleset reference is valid.", + "example": "example-ruleset@sha256:...", + "type": "string" + }, + "scan_scope": { + "description": "What was actually evaluated, not what the server is capable of doing. Listing the covered surfaces lets clients see, machine-readably, whether handler-side validation was assessed. A receipt covering only 'dependency' and 'package' leaves handler-side authority unassessed even when the verdict is 'clean'. Values are open strings; well-known values include 'dependency', 'package', and 'handler-validation'.", + "example": [ + "dependency", + "package", + "handler-validation" + ], + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "scanned_artifact_digest": { + "description": "Digest of the exact bytes that were scanned, as 'algorithm:hex'. This binds the verdict to a specific artifact: clients MUST compare it against the current package/artifact digest before trusting a 'clean' verdict, and treat a mismatch as inconclusive.", + "example": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "pattern": "^[a-z0-9]+:[a-f0-9]+$", + "type": "string" + }, + "scanned_artifact_ref": { + "description": "Human-meaningful reference to the artifact that was scanned, such as a package URL or image reference.", + "example": "pkg:npm/example/server@1.0.0", + "type": "string" + }, + "scanned_at": { + "description": "Timestamp when the scan was performed.", + "example": "2026-06-28T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "scanner": { + "description": "Identifier of the scanner that produced this receipt. An open string so any community scanner can populate it; the registry does not endorse any particular scanner.", + "example": "example-scanner", + "type": "string" + }, + "scanner_version": { + "description": "Version of the scanner that produced this receipt.", + "example": "1.2.3", + "type": "string" + }, + "verdict": { + "description": "Outcome of the scan under the named scanner, rule set, policy profile, artifact digest, and scope. 'inconclusive' is first-class: a report can fail to bind to the current artifact, use an unsupported package type, or omit handler-side checks, and those MUST NOT collapse into 'clean' or 'findings'.", + "enum": [ + "clean", + "warnings", + "findings", + "inconclusive" + ], + "example": "clean", + "type": "string" + } + }, + "required": [ + "scanner", + "scanned_artifact_digest", + "scan_scope", + "verdict", + "scanned_at", + "attestation" + ], + "type": "object" + }, "ServerDetail": { "description": "Schema for a static representation of an MCP server. Used in various contexts related to discovery, installation, and configuration.", "properties": { @@ -427,6 +559,13 @@ "version": "1.2.3" }, "type": "object" + }, + "io.modelcontextprotocol.registry/security-scan": { + "description": "Optional, scanner-neutral security scan receipts. Each entry is an evidence-scoped receipt, not a server-level safety property: a 'clean' verdict means clean only under the named scanner version, rule set, policy profile, and scanned artifact digest, for the listed scan_scope. Receipts are produced by any scanner; the registry does not endorse any particular one. Clients MUST NOT render 'clean' unless the receipt binds to the current package/artifact digest (scanned_artifact_digest) and the displayed claim names the covered scan_scope, because a dependency or package scan can be clean while handler-side validation remains unassessed.", + "items": { + "$ref": "#/definitions/SecurityScanReceipt" + }, + "type": "array" } }, "type": "object" diff --git a/docs/reference/server-json/generic-server-json.md b/docs/reference/server-json/generic-server-json.md index 7415988ae..4be3d494c 100644 --- a/docs/reference/server-json/generic-server-json.md +++ b/docs/reference/server-json/generic-server-json.md @@ -30,6 +30,58 @@ The optional `_meta` field allows publishers to include custom metadata alongsid When publishing to the official registry, custom metadata must be placed under the key `io.modelcontextprotocol.registry/publisher-provided`. See the [official registry requirements](./official-registry-requirements.md) for detailed restrictions and examples. +### Security scan receipts (`io.modelcontextprotocol.registry/security-scan`) + +The optional `io.modelcontextprotocol.registry/security-scan` extension carries an array of scanner-neutral security scan receipts. Each receipt is evidence-scoped: it records a verdict bound to a specific scanner, rule set, policy profile, and scanned artifact digest, rather than asserting a server-level safety property. The `scanner` and `rule_set_ref` fields are open strings, so any community scanner can produce receipts; the registry does not endorse any particular one. + +```jsonc +{ + "_meta": { + "io.modelcontextprotocol.registry/security-scan": [ + { + "scanner": "example-scanner", + "scanner_version": "1.2.3", + "rule_set_ref": "example-ruleset@sha256:...", + "policy_profile": "default-mcp-registry-v1", + "scanned_artifact_ref": "pkg:npm/example/server@1.0.0", + "scanned_artifact_digest": "sha256:...", + "scan_scope": ["dependency", "package"], + "verdict": "clean", + "scanned_at": "2026-06-28T00:00:00Z", + "freshness_expires_at": "2026-07-28T00:00:00Z", + "evidence_ref": "https://example.org/report.json", + "evidence_digest": "sha256:...", + "attestation": "publisher-asserted" + } + ] + } +} +``` + +A `clean` verdict means clean only under the named `scanner_version`, `rule_set_ref`, `policy_profile`, and `scanned_artifact_digest`, for the surfaces listed in `scan_scope`. It does not mean the server is globally safe. + +The `scanned_artifact_digest` binds the verdict to exact bytes, and `scan_scope` records what was actually evaluated (for example `dependency`, `package`, or `handler-validation`). These two fields encode an invariant for clients: + +- Do not render `clean` unless the receipt binds to the current package or artifact digest, and the displayed claim names the covered `scan_scope`. +- A dependency or package scan can be `clean` while handler-side validation remains unassessed, so a receipt that did not cover that surface should keep the reason machine-readable (for example `verdict: "inconclusive"` with `inconclusive_reason: "scope_excludes_handler_validation"`) rather than implying a global clean badge. + +`verdict` is one of `clean`, `warnings`, `findings`, or `inconclusive`. `inconclusive` is first-class: it covers reports that cannot bind to the current artifact, use an unsupported package type, or omit handler-side checks, and these should not collapse into `clean` or `findings`. When `verdict` is `inconclusive`, `inconclusive_reason` carries one of `artifact_digest_mismatch`, `unsupported_package_type`, `scope_excludes_handler_validation`, `evidence_unavailable`, or `stale_scan`. + +`attestation` distinguishes who asserts the receipt: `publisher-asserted` (self-asserted by the publisher), `registry-attested`, or `third-party-attested`. Signatures are intentionally left out of this version; `attestation` together with `evidence_digest` provides a verifiability hook without pulling in key distribution. + +This extension describes only what evidence was checked. A server's declared capability posture (what authority it exposes) is a separate concern and is not part of a scan receipt. + +The example above shows the receipt shape at the format level, which any registry or client can read. The official registry currently preserves only the `io.modelcontextprotocol.registry/publisher-provided` key, so a publisher-asserted receipt is carried nested under that key when publishing there; see [official registry requirements](./official-registry-requirements.md#security-scan-receipts). + +#### Render invariant + +A client MUST NOT surface a `clean` claim for a receipt unless both of the following hold, and a displayed claim MUST name the covered `scan_scope` so the user sees what was actually evaluated: + +- The receipt binds to the current artifact: the receipt's `scanned_artifact_digest` equals the digest of the package or artifact the client is about to install or display. A receipt whose `scanned_artifact_digest` is missing, malformed (not `algorithm:hex`), or does not match the current artifact does not bind, and the client MUST treat it as `inconclusive` (reason `artifact_digest_mismatch`) rather than rendering `clean`. +- The receipt names a non-empty `scan_scope`. A receipt with an absent or empty `scan_scope` does not say what was evaluated and MUST NOT be rendered as `clean`. + +Because `scan_scope` records only the surfaces that were actually checked, a `clean` receipt that omits a surface (for example a `["dependency", "package"]` receipt that never assessed `handler-validation`) MUST NOT be presented as a global clean badge; the displayed claim names the covered scope, and the unassessed surface stays representable as `inconclusive` with `inconclusive_reason: "scope_excludes_handler_validation"` rather than collapsing into `clean`. + ## Examples diff --git a/docs/reference/server-json/official-registry-requirements.md b/docs/reference/server-json/official-registry-requirements.md index 886aee0d3..fd0b083bc 100644 --- a/docs/reference/server-json/official-registry-requirements.md +++ b/docs/reference/server-json/official-registry-requirements.md @@ -92,6 +92,36 @@ When your `publisher-provided` metadata gets large or comes from multiple source This is **not enforced**; flat keys work fine for simple cases, and existing examples in this repo use the flat form. Use the namespaced form when organizing metadata from more than one source. +### Security Scan Receipts + +The `io.modelcontextprotocol.registry/security-scan` extension defines a scanner-neutral, evidence-scoped receipt shape (see the [format specification](./generic-server-json.md#security-scan-receipts-iomodelcontextprotocolregistrysecurity-scan)). It is defined at the format level so any registry or client can read and verify receipts. + +Today the official registry preserves only the `io.modelcontextprotocol.registry/publisher-provided` key, so a **publisher-asserted** scan receipt (`"attestation": "publisher-asserted"`) is carried by nesting it under `publisher-provided`, where it counts against the 4KB limit like any other publisher metadata. Receipts with `"attestation": "registry-attested"` or `"third-party-attested"` are not yet produced or stored by the official registry; those values are reserved for when registry- or third-party-attested receipts are supported. + +When publishing to the official registry, nest the receipt array under `publisher-provided`: + +```jsonc +{ + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "io.modelcontextprotocol.registry/security-scan": [ + { + "scanner": "example-scanner", + "scanner_version": "1.2.3", + "scanned_artifact_digest": "sha256:...", + "scan_scope": ["dependency", "package"], + "verdict": "clean", + "scanned_at": "2026-06-28T00:00:00Z", + "attestation": "publisher-asserted" + } + ] + } + } +} +``` + +Regardless of where a receipt is stored, the client invariant holds: do not render `clean` unless the receipt's `scanned_artifact_digest` binds to the current package or artifact and the displayed claim names the covered `scan_scope`. + ### Registry API Metadata vs server.json Metadata The `_meta` field in `server.json` is **different** from the `_meta` field returned in registry API responses: diff --git a/internal/validators/security_scan_test.go b/internal/validators/security_scan_test.go new file mode 100644 index 000000000..1fd32b591 --- /dev/null +++ b/internal/validators/security_scan_test.go @@ -0,0 +1,149 @@ +package validators_test + +import ( + "encoding/json" + "path/filepath" + "testing" + + jsonschema "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// securityScanSchemaPath points at the canonical, CI-synced draft schema that the +// io.modelcontextprotocol.registry/security-scan extension is defined in. The same +// file is compiled by tools/validate-examples; keeping the path relative to the +// validators package keeps this test runnable via `go test ./internal/validators/...`. +const securityScanSchemaPath = "../../docs/reference/server-json/draft/server.schema.json" + +// compileServerSchema compiles the draft server.schema.json the way the registry's +// validate-examples tool does, so the test exercises the published schema rather +// than a hand-rolled subset. +func compileServerSchema(t *testing.T) *jsonschema.Schema { + t.Helper() + compiler := jsonschema.NewCompiler() + compiler.Draft = jsonschema.Draft7 + schema, err := compiler.Compile(filepath.Clean(securityScanSchemaPath)) + require.NoError(t, err, "draft server.schema.json should compile") + return schema +} + +// serverJSONWithScanReceipt wraps a single security-scan receipt in an otherwise +// valid server.json document under the _meta extension key, so the receipt is +// validated exactly as a downstream client would encounter it. +func serverJSONWithScanReceipt(receipt map[string]any) map[string]any { + return map[string]any{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "com.example/test-server", + "description": "A test server", + "version": "1.0.0", + "_meta": map[string]any{ + "io.modelcontextprotocol.registry/security-scan": []any{receipt}, + }, + } +} + +// validScanReceipt returns a clean receipt that binds to a well-formed artifact +// digest and a non-empty scan scope. Cases below mutate a copy of this base. +func validScanReceipt() map[string]any { + return map[string]any{ + "scanner": "example-scanner", + "scanner_version": "1.2.3", + "scanned_artifact_digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "scan_scope": []any{"dependency", "package"}, + "verdict": "clean", + "scanned_at": "2026-06-28T00:00:00Z", + "attestation": "publisher-asserted", + } +} + +// TestSecurityScanReceiptSchema is the downstream-client test for the +// io.modelcontextprotocol.registry/security-scan extension added in #1404. +// It confirms the draft schema accepts a clean receipt that binds to a +// matching artifact digest with a non-empty scan_scope, and rejects receipts +// that would let a "clean" claim be surfaced without that binding: a +// mismatched/malformed scanned_artifact_digest, an empty scan_scope, and an +// inconclusive verdict missing its machine-readable inconclusive_reason. +func TestSecurityScanReceiptSchema(t *testing.T) { + schema := compileServerSchema(t) + + tests := []struct { + name string + mutate func(r map[string]any) + expectValid bool + description string + }{ + { + name: "clean receipt with matching digest and non-empty scope is accepted", + mutate: func(_ map[string]any) {}, + expectValid: true, + description: "A clean verdict that binds to a well-formed artifact digest and lists a scan_scope is the accepted shape", + }, + { + name: "mismatched/malformed scanned_artifact_digest is rejected", + mutate: func(r map[string]any) { + // Not in algorithm:hex form, so it cannot bind a verdict to exact bytes. + r["scanned_artifact_digest"] = "not-a-digest" + }, + expectValid: false, + description: "A scanned_artifact_digest that violates the algorithm:hex pattern must be rejected so clean never binds to unjoinable bytes", + }, + { + name: "missing scanned_artifact_digest is rejected", + mutate: func(r map[string]any) { + delete(r, "scanned_artifact_digest") + }, + expectValid: false, + description: "scanned_artifact_digest is required so a clean verdict always binds to a specific artifact", + }, + { + name: "empty scan_scope is rejected", + mutate: func(r map[string]any) { + r["scan_scope"] = []any{} + }, + expectValid: false, + description: "An empty scan_scope does not say what was evaluated, so it must be rejected (minItems 1)", + }, + { + name: "inconclusive verdict without inconclusive_reason is rejected", + mutate: func(r map[string]any) { + r["verdict"] = "inconclusive" + // inconclusive_reason intentionally omitted + }, + expectValid: false, + description: "An inconclusive verdict must carry a machine-readable inconclusive_reason so it never collapses into clean", + }, + { + name: "inconclusive verdict with inconclusive_reason is accepted", + mutate: func(r map[string]any) { + r["verdict"] = "inconclusive" + r["inconclusive_reason"] = "artifact_digest_mismatch" + }, + expectValid: true, + description: "Once an inconclusive verdict names its reason it is representable rather than collapsing into clean or findings", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + receipt := validScanReceipt() + tt.mutate(receipt) + + doc := serverJSONWithScanReceipt(receipt) + + // Round-trip through JSON to normalize types the way a real client + // reading a server.json document would. + raw, err := json.Marshal(doc) + require.NoError(t, err) + var instance any + require.NoError(t, json.Unmarshal(raw, &instance)) + + err = schema.Validate(instance) + if tt.expectValid { + assert.NoError(t, err, "%s", tt.description) + } else { + assert.Error(t, err, "%s", tt.description) + } + }) + } +}