From 8cffc33d718bf6da7da53fd3fd5947e17511371a Mon Sep 17 00:00:00 2001 From: Javier Rodriguez Date: Wed, 17 Jun 2026 13:49:19 +0200 Subject: [PATCH] feat(materials): add RADAMSA_REPORT and RADAMSA_CRASHES material types Add two material types for radamsa fuzzing evidence: - RADAMSA_REPORT: radamsa -M metadata log, projected to input.elements for policy evaluation - RADAMSA_CRASHES: crashing inputs (single file or tar.gz/zip archive), metadata-only with a crash-count annotation Includes a shared -M log parser subpackage, both crafters, and policy-engine content handling that treats crash content as metadata-only (excluded from filesystem reads and discarded when inline). Assisted-by: Claude Code Signed-off-by: Javier Rodriguez Chainloop-Trace-Sessions: 72f7fee5-d0c0-4328-a27b-319e49087aa6 Signed-off-by: Javier Rodriguez --- app/cli/documentation/cli-reference.mdx | 4 +- .../workflowcontract/v1/crafting_schema.ts | 17 ++ ...on.v1.Attestation.Material.jsonschema.json | 8 +- ...tation.v1.Attestation.Material.schema.json | 8 +- ...tation.v1.PolicyEvaluation.jsonschema.json | 4 +- ...ttestation.v1.PolicyEvaluation.schema.json | 4 +- ...v1.CraftingSchema.Material.jsonschema.json | 4 +- ...act.v1.CraftingSchema.Material.schema.json | 4 +- ...ct.v1.PolicyGroup.Material.jsonschema.json | 4 +- ...ntract.v1.PolicyGroup.Material.schema.json | 4 +- ...flowcontract.v1.PolicySpec.jsonschema.json | 4 +- ...workflowcontract.v1.PolicySpec.schema.json | 4 +- ...owcontract.v1.PolicySpecV2.jsonschema.json | 4 +- ...rkflowcontract.v1.PolicySpecV2.schema.json | 4 +- .../workflowcontract/v1/crafting_schema.pb.go | 19 +- .../workflowcontract/v1/crafting_schema.proto | 5 + .../v1/crafting_schema_validations.go | 4 + .../api/attestation/v1/crafting_state.go | 20 +- .../api/attestation/v1/crafting_state_test.go | 43 ++++ .../attestation/v1/testdata/radamsa-meta.txt | 3 + .../crafter/materials/materials.go | 4 + pkg/attestation/crafter/materials/radamsa.go | 211 ++++++++++++++++++ .../crafter/materials/radamsa/parse.go | 126 +++++++++++ .../crafter/materials/radamsa/parse_test.go | 77 +++++++ .../crafter/materials/radamsa_test.go | 186 +++++++++++++++ .../testdata/radamsa-meta-invalid.txt | 1 + .../materials/testdata/radamsa-meta.txt | 3 + 27 files changed, 757 insertions(+), 22 deletions(-) create mode 100644 pkg/attestation/crafter/api/attestation/v1/testdata/radamsa-meta.txt create mode 100644 pkg/attestation/crafter/materials/radamsa.go create mode 100644 pkg/attestation/crafter/materials/radamsa/parse.go create mode 100644 pkg/attestation/crafter/materials/radamsa/parse_test.go create mode 100644 pkg/attestation/crafter/materials/radamsa_test.go create mode 100644 pkg/attestation/crafter/materials/testdata/radamsa-meta-invalid.txt create mode 100644 pkg/attestation/crafter/materials/testdata/radamsa-meta.txt diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 441e80313..fd5105607 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -252,7 +252,7 @@ Options --annotation strings additional annotation in the format of key=value --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" "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" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "SYSINTERNALS_ACCESSCHK" "SYSINTERNALS_SIGCHECK" "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_PR_INFO" "CHAINLOOP_RUNNER_CONTEXT" "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" "RADAMSA_CRASHES" "RADAMSA_REPORT" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "SYSINTERNALS_ACCESSCHK" "SYSINTERNALS_SIGCHECK" "TWISTCLI_SCAN_JSON" "YELP_DETECT_SECRETS_BASELINE" "ZAP_DAST_ZIP"] --name string name of the material as shown in the contract --no-strict-validation skip strict schema validation for structured materials (SBOM_CYCLONEDX_JSON, OPENAPI_SPEC, ASYNCAPI_SPEC, OSSF_SCORECARD_JSON) --registry-password string registry password, ($CHAINLOOP_REGISTRY_PASSWORD) @@ -3025,7 +3025,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" "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" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "SYSINTERNALS_ACCESSCHK" "SYSINTERNALS_SIGCHECK" "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_PR_INFO" "CHAINLOOP_RUNNER_CONTEXT" "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" "RADAMSA_CRASHES" "RADAMSA_REPORT" "SARIF" "SBOM_CYCLONEDX_JSON" "SBOM_SPDX_JSON" "SLSA_PROVENANCE" "STRING" "SYSINTERNALS_ACCESSCHK" "SYSINTERNALS_SIGCHECK" "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 a651fd836..a0a2524b2 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -294,6 +294,13 @@ export enum CraftingSchema_Material_MaterialType { * https://github.com/ossf/scorecard */ OSSF_SCORECARD_JSON = 37, + /** + * RADAMSA_REPORT - radamsa -M metadata log, one record per generated iteration + * https://gitlab.com/akihe/radamsa + */ + RADAMSA_REPORT = 38, + /** RADAMSA_CRASHES - radamsa crashing inputs, a single file or a crashes/ archive (tar.gz or zip) */ + RADAMSA_CRASHES = 39, UNRECOGNIZED = -1, } @@ -413,6 +420,12 @@ export function craftingSchema_Material_MaterialTypeFromJSON(object: any): Craft case 37: case "OSSF_SCORECARD_JSON": return CraftingSchema_Material_MaterialType.OSSF_SCORECARD_JSON; + case 38: + case "RADAMSA_REPORT": + return CraftingSchema_Material_MaterialType.RADAMSA_REPORT; + case 39: + case "RADAMSA_CRASHES": + return CraftingSchema_Material_MaterialType.RADAMSA_CRASHES; case -1: case "UNRECOGNIZED": default: @@ -498,6 +511,10 @@ export function craftingSchema_Material_MaterialTypeToJSON(object: CraftingSchem return "CERTCC_DRANZER"; case CraftingSchema_Material_MaterialType.OSSF_SCORECARD_JSON: return "OSSF_SCORECARD_JSON"; + case CraftingSchema_Material_MaterialType.RADAMSA_REPORT: + return "RADAMSA_REPORT"; + case CraftingSchema_Material_MaterialType.RADAMSA_CRASHES: + return "RADAMSA_CRASHES"; 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 028785b49..2dd75c0f7 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 @@ -54,7 +54,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "title": "Material Type", "type": "string" @@ -144,7 +146,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "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 6438474d1..987accfd5 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 @@ -54,7 +54,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "title": "Material Type", "type": "string" @@ -144,7 +146,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "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 0601c9bfb..64d4294cb 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json @@ -151,7 +151,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "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 d300fb6fe..a19e8086d 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json @@ -151,7 +151,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "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 0c1211b28..7cc29ba71 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 @@ -71,7 +71,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "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 aac3c6b33..973a4c934 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 @@ -71,7 +71,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "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 8099ae814..27a3cf626 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 @@ -59,7 +59,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "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 2546bef37..8b33e9196 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 @@ -59,7 +59,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "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 f0646d2e7..ae7ea5f26 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.jsonschema.json @@ -73,7 +73,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "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 f93331e63..b5e39c824 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.schema.json @@ -73,7 +73,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "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 f6e63ebcb..fd618ba1d 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json @@ -94,7 +94,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "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 4b0b040cb..c983ba9b4 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json @@ -94,7 +94,9 @@ "SYSINTERNALS_SIGCHECK", "SYSINTERNALS_ACCESSCHK", "CERTCC_DRANZER", - "OSSF_SCORECARD_JSON" + "OSSF_SCORECARD_JSON", + "RADAMSA_REPORT", + "RADAMSA_CRASHES" ], "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 5ec49ba85..a6a87cb57 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -234,6 +234,11 @@ const ( // OpenSSF Scorecard result in JSON format // https://github.com/ossf/scorecard CraftingSchema_Material_OSSF_SCORECARD_JSON CraftingSchema_Material_MaterialType = 37 + // radamsa -M metadata log, one record per generated iteration + // https://gitlab.com/akihe/radamsa + CraftingSchema_Material_RADAMSA_REPORT CraftingSchema_Material_MaterialType = 38 + // radamsa crashing inputs, a single file or a crashes/ archive (tar.gz or zip) + CraftingSchema_Material_RADAMSA_CRASHES CraftingSchema_Material_MaterialType = 39 ) // Enum value maps for CraftingSchema_Material_MaterialType. @@ -277,6 +282,8 @@ var ( 35: "SYSINTERNALS_ACCESSCHK", 36: "CERTCC_DRANZER", 37: "OSSF_SCORECARD_JSON", + 38: "RADAMSA_REPORT", + 39: "RADAMSA_CRASHES", } CraftingSchema_Material_MaterialType_value = map[string]int32{ "MATERIAL_TYPE_UNSPECIFIED": 0, @@ -317,6 +324,8 @@ var ( "SYSINTERNALS_ACCESSCHK": 35, "CERTCC_DRANZER": 36, "OSSF_SCORECARD_JSON": 37, + "RADAMSA_REPORT": 38, + "RADAMSA_CRASHES": 39, } ) @@ -1978,7 +1987,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\"\xbe\x10\n" + + ")workflowcontract/v1/crafting_schema.proto\x12\x13workflowcontract.v1\x1a\x1bbuf/validate/validate.proto\"\xe7\x10\n" + "\x0eCraftingSchema\x122\n" + "\x0eschema_version\x18\x01 \x01(\tB\v\xbaH\x06r\x04\n" + "\x02v1\x18\x01R\rschemaVersion\x12N\n" + @@ -2001,7 +2010,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\x89\n" + + "\x11CHAINLOOP_SANDBOX\x10\t:\x02\x18\x01\x1a\xb2\n" + "\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" + @@ -2011,7 +2020,7 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x06output\x18\x04 \x01(\bB\x02\x18\x01R\x06output\x12E\n" + "\vannotations\x18\x05 \x03(\v2\x1f.workflowcontract.v1.AnnotationB\x02\x18\x01R\vannotations\x12\x1f\n" + "\vskip_upload\x18\x06 \x01(\bR\n" + - "skipUpload\"\xdb\x06\n" + + "skipUpload\"\x84\a\n" + "\fMaterialType\x12\x1d\n" + "\x19MATERIAL_TYPE_UNSPECIFIED\x10\x00\x12\n" + "\n" + @@ -2054,7 +2063,9 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x15SYSINTERNALS_SIGCHECK\x10\"\x12\x1a\n" + "\x16SYSINTERNALS_ACCESSCHK\x10#\x12\x12\n" + "\x0eCERTCC_DRANZER\x10$\x12\x17\n" + - "\x13OSSF_SCORECARD_JSON\x10%:\x02\x18\x01:\x02\x18\x01\"\xfb\x01\n" + + "\x13OSSF_SCORECARD_JSON\x10%\x12\x12\n" + + "\x0eRADAMSA_REPORT\x10&\x12\x13\n" + + "\x0fRADAMSA_CRASHES\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 65bba3ec9..f2455d2bb 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -175,6 +175,11 @@ message CraftingSchema { // OpenSSF Scorecard result in JSON format // https://github.com/ossf/scorecard OSSF_SCORECARD_JSON = 37; + // radamsa -M metadata log, one record per generated iteration + // https://gitlab.com/akihe/radamsa + RADAMSA_REPORT = 38; + // radamsa crashing inputs, a single file or a crashes/ archive (tar.gz or zip) + RADAMSA_CRASHES = 39; } } } diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go b/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go index 6a5531475..1be8f9645 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go @@ -48,6 +48,10 @@ var CraftingMaterialInValidationOrder = []CraftingSchema_Material_MaterialType{ CraftingSchema_Material_GRAPHQL_SPEC, CraftingSchema_Material_JUNIT_XML, CraftingSchema_Material_JACOCO_XML, + // NOTE: RADAMSA_REPORT and RADAMSA_CRASHES are intentionally omitted from + // auto-detection. RADAMSA_CRASHES single-file mode accepts almost any + // non-empty file and would eagerly shadow other types; both work fine when + // referenced with an explicit kind in a workflow contract. CraftingSchema_Material_HELM_CHART, CraftingSchema_Material_SARIF, CraftingSchema_Material_BLACKDUCK_SCA_JSON, diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state.go b/pkg/attestation/crafter/api/attestation/v1/crafting_state.go index 0a8b52196..3512fb801 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.go +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.go @@ -30,6 +30,7 @@ import ( "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/dranzer" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/jacoco" materialsjunit "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/junit" + materialsradamsa "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/radamsa" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/sigcheck" intoto "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/types/known/structpb" @@ -102,8 +103,10 @@ func (m *Attestation_Material) GetEvaluableContent(value string) ([]byte, error) } else if value == "" { return nil, errors.New("artifact path required") } else if m.MaterialType != v1.CraftingSchema_Material_HELM_CHART && - m.MaterialType != v1.CraftingSchema_Material_JUNIT_XML { - // read content from local filesystem (except for tgz charts) + m.MaterialType != v1.CraftingSchema_Material_JUNIT_XML && + m.MaterialType != v1.CraftingSchema_Material_RADAMSA_CRASHES { + // read content from local filesystem (except for tgz charts and + // metadata-only materials like radamsa crashes) rawMaterial, err = os.ReadFile(value) if err != nil { return nil, fmt.Errorf("failed to read material content: %w", err) @@ -182,6 +185,19 @@ func (m *Attestation_Material) ingestMaterialToJSON(rawMaterial []byte, value st } // this will render a json array return json.Marshal(suites) + case v1.CraftingSchema_Material_RADAMSA_REPORT: + // radamsa's -M metadata log is one record per generated iteration; render + // it as a JSON array so the policy engine exposes it as input.elements. + records, err := materialsradamsa.Parse(bytes.NewReader(rawMaterial)) + if err != nil { + return nil, fmt.Errorf("invalid radamsa -M metadata log: %w", err) + } + return json.Marshal(records) + case v1.CraftingSchema_Material_RADAMSA_CRASHES: + // metadata-only: the crash content (single binary file or archive) is + // never evaluated. Discard it so inline content is not parsed as JSON; + // policies read the crash count from chainloop_metadata.annotations. + return []byte("{}"), nil case v1.CraftingSchema_Material_JACOCO_XML: var report jacoco.Report if err := xml.Unmarshal(rawMaterial, &report); err != nil { diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go b/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go index 69bf1cbd7..c534ac0d6 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go @@ -240,6 +240,49 @@ func TestGetEvaluableContentWithMetadata(t *testing.T) { }, testField: "objects", }, + { + name: "radamsa report -M log projected to elements", + material: &Attestation_Material{ + MaterialType: schemaapi.CraftingSchema_Material_RADAMSA_REPORT, + M: &Attestation_Material_Artifact_{ + Artifact: &Attestation_Material_Artifact{ + Name: "name", Digest: "sha256:deadbeef", IsSubject: true, + }, + }, + }, + filename: "testdata/radamsa-meta.txt", + testField: "elements", + }, + { + // metadata-only: the (non-existent) crashes path must NOT be read. + name: "radamsa crashes metadata only", + material: &Attestation_Material{ + MaterialType: schemaapi.CraftingSchema_Material_RADAMSA_CRASHES, + M: &Attestation_Material_Artifact_{ + Artifact: &Attestation_Material_Artifact{ + Name: "name", Digest: "sha256:deadbeef", IsSubject: true, + }, + }, + Annotations: map[string]string{"chainloop.material.radamsa.crashes.count": "0"}, + }, + filename: "testdata/this-crashes-file-does-not-exist.tar.gz", + }, + { + // inline binary crash content must NOT be parsed as JSON; it is + // metadata-only regardless of how the content is sourced. + name: "radamsa crashes inline binary content", + material: &Attestation_Material{ + MaterialType: schemaapi.CraftingSchema_Material_RADAMSA_CRASHES, + M: &Attestation_Material_Artifact_{ + Artifact: &Attestation_Material_Artifact{ + Name: "name", Digest: "sha256:deadbeef", IsSubject: true, + Content: []byte("\x1f\x8b\x08\x00rawcrashingbytes"), + }, + }, + InlineCas: true, + Annotations: map[string]string{"chainloop.material.radamsa.crashes.count": "1"}, + }, + }, } for _, tc := range cases { diff --git a/pkg/attestation/crafter/api/attestation/v1/testdata/radamsa-meta.txt b/pkg/attestation/crafter/api/attestation/v1/testdata/radamsa-meta.txt new file mode 100644 index 000000000..3f37d11e1 --- /dev/null +++ b/pkg/attestation/crafter/api/attestation/v1/testdata/radamsa-meta.txt @@ -0,0 +1,3 @@ +seed: 705693910129640559698481 +muta-num: 1, generator: file, checksum: "CF5DA754A292766FAA5465FD", nth: 1, path: "/tmp/m/sample_1.eds", output: file-writer, length: 16892, pattern: many-dec +byte-dec: 1, generator: jump, head: "/tmp/t/sample.eds", checksum: "F2F767F4D2E28596BD5BD982", nth: 2, output: file-writer, length: 17199, pattern: many-dec diff --git a/pkg/attestation/crafter/materials/materials.go b/pkg/attestation/crafter/materials/materials.go index b47864cee..b320be610 100644 --- a/pkg/attestation/crafter/materials/materials.go +++ b/pkg/attestation/crafter/materials/materials.go @@ -307,6 +307,10 @@ func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Materia crafter, err = NewDranzerCrafter(materialSchema, casBackend, logger) case schemaapi.CraftingSchema_Material_OSSF_SCORECARD_JSON: crafter, err = NewOSSFScorecardCrafter(materialSchema, casBackend, logger, WithOSSFScorecardNoStrictValidation(opts.NoStrictValidation)) + case schemaapi.CraftingSchema_Material_RADAMSA_REPORT: + crafter, err = NewRadamsaReportCrafter(materialSchema, casBackend, logger) + case schemaapi.CraftingSchema_Material_RADAMSA_CRASHES: + crafter, err = NewRadamsaCrashesCrafter(materialSchema, casBackend, logger) default: return nil, fmt.Errorf("material of type %q not supported yet", materialSchema.Type) } diff --git a/pkg/attestation/crafter/materials/radamsa.go b/pkg/attestation/crafter/materials/radamsa.go new file mode 100644 index 000000000..f4b7516df --- /dev/null +++ b/pkg/attestation/crafter/materials/radamsa.go @@ -0,0 +1,211 @@ +// +// 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 ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "os" + "strconv" + + 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/attestation/crafter/materials/radamsa" + "github.com/chainloop-dev/chainloop/pkg/casclient" + "github.com/rs/zerolog" +) + +// AnnotationRadamsaCrashesCount is the annotation holding the number of crashing +// inputs recorded in a RADAMSA_CRASHES material (0 means no crash). +const AnnotationRadamsaCrashesCount = "chainloop.material.radamsa.crashes.count" + +const radamsaToolName = "radamsa" + +// RadamsaReportCrafter crafts a RADAMSA_REPORT material out of radamsa's -M +// metadata log. +type RadamsaReportCrafter struct { + *crafterCommon + backend *casclient.CASBackend +} + +func NewRadamsaReportCrafter(schema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, l *zerolog.Logger) (*RadamsaReportCrafter, error) { + if schema.Type != schemaapi.CraftingSchema_Material_RADAMSA_REPORT { + return nil, fmt.Errorf("material type is not a radamsa report") + } + return &RadamsaReportCrafter{ + backend: backend, + crafterCommon: &crafterCommon{logger: l, input: schema}, + }, nil +} + +func (c *RadamsaReportCrafter) Craft(ctx context.Context, filePath string) (*api.Attestation_Material, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("can't open the file: %w", err) + } + defer f.Close() + + if _, err := radamsa.Parse(f); err != nil { + return nil, fmt.Errorf("invalid radamsa -M metadata log: %w: %w", ErrInvalidMaterialType, err) + } + + m, err := uploadAndCraft(ctx, c.input, c.backend, filePath, c.logger) + if err != nil { + return nil, err + } + if m.Annotations == nil { + m.Annotations = make(map[string]string) + } + m.Annotations[AnnotationToolNameKey] = radamsaToolName + return m, nil +} + +// RadamsaCrashesCrafter crafts a RADAMSA_CRASHES material out of either a single +// crashing input or a crashes/ archive (tar.gz or zip). It is metadata-only: the +// crash count is recorded as an annotation rather than evaluated as content. +type RadamsaCrashesCrafter struct { + *crafterCommon + backend *casclient.CASBackend +} + +func NewRadamsaCrashesCrafter(schema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, l *zerolog.Logger) (*RadamsaCrashesCrafter, error) { + if schema.Type != schemaapi.CraftingSchema_Material_RADAMSA_CRASHES { + return nil, fmt.Errorf("material type is not radamsa crashes") + } + return &RadamsaCrashesCrafter{ + backend: backend, + crafterCommon: &crafterCommon{logger: l, input: schema}, + }, nil +} + +func (c *RadamsaCrashesCrafter) Craft(ctx context.Context, filePath string) (*api.Attestation_Material, error) { + info, err := os.Stat(filePath) + if err != nil { + return nil, fmt.Errorf("can't open the file: %w", err) + } + + isArchive, fileCount, err := inspectCrashesArchive(filePath) + if err != nil { + return nil, fmt.Errorf("reading crashes archive: %w", err) + } + + count := fileCount + if !isArchive { + // single crashing input: must be non-empty, counts as one crash. + if info.Size() == 0 { + return nil, fmt.Errorf("%w: crash file is empty", ErrInvalidMaterialType) + } + count = 1 + } + + m, err := uploadAndCraft(ctx, c.input, c.backend, filePath, c.logger) + if err != nil { + return nil, err + } + if m.Annotations == nil { + m.Annotations = make(map[string]string) + } + m.Annotations[AnnotationToolNameKey] = radamsaToolName + m.Annotations[AnnotationRadamsaCrashesCount] = strconv.Itoa(count) + return m, nil +} + +// inspectCrashesArchive reports whether path is a readable zip or tar.gz and, if +// so, how many regular-file entries it contains. A file that is not a valid +// archive returns (false, 0, nil) so the caller treats it as a single crash. +func inspectCrashesArchive(path string) (bool, int, error) { + magic, err := readMagic(path) + if err != nil { + return false, 0, err + } + switch { + // PK\x03\x04 local file header (entries present), PK\x05\x06 end-of-central + // -directory (an empty archive), PK\x07\x08 spanned-archive marker. + case bytes.HasPrefix(magic, []byte("PK\x03\x04")), + bytes.HasPrefix(magic, []byte("PK\x05\x06")), + bytes.HasPrefix(magic, []byte("PK\x07\x08")): + n, ok := countZipEntries(path) + return ok, n, nil + case bytes.HasPrefix(magic, []byte{0x1f, 0x8b}): + n, ok := countTarGzEntries(path) + return ok, n, nil + default: + return false, 0, nil + } +} + +func readMagic(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + buf := make([]byte, 4) + n, err := io.ReadFull(f, buf) + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) { + return nil, err + } + return buf[:n], nil +} + +func countZipEntries(path string) (int, bool) { + zr, err := zip.OpenReader(path) + if err != nil { + return 0, false + } + defer zr.Close() + count := 0 + for _, f := range zr.File { + if !f.FileInfo().IsDir() { + count++ + } + } + return count, true +} + +func countTarGzEntries(path string) (int, bool) { + f, err := os.Open(path) + if err != nil { + return 0, false + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return 0, false + } + defer gz.Close() + tr := tar.NewReader(gz) + count := 0 + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return 0, false + } + if hdr.Typeflag == tar.TypeReg { + count++ + } + } + return count, true +} diff --git a/pkg/attestation/crafter/materials/radamsa/parse.go b/pkg/attestation/crafter/materials/radamsa/parse.go new file mode 100644 index 000000000..5f002df1c --- /dev/null +++ b/pkg/attestation/crafter/materials/radamsa/parse.go @@ -0,0 +1,126 @@ +// +// 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 radamsa parses radamsa's -M metadata log into structured records. +package radamsa + +import ( + "bufio" + "errors" + "fmt" + "io" + "strconv" + "strings" +) + +// Parse reads a radamsa -M metadata log and returns one record per non-blank +// line. Each record is a map of key -> value, where quoted values are unquoted +// strings, integer-looking bare values are int64, and other bare tokens are +// strings. It errors if no parseable record is found. +func Parse(r io.Reader) ([]map[string]any, error) { + scanner := bufio.NewScanner(r) + // radamsa lines can be long (many fields); raise the buffer ceiling. + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + records := make([]map[string]any, 0) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + rec, err := parseLine(line) + if err != nil { + return nil, err + } + records = append(records, rec) + } + if err := scanner.Err(); err != nil { + return nil, err + } + if len(records) == 0 { + return nil, errors.New("no radamsa -M records found") + } + return records, nil +} + +func parseLine(line string) (map[string]any, error) { + rec := make(map[string]any) + for _, pair := range splitTopLevel(line) { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + key, rawVal, found := strings.Cut(pair, ": ") + if !found { + // allow a trailing "key:" with no value, but a token with no colon + // at all is not a valid meta-log pair. + trimmed, ok := strings.CutSuffix(pair, ":") + if !ok { + return nil, fmt.Errorf("invalid radamsa -M pair: %q", pair) + } + key = trimmed + } + rec[strings.TrimSpace(key)] = parseValue(strings.TrimSpace(rawVal)) + } + if len(rec) == 0 { + return nil, fmt.Errorf("invalid radamsa -M line: %q", line) + } + return rec, nil +} + +// splitTopLevel splits on ", " but not inside double-quoted spans. +func splitTopLevel(line string) []string { + var parts []string + var b strings.Builder + inQuote := false + for i := 0; i < len(line); i++ { + c := line[i] + switch { + case c == '\\' && i+1 < len(line): + // Preserve an escaped character verbatim (e.g. \" inside a quoted + // value) so it neither flips the quote state nor acts as a delimiter; + // strconv.Unquote unescapes it later in parseValue. + b.WriteByte(c) + b.WriteByte(line[i+1]) + i++ + case c == '"': + inQuote = !inQuote + b.WriteByte(c) + case !inQuote && c == ',' && i+1 < len(line) && line[i+1] == ' ': + parts = append(parts, b.String()) + b.Reset() + i++ // skip the space + default: + b.WriteByte(c) + } + } + parts = append(parts, b.String()) + return parts +} + +func parseValue(v string) any { + if len(v) >= 2 && strings.HasPrefix(v, `"`) && strings.HasSuffix(v, `"`) { + unq, err := strconv.Unquote(v) + if err != nil { + // fall back to trimming the surrounding quotes on non-Go-escaped text + return strings.Trim(v, `"`) + } + return unq + } + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return n + } + return v +} diff --git a/pkg/attestation/crafter/materials/radamsa/parse_test.go b/pkg/attestation/crafter/materials/radamsa/parse_test.go new file mode 100644 index 000000000..ebae664be --- /dev/null +++ b/pkg/attestation/crafter/materials/radamsa/parse_test.go @@ -0,0 +1,77 @@ +// +// 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 radamsa_test + +import ( + "strings" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/radamsa" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + const sample = `seed: 705693910129640559698481 +muta-num: 1, generator: file, checksum: "CF5DA754A292766FAA5465FD", nth: 1, path: "/tmp/m/sample_1.eds", output: file-writer, length: 16892, pattern: many-dec +byte-dec: 1, generator: jump, head: "/tmp/t/sample.eds", checksum: "F2F767F4D2E28596BD5BD982", nth: 2, output: file-writer, length: 17199, pattern: many-dec +` + records, err := radamsa.Parse(strings.NewReader(sample)) + require.NoError(t, err) + require.Len(t, records, 3) + + // lone seed line: too large for int64, kept as string + assert.Equal(t, "705693910129640559698481", records[0]["seed"]) + + // quoted value unquoted; bare int typed as int64; identifier kept as string + assert.Equal(t, "CF5DA754A292766FAA5465FD", records[1]["checksum"]) + assert.EqualValues(t, 1, records[1]["nth"]) + assert.Equal(t, "file", records[1]["generator"]) + assert.Equal(t, "/tmp/m/sample_1.eds", records[1]["path"]) + + // heterogeneous keys + assert.Equal(t, "/tmp/t/sample.eds", records[2]["head"]) + _, hasSource := records[2]["source"] + assert.False(t, hasSource) +} + +func TestParse_QuotedCommaNotSplit(t *testing.T) { + records, err := radamsa.Parse(strings.NewReader(`nth: 1, note: "a, b, c", length: 5`)) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, "a, b, c", records[0]["note"]) + assert.EqualValues(t, 5, records[0]["length"]) +} + +func TestParse_EscapedQuoteInValue(t *testing.T) { + // radamsa writes string values with escaped embedded quotes (\"); the comma + // split must not treat the escaped quote as the end of the quoted span. + records, err := radamsa.Parse(strings.NewReader(`path: "a\"b, c", nth: 1`)) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, `a"b, c`, records[0]["path"]) + assert.EqualValues(t, 1, records[0]["nth"]) +} + +func TestParse_Empty(t *testing.T) { + _, err := radamsa.Parse(strings.NewReader(" \n\n")) + assert.Error(t, err) +} + +func TestParse_Garbage(t *testing.T) { + _, err := radamsa.Parse(strings.NewReader("this is not a meta log")) + assert.Error(t, err) +} diff --git a/pkg/attestation/crafter/materials/radamsa_test.go b/pkg/attestation/crafter/materials/radamsa_test.go new file mode 100644 index 000000000..1d749b853 --- /dev/null +++ b/pkg/attestation/crafter/materials/radamsa_test.go @@ -0,0 +1,186 @@ +// +// 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_test + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "os" + "path/filepath" + "testing" + + contractAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials" + "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 TestNewRadamsaReportCrafter(t *testing.T) { + tests := []struct { + name string + kind contractAPI.CraftingSchema_Material_MaterialType + wantErr bool + }{ + {name: "happy path", kind: contractAPI.CraftingSchema_Material_RADAMSA_REPORT}, + {name: "wrong type", kind: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := materials.NewRadamsaReportCrafter(&contractAPI.CraftingSchema_Material{Type: tc.kind}, nil, nil) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} + +func TestRadamsaReportCrafter_Craft(t *testing.T) { + tests := []struct { + name string + filePath string + wantErr string + }{ + {name: "invalid path", filePath: "./testdata/nope.log", wantErr: "no such file"}, + {name: "not a meta log", filePath: "./testdata/radamsa-meta-invalid.txt", wantErr: "invalid radamsa -M metadata log"}, + {name: "valid -M log", filePath: "./testdata/radamsa-meta.txt"}, + } + schema := &contractAPI.CraftingSchema_Material{Name: "report", Type: contractAPI.CraftingSchema_Material_RADAMSA_REPORT} + l := zerolog.Nop() + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + uploader := mUploader.NewUploader(t) + if tc.wantErr == "" { + uploader.On("Upload", context.TODO(), mock.Anything, mock.Anything, mock.Anything). + Return(&casclient.UpDownStatus{}, nil) + } + backend := &casclient.CASBackend{Uploader: uploader} + crafter, err := materials.NewRadamsaReportCrafter(schema, backend, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), tc.filePath) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, contractAPI.CraftingSchema_Material_RADAMSA_REPORT.String(), got.MaterialType.String()) + assert.Equal(t, "radamsa", got.Annotations["chainloop.material.tool.name"]) + assert.True(t, got.UploadedToCas) + }) + } +} + +func TestNewRadamsaCrashesCrafter(t *testing.T) { + _, err := materials.NewRadamsaCrashesCrafter(&contractAPI.CraftingSchema_Material{Type: contractAPI.CraftingSchema_Material_RADAMSA_CRASHES}, nil, nil) + assert.NoError(t, err) + _, err = materials.NewRadamsaCrashesCrafter(&contractAPI.CraftingSchema_Material{Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE}, nil, nil) + assert.Error(t, err) +} + +func TestRadamsaCrashesCrafter_Craft(t *testing.T) { + dir := t.TempDir() + emptyTar := filepath.Join(dir, "crashes-empty.tar.gz") + writeTarGz(t, emptyTar, nil) + twoTar := filepath.Join(dir, "crashes.tar.gz") + writeTarGz(t, twoTar, map[string][]byte{"c_1.eds": []byte("AAAA"), "c_2.eds": []byte("BBBB")}) + emptyZip := filepath.Join(dir, "crashes-empty.zip") + writeZip(t, emptyZip, nil) + twoZip := filepath.Join(dir, "crashes.zip") + writeZip(t, twoZip, map[string][]byte{"c_1.eds": []byte("AAAA"), "c_2.eds": []byte("BBBB")}) + singleFile := filepath.Join(dir, "c_7.eds") + require.NoError(t, os.WriteFile(singleFile, []byte("crashing bytes"), 0o600)) + emptyFile := filepath.Join(dir, "empty.bin") + require.NoError(t, os.WriteFile(emptyFile, nil, 0o600)) + + tests := []struct { + name string + filePath string + wantErr string + wantCount string + }{ + {name: "empty tar.gz archive => count 0", filePath: emptyTar, wantCount: "0"}, + {name: "tar.gz with two crashes => count 2", filePath: twoTar, wantCount: "2"}, + {name: "empty zip archive => count 0", filePath: emptyZip, wantCount: "0"}, + {name: "zip with two crashes => count 2", filePath: twoZip, wantCount: "2"}, + {name: "single crash file => count 1", filePath: singleFile, wantCount: "1"}, + {name: "empty single file", filePath: emptyFile, wantErr: "empty"}, + {name: "missing file", filePath: filepath.Join(dir, "nope"), wantErr: "no such file"}, + } + schema := &contractAPI.CraftingSchema_Material{Name: "crashes", Type: contractAPI.CraftingSchema_Material_RADAMSA_CRASHES} + l := zerolog.Nop() + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + uploader := mUploader.NewUploader(t) + if tc.wantErr == "" { + uploader.On("Upload", context.TODO(), mock.Anything, mock.Anything, mock.Anything). + Return(&casclient.UpDownStatus{}, nil) + } + backend := &casclient.CASBackend{Uploader: uploader} + crafter, err := materials.NewRadamsaCrashesCrafter(schema, backend, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), tc.filePath) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, "radamsa", got.Annotations["chainloop.material.tool.name"]) + assert.Equal(t, tc.wantCount, got.Annotations["chainloop.material.radamsa.crashes.count"]) + }) + } +} + +// writeTarGz writes a .tar.gz with the given files (name->content); nil => empty archive. +func writeTarGz(t *testing.T, path string, files map[string][]byte) { + t.Helper() + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + for name, content := range files { + require.NoError(t, tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(content)), Typeflag: tar.TypeReg})) + _, err := tw.Write(content) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) +} + +// writeZip writes a .zip with the given files (name->content); nil => empty archive. +func writeZip(t *testing.T, path string, files map[string][]byte) { + t.Helper() + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + zw := zip.NewWriter(f) + for name, content := range files { + w, err := zw.Create(name) + require.NoError(t, err) + _, err = w.Write(content) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) +} diff --git a/pkg/attestation/crafter/materials/testdata/radamsa-meta-invalid.txt b/pkg/attestation/crafter/materials/testdata/radamsa-meta-invalid.txt new file mode 100644 index 000000000..4aafdbfbc --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/radamsa-meta-invalid.txt @@ -0,0 +1 @@ +this is not a radamsa metadata log diff --git a/pkg/attestation/crafter/materials/testdata/radamsa-meta.txt b/pkg/attestation/crafter/materials/testdata/radamsa-meta.txt new file mode 100644 index 000000000..3f37d11e1 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/radamsa-meta.txt @@ -0,0 +1,3 @@ +seed: 705693910129640559698481 +muta-num: 1, generator: file, checksum: "CF5DA754A292766FAA5465FD", nth: 1, path: "/tmp/m/sample_1.eds", output: file-writer, length: 16892, pattern: many-dec +byte-dec: 1, generator: jump, head: "/tmp/t/sample.eds", checksum: "F2F767F4D2E28596BD5BD982", nth: 2, output: file-writer, length: 17199, pattern: many-dec