From 1a6a0a85a5dfe9ac6db4536747d442397a0d1e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Wed, 26 Aug 2026 17:51:49 -0300 Subject: [PATCH 1/4] feat(materials): add CHAINLOOP_AI_SECURITY_CONTEXT material type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a first-party material type for the security context artifact: recurring vulnerability fingerprints mined from a repository's fix history, the attack surfaces they share, ranked risks, and byte-verifiable evidence anchors backing each claim. The payload is validated against an embedded, versioned JSON schema. The Chainloop evidence envelope is asserted in the crafter, matching the other first-party material types whose schemas are payload-rooted. The kind is excluded from auto-detection: the artifact is generic JSON inside an envelope and would shadow other JSON kinds, so it must be referenced with an explicit --kind CHAINLOOP_AI_SECURITY_CONTEXT or from a workflow contract. The crafter surfaces the scanned head SHA, the producing tool version, the fingerprint count, and whether the scan funnel reconciles as material annotations, so a policy can gate on them without downloading the payload. Signed-off-by: Matías Insaurralde Chainloop-Trace-Sessions: df945bde-e982-472c-bb81-50055df403bb --- app/cli/documentation/cli-reference.mdx | 4 +- .../workflowcontract/v1/crafting_schema.ts | 11 + ...on.v1.Attestation.Material.jsonschema.json | 6 +- ...tation.v1.Attestation.Material.schema.json | 6 +- ...tation.v1.PolicyEvaluation.jsonschema.json | 3 +- ...ttestation.v1.PolicyEvaluation.schema.json | 3 +- ...v1.CraftingSchema.Material.jsonschema.json | 3 +- ...act.v1.CraftingSchema.Material.schema.json | 3 +- ...ct.v1.PolicyGroup.Material.jsonschema.json | 3 +- ...ntract.v1.PolicyGroup.Material.schema.json | 3 +- ...flowcontract.v1.PolicySpec.jsonschema.json | 3 +- ...workflowcontract.v1.PolicySpec.schema.json | 3 +- ...owcontract.v1.PolicySpecV2.jsonschema.json | 3 +- ...rkflowcontract.v1.PolicySpecV2.schema.json | 3 +- .../workflowcontract/v1/crafting_schema.pb.go | 15 +- .../workflowcontract/v1/crafting_schema.proto | 4 + .../v1/crafting_schema_test.go | 4 + .../v1/crafting_schema_validations.go | 4 + .../ai-security-context-0.1.schema.json | 910 ++++++++ internal/schemavalidators/schemavalidators.go | 93 +- .../schemavalidators/schemavalidators_test.go | 41 + .../ai_security_context_missing_required.json | 184 ++ .../testdata/ai_security_context_valid.json | 191 ++ .../aisecuritycontext/aisecuritycontext.go | 280 +++ .../chainloop_ai_security_context.go | 151 ++ .../chainloop_ai_security_context_test.go | 317 +++ .../crafter/materials/materials.go | 2 + .../testdata/ai-security-context-bare.json | 191 ++ .../ai-security-context-extra-field.json | 196 ++ .../testdata/ai-security-context-minimal.json | 195 ++ .../testdata/ai-security-context.json | 1933 +++++++++++++++++ 31 files changed, 4730 insertions(+), 38 deletions(-) create mode 100644 internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json create mode 100644 internal/schemavalidators/testdata/ai_security_context_missing_required.json create mode 100644 internal/schemavalidators/testdata/ai_security_context_valid.json create mode 100644 pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go create mode 100644 pkg/attestation/crafter/materials/chainloop_ai_security_context.go create mode 100644 pkg/attestation/crafter/materials/chainloop_ai_security_context_test.go create mode 100644 pkg/attestation/crafter/materials/testdata/ai-security-context-bare.json create mode 100644 pkg/attestation/crafter/materials/testdata/ai-security-context-extra-field.json create mode 100644 pkg/attestation/crafter/materials/testdata/ai-security-context-minimal.json create mode 100644 pkg/attestation/crafter/materials/testdata/ai-security-context.json diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 20773a9c5..728849efc 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -268,7 +268,7 @@ Options --append reserved for a future release: will control whether --policy-input and --policy-input-from-file append to (rather than replace) the contract-declared value; has no effect yet --attestation-id string Unique identifier of the in-progress attestation -h, --help help for add ---kind string kind of the material to be recorded: ["ARTIFACT" "ASYNCAPI_SPEC" "ATTESTATION" "BLACKDUCK_SCA_JSON" "CERTCC_DRANZER" "CHAINLOOP_AI_AGENT_CONFIG" "CHAINLOOP_AI_CODING_SESSION" "CHAINLOOP_PR_INFO" "CHAINLOOP_RUNNER_CONTEXT" "CHECKMARX_JSON" "COBERTURA_XML" "CONTAINER_IMAGE" "CSAF_INFORMATIONAL_ADVISORY" "CSAF_SECURITY_ADVISORY" "CSAF_SECURITY_INCIDENT_RESPONSE" "CSAF_VEX" "EVIDENCE" "GHAS_CODE_SCAN" "GHAS_DEPENDENCY_SCAN" "GHAS_SECRET_SCAN" "GITLAB_SECURITY_REPORT" "GITLEAKS_JSON" "GRAPHQL_SPEC" "HELM_CHART" "JACOCO_XML" "JUNIT_XML" "OPENAPI_SPEC" "OPENVEX" "OSSF_SCORECARD_JSON" "OVERSECURED_JSON" "PITEST_XML" "RADAMSA_CRASHES" "RADAMSA_REPORT" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "SYSINTERNALS_ACCESSCHK" "SYSINTERNALS_SIGCHECK" "TRUFFLEHOG_JSON" "TWISTCLI_SCAN_JSON" "YELP_DETECT_SECRETS_BASELINE" "ZAP_DAST_ZIP"] +--kind string kind of the material to be recorded: ["ARTIFACT" "ASYNCAPI_SPEC" "ATTESTATION" "BLACKDUCK_SCA_JSON" "CERTCC_DRANZER" "CHAINLOOP_AI_AGENT_CONFIG" "CHAINLOOP_AI_CODING_SESSION" "CHAINLOOP_AI_SECURITY_CONTEXT" "CHAINLOOP_PR_INFO" "CHAINLOOP_RUNNER_CONTEXT" "CHECKMARX_JSON" "COBERTURA_XML" "CONTAINER_IMAGE" "CSAF_INFORMATIONAL_ADVISORY" "CSAF_SECURITY_ADVISORY" "CSAF_SECURITY_INCIDENT_RESPONSE" "CSAF_VEX" "EVIDENCE" "GHAS_CODE_SCAN" "GHAS_DEPENDENCY_SCAN" "GHAS_SECRET_SCAN" "GITLAB_SECURITY_REPORT" "GITLEAKS_JSON" "GRAPHQL_SPEC" "HELM_CHART" "JACOCO_XML" "JUNIT_XML" "OPENAPI_SPEC" "OPENVEX" "OSSF_SCORECARD_JSON" "OVERSECURED_JSON" "PITEST_XML" "RADAMSA_CRASHES" "RADAMSA_REPORT" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "SYSINTERNALS_ACCESSCHK" "SYSINTERNALS_SIGCHECK" "TRUFFLEHOG_JSON" "TWISTCLI_SCAN_JSON" "YELP_DETECT_SECRETS_BASELINE" "ZAP_DAST_ZIP"] --max-extract-entries int max number of files to extract when --value is an archive (default 10000) --max-extract-size string max total uncompressed size to extract when --value is an archive (default "1GiB") --name string name of the material as shown in the contract @@ -3046,7 +3046,7 @@ Options --annotation strings Key-value pairs of material annotations (key=value) -h, --help help for eval --input stringArray Key-value pairs of policy inputs (key=value) ---kind string Kind of the material: ["ARTIFACT" "ASYNCAPI_SPEC" "ATTESTATION" "BLACKDUCK_SCA_JSON" "CERTCC_DRANZER" "CHAINLOOP_AI_AGENT_CONFIG" "CHAINLOOP_AI_CODING_SESSION" "CHAINLOOP_PR_INFO" "CHAINLOOP_RUNNER_CONTEXT" "CHECKMARX_JSON" "COBERTURA_XML" "CONTAINER_IMAGE" "CSAF_INFORMATIONAL_ADVISORY" "CSAF_SECURITY_ADVISORY" "CSAF_SECURITY_INCIDENT_RESPONSE" "CSAF_VEX" "EVIDENCE" "GHAS_CODE_SCAN" "GHAS_DEPENDENCY_SCAN" "GHAS_SECRET_SCAN" "GITLAB_SECURITY_REPORT" "GITLEAKS_JSON" "GRAPHQL_SPEC" "HELM_CHART" "JACOCO_XML" "JUNIT_XML" "OPENAPI_SPEC" "OPENVEX" "OSSF_SCORECARD_JSON" "OVERSECURED_JSON" "PITEST_XML" "RADAMSA_CRASHES" "RADAMSA_REPORT" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "SYSINTERNALS_ACCESSCHK" "SYSINTERNALS_SIGCHECK" "TRUFFLEHOG_JSON" "TWISTCLI_SCAN_JSON" "YELP_DETECT_SECRETS_BASELINE" "ZAP_DAST_ZIP"] +--kind string Kind of the material: ["ARTIFACT" "ASYNCAPI_SPEC" "ATTESTATION" "BLACKDUCK_SCA_JSON" "CERTCC_DRANZER" "CHAINLOOP_AI_AGENT_CONFIG" "CHAINLOOP_AI_CODING_SESSION" "CHAINLOOP_AI_SECURITY_CONTEXT" "CHAINLOOP_PR_INFO" "CHAINLOOP_RUNNER_CONTEXT" "CHECKMARX_JSON" "COBERTURA_XML" "CONTAINER_IMAGE" "CSAF_INFORMATIONAL_ADVISORY" "CSAF_SECURITY_ADVISORY" "CSAF_SECURITY_INCIDENT_RESPONSE" "CSAF_VEX" "EVIDENCE" "GHAS_CODE_SCAN" "GHAS_DEPENDENCY_SCAN" "GHAS_SECRET_SCAN" "GITLAB_SECURITY_REPORT" "GITLEAKS_JSON" "GRAPHQL_SPEC" "HELM_CHART" "JACOCO_XML" "JUNIT_XML" "OPENAPI_SPEC" "OPENVEX" "OSSF_SCORECARD_JSON" "OVERSECURED_JSON" "PITEST_XML" "RADAMSA_CRASHES" "RADAMSA_REPORT" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "SYSINTERNALS_ACCESSCHK" "SYSINTERNALS_SIGCHECK" "TRUFFLEHOG_JSON" "TWISTCLI_SCAN_JSON" "YELP_DETECT_SECRETS_BASELINE" "ZAP_DAST_ZIP"] --material string Path to material or attestation file -p, --policy string Policy reference (./my-policy.yaml, https://my-domain.com/my-policy.yaml, chainloop://my-stored-policy) (default "policy.yaml") --project string Project name to use as engine context for chainloop.* built-ins diff --git a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts index 5e039bc29..337dc2c52 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -336,6 +336,12 @@ export enum CraftingSchema_Material_MaterialType { * https://pitest.org/ */ PITEST_XML = 44, + /** + * CHAINLOOP_AI_SECURITY_CONTEXT - Chainloop AI security context: vulnerability fingerprints mined from a + * repository's fix history, the attack surfaces they share, ranked risks, + * and byte-verifiable evidence anchors backing each claim + */ + CHAINLOOP_AI_SECURITY_CONTEXT = 45, UNRECOGNIZED = -1, } @@ -476,6 +482,9 @@ export function craftingSchema_Material_MaterialTypeFromJSON(object: any): Craft case 44: case "PITEST_XML": return CraftingSchema_Material_MaterialType.PITEST_XML; + case 45: + case "CHAINLOOP_AI_SECURITY_CONTEXT": + return CraftingSchema_Material_MaterialType.CHAINLOOP_AI_SECURITY_CONTEXT; case -1: case "UNRECOGNIZED": default: @@ -575,6 +584,8 @@ export function craftingSchema_Material_MaterialTypeToJSON(object: CraftingSchem return "OVERSECURED_JSON"; case CraftingSchema_Material_MaterialType.PITEST_XML: return "PITEST_XML"; + case CraftingSchema_Material_MaterialType.CHAINLOOP_AI_SECURITY_CONTEXT: + return "CHAINLOOP_AI_SECURITY_CONTEXT"; case CraftingSchema_Material_MaterialType.UNRECOGNIZED: default: return "UNRECOGNIZED"; diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.jsonschema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.jsonschema.json index 7d342996d..1e9131d91 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.jsonschema.json @@ -61,7 +61,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" @@ -158,7 +159,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.schema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.schema.json index 354dba9fc..3661c9bf4 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.schema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.Attestation.Material.schema.json @@ -61,7 +61,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" @@ -158,7 +159,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json index 743aa050e..78a2c9514 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json @@ -172,7 +172,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json index b188852c0..ed6228d36 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json @@ -172,7 +172,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.jsonschema.json index b203b9530..3203a4ea4 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.jsonschema.json @@ -82,7 +82,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.schema.json index 246b7302f..0c0d8190c 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.CraftingSchema.Material.schema.json @@ -82,7 +82,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json index 0f5d1e79c..aaac50622 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.jsonschema.json @@ -106,7 +106,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json index 781a410fd..3ead8e336 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicyGroup.Material.schema.json @@ -106,7 +106,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.jsonschema.json index 5b97df6db..a96f72d35 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.jsonschema.json @@ -80,7 +80,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.schema.json index f3f357631..cb2fa59ff 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.schema.json @@ -80,7 +80,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json index f729155ed..a14cea087 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json @@ -101,7 +101,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json index cbfbbdb9b..15dbc37f3 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json @@ -101,7 +101,8 @@ "COBERTURA_XML", "CHECKMARX_JSON", "OVERSECURED_JSON", - "PITEST_XML" + "PITEST_XML", + "CHAINLOOP_AI_SECURITY_CONTEXT" ], "title": "Material Type", "type": "string" diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go index e097271e7..5b2c4c0ac 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -256,6 +256,10 @@ const ( // PIT mutation testing report in its native XML format (mutations.xml) // https://pitest.org/ CraftingSchema_Material_PITEST_XML CraftingSchema_Material_MaterialType = 44 + // Chainloop AI security context: vulnerability fingerprints mined from a + // repository's fix history, the attack surfaces they share, ranked risks, + // and byte-verifiable evidence anchors backing each claim + CraftingSchema_Material_CHAINLOOP_AI_SECURITY_CONTEXT CraftingSchema_Material_MaterialType = 45 ) // Enum value maps for CraftingSchema_Material_MaterialType. @@ -306,6 +310,7 @@ var ( 42: "CHECKMARX_JSON", 43: "OVERSECURED_JSON", 44: "PITEST_XML", + 45: "CHAINLOOP_AI_SECURITY_CONTEXT", } CraftingSchema_Material_MaterialType_value = map[string]int32{ "MATERIAL_TYPE_UNSPECIFIED": 0, @@ -353,6 +358,7 @@ var ( "CHECKMARX_JSON": 42, "OVERSECURED_JSON": 43, "PITEST_XML": 44, + "CHAINLOOP_AI_SECURITY_CONTEXT": 45, } ) @@ -2101,7 +2107,7 @@ var File_workflowcontract_v1_crafting_schema_proto protoreflect.FileDescriptor const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\n" + - ")workflowcontract/v1/crafting_schema.proto\x12\x13workflowcontract.v1\x1a\x1bbuf/validate/validate.proto\"\xf6\x12\n" + + ")workflowcontract/v1/crafting_schema.proto\x12\x13workflowcontract.v1\x1a\x1bbuf/validate/validate.proto\"\x99\x13\n" + "\x0eCraftingSchema\x122\n" + "\x0eschema_version\x18\x01 \x01(\tB\v\xbaH\x06r\x04\n" + "\x02v1\x18\x01R\rschemaVersion\x12N\n" + @@ -2124,7 +2130,7 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x0fDAGGER_PIPELINE\x10\x06\x12\x15\n" + "\x11TEAMCITY_PIPELINE\x10\a\x12\x13\n" + "\x0fTEKTON_PIPELINE\x10\b\x12\x15\n" + - "\x11CHAINLOOP_SANDBOX\x10\t:\x02\x18\x01\x1a\xc1\f\n" + + "\x11CHAINLOOP_SANDBOX\x10\t:\x02\x18\x01\x1a\xe4\f\n" + "\bMaterial\x12[\n" + "\x04type\x18\x01 \x01(\x0e29.workflowcontract.v1.CraftingSchema.Material.MaterialTypeB\f\xbaH\a\x82\x01\x04\x10\x01 \x00\x18\x01R\x04type\x12\x99\x01\n" + "\x04name\x18\x02 \x01(\tB\x84\x01\xbaH\x7f\xba\x01|\n" + @@ -2135,7 +2141,7 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\vskip_upload\x18\x06 \x01(\bR\n" + "skipUpload\x12\xaa\x01\n" + "\x05group\x18\a \x01(\tB\x93\x01\xbaH\x8f\x01\xba\x01\x8b\x01\n" + - "\x0egroup.dns-1123\x12:must contain only lowercase letters, numbers, and hyphens.\x1a=this == '' || this.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')R\x05group\"\xe6\a\n" + + "\x0egroup.dns-1123\x12:must contain only lowercase letters, numbers, and hyphens.\x1a=this == '' || this.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')R\x05group\"\x89\b\n" + "\fMaterialType\x12\x1d\n" + "\x19MATERIAL_TYPE_UNSPECIFIED\x10\x00\x12\n" + "\n" + @@ -2186,7 +2192,8 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x0eCHECKMARX_JSON\x10*\x12\x14\n" + "\x10OVERSECURED_JSON\x10+\x12\x0e\n" + "\n" + - "PITEST_XML\x10,:\x02\x18\x01:\x02\x18\x01\"\xfb\x01\n" + + "PITEST_XML\x10,\x12!\n" + + "\x1dCHAINLOOP_AI_SECURITY_CONTEXT\x10-:\x02\x18\x01:\x02\x18\x01\"\xfb\x01\n" + "\x10CraftingSchemaV2\x128\n" + "\vapi_version\x18\x01 \x01(\tB\x17\xbaH\x14r\x12\n" + "\x10chainloop.dev/v1R\n" + diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto index 38fd7434e..aedf88077 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -205,6 +205,10 @@ message CraftingSchema { // PIT mutation testing report in its native XML format (mutations.xml) // https://pitest.org/ PITEST_XML = 44; + // Chainloop AI security context: vulnerability fingerprints mined from a + // repository's fix history, the attack surfaces they share, ranked risks, + // and byte-verifiable evidence anchors backing each claim + CHAINLOOP_AI_SECURITY_CONTEXT = 45; } } } diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema_test.go b/app/controlplane/api/workflowcontract/v1/crafting_schema_test.go index d0b3734e2..5e5f18c03 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema_test.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema_test.go @@ -326,6 +326,10 @@ func TestMaterialKindsExcludedFromAutoDetection(t *testing.T) { name: "oversecured export envelope is not published by the vendor", kind: v1.CraftingSchema_Material_OVERSECURED_JSON, }, + { + name: "chainloop AI security context is generic JSON in an envelope", + kind: v1.CraftingSchema_Material_CHAINLOOP_AI_SECURITY_CONTEXT, + }, { name: "radamsa report", kind: v1.CraftingSchema_Material_RADAMSA_REPORT, diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go b/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go index 22043c66a..28a6289fe 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go @@ -42,6 +42,10 @@ import ( // an app platform), but the export envelope is not published by the vendor, // so pinning it here would bet every other JSON kind on a shape inferred // from a sample. +// - CHAINLOOP_AI_SECURITY_CONTEXT: generic JSON inside the Chainloop evidence +// envelope, so it would shadow (or be shadowed by) other JSON kinds. The +// envelope's evidence id is a strong fingerprint, but it is only reachable +// after a full parse, which auto-detection ordering cannot rely on. // // TestMaterialKindsExcludedFromAutoDetection pins these exclusions. var CraftingMaterialInValidationOrder = []CraftingSchema_Material_MaterialType{ diff --git a/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json b/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json new file mode 100644 index 000000000..c2124e466 --- /dev/null +++ b/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json @@ -0,0 +1,910 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://schemas.chainloop.dev/aisecuritycontext/0.1/ai-security-context.schema.json", + "type": "object", + "title": "AI Security Context", + "description": "The compiled hand-off artifact: facts, not prose. There is deliberately no agent_brief, summary, usage_directive, or per-risk rationale \u2014 each is a pure projection of the fields below and is rebuilt on demand by internal/render. Model-authored judgments (sink, poc, root_cause, invariant, check_hint) do live here, because no template can reconstruct them. Every array is sorted, so two scans over identical history with identical model output produce byte-identical artifacts. This schema describes the PAYLOAD only \u2014 the object the producer writes under `data`. The Chainloop evidence envelope {chainloop.material.evidence.id, schema, data} is asserted in Go by the crafter, matching every other first-party material schema.", + "required": [ + "schema_version", + "generated_at", + "repo", + "provenance", + "scan", + "class_counts", + "min_support", + "top_risks", + "shared_surfaces", + "fingerprints" + ], + "additionalProperties": false, + "properties": { + "schema_version": { + "const": "security-context-0.1", + "description": "Payload schema version (contracts.SecurityContextSchema). Pinned so an artifact of a different version fails loudly rather than validating partially." + }, + "generated_at": { + "type": "string", + "format": "date-time", + "minLength": 1, + "description": "RFC 3339 timestamp of when the context was compiled" + }, + "repo": { + "$ref": "#/definitions/repo_ref" + }, + "provenance": { + "$ref": "#/definitions/provenance" + }, + "scan": { + "$ref": "#/definitions/scan_stats" + }, + "class_counts": { + "type": "object", + "description": "Fingerprint count per L0 class. Sums to len(fingerprints).", + "propertyNames": { + "$ref": "#/definitions/l0_class" + }, + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "min_support": { + "type": "integer", + "minimum": 1, + "description": "Number of origin fixes a derived claim needs before it is stated as a pattern. Emitted so a reader can tell a suppressed claim from an absent one." + }, + "top_risks": { + "type": "array", + "description": "Components with a security-fix history, ranked by severity mass then recency-weighted fix count. Capped at 10, so a full list may mean 'at least 10'.", + "maxItems": 10, + "items": { + "$ref": "#/definitions/top_risk" + } + }, + "shared_surfaces": { + "type": "array", + "description": "Invariants that must hold at every entry point reaching a shared sink. Clustered on shared sink symbols rather than on L0 class, because grouping by class produces a histogram wearing a surface's name.", + "items": { + "$ref": "#/definitions/shared_surface" + } + }, + "fingerprints": { + "type": "array", + "description": "The adjudicated past fixes this context is built from, one per distinct fix", + "items": { + "$ref": "#/definitions/fingerprint" + } + } + }, + "definitions": { + "repo_ref": { + "type": "object", + "title": "Repository reference", + "description": "The scanned repository at a specific revision. Treated downstream as a cross-check against the platform's own dispatch record, never as a join key.", + "required": [ + "owner", + "name", + "url", + "ref", + "head_sha" + ], + "additionalProperties": false, + "properties": { + "owner": { + "type": "string", + "description": "Repository owner or organization" + }, + "name": { + "type": "string", + "description": "Repository name" + }, + "url": { + "type": "string", + "description": "Clone or browse URL of the repository" + }, + "ref": { + "type": "string", + "description": "Branch, tag, or revision expression the scan resolved" + }, + "head_sha": { + "$ref": "#/definitions/commit_sha", + "description": "Coverage watermark: the newest commit such that everything older inside the scanned window reached a terminal state. Mandatory in practice \u2014 without it nothing downstream can detect staleness, invalidate a lead, or re-verify an anchor. It may lag the scanned revision when scan.unresolved[] is non-empty." + } + } + }, + "provenance": { + "type": "object", + "title": "Provenance", + "description": "What produced the artifact. For a pipeline whose output is model-dependent, a scan is not reproducible or auditable without the tool version, the per-phase model and prompt identity, and the profile flags that decide which gates ran.", + "required": [ + "tool", + "tool_version", + "protocol" + ], + "additionalProperties": false, + "properties": { + "tool": { + "type": "string", + "description": "Producing tool, e.g. \"strata-go\"" + }, + "tool_version": { + "type": "string", + "description": "Version of the producing tool" + }, + "protocol": { + "type": "string", + "description": "Transport the adjudicator drove, e.g. \"codex-app-server\"" + }, + "triage_model": { + "type": "string", + "description": "Phase 1 model id. A change to this invalidates an incremental baseline." + }, + "adjudication_model": { + "type": "string", + "description": "Phase 2 model id. A change to this invalidates an incremental baseline." + }, + "triage_prompt_id": { + "type": "string", + "description": "Identity of the Phase 1 prompt" + }, + "adjudication_prompt_id": { + "type": "string", + "description": "Identity of the Phase 2 prompt" + }, + "input_profile": { + "type": "string", + "enum": [ + "D0", + "D1", + "D2", + "D3" + ], + "description": "Stage-1 diff-shaping profile. D0 (normalised diff only) is production; higher profiles add commit message / linked context and were measured to destabilise triage." + }, + "decision_profile": { + "type": "string", + "description": "Phase 2 decision profile, e.g. \"A0\" (no advisory context available to the adjudicator)" + }, + "cwe_catalog_version": { + "type": "string", + "description": "CWE catalog the class/CWE mapping was taken from, e.g. \"CWE-4.20\"" + }, + "anchor_verification": { + "type": "string", + "description": "Anchor checking mode, e.g. \"relocating\" \u2014 model line numbers may be corrected when the quoted bytes are found elsewhere in the same blob" + } + } + }, + "scan_stats": { + "type": "object", + "title": "Scan statistics", + "description": "The funnel: every stage records what it dropped, so the report is reconcilable and a silent failure cannot masquerade as a clean scan. The invariant is adjudicated == findings + abstained + rejected + no_finding + adjudication_errors; when it does not hold, `reconciles` is false and the scan must not be read as complete.", + "required": [ + "window", + "commits_scanned", + "commits_triaged", + "commits_skipped", + "triage_candidates", + "adjudicated", + "findings", + "abstained", + "rejected", + "no_finding", + "triage_errors", + "adjudication_errors", + "anchors_verified", + "anchors_relocated", + "anchors_rejected", + "input_tokens", + "output_tokens", + "wall_clock_s", + "reconciles" + ], + "additionalProperties": false, + "properties": { + "window": { + "$ref": "#/definitions/scan_window" + }, + "commits_scanned": { + "type": "integer", + "minimum": 0, + "description": "Commits in the resolved window" + }, + "commits_triaged": { + "type": "integer", + "minimum": 0, + "description": "Commits Phase 1 classified" + }, + "commits_skipped": { + "type": "integer", + "minimum": 0, + "description": "Commits dropped before triage, e.g. an oversize diff past --max-diff-bytes" + }, + "triage_candidates": { + "type": "integer", + "minimum": 0, + "description": "Commits triage marked YES and handed to Phase 2" + }, + "adjudicated": { + "type": "integer", + "minimum": 0, + "description": "Commits Phase 2 examined" + }, + "findings": { + "type": "integer", + "minimum": 0, + "description": "Accepted adjudications over all runs. Distinct fixes is len(fingerprints) == findings - duplicates_merged." + }, + "abstained": { + "type": "integer", + "minimum": 0, + "description": "ABSTAIN verdicts. Never collapsed into NO." + }, + "rejected": { + "type": "integer", + "minimum": 0, + "description": "Adjudications a gate refused (anchor, reachability, or origin)" + }, + "no_finding": { + "type": "integer", + "minimum": 0, + "description": "Decided NO: examined and found not to be a security fix" + }, + "triage_errors": { + "type": "integer", + "minimum": 0, + "description": "Commits whose triage call failed. These appear in unresolved[] and hold back the watermark." + }, + "adjudication_errors": { + "type": "integer", + "minimum": 0, + "description": "Commits whose adjudication failed or timed out. These appear in unresolved[] and hold back the watermark." + }, + "anchors_verified": { + "type": "integer", + "minimum": 0, + "description": "Anchors whose quoted bytes were located and hashed in the named revision" + }, + "anchors_relocated": { + "type": "integer", + "minimum": 0, + "description": "Subset of verified anchors whose line numbers were wrong but whose bytes were right, and whose coordinates were corrected. The common case \u2014 models transcribe spans faithfully and line numbers poorly." + }, + "anchors_rejected": { + "type": "integer", + "minimum": 0, + "description": "Anchors whose quoted bytes were not found in the named revision. Rejected anchors are dropped from the artifact, so this count has no corresponding entry in fingerprints[].anchors." + }, + "input_tokens": { + "type": "integer", + "minimum": 0, + "description": "Prompt tokens across both model phases" + }, + "output_tokens": { + "type": "integer", + "minimum": 0, + "description": "Completion tokens across both model phases" + }, + "wall_clock_s": { + "type": "number", + "minimum": 0, + "description": "Wall-clock seconds for the scan" + }, + "reconciles": { + "type": "boolean", + "description": "Whether the funnel invariant above holds. False means adjudications vanished unattributed." + }, + "duplicates_merged": { + "type": "integer", + "minimum": 0, + "description": "Fixes recognised under a second commit_sha during the merge, which is what bridges `findings` (accepted adjudications) and len(fingerprints) (distinct fixes)." + }, + "unresolved": { + "type": "array", + "description": "Commits inside the covered window that never reached a terminal state. The other half of the coverage defence: under incremental semantics a hole is permanent once head_sha advances past it, so a later run retries what is recorded here.", + "items": { + "$ref": "#/definitions/unresolved" + } + }, + "truncated_unresolved": { + "type": "integer", + "minimum": 0, + "description": "Holes the list cap dropped, so a truncated list still reports an honest total" + } + } + }, + "scan_window": { + "type": "object", + "title": "Scan window", + "description": "The history the artifact covers, so an incremental rescan knows where to resume and a reader knows what was NOT examined. Under incremental scanning the window is cumulative: from_sha keeps the earliest coverage boundary the artifact has ever had, to_sha advances to the current watermark.", + "additionalProperties": false, + "properties": { + "from_sha": { + "$ref": "#/definitions/commit_sha", + "description": "Oldest commit covered" + }, + "to_sha": { + "$ref": "#/definitions/commit_sha", + "description": "Newest commit covered (the watermark)" + }, + "last_n": { + "type": "integer", + "minimum": 0, + "description": "The --last bound the walk was truncated to, when one was set" + } + } + }, + "unresolved": { + "type": "object", + "title": "Unresolved commit", + "description": "One commit inside the covered window that produced no answer. The reason is human-readable on purpose: it is what tells an operator whether to raise --max-diff-bytes, retry a flaky provider, or investigate.", + "required": [ + "sha", + "reason" + ], + "additionalProperties": false, + "properties": { + "sha": { + "$ref": "#/definitions/commit_sha" + }, + "reason": { + "type": "string", + "minLength": 1, + "description": "Why it never resolved, e.g. \"diff too large\", \"triage: \", or an adjudication timeout" + } + } + }, + "top_risk": { + "type": "object", + "title": "Top risk", + "description": "A component with a security-fix history, ranked by severity mass then recency-weighted fix count.", + "required": [ + "component", + "kind", + "classes", + "severity", + "fix_count", + "recurring", + "severity_mass", + "recency_weight", + "cwe", + "evidence" + ], + "additionalProperties": false, + "properties": { + "component": { + "type": "string", + "minLength": 1, + "description": "Repository-relative path of the component" + }, + "kind": { + "type": "string", + "enum": [ + "source", + "test", + "manifest", + "config", + "generated", + "docs" + ], + "description": "What kind of file the component is, so a reader can discount a risk that lives only in tests or generated code" + }, + "classes": { + "type": "array", + "description": "L0 classes of the fixes that touched this component", + "items": { + "$ref": "#/definitions/l0_class" + } + }, + "severity": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical", + "unknown", + "" + ], + "description": "Highest severity among the backing fixes. Empty is emitted, not filtered: the ranking treats \"unknown\" and \"\" as the same rank 0 (see sevRank), so a component whose only fix is rated \"unknown\" carries a peak severity of \"\". Read it as \"no rated fix\", the same as \"unknown\"." + }, + "fix_count": { + "type": "integer", + "minimum": 1, + "description": "Security fixes that touched this component" + }, + "recurring": { + "type": "boolean", + "description": "Whether fix_count reaches min_support. Distinguishes a genuine pattern from a component seen exactly once \u2014 at low fingerprint counts that is the difference between a true statement and a false one." + }, + "severity_mass": { + "type": "integer", + "minimum": 0, + "description": "Summed severity weight of the backing fixes; the primary ranking key" + }, + "recency_weight": { + "type": "number", + "minimum": 0, + "description": "Recency-weighted fix count; the tiebreaker, so an old cluster does not outrank a live one" + }, + "cwe": { + "type": "array", + "description": "CWE ids carried by the backing fixes", + "items": { + "$ref": "#/definitions/cwe_id" + } + }, + "evidence": { + "$ref": "#/definitions/sha_list", + "description": "Origin fix commit SHAs backing this risk" + } + } + }, + "shared_surface": { + "type": "object", + "title": "Shared surface", + "description": "One invariant that must hold at every entry point reaching a shared sink \u2014 the highest-value section, because a guard enforced on one path and skipped on a sibling path is a live bug.", + "required": [ + "surface", + "class", + "guard", + "guard_kind", + "sink_symbols", + "entry_points", + "check_hint", + "support", + "origin_fixes" + ], + "additionalProperties": false, + "properties": { + "surface": { + "type": "string", + "minLength": 1, + "description": "Name of the surface, taken from the shared sink symbols it clusters on" + }, + "class": { + "type": "string", + "pattern": "^$|^(memory_safety|input_validation|injection|path_traversal|access_control|authentication|cryptography|resource_exhaustion|concurrency_toctou|deserialization|information_disclosure|logic_flaw|supply_chain|other)(/(memory_safety|input_validation|injection|path_traversal|access_control|authentication|cryptography|resource_exhaustion|concurrency_toctou|deserialization|information_disclosure|logic_flaw|supply_chain|other))*$", + "description": "L0 class of the fixes backing the surface. NOT a single class token: surfaces cluster on shared sink symbols, so a cluster spanning several classes emits them slash-joined and sorted (\"cryptography/resource_exhaustion\"). Each segment is a member of the closed taxonomy; empty when no backing fix carried a class." + }, + "guard": { + "$ref": "#/definitions/symbol_list", + "description": "Symbols whose presence at a call site makes it safe" + }, + "guard_kind": { + "type": "string", + "enum": [ + "guard_added", + "sink_removed", + "mixed", + "unknown" + ], + "description": "Shape of the backing fixes, which decides the variant search: guard_added means search for the sink and assert the guard is near it; sink_removed means any surviving occurrence of the sink is itself a lead." + }, + "sink_symbols": { + "$ref": "#/definitions/symbol_list", + "description": "Greppable identifiers for the dangerous operation this surface clusters on" + }, + "entry_points": { + "$ref": "#/definitions/path_list", + "description": "Paths that reach the shared sink" + }, + "check_hint": { + "type": "string", + "description": "Model-authored instruction for how to test the invariant at a new call site" + }, + "support": { + "type": "integer", + "minimum": 1, + "description": "Number of origin fixes backing this surface. Compare against min_support before reading it as a pattern." + }, + "origin_fixes": { + "$ref": "#/definitions/sha_list", + "description": "Fix commit SHAs backing this surface" + } + } + }, + "fingerprint": { + "type": "object", + "title": "Fingerprint", + "description": "One adjudicated past fix \u2014 the Phase 2 output and the atom the context is built from. Identity has two keys on purpose: `id` is a short derived alias convenient for joining across artifacts but which dies with the commit it names; `patch_id` is invariant under rebase, squash, and clean cherry-pick, and is a matching hint rather than a primary key.", + "required": [ + "id", + "commit_sha", + "commit_date", + "commit_subject", + "class", + "cwe", + "components", + "severity", + "summary", + "fix_completeness", + "reachability" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^fp_[0-9a-f]{10}$", + "description": "Derived alias, \"fp_\" + commit_sha[:10]" + }, + "patch_id": { + "type": "string", + "pattern": "^[0-9a-f]{40}$", + "description": "`git patch-id --stable` over the commit's diff. Absent for merge commits and empty diffs, which have no well-defined value." + }, + "commit_sha": { + "$ref": "#/definitions/commit_sha", + "description": "The fixing commit" + }, + "commit_date": { + "type": "string", + "format": "date-time", + "minLength": 1, + "description": "Committer date of the fixing commit (git %cI), which is when the fix landed" + }, + "commit_subject": { + "type": "string", + "description": "Subject line of the fixing commit" + }, + "class": { + "$ref": "#/definitions/l0_class" + }, + "cwe": { + "type": "array", + "description": "CWE ids for the flaw, from the catalog named in provenance", + "items": { + "$ref": "#/definitions/cwe_id" + } + }, + "components": { + "$ref": "#/definitions/path_list", + "description": "Paths this commit MODIFIED. Every derived section keys off this, so reachability must not be folded in \u2014 doing so inflates fix counts and manufactures hot components out of files that were never touched." + }, + "reachable_from": { + "$ref": "#/definitions/path_list", + "description": "Paths the vulnerability was reachable through but which the commit did not change" + }, + "sink_symbols": { + "$ref": "#/definitions/symbol_list", + "description": "Greppable identifiers for the dangerous operation. This is what makes a fingerprint actionable \u2014 without it a past fix cannot be searched for elsewhere in the tree." + }, + "guard_symbols": { + "$ref": "#/definitions/symbol_list", + "description": "Identifiers for the check whose presence makes a call site safe" + }, + "sink": { + "type": "string", + "description": "The dangerous operation in prose" + }, + "fix_kind": { + "type": "string", + "enum": [ + "guard_added", + "sink_removed", + "unknown" + ], + "description": "Which of the two shapes the fix takes, which decides how to search for variants" + }, + "fix_shape": { + "type": "string", + "description": "How the fix was carried out, in prose" + }, + "severity": { + "$ref": "#/definitions/severity" + }, + "confidence": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ], + "description": "Adjudicator's confidence in the finding" + }, + "summary": { + "type": "string", + "description": "The one-line what" + }, + "poc": { + "type": "string", + "description": "The attacker story: what an attacker could do before the fix" + }, + "root_cause": { + "type": "string", + "description": "Why the flaw existed. One of the two halves the adjudicator must establish to reach YES, retained as the basis for poc." + }, + "attacker_preconditions": { + "type": "string", + "description": "What an attacker needed in order to reach the flaw. The other half behind a YES." + }, + "invariant": { + "type": "string", + "description": "The property the fix established and which must keep holding. Model-authored; not reconstructible from the other fields." + }, + "fix_completeness": { + "type": "string", + "enum": [ + "complete", + "multi", + "partial", + "unknown" + ], + "description": "How much of the vulnerability this one commit repairs. `multi` (the fix spans several commits) is deliberately distinct from `complete`." + }, + "anchors": { + "type": "array", + "description": "Byte-verifiable evidence spans backing the claim. Anchors that failed verification are dropped, not recorded.", + "items": { + "$ref": "#/definitions/anchor" + } + }, + "reachability": { + "$ref": "#/definitions/reachability" + }, + "failure_containment": { + "type": "string", + "enum": [ + "crashes", + "degraded", + "unknown" + ], + "description": "What still limits the damage when this invariant is violated. \"contained\" is a member of the producer's vocabulary but can never appear here: gate 2 rejects it, because a fault absorbed by the runtime or recovery middleware is hardening, not a security fix." + }, + "introduced_by": { + "type": "array", + "description": "Where the flaw came from. EVERY entry independently introduced it \u2014 a list is a set of co-introductions (the same missing check added on two code paths), never a set of candidates the model was choosing between. Absent, never empty, means the archaeology was inconclusive. Ordered by committed_at.", + "minItems": 1, + "items": { + "$ref": "#/definitions/commit_ref" + } + }, + "introduced_to_fixed_seconds": { + "type": "integer", + "description": "commit_date minus the EARLIEST verified introduction, in seconds. Seconds rather than days because fixes land minutes after their introduction often enough that a day-granular field rounds the signal to zero. Absent means no verified origin, or a date anomaly \u2014 which is a different fact from zero." + }, + "status": { + "type": "string", + "enum": [ + "", + "reverted" + ], + "description": "Absent means live. A reverted fingerprint is ANNOTATED, never dropped: a repository that fixed something and then un-fixed it is more interesting than one that never fixed it, so it stays in top_risks and shared_surfaces with the revert recorded." + }, + "reverted_by": { + "$ref": "#/definitions/commit_sha", + "description": "The commit that reverted this fix, established by reverse patch-id match against the artifact's known fixes" + } + } + }, + "anchor": { + "type": "object", + "title": "Anchor", + "description": "A byte-verifiable evidence span. The model supplies revision/path/start_line/end_line/quoted_span; the checker resolves the revision to a concrete SHA, locates the span in that blob, and stamps span_sha256/verified/relocated. A consumer can therefore re-verify independently \u2014 `git show :` sliced at the line range must hash to span_sha256 \u2014 which a bare revision/path/line triple does not permit.", + "required": [ + "revision", + "revision_sha", + "path", + "start_line", + "end_line", + "quoted_span", + "span_sha256", + "verified", + "relocated" + ], + "additionalProperties": false, + "properties": { + "revision": { + "type": "string", + "enum": [ + "commit", + "parent", + "head" + ], + "description": "Which tree the span was read from: the fixing commit, its parent (pre-fix state), or repository HEAD. A closed vocabulary \u2014 the adjudicator works in an isolated checkout at the commit, so \"head\" and \"commit\" denote the same tree there, and a consumer resolves all three through revision_sha rather than re-deriving them." + }, + "revision_sha": { + "$ref": "#/definitions/commit_sha", + "description": "Concrete commit the span was read from" + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Repository-relative path of the anchored file" + }, + "start_line": { + "type": "integer", + "minimum": 1, + "description": "First line of the span, 1-indexed inclusive" + }, + "end_line": { + "type": "integer", + "minimum": 1, + "description": "Last line of the span, inclusive" + }, + "quoted_span": { + "type": "string", + "minLength": 1, + "description": "The exact bytes at those coordinates, which is what span_sha256 hashes" + }, + "span_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "SHA-256 of quoted_span, for independent re-verification" + }, + "verified": { + "type": "boolean", + "description": "Whether the bytes were located and hashed in the named revision. Unverified anchors are dropped, so this is true throughout a well-formed artifact." + }, + "relocated": { + "type": "boolean", + "description": "The model's line numbers were wrong but its bytes were right: the span was found elsewhere in the same blob and the coordinates were corrected. Rejecting this case would discard sound evidence." + }, + "supports": { + "type": "array", + "description": "Which claims of the fingerprint this span backs", + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, + "severity": { + "type": "object", + "title": "Severity", + "description": "The level together with how it was derived, so a consumer can tell a CVSS-backed rating from a model estimate.", + "required": [ + "level", + "source" + ], + "additionalProperties": false, + "properties": { + "level": { + "$ref": "#/definitions/severity_level" + }, + "source": { + "type": "string", + "enum": [ + "cvss", + "advisory", + "repository", + "human", + "model_estimate" + ], + "description": "How the rating was derived. Closed vocabulary, enforced at the adjudication gate. Under decision profile A0 the adjudicator has no advisory context, so \"advisory\" is refused there and real A0 output is \"model_estimate\" \u2014 with score/vector still present, since A0 does not suppress a model-estimated CVSS." + }, + "score": { + "type": "number", + "minimum": 0, + "maximum": 10, + "description": "Numeric CVSS base score, when one was produced" + }, + "vector": { + "type": "string", + "description": "CVSS vector string, when one was produced" + } + } + }, + "reachability": { + "type": "object", + "title": "Reachability", + "description": "How the commit changes what an attacker can reach. Only \"narrows\" survives the gate; before/after are retained because they are the evidence for that verdict, not a restatement of it.", + "required": [ + "verdict" + ], + "additionalProperties": false, + "properties": { + "verdict": { + "type": "string", + "enum": [ + "narrows", + "widens", + "unchanged" + ], + "description": "Direction of the change in attacker-reachable state" + }, + "before": { + "type": "string", + "description": "What was reachable before the fix" + }, + "after": { + "type": "string", + "description": "What is reachable after it" + } + } + }, + "commit_ref": { + "type": "object", + "title": "Commit reference", + "description": "A commit and what it did there, with the pipeline's proof attached. The model supplies commit_sha and description; the pipeline resolves a short SHA to a full one, proves the commit is an ancestor of the fix, and stamps committed_at and verified \u2014 never the model, because a date read from git always parses and always agrees with the SHA beside it, which a reported one does not.", + "required": [ + "commit_sha", + "verified" + ], + "additionalProperties": false, + "properties": { + "commit_sha": { + "$ref": "#/definitions/commit_sha" + }, + "description": { + "type": "string", + "description": "Model-supplied short noun phrase for what the commit did" + }, + "committed_at": { + "type": "string", + "format": "date-time", + "description": "Derived from git (%cI), never reported by the model" + }, + "verified": { + "type": "boolean", + "description": "Proved to be an ancestor of the fix commit \u2014 NOT proved to be the true origin. Git can refute a commit that could not possibly have introduced the flaw; it cannot confirm that an ancestor is where the flaw actually entered. A consumer joining fingerprints on origin must require this." + } + } + }, + "commit_sha": { + "type": "string", + "pattern": "^[0-9a-f]{40}$", + "description": "Full 40-hex git commit SHA. Short SHAs are resolved before they reach the artifact." + }, + "cwe_id": { + "type": "string", + "pattern": "^CWE-[0-9]+$", + "description": "CWE identifier in canonical form" + }, + "l0_class": { + "type": "string", + "enum": [ + "memory_safety", + "input_validation", + "injection", + "path_traversal", + "access_control", + "authentication", + "cryptography", + "resource_exhaustion", + "concurrency_toctou", + "deserialization", + "information_disclosure", + "logic_flaw", + "supply_chain", + "other" + ], + "description": "The closed, stable top-level security taxonomy. Closed on purpose: an earlier open list let a real run emit \"race_condition\", which nothing validated." + }, + "severity_level": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical", + "unknown" + ], + "description": "Severity band" + }, + "symbol_list": { + "type": "array", + "description": "Greppable code identifiers, sorted", + "items": { + "type": "string", + "minLength": 1 + } + }, + "path_list": { + "type": "array", + "description": "Repository-relative paths, sorted", + "items": { + "type": "string", + "minLength": 1 + } + }, + "sha_list": { + "type": "array", + "description": "Full commit SHAs, sorted", + "items": { + "$ref": "#/definitions/commit_sha" + } + } + } +} diff --git a/internal/schemavalidators/schemavalidators.go b/internal/schemavalidators/schemavalidators.go index 3fd170c26..3e410a188 100644 --- a/internal/schemavalidators/schemavalidators.go +++ b/internal/schemavalidators/schemavalidators.go @@ -54,6 +54,9 @@ type AIAgentConfigVersion string // AICodingSessionVersion represents the version of AI Coding Session schema. type AICodingSessionVersion string +// AISecurityContextVersion represents the version of AI Security Context schema. +type AISecurityContextVersion string + type ScorecardVersion string const ( @@ -79,6 +82,8 @@ const ( AIAgentConfigVersion0_1 AIAgentConfigVersion = "0.1" // AICodingSessionVersion0_1 represents AI Coding Session version 0.1 schema. AICodingSessionVersion0_1 AICodingSessionVersion = "0.1" + // AISecurityContextVersion0_1 represents AI Security Context version 0.1 schema. + AISecurityContextVersion0_1 AISecurityContextVersion = "0.1" // ScorecardVersionV2 represents the OpenSSF Scorecard V2 JSON result schema. ScorecardVersionV2 ScorecardVersion = "v2" // OpenAPIVersion2_0 represents Swagger/OpenAPI version 2.0 schema. @@ -142,6 +147,10 @@ var ( //go:embed internal_schemas/aicodingsession/ai-coding-session-0.1.schema.json aiCodingSessionSpecVersion0_1 string + // AI Security Context schemas + //go:embed internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json + aiSecurityContextSpecVersion0_1 string + // OpenAPI schemas //go:embed external_schemas/openapi/json-schema-draft-04.json jsonSchemaDraft04 string @@ -166,24 +175,26 @@ var ( ) var ( - compiledCycloneDxSchemas map[CycloneDXVersion]*jsonschema.Schema - cycloneDxOnce sync.Once - compiledCSAFSchemas map[CSAFVersion]*jsonschema.Schema - csafOnce sync.Once - compiledRunnerContextSchemas map[RunnerContextVersion]*jsonschema.Schema - runnerContextOnce sync.Once - compiledPRInfoSchemas map[PRInfoVersion]*jsonschema.Schema - prInfoOnce sync.Once - compiledAIAgentConfigSchemas map[AIAgentConfigVersion]*jsonschema.Schema - aiAgentConfigOnce sync.Once - compiledAICodingSessionSchemas map[AICodingSessionVersion]*jsonschema.Schema - aiCodingSessionOnce sync.Once - compiledOpenAPISchemas map[OpenAPIVersion]*jsonschema.Schema - openapiOnce sync.Once - compiledAsyncAPISchemas map[AsyncAPIVersion]*jsonschema.Schema - asyncapiOnce sync.Once - compiledScorecardSchemas map[ScorecardVersion]*jsonschema.Schema - scorecardOnce sync.Once + compiledCycloneDxSchemas map[CycloneDXVersion]*jsonschema.Schema + cycloneDxOnce sync.Once + compiledCSAFSchemas map[CSAFVersion]*jsonschema.Schema + csafOnce sync.Once + compiledRunnerContextSchemas map[RunnerContextVersion]*jsonschema.Schema + runnerContextOnce sync.Once + compiledPRInfoSchemas map[PRInfoVersion]*jsonschema.Schema + prInfoOnce sync.Once + compiledAIAgentConfigSchemas map[AIAgentConfigVersion]*jsonschema.Schema + aiAgentConfigOnce sync.Once + compiledAICodingSessionSchemas map[AICodingSessionVersion]*jsonschema.Schema + aiCodingSessionOnce sync.Once + compiledAISecurityContextSchemas map[AISecurityContextVersion]*jsonschema.Schema + aiSecurityContextOnce sync.Once + compiledOpenAPISchemas map[OpenAPIVersion]*jsonschema.Schema + openapiOnce sync.Once + compiledAsyncAPISchemas map[AsyncAPIVersion]*jsonschema.Schema + asyncapiOnce sync.Once + compiledScorecardSchemas map[ScorecardVersion]*jsonschema.Schema + scorecardOnce sync.Once ) func initCycloneDxSchemas() { @@ -292,6 +303,23 @@ func initAICodingSessionSchemas() { } } +// aiSecurityContextSchemaURL is the namespace label the AI security context +// schema is registered under. Nothing fetches it: the compiler binds the string +// to the embedded document below, and the producer writes the same string into +// the evidence envelope's `schema` field. +const aiSecurityContextSchemaURL = "https://schemas.chainloop.dev/aisecuritycontext/0.1/ai-security-context.schema.json" + +func initAISecurityContextSchemas() { + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource(aiSecurityContextSchemaURL, strings.NewReader(aiSecurityContextSpecVersion0_1)); err != nil { + panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", aiSecurityContextSchemaURL, err)) + } + + compiledAISecurityContextSchemas = map[AISecurityContextVersion]*jsonschema.Schema{ + AISecurityContextVersion0_1: compiler.MustCompile(aiSecurityContextSchemaURL), + } +} + // ValidateCycloneDX validates the given object against the specified CycloneDX schema version. func ValidateCycloneDX(data interface{}, version CycloneDXVersion) error { cycloneDxOnce.Do(initCycloneDxSchemas) @@ -459,6 +487,35 @@ func ValidateAICodingSession(data any, version AICodingSessionVersion) error { return nil } +// ValidateSecurityContext validates the given object against the specified AI +// Security Context schema version. +// +// The argument is the evidence envelope's `data` payload, not the whole file: +// the schema is payload-rooted like every other internal schema, and the +// envelope is asserted by the crafter in Go. +func ValidateSecurityContext(data any, version AISecurityContextVersion) error { + aiSecurityContextOnce.Do(initAISecurityContextSchemas) + + if version == "" { + version = AISecurityContextVersion0_1 + } + + schema, ok := compiledAISecurityContextSchemas[version] + if !ok { + return errors.New("invalid AI security context schema version") + } + + if err := schema.Validate(data); err != nil { + var invalidJSONTypeError jsonschema.InvalidJSONTypeError + if errors.As(err, &invalidJSONTypeError) { + return ErrInvalidJSONPayload + } + return err + } + + return nil +} + func initScorecardSchemas() { compiler := jsonschema.NewCompiler() if err := compiler.AddResource("https://chainloop.dev/schemas/scorecard/v2.json", strings.NewReader(scorecardSpecVersionV2)); err != nil { diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index 23b4cf222..100e69575 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -315,6 +315,47 @@ func TestValidateAICodingSession(t *testing.T) { } } +func TestValidateSecurityContext(t *testing.T) { + testCases := []struct { + name string + filePath string + wantErr string + }{ + { + name: "valid security context", + filePath: "./testdata/ai_security_context_valid.json", + }, + { + name: "missing required fields", + filePath: "./testdata/ai_security_context_missing_required.json", + wantErr: "missing properties", + }, + { + name: "completely wrong format", + filePath: "./testdata/sbom-spdx.json", + wantErr: "missing properties", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + f, err := os.ReadFile(tc.filePath) + require.NoError(t, err) + + var v any + require.NoError(t, json.Unmarshal(f, &v)) + + err = schemavalidators.ValidateSecurityContext(v, "") + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + }) + } +} + func TestValidateOpenAPI(t *testing.T) { testCases := []struct { name string diff --git a/internal/schemavalidators/testdata/ai_security_context_missing_required.json b/internal/schemavalidators/testdata/ai_security_context_missing_required.json new file mode 100644 index 000000000..562050777 --- /dev/null +++ b/internal/schemavalidators/testdata/ai_security_context_missing_required.json @@ -0,0 +1,184 @@ +{ + "schema_version": "security-context-0.1", + "generated_at": "2026-08-26T19:30:14Z", + "provenance": { + "tool": "strata-go", + "tool_version": "dev", + "protocol": "codex-app-server", + "triage_model": "openai/gpt-5.6-luna:nitro", + "adjudication_model": "openai/gpt-5.6-terra:nitro", + "triage_prompt_id": "current-diff-only-v1", + "adjudication_prompt_id": "adjudicate-a0-v3", + "input_profile": "D0", + "decision_profile": "A0", + "cwe_catalog_version": "CWE-4.20", + "anchor_verification": "relocating" + }, + "scan": { + "window": { + "from_sha": "a6214f62be37e2234f1c816dfd7a84c25376c98f", + "to_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + }, + "commits_scanned": 6, + "commits_triaged": 6, + "commits_skipped": 0, + "triage_candidates": 1, + "adjudicated": 1, + "findings": 1, + "abstained": 0, + "rejected": 0, + "no_finding": 0, + "triage_errors": 0, + "adjudication_errors": 0, + "anchors_verified": 4, + "anchors_relocated": 2, + "anchors_rejected": 0, + "input_tokens": 45459, + "output_tokens": 2642, + "wall_clock_s": 45.175430959, + "reconciles": true + }, + "class_counts": { + "injection": 1 + }, + "min_support": 2, + "top_risks": [ + { + "component": "pkg/api/handler.go", + "kind": "source", + "classes": [ + "injection" + ], + "severity": "critical", + "fix_count": 1, + "recurring": false, + "severity_mass": 8, + "recency_weight": 1, + "cwe": [ + "CWE-78" + ], + "evidence": [ + "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + ] + } + ], + "shared_surfaces": [ + { + "surface": "Injection — exec.Command(\"sh\", \"-c\"", + "class": "injection", + "guard": [], + "guard_kind": "sink_removed", + "sink_symbols": [ + "exec.Command(\"sh\", \"-c\"" + ], + "entry_points": [ + "pkg/api/handler.go" + ], + "check_hint": "Untrusted path input must be supplied as a literal argument to a fixed executable, never concatenated into a command interpreted by a shell.", + "support": 1, + "origin_fixes": [ + "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + ] + } + ], + "fingerprints": [ + { + "id": "fp_8c948c742b", + "patch_id": "32d18a48dac298fa43bd3dcffdb3bbfe06a008aa", + "commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "commit_date": "2026-08-19T19:58:28-03:00", + "commit_subject": "Avoid shell invoction in command handler", + "class": "injection", + "cwe": [ + "CWE-78" + ], + "components": [ + "pkg/api/handler.go" + ], + "reachable_from": [ + "cmd/server/main.go", + "pkg/api/router.go" + ], + "sink_symbols": [ + "exec.Command(\"sh\", \"-c\"" + ], + "sink": "Shell interpreter execution through exec.Command using a command string built from the path query parameter.", + "fix_kind": "sink_removed", + "fix_shape": "Removes shell parsing of the attacker-controlled path by invoking ls directly with path as one argv element.", + "severity": { + "level": "critical", + "source": "model_estimate", + "score": 9.8, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + }, + "confidence": "high", + "summary": "Fixes unauthenticated OS command injection in GET /ls.", + "poc": "An unauthenticated remote caller could execute arbitrary shell commands with the service process's privileges.", + "root_cause": "Attacker-controlled query input was string-concatenated into a shell command passed to sh -c.", + "attacker_preconditions": "Network access to the unauthenticated GET /ls endpoint and control of its path query parameter.", + "invariant": "Untrusted path input must be supplied as a literal argument to a fixed executable, never concatenated into a command interpreted by a shell.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "parent", + "revision_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b", + "path": "pkg/api/handler.go", + "start_line": 39, + "end_line": 46, + "quoted_span": "func lsHandler(c *gin.Context) {\n\tpath := c.Query(\"path\")\n\tif path == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"path query parameter is required\"})\n\t\treturn\n\t}\n\n\toutput, err := exec.Command(\"sh\", \"-c\", \"ls \"+path).CombinedOutput()", + "span_sha256": "492000e331be2eace22d94b23fb96abf1bab08c7e91d9a0bdddb736adcba7f9d", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "pkg/api/handler.go", + "start_line": 46, + "end_line": 46, + "quoted_span": "\toutput, err := exec.Command(\"ls\", path).CombinedOutput()", + "span_sha256": "7a247f7cecee0857780f320ce4af77499d53d4f80fad002e2dcac5a00ebb417e", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "pkg/api/router.go", + "start_line": 7, + "end_line": 14, + "quoted_span": "func NewRouter() *gin.Engine {\n\trouter := gin.Default()\n\n\trouter.POST(\"/hello\", helloHandler)\n\trouter.GET(\"/ls\", lsHandler)\n\n\treturn router\n}", + "span_sha256": "1f94d0ca3f50b6765a754e3af395c2056da181372d9271d108721cb6607b9013", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "cmd/server/main.go", + "start_line": 10, + "end_line": 18, + "quoted_span": "func main() {\n\tcfg := config.Load()\n\n\trouter := api.NewRouter()\n\tlog.Printf(\"listening on %s\", cfg.Addr)\n\tif err := router.Run(cfg.Addr); err != nil {\n\t\tlog.Fatal(err)\n\t}\n}", + "span_sha256": "271a83874851361f5216b4613551fc5af472d08d7ade12e6e39453d982c6fa9d", + "verified": true, + "relocated": true + } + ], + "reachability": { + "verdict": "narrows", + "before": "The caller could inject shell syntax through path and cause sh -c to execute arbitrary commands.", + "after": "The caller can cause only the fixed ls executable to run with path as a single argument." + }, + "failure_containment": "degraded", + "introduced_by": [ + { + "commit_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b", + "description": "GET /ls handler shell invocation", + "committed_at": "2026-08-19T19:56:46-03:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 102 + } + ] +} diff --git a/internal/schemavalidators/testdata/ai_security_context_valid.json b/internal/schemavalidators/testdata/ai_security_context_valid.json new file mode 100644 index 000000000..0a0c2adb1 --- /dev/null +++ b/internal/schemavalidators/testdata/ai_security_context_valid.json @@ -0,0 +1,191 @@ +{ + "schema_version": "security-context-0.1", + "generated_at": "2026-08-26T19:30:14Z", + "repo": { + "owner": "chainloop-dev", + "name": "sample-repo-go", + "url": "https://github.com/chainloop-dev/sample-repo-go", + "ref": "main", + "head_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + }, + "provenance": { + "tool": "strata-go", + "tool_version": "dev", + "protocol": "codex-app-server", + "triage_model": "openai/gpt-5.6-luna:nitro", + "adjudication_model": "openai/gpt-5.6-terra:nitro", + "triage_prompt_id": "current-diff-only-v1", + "adjudication_prompt_id": "adjudicate-a0-v3", + "input_profile": "D0", + "decision_profile": "A0", + "cwe_catalog_version": "CWE-4.20", + "anchor_verification": "relocating" + }, + "scan": { + "window": { + "from_sha": "a6214f62be37e2234f1c816dfd7a84c25376c98f", + "to_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + }, + "commits_scanned": 6, + "commits_triaged": 6, + "commits_skipped": 0, + "triage_candidates": 1, + "adjudicated": 1, + "findings": 1, + "abstained": 0, + "rejected": 0, + "no_finding": 0, + "triage_errors": 0, + "adjudication_errors": 0, + "anchors_verified": 4, + "anchors_relocated": 2, + "anchors_rejected": 0, + "input_tokens": 45459, + "output_tokens": 2642, + "wall_clock_s": 45.175430959, + "reconciles": true + }, + "class_counts": { + "injection": 1 + }, + "min_support": 2, + "top_risks": [ + { + "component": "pkg/api/handler.go", + "kind": "source", + "classes": [ + "injection" + ], + "severity": "critical", + "fix_count": 1, + "recurring": false, + "severity_mass": 8, + "recency_weight": 1, + "cwe": [ + "CWE-78" + ], + "evidence": [ + "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + ] + } + ], + "shared_surfaces": [ + { + "surface": "Injection — exec.Command(\"sh\", \"-c\"", + "class": "injection", + "guard": [], + "guard_kind": "sink_removed", + "sink_symbols": [ + "exec.Command(\"sh\", \"-c\"" + ], + "entry_points": [ + "pkg/api/handler.go" + ], + "check_hint": "Untrusted path input must be supplied as a literal argument to a fixed executable, never concatenated into a command interpreted by a shell.", + "support": 1, + "origin_fixes": [ + "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + ] + } + ], + "fingerprints": [ + { + "id": "fp_8c948c742b", + "patch_id": "32d18a48dac298fa43bd3dcffdb3bbfe06a008aa", + "commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "commit_date": "2026-08-19T19:58:28-03:00", + "commit_subject": "Avoid shell invoction in command handler", + "class": "injection", + "cwe": [ + "CWE-78" + ], + "components": [ + "pkg/api/handler.go" + ], + "reachable_from": [ + "cmd/server/main.go", + "pkg/api/router.go" + ], + "sink_symbols": [ + "exec.Command(\"sh\", \"-c\"" + ], + "sink": "Shell interpreter execution through exec.Command using a command string built from the path query parameter.", + "fix_kind": "sink_removed", + "fix_shape": "Removes shell parsing of the attacker-controlled path by invoking ls directly with path as one argv element.", + "severity": { + "level": "critical", + "source": "model_estimate", + "score": 9.8, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + }, + "confidence": "high", + "summary": "Fixes unauthenticated OS command injection in GET /ls.", + "poc": "An unauthenticated remote caller could execute arbitrary shell commands with the service process's privileges.", + "root_cause": "Attacker-controlled query input was string-concatenated into a shell command passed to sh -c.", + "attacker_preconditions": "Network access to the unauthenticated GET /ls endpoint and control of its path query parameter.", + "invariant": "Untrusted path input must be supplied as a literal argument to a fixed executable, never concatenated into a command interpreted by a shell.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "parent", + "revision_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b", + "path": "pkg/api/handler.go", + "start_line": 39, + "end_line": 46, + "quoted_span": "func lsHandler(c *gin.Context) {\n\tpath := c.Query(\"path\")\n\tif path == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"path query parameter is required\"})\n\t\treturn\n\t}\n\n\toutput, err := exec.Command(\"sh\", \"-c\", \"ls \"+path).CombinedOutput()", + "span_sha256": "492000e331be2eace22d94b23fb96abf1bab08c7e91d9a0bdddb736adcba7f9d", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "pkg/api/handler.go", + "start_line": 46, + "end_line": 46, + "quoted_span": "\toutput, err := exec.Command(\"ls\", path).CombinedOutput()", + "span_sha256": "7a247f7cecee0857780f320ce4af77499d53d4f80fad002e2dcac5a00ebb417e", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "pkg/api/router.go", + "start_line": 7, + "end_line": 14, + "quoted_span": "func NewRouter() *gin.Engine {\n\trouter := gin.Default()\n\n\trouter.POST(\"/hello\", helloHandler)\n\trouter.GET(\"/ls\", lsHandler)\n\n\treturn router\n}", + "span_sha256": "1f94d0ca3f50b6765a754e3af395c2056da181372d9271d108721cb6607b9013", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "cmd/server/main.go", + "start_line": 10, + "end_line": 18, + "quoted_span": "func main() {\n\tcfg := config.Load()\n\n\trouter := api.NewRouter()\n\tlog.Printf(\"listening on %s\", cfg.Addr)\n\tif err := router.Run(cfg.Addr); err != nil {\n\t\tlog.Fatal(err)\n\t}\n}", + "span_sha256": "271a83874851361f5216b4613551fc5af472d08d7ade12e6e39453d982c6fa9d", + "verified": true, + "relocated": true + } + ], + "reachability": { + "verdict": "narrows", + "before": "The caller could inject shell syntax through path and cause sh -c to execute arbitrary commands.", + "after": "The caller can cause only the fixed ls executable to run with path as a single argument." + }, + "failure_containment": "degraded", + "introduced_by": [ + { + "commit_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b", + "description": "GET /ls handler shell invocation", + "committed_at": "2026-08-19T19:56:46-03:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 102 + } + ] +} diff --git a/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go b/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go new file mode 100644 index 000000000..1f3ecb4ca --- /dev/null +++ b/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go @@ -0,0 +1,280 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package aisecuritycontext defines the wire types of the CHAINLOOP_AI_SECURITY_CONTEXT +// material: a security context compiled from a repository's fix history, carrying +// recurring vulnerability fingerprints, the attack surfaces they share, ranked +// risks, and byte-verifiable evidence anchors backing each claim. +// +// These structs mirror the producer's contract and exist for annotation +// extraction only. Validation always runs on the generically decoded payload, +// because a typed decode drops unknown fields and would defeat the schema's +// additionalProperties: false. +package aisecuritycontext + +const ( + // EvidenceID is the identifier for the AI security context material type. + EvidenceID = "CHAINLOOP_AI_SECURITY_CONTEXT" + // EvidenceSchemaURL is the namespace label of the JSON schema for the AI + // security context payload. Nothing fetches it; the producer writes this + // exact string into the envelope and the validator binds it to an embedded + // schema at compile time. + EvidenceSchemaURL = "https://schemas.chainloop.dev/aisecuritycontext/0.1/ai-security-context.schema.json" +) + +// RepoRef identifies the scanned repository at a specific revision. Downstream +// this is a cross-check against the dispatch record that requested the scan, +// never a join key. +type RepoRef struct { + Owner string `json:"owner"` + Name string `json:"name"` + URL string `json:"url"` + Ref string `json:"ref"` + HeadSHA string `json:"head_sha"` +} + +// Provenance records what produced the artifact. The output is model-dependent, +// so a scan is not reproducible or auditable without the tool version and the +// per-phase model, prompt, and profile identity. +type Provenance struct { + Tool string `json:"tool"` + ToolVersion string `json:"tool_version"` + Protocol string `json:"protocol"` + TriageModel string `json:"triage_model,omitempty"` + AdjudicationModel string `json:"adjudication_model,omitempty"` + TriagePromptID string `json:"triage_prompt_id,omitempty"` + AdjudicationPromptID string `json:"adjudication_prompt_id,omitempty"` + InputProfile string `json:"input_profile,omitempty"` + DecisionProfile string `json:"decision_profile,omitempty"` + CWECatalogVersion string `json:"cwe_catalog_version,omitempty"` + AnchorVerification string `json:"anchor_verification,omitempty"` +} + +// ScanWindow pins the history the scan covered, so a reader knows what was not +// examined. +type ScanWindow struct { + FromSHA string `json:"from_sha,omitempty"` + ToSHA string `json:"to_sha,omitempty"` + LastN int `json:"last_n,omitempty"` +} + +// Unresolved is one commit inside the covered window that produced no answer. +type Unresolved struct { + SHA string `json:"sha"` + Reason string `json:"reason"` +} + +// ScanStats is the funnel every stage reports into, so that a silent failure +// cannot masquerade as a clean scan. Reconciles asserts +// +// adjudicated == findings + abstained + rejected + no_finding + adjudication_errors +// +// and a false value means the scan must not be read as complete. +type ScanStats struct { + Window ScanWindow `json:"window"` + CommitsScanned int `json:"commits_scanned"` + CommitsTriaged int `json:"commits_triaged"` + CommitsSkipped int `json:"commits_skipped"` + TriageCandidates int `json:"triage_candidates"` + Adjudicated int `json:"adjudicated"` + Findings int `json:"findings"` + Abstained int `json:"abstained"` + Rejected int `json:"rejected"` + NoFinding int `json:"no_finding"` + TriageErrors int `json:"triage_errors"` + AdjudicationErrors int `json:"adjudication_errors"` + AnchorsVerified int `json:"anchors_verified"` + AnchorsRelocated int `json:"anchors_relocated"` + AnchorsRejected int `json:"anchors_rejected"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + WallClockS float64 `json:"wall_clock_s"` + Reconciles bool `json:"reconciles"` + DuplicatesMerged int `json:"duplicates_merged,omitempty"` + Unresolved []Unresolved `json:"unresolved,omitempty"` + TruncatedUnresolved int `json:"truncated_unresolved,omitempty"` +} + +// TopRisk is a component with a security-fix history, ranked by severity mass +// then recency-weighted fix count. +type TopRisk struct { + Component string `json:"component"` + Kind string `json:"kind"` + Classes []string `json:"classes"` + Severity string `json:"severity"` + FixCount int `json:"fix_count"` + Recurring bool `json:"recurring"` + SeverityMass int `json:"severity_mass"` + RecencyWeight float64 `json:"recency_weight"` + CWE []string `json:"cwe"` + Evidence []string `json:"evidence"` +} + +// SharedSurface is one invariant that must hold at every entry point reaching a +// shared sink, corroborated by two or more independent fixes. +// +// Class is not a single taxonomy token: surfaces cluster on shared sink symbols, +// so a cluster spanning several classes carries them slash-joined. +type SharedSurface struct { + Surface string `json:"surface"` + Class string `json:"class"` + Guard []string `json:"guard"` + GuardKind string `json:"guard_kind"` + SinkSymbols []string `json:"sink_symbols"` + EntryPoints []string `json:"entry_points"` + CheckHint string `json:"check_hint"` + Support int `json:"support"` + OriginFixes []string `json:"origin_fixes"` +} + +// CommitRef names a commit and what it did there, with the producer's proof +// attached. Verified means the commit was proved to be an ancestor of the fix — +// not that it is where the flaw actually entered. +type CommitRef struct { + CommitSHA string `json:"commit_sha"` + Description string `json:"description,omitempty"` + CommittedAt string `json:"committed_at,omitempty"` + Verified bool `json:"verified"` +} + +// Anchor is a byte-verifiable evidence span. A consumer can re-verify it +// independently: git show : sliced at the line range must +// hash to SpanSHA256. +type Anchor struct { + Revision string `json:"revision"` + RevisionSHA string `json:"revision_sha"` + Path string `json:"path"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + QuotedSpan string `json:"quoted_span"` + SpanSHA256 string `json:"span_sha256"` + Verified bool `json:"verified"` + Relocated bool `json:"relocated"` + Supports []string `json:"supports,omitempty"` +} + +// Severity carries the level together with how it was derived, so a consumer can +// tell a CVSS-backed rating from a model estimate. +type Severity struct { + Level string `json:"level"` + Source string `json:"source"` + Score *float64 `json:"score,omitempty"` + Vector string `json:"vector,omitempty"` +} + +// Reachability records how a fix changed what an attacker can reach. Before and +// After are the evidence for the verdict, not a restatement of it. +type Reachability struct { + Verdict string `json:"verdict"` + Before string `json:"before,omitempty"` + After string `json:"after,omitempty"` +} + +// Fingerprint is one adjudicated past fix, the atom the security context is +// built from. +type Fingerprint struct { + ID string `json:"id"` + PatchID string `json:"patch_id,omitempty"` + CommitSHA string `json:"commit_sha"` + CommitDate string `json:"commit_date"` + CommitSubject string `json:"commit_subject"` + + Class string `json:"class"` + CWE []string `json:"cwe"` + + // Components are the paths the fix modified; ReachableFrom are paths the + // vulnerability was reachable through but which the fix did not change. + // Derived sections key off Components, so folding the two together would + // manufacture hot components out of untouched files. + Components []string `json:"components"` + ReachableFrom []string `json:"reachable_from,omitempty"` + + SinkSymbols []string `json:"sink_symbols,omitempty"` + GuardSymbols []string `json:"guard_symbols,omitempty"` + Sink string `json:"sink,omitempty"` + FixKind string `json:"fix_kind,omitempty"` + FixShape string `json:"fix_shape,omitempty"` + + Severity Severity `json:"severity"` + Confidence string `json:"confidence,omitempty"` + + Summary string `json:"summary"` + POC string `json:"poc,omitempty"` + RootCause string `json:"root_cause,omitempty"` + AttackerPreconditions string `json:"attacker_preconditions,omitempty"` + + Invariant string `json:"invariant,omitempty"` + FixCompleteness string `json:"fix_completeness"` + + Anchors []Anchor `json:"anchors,omitempty"` + + Reachability Reachability `json:"reachability"` + FailureContainment string `json:"failure_containment,omitempty"` + + // IntroducedBy lists commits that each independently introduced the flaw — + // a set of co-introductions, never candidates the producer was choosing + // between. Absent, never empty, means the archaeology was inconclusive. + IntroducedBy []CommitRef `json:"introduced_by,omitempty"` + IntroducedToFixedSeconds int64 `json:"introduced_to_fixed_seconds,omitempty"` + + // Status is empty when the invariant the fix established still holds, and + // "reverted" when a later commit undid it. A reverted fingerprint is + // annotated rather than dropped. + Status string `json:"status,omitempty"` + RevertedBy string `json:"reverted_by,omitempty"` +} + +// Data is the AI security context payload: the object the producer writes under +// the envelope's `data` field, and what the JSON schema validates. +type Data struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt string `json:"generated_at"` + Repo RepoRef `json:"repo"` + Provenance Provenance `json:"provenance"` + Scan ScanStats `json:"scan"` + + ClassCounts map[string]int `json:"class_counts"` + + // MinSupport is how many origin fixes a derived claim needs before it is + // stated as a pattern, emitted so a reader can tell a suppressed claim from + // an absent one. + MinSupport int `json:"min_support"` + + TopRisks []TopRisk `json:"top_risks"` + SharedSurfaces []SharedSurface `json:"shared_surfaces"` + Fingerprints []Fingerprint `json:"fingerprints"` +} + +// Evidence is the Chainloop material envelope around a security context. +// +// The producer writes it: the crafter streams the file as it sits on disk and +// records the digest of those exact bytes, so a wrapper added downstream would +// mean attesting a digest for content that was never written. The envelope is +// also what makes the blob self-identifying once it is in CAS with none of the +// surrounding attestation context. +type Evidence struct { + ID string `json:"chainloop.material.evidence.id"` + Schema string `json:"schema"` + Data Data `json:"data"` +} + +// NewEvidence wraps a payload in the material envelope. +func NewEvidence(data Data) *Evidence { + return &Evidence{ + ID: EvidenceID, + Schema: EvidenceSchemaURL, + Data: data, + } +} diff --git a/pkg/attestation/crafter/materials/chainloop_ai_security_context.go b/pkg/attestation/crafter/materials/chainloop_ai_security_context.go new file mode 100644 index 000000000..2dc591a04 --- /dev/null +++ b/pkg/attestation/crafter/materials/chainloop_ai_security_context.go @@ -0,0 +1,151 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package materials + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/internal/schemavalidators" + api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aisecuritycontext" + "github.com/chainloop-dev/chainloop/pkg/casclient" + + "github.com/rs/zerolog" +) + +var ( + annotationSecurityContextHeadSHA = api.CreateAnnotation("material.securitycontext.head_sha") + annotationSecurityContextToolVersion = api.CreateAnnotation("material.securitycontext.tool_version") + annotationSecurityContextFingerprints = api.CreateAnnotation("material.securitycontext.fingerprints") + annotationSecurityContextReconciles = api.CreateAnnotation("material.securitycontext.reconciles") +) + +type ChainloopAISecurityContextCrafter struct { + *crafterCommon + backend *casclient.CASBackend +} + +// NewChainloopAISecurityContextCrafter generates a new CHAINLOOP_AI_SECURITY_CONTEXT material. +// This material type contains a security context compiled from a repository's fix history. +func NewChainloopAISecurityContextCrafter(schema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, l *zerolog.Logger) (*ChainloopAISecurityContextCrafter, error) { + if schema.Type != schemaapi.CraftingSchema_Material_CHAINLOOP_AI_SECURITY_CONTEXT { + return nil, fmt.Errorf("material type is not chainloop_ai_security_context") + } + + craftCommon := &crafterCommon{logger: l, input: schema} + return &ChainloopAISecurityContextCrafter{backend: backend, crafterCommon: craftCommon}, nil +} + +// Craft validates the AI security context against the JSON schema, calculates the +// digest, uploads it and returns the material definition. +func (c *ChainloopAISecurityContextCrafter) Craft(ctx context.Context, artifactPath string) (*api.Attestation_Material, error) { + f, err := os.ReadFile(artifactPath) + if err != nil { + return nil, fmt.Errorf("can't open the file: %w", err) + } + + // Unmarshal the envelope, keeping data as raw JSON for schema validation + var envelope struct { + ID string `json:"chainloop.material.evidence.id"` + Schema string `json:"schema"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(f, &envelope); err != nil { + c.logger.Debug().Err(err).Msg("error decoding file") + return nil, fmt.Errorf("invalid JSON format: %w", err) + } + + // The envelope is asserted here rather than by the schema, which is + // payload-rooted like every other internal schema. Checked before the data + // decode so that a bare payload or an unrelated JSON document names the + // actual problem instead of failing on empty input. + if err := validateSecurityContextEnvelope(envelope.ID, envelope.Schema, envelope.Data); err != nil { + c.logger.Debug().Err(err).Msg("evidence envelope validation failed") + return nil, err + } + + // Unmarshal data into a typed struct for annotation extraction + var data aisecuritycontext.Data + if err := json.Unmarshal(envelope.Data, &data); err != nil { + c.logger.Debug().Err(err).Msg("error decoding data field") + return nil, fmt.Errorf("failed to unmarshal data: %w", err) + } + + // Validate using raw JSON to preserve unknown fields for strict schema validation + var rawData any + if err := json.Unmarshal(envelope.Data, &rawData); err != nil { + return nil, fmt.Errorf("failed to unmarshal data for validation: %w", err) + } + + if err := schemavalidators.ValidateSecurityContext(rawData, schemavalidators.AISecurityContextVersion0_1); err != nil { + c.logger.Debug().Err(err).Msg("schema validation failed") + return nil, fmt.Errorf("AI security context validation failed: %w", err) + } + + material, err := uploadAndCraft(ctx, c.input, c.backend, artifactPath, c.logger) + if err != nil { + return nil, err + } + + c.annotate(material, &data) + + return material, nil +} + +// validateSecurityContextEnvelope checks the two constant envelope fields and +// that a payload is present at all. +func validateSecurityContextEnvelope(id, schema string, data json.RawMessage) error { + if len(data) == 0 { + return fmt.Errorf("no `data` field: a %s artifact must be wrapped in the Chainloop evidence "+ + "envelope {chainloop.material.evidence.id, schema, data}", aisecuritycontext.EvidenceID) + } + if id != aisecuritycontext.EvidenceID { + return fmt.Errorf("evidence id is %q, want %q", id, aisecuritycontext.EvidenceID) + } + if schema != aisecuritycontext.EvidenceSchemaURL { + return fmt.Errorf("schema is %q, want %q", schema, aisecuritycontext.EvidenceSchemaURL) + } + return nil +} + +// annotate surfaces the fields a policy is most likely to gate on, so that it +// does not have to download the payload from CAS to read them. +func (c *ChainloopAISecurityContextCrafter) annotate(material *api.Attestation_Material, data *aisecuritycontext.Data) { + if data.Repo.HeadSHA != "" { + material.Annotations[annotationSecurityContextHeadSHA] = data.Repo.HeadSHA + } + + if v := data.Provenance.ToolVersion; v != "" { + material.Annotations[annotationSecurityContextToolVersion] = v + if v == "dev" { + // A development build carries no version, so the context cannot be + // traced back to the code that produced it. + c.logger.Warn().Msg("the AI security context was produced by a 'dev' build of the scanner") + } + } + + material.Annotations[annotationSecurityContextFingerprints] = strconv.Itoa(len(data.Fingerprints)) + + // A funnel that does not reconcile means adjudications vanished unattributed: + // the scan is incomplete and must not be read as a clean result. Published so + // a policy can reject it without reading the payload. + material.Annotations[annotationSecurityContextReconciles] = strconv.FormatBool(data.Scan.Reconciles) +} diff --git a/pkg/attestation/crafter/materials/chainloop_ai_security_context_test.go b/pkg/attestation/crafter/materials/chainloop_ai_security_context_test.go new file mode 100644 index 000000000..fa02b764f --- /dev/null +++ b/pkg/attestation/crafter/materials/chainloop_ai_security_context_test.go @@ -0,0 +1,317 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package materials + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/pkg/casclient" + mUploader "github.com/chainloop-dev/chainloop/pkg/casclient/mocks" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestNewChainloopAISecurityContextCrafter_WrongType(t *testing.T) { + logger := zerolog.Nop() + + schema := &schemaapi.CraftingSchema_Material{ + Type: schemaapi.CraftingSchema_Material_SBOM_CYCLONEDX_JSON, + } + + _, err := NewChainloopAISecurityContextCrafter(schema, nil, &logger) + require.Error(t, err) + assert.Contains(t, err.Error(), "material type is not chainloop_ai_security_context") +} + +func TestNewChainloopAISecurityContextCrafter_CorrectType(t *testing.T) { + logger := zerolog.Nop() + + schema := &schemaapi.CraftingSchema_Material{ + Type: schemaapi.CraftingSchema_Material_CHAINLOOP_AI_SECURITY_CONTEXT, + } + + crafter, err := NewChainloopAISecurityContextCrafter(schema, nil, &logger) + require.NoError(t, err) + assert.NotNil(t, crafter) +} + +// newSecurityContextCrafter builds a crafter whose uploader always succeeds, so +// that a test failure is always about the security context and never about CAS. +func newSecurityContextCrafter(t *testing.T) *ChainloopAISecurityContextCrafter { + t.Helper() + + logger := zerolog.Nop() + schema := &schemaapi.CraftingSchema_Material{ + Name: "test", + Type: schemaapi.CraftingSchema_Material_CHAINLOOP_AI_SECURITY_CONTEXT, + } + + uploader := mUploader.NewUploader(t) + uploader.On("Upload", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&casclient.UpDownStatus{Digest: "deadbeef", Filename: "security-context.json"}, nil). + Maybe() + + crafter, err := NewChainloopAISecurityContextCrafter(schema, &casclient.CASBackend{Uploader: uploader}, &logger) + require.NoError(t, err) + return crafter +} + +func TestChainloopAISecurityContextCrafter_Craft(t *testing.T) { + testCases := []struct { + name string + path string + wantErr string + }{ + { + name: "a full security context", + path: "./testdata/ai-security-context.json", + }, + { + name: "a security context with a single fingerprint", + path: "./testdata/ai-security-context-minimal.json", + }, + { + // The payload on its own. Rejected because the envelope is what makes + // the blob self-identifying once it is in CAS. + name: "the payload without the evidence envelope", + path: "./testdata/ai-security-context-bare.json", + wantErr: "must be wrapped in the Chainloop evidence envelope", + }, + { + // additionalProperties: false at every level is what makes an explicit + // --kind fail loudly on the wrong file, since this kind is excluded + // from auto-detection. + name: "an unknown field inside a fingerprint", + path: "./testdata/ai-security-context-extra-field.json", + wantErr: "additionalProperties 'unexpected_field' not allowed", + }, + { + name: "an unrelated JSON document", + path: "../../../../internal/schemavalidators/testdata/sbom-spdx.json", + wantErr: "must be wrapped in the Chainloop evidence envelope", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := newSecurityContextCrafter(t).Craft(context.TODO(), tc.path) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, schemaapi.CraftingSchema_Material_CHAINLOOP_AI_SECURITY_CONTEXT, got.MaterialType) + }) + } +} + +func TestChainloopAISecurityContextCrafter_Envelope(t *testing.T) { + const ( + validID = "CHAINLOOP_AI_SECURITY_CONTEXT" + validURL = "https://schemas.chainloop.dev/aisecuritycontext/0.1/ai-security-context.schema.json" + ) + + testCases := []struct { + name string + id string + schema string + wantErr string + }{ + { + name: "the envelope the producer writes", + id: validID, + schema: validURL, + }, + { + // The kind was renamed during design; an artifact from a producer that + // predates the rename must not pass as this material. + name: "an evidence id from another material", + id: "CHAINLOOP_SECURITY_POSTURE", + schema: validURL, + wantErr: `evidence id is "CHAINLOOP_SECURITY_POSTURE"`, + }, + { + name: "a schema URL from another material", + id: validID, + schema: "https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json", + wantErr: "schema is", + }, + { + name: "an empty envelope", + wantErr: "must be wrapped in the Chainloop evidence envelope", + }, + } + + payload, err := os.ReadFile("./testdata/ai-security-context-bare.json") + require.NoError(t, err) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "security-context.json") + require.NoError(t, os.WriteFile(path, envelopeWith(t, tc.id, tc.schema, payload), 0o600)) + + _, err := newSecurityContextCrafter(t).Craft(context.TODO(), path) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +// envelopeWith wraps a payload in an envelope carrying the given id and schema. +// An empty id and schema produce an envelope with neither, which is the shape a +// producer that forgot to wrap its output would write. +func envelopeWith(t *testing.T, id, schema string, payload []byte) []byte { + t.Helper() + + if id == "" && schema == "" { + return []byte(`{}`) + } + + doc := map[string]any{ + "chainloop.material.evidence.id": id, + "schema": schema, + "data": json.RawMessage(payload), + } + b, err := json.Marshal(doc) + require.NoError(t, err) + return b +} + +func TestChainloopAISecurityContextCrafter_Annotations(t *testing.T) { + testCases := []struct { + name string + path string + headSHA string + toolVersion string + fingerprints string + reconciles string + }{ + { + name: "a full security context", + path: "./testdata/ai-security-context.json", + headSHA: "49d3dd9a8b53f4988cf43d1d824fb3b14f808fcb", + toolVersion: "dev", + fingerprints: "12", + reconciles: "true", + }, + { + name: "a security context with a single fingerprint", + path: "./testdata/ai-security-context-minimal.json", + headSHA: "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + toolVersion: "dev", + fingerprints: "1", + reconciles: "true", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := newSecurityContextCrafter(t).Craft(context.TODO(), tc.path) + require.NoError(t, err) + + assert.Equal(t, tc.headSHA, got.Annotations[annotationSecurityContextHeadSHA]) + assert.Equal(t, tc.toolVersion, got.Annotations[annotationSecurityContextToolVersion]) + assert.Equal(t, tc.fingerprints, got.Annotations[annotationSecurityContextFingerprints]) + assert.Equal(t, tc.reconciles, got.Annotations[annotationSecurityContextReconciles]) + }) + } +} + +// TestChainloopAISecurityContextCrafter_ReconcilesIsAlwaysAnnotated pins the one +// annotation that must be present even when false: a funnel that does not +// reconcile means the scan is incomplete, and a policy can only reject that if +// the annotation is there to read. +func TestChainloopAISecurityContextCrafter_ReconcilesIsAlwaysAnnotated(t *testing.T) { + raw, err := os.ReadFile("./testdata/ai-security-context-minimal.json") + require.NoError(t, err) + + var doc map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &doc)) + + var data map[string]json.RawMessage + require.NoError(t, json.Unmarshal(doc["data"], &data)) + var scan map[string]any + require.NoError(t, json.Unmarshal(data["scan"], &scan)) + scan["reconciles"] = false + + data["scan"] = mustMarshal(t, scan) + doc["data"] = mustMarshal(t, data) + + path := filepath.Join(t.TempDir(), "security-context.json") + require.NoError(t, os.WriteFile(path, mustMarshal(t, doc), 0o600)) + + got, err := newSecurityContextCrafter(t).Craft(context.TODO(), path) + require.NoError(t, err) + assert.Equal(t, "false", got.Annotations[annotationSecurityContextReconciles]) +} + +func TestChainloopAISecurityContextCrafter_FileErrors(t *testing.T) { + testCases := []struct { + name string + content string + wantErr string + }{ + { + name: "not JSON at all", + content: "not json", + wantErr: "invalid JSON format", + }, + { + name: "a JSON array rather than an envelope", + content: `[]`, + wantErr: "invalid JSON format", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "security-context.json") + require.NoError(t, os.WriteFile(path, []byte(tc.content), 0o600)) + + _, err := newSecurityContextCrafter(t).Craft(context.TODO(), path) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +func TestChainloopAISecurityContextCrafter_FileNotFound(t *testing.T) { + _, err := newSecurityContextCrafter(t).Craft(context.TODO(), "./testdata/does-not-exist.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "can't open the file") +} + +func mustMarshal(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return b +} diff --git a/pkg/attestation/crafter/materials/materials.go b/pkg/attestation/crafter/materials/materials.go index ad29a312d..73574bd3b 100644 --- a/pkg/attestation/crafter/materials/materials.go +++ b/pkg/attestation/crafter/materials/materials.go @@ -438,6 +438,8 @@ func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Materia crafter, err = NewChainloopAIAgentConfigCrafter(materialSchema, casBackend, logger) case schemaapi.CraftingSchema_Material_CHAINLOOP_AI_CODING_SESSION: crafter, err = NewChainloopAICodingSessionCrafter(materialSchema, casBackend, logger, WithAICodingSessionSkipRedaction(opts.SkipSecretRedaction)) + case schemaapi.CraftingSchema_Material_CHAINLOOP_AI_SECURITY_CONTEXT: + crafter, err = NewChainloopAISecurityContextCrafter(materialSchema, casBackend, logger) case schemaapi.CraftingSchema_Material_OPENAPI_SPEC: crafter, err = NewOpenAPICrafter(materialSchema, casBackend, logger, WithOpenAPINoStrictValidation(opts.NoStrictValidation)) case schemaapi.CraftingSchema_Material_ASYNCAPI_SPEC: diff --git a/pkg/attestation/crafter/materials/testdata/ai-security-context-bare.json b/pkg/attestation/crafter/materials/testdata/ai-security-context-bare.json new file mode 100644 index 000000000..0a0c2adb1 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/ai-security-context-bare.json @@ -0,0 +1,191 @@ +{ + "schema_version": "security-context-0.1", + "generated_at": "2026-08-26T19:30:14Z", + "repo": { + "owner": "chainloop-dev", + "name": "sample-repo-go", + "url": "https://github.com/chainloop-dev/sample-repo-go", + "ref": "main", + "head_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + }, + "provenance": { + "tool": "strata-go", + "tool_version": "dev", + "protocol": "codex-app-server", + "triage_model": "openai/gpt-5.6-luna:nitro", + "adjudication_model": "openai/gpt-5.6-terra:nitro", + "triage_prompt_id": "current-diff-only-v1", + "adjudication_prompt_id": "adjudicate-a0-v3", + "input_profile": "D0", + "decision_profile": "A0", + "cwe_catalog_version": "CWE-4.20", + "anchor_verification": "relocating" + }, + "scan": { + "window": { + "from_sha": "a6214f62be37e2234f1c816dfd7a84c25376c98f", + "to_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + }, + "commits_scanned": 6, + "commits_triaged": 6, + "commits_skipped": 0, + "triage_candidates": 1, + "adjudicated": 1, + "findings": 1, + "abstained": 0, + "rejected": 0, + "no_finding": 0, + "triage_errors": 0, + "adjudication_errors": 0, + "anchors_verified": 4, + "anchors_relocated": 2, + "anchors_rejected": 0, + "input_tokens": 45459, + "output_tokens": 2642, + "wall_clock_s": 45.175430959, + "reconciles": true + }, + "class_counts": { + "injection": 1 + }, + "min_support": 2, + "top_risks": [ + { + "component": "pkg/api/handler.go", + "kind": "source", + "classes": [ + "injection" + ], + "severity": "critical", + "fix_count": 1, + "recurring": false, + "severity_mass": 8, + "recency_weight": 1, + "cwe": [ + "CWE-78" + ], + "evidence": [ + "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + ] + } + ], + "shared_surfaces": [ + { + "surface": "Injection — exec.Command(\"sh\", \"-c\"", + "class": "injection", + "guard": [], + "guard_kind": "sink_removed", + "sink_symbols": [ + "exec.Command(\"sh\", \"-c\"" + ], + "entry_points": [ + "pkg/api/handler.go" + ], + "check_hint": "Untrusted path input must be supplied as a literal argument to a fixed executable, never concatenated into a command interpreted by a shell.", + "support": 1, + "origin_fixes": [ + "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + ] + } + ], + "fingerprints": [ + { + "id": "fp_8c948c742b", + "patch_id": "32d18a48dac298fa43bd3dcffdb3bbfe06a008aa", + "commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "commit_date": "2026-08-19T19:58:28-03:00", + "commit_subject": "Avoid shell invoction in command handler", + "class": "injection", + "cwe": [ + "CWE-78" + ], + "components": [ + "pkg/api/handler.go" + ], + "reachable_from": [ + "cmd/server/main.go", + "pkg/api/router.go" + ], + "sink_symbols": [ + "exec.Command(\"sh\", \"-c\"" + ], + "sink": "Shell interpreter execution through exec.Command using a command string built from the path query parameter.", + "fix_kind": "sink_removed", + "fix_shape": "Removes shell parsing of the attacker-controlled path by invoking ls directly with path as one argv element.", + "severity": { + "level": "critical", + "source": "model_estimate", + "score": 9.8, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + }, + "confidence": "high", + "summary": "Fixes unauthenticated OS command injection in GET /ls.", + "poc": "An unauthenticated remote caller could execute arbitrary shell commands with the service process's privileges.", + "root_cause": "Attacker-controlled query input was string-concatenated into a shell command passed to sh -c.", + "attacker_preconditions": "Network access to the unauthenticated GET /ls endpoint and control of its path query parameter.", + "invariant": "Untrusted path input must be supplied as a literal argument to a fixed executable, never concatenated into a command interpreted by a shell.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "parent", + "revision_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b", + "path": "pkg/api/handler.go", + "start_line": 39, + "end_line": 46, + "quoted_span": "func lsHandler(c *gin.Context) {\n\tpath := c.Query(\"path\")\n\tif path == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"path query parameter is required\"})\n\t\treturn\n\t}\n\n\toutput, err := exec.Command(\"sh\", \"-c\", \"ls \"+path).CombinedOutput()", + "span_sha256": "492000e331be2eace22d94b23fb96abf1bab08c7e91d9a0bdddb736adcba7f9d", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "pkg/api/handler.go", + "start_line": 46, + "end_line": 46, + "quoted_span": "\toutput, err := exec.Command(\"ls\", path).CombinedOutput()", + "span_sha256": "7a247f7cecee0857780f320ce4af77499d53d4f80fad002e2dcac5a00ebb417e", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "pkg/api/router.go", + "start_line": 7, + "end_line": 14, + "quoted_span": "func NewRouter() *gin.Engine {\n\trouter := gin.Default()\n\n\trouter.POST(\"/hello\", helloHandler)\n\trouter.GET(\"/ls\", lsHandler)\n\n\treturn router\n}", + "span_sha256": "1f94d0ca3f50b6765a754e3af395c2056da181372d9271d108721cb6607b9013", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "cmd/server/main.go", + "start_line": 10, + "end_line": 18, + "quoted_span": "func main() {\n\tcfg := config.Load()\n\n\trouter := api.NewRouter()\n\tlog.Printf(\"listening on %s\", cfg.Addr)\n\tif err := router.Run(cfg.Addr); err != nil {\n\t\tlog.Fatal(err)\n\t}\n}", + "span_sha256": "271a83874851361f5216b4613551fc5af472d08d7ade12e6e39453d982c6fa9d", + "verified": true, + "relocated": true + } + ], + "reachability": { + "verdict": "narrows", + "before": "The caller could inject shell syntax through path and cause sh -c to execute arbitrary commands.", + "after": "The caller can cause only the fixed ls executable to run with path as a single argument." + }, + "failure_containment": "degraded", + "introduced_by": [ + { + "commit_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b", + "description": "GET /ls handler shell invocation", + "committed_at": "2026-08-19T19:56:46-03:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 102 + } + ] +} diff --git a/pkg/attestation/crafter/materials/testdata/ai-security-context-extra-field.json b/pkg/attestation/crafter/materials/testdata/ai-security-context-extra-field.json new file mode 100644 index 000000000..86e2a3ba3 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/ai-security-context-extra-field.json @@ -0,0 +1,196 @@ +{ + "chainloop.material.evidence.id": "CHAINLOOP_AI_SECURITY_CONTEXT", + "schema": "https://schemas.chainloop.dev/aisecuritycontext/0.1/ai-security-context.schema.json", + "data": { + "schema_version": "security-context-0.1", + "generated_at": "2026-08-26T19:30:14Z", + "repo": { + "owner": "chainloop-dev", + "name": "sample-repo-go", + "url": "https://github.com/chainloop-dev/sample-repo-go", + "ref": "main", + "head_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + }, + "provenance": { + "tool": "strata-go", + "tool_version": "dev", + "protocol": "codex-app-server", + "triage_model": "openai/gpt-5.6-luna:nitro", + "adjudication_model": "openai/gpt-5.6-terra:nitro", + "triage_prompt_id": "current-diff-only-v1", + "adjudication_prompt_id": "adjudicate-a0-v3", + "input_profile": "D0", + "decision_profile": "A0", + "cwe_catalog_version": "CWE-4.20", + "anchor_verification": "relocating" + }, + "scan": { + "window": { + "from_sha": "a6214f62be37e2234f1c816dfd7a84c25376c98f", + "to_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + }, + "commits_scanned": 6, + "commits_triaged": 6, + "commits_skipped": 0, + "triage_candidates": 1, + "adjudicated": 1, + "findings": 1, + "abstained": 0, + "rejected": 0, + "no_finding": 0, + "triage_errors": 0, + "adjudication_errors": 0, + "anchors_verified": 4, + "anchors_relocated": 2, + "anchors_rejected": 0, + "input_tokens": 45459, + "output_tokens": 2642, + "wall_clock_s": 45.175430959, + "reconciles": true + }, + "class_counts": { + "injection": 1 + }, + "min_support": 2, + "top_risks": [ + { + "component": "pkg/api/handler.go", + "kind": "source", + "classes": [ + "injection" + ], + "severity": "critical", + "fix_count": 1, + "recurring": false, + "severity_mass": 8, + "recency_weight": 1, + "cwe": [ + "CWE-78" + ], + "evidence": [ + "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + ] + } + ], + "shared_surfaces": [ + { + "surface": "Injection — exec.Command(\"sh\", \"-c\"", + "class": "injection", + "guard": [], + "guard_kind": "sink_removed", + "sink_symbols": [ + "exec.Command(\"sh\", \"-c\"" + ], + "entry_points": [ + "pkg/api/handler.go" + ], + "check_hint": "Untrusted path input must be supplied as a literal argument to a fixed executable, never concatenated into a command interpreted by a shell.", + "support": 1, + "origin_fixes": [ + "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + ] + } + ], + "fingerprints": [ + { + "id": "fp_8c948c742b", + "patch_id": "32d18a48dac298fa43bd3dcffdb3bbfe06a008aa", + "commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "commit_date": "2026-08-19T19:58:28-03:00", + "commit_subject": "Avoid shell invoction in command handler", + "class": "injection", + "cwe": [ + "CWE-78" + ], + "components": [ + "pkg/api/handler.go" + ], + "reachable_from": [ + "cmd/server/main.go", + "pkg/api/router.go" + ], + "sink_symbols": [ + "exec.Command(\"sh\", \"-c\"" + ], + "sink": "Shell interpreter execution through exec.Command using a command string built from the path query parameter.", + "fix_kind": "sink_removed", + "fix_shape": "Removes shell parsing of the attacker-controlled path by invoking ls directly with path as one argv element.", + "severity": { + "level": "critical", + "source": "model_estimate", + "score": 9.8, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + }, + "confidence": "high", + "summary": "Fixes unauthenticated OS command injection in GET /ls.", + "poc": "An unauthenticated remote caller could execute arbitrary shell commands with the service process's privileges.", + "root_cause": "Attacker-controlled query input was string-concatenated into a shell command passed to sh -c.", + "attacker_preconditions": "Network access to the unauthenticated GET /ls endpoint and control of its path query parameter.", + "invariant": "Untrusted path input must be supplied as a literal argument to a fixed executable, never concatenated into a command interpreted by a shell.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "parent", + "revision_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b", + "path": "pkg/api/handler.go", + "start_line": 39, + "end_line": 46, + "quoted_span": "func lsHandler(c *gin.Context) {\n\tpath := c.Query(\"path\")\n\tif path == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"path query parameter is required\"})\n\t\treturn\n\t}\n\n\toutput, err := exec.Command(\"sh\", \"-c\", \"ls \"+path).CombinedOutput()", + "span_sha256": "492000e331be2eace22d94b23fb96abf1bab08c7e91d9a0bdddb736adcba7f9d", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "pkg/api/handler.go", + "start_line": 46, + "end_line": 46, + "quoted_span": "\toutput, err := exec.Command(\"ls\", path).CombinedOutput()", + "span_sha256": "7a247f7cecee0857780f320ce4af77499d53d4f80fad002e2dcac5a00ebb417e", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "pkg/api/router.go", + "start_line": 7, + "end_line": 14, + "quoted_span": "func NewRouter() *gin.Engine {\n\trouter := gin.Default()\n\n\trouter.POST(\"/hello\", helloHandler)\n\trouter.GET(\"/ls\", lsHandler)\n\n\treturn router\n}", + "span_sha256": "1f94d0ca3f50b6765a754e3af395c2056da181372d9271d108721cb6607b9013", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "cmd/server/main.go", + "start_line": 10, + "end_line": 18, + "quoted_span": "func main() {\n\tcfg := config.Load()\n\n\trouter := api.NewRouter()\n\tlog.Printf(\"listening on %s\", cfg.Addr)\n\tif err := router.Run(cfg.Addr); err != nil {\n\t\tlog.Fatal(err)\n\t}\n}", + "span_sha256": "271a83874851361f5216b4613551fc5af472d08d7ade12e6e39453d982c6fa9d", + "verified": true, + "relocated": true + } + ], + "reachability": { + "verdict": "narrows", + "before": "The caller could inject shell syntax through path and cause sh -c to execute arbitrary commands.", + "after": "The caller can cause only the fixed ls executable to run with path as a single argument." + }, + "failure_containment": "degraded", + "introduced_by": [ + { + "commit_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b", + "description": "GET /ls handler shell invocation", + "committed_at": "2026-08-19T19:56:46-03:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 102, + "unexpected_field": "x" + } + ] + } +} diff --git a/pkg/attestation/crafter/materials/testdata/ai-security-context-minimal.json b/pkg/attestation/crafter/materials/testdata/ai-security-context-minimal.json new file mode 100644 index 000000000..9dc30703a --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/ai-security-context-minimal.json @@ -0,0 +1,195 @@ +{ + "chainloop.material.evidence.id": "CHAINLOOP_AI_SECURITY_CONTEXT", + "schema": "https://schemas.chainloop.dev/aisecuritycontext/0.1/ai-security-context.schema.json", + "data": { + "schema_version": "security-context-0.1", + "generated_at": "2026-08-26T19:30:14Z", + "repo": { + "owner": "chainloop-dev", + "name": "sample-repo-go", + "url": "https://github.com/chainloop-dev/sample-repo-go", + "ref": "main", + "head_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + }, + "provenance": { + "tool": "strata-go", + "tool_version": "dev", + "protocol": "codex-app-server", + "triage_model": "openai/gpt-5.6-luna:nitro", + "adjudication_model": "openai/gpt-5.6-terra:nitro", + "triage_prompt_id": "current-diff-only-v1", + "adjudication_prompt_id": "adjudicate-a0-v3", + "input_profile": "D0", + "decision_profile": "A0", + "cwe_catalog_version": "CWE-4.20", + "anchor_verification": "relocating" + }, + "scan": { + "window": { + "from_sha": "a6214f62be37e2234f1c816dfd7a84c25376c98f", + "to_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + }, + "commits_scanned": 6, + "commits_triaged": 6, + "commits_skipped": 0, + "triage_candidates": 1, + "adjudicated": 1, + "findings": 1, + "abstained": 0, + "rejected": 0, + "no_finding": 0, + "triage_errors": 0, + "adjudication_errors": 0, + "anchors_verified": 4, + "anchors_relocated": 2, + "anchors_rejected": 0, + "input_tokens": 45459, + "output_tokens": 2642, + "wall_clock_s": 45.175430959, + "reconciles": true + }, + "class_counts": { + "injection": 1 + }, + "min_support": 2, + "top_risks": [ + { + "component": "pkg/api/handler.go", + "kind": "source", + "classes": [ + "injection" + ], + "severity": "critical", + "fix_count": 1, + "recurring": false, + "severity_mass": 8, + "recency_weight": 1, + "cwe": [ + "CWE-78" + ], + "evidence": [ + "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + ] + } + ], + "shared_surfaces": [ + { + "surface": "Injection — exec.Command(\"sh\", \"-c\"", + "class": "injection", + "guard": [], + "guard_kind": "sink_removed", + "sink_symbols": [ + "exec.Command(\"sh\", \"-c\"" + ], + "entry_points": [ + "pkg/api/handler.go" + ], + "check_hint": "Untrusted path input must be supplied as a literal argument to a fixed executable, never concatenated into a command interpreted by a shell.", + "support": 1, + "origin_fixes": [ + "8c948c742bdfc09c4aae6b3c386faeb98f925ff2" + ] + } + ], + "fingerprints": [ + { + "id": "fp_8c948c742b", + "patch_id": "32d18a48dac298fa43bd3dcffdb3bbfe06a008aa", + "commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "commit_date": "2026-08-19T19:58:28-03:00", + "commit_subject": "Avoid shell invoction in command handler", + "class": "injection", + "cwe": [ + "CWE-78" + ], + "components": [ + "pkg/api/handler.go" + ], + "reachable_from": [ + "cmd/server/main.go", + "pkg/api/router.go" + ], + "sink_symbols": [ + "exec.Command(\"sh\", \"-c\"" + ], + "sink": "Shell interpreter execution through exec.Command using a command string built from the path query parameter.", + "fix_kind": "sink_removed", + "fix_shape": "Removes shell parsing of the attacker-controlled path by invoking ls directly with path as one argv element.", + "severity": { + "level": "critical", + "source": "model_estimate", + "score": 9.8, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + }, + "confidence": "high", + "summary": "Fixes unauthenticated OS command injection in GET /ls.", + "poc": "An unauthenticated remote caller could execute arbitrary shell commands with the service process's privileges.", + "root_cause": "Attacker-controlled query input was string-concatenated into a shell command passed to sh -c.", + "attacker_preconditions": "Network access to the unauthenticated GET /ls endpoint and control of its path query parameter.", + "invariant": "Untrusted path input must be supplied as a literal argument to a fixed executable, never concatenated into a command interpreted by a shell.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "parent", + "revision_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b", + "path": "pkg/api/handler.go", + "start_line": 39, + "end_line": 46, + "quoted_span": "func lsHandler(c *gin.Context) {\n\tpath := c.Query(\"path\")\n\tif path == \"\" {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"error\": \"path query parameter is required\"})\n\t\treturn\n\t}\n\n\toutput, err := exec.Command(\"sh\", \"-c\", \"ls \"+path).CombinedOutput()", + "span_sha256": "492000e331be2eace22d94b23fb96abf1bab08c7e91d9a0bdddb736adcba7f9d", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "pkg/api/handler.go", + "start_line": 46, + "end_line": 46, + "quoted_span": "\toutput, err := exec.Command(\"ls\", path).CombinedOutput()", + "span_sha256": "7a247f7cecee0857780f320ce4af77499d53d4f80fad002e2dcac5a00ebb417e", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "pkg/api/router.go", + "start_line": 7, + "end_line": 14, + "quoted_span": "func NewRouter() *gin.Engine {\n\trouter := gin.Default()\n\n\trouter.POST(\"/hello\", helloHandler)\n\trouter.GET(\"/ls\", lsHandler)\n\n\treturn router\n}", + "span_sha256": "1f94d0ca3f50b6765a754e3af395c2056da181372d9271d108721cb6607b9013", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "path": "cmd/server/main.go", + "start_line": 10, + "end_line": 18, + "quoted_span": "func main() {\n\tcfg := config.Load()\n\n\trouter := api.NewRouter()\n\tlog.Printf(\"listening on %s\", cfg.Addr)\n\tif err := router.Run(cfg.Addr); err != nil {\n\t\tlog.Fatal(err)\n\t}\n}", + "span_sha256": "271a83874851361f5216b4613551fc5af472d08d7ade12e6e39453d982c6fa9d", + "verified": true, + "relocated": true + } + ], + "reachability": { + "verdict": "narrows", + "before": "The caller could inject shell syntax through path and cause sh -c to execute arbitrary commands.", + "after": "The caller can cause only the fixed ls executable to run with path as a single argument." + }, + "failure_containment": "degraded", + "introduced_by": [ + { + "commit_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b", + "description": "GET /ls handler shell invocation", + "committed_at": "2026-08-19T19:56:46-03:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 102 + } + ] + } +} diff --git a/pkg/attestation/crafter/materials/testdata/ai-security-context.json b/pkg/attestation/crafter/materials/testdata/ai-security-context.json new file mode 100644 index 000000000..2707251a5 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/ai-security-context.json @@ -0,0 +1,1933 @@ +{ + "chainloop.material.evidence.id": "CHAINLOOP_AI_SECURITY_CONTEXT", + "schema": "https://schemas.chainloop.dev/aisecuritycontext/0.1/ai-security-context.schema.json", + "data": { + "schema_version": "security-context-0.1", + "generated_at": "2026-08-26T19:39:57Z", + "repo": { + "owner": "chainloop-dev", + "name": "testrepo", + "url": "https://github.com/chainloop-dev/testrepo", + "ref": "master", + "head_sha": "49d3dd9a8b53f4988cf43d1d824fb3b14f808fcb" + }, + "provenance": { + "tool": "strata-go", + "tool_version": "dev", + "protocol": "codex-app-server", + "triage_model": "openai/gpt-5.6-luna:nitro", + "adjudication_model": "openai/gpt-5.6-terra:nitro", + "triage_prompt_id": "current-diff-only-v1", + "adjudication_prompt_id": "adjudicate-a0-v3", + "input_profile": "D0", + "decision_profile": "A0", + "cwe_catalog_version": "CWE-4.20", + "anchor_verification": "relocating" + }, + "scan": { + "window": { + "from_sha": "783206b0fad6dc1de7c3252362f74fe4ffd00a10", + "to_sha": "49d3dd9a8b53f4988cf43d1d824fb3b14f808fcb", + "last_n": 200 + }, + "commits_scanned": 199, + "commits_triaged": 199, + "commits_skipped": 1, + "triage_candidates": 24, + "adjudicated": 24, + "findings": 12, + "abstained": 12, + "rejected": 0, + "no_finding": 0, + "triage_errors": 0, + "adjudication_errors": 0, + "anchors_verified": 75, + "anchors_relocated": 32, + "anchors_rejected": 2, + "input_tokens": 4531707, + "output_tokens": 101010, + "wall_clock_s": 387.844874, + "reconciles": true, + "unresolved": [ + { + "sha": "d514564a92bf4f472b0ac21cbac794a9494346f4", + "reason": "diff too large (2065160 bytes)" + } + ] + }, + "class_counts": { + "access_control": 1, + "authentication": 1, + "input_validation": 1, + "other": 1, + "resource_exhaustion": 8 + }, + "min_support": 2, + "top_risks": [ + { + "component": "openapi3filter/req_resp_decoder.go", + "kind": "source", + "classes": [ + "resource_exhaustion" + ], + "severity": "high", + "fix_count": 5, + "recurring": true, + "severity_mass": 20, + "recency_weight": 5, + "cwe": [ + "CWE-400", + "CWE-476" + ], + "evidence": [ + "1223a0f215d2cf9beb2d9eb9ea2649d001c21388", + "30e292371a89ca6d1f91a9553e0aa518b49f838d", + "68ac2affa325514d7d6e731204d6a1edf6bdff64", + "d090e36b2f1c42c898ba2a687a8f6e25f5ca727f", + "f5441d67f855ed34d975b3b85665764644ab1459" + ] + }, + { + "component": "openapi3filter/validation_handler.go", + "kind": "source", + "classes": [ + "access_control", + "authentication" + ], + "severity": "high", + "fix_count": 2, + "recurring": true, + "severity_mass": 8, + "recency_weight": 2, + "cwe": [ + "CWE-306", + "CWE-863" + ], + "evidence": [ + "eda80e2676e9f577ceed2dd80e64f16083edb041", + "f0407d53b0730280266f454b755010e7eeb985da" + ] + }, + { + "component": "openapi3/loader.go", + "kind": "source", + "classes": [ + "resource_exhaustion" + ], + "severity": "high", + "fix_count": 1, + "recurring": false, + "severity_mass": 4, + "recency_weight": 1, + "cwe": [ + "CWE-476" + ], + "evidence": [ + "88aa64c7cbd03ecadbb419c473bdcaa8b0124c6b" + ] + }, + { + "component": "openapi3filter/validation_error_encoder.go", + "kind": "source", + "classes": [ + "resource_exhaustion" + ], + "severity": "high", + "fix_count": 1, + "recurring": false, + "severity_mass": 4, + "recency_weight": 1, + "cwe": [ + "CWE-476" + ], + "evidence": [ + "1d0a337c9b1570fab283be8a04c8af6e43b9a22c" + ] + }, + { + "component": "openapi3/schema.go", + "kind": "source", + "classes": [ + "input_validation", + "other" + ], + "severity": "medium", + "fix_count": 2, + "recurring": true, + "severity_mass": 3, + "recency_weight": 2, + "cwe": [ + "CWE-20", + "CWE-476" + ], + "evidence": [ + "94fcebf4edbfed0bd8818781f3be60dc2c196d85", + "d33e44e67734dcde81b576582185e1343fb3985d" + ] + }, + { + "component": "openapi2conv/openapi2_conv.go", + "kind": "source", + "classes": [ + "resource_exhaustion" + ], + "severity": "medium", + "fix_count": 1, + "recurring": false, + "severity_mass": 2, + "recency_weight": 1, + "cwe": [ + "CWE-476" + ], + "evidence": [ + "355692920adc95a261f8afba887dce636a0e2d67" + ] + }, + { + "component": "openapi3/schema_pattern.go", + "kind": "source", + "classes": [ + "other" + ], + "severity": "low", + "fix_count": 1, + "recurring": false, + "severity_mass": 1, + "recency_weight": 1, + "cwe": [ + "CWE-476" + ], + "evidence": [ + "d33e44e67734dcde81b576582185e1343fb3985d" + ] + } + ], + "shared_surfaces": [ + { + "surface": "Access Control — h.Handler.ServeHTTP (+1 related sink)", + "class": "access_control/authentication", + "guard": [ + "ErrEncodedPathSeparator", + "checkEncodedPathSeparator" + ], + "guard_kind": "mixed", + "sink_symbols": [ + "h.Handler.ServeHTTP", + "next.ServeHTTP" + ], + "entry_points": [ + ".github/docs/openapi3filter.txt", + "README.md", + "openapi3filter/encoded_path_route_confusion_test.go", + "openapi3filter/issue1105_test.go", + "openapi3filter/testdata/encoded_path_authorization_bypass.yml", + "openapi3filter/validation_error_test.go", + "openapi3filter/validation_handler.go" + ], + "check_hint": "For a request with a non-empty OpenAPI security requirement, validation must invoke an explicitly supplied AuthenticationFunc or reject the request; it must not silently treat authentication as successful.", + "support": 2, + "origin_fixes": [ + "eda80e2676e9f577ceed2dd80e64f16083edb041", + "f0407d53b0730280266f454b755010e7eeb985da" + ] + }, + { + "surface": "Other — cp.MatchString(value)", + "class": "other", + "guard": [], + "guard_kind": "guard_added", + "sink_symbols": [ + "cp.MatchString(value)" + ], + "entry_points": [ + "openapi3/issue1044_test.go", + "openapi3/schema.go", + "openapi3/schema_pattern.go" + ], + "check_hint": "A regex matcher must be non-nil before MatchString is called; a pattern compilation failure must remain a validation error.", + "support": 1, + "origin_fixes": [ + "d33e44e67734dcde81b576582185e1343fb3985d" + ] + }, + { + "surface": "Input Validation — fmt.Sprintf(\"%.10f\", *v) (+1 related sink)", + "class": "input_validation", + "guard": [ + "multipleOfFailure" + ], + "guard_kind": "guard_added", + "sink_symbols": [ + "fmt.Sprintf(\"%.10f\", *v)", + "fmt.Sprintf(\"%.10f\", value)" + ], + "entry_points": [ + "openapi3/schema.go", + "openapi3/schema_test.go" + ], + "check_hint": "Every accepted numeric instance must be an integer multiple of the schema's positive multipleOf value, subject only to bounded float representation error.", + "support": 1, + "origin_fixes": [ + "94fcebf4edbfed0bd8818781f3be60dc2c196d85" + ] + }, + { + "surface": "Resource Exhaustion — c.Value.AdditionalProperties.Has (+1 related sink)", + "class": "resource_exhaustion", + "guard": [], + "guard_kind": "guard_added", + "sink_symbols": [ + "c.Value.AdditionalProperties.Has", + "c.Value.AdditionalProperties.Schema" + ], + "entry_points": [ + "openapi3/ghsa_mh7x_f8wq_4jhx_test.go", + "openapi3/loader.go" + ], + "check_hint": "A JSON-pointer traversal may dereference SchemaRef.Value only after confirming that the referenced schema has resolved to a non-nil value.", + "support": 1, + "origin_fixes": [ + "88aa64c7cbd03ecadbb419c473bdcaa8b0124c6b" + ] + }, + { + "surface": "Resource Exhaustion — components.Schemas[name]", + "class": "resource_exhaustion", + "guard": [], + "guard_kind": "guard_added", + "sink_symbols": [ + "components.Schemas[name]" + ], + "entry_points": [ + "openapi2conv/issue1062_test.go", + "openapi2conv/openapi2_conv.go" + ], + "check_hint": "Schema-reference conversion must not dereference the components table when no components context was supplied.", + "support": 1, + "origin_fixes": [ + "355692920adc95a261f8afba887dce636a0e2d67" + ] + }, + { + "surface": "Resource Exhaustion — e.Parameter.In", + "class": "resource_exhaustion", + "guard": [], + "guard_kind": "guard_added", + "sink_symbols": [ + "e.Parameter.In" + ], + "entry_points": [ + "openapi3filter/ghsa_mmfr_pmjx_hw9w_test.go", + "openapi3filter/validation_error_encoder.go" + ], + "check_hint": "A RequestError without a Parameter (including request-body errors) must not dereference e.Parameter while converting an error.", + "support": 1, + "origin_fixes": [ + "1d0a337c9b1570fab283be8a04c8af6e43b9a22c" + ] + }, + { + "surface": "Resource Exhaustion — mt.Schema.Value", + "class": "resource_exhaustion", + "guard": [], + "guard_kind": "guard_added", + "sink_symbols": [ + "mt.Schema.Value" + ], + "entry_points": [ + "openapi3filter/req_resp_decoder.go", + "openapi3filter/validate_request_test.go" + ], + "check_hint": "The default content-parameter decoder must not dereference MediaType.Schema unless that SchemaRef is non-nil; malformed-but-valid media-type content must be rejected as a validation error.", + "support": 1, + "origin_fixes": [ + "68ac2affa325514d7d6e731204d6a1edf6bdff64" + ] + }, + { + "surface": "Resource Exhaustion — propSchema.Value.Items.Value (+2 related sinkes)", + "class": "resource_exhaustion", + "guard": [], + "guard_kind": "guard_added", + "sink_symbols": [ + "propSchema.Value.Items.Value", + "schemaRef.Value.Items", + "valueSchema.Value.Items" + ], + "entry_points": [ + "openapi3filter/array_items_nil_panic_test.go", + "openapi3filter/req_resp_decoder.go" + ], + "check_hint": "Every decoder that needs to interpret an array element must first ensure that the array has a non-nil, resolved Items schema; otherwise it must return an error.", + "support": 1, + "origin_fixes": [ + "30e292371a89ca6d1f91a9553e0aa518b49f838d" + ] + }, + { + "surface": "Resource Exhaustion — schema.Value.AllOf", + "class": "resource_exhaustion", + "guard": [], + "guard_kind": "guard_added", + "sink_symbols": [ + "schema.Value.AllOf" + ], + "entry_points": [ + "openapi3filter/ghsa_74vm_87hj_r66f_test.go", + "openapi3filter/req_resp_decoder.go" + ], + "check_hint": "A SchemaRef must be non-nil before any access to schema.Value.", + "support": 1, + "origin_fixes": [ + "f5441d67f855ed34d975b3b85665764644ab1459" + ] + }, + { + "surface": "Resource Exhaustion — append(result, nil) (+1 related sink)", + "class": "resource_exhaustion", + "guard": [ + "maxSliceMapToSliceGap" + ], + "guard_kind": "guard_added", + "sink_symbols": [ + "append(result, nil)", + "sliceMapToSlice" + ], + "entry_points": [ + "openapi3filter/req_resp_decoder.go" + ], + "check_hint": "A deepObject array's reconstructed length must remain bounded relative to the number of supplied elements, and array indexes must be non-negative.", + "support": 1, + "origin_fixes": [ + "1223a0f215d2cf9beb2d9eb9ea2649d001c21388" + ] + }, + { + "surface": "Resource Exhaustion — regexp.MustCompile(fmt.Sprintf", + "class": "resource_exhaustion", + "guard": [ + "paramPrefixRE" + ], + "guard_kind": "guard_added", + "sink_symbols": [ + "regexp.MustCompile(fmt.Sprintf" + ], + "entry_points": [ + "openapi3filter/req_resp_decoder.go" + ], + "check_hint": "A parameter-specific prefix matcher must be compiled at most once for a deepObject decoding operation, independently of the number of untrusted query keys.", + "support": 1, + "origin_fixes": [ + "d090e36b2f1c42c898ba2a687a8f6e25f5ca727f" + ] + } + ], + "fingerprints": [ + { + "id": "fp_94fcebf4ed", + "patch_id": "92c1e2499e90dc1b089cea95cc2806eba4fa9bb1", + "commit_sha": "94fcebf4edbfed0bd8818781f3be60dc2c196d85", + "commit_date": "2026-08-19T12:34:47+02:00", + "commit_subject": "openapi3: compare multipleOf on exact rationals, not fixed-precision decimals (#1252)", + "class": "input_validation", + "cwe": [ + "CWE-20" + ], + "components": [ + "openapi3/schema.go", + "openapi3/schema_test.go" + ], + "reachable_from": [ + "openapi3filter/req_resp_decoder.go", + "openapi3filter/validate_request.go", + "openapi3filter/validation_handler.go" + ], + "sink_symbols": [ + "fmt.Sprintf(\"%.10f\", *v)", + "fmt.Sprintf(\"%.10f\", value)" + ], + "guard_symbols": [ + "multipleOfFailure" + ], + "sink": "Fixed-precision formatting used as the multipleOf validation decision input.", + "fix_kind": "guard_added", + "fix_shape": "Replaces lossy fixed-ten-decimal formatting with an exact-rational multipleOf decision, rejecting non-multiples such as 1e-300 for multipleOf 0.01.", + "severity": { + "level": "medium", + "source": "model_estimate", + "score": 5.3, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N" + }, + "confidence": "high", + "summary": "Openapi3 multipleOf validation no longer accepts tiny nonzero values rounded to zero by fixed-precision formatting.", + "poc": "An attacker can make request validation accept a numeric value prohibited by the API schema, allowing it to reach the wrapped application handler.", + "root_cause": "The multipleOf validator rounded float operands to ten decimal places before determining whether their quotient was integral.", + "attacker_preconditions": "The application uses openapi3filter request validation with a positive multipleOf constraint, and an unauthenticated client can supply the corresponding JSON number or request parameter.", + "invariant": "Every accepted numeric instance must be an integer multiple of the schema's positive multipleOf value, subject only to bounded float representation error.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "parent", + "revision_sha": "e9e5e116a3878c25132543fb5591ece5b8e204fd", + "path": "openapi3/schema.go", + "start_line": 2562, + "end_line": 2569, + "quoted_span": "\t// \"multipleOf\"\n\tif v := schema.MultipleOf; v != nil {\n\t\t// \"A numeric instance is valid only if division by this keyword's\n\t\t// value results in an integer.\"\n\t\tnumRat, denRat := \u0026big.Rat{}, \u0026big.Rat{}\n\t\tnumRat.SetString(fmt.Sprintf(\"%.10f\", value))\n\t\tdenRat.SetString(fmt.Sprintf(\"%.10f\", *v))\n\t\tif !(\u0026big.Rat{}).Quo(numRat, denRat).IsInt() {", + "span_sha256": "1a141690cdc76ba8b1ba0fad6a9b7c6c48005f9b3b2288d5e290c099cd534d38", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "94fcebf4edbfed0bd8818781f3be60dc2c196d85", + "path": "openapi3/schema.go", + "start_line": 2408, + "end_line": 2435, + "quoted_span": "\texactValue := new(big.Rat).SetFloat64(value)\n\texactMultipleOf := new(big.Rat).SetFloat64(multipleOf)\n\tquotient := new(big.Rat).Quo(exactValue, exactMultipleOf)\n\tif quotient.IsInt() {\n\t\treturn \"\", true\n\t}\n\n\t// The quotient is not an integer, so floor and floor+1 bracket it. Both are\n\t// computed through big.Int, which no quotient can overflow. Compare the two\n\t// candidate multiples against the value itself rather than comparing the\n\t// quotient against an integer, so the window stays tied to the value's own\n\t// precision at every magnitude.\n\tfloor := new(big.Int).Div(quotient.Num(), quotient.Denom())\n\tvar distance *big.Rat\n\tfor _, k := range []*big.Int{floor, new(big.Int).Add(floor, big.NewInt(1))} {\n\t\tgap := new(big.Rat).Sub(exactValue, new(big.Rat).Mul(new(big.Rat).SetInt(k), exactMultipleOf))\n\t\tgap.Abs(gap)\n\t\tif distance == nil || gap.Cmp(distance) \u003c 0 {\n\t\t\tdistance = gap\n\t\t}\n\t}\n\n\tslack := new(big.Rat).SetFrac(big.NewInt(1), new(big.Int).Lsh(big.NewInt(1), multipleOfSlack))\n\ttolerance := new(big.Rat).Mul(new(big.Rat).Abs(exactValue), slack)\n\tif distance.Cmp(tolerance) \u003c= 0 {\n\t\treturn \"\", true\n\t}\n\treturn notAMultiple, false", + "span_sha256": "657a18de970ee4ab8822dc8763d5f49d858bfebaf1f7f7b65ccb0e28c48337be", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "94fcebf4edbfed0bd8818781f3be60dc2c196d85", + "path": "openapi3/schema_test.go", + "start_line": 1569, + "end_line": 1572, + "quoted_span": "\tt.Run(\"a value far below the multipleOf is not a multiple\", func(t *testing.T) {\n\t\trequire.ErrorContains(t, numberSchema(0.01).VisitJSON(1e-300),\n\t\t\t\"number must be a multiple of 0.01\")\n\t})", + "span_sha256": "595cfeffdc55061b970732ba723f145a3473d4113a998b2f1c2454f96f980cdb", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "94fcebf4edbfed0bd8818781f3be60dc2c196d85", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 1429, + "end_line": 1437, + "quoted_span": "func JSONBodyDecoder(body io.Reader, header http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) (any, error) {\n\tvar value any\n\tdec := json.NewDecoder(body)\n\tdec.UseNumber()\n\tif err := dec.Decode(\u0026value); err != nil {\n\t\treturn nil, \u0026ParseError{Kind: KindInvalidFormat, Cause: err}\n\t}\n\treturn value, nil\n}", + "span_sha256": "3b81795867b4c145a7556bacc95593f2eeda33af3274f887e2c8e779412dbacb", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "94fcebf4edbfed0bd8818781f3be60dc2c196d85", + "path": "openapi3/schema.go", + "start_line": 1996, + "end_line": 2008, + "quoted_span": "\tcase json.Number:\n\t\tvalueFloat64, err := value.Float64()\n\t\tif err != nil {\n\t\t\treturn \u0026SchemaError{\n\t\t\t\tValue: value,\n\t\t\t\tSchema: schema,\n\t\t\t\tSchemaField: \"type\",\n\t\t\t\tReason: \"cannot convert json.Number to float64\",\n\t\t\t\tcustomizeMessageError: settings.customizeMessageError,\n\t\t\t\tOrigin: err,\n\t\t\t}\n\t\t}\n\t\treturn schema.visitJSONNumber(settings, valueFloat64)", + "span_sha256": "4a69c36bc6156c8c3f20cf5db17053857106e69c0631354cab06a9b867cd23ad", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "94fcebf4edbfed0bd8818781f3be60dc2c196d85", + "path": "openapi3filter/validate_request.go", + "start_line": 250, + "end_line": 253, + "quoted_span": "\tif err = schema.VisitJSON(value, opts...); err != nil {\n\t\treturn \u0026RequestError{Input: input, Parameter: parameter, Err: err}\n\t}\n\treturn nil", + "span_sha256": "0a6f73e15735dcbad8769783e3449866cc20f23854d20c3a840417e51cc4e6f9", + "verified": true, + "relocated": true + } + ], + "reachability": { + "verdict": "narrows", + "before": "A JSON number such as 1e-300 could pass a positive multipleOf: 0.01 request constraint after fixed-precision formatting rounded it to zero.", + "after": "The same request-validation paths return a schema error for non-multiples instead of passing them to the application." + }, + "failure_containment": "degraded", + "introduced_by": [ + { + "commit_sha": "d4bdebd22566dd9c871036eb39343033acd8c6bc", + "description": "fixed-precision multipleOf conversion", + "committed_at": "2025-05-09T02:19:30+02:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 40385717 + }, + { + "id": "fp_eda80e2676", + "patch_id": "6a6fc53cd8f9e521e13f81a9112a2282424a221a", + "commit_sha": "eda80e2676e9f577ceed2dd80e64f16083edb041", + "commit_date": "2026-08-18T16:12:06+02:00", + "commit_subject": "Merge commit from fork", + "class": "access_control", + "cwe": [ + "CWE-863" + ], + "components": [ + ".github/docs/openapi3filter.txt", + "openapi3filter/encoded_path_route_confusion_test.go", + "openapi3filter/issue1105_test.go", + "openapi3filter/testdata/encoded_path_authorization_bypass.yml", + "openapi3filter/validation_handler.go" + ], + "reachable_from": [ + "routers/legacy/router.go" + ], + "sink_symbols": [ + "h.Handler.ServeHTTP", + "next.ServeHTTP" + ], + "guard_symbols": [ + "ErrEncodedPathSeparator", + "checkEncodedPathSeparator" + ], + "sink": "Forwarding the original request to the downstream handler after validation.", + "fix_kind": "guard_added", + "fix_shape": "Rejects encoded-slash requests when the route selected from an escaped-equivalent shadow request differs from the decoded route, before authentication/validation and downstream dispatch. The analogous Validator.Middleware path remains unguarded.", + "severity": { + "level": "high", + "source": "model_estimate", + "score": 8.1, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N" + }, + "confidence": "high", + "summary": "Fixes an encoded-path route-confusion authorization bypass in ValidationHandler.", + "poc": "An unauthenticated remote client could cause validation to select an unauthenticated public operation while the encoded-path downstream router executes a protected operation.", + "root_cause": "ValidationHandler trusted a decoded-path route lookup for authorization while forwarding the original escaped request to a downstream router that may match encoded separators differently.", + "attacker_preconditions": "The application uses ValidationHandler with an OpenAPI document where a public decoded route collides with a protected parameterized route, and dispatches to gorilla/mux configured with UseEncodedPath; the attacker can send a request containing %2F.", + "invariant": "The operation whose security requirements are validated must be the same operation that receives the request from the downstream router.", + "fix_completeness": "partial", + "anchors": [ + { + "revision": "commit", + "revision_sha": "eda80e2676e9f577ceed2dd80e64f16083edb041", + "path": "openapi3filter/validation_handler.go", + "start_line": 83, + "end_line": 87, + "quoted_span": "// ErrEncodedPathSeparator is returned when a request path holds an encoded path\n// separator and the decoded path resolves to another operation than the escaped\n// path does. Serving such a request would validate one operation while the\n// wrapped handler serves the other one.\nvar ErrEncodedPathSeparator error = \u0026routers.RouteError{Reason: \"path contains an encoded path separator resolving to another operation\"}", + "span_sha256": "483918177e94d4680396aeaeb04e081cb2a0d8dbce66419499a4fb902ee7ad51", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "eda80e2676e9f577ceed2dd80e64f16083edb041", + "path": "openapi3filter/validation_handler.go", + "start_line": 89, + "end_line": 113, + "quoted_span": "func (h *ValidationHandler) validateRequest(r *http.Request) error {\n\t// Find route\n\troute, pathParams, err := h.router.FindRoute(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err := h.checkEncodedPathSeparator(r, route); err != nil {\n\t\treturn err\n\t}\n\n\toptions := \u0026Options{\n\t\tAuthenticationFunc: h.AuthenticationFunc,\n\t}\n\n\t// Validate request\n\trequestValidationInput := \u0026RequestValidationInput{\n\t\tRequest: r,\n\t\tPathParams: pathParams,\n\t\tRoute: route,\n\t\tOptions: options,\n\t}\n\tif err = ValidateRequest(r.Context(), requestValidationInput); err != nil {\n\t\treturn err\n\t}", + "span_sha256": "08a209d605e89a07f3c5023a8d83dacc82e573973db525462a87a8f8b18f73a2", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "eda80e2676e9f577ceed2dd80e64f16083edb041", + "path": "openapi3filter/validation_handler.go", + "start_line": 118, + "end_line": 141, + "quoted_span": "// checkEncodedPathSeparator rejects a request whose encoded path separators make\n// r.URL.Path and r.URL.RawPath select different operations. The router matches\n// on the decoded path while routers such as gorilla/mux with UseEncodedPath\n// match on the escaped one, so the operation validated here — its security\n// requirements included — would not be the operation the wrapped handler serves.\nfunc (h *ValidationHandler) checkEncodedPathSeparator(r *http.Request, route *routers.Route) error {\n\tescapedPath := r.URL.EscapedPath()\n\tsegment := escapedPath\n\tfor _, separator := range []string{\"%2F\", \"%2f\"} {\n\t\tsegment = strings.ReplaceAll(segment, separator, \"~\")\n\t}\n\tif segment == escapedPath {\n\t\treturn nil\n\t}\n\n\tshadow := r.Clone(r.Context())\n\tshadow.URL.Path, shadow.URL.RawPath = segment, \"\"\n\tescapedRoute, _, err := h.router.FindRoute(shadow)\n\tif err != nil ||\n\t\tescapedRoute.Method != route.Method ||\n\t\tescapedRoute.Path != route.Path ||\n\t\tescapedRoute.Operation != route.Operation {\n\t\treturn ErrEncodedPathSeparator\n\t}", + "span_sha256": "a02f5a979a33e26caa32f8b9db2e62e8b3a53b59810d26847fc6fd0c3512f997", + "verified": true, + "relocated": false + }, + { + "revision": "parent", + "revision_sha": "435c5db68d177dc136c98849a3783bf79087b27e", + "path": "openapi3filter/validation_handler.go", + "start_line": 63, + "end_line": 72, + "quoted_span": "// Middleware implements gorilla/mux MiddlewareFunc\nfunc (h *ValidationHandler) Middleware(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif handled := h.before(w, r); handled {\n\t\t\treturn\n\t\t}\n\t\t// TODO: validateResponse\n\t\tnext.ServeHTTP(w, r)\n\t})\n}", + "span_sha256": "32197d30e2f744001278dab52005fd05bff8ef7a6a2f92ac8b75cd1fa52bee98", + "verified": true, + "relocated": true + }, + { + "revision": "parent", + "revision_sha": "435c5db68d177dc136c98849a3783bf79087b27e", + "path": "openapi3filter/validation_handler.go", + "start_line": 82, + "end_line": 100, + "quoted_span": "func (h *ValidationHandler) validateRequest(r *http.Request) error {\n\t// Find route\n\troute, pathParams, err := h.router.FindRoute(r)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\toptions := \u0026Options{\n\t\tAuthenticationFunc: h.AuthenticationFunc,\n\t}\n\n\t// Validate request\n\trequestValidationInput := \u0026RequestValidationInput{\n\t\tRequest: r,\n\t\tPathParams: pathParams,\n\t\tRoute: route,\n\t\tOptions: options,\n\t}\n\tif err = ValidateRequest(r.Context(), requestValidationInput); err != nil {", + "span_sha256": "c4ede6db78f3d5223235a730227785bae2f18d10b0c4e08110a742ed58899b65", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "eda80e2676e9f577ceed2dd80e64f16083edb041", + "path": "openapi3filter/encoded_path_route_confusion_test.go", + "start_line": 22, + "end_line": 49, + "quoted_span": "\thandler := \u0026openapi3filter.ValidationHandler{\n\t\tFile: \"testdata/encoded_path_authorization_bypass.yml\",\n\t\tAuthenticationFunc: func(_ context.Context, input *openapi3filter.AuthenticationInput) error {\n\t\t\tauthCalls.Add(1)\n\t\t\tif input.RequestValidationInput.Request.Header.Get(\"X-API-Key\") != \"good\" {\n\t\t\t\treturn input.NewError(errors.New(\"missing or invalid X-API-Key\"))\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t\tErrorEncoder: func(_ context.Context, _ error, w http.ResponseWriter) {\n\t\t\tw.WriteHeader(http.StatusUnauthorized)\n\t\t},\n\t}\n\terr := handler.Load()\n\trequire.NoError(t, err)\n\n\tdownstream := mux.NewRouter().UseEncodedPath()\n\tdownstream.HandleFunc(\"/public/status\", func(w http.ResponseWriter, _ *http.Request) {\n\t\tdownstreamRoute.Store(\"/public/status\")\n\t\tw.WriteHeader(http.StatusNoContent)\n\t}).Methods(http.MethodGet)\n\tdownstream.HandleFunc(\"/{id}\", func(w http.ResponseWriter, r *http.Request) {\n\t\tdownstreamRoute.Store(\"/{id}\")\n\t\tprotectedID.Store(mux.Vars(r)[\"id\"])\n\t\tw.WriteHeader(http.StatusAccepted)\n\t}).Methods(http.MethodGet)\n\n\tserver := httptest.NewServer(handler.Middleware(downstream))", + "span_sha256": "a0695a4163092afa8832cb484d23a8a3546dab15720dbee80c57a23202f9a01a", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "eda80e2676e9f577ceed2dd80e64f16083edb041", + "path": "openapi3filter/encoded_path_route_confusion_test.go", + "start_line": 76, + "end_line": 85, + "quoted_span": "\tt.Run(\"encoded slash skips authentication and reaches protected handler\", func(t *testing.T) {\n\t\tbefore := authCalls.Load()\n\t\tstatus := request(\"/public%2Fstatus\")\n\t\trequire.Equal(t, http.StatusUnauthorized, status)\n\t\troute, _ := downstreamRoute.Load().(string)\n\t\trequire.Equal(t, \"/public/status\", route)\n\t\tid, _ := protectedID.Load().(string)\n\t\trequire.Equal(t, \"\", id)\n\t\trequire.Equal(t, before, authCalls.Load())\n\t})", + "span_sha256": "cec1d0078d737952977bf91d09560931c58543881e249e32fff247995b30e7e9", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "eda80e2676e9f577ceed2dd80e64f16083edb041", + "path": "openapi3filter/middleware.go", + "start_line": 119, + "end_line": 147, + "quoted_span": "func (v *Validator) Middleware(h http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tctx := r.Context()\n\t\troute, pathParams, err := v.router.FindRoute(r)\n\t\tif err != nil {\n\t\t\tv.logFunc(ctx, \"validation error: failed to find route for \"+r.URL.String(), err)\n\t\t\tv.errFunc(ctx, w, http.StatusNotFound, ErrCodeCannotFindRoute, err)\n\t\t\treturn\n\t\t}\n\t\trequestValidationInput := \u0026RequestValidationInput{\n\t\t\tRequest: r,\n\t\t\tPathParams: pathParams,\n\t\t\tRoute: route,\n\t\t\tOptions: \u0026v.options,\n\t\t}\n\t\tif err = ValidateRequest(ctx, requestValidationInput); err != nil {\n\t\t\tv.logFunc(ctx, \"invalid request\", err)\n\t\t\tv.errFunc(ctx, w, http.StatusBadRequest, ErrCodeRequestInvalid, err)\n\t\t\treturn\n\t\t}\n\n\t\tvar wr responseWrapper\n\t\tif v.strict {\n\t\t\twr = \u0026strictResponseWrapper{w: w}\n\t\t} else {\n\t\t\twr = newWarnResponseWrapper(w)\n\t\t}\n\n\t\th.ServeHTTP(wr, r)", + "span_sha256": "40ecb8ff65266c3da755e515ca6f0bc704e4fb76d89440ceae27856eb3943df4", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "eda80e2676e9f577ceed2dd80e64f16083edb041", + "path": "routers/legacy/router.go", + "start_line": 107, + "end_line": 118, + "quoted_span": "// FindRoute extracts the route and parameters of an http.Request\nfunc (router *Router) FindRoute(req *http.Request) (*routers.Route, map[string]string, error) {\n\tmethod, url := req.Method, req.URL\n\tdoc := router.doc\n\n\t// Get server\n\tservers := doc.Servers\n\tvar server *openapi3.Server\n\tvar remainingPath string\n\tvar pathParams map[string]string\n\tif len(servers) == 0 {\n\t\tremainingPath = url.Path", + "span_sha256": "3d17770000d675c29243d903eddf8d8308056f96a35bcbc5e076caafe05315e1", + "verified": true, + "relocated": true + } + ], + "reachability": { + "verdict": "narrows", + "before": "A request such as /public%2Fstatus could validate as the public /public/status operation yet be dispatched by an encoded-path router to the protected /{id} operation.", + "after": "Encoded separators that would select a different escaped-path operation are stopped by the error encoder before the wrapped handler is invoked." + }, + "failure_containment": "degraded", + "introduced_by": [ + { + "commit_sha": "55d6baed46488585dd9a5a4f31dbe0ce03ef5592", + "description": "ValidationHandler direct Handler dispatch", + "committed_at": "2020-05-25T00:47:57+02:00", + "verified": true + }, + { + "commit_sha": "26e9a3787488e3608f86913af5fdee929632e3e5", + "description": "ValidationHandler gorilla/mux Middleware dispatch", + "committed_at": "2020-06-15T13:07:01+02:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 196701849 + }, + { + "id": "fp_d33e44e677", + "patch_id": "825bfb590177bd3a3eccc15bf620c57c975f6a0a", + "commit_sha": "d33e44e67734dcde81b576582185e1343fb3985d", + "commit_date": "2026-08-14T09:19:35+02:00", + "commit_subject": "openapi3: stop validation panicking on an uncompilable pattern (#1245)", + "class": "other", + "cwe": [ + "CWE-476" + ], + "components": [ + "openapi3/issue1044_test.go", + "openapi3/schema.go", + "openapi3/schema_pattern.go" + ], + "reachable_from": [ + "openapi3filter/validate_request.go" + ], + "sink_symbols": [ + "cp.MatchString(value)" + ], + "sink": "Invocation of RegexMatcher.MatchString on a failed pattern compiler result.", + "fix_kind": "guard_added", + "fix_shape": "Compilation failure now clears a typed-nil matcher and matching occurs only with a non-nil matcher, so multi-error validation returns the compilation error instead of panicking.", + "severity": { + "level": "low", + "source": "model_estimate" + }, + "confidence": "high", + "summary": "Prevents a request-triggerable nil-pointer panic during multi-error validation of an uncompilable OpenAPI regex pattern.", + "poc": "Remote denial of service: a request reaching the affected schema could panic the Go process when multi-error validation was enabled.", + "root_cause": "The shared pattern-validation refactor introduced in 98141fa4 continued after a compile error in multi-error mode and unconditionally invoked MatchString on the compiler result; conversion of a nil *regexp.Regexp to RegexMatcher made that result a non-nil interface containing a nil pointer.", + "attacker_preconditions": "The application validates attacker-controlled request parameters or bodies with openapi3filter.Options.MultiError enabled and a reachable string schema contains a pattern rejected by the configured regex compiler (including Go RE2).", + "invariant": "A regex matcher must be non-nil before MatchString is called; a pattern compilation failure must remain a validation error.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "commit", + "revision_sha": "d33e44e67734dcde81b576582185e1343fb3985d", + "path": "openapi3/issue1044_test.go", + "start_line": 12, + "end_line": 24, + "quoted_span": "// Go's regexp is RE2 and rejects the lookarounds ECMA 262 allows, so this\n// pattern cannot be compiled.\nconst issue1044Pattern = `^((?!-)[A-Za-z0-9-]{1,63}(?\u003c!-)\\.)+[A-Za-z]{2,6}$`\n\nfunc TestIssue1044(t *testing.T) {\n\tschema := openapi3.NewStringSchema().WithPattern(issue1044Pattern)\n\n\terr := schema.VisitJSON(\"example.com\", openapi3.MultiErrors())\n\n\tvar patternErr *openapi3.SchemaPatternRegexError\n\trequire.ErrorAs(t, err, \u0026patternErr)\n\trequire.Equal(t, issue1044Pattern, patternErr.Pattern)\n}", + "span_sha256": "3c560a6cb05d8a14b4f610a0e112403dc66cf0847cbab99398be6b755bbed132", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "d33e44e67734dcde81b576582185e1343fb3985d", + "path": "openapi3/schema_pattern.go", + "start_line": 23, + "end_line": 34, + "quoted_span": "\tif err != nil {\n\t\tschemaErr := \u0026SchemaError{\n\t\t\tSchema: schema,\n\t\t\tSchemaField: \"pattern\",\n\t\t\tOrigin: err,\n\t\t\tReason: fmt.Sprintf(\"cannot compile pattern %q: %v\", pattern, err),\n\t\t}\n\t\t// A failed compile can yield a typed nil, which no call site's nil check catches.\n\t\tcp = nil\n\t\terr = newSchemaPatternRegexError(pattern, schemaErr, schema.Origin)\n\t\treturn\n\t}", + "span_sha256": "e09a4fc4c9fd1ca9c8d32c8a996abd5d2cbbf0f1818f0f466e34b3faedee45d3", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "d33e44e67734dcde81b576582185e1343fb3985d", + "path": "openapi3/schema.go", + "start_line": 2666, + "end_line": 2678, + "quoted_span": "\t\tif cp != nil \u0026\u0026 !cp.MatchString(value) {\n\t\t\terr := \u0026SchemaError{\n\t\t\t\tValue: value,\n\t\t\t\tSchema: schema,\n\t\t\t\tSchemaField: \"pattern\",\n\t\t\t\tReason: fmt.Sprintf(`string doesn't match the regular expression \"%s\"`, schema.Pattern),\n\t\t\t\tcustomizeMessageError: settings.customizeMessageError,\n\t\t\t}\n\t\t\tif !settings.multiError {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tme = append(me, err)\n\t\t}", + "span_sha256": "c6c56ca02ca34e9032413950790d085548c62e13d2aeb56d766b5b8daae72883", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "d33e44e67734dcde81b576582185e1343fb3985d", + "path": "openapi3filter/validate_request.go", + "start_line": 240, + "end_line": 250, + "quoted_span": "\tvar opts []openapi3.SchemaValidationOption\n\tif options.MultiError {\n\t\topts = append(opts, openapi3.MultiErrors())\n\t}\n\tif options.customSchemaErrorFunc != nil {\n\t\topts = append(opts, openapi3.SetSchemaErrorMessageCustomizer(options.customSchemaErrorFunc))\n\t}\n\tif input.Route != nil \u0026\u0026 input.Route.Spec.IsOpenAPI31OrLater() {\n\t\topts = append(opts, openapi3.EnableJSONSchema2020())\n\t}\n\tif err = schema.VisitJSON(value, opts...); err != nil {", + "span_sha256": "4c4d55ed9c7ec82f413a8d3627c78eb826c0bec30b15ffa2593f8d85e47b0c9e", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "d33e44e67734dcde81b576582185e1343fb3985d", + "path": "openapi3filter/validate_request.go", + "start_line": 345, + "end_line": 364, + "quoted_span": "\tif options.MultiError {\n\t\topts = append(opts, openapi3.MultiErrors())\n\t}\n\tif options.customSchemaErrorFunc != nil {\n\t\topts = append(opts, openapi3.SetSchemaErrorMessageCustomizer(options.customSchemaErrorFunc))\n\t}\n\tif options.ExcludeReadOnlyValidations {\n\t\topts = append(opts, openapi3.DisableReadOnlyValidation())\n\t}\n\tif options.RegexCompiler != nil {\n\t\topts = append(opts, openapi3.SetSchemaRegexCompiler(options.RegexCompiler))\n\t}\n\t// Append additional schema validation options (e.g., document-scoped format validators)\n\topts = append(opts, options.SchemaValidationOptions...)\n\tif input.Route != nil \u0026\u0026 input.Route.Spec.IsOpenAPI31OrLater() {\n\t\topts = append(opts, openapi3.EnableJSONSchema2020())\n\t}\n\n\t// Validate JSON with the schema\n\tif err := contentType.Schema.Value.VisitJSON(value, opts...); err != nil {", + "span_sha256": "cbdcaca3470131a879d8e113bfbe3dabb93d3fd3e4546bef47674b7348576e41", + "verified": true, + "relocated": false + } + ], + "reachability": { + "verdict": "narrows", + "before": "A request value reaching a schema with an uncompilable pattern and multi-error enabled reached a nil matcher method call and panicked.", + "after": "The same request reaches a SchemaPatternRegexError returned to the validation caller." + }, + "failure_containment": "crashes", + "introduced_by": [ + { + "commit_sha": "98141fa4ce8aa2c798b3c0a680707eb46d91847a", + "description": "shared compiled-pattern validation path", + "committed_at": "2023-06-21T10:53:57+02:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 99354338 + }, + { + "id": "fp_f5441d67f8", + "patch_id": "2d1387b286db794ffd2eb7dc6e1bb0ec8df005bf", + "commit_sha": "f5441d67f855ed34d975b3b85665764644ab1459", + "commit_date": "2026-08-03T12:58:07+02:00", + "commit_subject": "Merge commit from fork", + "class": "resource_exhaustion", + "cwe": [ + "CWE-476" + ], + "components": [ + "openapi3filter/ghsa_74vm_87hj_r66f_test.go", + "openapi3filter/req_resp_decoder.go" + ], + "reachable_from": [ + "openapi3filter/middleware.go" + ], + "sink_symbols": [ + "schema.Value.AllOf" + ], + "sink": "Nil dereference of schema.Value.AllOf in decodeValue.", + "fix_kind": "guard_added", + "fix_shape": "Short-circuits schema-less values before dereferencing SchemaRef.Value, allowing response validation to return normally.", + "severity": { + "level": "high", + "source": "model_estimate", + "score": 7.5, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + "confidence": "high", + "summary": "Fixes a remotely triggerable nil-pointer denial of service in response-header validation.", + "poc": "A remote request can trigger an unhandled nil-pointer panic during response validation, causing denial of service for the serving request/process.", + "root_cause": "Response-header validation assumed every Header used Schema, but OpenAPI permits the mutually exclusive content form, whose MediaType may omit Schema.", + "attacker_preconditions": "A service uses openapi3filter response validation (including Validator.Middleware) with a valid OpenAPI response header declared through content whose media type omits schema; the attacker can send a non-HEAD request that reaches that operation and passes request validation.", + "invariant": "A SchemaRef must be non-nil before any access to schema.Value.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "commit", + "revision_sha": "f5441d67f855ed34d975b3b85665764644ab1459", + "path": "openapi3/header.go", + "start_line": 73, + "end_line": 92, + "quoted_span": "\tif (header.Schema == nil) == (len(header.Content) == 0) {\n\t\treturn \u0026HeaderFieldValidationError{Field: \"schema\",\n\t\t\tCause: newHeaderContentSchemaExactlyOne(header, header.Origin)}\n\t}\n\tif schema := header.Schema; schema != nil {\n\t\tif err := schema.Validate(ctx); err != nil {\n\t\t\treturn \u0026HeaderFieldValidationError{Field: \"schema\", Cause: err}\n\t\t}\n\t}\n\n\tif content := header.Content; content != nil {\n\t\tif len(content) \u003e 1 {\n\t\t\treturn \u0026HeaderFieldValidationError{Field: \"content\",\n\t\t\t\tCause: newHeaderContentSingleEntry(header.Origin)}\n\t\t}\n\n\t\tif err := content.Validate(ctx); err != nil {\n\t\t\treturn \u0026HeaderFieldValidationError{Field: \"content\", Cause: err}\n\t\t}\n\t}", + "span_sha256": "c8681a84c2ec6c659d4fc28d7161bd82cc07272231fcdcdcb3e1f7db5df10f79", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "f5441d67f855ed34d975b3b85665764644ab1459", + "path": "openapi3filter/ghsa_74vm_87hj_r66f_test.go", + "start_line": 30, + "end_line": 50, + "quoted_span": "const specHeader = `\nopenapi: 3.0.3\ninfo: {title: poc, version: \"1.0.0\"}\npaths:\n /x:\n get:\n responses:\n \"200\":\n description: ok\n headers:\n X-Thing:\n content:\n application/json: {}\n`\n\nfunc validatedInput(t *testing.T, spec string, hdr http.Header) *openapi3filter.ResponseValidationInput {\n\tt.Helper()\n\tdoc, err := openapi3.NewLoader().LoadFromData([]byte(spec))\n\trequire.NoError(t, err)\n\terr = doc.Validate(t.Context())\n\trequire.NoError(t, err)", + "span_sha256": "ff770dd41bd1733b3fe92779d1208f20a3fe144152a87b85b9309c46c34dbcac", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "f5441d67f855ed34d975b3b85665764644ab1459", + "path": "openapi3filter/ghsa_74vm_87hj_r66f_test.go", + "start_line": 69, + "end_line": 73, + "quoted_span": "func TestResponseHeaderNilSchema(t *testing.T) {\n\tin := validatedInput(t, specHeader, http.Header{\"Content-Type\": {\"application/json\"}})\n\terr := openapi3filter.ValidateResponse(t.Context(), in)\n\trequire.NoError(t, err)\n}", + "span_sha256": "6129d49c6be6d63f0f73410f7b9d063d854a860a5709ff99ac17bbbf532bdcd1", + "verified": true, + "relocated": true + }, + { + "revision": "parent", + "revision_sha": "d090e36b2f1c42c898ba2a687a8f6e25f5ca727f", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 270, + "end_line": 273, + "quoted_span": "func decodeValue(dec valueDecoder, param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef, required bool) (any, bool, error) {\n\tvar found bool\n\n\tif len(schema.Value.AllOf) \u003e 0 {", + "span_sha256": "fe54c84e7251b197dec29cab8370b1c21206452d7c0ad68f43d11da73a812f1b", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "f5441d67f855ed34d975b3b85665764644ab1459", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 270, + "end_line": 275, + "quoted_span": "func decodeValue(dec valueDecoder, param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef, required bool) (any, bool, error) {\n\tvar found bool\n\n\tif schema == nil {\n\t\treturn nil, false, nil\n\t}", + "span_sha256": "a61e40171d3403f21cd11ebe3f31ee2c02c737e7bb4495e73083fb3d260d4465", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "f5441d67f855ed34d975b3b85665764644ab1459", + "path": "openapi3filter/middleware.go", + "start_line": 117, + "end_line": 161, + "quoted_span": "// Middleware returns an http.Handler which wraps the given handler with\n// request and response validation.\nfunc (v *Validator) Middleware(h http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tctx := r.Context()\n\t\troute, pathParams, err := v.router.FindRoute(r)\n\t\tif err != nil {\n\t\t\tv.logFunc(ctx, \"validation error: failed to find route for \"+r.URL.String(), err)\n\t\t\tv.errFunc(ctx, w, http.StatusNotFound, ErrCodeCannotFindRoute, err)\n\t\t\treturn\n\t\t}\n\t\trequestValidationInput := \u0026RequestValidationInput{\n\t\t\tRequest: r,\n\t\t\tPathParams: pathParams,\n\t\t\tRoute: route,\n\t\t\tOptions: \u0026v.options,\n\t\t}\n\t\tif err = ValidateRequest(ctx, requestValidationInput); err != nil {\n\t\t\tv.logFunc(ctx, \"invalid request\", err)\n\t\t\tv.errFunc(ctx, w, http.StatusBadRequest, ErrCodeRequestInvalid, err)\n\t\t\treturn\n\t\t}\n\n\t\tvar wr responseWrapper\n\t\tif v.strict {\n\t\t\twr = \u0026strictResponseWrapper{w: w}\n\t\t} else {\n\t\t\twr = newWarnResponseWrapper(w)\n\t\t}\n\n\t\th.ServeHTTP(wr, r)\n\n\t\tif err = ValidateResponse(ctx, \u0026ResponseValidationInput{\n\t\t\tRequestValidationInput: requestValidationInput,\n\t\t\tStatus: wr.statusCode(),\n\t\t\tHeader: wr.Header(),\n\t\t\tBody: io.NopCloser(bytes.NewBuffer(wr.bodyContents())),\n\t\t\tOptions: \u0026v.options,\n\t\t}); err != nil {\n\t\t\tv.logFunc(ctx, \"invalid response\", err)\n\t\t\tif v.strict {\n\t\t\t\tv.errFunc(ctx, w, http.StatusInternalServerError, ErrCodeResponseInvalid, err)\n\t\t\t}\n\t\t\treturn\n\t\t}", + "span_sha256": "a3c1239ddea9d00e5efd618c0978a0ce268cfdad465c671c1f3e6435947206e3", + "verified": true, + "relocated": true + } + ], + "reachability": { + "verdict": "narrows", + "before": "A request routed through Validator.Middleware reaches ValidateResponse, whose schema-less response header reaches schema.Value.AllOf and panics.", + "after": "The response-validation path returns without decoding or dereferencing a nil schema." + }, + "failure_containment": "crashes", + "introduced_by": [ + { + "commit_sha": "9ea22aedcb3f9fa0b06acb08981c11bc8fde4269", + "description": "response-header validation", + "committed_at": "2022-10-28T16:40:47+02:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 118786640 + }, + { + "id": "fp_d090e36b2f", + "patch_id": "ea28b0f8c01a1a8aab09bb7d1b36561db0ada0e5", + "commit_sha": "d090e36b2f1c42c898ba2a687a8f6e25f5ca727f", + "commit_date": "2026-08-03T12:27:15+02:00", + "commit_subject": "Merge commit from fork", + "class": "resource_exhaustion", + "cwe": [ + "CWE-400" + ], + "components": [ + "openapi3filter/req_resp_decoder.go" + ], + "reachable_from": [ + "openapi3filter/middleware.go", + "openapi3filter/validate_request.go", + "openapi3filter/validate_request_input.go", + "openapi3filter/validation_handler.go" + ], + "sink_symbols": [ + "regexp.MustCompile(fmt.Sprintf" + ], + "guard_symbols": [ + "paramPrefixRE" + ], + "sink": "Repeated regular-expression compilation in the deepObject query-key loop.", + "fix_kind": "guard_added", + "fix_shape": "Compiles the parameter-prefix regular expression once per DecodeObject invocation rather than once for every supplied query key.", + "severity": { + "level": "high", + "source": "model_estimate", + "score": 7.5, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + "confidence": "high", + "summary": "Fixes request-validation resource exhaustion from recompiling a deepObject parameter regex for every query key.", + "poc": "A crafted request with many query keys could cause excessive CPU use and regex-compilation allocations during validation, reducing service availability before the wrapped handler runs.", + "root_cause": "The deepObject decoder compiled an invariant regular expression inside a loop over attacker-controlled query keys.", + "attacker_preconditions": "The attacker can send a request that reaches request validation for an operation configured with an object query parameter using the deepObject serialization style; the request supplies many distinct query keys.", + "invariant": "A parameter-specific prefix matcher must be compiled at most once for a deepObject decoding operation, independently of the number of untrusted query keys.", + "fix_completeness": "partial", + "anchors": [ + { + "revision": "head", + "revision_sha": "d090e36b2f1c42c898ba2a687a8f6e25f5ca727f", + "path": "openapi3filter/validate_request_input.go", + "start_line": 31, + "end_line": 38, + "quoted_span": "func (input *RequestValidationInput) GetQueryParams() url.Values {\n\tq := input.QueryParams\n\tif q == nil {\n\t\tq = input.Request.URL.Query()\n\t\tinput.QueryParams = q\n\t}\n\treturn q\n}", + "span_sha256": "1a375b5f82f68ef9b2db3ae28056fed60f5ef7ee9e9eaafb78ad81c3156c1443", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "d090e36b2f1c42c898ba2a687a8f6e25f5ca727f", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 254, + "end_line": 344, + "quoted_span": "\tcase openapi3.ParameterInQuery:\n\t\tif len(input.GetQueryParams()) == 0 {\n\t\t\treturn nil, false, nil\n\t\t}\n\t\tdec = \u0026urlValuesDecoder{values: input.GetQueryParams()}\n\tcase openapi3.ParameterInHeader:\n\t\tdec = \u0026headerParamDecoder{header: input.Request.Header}\n\tcase openapi3.ParameterInCookie:\n\t\tdec = \u0026cookieParamDecoder{req: input.Request}\n\tdefault:\n\t\treturn nil, false, fmt.Errorf(\"unsupported parameter's 'in': %s\", param.In)\n\t}\n\n\treturn decodeValue(dec, param.Name, sm, param.Schema, param.Required)\n}\n\nfunc decodeValue(dec valueDecoder, param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef, required bool) (any, bool, error) {\n\tvar found bool\n\n\tif len(schema.Value.AllOf) \u003e 0 {\n\t\tvar value any\n\t\tvar err error\n\t\tfor _, sr := range schema.Value.AllOf {\n\t\t\tvar f bool\n\t\t\tvalue, f, err = decodeValue(dec, param, sm, sr, required)\n\t\t\tfound = found || f\n\t\t\tif value == nil || err != nil {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\treturn value, found, err\n\t}\n\n\tif len(schema.Value.AnyOf) \u003e 0 {\n\t\tfor _, sr := range schema.Value.AnyOf {\n\t\t\tvalue, f, _ := decodeValue(dec, param, sm, sr, required)\n\t\t\tfound = found || f\n\t\t\tif value != nil {\n\t\t\t\treturn value, found, nil\n\t\t\t}\n\t\t}\n\t\tif required {\n\t\t\treturn nil, found, fmt.Errorf(\"decoding anyOf for parameter %q failed\", param)\n\t\t}\n\t\treturn nil, found, nil\n\t}\n\n\tif len(schema.Value.OneOf) \u003e 0 {\n\t\tisMatched := 0\n\t\tvar value any\n\t\tfor _, sr := range schema.Value.OneOf {\n\t\t\tv, f, _ := decodeValue(dec, param, sm, sr, required)\n\t\t\tfound = found || f\n\t\t\tif v != nil {\n\t\t\t\tvalue = v\n\t\t\t\tisMatched++\n\t\t\t}\n\t\t}\n\t\tif isMatched \u003e= 1 {\n\t\t\treturn value, found, nil\n\t\t}\n\t\tif required {\n\t\t\treturn nil, found, fmt.Errorf(\"decoding oneOf failed: %q is required\", param)\n\t\t}\n\t\treturn nil, found, nil\n\t}\n\n\tif schema.Value.Not != nil {\n\t\t// TODO(decode not): handle decoding \"not\" JSON Schema\n\t\treturn nil, found, errors.New(\"not implemented: decoding 'not'\")\n\t}\n\n\tif schema.Value.Type != nil {\n\t\tvar decodeFn func(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (any, bool, error)\n\t\tswitch {\n\t\tcase schema.Value.Type.Is(\"array\"):\n\t\t\tdecodeFn = func(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (any, bool, error) {\n\t\t\t\tres, b, e := dec.DecodeArray(param, sm, schema)\n\t\t\t\tif len(res) == 0 {\n\t\t\t\t\treturn nil, b, e\n\t\t\t\t}\n\t\t\t\treturn res, b, e\n\t\t\t}\n\t\tcase schema.Value.Type.Is(\"object\"):\n\t\t\tdecodeFn = func(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (any, bool, error) {\n\t\t\t\treturn dec.DecodeObject(param, sm, schema)\n\t\t\t}\n\t\tdefault:\n\t\t\tdecodeFn = dec.DecodePrimitive\n\t\t}\n\t\treturn decodeFn(param, sm, schema)", + "span_sha256": "a3528bf935d9873a39329f1e6ee42f5482ffd6e3e44565a89de707ea3a5813a5", + "verified": true, + "relocated": false + }, + { + "revision": "parent", + "revision_sha": "947498e5a6bd4d8424522052f8ef6c1bb556d47e", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 686, + "end_line": 693, + "quoted_span": "\tcase \"deepObject\":\n\t\tpropsFn = func(params url.Values) (map[string]string, error) {\n\t\t\tprops := make(map[string]string)\n\t\t\tfor key, values := range params {\n\t\t\t\tif !regexp.MustCompile(fmt.Sprintf(`^%s\\[`, regexp.QuoteMeta(param))).MatchString(key) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tmatches := deepObjectBracketRE.FindAllStringSubmatch(key, -1)", + "span_sha256": "045653106b8b2893cf897b6db284e4276f1351a9564cff5a4531208c2b932da7", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "d090e36b2f1c42c898ba2a687a8f6e25f5ca727f", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 686, + "end_line": 710, + "quoted_span": "\tcase \"deepObject\":\n\t\t// Compile the parameter-name prefix matcher once: it depends only on\n\t\t// param (constant for the whole loop), not on the loop variable. Doing\n\t\t// this inside the loop recompiles it once per query key, turning an\n\t\t// attacker-controlled key count into proportional CPU.\n\t\tparamPrefixRE := regexp.MustCompile(fmt.Sprintf(`^%s\\[`, regexp.QuoteMeta(param)))\n\t\tpropsFn = func(params url.Values) (map[string]string, error) {\n\t\t\tprops := make(map[string]string)\n\t\t\tfor key, values := range params {\n\t\t\t\tif !paramPrefixRE.MatchString(key) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tmatches := deepObjectBracketRE.FindAllStringSubmatch(key, -1)\n\t\t\t\tswitch l := len(matches); {\n\t\t\t\tcase l == 0:\n\t\t\t\t\t// A query parameter's name does not match the required format, so skip it.\n\t\t\t\t\tcontinue\n\t\t\t\tcase l \u003e= 1:\n\t\t\t\t\tkk := []string{}\n\t\t\t\t\tfor _, m := range matches {\n\t\t\t\t\t\tkk = append(kk, m[1])\n\t\t\t\t\t}\n\t\t\t\t\tprops[strings.Join(kk, urlDecoderDelimiter)] = strings.Join(values, urlDecoderDelimiter)\n\t\t\t\t}\n\t\t\t}", + "span_sha256": "3f774619af5a7535aad74c9c4a4e0f921f0b15d905559919e7c9c6e6ed7c3527", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "d090e36b2f1c42c898ba2a687a8f6e25f5ca727f", + "path": "openapi3filter/middleware.go", + "start_line": 128, + "end_line": 138, + "quoted_span": "\t\trequestValidationInput := \u0026RequestValidationInput{\n\t\t\tRequest: r,\n\t\t\tPathParams: pathParams,\n\t\t\tRoute: route,\n\t\t\tOptions: \u0026v.options,\n\t\t}\n\t\tif err = ValidateRequest(ctx, requestValidationInput); err != nil {\n\t\t\tv.logFunc(ctx, \"invalid request\", err)\n\t\t\tv.errFunc(ctx, w, http.StatusBadRequest, ErrCodeRequestInvalid, err)\n\t\t\treturn\n\t\t}", + "span_sha256": "ef17ac18e84955e3929a333140d40c79ab2da1647268445efe8e66b8c8afd782", + "verified": true, + "relocated": true + } + ], + "reachability": { + "verdict": "narrows", + "before": "A deepObject request with N query keys reached N regexp.MustCompile calls during request validation.", + "after": "A deepObject request reaches one regexp compilation followed by reuse of paramPrefixRE for every key." + }, + "failure_containment": "degraded", + "introduced_by": [ + { + "commit_sha": "2ab0c0e0ff1cd1ca72537dbddf2bebebd32d827e", + "description": "deepObject parameter-name filtering in urlValuesDecoder.DecodeObject", + "committed_at": "2024-05-11T09:35:01+02:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 70339934 + }, + { + "id": "fp_88aa64c7cb", + "patch_id": "b39c26eb3dcaa0d55cdb33a4ab9f0badc457441d", + "commit_sha": "88aa64c7cbd03ecadbb419c473bdcaa8b0124c6b", + "commit_date": "2026-07-25T14:43:34+02:00", + "commit_subject": "Merge commit from fork", + "class": "resource_exhaustion", + "cwe": [ + "CWE-476" + ], + "components": [ + "openapi3/ghsa_mh7x_f8wq_4jhx_test.go", + "openapi3/loader.go" + ], + "reachable_from": [ + "cmd/validate/main.go" + ], + "sink_symbols": [ + "c.Value.AdditionalProperties.Has", + "c.Value.AdditionalProperties.Schema" + ], + "sink": "Unconditional dereference of an unresolved SchemaRef.Value while resolving an additionalProperties JSON-pointer segment.", + "fix_kind": "guard_added", + "fix_shape": "The resolver preserves an unresolved reference when its SchemaRef has no Value, allowing validation to return an error instead of dereferencing nil.", + "severity": { + "level": "high", + "source": "model_estimate", + "score": 7.5, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + "confidence": "high", + "summary": "Fixes a crafted OpenAPI reference that could panic the loader and deny service.", + "poc": "A crafted OpenAPI 3 document can panic the Go process while resolving references, causing denial of service.", + "root_cause": "The additionalProperties branch in JSON-pointer component resolution dereferenced SchemaRef.Value even when the SchemaRef represented an unresolved $ref and therefore had Value == nil.", + "attacker_preconditions": "The consuming application accepts an attacker-controlled OpenAPI document and invokes the loader without recovering panics; the repository's validate CLI reaches this loader for a supplied file or stdin.", + "invariant": "A JSON-pointer traversal may dereference SchemaRef.Value only after confirming that the referenced schema has resolved to a non-nil value.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "commit", + "revision_sha": "88aa64c7cbd03ecadbb419c473bdcaa8b0124c6b", + "path": "openapi3/ghsa_mh7x_f8wq_4jhx_test.go", + "start_line": 12, + "end_line": 26, + "quoted_span": "\tspec := `\nopenapi: 3.0.3\ninfo: {title: t, version: \"1.0.0\"}\npaths: {}\ncomponents:\n schemas:\n A:\n $ref: '#/components/schemas/A/additionalProperties'\n`\n\n\tloader := openapi3.NewLoader()\n\tdoc, err := loader.LoadFromData([]byte(spec))\n\trequire.NoError(t, err)\n\terr = doc.Validate(loader.Context)\n\trequire.EqualError(t, err, `invalid components: schema \"A\": found unresolved ref: \"#/components/schemas/A/additionalProperties\"`)", + "span_sha256": "efb27b00e8479503a481de231f6d1764557f945877614c09a3c9c166ce995a9b", + "verified": true, + "relocated": false + }, + { + "revision": "parent", + "revision_sha": "f1a5b523b82868d2d9be2e116b4e942892be72c3", + "path": "openapi3/loader.go", + "start_line": 452, + "end_line": 461, + "quoted_span": "\t\t\t// Special case due to multijson\n\t\t\tcase *SchemaRef:\n\t\t\t\tif pathPart == \"additionalProperties\" {\n\t\t\t\t\tif ap := c.Value.AdditionalProperties.Has; ap != nil {\n\t\t\t\t\t\tcursor = *ap\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcursor = c.Value.AdditionalProperties.Schema\n\t\t\t\t\t}\n\t\t\t\t\tattempted = true\n\t\t\t\t}", + "span_sha256": "545b5743f564c844b869f8eaf7ff6721cda9f9a1d7aa5295d453b14d5b631cbf", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "88aa64c7cbd03ecadbb419c473bdcaa8b0124c6b", + "path": "openapi3/loader.go", + "start_line": 452, + "end_line": 463, + "quoted_span": "\t\t\t// Special case due to multijson\n\t\t\tcase *SchemaRef:\n\t\t\t\tif pathPart == \"additionalProperties\" {\n\t\t\t\t\tif s := c.Value; s != nil {\n\t\t\t\t\t\tif ap := s.AdditionalProperties.Has; ap != nil {\n\t\t\t\t\t\t\tcursor = *ap\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tcursor = s.AdditionalProperties.Schema\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tattempted = true\n\t\t\t\t}", + "span_sha256": "1736a68547618fbed9da73401f3bdbf61df43bdc1eea7c2ec0745b6b244b79e0", + "verified": true, + "relocated": false + }, + { + "revision": "parent", + "revision_sha": "f1a5b523b82868d2d9be2e116b4e942892be72c3", + "path": "openapi3/loader.go", + "start_line": 1027, + "end_line": 1048, + "quoted_span": "\tif ref := component.Ref; ref != \"\" {\n\t\tif component.Value != nil {\n\t\t\treturn nil\n\t\t}\n\t\tif !loader.shouldVisitRef(ref, func(value any) {\n\t\t\tcomponent.Value = value.(*Schema)\n\t\t\trefPath, _ := loader.resolveRefPath(ref, documentPath)\n\t\t\tcomponent.setRefPath(refPath)\n\t\t}) {\n\t\t\treturn nil\n\t\t}\n\t\tloader.visitRef(ref)\n\t\tif isSingleRefElement(ref) {\n\t\t\tvar schema Schema\n\t\t\tif documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, \u0026schema); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tcomponent.Value = \u0026schema\n\t\t\tcomponent.setRefPath(documentPath)\n\t\t} else {\n\t\t\tvar resolved SchemaRef\n\t\t\tdoc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, \u0026resolved)", + "span_sha256": "f4949ef74420c36a08ba673a64d93d70e95f09eb4296ca7c5d94c03f9077e485", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "88aa64c7cbd03ecadbb419c473bdcaa8b0124c6b", + "path": "cmd/validate/main.go", + "start_line": 60, + "end_line": 73, + "quoted_span": "\tswitch {\n\tcase vd.OpenAPI == \"3\" || strings.HasPrefix(vd.OpenAPI, \"3.\"):\n\t\tloader := openapi3.NewLoader()\n\t\tloader.IsExternalRefsAllowed = *ext\n\n\t\tvar doc *openapi3.T\n\t\tif filename == \"-\" {\n\t\t\tdoc, err = loader.LoadFromStdin()\n\t\t} else {\n\t\t\tdoc, err = loader.LoadFromFile(filename)\n\t\t}\n\t\tif err != nil {\n\t\t\tlog.Fatalln(\"Loading error:\", err)\n\t\t}", + "span_sha256": "e979c4a58437e6da277e26bad3a8e652f386e53b92aa695f3b5a55a257d5b356", + "verified": true, + "relocated": true + } + ], + "reachability": { + "verdict": "narrows", + "before": "The crafted reference reached an unconditional nil dereference during reference resolution and crashed the process.", + "after": "The crafted reference reaches validation as an unresolved-reference error." + }, + "failure_containment": "crashes", + "introduced_by": [ + { + "commit_sha": "5ffbbe3711120676edb99bfd7b8f02eeaff52e72", + "description": "additionalProperties JSON-pointer traversal for SchemaRef", + "committed_at": "2021-06-23T10:56:33+02:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 160544821 + }, + { + "id": "fp_f0407d53b0", + "patch_id": "79a2923ba5e14bae48a7aaeecf438d37127a3d70", + "commit_sha": "f0407d53b0730280266f454b755010e7eeb985da", + "commit_date": "2026-07-24T01:08:15+02:00", + "commit_subject": "Merge commit from fork", + "class": "authentication", + "cwe": [ + "CWE-306" + ], + "components": [ + "README.md", + "openapi3filter/validation_error_test.go", + "openapi3filter/validation_handler.go" + ], + "sink_symbols": [ + "h.Handler.ServeHTTP", + "next.ServeHTTP" + ], + "sink": "Downstream protected HTTP-handler dispatch after request validation.", + "fix_kind": "sink_removed", + "fix_shape": "Removes the implicit always-successful authentication callback, so secured requests without an explicitly configured authenticator fail validation rather than reaching the application handler.", + "severity": { + "level": "high", + "source": "model_estimate", + "score": 7.5 + }, + "confidence": "high", + "summary": "ValidationHandler no longer silently bypasses OpenAPI security requirements when no authentication callback is configured.", + "poc": "Authentication bypass of OpenAPI-declared protected endpoints handled through ValidationHandler.", + "root_cause": "ValidationHandler.Load silently replaced a missing AuthenticationFunc with NoopAuthenticationFunc, whose unconditional nil return satisfied every declared security requirement.", + "attacker_preconditions": "An internet-reachable service uses ValidationHandler for an OpenAPI operation with non-empty security requirements but omits AuthenticationFunc; the attacker can send a request that otherwise passes request validation.", + "invariant": "For a request with a non-empty OpenAPI security requirement, validation must invoke an explicitly supplied AuthenticationFunc or reject the request; it must not silently treat authentication as successful.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "parent", + "revision_sha": "c9476701743509b5c9ed05858554eacf30672398", + "path": "openapi3filter/validation_handler.go", + "start_line": 47, + "end_line": 49, + "quoted_span": "\tif h.AuthenticationFunc == nil {\n\t\th.AuthenticationFunc = NoopAuthenticationFunc\n\t}", + "span_sha256": "57f93ac7eb78ea293142fb9fd3e0aef13fcf90c000fbbe91695ca2818b0aaaa8", + "verified": true, + "relocated": false + }, + { + "revision": "parent", + "revision_sha": "c9476701743509b5c9ed05858554eacf30672398", + "path": "openapi3filter/validation_handler.go", + "start_line": 18, + "end_line": 18, + "quoted_span": "func NoopAuthenticationFunc(context.Context, *AuthenticationInput) error { return nil }", + "span_sha256": "3e26df63b49be83c3e4650e2ba1f4fdddd75967936db3d4a77240d31469c7dec", + "verified": true, + "relocated": false + }, + { + "revision": "parent", + "revision_sha": "c9476701743509b5c9ed05858554eacf30672398", + "path": "openapi3filter/validation_handler.go", + "start_line": 91, + "end_line": 104, + "quoted_span": "\toptions := \u0026Options{\n\t\tAuthenticationFunc: h.AuthenticationFunc,\n\t}\n\n\t// Validate request\n\trequestValidationInput := \u0026RequestValidationInput{\n\t\tRequest: r,\n\t\tPathParams: pathParams,\n\t\tRoute: route,\n\t\tOptions: options,\n\t}\n\tif err = ValidateRequest(r.Context(), requestValidationInput); err != nil {\n\t\treturn err\n\t}", + "span_sha256": "30c249e751ae0d774a07d8b93b17f26d1cbd33d708babb618ac86b91ae9202dc", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "f0407d53b0730280266f454b755010e7eeb985da", + "path": "openapi3filter/validation_handler.go", + "start_line": 55, + "end_line": 72, + "quoted_span": "func (h *ValidationHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {\n\tif handled := h.before(w, r); handled {\n\t\treturn\n\t}\n\t// TODO: validateResponse\n\th.Handler.ServeHTTP(w, r)\n}\n\n// Middleware implements gorilla/mux MiddlewareFunc\nfunc (h *ValidationHandler) Middleware(next http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif handled := h.before(w, r); handled {\n\t\t\treturn\n\t\t}\n\t\t// TODO: validateResponse\n\t\tnext.ServeHTTP(w, r)\n\t})\n}", + "span_sha256": "2366e336e591aa27e2737acb949ead31bb796e3f1cb6c559f6e13b580ea17153", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "f0407d53b0730280266f454b755010e7eeb985da", + "path": "openapi3filter/validation_handler.go", + "start_line": 47, + "end_line": 50, + "quoted_span": "\t// NOTE: users MUST set AuthenticationFunc explicitly or expect ErrAuthenticationServiceMissing when verifying SecurityRequirements\n\tif h.ErrorEncoder == nil {\n\t\th.ErrorEncoder = DefaultErrorEncoder\n\t}", + "span_sha256": "9a01c822afaa427f773bd9b0d1ebd912292be098565723b9f1a4a6b39bed8ea5", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "f0407d53b0730280266f454b755010e7eeb985da", + "path": "openapi3filter/validation_handler.go", + "start_line": 74, + "end_line": 80, + "quoted_span": "func (h *ValidationHandler) before(w http.ResponseWriter, r *http.Request) (handled bool) {\n\tif err := h.validateRequest(r); err != nil {\n\t\th.ErrorEncoder(r.Context(), err, w)\n\t\treturn true\n\t}\n\treturn false\n}", + "span_sha256": "ae19d8eddadbdf31aa96de8dce8170731e0e9a75c4df03795864fe7648af6f9b", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "f0407d53b0730280266f454b755010e7eeb985da", + "path": "openapi3filter/validate_request.go", + "start_line": 45, + "end_line": 59, + "quoted_span": "\t// Security\n\tsecurity := operation.Security\n\t// If there aren't any security requirements for the operation\n\tif security == nil {\n\t\t// Use the global security requirements.\n\t\tsecurity = \u0026route.Spec.Security\n\t}\n\tif security != nil {\n\t\tif err := ValidateSecurityRequirements(ctx, input, *security); err != nil {\n\t\t\tif !options.MultiError {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tme = append(me, err)\n\t\t}\n\t}", + "span_sha256": "0bf97d8a140c586aa885351e062b47d4d6a702b9d994b7c63ea98c03e284ad9a", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "f0407d53b0730280266f454b755010e7eeb985da", + "path": "openapi3filter/validate_request.go", + "start_line": 436, + "end_line": 439, + "quoted_span": "\tf := options.AuthenticationFunc\n\tif f == nil {\n\t\treturn ErrAuthenticationServiceMissing\n\t}", + "span_sha256": "ba4b70142e4aae621c51e48aa25b5bc2dcdc0e526d3e4181ac141d55e5bf155f", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "f0407d53b0730280266f454b755010e7eeb985da", + "path": "README.md", + "start_line": 329, + "end_line": 330, + "quoted_span": "### v0.144.0\n* `openapi3filter.ValidationHandler.AuthenticationFunc` no longer defaults to `NoopAuthenticationFunc`. Users now have to explicitly set the field and implement their own `AuthenticationFunc` or use the noop impl should they wish to.", + "span_sha256": "2cf7c3556e13831ab3b63c426debccee915a94a726914d4aed65a0db80eb2f95", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "f0407d53b0730280266f454b755010e7eeb985da", + "path": "openapi3filter/validation_error_test.go", + "start_line": 694, + "end_line": 699, + "quoted_span": "\th := \u0026ValidationHandler{\n\t\tAuthenticationFunc: NoopAuthenticationFunc,\n\t\tHandler: tt.fields.Handler,\n\t\tFile: tt.fields.File,\n\t\tErrorEncoder: tt.fields.ErrorEncoder,\n\t}", + "span_sha256": "b4abb5f62182d829beb38c8a8255d29180c0c672a9a626d414eb10fdcb4d3ef6", + "verified": true, + "relocated": false + } + ], + "reachability": { + "verdict": "narrows", + "before": "Those requests received an implicit NoopAuthenticationFunc, passed the security-validation callback, and could reach the configured handler or middleware next handler.", + "after": "Unauthenticated requests to OpenAPI-secured operations with no configured AuthenticationFunc are rejected by validation and encoded as an error response." + }, + "failure_containment": "degraded", + "introduced_by": [ + { + "commit_sha": "55d6baed46488585dd9a5a4f31dbe0ce03ef5592", + "description": "ValidationHandler default authentication setup", + "committed_at": "2020-05-25T00:47:57+02:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 194487618 + }, + { + "id": "fp_68ac2affa3", + "patch_id": "4a7d714677b481c7d01291435469cd93f8b886a9", + "commit_sha": "68ac2affa325514d7d6e731204d6a1edf6bdff64", + "commit_date": "2026-07-23T23:00:10+02:00", + "commit_subject": "Merge commit from fork", + "class": "resource_exhaustion", + "cwe": [ + "CWE-476" + ], + "components": [ + "openapi3filter/req_resp_decoder.go", + "openapi3filter/validate_request_test.go" + ], + "reachable_from": [ + "openapi3filter/middleware.go", + "openapi3filter/validate_request.go", + "openapi3filter/validation_handler.go" + ], + "sink_symbols": [ + "mt.Schema.Value" + ], + "sink": "Nil-pointer dereference of the media type SchemaRef during content-parameter decoding.", + "fix_kind": "guard_added", + "fix_shape": "A schema-less content parameter now produces a validation error, which the supplied HTTP middleware returns as a 400, instead of dereferencing a nil SchemaRef.", + "severity": { + "level": "high", + "source": "model_estimate", + "score": 7.5, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + "confidence": "high", + "summary": "Fixes an externally triggerable nil-pointer panic in default OpenAPI content-parameter validation.", + "poc": "A remote request can panic request validation, causing denial of service for the affected request/handler and potentially the process when the embedding server has no panic recovery.", + "root_cause": "defaultContentParameterDecoder checked that the application/json MediaType existed but omitted the required nil check for its SchemaRef before accessing Value.", + "attacker_preconditions": "The deployed validator uses its default content-parameter decoder and exposes an operation whose valid OpenAPI document has a content parameter with application/json but no schema; the attacker supplies that parameter (no credentials are needed when that operation has no security requirement).", + "invariant": "The default content-parameter decoder must not dereference MediaType.Schema unless that SchemaRef is non-nil; malformed-but-valid media-type content must be rejected as a validation error.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "parent", + "revision_sha": "3e5d7f4d5a0c335d8085de5c0e552efe61e37e03", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 192, + "end_line": 197, + "quoted_span": "\tmt := content.Get(\"application/json\")\n\tif mt == nil {\n\t\terr = fmt.Errorf(\"parameter %q has no content schema\", param.Name)\n\t\treturn\n\t}\n\toutSchema = mt.Schema.Value", + "span_sha256": "560e269522026b0f9dedf1bbaa29fe96e2f3b1f2e64f35d9687e72bfcd48194a", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "68ac2affa325514d7d6e731204d6a1edf6bdff64", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 192, + "end_line": 201, + "quoted_span": "\tmt := content.Get(\"application/json\")\n\tif mt == nil {\n\t\terr = fmt.Errorf(\"parameter %q has no content schema\", param.Name)\n\t\treturn\n\t}\n\tif mt.Schema == nil {\n\t\terr = fmt.Errorf(\"parameter %q content media type has no schema\", param.Name)\n\t\treturn\n\t}\n\toutSchema = mt.Schema.Value", + "span_sha256": "16ac81a51a1cc13eaf4412ae2526cd7b8bc7a1094a01e849b84793833a90fd13", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "68ac2affa325514d7d6e731204d6a1edf6bdff64", + "path": "openapi3/media_type.go", + "start_line": 117, + "end_line": 127, + "quoted_span": "// Validate returns an error if MediaType does not comply with the OpenAPI spec.\nfunc (mediaType *MediaType) Validate(ctx context.Context, opts ...ValidationOption) error {\n\tctx = WithValidationOptions(ctx, opts...)\n\n\tif mediaType == nil {\n\t\treturn nil\n\t}\n\tif schema := mediaType.Schema; schema != nil {\n\t\tif err := schema.Validate(ctx); err != nil {\n\t\t\treturn err\n\t\t}", + "span_sha256": "9bfafe50ae2384bffd18b8b3d64fc48086e74e9ca1e0d20e55a0af8bad67a353", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "68ac2affa325514d7d6e731204d6a1edf6bdff64", + "path": "openapi3/parameter.go", + "start_line": 364, + "end_line": 378, + "quoted_span": "\tif (parameter.Schema == nil) == (len(parameter.Content) == 0) {\n\t\treturn \u0026ParameterFieldValidationError{ParameterName: parameter.Name, Field: \"schema\",\n\t\t\tCause: newParameterContentSchemaExactlyOne(parameter.Origin)}\n\t}\n\n\tif content := parameter.Content; content != nil {\n\t\tif len(content) \u003e 1 {\n\t\t\treturn \u0026ParameterFieldValidationError{ParameterName: parameter.Name, Field: \"content\",\n\t\t\t\tCause: newParameterContentSingleEntry(parameter.Origin)}\n\t\t}\n\n\t\tif err := content.Validate(ctx); err != nil {\n\t\t\treturn \u0026ParameterFieldValidationError{ParameterName: parameter.Name, Field: \"content\", Cause: err}\n\t\t}\n\t}", + "span_sha256": "2a7fcf981bfa6316a6879e48721c416bdc78cdaad18f35e573231617bbfde6e6", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "68ac2affa325514d7d6e731204d6a1edf6bdff64", + "path": "openapi3filter/middleware.go", + "start_line": 117, + "end_line": 138, + "quoted_span": "// Middleware returns an http.Handler which wraps the given handler with\n// request and response validation.\nfunc (v *Validator) Middleware(h http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tctx := r.Context()\n\t\troute, pathParams, err := v.router.FindRoute(r)\n\t\tif err != nil {\n\t\t\tv.logFunc(ctx, \"validation error: failed to find route for \"+r.URL.String(), err)\n\t\t\tv.errFunc(ctx, w, http.StatusNotFound, ErrCodeCannotFindRoute, err)\n\t\t\treturn\n\t\t}\n\t\trequestValidationInput := \u0026RequestValidationInput{\n\t\t\tRequest: r,\n\t\t\tPathParams: pathParams,\n\t\t\tRoute: route,\n\t\t\tOptions: \u0026v.options,\n\t\t}\n\t\tif err = ValidateRequest(ctx, requestValidationInput); err != nil {\n\t\t\tv.logFunc(ctx, \"invalid request\", err)\n\t\t\tv.errFunc(ctx, w, http.StatusBadRequest, ErrCodeRequestInvalid, err)\n\t\t\treturn\n\t\t}", + "span_sha256": "53349654ede0e17f8b33f6adffd0e523333200f6f0d4064651b83ba637c24e42", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "68ac2affa325514d7d6e731204d6a1edf6bdff64", + "path": "openapi3filter/validate_request_test.go", + "start_line": 572, + "end_line": 606, + "quoted_span": "// TestValidateRequestContentParameterWithoutSchema is a regression test for\n// GHSA-jpcw-4wr7-c3vq: a \"content\" parameter whose media type has no\n// \"schema\" is legal per the OpenAPI spec (doc.Validate accepts it) but used\n// to nil-pointer-dereference/panic in defaultContentParameterDecoder instead\n// of returning a validation error.\nfunc TestValidateRequestContentParameterWithoutSchema(t *testing.T) {\n\tconst spec = `\nopenapi: 3.0.3\ninfo: {title: poc, version: \"1.0.0\"}\npaths:\n /c:\n get:\n parameters:\n - name: cfg\n in: query\n content:\n application/json: {}\n responses:\n \"200\": {description: ok}\n`\n\trouter := setupTestRouter(t, spec)\n\n\treq, err := http.NewRequest(http.MethodGet, \"/c?cfg=1\", nil)\n\trequire.NoError(t, err)\n\troute, pathParams, err := router.FindRoute(req)\n\trequire.NoError(t, err)\n\n\trequire.NotPanics(t, func() {\n\t\terr = ValidateRequest(t.Context(), \u0026RequestValidationInput{\n\t\t\tRequest: req,\n\t\t\tPathParams: pathParams,\n\t\t\tRoute: route,\n\t\t})\n\t})\n\trequire.Error(t, err)", + "span_sha256": "02ece890697fb3d5064eadb0613c112d79b2bd6829fd37c41c69f5217296fb27", + "verified": true, + "relocated": false + } + ], + "reachability": { + "verdict": "narrows", + "before": "A request containing a configured schema-less content parameter reached mt.Schema.Value and panicked.", + "after": "The request reaches a returned validation error (and the supplied middleware emits HTTP 400)." + }, + "failure_containment": "crashes", + "introduced_by": [ + { + "commit_sha": "d3180292eead101a815a9291fd8b6aca8b45927e", + "description": "default JSON content-parameter decoder", + "committed_at": "2019-05-07T17:22:07+02:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 227597883 + }, + { + "id": "fp_30e292371a", + "patch_id": "b9cf2496dc5e21b6f58ba19d3f5473faaeabe517", + "commit_sha": "30e292371a89ca6d1f91a9553e0aa518b49f838d", + "commit_date": "2026-07-21T14:48:31+02:00", + "commit_subject": "Merge commit from fork", + "class": "resource_exhaustion", + "cwe": [ + "CWE-476" + ], + "components": [ + "openapi3filter/array_items_nil_panic_test.go", + "openapi3filter/req_resp_decoder.go" + ], + "reachable_from": [ + "openapi3filter/middleware.go", + "openapi3filter/validate_request.go", + "openapi3filter/validate_response.go", + "openapi3filter/validation_handler.go" + ], + "sink_symbols": [ + "propSchema.Value.Items.Value", + "schemaRef.Value.Items", + "valueSchema.Value.Items" + ], + "sink": "Nil-pointer dereference of an optional OpenAPI Schema.Items reference during array decoding.", + "fix_kind": "guard_added", + "fix_shape": "Rejects nil or unresolved array item schemas with ordinary decoding errors instead of dereferencing them on the guarded parameter, urlencoded-body, and multipart-body paths.", + "severity": { + "level": "high", + "source": "model_estimate", + "score": 7.5, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + "confidence": "high", + "summary": "Guards OpenAPI 3.1 array decoders against nil Items panics.", + "poc": "Remote denial of service through a nil-pointer panic in an application embedding the validator without panic recovery.", + "root_cause": "OpenAPI 3.1 made items optional for arrays, but request and response decoding retained unconditional Items dereferences.", + "attacker_preconditions": "The application validates requests or responses with openapi3filter using a valid OpenAPI 3.1 document that declares an array without items; an attacker can then send a matching parameter or form body to an operation reachable without an authentication requirement, and the embedding application has no panic recovery around the library call.", + "invariant": "Every decoder that needs to interpret an array element must first ensure that the array has a non-nil, resolved Items schema; otherwise it must return an error.", + "fix_completeness": "partial", + "anchors": [ + { + "revision": "parent", + "revision_sha": "c11a4812ba7be3ee3d81453be0c397ee84c238d5", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 1126, + "end_line": 1129, + "quoted_span": "func parseArray(raw []string, schemaRef *openapi3.SchemaRef) ([]any, error) {\n\tvar value []any\n\tfor i, v := range raw {\n\t\titem, err := parsePrimitive(v, schemaRef.Value.Items)", + "span_sha256": "ce1ff26bb725f83d768558c9401af963678aaa47b6dde057e229791ddc103f5f", + "verified": true, + "relocated": true + }, + { + "revision": "parent", + "revision_sha": "c11a4812ba7be3ee3d81453be0c397ee84c238d5", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 1420, + "end_line": 1424, + "quoted_span": "\t\tcase propType.Is(\"array\"):\n\t\t\titems := propSchema.Value.Items.Value\n\t\t\tif !(items.Type.Is(\"string\") || items.Type.Is(\"integer\") || items.Type.Is(\"number\") || items.Type.Is(\"boolean\")) {\n\t\t\t\treturn nil, fmt.Errorf(\"unsupported schema of request body's property %q\", propName)\n\t\t\t}", + "span_sha256": "5068f00f713463f39447a80103899d492e1196b9230fd44567ce2c7c5d40c1ee", + "verified": true, + "relocated": false + }, + { + "revision": "parent", + "revision_sha": "c11a4812ba7be3ee3d81453be0c397ee84c238d5", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 1563, + "end_line": 1570, + "quoted_span": "\t\t\tif valueSchema.Value.Type.Is(\"array\") {\n\t\t\t\tvalueSchema = valueSchema.Value.Items\n\t\t\t}\n\t\t}\n\n\t\tpartHeader := http.Header(part.Header)\n\t\tvar value any\n\t\tif _, value, err = decodeBody(part, partHeader, valueSchema, subEncFn); err != nil {", + "span_sha256": "97b4abbd521eb42af298effd251def053729c42a7c8d34eab0d761c9c2616c9e", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "30e292371a89ca6d1f91a9553e0aa518b49f838d", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 1126, + "end_line": 1132, + "quoted_span": "func parseArray(raw []string, schemaRef *openapi3.SchemaRef) ([]any, error) {\n\tif schemaRef.Value.Items == nil || schemaRef.Value.Items.Value == nil {\n\t\treturn nil, fmt.Errorf(\"array items schema is required for decoding\")\n\t}\n\tvar value []any\n\tfor i, v := range raw {\n\t\titem, err := parsePrimitive(v, schemaRef.Value.Items)", + "span_sha256": "4f8d3fb3dc3139c3a76138e7e88b271bcdada395b3d076971d5ba160513db64c", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "30e292371a89ca6d1f91a9553e0aa518b49f838d", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 1423, + "end_line": 1427, + "quoted_span": "\t\tcase propType.Is(\"array\"):\n\t\t\tif propSchema.Value.Items == nil || propSchema.Value.Items.Value == nil {\n\t\t\t\treturn nil, fmt.Errorf(\"unsupported schema of request body's property %q: array items required\", propName)\n\t\t\t}\n\t\t\titems := propSchema.Value.Items.Value", + "span_sha256": "48168fbe9327185e98a2081420a387c37998dbec5b2679fbd5f6c47c19c9e07b", + "verified": true, + "relocated": false + }, + { + "revision": "head", + "revision_sha": "30e292371a89ca6d1f91a9553e0aa518b49f838d", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 1569, + "end_line": 1574, + "quoted_span": "\t\t\tif valueSchema.Value.Type.Is(\"array\") {\n\t\t\t\tif valueSchema.Value.Items == nil {\n\t\t\t\t\treturn nil, fmt.Errorf(\"unsupported schema of multipart part %q: array items required\", name)\n\t\t\t\t}\n\t\t\t\tvalueSchema = valueSchema.Value.Items\n\t\t\t}", + "span_sha256": "f30954012c71c81b6cb285fdffa3859e1d974466d442cc45f87d205d2326f65d", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "30e292371a89ca6d1f91a9553e0aa518b49f838d", + "path": "openapi3filter/array_items_nil_panic_test.go", + "start_line": 110, + "end_line": 118, + "quoted_span": "\tpanicVal := catchPanicValue(func() {\n\t\treqErr = openapi3filter.ValidateRequest(context.Background(), \u0026openapi3filter.RequestValidationInput{\n\t\t\tRequest: req, PathParams: pathParams, Route: route,\n\t\t\tOptions: \u0026openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc},\n\t\t})\n\t})\n\trequire.Nil(t, panicVal, \"must not panic\")\n\trequire.Error(t, reqErr, \"must return a clean error\")\n\trequire.Contains(t, reqErr.Error(), \"array items required\")", + "span_sha256": "60717733381d80314a46de65f5b47e84c6f3447d1d9648958cf4eab7a83eaa71", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "30e292371a89ca6d1f91a9553e0aa518b49f838d", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 559, + "end_line": 563, + "quoted_span": "func (d *urlValuesDecoder) parseArray(raw []string, schemaRef *openapi3.SchemaRef) ([]any, error) {\n\tvar value []any\n\n\tfor i, v := range raw {\n\t\titem, err := d.parseValue(v, schemaRef.Value.Items)", + "span_sha256": "de8980a9902e967a986f425e6b759f01ec877482d166b7ed5c3593b5a7699d69", + "verified": true, + "relocated": true + }, + { + "revision": "head", + "revision_sha": "30e292371a89ca6d1f91a9553e0aa518b49f838d", + "path": "openapi3filter/middleware.go", + "start_line": 119, + "end_line": 138, + "quoted_span": "func (v *Validator) Middleware(h http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tctx := r.Context()\n\t\troute, pathParams, err := v.router.FindRoute(r)\n\t\tif err != nil {\n\t\t\tv.logFunc(ctx, \"validation error: failed to find route for \"+r.URL.String(), err)\n\t\t\tv.errFunc(ctx, w, http.StatusNotFound, ErrCodeCannotFindRoute, err)\n\t\t\treturn\n\t\t}\n\t\trequestValidationInput := \u0026RequestValidationInput{\n\t\t\tRequest: r,\n\t\t\tPathParams: pathParams,\n\t\t\tRoute: route,\n\t\t\tOptions: \u0026v.options,\n\t\t}\n\t\tif err = ValidateRequest(ctx, requestValidationInput); err != nil {\n\t\t\tv.logFunc(ctx, \"invalid request\", err)\n\t\t\tv.errFunc(ctx, w, http.StatusBadRequest, ErrCodeRequestInvalid, err)\n\t\t\treturn\n\t\t}", + "span_sha256": "e99c654e071a7880af441535d6eb87d9f737c947c269724627d59d79bfd74c16", + "verified": true, + "relocated": true + } + ], + "reachability": { + "verdict": "narrows", + "before": "A request or response reaching those decoders with a valid OpenAPI 3.1 array schema lacking items caused a nil-pointer dereference.", + "after": "The guarded generic array, urlencoded-form, and multipart-form decoders return decoding errors for missing item schemas." + }, + "failure_containment": "crashes", + "introduced_by": [ + { + "commit_sha": "4cefcc72a2ee324ee30b866309584ee4724e7c7b", + "description": "OpenAPI 3.1 array-schema validation allowing missing items", + "committed_at": "2026-04-24T08:40:04+02:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 7625307 + }, + { + "id": "fp_1223a0f215", + "patch_id": "16599b78fa94f10fbb3efce24ab6e6247e6022c4", + "commit_sha": "1223a0f215d2cf9beb2d9eb9ea2649d001c21388", + "commit_date": "2026-07-12T00:04:27+02:00", + "commit_subject": "openapi3filter: fix unbounded allocation in deepObject array decoding (C-02)", + "class": "resource_exhaustion", + "cwe": [ + "CWE-400" + ], + "components": [ + "openapi3filter/req_resp_decoder.go" + ], + "reachable_from": [ + "openapi3filter/middleware.go", + "openapi3filter/validate_request.go" + ], + "sink_symbols": [ + "append(result, nil)", + "sliceMapToSlice" + ], + "guard_symbols": [ + "maxSliceMapToSliceGap" + ], + "sink": "The sparse-array reconstruction loop in sliceMapToSlice, specifically its repeated append of nil entries through the maximum supplied index.", + "fix_kind": "guard_added", + "fix_shape": "Rejects negative indexes and sparse array maps whose reconstructed length exceeds the supplied-element count by more than 10,000, preventing attacker-controlled allocation amplification before schema validation.", + "severity": { + "level": "high", + "source": "model_estimate", + "score": 7.5, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + "confidence": "high", + "summary": "Fixes remote denial of service from unbounded deepObject array-index allocation.", + "poc": "Unauthenticated remote clients can cause multi-gigabyte heap allocations during request validation, exhausting memory and terminating or severely disrupting the service.", + "root_cause": "sliceMapToSlice trusted the maximum attacker-supplied numeric index and filled every omitted slot with nil, making reconstructed memory use proportional to the index rather than the request's supplied elements.", + "attacker_preconditions": "A service uses this validator for an externally reachable OpenAPI operation with query validation enabled and a deepObject object schema containing an array; the attacker can submit a bracketed numeric array index in that query parameter.", + "invariant": "A deepObject array's reconstructed length must remain bounded relative to the number of supplied elements, and array indexes must be non-negative.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "commit", + "revision_sha": "1223a0f215d2cf9beb2d9eb9ea2649d001c21388", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 661, + "end_line": 687, + "quoted_span": "\tcase \"deepObject\":\n\t\tpropsFn = func(params url.Values) (map[string]string, error) {\n\t\t\tprops := make(map[string]string)\n\t\t\tfor key, values := range params {\n\t\t\t\tif !regexp.MustCompile(fmt.Sprintf(`^%s\\[`, regexp.QuoteMeta(param))).MatchString(key) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tmatches := regexp.MustCompile(`\\[(.*?)\\]`).FindAllStringSubmatch(key, -1)\n\t\t\t\tswitch l := len(matches); {\n\t\t\t\tcase l == 0:\n\t\t\t\t\t// A query parameter's name does not match the required format, so skip it.\n\t\t\t\t\tcontinue\n\t\t\t\tcase l \u003e= 1:\n\t\t\t\t\tkk := []string{}\n\t\t\t\t\tfor _, m := range matches {\n\t\t\t\t\t\tkk = append(kk, m[1])\n\t\t\t\t\t}\n\t\t\t\t\tprops[strings.Join(kk, urlDecoderDelimiter)] = strings.Join(values, urlDecoderDelimiter)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(props) == 0 {\n\t\t\t\t// HTTP request does not contain query parameters encoded by rules of style \"deepObject\".\n\t\t\t\treturn nil, nil\n\t\t\t}\n\t\t\treturn props, nil\n\t\t}", + "span_sha256": "c937a3c13a27e6e9064bb1de79af0417b64fdc65606786a3c6496142c79e7320", + "verified": true, + "relocated": true + }, + { + "revision": "commit", + "revision_sha": "1223a0f215d2cf9beb2d9eb9ea2649d001c21388", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 988, + "end_line": 1012, + "quoted_span": "\tcase schema.Value.Type.Is(\"array\"):\n\t\tparamArr, ok := deepGet(params, mapKeys...)\n\t\tif !ok {\n\t\t\treturn nil, nil\n\t\t}\n\t\tt, isMap := paramArr.(map[string]any)\n\t\tif !isMap {\n\t\t\treturn nil, \u0026ParseError{path: pathFromKeys(mapKeys), Kind: KindInvalidFormat, Reason: \"array items must be set with indexes\"}\n\t\t}\n\t\t// intermediate arrays have to be instantiated\n\t\tarr, err := sliceMapToSlice(t)\n\t\tif err != nil {\n\t\t\treturn nil, \u0026ParseError{path: pathFromKeys(mapKeys), Kind: KindInvalidFormat, Reason: fmt.Sprintf(\"could not convert value map to array: %v\", err)}\n\t\t}\n\t\tresultArr := make([]any /*not 0,*/, len(arr))\n\t\tfor i := range arr {\n\t\t\tr, err := buildResObj(params, mapKeys, strconv.Itoa(i), schema.Value.Items)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tif r != nil {\n\t\t\t\tresultArr[i] = r\n\t\t\t}\n\t\t}\n\t\treturn resultArr, nil", + "span_sha256": "1737a1d9f7d03d6b74b93bb24fb7e83aea573c463b91b12c3dac9d3555a06320", + "verified": true, + "relocated": true + }, + { + "revision": "parent", + "revision_sha": "61f37b693702a391985bd49638954004eda0135e", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 947, + "end_line": 961, + "quoted_span": "\tmax := -1\n\tfor _, k := range keys {\n\t\tif k \u003e max {\n\t\t\tmax = k\n\t\t}\n\t}\n\tfor i := 0; i \u003c= max; i++ {\n\t\tval, ok := m[strconv.Itoa(i)]\n\t\tif !ok {\n\t\t\tresult = append(result, nil)\n\t\t\tcontinue\n\t\t}\n\t\tresult = append(result, val)\n\t}\n\treturn result, nil", + "span_sha256": "c128f26795fc6934cbddcbfe516eb05dd332cb5a8401669287e37ca4f7ae2197", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "1223a0f215d2cf9beb2d9eb9ea2649d001c21388", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 935, + "end_line": 968, + "quoted_span": "// maxSliceMapToSliceGap bounds how many synthesized nil holes sliceMapToSlice\n// will fill in for a sparse array before rejecting the input. Without this,\n// an attacker-supplied index (e.g. from a deepObject query parameter) drives\n// an allocation proportional to the index itself, regardless of how many\n// elements were actually provided.\nconst maxSliceMapToSliceGap = 10000\n\n// example: map[0:map[key:true] 1:map[key:false]] -\u003e [map[key:true] map[key:false]]\nfunc sliceMapToSlice(m map[string]any) ([]any, error) {\n\tvar result []any\n\n\tkeys := make([]int, 0, len(m))\n\tfor k := range m {\n\t\tkey, err := strconv.Atoi(k)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"array indexes must be integers: %w\", err)\n\t\t}\n\t\tif key \u003c 0 {\n\t\t\treturn nil, fmt.Errorf(\"array indexes must not be negative: %d\", key)\n\t\t}\n\t\tkeys = append(keys, key)\n\t}\n\tmax := -1\n\tfor _, k := range keys {\n\t\tif k \u003e max {\n\t\t\tmax = k\n\t\t}\n\t}\n\t// max+1 is the size of the slice this loop is about to build; bound the\n\t// gap between what was actually supplied (len(m)) and that size so a\n\t// single huge index can't force an outsized allocation.\n\tif gap := max + 1 - len(m); gap \u003e maxSliceMapToSliceGap {\n\t\treturn nil, fmt.Errorf(\"array index %d is too sparse relative to the %d supplied items\", max, len(m))\n\t}", + "span_sha256": "dbd6bddd208698dc145dd572d016e2d645a2396e734720fb242372ca9452b9e7", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "1223a0f215d2cf9beb2d9eb9ea2649d001c21388", + "path": "openapi3filter/middleware.go", + "start_line": 117, + "end_line": 138, + "quoted_span": "// Middleware returns an http.Handler which wraps the given handler with\n// request and response validation.\nfunc (v *Validator) Middleware(h http.Handler) http.Handler {\n\treturn http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tctx := r.Context()\n\t\troute, pathParams, err := v.router.FindRoute(r)\n\t\tif err != nil {\n\t\t\tv.logFunc(ctx, \"validation error: failed to find route for \"+r.URL.String(), err)\n\t\t\tv.errFunc(ctx, w, http.StatusNotFound, ErrCodeCannotFindRoute, err)\n\t\t\treturn\n\t\t}\n\t\trequestValidationInput := \u0026RequestValidationInput{\n\t\t\tRequest: r,\n\t\t\tPathParams: pathParams,\n\t\t\tRoute: route,\n\t\t\tOptions: \u0026v.options,\n\t\t}\n\t\tif err = ValidateRequest(ctx, requestValidationInput); err != nil {\n\t\t\tv.logFunc(ctx, \"invalid request\", err)\n\t\t\tv.errFunc(ctx, w, http.StatusBadRequest, ErrCodeRequestInvalid, err)\n\t\t\treturn\n\t\t}", + "span_sha256": "53349654ede0e17f8b33f6adffd0e523333200f6f0d4064651b83ba637c24e42", + "verified": true, + "relocated": true + } + ], + "reachability": { + "verdict": "narrows", + "before": "A deepObject query parameter containing a very large numeric array index reached the reconstruction loop and caused allocations proportional to that index.", + "after": "Sparse deepObject array indexes with a gap over 10,000 are returned as ParseError-backed invalid request errors before slice reconstruction." + }, + "failure_containment": "crashes", + "introduced_by": [ + { + "commit_sha": "78bb273e5892da3b0c8fc31857499449adfaba6c", + "description": "deepObject nested-array reconstruction", + "committed_at": "2024-03-22T20:15:06+01:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 72672561 + }, + { + "id": "fp_1d0a337c9b", + "patch_id": "ee26ffd99ee6ff2c2787259f666bfb67914593c1", + "commit_sha": "1d0a337c9b1570fab283be8a04c8af6e43b9a22c", + "commit_date": "2026-07-10T10:49:45+02:00", + "commit_subject": "Merge commit from fork", + "class": "resource_exhaustion", + "cwe": [ + "CWE-476" + ], + "components": [ + "openapi3filter/ghsa_mmfr_pmjx_hw9w_test.go", + "openapi3filter/validation_error_encoder.go" + ], + "reachable_from": [ + "openapi3filter/req_resp_decoder.go", + "openapi3filter/validate_request.go" + ], + "sink_symbols": [ + "e.Parameter.In" + ], + "sink": "Nil-pointer dereference of RequestError.Parameter while formatting a nested ParseError.", + "fix_kind": "guard_added", + "fix_shape": "Adds a nil check before the query-parameter formatting path, allowing malformed multipart body errors to fall through to a 400 ValidationError instead of panicking.", + "severity": { + "level": "high", + "source": "model_estimate", + "score": 7.5, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + "confidence": "high", + "summary": "Fixes a remotely triggerable nil-pointer panic in multipart request validation error conversion.", + "poc": "A remote unauthenticated caller meeting the endpoint preconditions could trigger a nil-pointer panic while the application renders a malformed multipart request error, causing denial of service.", + "root_cause": "convertParseError assumed every nested invalid-format ParseError belonged to a parameter and dereferenced the nil Parameter of a request-body RequestError.", + "attacker_preconditions": "The application exposes a request-body-validated multipart/form-data endpoint with a scalar property and renders validation failures through ConvertErrors or ValidationErrorEncoder; the endpoint must be reachable without authentication.", + "invariant": "A RequestError without a Parameter (including request-body errors) must not dereference e.Parameter while converting an error.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "parent", + "revision_sha": "98d956447b64eaa10d3570a80b3be1a2849945f1", + "path": "openapi3filter/validation_error_encoder.go", + "start_line": 118, + "end_line": 130, + "quoted_span": "\t} else if innerErr.RootCause() != nil {\n\t\tif rootErr, ok := innerErr.Cause.(*ParseError); ok \u0026\u0026\n\t\t\trootErr.Kind == KindInvalidFormat \u0026\u0026 e.Parameter.In == \"query\" {\n\t\t\treturn \u0026ValidationError{\n\t\t\t\tStatus: http.StatusBadRequest,\n\t\t\t\tTitle: fmt.Sprintf(\"parameter %q in %s is invalid: %v is %s\",\n\t\t\t\t\te.Parameter.Name, e.Parameter.In, rootErr.Value, rootErr.Reason),\n\t\t\t}\n\t\t}\n\t\treturn \u0026ValidationError{\n\t\t\tStatus: http.StatusBadRequest,\n\t\t\tTitle: innerErr.Reason,\n\t\t}", + "span_sha256": "e3ca1ff54c9269ee0b94bbe01e785fe2102d0cb563835aff6db1f8e942cca4fd", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "1d0a337c9b1570fab283be8a04c8af6e43b9a22c", + "path": "openapi3filter/validation_error_encoder.go", + "start_line": 118, + "end_line": 138, + "quoted_span": "\t} else if innerErr.RootCause() != nil {\n\t\tif rootErr, ok := innerErr.Cause.(*ParseError); ok \u0026\u0026\n\t\t\trootErr.Kind == KindInvalidFormat \u0026\u0026 e.Parameter != nil \u0026\u0026 e.Parameter.In == \"query\" {\n\t\t\treturn \u0026ValidationError{\n\t\t\t\tStatus: http.StatusBadRequest,\n\t\t\t\tTitle: fmt.Sprintf(\"parameter %q in %s is invalid: %v is %s\",\n\t\t\t\t\te.Parameter.Name, e.Parameter.In, rootErr.Value, rootErr.Reason),\n\t\t\t}\n\t\t}\n\t\t// For body parse errors (e.Parameter == nil) the outer ParseError's\n\t\t// Reason is often empty, e.g. the multipart decoder wraps a part's\n\t\t// *ParseError without setting one. Fall back to the full error text so\n\t\t// the response still carries a meaningful message.\n\t\ttitle := innerErr.Reason\n\t\tif title == \"\" {\n\t\t\ttitle = innerErr.Error()\n\t\t}\n\t\treturn \u0026ValidationError{\n\t\t\tStatus: http.StatusBadRequest,\n\t\t\tTitle: title,\n\t\t}", + "span_sha256": "0d0259df5b10b28c12f115d17a2950abb033e7de98dc7d7ea52b6bd5ee68423d", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "1d0a337c9b1570fab283be8a04c8af6e43b9a22c", + "path": "openapi3filter/req_resp_decoder.go", + "start_line": 1545, + "end_line": 1560, + "quoted_span": "\t\tpartHeader := http.Header(part.Header)\n\t\tvar value any\n\t\tif _, value, err = decodeBody(part, partHeader, valueSchema, subEncFn); err != nil {\n\t\t\tif v, ok := err.(*ParseError); ok {\n\t\t\t\treturn nil, \u0026ParseError{path: []any{name}, Cause: v}\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"part %s: %w\", name, err)\n\t\t}\n\n\t\t// Parse primitive types when no content type is explicitly provided, or the content type is set to text/plain\n\t\tif contentType := partHeader.Get(headerCT); contentType == \"\" || contentType == \"text/plain\" {\n\t\t\tif value, err = parsePrimitive(value.(string), valueSchema); err != nil {\n\t\t\t\tif v, ok := err.(*ParseError); ok {\n\t\t\t\t\treturn nil, \u0026ParseError{path: []any{name}, Cause: v}\n\t\t\t\t}\n\t\t\t\treturn nil, fmt.Errorf(\"part %s: %w\", name, err)", + "span_sha256": "4ea3a86f16dfa115ffc57d5846a931469ffd4b49d8ca008d3ffd5ea03d96b52f", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "1d0a337c9b1570fab283be8a04c8af6e43b9a22c", + "path": "openapi3filter/validate_request.go", + "start_line": 323, + "end_line": 331, + "quoted_span": "\tencFn := func(name string) *openapi3.Encoding { return contentType.Encoding[name] }\n\tmediaType, value, err := decodeBody(bytes.NewReader(data), req.Header, contentType.Schema, encFn)\n\tif err != nil {\n\t\treturn \u0026RequestError{\n\t\t\tInput: input,\n\t\t\tRequestBody: requestBody,\n\t\t\tReason: \"failed to decode request body\",\n\t\t\tErr: err,\n\t\t}", + "span_sha256": "1027b91c434f0db263e9036e79352df300612b83a5b56d8d578ba7acf902e9bc", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "1d0a337c9b1570fab283be8a04c8af6e43b9a22c", + "path": "openapi3filter/ghsa_mmfr_pmjx_hw9w_test.go", + "start_line": 111, + "end_line": 131, + "quoted_span": "\t\t\treq, err := http.NewRequest(http.MethodPost, \"/upload\", strings.NewReader(body))\n\t\t\trequire.NoError(t, err)\n\t\t\treq.Header.Set(\"Content-Type\", contentType)\n\n\t\t\troute, pathParams, err := router.FindRoute(req)\n\t\t\trequire.NoError(t, err)\n\n\t\t\treqErr := openapi3filter.ValidateRequest(ctx, \u0026openapi3filter.RequestValidationInput{\n\t\t\t\tRequest: req,\n\t\t\t\tPathParams: pathParams,\n\t\t\t\tRoute: route,\n\t\t\t\tOptions: \u0026openapi3filter.Options{AuthenticationFunc: openapi3filter.NoopAuthenticationFunc},\n\t\t\t})\n\t\t\trequire.Error(t, reqErr)\n\n\t\t\t// This is what a typical error-rendering middleware calls; it must\n\t\t\t// not panic.\n\t\t\tvar converted error\n\t\t\trequire.NotPanics(t, func() {\n\t\t\t\tconverted = openapi3filter.ConvertErrors(reqErr)\n\t\t\t})", + "span_sha256": "796b7c5d25b2fde15f3ec329b733ae5d33eb5f3d4a881e10bf12b05e0280e7d3", + "verified": true, + "relocated": false + } + ], + "reachability": { + "verdict": "narrows", + "before": "The crafted multipart request could reach the nil dereference in convertParseError when validation errors were converted.", + "after": "The crafted multipart request receives a 400 ValidationError when converted." + }, + "failure_containment": "crashes", + "introduced_by": [ + { + "commit_sha": "55d6baed46488585dd9a5a4f31dbe0ce03ef5592", + "description": "validation error encoder query ParseError formatting", + "committed_at": "2020-05-25T00:47:57+02:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 193312908 + }, + { + "id": "fp_355692920a", + "patch_id": "1076adb70ecc53db903a03f459016b2d2f4688c1", + "commit_sha": "355692920adc95a261f8afba887dce636a0e2d67", + "commit_date": "2026-04-25T10:19:52+02:00", + "commit_subject": "openapi2conv: nil-guard components lookup in FromV3SchemaRef (#1156)", + "class": "resource_exhaustion", + "cwe": [ + "CWE-476" + ], + "components": [ + "openapi2conv/issue1062_test.go", + "openapi2conv/openapi2_conv.go" + ], + "sink_symbols": [ + "components.Schemas[name]" + ], + "sink": "Nil dereference during the components schema-map lookup in FromV3SchemaRef.", + "fix_kind": "guard_added", + "fix_shape": "Adds a nil check before the component-schema lookup, causing unresolved nested refs to be emitted as plain converted refs instead of panicking.", + "severity": { + "level": "medium", + "source": "model_estimate", + "score": 5.3, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L" + }, + "confidence": "high", + "summary": "Fixes a crafted OpenAPI document-triggered nil-pointer denial of service in V3-to-V2 conversion.", + "poc": "A crafted multipart/form-data schema with an array item component reference panics the Go converter, enabling denial of service for a process that converts untrusted specifications.", + "root_cause": "FromV3RequestBodyFormData passed nil components while FromV3SchemaRef unconditionally indexed components.Schemas for referenced schemas.", + "attacker_preconditions": "The application accepts an attacker-controlled OpenAPI 3 document and invokes openapi2conv.FromV3 without recovering panics.", + "invariant": "Schema-reference conversion must not dereference the components table when no components context was supplied.", + "fix_completeness": "complete", + "anchors": [ + { + "revision": "parent", + "revision_sha": "5a0a3373598fa8184f7214e0af51164533130c7d", + "path": "openapi2conv/openapi2_conv.go", + "start_line": 848, + "end_line": 858, + "quoted_span": "func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components) (*openapi2.SchemaRef, *openapi2.Parameter) {\n\tif ref := schema.Ref; ref != \"\" {\n\t\tname := getParameterNameFromNewRef(ref)\n\t\tif val, ok := components.Schemas[name]; ok {\n\t\t\tif val.Value.Format == \"binary\" {\n\t\t\t\tv2Ref := strings.Replace(ref, \"#/components/schemas/\", \"#/parameters/\", 1)\n\t\t\t\treturn nil, \u0026openapi2.Parameter{Ref: v2Ref}\n\t\t\t}\n\t\t}\n\n\t\treturn \u0026openapi2.SchemaRef{Ref: FromV3Ref(ref)}, nil", + "span_sha256": "e40f76931d0680cb85a1da8750324109ada6f69b424c69a8eafe764c5c6c992b", + "verified": true, + "relocated": false + }, + { + "revision": "parent", + "revision_sha": "5a0a3373598fa8184f7214e0af51164533130c7d", + "path": "openapi2conv/openapi2_conv.go", + "start_line": 1038, + "end_line": 1041, + "quoted_span": "\t\tvar v2Items *openapi2.SchemaRef\n\t\tif val.Items != nil {\n\t\t\tv2Items, _ = FromV3SchemaRef(val.Items, nil)\n\t\t}", + "span_sha256": "426ebb0b04a5885eab4777e4c20285bbee976aba2fc7c65df3c6dc3e4197f712", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "355692920adc95a261f8afba887dce636a0e2d67", + "path": "openapi2conv/openapi2_conv.go", + "start_line": 848, + "end_line": 868, + "quoted_span": "func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components) (*openapi2.SchemaRef, *openapi2.Parameter) {\n\tif ref := schema.Ref; ref != \"\" {\n\t\t// FromV3RequestBodyFormData (and other recursive call sites in\n\t\t// this file) pass components=nil when recursing into array\n\t\t// items and nested refs. Without guarding, components.Schemas\n\t\t// nil-derefs before we even have a chance to look up the\n\t\t// component, so a ref like '#/components/schemas/CreateEmbeddingRequest'\n\t\t// inside an array schema crashes the converter (#1062).\n\t\t// Treat a missing components table the same as 'the target\n\t\t// schema is not known locally': emit a plain $ref and move on.\n\t\tname := getParameterNameFromNewRef(ref)\n\t\tif components != nil {\n\t\t\tif val, ok := components.Schemas[name]; ok {\n\t\t\t\tif val.Value.Format == \"binary\" {\n\t\t\t\t\tv2Ref := strings.Replace(ref, \"#/components/schemas/\", \"#/parameters/\", 1)\n\t\t\t\t\treturn nil, \u0026openapi2.Parameter{Ref: v2Ref}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn \u0026openapi2.SchemaRef{Ref: FromV3Ref(ref)}, nil", + "span_sha256": "cc3ad14725af70500906ed28ce72b454e3a32910194e7d89e4eb73209a8975c9", + "verified": true, + "relocated": false + }, + { + "revision": "commit", + "revision_sha": "355692920adc95a261f8afba887dce636a0e2d67", + "path": "openapi2conv/issue1062_test.go", + "start_line": 59, + "end_line": 66, + "quoted_span": "\tvar doc3 openapi3.T\n\trequire.NoError(t, yaml.Unmarshal([]byte(v3Spec), \u0026doc3), \"unmarshal v3 spec\")\n\n\t// Pre-fix: this call panicked with\n\t// \"runtime error: invalid memory reference or nil pointer dereference\"\n\t// inside FromV3SchemaRef when it deref'd nil components.Schemas.\n\tv2, err := FromV3(\u0026doc3)\n\trequire.NoError(t, err, \"FromV3 must not error on form-data array of $refs\")", + "span_sha256": "9db5c8f07388939b16d36710d0108967bb90c391abbfced32080e797770800b5", + "verified": true, + "relocated": false + } + ], + "reachability": { + "verdict": "narrows", + "before": "A multipart/form-data array item containing a component $ref reaches a nil dereference in FromV3SchemaRef via FromV3.", + "after": "The same document reaches plain $ref conversion and returns normally." + }, + "failure_containment": "crashes", + "introduced_by": [ + { + "commit_sha": "1eeb41ca5a03ec81862d9135bcb0a09c6d7d7d2a", + "description": "form-data array-item conversion passing nil components", + "committed_at": "2024-10-01T22:44:42+02:00", + "verified": true + } + ], + "introduced_to_fixed_seconds": 49289710 + } + ] + } +} From e9fd875ce9ebcc67ace82a61d11b1d00bf5be810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Wed, 26 Aug 2026 18:55:06 -0300 Subject: [PATCH 2/4] fix(schemavalidators): keep the L0 taxonomy in one place in the AI security context schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The closed L0 class taxonomy was spelled out twice in the AI security context schema: once as the l0_class enum and once inline in shared_surface.class's pattern. Extending it meant editing both, and updating only one let the two diverge, so the same class would be rejected on a shared surface while an individual fingerprint carrying it passed. Hoist the pattern into a l0_class_path definition that shared_surface.class references, and mark it as derived from the l0_class enum. The wire format is unchanged: shared surfaces still carry slash-joined class tokens, so the pattern stays the way membership is checked, since draft-07 cannot interpolate an enum into a pattern. Guard the derivation with tests that rebuild the pattern from the enum and drive every taxonomy member through both the single-token and slash-joined positions, so a taxonomy edit that touches only one copy fails instead of silently widening or narrowing validation. Chainloop-Trace-Sessions: 3f365023-61a3-4af3-bbe9-03688f152834, df945bde-e982-472c-bb81-50055df403bb Signed-off-by: Matías Insaurralde --- .../ai-security-context-0.1.schema.json | 8 +- .../schemavalidators/schemavalidators_test.go | 142 ++++++++++++++++++ 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json b/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json index c2124e466..691a10862 100644 --- a/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json +++ b/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json @@ -463,8 +463,7 @@ "description": "Name of the surface, taken from the shared sink symbols it clusters on" }, "class": { - "type": "string", - "pattern": "^$|^(memory_safety|input_validation|injection|path_traversal|access_control|authentication|cryptography|resource_exhaustion|concurrency_toctou|deserialization|information_disclosure|logic_flaw|supply_chain|other)(/(memory_safety|input_validation|injection|path_traversal|access_control|authentication|cryptography|resource_exhaustion|concurrency_toctou|deserialization|information_disclosure|logic_flaw|supply_chain|other))*$", + "$ref": "#/definitions/l0_class_path", "description": "L0 class of the fixes backing the surface. NOT a single class token: surfaces cluster on shared sink symbols, so a cluster spanning several classes emits them slash-joined and sorted (\"cryptography/resource_exhaustion\"). Each segment is a member of the closed taxonomy; empty when no backing fix carried a class." }, "guard": { @@ -872,6 +871,11 @@ ], "description": "The closed, stable top-level security taxonomy. Closed on purpose: an earlier open list let a real run emit \"race_condition\", which nothing validated." }, + "l0_class_path": { + "type": "string", + "pattern": "^$|^(memory_safety|input_validation|injection|path_traversal|access_control|authentication|cryptography|resource_exhaustion|concurrency_toctou|deserialization|information_disclosure|logic_flaw|supply_chain|other)(/(memory_safety|input_validation|injection|path_traversal|access_control|authentication|cryptography|resource_exhaustion|concurrency_toctou|deserialization|information_disclosure|logic_flaw|supply_chain|other))*$", + "description": "One or more l0_class members, slash-joined and sorted, or empty. GENERATED: draft-07 cannot interpolate an enum into a pattern, so the taxonomy is authored once in #/definitions/l0_class and this alternation is derived from it \u2014 never hand-edit one without the other. TestAISecurityContextL0TaxonomyHasSingleSource regenerates the pattern from the enum and fails if they drift." + }, "severity_level": { "type": "string", "enum": [ diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index 100e69575..432dd9d79 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -17,7 +17,9 @@ package schemavalidators_test import ( "encoding/json" + "fmt" "os" + "strings" "testing" "github.com/chainloop-dev/chainloop/internal/schemavalidators" @@ -546,3 +548,143 @@ func TestValidateOSSFScorecard(t *testing.T) { }) } } + +// aiSecurityContextSchemaPath is the on-disk copy of the schema embedded by the +// package. The taxonomy guard below reads it directly so it asserts against the +// exact document that ships. +const aiSecurityContextSchemaPath = "./internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json" + +// l0TaxonomyDefinitions is the slice of the AI security context schema that +// carries the L0 taxonomy: the enum that defines it and the pattern derived from +// it. JSON Schema draft-07 cannot interpolate an enum into a pattern, so the +// pattern is generated rather than referenced, and the tests below are what stop +// the two from drifting. +type l0TaxonomyDefinitions struct { + Definitions struct { + L0Class struct { + Enum []string `json:"enum"` + } `json:"l0_class"` + L0ClassPath struct { + Pattern string `json:"pattern"` + } `json:"l0_class_path"` + } `json:"definitions"` +} + +// l0ClassPathPattern derives the pattern for a slash-joined, sorted path of +// taxonomy members from the taxonomy itself. Whoever edits the enum regenerates +// the pattern with this, rather than hand-editing a second copy of the list. +func l0ClassPathPattern(taxonomy []string) string { + alternation := strings.Join(taxonomy, "|") + return fmt.Sprintf("^$|^(%s)(/(%s))*$", alternation, alternation) +} + +func readL0Taxonomy(t *testing.T) (l0TaxonomyDefinitions, []byte) { + t.Helper() + + raw, err := os.ReadFile(aiSecurityContextSchemaPath) + require.NoError(t, err) + + var defs l0TaxonomyDefinitions + require.NoError(t, json.Unmarshal(raw, &defs)) + require.NotEmpty(t, defs.Definitions.L0Class.Enum, "l0_class enum must define the taxonomy") + + return defs, raw +} + +// TestAISecurityContextL0TaxonomyHasSingleSource pins the taxonomy to one place. +// A second hand-maintained copy of the class list lets the two diverge silently: +// a shared_surface carrying a newly added class would be rejected while a +// fingerprint carrying the same class passes. +func TestAISecurityContextL0TaxonomyHasSingleSource(t *testing.T) { + defs, raw := readL0Taxonomy(t) + taxonomy := defs.Definitions.L0Class.Enum + + want := l0ClassPathPattern(taxonomy) + require.Equal(t, want, defs.Definitions.L0ClassPath.Pattern, + "l0_class_path.pattern is derived from the l0_class enum; regenerate it after editing the taxonomy") + + // The derived pattern repeats the alternation twice (first segment, then the + // slash-joined tail) and nothing else in the document may spell out the + // taxonomy. Any other occurrence is a copy that can drift. + alternation := strings.Join(taxonomy, "|") + require.Equal(t, 2, strings.Count(string(raw), alternation), + "the taxonomy must only be spelled out in l0_class_path.pattern; reference #/definitions/l0_class or #/definitions/l0_class_path instead") +} + +// TestValidateSecurityContextL0Class checks that the enum and the derived pattern +// accept the same taxonomy, in both the single-token position (fingerprints) and +// the slash-joined position (shared surfaces). +func TestValidateSecurityContextL0Class(t *testing.T) { + defs, _ := readL0Taxonomy(t) + taxonomy := defs.Definitions.L0Class.Enum + + // loadPayload returns a fresh decode per case so mutations do not leak. The + // validator consumes generically decoded JSON, so a map is the input form here. + loadPayload := func(t *testing.T) map[string]any { + t.Helper() + f, err := os.ReadFile("./testdata/ai_security_context_valid.json") + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(f, &payload)) + return payload + } + + setClass := func(t *testing.T, payload map[string]any, section, class string) { + t.Helper() + entries, ok := payload[section].([]any) + require.True(t, ok, "%s must be a populated array in the fixture", section) + require.NotEmpty(t, entries) + + entry, ok := entries[0].(map[string]any) + require.True(t, ok) + entry["class"] = class + } + + for _, class := range taxonomy { + t.Run("fingerprint accepts "+class, func(t *testing.T) { + payload := loadPayload(t) + setClass(t, payload, "fingerprints", class) + require.NoError(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + + t.Run("shared surface accepts "+class, func(t *testing.T) { + payload := loadPayload(t) + setClass(t, payload, "shared_surfaces", class) + require.NoError(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + } + + t.Run("shared surface accepts a slash-joined cluster", func(t *testing.T) { + require.GreaterOrEqual(t, len(taxonomy), 2) + + payload := loadPayload(t) + setClass(t, payload, "shared_surfaces", strings.Join(taxonomy, "/")) + require.NoError(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + + t.Run("shared surface accepts an empty class", func(t *testing.T) { + payload := loadPayload(t) + setClass(t, payload, "shared_surfaces", "") + require.NoError(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + + rejected := []struct { + name string + section string + class string + }{ + {name: "fingerprint rejects a class outside the taxonomy", section: "fingerprints", class: "race_condition"}, + {name: "shared surface rejects a class outside the taxonomy", section: "shared_surfaces", class: "race_condition"}, + {name: "shared surface rejects an unknown segment in a cluster", section: "shared_surfaces", class: taxonomy[0] + "/race_condition"}, + {name: "shared surface rejects a trailing separator", section: "shared_surfaces", class: taxonomy[0] + "/"}, + } + + for _, tc := range rejected { + t.Run(tc.name, func(t *testing.T) { + payload := loadPayload(t) + setClass(t, payload, tc.section, tc.class) + require.Error(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + } +} From 869f98f02f07d879c80cf5940d76e5d63a57a1d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Wed, 26 Aug 2026 19:30:36 -0300 Subject: [PATCH 3/4] fix(materials): publish the AI security context scanner through the shared tool annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crafter announced the producing scanner under a key private to this material kind, which left it invisible to generic policies and to tooling that reads tool identity the same way for every kind. It also surfaced only the version, so an attested material carried a bare version string naming no tool at all, even though the payload already required the tool name. Emit both through the shared vocabulary instead: chainloop.material.tools, plus the legacy name and version keys, matching what the CycloneDX and SPDX crafters already do. The annotations that are genuinely specific to a security context — head SHA, fingerprint count and whether the scan funnel reconciles — stay as they were. Chainloop-Trace-Sessions: 3f365023-61a3-4af3-bbe9-03688f152834, df945bde-e982-472c-bb81-50055df403bb Signed-off-by: Matías Insaurralde --- .../chainloop_ai_security_context.go | 36 +++-- .../chainloop_ai_security_context_test.go | 125 +++++++++++++++++- 2 files changed, 146 insertions(+), 15 deletions(-) diff --git a/pkg/attestation/crafter/materials/chainloop_ai_security_context.go b/pkg/attestation/crafter/materials/chainloop_ai_security_context.go index 2dc591a04..1d4ee9504 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_security_context.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_security_context.go @@ -33,7 +33,6 @@ import ( var ( annotationSecurityContextHeadSHA = api.CreateAnnotation("material.securitycontext.head_sha") - annotationSecurityContextToolVersion = api.CreateAnnotation("material.securitycontext.tool_version") annotationSecurityContextFingerprints = api.CreateAnnotation("material.securitycontext.fingerprints") annotationSecurityContextReconciles = api.CreateAnnotation("material.securitycontext.reconciles") ) @@ -133,14 +132,7 @@ func (c *ChainloopAISecurityContextCrafter) annotate(material *api.Attestation_M material.Annotations[annotationSecurityContextHeadSHA] = data.Repo.HeadSHA } - if v := data.Provenance.ToolVersion; v != "" { - material.Annotations[annotationSecurityContextToolVersion] = v - if v == "dev" { - // A development build carries no version, so the context cannot be - // traced back to the code that produced it. - c.logger.Warn().Msg("the AI security context was produced by a 'dev' build of the scanner") - } - } + c.annotateTool(material, &data.Provenance) material.Annotations[annotationSecurityContextFingerprints] = strconv.Itoa(len(data.Fingerprints)) @@ -149,3 +141,29 @@ func (c *ChainloopAISecurityContextCrafter) annotate(material *api.Attestation_M // a policy can reject it without reading the payload. material.Annotations[annotationSecurityContextReconciles] = strconv.FormatBool(data.Scan.Reconciles) } + +// annotateTool publishes the scanner through the shared material-tool +// vocabulary rather than a key private to this kind, so that a generic policy +// or any tooling that reads tool identity the same way for every material kind +// can see it. +func (c *ChainloopAISecurityContextCrafter) annotateTool(material *api.Attestation_Material, provenance *aisecuritycontext.Provenance) { + if provenance.ToolVersion == "dev" { + // A development build carries no version, so the context cannot be + // traced back to the code that produced it. + c.logger.Warn().Msg("the AI security context was produced by a 'dev' build of the scanner") + } + + // The schema requires both keys but constrains neither to be non-empty. An + // empty name would render as a bare "@version" entry, which names no tool. + if provenance.Tool == "" { + return + } + + SetToolsAnnotation(material, []Tool{{Name: provenance.Tool, Version: provenance.ToolVersion}}) + + // Maintain backward compatibility - keep legacy keys for the first tool + material.Annotations[AnnotationToolNameKey] = provenance.Tool + if provenance.ToolVersion != "" { + material.Annotations[AnnotationToolVersionKey] = provenance.ToolVersion + } +} diff --git a/pkg/attestation/crafter/materials/chainloop_ai_security_context_test.go b/pkg/attestation/crafter/materials/chainloop_ai_security_context_test.go index fa02b764f..860a0092b 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_security_context_test.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_security_context_test.go @@ -23,6 +23,7 @@ import ( "testing" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + api "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/v1" "github.com/chainloop-dev/chainloop/pkg/casclient" mUploader "github.com/chainloop-dev/chainloop/pkg/casclient/mocks" "github.com/rs/zerolog" @@ -99,8 +100,8 @@ func TestChainloopAISecurityContextCrafter_Craft(t *testing.T) { }, { // additionalProperties: false at every level is what makes an explicit - // --kind fail loudly on the wrong file, since this kind is excluded - // from auto-detection. + // --kind fail loudly on the wrong file, and what keeps auto-detection + // from accepting a near-miss. name: "an unknown field inside a fingerprint", path: "./testdata/ai-security-context-extra-field.json", wantErr: "additionalProperties 'unexpected_field' not allowed", @@ -210,7 +211,6 @@ func TestChainloopAISecurityContextCrafter_Annotations(t *testing.T) { name string path string headSHA string - toolVersion string fingerprints string reconciles string }{ @@ -218,7 +218,6 @@ func TestChainloopAISecurityContextCrafter_Annotations(t *testing.T) { name: "a full security context", path: "./testdata/ai-security-context.json", headSHA: "49d3dd9a8b53f4988cf43d1d824fb3b14f808fcb", - toolVersion: "dev", fingerprints: "12", reconciles: "true", }, @@ -226,7 +225,6 @@ func TestChainloopAISecurityContextCrafter_Annotations(t *testing.T) { name: "a security context with a single fingerprint", path: "./testdata/ai-security-context-minimal.json", headSHA: "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", - toolVersion: "dev", fingerprints: "1", reconciles: "true", }, @@ -238,13 +236,128 @@ func TestChainloopAISecurityContextCrafter_Annotations(t *testing.T) { require.NoError(t, err) assert.Equal(t, tc.headSHA, got.Annotations[annotationSecurityContextHeadSHA]) - assert.Equal(t, tc.toolVersion, got.Annotations[annotationSecurityContextToolVersion]) assert.Equal(t, tc.fingerprints, got.Annotations[annotationSecurityContextFingerprints]) assert.Equal(t, tc.reconciles, got.Annotations[annotationSecurityContextReconciles]) }) } } +// TestChainloopAISecurityContextCrafter_ToolAnnotations pins the producer to the +// shared material-tool vocabulary. A private key would make the scanner +// invisible to generic policies and to any tooling that reads tool identity the +// same way for every material kind. +func TestChainloopAISecurityContextCrafter_ToolAnnotations(t *testing.T) { + testCases := []struct { + name string + path string + wantTools string + wantName string + wantVersion string + }{ + { + name: "a full security context", + path: "./testdata/ai-security-context.json", + wantTools: `["strata-go@dev"]`, + wantName: "strata-go", + wantVersion: "dev", + }, + { + name: "a security context with a single fingerprint", + path: "./testdata/ai-security-context-minimal.json", + wantTools: `["strata-go@dev"]`, + wantName: "strata-go", + wantVersion: "dev", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := newSecurityContextCrafter(t).Craft(context.TODO(), tc.path) + require.NoError(t, err) + + assert.Equal(t, tc.wantTools, got.Annotations[AnnotationToolsKey]) + assert.Equal(t, tc.wantName, got.Annotations[AnnotationToolNameKey]) + assert.Equal(t, tc.wantVersion, got.Annotations[AnnotationToolVersionKey]) + + // The private key this replaced must not linger: two sources of tool + // identity is how consumers end up reading the wrong one. + assert.NotContains(t, got.Annotations, api.CreateAnnotation("material.securitycontext.tool_version")) + }) + } +} + +// TestChainloopAISecurityContextCrafter_ToolAnnotationsIncomplete covers the +// producer emitting a blank tool name or version. The schema requires both keys +// but constrains neither to be non-empty, so a partial value must not turn into +// a malformed "@version" entry in the shared vocabulary. +func TestChainloopAISecurityContextCrafter_ToolAnnotationsIncomplete(t *testing.T) { + testCases := []struct { + name string + tool string + toolVersion string + wantTools string + wantName string + wantVersion string + }{ + { + name: "no tool name", + tool: "", + toolVersion: "1.2.3", + wantTools: "", + wantName: "", + wantVersion: "", + }, + { + name: "no tool version", + tool: "strata-go", + toolVersion: "", + wantTools: `["strata-go"]`, + wantName: "strata-go", + wantVersion: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + path := securityContextWithProvenance(t, tc.tool, tc.toolVersion) + + got, err := newSecurityContextCrafter(t).Craft(context.TODO(), path) + require.NoError(t, err) + + assert.Equal(t, tc.wantTools, got.Annotations[AnnotationToolsKey]) + assert.Equal(t, tc.wantName, got.Annotations[AnnotationToolNameKey]) + assert.Equal(t, tc.wantVersion, got.Annotations[AnnotationToolVersionKey]) + }) + } +} + +// securityContextWithProvenance rewrites the minimal fixture's producing tool and +// returns the path to the rewritten file. +func securityContextWithProvenance(t *testing.T, tool, toolVersion string) string { + t.Helper() + + raw, err := os.ReadFile("./testdata/ai-security-context-minimal.json") + require.NoError(t, err) + + var doc map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &doc)) + + var data map[string]json.RawMessage + require.NoError(t, json.Unmarshal(doc["data"], &data)) + + var provenance map[string]any + require.NoError(t, json.Unmarshal(data["provenance"], &provenance)) + provenance["tool"] = tool + provenance["tool_version"] = toolVersion + + data["provenance"] = mustMarshal(t, provenance) + doc["data"] = mustMarshal(t, data) + + path := filepath.Join(t.TempDir(), "security-context.json") + require.NoError(t, os.WriteFile(path, mustMarshal(t, doc), 0o600)) + return path +} + // TestChainloopAISecurityContextCrafter_ReconcilesIsAlwaysAnnotated pins the one // annotation that must be present even when false: a funnel that does not // reconcile means the scan is incomplete, and a policy can only reject that if From 5ee9f3a04a41341da6be5aac8deb098b6b74d876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Wed, 26 Aug 2026 19:32:22 -0300 Subject: [PATCH 4/4] fix(materials): auto-detect the AI security context kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kind was held out of the auto-detection order on the grounds that its evidence id is only reachable after a full parse. Detection already crafts each kind in turn and parses the file completely, so that constraint does not exist, and the crafter matches on the exact evidence id and schema URL before it accepts anything — a stricter fingerprint than the two sibling AI kinds already in the list, neither of which inspects the envelope at all. The exclusion did not make a contract-free `attestation add` ask for an explicit --kind either. Nothing ahead of it matched, so the file fell through to the ARTIFACT catch-all and was recorded as a generic artifact, losing both the schema validation and the annotations the kind exists to publish. Add it alongside the other CHAINLOOP_AI_* entries and replace the exclusion assertion with a positive detection test. Chainloop-Trace-Sessions: 3f365023-61a3-4af3-bbe9-03688f152834, cc2bbc73-4145-4e0a-a516-ebbe5d42ca81, df945bde-e982-472c-bb81-50055df403bb Signed-off-by: Matías Insaurralde --- .../api/workflowcontract/v1/crafting_schema_test.go | 4 ---- .../workflowcontract/v1/crafting_schema_validations.go | 5 +---- pkg/attestation/crafter/crafter_test.go | 8 ++++++++ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema_test.go b/app/controlplane/api/workflowcontract/v1/crafting_schema_test.go index 5e5f18c03..d0b3734e2 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema_test.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema_test.go @@ -326,10 +326,6 @@ func TestMaterialKindsExcludedFromAutoDetection(t *testing.T) { name: "oversecured export envelope is not published by the vendor", kind: v1.CraftingSchema_Material_OVERSECURED_JSON, }, - { - name: "chainloop AI security context is generic JSON in an envelope", - kind: v1.CraftingSchema_Material_CHAINLOOP_AI_SECURITY_CONTEXT, - }, { name: "radamsa report", kind: v1.CraftingSchema_Material_RADAMSA_REPORT, diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go b/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go index 28a6289fe..99db20ae6 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go @@ -42,10 +42,6 @@ import ( // an app platform), but the export envelope is not published by the vendor, // so pinning it here would bet every other JSON kind on a shape inferred // from a sample. -// - CHAINLOOP_AI_SECURITY_CONTEXT: generic JSON inside the Chainloop evidence -// envelope, so it would shadow (or be shadowed by) other JSON kinds. The -// envelope's evidence id is a strong fingerprint, but it is only reachable -// after a full parse, which auto-detection ordering cannot rely on. // // TestMaterialKindsExcludedFromAutoDetection pins these exclusions. var CraftingMaterialInValidationOrder = []CraftingSchema_Material_MaterialType{ @@ -77,6 +73,7 @@ var CraftingMaterialInValidationOrder = []CraftingSchema_Material_MaterialType{ CraftingSchema_Material_CHAINLOOP_RUNNER_CONTEXT, CraftingSchema_Material_CHAINLOOP_AI_AGENT_CONFIG, CraftingSchema_Material_CHAINLOOP_AI_CODING_SESSION, + CraftingSchema_Material_CHAINLOOP_AI_SECURITY_CONTEXT, CraftingSchema_Material_ATTESTATION, CraftingSchema_Material_CONTAINER_IMAGE, CraftingSchema_Material_ARTIFACT, diff --git a/pkg/attestation/crafter/crafter_test.go b/pkg/attestation/crafter/crafter_test.go index 46e8decde..3a8946f03 100644 --- a/pkg/attestation/crafter/crafter_test.go +++ b/pkg/attestation/crafter/crafter_test.go @@ -664,6 +664,14 @@ func (s *crafterSuite) TestAddMaterialsAutomatic() { materialPath: "./materials/testdata/junit.xml", expectedType: schemaapi.CraftingSchema_Material_JUNIT_XML, }, + { + // Detection must reach this kind rather than falling through to the + // ARTIFACT catch-all, which would silently drop both the schema + // validation and the annotations the kind exists to publish. + name: "chainloop AI security context", + materialPath: "./materials/testdata/ai-security-context.json", + expectedType: schemaapi.CraftingSchema_Material_CHAINLOOP_AI_SECURITY_CONTEXT, + }, { name: "artifact", materialPath: "./materials/testdata/missing-empty.tgz",