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"])
+}