diff --git a/app/cli/documentation/cli-reference.mdx b/app/cli/documentation/cli-reference.mdx index 63e58e572..1c996dfc9 100755 --- a/app/cli/documentation/cli-reference.mdx +++ b/app/cli/documentation/cli-reference.mdx @@ -262,7 +262,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" "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"] +--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" "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" "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 @@ -3038,7 +3038,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" "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"] +--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" "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" "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 2be5f6737..428b323da 100644 --- a/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts +++ b/app/controlplane/api/gen/frontend/workflowcontract/v1/crafting_schema.ts @@ -307,6 +307,16 @@ export enum CraftingSchema_Material_MaterialType { RADAMSA_REPORT = 38, /** RADAMSA_CRASHES - radamsa crashing inputs, a single file or a crashes/ archive (tar.gz or zip) */ RADAMSA_CRASHES = 39, + /** + * TRUFFLEHOG_JSON - TruffleHog secret scanning report in JSONL format (one JSON finding per line) + * https://github.com/trufflesecurity/trufflehog + */ + TRUFFLEHOG_JSON = 40, + /** + * COBERTURA_XML - Cobertura code coverage report in XML format + * https://github.com/cobertura/cobertura + */ + COBERTURA_XML = 41, UNRECOGNIZED = -1, } @@ -432,6 +442,12 @@ export function craftingSchema_Material_MaterialTypeFromJSON(object: any): Craft case 39: case "RADAMSA_CRASHES": return CraftingSchema_Material_MaterialType.RADAMSA_CRASHES; + case 40: + case "TRUFFLEHOG_JSON": + return CraftingSchema_Material_MaterialType.TRUFFLEHOG_JSON; + case 41: + case "COBERTURA_XML": + return CraftingSchema_Material_MaterialType.COBERTURA_XML; case -1: case "UNRECOGNIZED": default: @@ -521,6 +537,10 @@ export function craftingSchema_Material_MaterialTypeToJSON(object: CraftingSchem return "RADAMSA_REPORT"; case CraftingSchema_Material_MaterialType.RADAMSA_CRASHES: return "RADAMSA_CRASHES"; + case CraftingSchema_Material_MaterialType.TRUFFLEHOG_JSON: + return "TRUFFLEHOG_JSON"; + case CraftingSchema_Material_MaterialType.COBERTURA_XML: + return "COBERTURA_XML"; 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 2dd75c0f7..531471cfc 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 @@ -56,7 +56,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "title": "Material Type", "type": "string" @@ -148,7 +150,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "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 987accfd5..bdbcd4c41 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 @@ -56,7 +56,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "title": "Material Type", "type": "string" @@ -148,7 +150,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "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 e023142db..49f14380e 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.jsonschema.json @@ -167,7 +167,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "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 47e3c449e..af52a9ebd 100644 --- a/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json +++ b/app/controlplane/api/gen/jsonschema/attestation.v1.PolicyEvaluation.schema.json @@ -167,7 +167,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "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 089902ead..c2dd51e45 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 @@ -77,7 +77,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "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 8546a2610..69bf7b496 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 @@ -77,7 +77,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "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 27a3cf626..059310dca 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 @@ -61,7 +61,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "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 8b33e9196..8db90b845 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 @@ -61,7 +61,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "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 ae7ea5f26..dc6ce0d85 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.jsonschema.json @@ -75,7 +75,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "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 b5e39c824..fe468a11f 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpec.schema.json @@ -75,7 +75,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "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 fd618ba1d..2da9c7712 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.jsonschema.json @@ -96,7 +96,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "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 c983ba9b4..813a5f2f9 100644 --- a/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json +++ b/app/controlplane/api/gen/jsonschema/workflowcontract.v1.PolicySpecV2.schema.json @@ -96,7 +96,9 @@ "CERTCC_DRANZER", "OSSF_SCORECARD_JSON", "RADAMSA_REPORT", - "RADAMSA_CRASHES" + "RADAMSA_CRASHES", + "TRUFFLEHOG_JSON", + "COBERTURA_XML" ], "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 b2b648761..457219893 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.pb.go @@ -239,6 +239,12 @@ const ( 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 + // TruffleHog secret scanning report in JSONL format (one JSON finding per line) + // https://github.com/trufflesecurity/trufflehog + CraftingSchema_Material_TRUFFLEHOG_JSON CraftingSchema_Material_MaterialType = 40 + // Cobertura code coverage report in XML format + // https://github.com/cobertura/cobertura + CraftingSchema_Material_COBERTURA_XML CraftingSchema_Material_MaterialType = 41 ) // Enum value maps for CraftingSchema_Material_MaterialType. @@ -284,6 +290,8 @@ var ( 37: "OSSF_SCORECARD_JSON", 38: "RADAMSA_REPORT", 39: "RADAMSA_CRASHES", + 40: "TRUFFLEHOG_JSON", + 41: "COBERTURA_XML", } CraftingSchema_Material_MaterialType_value = map[string]int32{ "MATERIAL_TYPE_UNSPECIFIED": 0, @@ -326,6 +334,8 @@ var ( "OSSF_SCORECARD_JSON": 37, "RADAMSA_REPORT": 38, "RADAMSA_CRASHES": 39, + "TRUFFLEHOG_JSON": 40, + "COBERTURA_XML": 41, } ) @@ -1998,7 +2008,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\"\x94\x12\n" + + ")workflowcontract/v1/crafting_schema.proto\x12\x13workflowcontract.v1\x1a\x1bbuf/validate/validate.proto\"\xbc\x12\n" + "\x0eCraftingSchema\x122\n" + "\x0eschema_version\x18\x01 \x01(\tB\v\xbaH\x06r\x04\n" + "\x02v1\x18\x01R\rschemaVersion\x12N\n" + @@ -2021,7 +2031,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\xdf\v\n" + + "\x11CHAINLOOP_SANDBOX\x10\t:\x02\x18\x01\x1a\x87\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" + @@ -2032,7 +2042,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\"\x84\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\"\xac\a\n" + "\fMaterialType\x12\x1d\n" + "\x19MATERIAL_TYPE_UNSPECIFIED\x10\x00\x12\n" + "\n" + @@ -2077,7 +2087,9 @@ const file_workflowcontract_v1_crafting_schema_proto_rawDesc = "" + "\x0eCERTCC_DRANZER\x10$\x12\x17\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" + + "\x0fRADAMSA_CRASHES\x10'\x12\x13\n" + + "\x0fTRUFFLEHOG_JSON\x10(\x12\x11\n" + + "\rCOBERTURA_XML\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 0d326fc7c..086ab4e7b 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema.proto +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema.proto @@ -188,6 +188,12 @@ message CraftingSchema { RADAMSA_REPORT = 38; // radamsa crashing inputs, a single file or a crashes/ archive (tar.gz or zip) RADAMSA_CRASHES = 39; + // TruffleHog secret scanning report in JSONL format (one JSON finding per line) + // https://github.com/trufflesecurity/trufflehog + TRUFFLEHOG_JSON = 40; + // Cobertura code coverage report in XML format + // https://github.com/cobertura/cobertura + COBERTURA_XML = 41; } } } diff --git a/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go b/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go index 1be8f9645..7faa90aa6 100644 --- a/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go +++ b/app/controlplane/api/workflowcontract/v1/crafting_schema_validations.go @@ -48,6 +48,7 @@ var CraftingMaterialInValidationOrder = []CraftingSchema_Material_MaterialType{ CraftingSchema_Material_GRAPHQL_SPEC, CraftingSchema_Material_JUNIT_XML, CraftingSchema_Material_JACOCO_XML, + CraftingSchema_Material_COBERTURA_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 diff --git a/pkg/attestation/crafter/api/attestation/v1/crafting_state.go b/pkg/attestation/crafter/api/attestation/v1/crafting_state.go index 5dd45f4f2..94e400179 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state.go +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state.go @@ -27,10 +27,12 @@ import ( v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/accesschk" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/attestation" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/cobertura" "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/trufflehog" "github.com/chainloop-dev/chainloop/pkg/tabular" intoto "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/types/known/structpb" @@ -193,6 +195,15 @@ func (m *Attestation_Material) ingestMaterialToJSON(rawMaterial []byte, value st return nil, fmt.Errorf("invalid radamsa -M metadata log: %w", err) } return json.Marshal(records) + case v1.CraftingSchema_Material_TRUFFLEHOG_JSON: + // TruffleHog --json output is JSONL (one finding per line), not a JSON + // array; render it as a JSON array so the policy engine exposes the + // findings as input.elements. + findings, err := trufflehog.Parse(bytes.NewReader(rawMaterial)) + if err != nil { + return nil, fmt.Errorf("invalid trufflehog report: %w", err) + } + return json.Marshal(findings) 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; @@ -204,6 +215,12 @@ func (m *Attestation_Material) ingestMaterialToJSON(rawMaterial []byte, value st return nil, fmt.Errorf("invalid Jacoco report file: %w", err) } return json.Marshal(&report) + case v1.CraftingSchema_Material_COBERTURA_XML: + var report cobertura.Coverage + if err := xml.Unmarshal(rawMaterial, &report); err != nil { + return nil, fmt.Errorf("invalid Cobertura report file: %w", err) + } + return json.Marshal(&report) case v1.CraftingSchema_Material_SYSINTERNALS_SIGCHECK: report, err := tabular.Parse(rawMaterial) if 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 c534ac0d6..dc8e567bf 100644 --- a/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go +++ b/pkg/attestation/crafter/api/attestation/v1/crafting_state_test.go @@ -22,6 +22,7 @@ import ( schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNormalizeOutput(t *testing.T) { @@ -199,6 +200,19 @@ func TestGetEvaluableContentWithMetadata(t *testing.T) { filename: "testdata/sbom.cyclonedx.json", testField: "bomFormat", }, + { + name: "cobertura xml material projected to json", + material: &Attestation_Material{ + MaterialType: schemaapi.CraftingSchema_Material_COBERTURA_XML, + M: &Attestation_Material_Artifact_{ + Artifact: &Attestation_Material_Artifact{ + Name: "name", Digest: "sha256:deadbeef", IsSubject: true, + }, + }, + }, + filename: "testdata/cobertura.xml", + testField: "packages", + }, { name: "sigcheck csv material", material: &Attestation_Material{ @@ -253,6 +267,19 @@ func TestGetEvaluableContentWithMetadata(t *testing.T) { filename: "testdata/radamsa-meta.txt", testField: "elements", }, + { + name: "trufflehog JSONL projected to elements", + material: &Attestation_Material{ + MaterialType: schemaapi.CraftingSchema_Material_TRUFFLEHOG_JSON, + M: &Attestation_Material_Artifact_{ + Artifact: &Attestation_Material_Artifact{ + Name: "name", Digest: "sha256:deadbeef", IsSubject: true, + }, + }, + }, + filename: "testdata/trufflehog-report.json", + testField: "elements", + }, { // metadata-only: the (non-existent) crashes path must NOT be read. name: "radamsa crashes metadata only", @@ -303,3 +330,48 @@ func TestGetEvaluableContentWithMetadata(t *testing.T) { }) } } + +// TestCoberturaEmptyReportIsEvaluable guards the requirement that a legitimate +// empty coverage report (line-rate="NaN", no packages) projects to valid JSON +// the policy engine can evaluate — instead of failing with a NaN marshal error, +// which a policy would surface as a violation. line-rate is null and +// lines-valid is 0, so a policy can guard on lines-valid > 0 and treat the +// report as valid (no violations). +func TestCoberturaEmptyReportIsEvaluable(t *testing.T) { + m := &Attestation_Material{ + MaterialType: schemaapi.CraftingSchema_Material_COBERTURA_XML, + M: &Attestation_Material_Artifact_{ + Artifact: &Attestation_Material_Artifact{Name: "coverage", Digest: "sha256:deadbeef"}, + }, + } + + content, err := m.GetEvaluableContent("testdata/cobertura-empty.xml") + require.NoError(t, err, "empty report must be evaluable, not error on NaN") + + var decoded map[string]any + require.NoError(t, json.NewDecoder(bytes.NewReader(content)).Decode(&decoded)) + assert.Nil(t, decoded["line-rate"], "NaN line-rate must project as null") + assert.EqualValues(t, 0, decoded["lines-valid"], "lines-valid stays 0 so a policy can detect an empty report") +} + +// TestTruffleHogCleanScanIsEvaluable guards that a clean scan (TruffleHog found +// no secrets, leaving a zero-byte file) projects to valid policy input with an +// empty findings list, so a secrets policy sees "no secrets -> no violation" +// rather than an evaluation error. +func TestTruffleHogCleanScanIsEvaluable(t *testing.T) { + m := &Attestation_Material{ + MaterialType: schemaapi.CraftingSchema_Material_TRUFFLEHOG_JSON, + M: &Attestation_Material_Artifact_{ + Artifact: &Attestation_Material_Artifact{Name: "secrets", Digest: "sha256:deadbeef"}, + }, + } + + content, err := m.GetEvaluableContent("testdata/trufflehog-clean-scan.jsonl") + require.NoError(t, err, "clean scan must be evaluable") + + var decoded map[string]any + require.NoError(t, json.NewDecoder(bytes.NewReader(content)).Decode(&decoded)) + elements, ok := decoded["elements"].([]any) + require.True(t, ok, "clean scan must project to an elements array") + assert.Empty(t, elements, "a clean scan has zero findings") +} diff --git a/pkg/attestation/crafter/api/attestation/v1/testdata/cobertura-empty.xml b/pkg/attestation/crafter/api/attestation/v1/testdata/cobertura-empty.xml new file mode 100644 index 000000000..800771df0 --- /dev/null +++ b/pkg/attestation/crafter/api/attestation/v1/testdata/cobertura-empty.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/pkg/attestation/crafter/api/attestation/v1/testdata/cobertura.xml b/pkg/attestation/crafter/api/attestation/v1/testdata/cobertura.xml new file mode 100644 index 000000000..0850cdd1a --- /dev/null +++ b/pkg/attestation/crafter/api/attestation/v1/testdata/cobertura.xml @@ -0,0 +1,36 @@ + + + + + src/main/java + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkg/attestation/crafter/api/attestation/v1/testdata/trufflehog-clean-scan.jsonl b/pkg/attestation/crafter/api/attestation/v1/testdata/trufflehog-clean-scan.jsonl new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/attestation/crafter/api/attestation/v1/testdata/trufflehog-report.json b/pkg/attestation/crafter/api/attestation/v1/testdata/trufflehog-report.json new file mode 100644 index 000000000..cfc60c352 --- /dev/null +++ b/pkg/attestation/crafter/api/attestation/v1/testdata/trufflehog-report.json @@ -0,0 +1,2 @@ +{"SourceMetadata":{"Data":{"Filesystem":{"file":"config/secrets.yaml","line":12}}},"SourceID":0,"SourceType":15,"SourceName":"trufflehog - filesystem","DetectorType":8,"DetectorName":"Github","DecoderName":"PLAIN","Verified":true,"Raw":"ghp_exampleTokenValue","Redacted":"ghp_***","ExtraData":null} +{"SourceMetadata":{"Data":{"Filesystem":{"file":"app/.env","line":4}}},"SourceID":0,"SourceType":15,"SourceName":"trufflehog - filesystem","DetectorType":9,"DetectorName":"AWS","DecoderName":"PLAIN","Verified":false,"Raw":"AKIAEXAMPLE","Redacted":"AKIA***","ExtraData":null} diff --git a/pkg/attestation/crafter/materials/cobertura.go b/pkg/attestation/crafter/materials/cobertura.go new file mode 100644 index 000000000..54738a724 --- /dev/null +++ b/pkg/attestation/crafter/materials/cobertura.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 materials + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "os" + + 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/cobertura" + "github.com/chainloop-dev/chainloop/pkg/casclient" + "github.com/rs/zerolog" +) + +type CoberturaCrafter struct { + *crafterCommon + backend *casclient.CASBackend +} + +func NewCoberturaCrafter(schema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, l *zerolog.Logger) *CoberturaCrafter { + return &CoberturaCrafter{ + crafterCommon: &crafterCommon{logger: l, input: schema}, + backend: backend, + } +} + +func (c *CoberturaCrafter) 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() + + data, err := io.ReadAll(f) + if err != nil { + return nil, fmt.Errorf("can't read the file: %w", err) + } + + var report cobertura.Coverage + // Coverage pins its XMLName to "coverage", so xml.Unmarshal rejects a + // mismatched root element (e.g. JaCoCo's or JUnit's ). + if err := xml.Unmarshal(data, &report); err != nil { + return nil, fmt.Errorf("invalid Cobertura report file: %w", ErrInvalidMaterialType) + } + + // Reject only genuinely dataless input: a zero line-rate AND no packages. + // Both legitimate low-data cases are still accepted, so we neither suppress + // real results nor reject valid empty reports: + // - real 0% coverage has line-rate "0" but carries packages (the uncovered + // code), so len(Packages) > 0 keeps it — a policy can flag it; + // - an empty report (no measurable lines) has line-rate "NaN" (0/0), which + // is not == 0, so it is kept and projects to a null rate for a policy to + // treat as "nothing to measure" (distinct from 0% coverage). + // The pinned root checked above already excludes other formats. + if report.LineRate == 0 && len(report.Packages) == 0 { + return nil, fmt.Errorf("invalid Cobertura report file, missing coverage data: %w", ErrInvalidMaterialType) + } + + return uploadAndCraft(ctx, c.input, c.backend, filePath, c.logger) +} diff --git a/pkg/attestation/crafter/materials/cobertura/cobertura.go b/pkg/attestation/crafter/materials/cobertura/cobertura.go new file mode 100644 index 000000000..8e295e0e5 --- /dev/null +++ b/pkg/attestation/crafter/materials/cobertura/cobertura.go @@ -0,0 +1,100 @@ +// +// 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 cobertura provides the XML structs to parse Cobertura code coverage +// reports. See https://github.com/cobertura/cobertura and the coverage-04.dtd +// for the format definition. +package cobertura + +import ( + "encoding/json" + "encoding/xml" + "math" +) + +// Rate is a coverage ratio (line-rate, branch-rate, complexity). It serialises +// NaN/Inf as JSON null instead of erroring, because an empty-but-valid report — +// a service with no measurable lines, where coverage tools emit +// line-rate="NaN" (0/0) — must still project to valid JSON that the policy +// engine can evaluate. Without this, json.Marshal fails on NaN and the whole +// material becomes un-evaluable, which a policy would surface as a failure even +// though the report is legitimate. Policies should guard on lines-valid > 0 +// before interpreting the rate so an empty report is treated as valid (no +// violations) rather than as 0% coverage. +type Rate float64 + +// MarshalJSON renders finite values as numbers and NaN/Inf as null. +func (r Rate) MarshalJSON() ([]byte, error) { + f := float64(r) + if math.IsNaN(f) || math.IsInf(f, 0) { + return []byte("null"), nil + } + return json.Marshal(f) +} + +// Coverage is the root element of a Cobertura report. +type Coverage struct { + XMLName xml.Name `xml:"coverage" json:"-"` + LineRate Rate `xml:"line-rate,attr" json:"line-rate"` + BranchRate Rate `xml:"branch-rate,attr" json:"branch-rate"` + LinesCovered int `xml:"lines-covered,attr" json:"lines-covered"` + LinesValid int `xml:"lines-valid,attr" json:"lines-valid"` + BranchesCovered int `xml:"branches-covered,attr" json:"branches-covered"` + BranchesValid int `xml:"branches-valid,attr" json:"branches-valid"` + Complexity Rate `xml:"complexity,attr" json:"complexity"` + Version string `xml:"version,attr" json:"version"` + Timestamp int64 `xml:"timestamp,attr" json:"timestamp"` + Sources []string `xml:"sources>source" json:"sources"` + Packages []Package `xml:"packages>package" json:"packages"` +} + +// Package is a element grouping classes. +type Package struct { + Name string `xml:"name,attr" json:"name"` + LineRate Rate `xml:"line-rate,attr" json:"line-rate"` + BranchRate Rate `xml:"branch-rate,attr" json:"branch-rate"` + Complexity Rate `xml:"complexity,attr" json:"complexity"` + Classes []Class `xml:"classes>class" json:"classes"` +} + +// Class is a element within a package. +type Class struct { + Name string `xml:"name,attr" json:"name"` + Filename string `xml:"filename,attr" json:"filename"` + LineRate Rate `xml:"line-rate,attr" json:"line-rate"` + BranchRate Rate `xml:"branch-rate,attr" json:"branch-rate"` + Complexity Rate `xml:"complexity,attr" json:"complexity"` + Methods []Method `xml:"methods>method" json:"methods"` + Lines []Line `xml:"lines>line" json:"lines"` +} + +// Method is a element within a class. +type Method struct { + Name string `xml:"name,attr" json:"name"` + Signature string `xml:"signature,attr" json:"signature"` + LineRate Rate `xml:"line-rate,attr" json:"line-rate"` + BranchRate Rate `xml:"branch-rate,attr" json:"branch-rate"` + Complexity Rate `xml:"complexity,attr" json:"complexity"` + Lines []Line `xml:"lines>line" json:"lines"` +} + +// Line is a element describing coverage for a single source line. +type Line struct { + Number int `xml:"number,attr" json:"number"` + Hits int `xml:"hits,attr" json:"hits"` + Branch bool `xml:"branch,attr" json:"branch"` + // ConditionCoverage is only present on branch lines (e.g. "50% (1/2)"). + ConditionCoverage string `xml:"condition-coverage,attr,omitempty" json:"condition-coverage,omitempty"` +} diff --git a/pkg/attestation/crafter/materials/cobertura/cobertura_test.go b/pkg/attestation/crafter/materials/cobertura/cobertura_test.go new file mode 100644 index 000000000..6fbac846e --- /dev/null +++ b/pkg/attestation/crafter/materials/cobertura/cobertura_test.go @@ -0,0 +1,149 @@ +// +// 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 cobertura_test + +import ( + "encoding/json" + "encoding/xml" + "math" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/cobertura" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnmarshal(t *testing.T) { + testCases := []struct { + name string + input string + wantErr bool + assert func(t *testing.T, c *cobertura.Coverage) + }{ + { + name: "valid cobertura report", + input: ` + + + src + + + + + + + + + + + + + +`, + assert: func(t *testing.T, c *cobertura.Coverage) { + assert.Equal(t, "coverage", c.XMLName.Local) + assert.InDelta(t, 0.75, float64(c.LineRate), 0.001) + assert.InDelta(t, 0.5, float64(c.BranchRate), 0.001) + assert.Equal(t, 3, c.LinesCovered) + assert.Equal(t, 4, c.LinesValid) + assert.Equal(t, []string{"src"}, c.Sources) + require.Len(t, c.Packages, 1) + assert.Equal(t, "com.example", c.Packages[0].Name) + require.Len(t, c.Packages[0].Classes, 1) + assert.Equal(t, "src/Main.java", c.Packages[0].Classes[0].Filename) + require.Len(t, c.Packages[0].Classes[0].Lines, 2) + assert.True(t, c.Packages[0].Classes[0].Lines[1].Branch) + assert.Equal(t, "50% (1/2)", c.Packages[0].Classes[0].Lines[1].ConditionCoverage) + }, + }, + { + // A named XMLName tag makes xml.Unmarshal reject a mismatched root + // element (e.g. a JaCoCo ), which is what we rely on to + // distinguish Cobertura from other XML coverage formats. + name: "wrong root element is rejected", + input: ``, + wantErr: true, + }, + { + name: "malformed xml returns error", + input: ` 0 before interpreting coverage. +func TestEmptyReportMarshalsToValidJSON(t *testing.T) { + const emptyReport = ` + + + +` + + var c cobertura.Coverage + require.NoError(t, xml.Unmarshal([]byte(emptyReport), &c)) + + out, err := json.Marshal(&c) + require.NoError(t, err, "empty report must marshal without a NaN error") + + var decoded map[string]any + require.NoError(t, json.Unmarshal(out, &decoded)) + assert.Nil(t, decoded["line-rate"], "NaN line-rate must project as null") + assert.EqualValues(t, 0, decoded["lines-valid"], "lines-valid stays 0 so policies can detect an empty report") +} diff --git a/pkg/attestation/crafter/materials/cobertura_test.go b/pkg/attestation/crafter/materials/cobertura_test.go new file mode 100644 index 000000000..04b3fdbb1 --- /dev/null +++ b/pkg/attestation/crafter/materials/cobertura_test.go @@ -0,0 +1,124 @@ +// +// 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. + +//nolint:dupl +package materials_test + +import ( + "context" + "testing" + + contractAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + attestationApi "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/api/attestation/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 TestCoberturaCraft(t *testing.T) { + testCases := []struct { + name string + filePath string + wantErr string + }{ + { + name: "invalid path", + filePath: "./testdata/non-existing.xml", + wantErr: "no such file or directory", + }, + { + name: "invalid artifact type", + filePath: "./testdata/simple.txt", + wantErr: "unexpected material type", + }, + { + name: "wrong xml root (jacoco report)", + filePath: "./testdata/jacoco.xml", + wantErr: "unexpected material type", + }, + { + name: "wrong xml root (junit testsuite)", + filePath: "./testdata/junit.xml", + wantErr: "unexpected material type", + }, + { + name: "valid artifact type", + filePath: "./testdata/cobertura.xml", + }, + } + + assert := assert.New(t) + schema := &contractAPI.CraftingSchema_Material{ + Name: "test", + Type: contractAPI.CraftingSchema_Material_COBERTURA_XML, + } + l := zerolog.Nop() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Mock uploader + uploader := mUploader.NewUploader(t) + if tc.wantErr == "" { + uploader.On("Upload", context.TODO(), mock.Anything, mock.Anything, mock.Anything). + Return(&casclient.UpDownStatus{ + Digest: "deadbeef", + Filename: "cobertura.xml", + }, nil) + } + backend := &casclient.CASBackend{Uploader: uploader} + crafter := materials.NewCoberturaCrafter(schema, backend, &l) + + got, err := crafter.Craft(context.TODO(), tc.filePath) + if tc.wantErr != "" { + assert.ErrorContains(err, tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(contractAPI.CraftingSchema_Material_COBERTURA_XML.String(), got.MaterialType.String()) + assert.True(got.UploadedToCas) + + // The result includes the digest reference + assert.Equal(&attestationApi.Attestation_Material_Artifact{ + Id: "test", Digest: "sha256:00b1d466b66effb5b42b02adcaa63c99fcf3df4d7e54f66e0e481bf4a15fdd38", Name: "cobertura.xml", + }, got.GetArtifact()) + }) + } +} + +// TestCoberturaCraftEmptyReport asserts that a legitimate empty coverage report +// (a service with no measurable lines, where the tool emits line-rate="NaN") +// is accepted, not rejected. Empty coverage is valid evidence; it must be +// attestable so a downstream policy can decide it is not a violation. +func TestCoberturaCraftEmptyReport(t *testing.T) { + schema := &contractAPI.CraftingSchema_Material{ + Name: "test", + Type: contractAPI.CraftingSchema_Material_COBERTURA_XML, + } + l := zerolog.Nop() + uploader := mUploader.NewUploader(t) + uploader.On("Upload", context.TODO(), mock.Anything, mock.Anything, mock.Anything). + Return(&casclient.UpDownStatus{Digest: "deadbeef", Filename: "cobertura-empty.xml"}, nil) + backend := &casclient.CASBackend{Uploader: uploader} + crafter := materials.NewCoberturaCrafter(schema, backend, &l) + + got, err := crafter.Craft(context.TODO(), "./testdata/cobertura-empty.xml") + require.NoError(t, err, "an empty-but-valid cobertura report must be accepted") + require.NotNil(t, got) + assert.Equal(t, contractAPI.CraftingSchema_Material_COBERTURA_XML.String(), got.MaterialType.String()) +} diff --git a/pkg/attestation/crafter/materials/materials.go b/pkg/attestation/crafter/materials/materials.go index 420840f72..48900e5b7 100644 --- a/pkg/attestation/crafter/materials/materials.go +++ b/pkg/attestation/crafter/materials/materials.go @@ -16,6 +16,7 @@ package materials import ( + "bytes" "context" "encoding/json" "errors" @@ -109,18 +110,52 @@ type crafterCommon struct { input *schemaapi.CraftingSchema_Material } +// uploadAndCraftOpts tunes uploadAndCraft behaviour. +type uploadAndCraftOpts struct { + // emptyContentFallback, when set, replaces the artifact content if the file + // on disk is empty (zero bytes). Report types that are legitimately empty + // (e.g. a TruffleHog scan that finds no secrets emits no output) use this to + // craft a canonical representation ("[]") instead of a zero-byte file. This + // keeps the recorded digest meaningful and avoids the universal empty-file + // digest, which would collide across unrelated empty materials of any type. + // The original filename is preserved. + emptyContentFallback []byte +} + +type uploadAndCraftOption func(*uploadAndCraftOpts) + +// withEmptyContentFallback substitutes canonical content when the artifact file +// is empty, rather than rejecting it. +func withEmptyContentFallback(content []byte) uploadAndCraftOption { + return func(o *uploadAndCraftOpts) { o.emptyContentFallback = content } +} + // uploadAndCraft uploads the artifact to CAS and crafts the material // this function is used by all the uploadable artifacts crafters (SBOMs, JUnit, and more in the future) -func uploadAndCraft(ctx context.Context, input *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, artifactPath string, l *zerolog.Logger) (*api.Attestation_Material, error) { +func uploadAndCraft(ctx context.Context, input *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, artifactPath string, l *zerolog.Logger, opts ...uploadAndCraftOption) (*api.Attestation_Material, error) { + options := &uploadAndCraftOpts{} + for _, o := range opts { + o(options) + } + // 1 - Check the file can be stored in the provided CAS backend result, err := fileStats(artifactPath) if err != nil { return nil, fmt.Errorf("getting file stats: %w", err) } - defer result.r.Close() + defer func() { _ = result.r.Close() }() if result.size == 0 { - return nil, fmt.Errorf("%w: %w", ErrBaseUploadAndCraft, errors.New("file is empty")) + if len(options.emptyContentFallback) == 0 { + return nil, fmt.Errorf("%w: %w", ErrBaseUploadAndCraft, errors.New("file is empty")) + } + // Substitute canonical content for the empty file, preserving the + // original filename, so the digest reflects the report's meaning. + _ = result.r.Close() + result, err = fileStatsFromBytes(result.filename, options.emptyContentFallback) + if err != nil { + return nil, fmt.Errorf("getting file stats: %w", err) + } } // Determine if we should skip the upload based on contract setting @@ -221,6 +256,22 @@ func fileStats(filepath string) (*fileInfo, error) { return &fileInfo{filename: stat.Name(), digest: hash.String(), size: stat.Size(), r: f}, nil } +// fileStatsFromBytes builds a fileInfo from in-memory content, preserving the +// given filename. Used to craft a canonical representation when the on-disk +// file is empty (see uploadAndCraft's emptyContentFallback). +func fileStatsFromBytes(filename string, content []byte) (*fileInfo, error) { + hash, _, err := cr_v1.SHA256(bytes.NewReader(content)) + if err != nil { + return nil, fmt.Errorf("generating digest: %w", err) + } + return &fileInfo{ + filename: filename, + digest: hash.String(), + size: int64(len(content)), + r: io.NopCloser(bytes.NewReader(content)), + }, nil +} + type Craftable interface { Craft(ctx context.Context, value string) (*api.Attestation_Material, error) } @@ -258,6 +309,8 @@ func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Materia crafter, err = NewJUnitXMLCrafter(materialSchema, casBackend, logger) case schemaapi.CraftingSchema_Material_JACOCO_XML: crafter = NewJacocoCrafter(materialSchema, casBackend, logger) + case schemaapi.CraftingSchema_Material_COBERTURA_XML: + crafter = NewCoberturaCrafter(materialSchema, casBackend, logger) case schemaapi.CraftingSchema_Material_OPENVEX: crafter, err = NewOpenVEXCrafter(materialSchema, casBackend, logger) case schemaapi.CraftingSchema_Material_CSAF_VEX: @@ -322,6 +375,8 @@ func Craft(ctx context.Context, materialSchema *schemaapi.CraftingSchema_Materia crafter, err = NewRadamsaReportCrafter(materialSchema, casBackend, logger) case schemaapi.CraftingSchema_Material_RADAMSA_CRASHES: crafter, err = NewRadamsaCrashesCrafter(materialSchema, casBackend, logger) + case schemaapi.CraftingSchema_Material_TRUFFLEHOG_JSON: + crafter, err = NewTrufflehogCrafter(materialSchema, casBackend, logger) default: return nil, fmt.Errorf("material of type %q not supported yet", materialSchema.Type) } diff --git a/pkg/attestation/crafter/materials/testdata/cobertura-empty.xml b/pkg/attestation/crafter/materials/testdata/cobertura-empty.xml new file mode 100644 index 000000000..800771df0 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/cobertura-empty.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/pkg/attestation/crafter/materials/testdata/cobertura.xml b/pkg/attestation/crafter/materials/testdata/cobertura.xml new file mode 100644 index 000000000..0850cdd1a --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/cobertura.xml @@ -0,0 +1,36 @@ + + + + + src/main/java + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkg/attestation/crafter/materials/testdata/trufflehog-clean-scan.jsonl b/pkg/attestation/crafter/materials/testdata/trufflehog-clean-scan.jsonl new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/attestation/crafter/materials/testdata/trufflehog-empty.json b/pkg/attestation/crafter/materials/testdata/trufflehog-empty.json new file mode 100644 index 000000000..139597f9c --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/trufflehog-empty.json @@ -0,0 +1,2 @@ + + diff --git a/pkg/attestation/crafter/materials/testdata/trufflehog-missing-detector.json b/pkg/attestation/crafter/materials/testdata/trufflehog-missing-detector.json new file mode 100644 index 000000000..34d570339 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/trufflehog-missing-detector.json @@ -0,0 +1 @@ +{"SourceMetadata":{"Data":{}},"SourceID":0,"Verified":false,"Raw":"something"} diff --git a/pkg/attestation/crafter/materials/testdata/trufflehog-report.json b/pkg/attestation/crafter/materials/testdata/trufflehog-report.json new file mode 100644 index 000000000..cfc60c352 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/trufflehog-report.json @@ -0,0 +1,2 @@ +{"SourceMetadata":{"Data":{"Filesystem":{"file":"config/secrets.yaml","line":12}}},"SourceID":0,"SourceType":15,"SourceName":"trufflehog - filesystem","DetectorType":8,"DetectorName":"Github","DecoderName":"PLAIN","Verified":true,"Raw":"ghp_exampleTokenValue","Redacted":"ghp_***","ExtraData":null} +{"SourceMetadata":{"Data":{"Filesystem":{"file":"app/.env","line":4}}},"SourceID":0,"SourceType":15,"SourceName":"trufflehog - filesystem","DetectorType":9,"DetectorName":"AWS","DecoderName":"PLAIN","Verified":false,"Raw":"AKIAEXAMPLE","Redacted":"AKIA***","ExtraData":null} diff --git a/pkg/attestation/crafter/materials/trufflehog.go b/pkg/attestation/crafter/materials/trufflehog.go new file mode 100644 index 000000000..0fa647a71 --- /dev/null +++ b/pkg/attestation/crafter/materials/trufflehog.go @@ -0,0 +1,91 @@ +// +// 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" + "fmt" + "os" + + 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/trufflehog" + "github.com/chainloop-dev/chainloop/pkg/casclient" + "github.com/rs/zerolog" +) + +const trufflehogToolName = "trufflehog" + +// TrufflehogCrafter crafts a TRUFFLEHOG_JSON material out of TruffleHog's +// --json output, which is JSONL (one JSON finding per line). +type TrufflehogCrafter struct { + *crafterCommon + backend *casclient.CASBackend +} + +func NewTrufflehogCrafter(schema *schemaapi.CraftingSchema_Material, backend *casclient.CASBackend, l *zerolog.Logger) (*TrufflehogCrafter, error) { + if schema.Type != schemaapi.CraftingSchema_Material_TRUFFLEHOG_JSON { + return nil, fmt.Errorf("material type is not a TruffleHog report") + } + return &TrufflehogCrafter{ + backend: backend, + crafterCommon: &crafterCommon{logger: l, input: schema}, + }, nil +} + +func (i *TrufflehogCrafter) 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() + + findings, err := trufflehog.Parse(f) + if err != nil { + return nil, fmt.Errorf("invalid trufflehog report file: %w", ErrInvalidMaterialType) + } + + // A clean scan (no secrets found) is a valid, meaningful result. TruffleHog + // emits nothing in that case, leaving a zero-byte file. Rather than reject + // it (or store an empty artifact, whose universal empty-file digest would + // collide with unrelated empty materials), craft a canonical empty findings + // report, "[]", so a passing scan is attestable and projects to an empty + // findings list for policy evaluation. + var craftOpts []uploadAndCraftOption + if len(findings) == 0 { + i.logger.Debug().Msg("Accepting an empty report (no secrets found).") + craftOpts = append(craftOpts, withEmptyContentFallback(trufflehog.CanonicalEmpty)) + } else { + finding := findings[0] + // TruffleHog findings always carry a DetectorName. If the first parsed + // line lacks it, this is valid JSONL of some other kind, not a report. + if finding.DetectorName == "" { + return nil, fmt.Errorf("'DetectorName' field not found in trufflehog report: %w", ErrInvalidMaterialType) + } + } + + m, err := uploadAndCraft(ctx, i.input, i.backend, filePath, i.logger, craftOpts...) + if err != nil { + return nil, err + } + + if m.Annotations == nil { + m.Annotations = make(map[string]string) + } + m.Annotations[AnnotationToolNameKey] = trufflehogToolName + + return m, nil +} diff --git a/pkg/attestation/crafter/materials/trufflehog/parse.go b/pkg/attestation/crafter/materials/trufflehog/parse.go new file mode 100644 index 000000000..7f59ce381 --- /dev/null +++ b/pkg/attestation/crafter/materials/trufflehog/parse.go @@ -0,0 +1,138 @@ +// +// 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 trufflehog parses TruffleHog's --json output, which is JSONL +// (newline-delimited: one JSON finding object per line), into structured +// findings. +package trufflehog + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" +) + +// CanonicalEmpty is the content crafted for a clean scan (zero findings). +// TruffleHog emits nothing when it finds no secrets, so there is no native +// empty document; storing this keeps a passing scan attestable and round-trips +// through Parse to an empty findings list. The crafter and Parse share this one +// definition so the write and read sides never drift. +var CanonicalEmpty = []byte("[]") + +// SourceMetadata mirrors the nested source information TruffleHog attaches to +// each finding. Only the fields needed for policy evaluation are decoded; the +// concrete source shape (Filesystem, Git, ...) varies per source type, so it is +// kept as a raw JSON message to preserve it verbatim without losing type safety +// on the fields we do read. +type SourceMetadata struct { + Data json.RawMessage `json:"Data,omitempty"` +} + +// Finding is a single TruffleHog secret detection result (one JSONL line). +type Finding struct { + SourceMetadata SourceMetadata `json:"SourceMetadata"` + SourceID int `json:"SourceID"` + SourceType int `json:"SourceType"` + SourceName string `json:"SourceName"` + DetectorType int `json:"DetectorType"` + DetectorName string `json:"DetectorName"` + DecoderName string `json:"DecoderName"` + Verified bool `json:"Verified"` + Raw string `json:"Raw"` + Redacted string `json:"Redacted"` +} + +// Parse reads TruffleHog output and returns its findings. It accepts two forms: +// +// - JSONL: one JSON finding object per line (TruffleHog's native --json +// output). +// - JSON array: a single "[...]" document. This is the canonical form we +// craft for a clean scan (TruffleHog emits nothing when it finds no +// secrets, so "[]" represents zero findings). +// +// It errors if a non-blank line (JSONL) or the document (array) is not valid +// JSON. An input with no findings (empty, whitespace only, or "[]") yields an +// empty slice and no error; callers decide whether an empty report is +// acceptable. +func Parse(r io.Reader) ([]Finding, error) { + br := bufio.NewReader(r) + + // Peek at the first non-whitespace byte to distinguish a JSON array + // document from JSONL without loading the whole (potentially large) stream. + first, err := firstNonSpaceByte(br) + if errors.Is(err, io.EOF) { + return make([]Finding, 0), nil // empty input: zero findings + } + if err != nil { + return nil, err + } + + dec := json.NewDecoder(br) + + // A single JSON array document — the canonical form we craft for a clean + // scan (see CanonicalEmpty), and what jq -s style tooling produces. + if first == '[' { + findings := make([]Finding, 0) + if err := dec.Decode(&findings); err != nil { + return nil, fmt.Errorf("invalid trufflehog JSON array: %w", err) + } + // Reject any non-whitespace content after the array. Without this, a + // report like "[]" with findings appended afterwards would decode as + // the empty array and silently ignore the rest, hiding those findings. + if dec.More() { + return nil, fmt.Errorf("unexpected content after trufflehog JSON array") + } + return findings, nil + } + + // JSONL: one finding object per line. json.Decoder treats the separating + // newlines as whitespace, so it streams records natively with no + // line-length limit and no manual blank-line handling. + findings := make([]Finding, 0) + for { + var f Finding + err := dec.Decode(&f) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("invalid trufflehog JSONL: %w", err) + } + findings = append(findings, f) + } + return findings, nil +} + +// firstNonSpaceByte consumes leading JSON whitespace and returns the first +// meaningful byte, leaving it unread on the buffered reader so the subsequent +// decoder/scanner sees the full remaining content. Returns io.EOF if the input +// is empty or whitespace only. +func firstNonSpaceByte(br *bufio.Reader) (byte, error) { + for { + b, err := br.ReadByte() + if err != nil { + return 0, err + } + if b == ' ' || b == '\t' || b == '\n' || b == '\r' { + continue + } + if err := br.UnreadByte(); err != nil { + return 0, err + } + return b, nil + } +} diff --git a/pkg/attestation/crafter/materials/trufflehog/parse_test.go b/pkg/attestation/crafter/materials/trufflehog/parse_test.go new file mode 100644 index 000000000..b5e56fe03 --- /dev/null +++ b/pkg/attestation/crafter/materials/trufflehog/parse_test.go @@ -0,0 +1,137 @@ +// +// 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 trufflehog_test + +import ( + "strings" + "testing" + + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/trufflehog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + testCases := []struct { + name string + input string + wantErr bool + wantLen int + assertResult func(t *testing.T, findings []trufflehog.Finding) + }{ + { + name: "empty input", + input: "", + wantLen: 0, + }, + { + name: "whitespace only", + input: " \n\n\t\n", + wantLen: 0, + }, + { + name: "two findings, one verified", + input: `{"SourceMetadata":{"Data":{"Filesystem":{"file":"config.yaml","line":10}}},"SourceID":0,"SourceType":15,"SourceName":"trufflehog - filesystem","DetectorType":8,"DetectorName":"Github","DecoderName":"PLAIN","Verified":true,"Raw":"ghp_secret","Redacted":"ghp_***"} +{"SourceMetadata":{"Data":{"Filesystem":{"file":"app.env","line":3}}},"SourceID":0,"SourceType":15,"SourceName":"trufflehog - filesystem","DetectorType":9,"DetectorName":"AWS","DecoderName":"PLAIN","Verified":false,"Raw":"AKIA...","Redacted":"AKIA***"}`, + wantLen: 2, + assertResult: func(t *testing.T, findings []trufflehog.Finding) { + assert.Equal(t, "Github", findings[0].DetectorName) + assert.Equal(t, 8, findings[0].DetectorType) + assert.True(t, findings[0].Verified) + assert.Equal(t, "ghp_secret", findings[0].Raw) + assert.Equal(t, "ghp_***", findings[0].Redacted) + assert.Equal(t, "AWS", findings[1].DetectorName) + assert.False(t, findings[1].Verified) + assert.NotEmpty(t, findings[0].SourceMetadata.Data) + }, + }, + { + name: "blank lines between findings are skipped", + input: `{"DetectorName":"Github","Verified":true,"Raw":"x"} + +{"DetectorName":"AWS","Verified":false,"Raw":"y"} +`, + wantLen: 2, + }, + { + name: "empty JSON array (canonical clean scan)", + input: `[]`, + wantLen: 0, + }, + { + name: "empty JSON array with surrounding whitespace", + input: " \n [] \n", + wantLen: 0, + }, + { + name: "JSON array with findings", + input: `[{"DetectorName":"Github","Verified":true,"Raw":"x"},{"DetectorName":"AWS","Verified":false,"Raw":"y"}]`, + wantLen: 2, + assertResult: func(t *testing.T, findings []trufflehog.Finding) { + assert.Equal(t, "Github", findings[0].DetectorName) + assert.Equal(t, "AWS", findings[1].DetectorName) + }, + }, + { + name: "malformed JSON array", + input: `[{"DetectorName":"Github"`, + wantErr: true, + }, + { + // "[]" with findings appended must NOT be read as an empty report; + // trailing content is rejected so findings cannot be hidden. + name: "content appended after empty array is rejected", + input: "[]\n{\"DetectorName\":\"Slack\",\"Verified\":true}", + wantErr: true, + }, + { + name: "content appended after non-empty array is rejected", + input: `[{"DetectorName":"Slack"}] {"DetectorName":"AWS"}`, + wantErr: true, + }, + { + name: "empty array with trailing whitespace is accepted", + input: "[]\n \n", + wantLen: 0, + }, + { + name: "malformed line", + input: `this is not json`, + wantErr: true, + }, + { + name: "valid line followed by malformed line", + input: `{"DetectorName":"Github","Verified":true} +{invalid}`, + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + findings, err := trufflehog.Parse(strings.NewReader(tc.input)) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Len(t, findings, tc.wantLen) + if tc.assertResult != nil { + tc.assertResult(t, findings) + } + }) + } +} diff --git a/pkg/attestation/crafter/materials/trufflehog_test.go b/pkg/attestation/crafter/materials/trufflehog_test.go new file mode 100644 index 000000000..a628b6be1 --- /dev/null +++ b/pkg/attestation/crafter/materials/trufflehog_test.go @@ -0,0 +1,172 @@ +// +// 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 ( + "context" + "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 TestNewTrufflehogCrafter(t *testing.T) { + testCases := []struct { + name string + input *contractAPI.CraftingSchema_Material + wantErr bool + }{ + { + name: "happy path", + input: &contractAPI.CraftingSchema_Material{ + Type: contractAPI.CraftingSchema_Material_TRUFFLEHOG_JSON, + }, + }, + { + name: "wrong type", + input: &contractAPI.CraftingSchema_Material{ + Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE, + }, + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := materials.NewTrufflehogCrafter(tc.input, nil, nil) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} + +func TestTrufflehogCrafter_Craft(t *testing.T) { + testCases := []struct { + name string + filePath string + wantErr string + annotations map[string]string + }{ + { + name: "invalid path", + filePath: "./testdata/non-existing.json", + wantErr: "no such file or directory", + }, + { + name: "not JSONL content", + filePath: "./testdata/simple.txt", + wantErr: "unexpected material type", + }, + { + name: "wrong JSON content (multi-line object)", + filePath: "./testdata/sbom-spdx.json", + wantErr: "unexpected material type", + }, + { + name: "valid JSONL but missing DetectorName", + filePath: "./testdata/trufflehog-missing-detector.json", + wantErr: "unexpected material type", + }, + { + name: "empty report (no findings)", + filePath: "./testdata/trufflehog-empty.json", + annotations: map[string]string{ + "chainloop.material.tool.name": "trufflehog", + }, + }, + { + name: "report with findings", + filePath: "./testdata/trufflehog-report.json", + annotations: map[string]string{ + "chainloop.material.tool.name": "trufflehog", + }, + }, + } + + schema := &contractAPI.CraftingSchema_Material{ + Name: "test", + Type: contractAPI.CraftingSchema_Material_TRUFFLEHOG_JSON, + } + + l := zerolog.Nop() + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Mock uploader + 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.NewTrufflehogCrafter(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_TRUFFLEHOG_JSON.String(), got.MaterialType.String()) + assert.True(t, got.UploadedToCas) + + for k, v := range tc.annotations { + assert.Equal(t, v, got.Annotations[k]) + } + }) + } +} + +// TestTrufflehogCrafter_CleanScanEmptyFile covers a clean scan: TruffleHog +// finds no secrets and emits nothing, leaving a zero-byte file. It must be +// accepted and crafted as the canonical empty report "[]", so the recorded +// digest is sha256("[]") — not the universal empty-file digest, which would +// collide across unrelated empty materials. +func TestTrufflehogCrafter_CleanScanEmptyFile(t *testing.T) { + // well-known digests + const emptyReportDigest = "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945" // sha256("[]") + const emptyFileDigest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" // sha256("") + + schema := &contractAPI.CraftingSchema_Material{ + Name: "test", + Type: contractAPI.CraftingSchema_Material_TRUFFLEHOG_JSON, + } + l := zerolog.Nop() + uploader := mUploader.NewUploader(t) + uploader.On("Upload", context.TODO(), mock.Anything, mock.Anything, mock.Anything). + Return(&casclient.UpDownStatus{}, nil) + backend := &casclient.CASBackend{Uploader: uploader} + crafter, err := materials.NewTrufflehogCrafter(schema, backend, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), "./testdata/trufflehog-clean-scan.jsonl") + require.NoError(t, err, "a clean scan (zero-byte output) must be accepted") + assert.True(t, got.UploadedToCas) + assert.Equal(t, emptyReportDigest, got.GetArtifact().Digest, "clean scan must be stored as canonical []") + assert.NotEqual(t, emptyFileDigest, got.GetArtifact().Digest, "must not use the universal empty-file digest") + assert.Equal(t, "trufflehog", got.Annotations["chainloop.material.tool.name"]) +}