From 29c2c1a683ee0dbc3b5b9cefedb8e5c16aefa110 Mon Sep 17 00:00:00 2001 From: Itsik Kanias Date: Wed, 10 Jun 2026 18:49:09 +0300 Subject: [PATCH 1/2] feat(ingestor): extract structured CycloneDX model card fields into sbom_ai.properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract structured fields from CycloneDX ModelCard into sbom_ai.properties as frontend-friendly keys: model_parameters.task → primaryPurpose, model_parameters.approach.type → typeOfModel, and considerations.technical_limitations → limitation (joined with "; "). Generic model_card.properties overlay on top, preserving precedence. Implements TC-4516 Co-Authored-By: Claude Opus 4.6 --- .../ai/test_structured_model_card.json | 180 ++++++++++++++++++ .../sbom/common/machine_learning_model.rs | 53 +++++- .../ingestor/src/service/sbom/cyclonedx.rs | 164 ++++++++++++++++ 3 files changed, 390 insertions(+), 7 deletions(-) create mode 100644 etc/test-data/cyclonedx/ai/test_structured_model_card.json diff --git a/etc/test-data/cyclonedx/ai/test_structured_model_card.json b/etc/test-data/cyclonedx/ai/test_structured_model_card.json new file mode 100644 index 000000000..e3e0ccfa5 --- /dev/null +++ b/etc/test-data/cyclonedx/ai/test_structured_model_card.json @@ -0,0 +1,180 @@ +{ + "bomFormat": "CycloneDX", + "components": [ + { + "authors": [ + { + "name": "nvidia" + } + ], + "bom-ref": "pkg:huggingface/nvidia/canary-1b-v2@1.0", + "copyright": "NOASSERTION", + "description": "No description available", + "externalReferences": [ + { + "type": "website", + "url": "https://huggingface.co/nvidia/canary-1b-v2" + } + ], + "group": "nvidia", + "licenses": [ + { + "license": { + "id": "unknown", + "url": "https://spdx.org/licenses/" + } + } + ], + "manufacturer": { + "name": "nvidia", + "url": [ + "https://huggingface.co/nvidia" + ] + }, + "modelCard": { + "modelParameters": { + "architectureFamily": "transformer", + "inputs": [ + { + "format": "text" + } + ], + "modelArchitecture": "canary-1b-v2ForCausalLM", + "outputs": [ + { + "format": "generated-text" + } + ], + "task": "text-generation", + "approach": { + "type": "supervised-learning" + } + }, + "quantitativeAnalysis": { + "graphics": {} + }, + "considerations": { + "technicalLimitations": [ + "Limited to English text", + "Max 2048 tokens" + ] + } + }, + "name": "canary-1b-v2", + "publisher": "nvidia", + "purl": "pkg:huggingface/nvidia/canary-1b-v2@1.0", + "supplier": { + "name": "nvidia", + "url": [ + "https://huggingface.co/nvidia" + ] + }, + "type": "machine-learning-model", + "version": "1.0" + } + ], + "dependencies": [ + { + "dependsOn": [ + "pkg:huggingface/nvidia/canary-1b-v2@1.0" + ], + "ref": "pkg:generic/nvidia%2Fcanary-1b-v2@1.0" + } + ], + "externalReferences": [ + { + "type": "distribution", + "url": "https://huggingface.co/nvidia/canary-1b-v2" + } + ], + "metadata": { + "component": { + "bom-ref": "pkg:generic/nvidia%2Fcanary-1b-v2@1.0", + "copyright": "NOASSERTION", + "description": "No description available", + "name": "canary-1b-v2", + "purl": "pkg:generic/nvidia%2Fcanary-1b-v2@1.0", + "type": "application", + "version": "1.0" + }, + "properties": [ + { + "name": "primaryPurpose", + "value": "automatic-speech-recognition" + }, + { + "name": "suppliedBy", + "value": "nvidia" + }, + { + "name": "typeOfModel", + "value": "transformer" + }, + { + "name": "bomFormat", + "value": "CycloneDX" + }, + { + "name": "specVersion", + "value": "1.6" + }, + { + "name": "serialNumber", + "value": "urn:uuid:nvidia-canary-1b-v2" + }, + { + "name": "version", + "value": "1.0.0" + }, + { + "name": "primaryPurpose", + "value": "automatic-speech-recognition" + }, + { + "name": "suppliedBy", + "value": "nvidia" + }, + { + "name": "licenses", + "value": "cc-by-4.0" + }, + { + "name": "limitation", + "value": "adhered to, & Mitigation | Transcripts and translations may be not 100% accurate" + }, + { + "name": "safetyRiskAssessment", + "value": "\n\nField | Response\n:---------------------------------------------------:|:----------------------------------\nModel Application(s) | Speech to Text Transcription\nDescribe the life critical impact | None\nUse Case Restrictions | Abide by [CC-BY-4, \n\nField | Response\n:---------------------------------------------------------------------------------------------------|:---------------:\nParticipation considerations from adversely impacted groups [protected classes](https://www, | If a word is not trained in the language model and not presented in vocabulary, the word is not likely to be recognized, & Security, and Privacy Subcards [here](https://developer, | None\n\n## Explainability:\n\nField | Response\n:------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------:\nIntended Domain | Speech to Text Transcription and Translation\nModel Type | Attention Encoder-Decoder\nIntended Users | This model is intended for developers, researchers, academics, and industries building conversational based applications" + }, + { + "name": "typeOfModel", + "value": "transformer" + }, + { + "name": "downloadLocation", + "value": "https://huggingface.co/nvidia/canary-1b-v2/tree/main" + }, + { + "name": "external_references", + "value": "[{\"type\": \"website\", \"url\": \"https://huggingface.co/nvidia/canary-1b-v2\", \"comment\": \"Model repository\"}, {\"type\": \"distribution\", \"url\": \"https://huggingface.co/nvidia/canary-1b-v2/tree/main\", \"comment\": \"Model files\"}]" + } + ], + "timestamp": "2025-09-26T10:07:22.695451Z", + "tools": { + "components": [ + { + "bom-ref": "pkg:generic/aetheris-ai/aetheris-aibom-generator@1.0.0", + "manufacturer": { + "name": "Aetheris AI" + }, + "name": "aetheris-aibom-generator", + "type": "application", + "version": "1.0" + } + ] + } + }, + "serialNumber": "urn:uuid:test-structured-fields", + "specVersion": "1.6", + "version": 1 +} \ No newline at end of file diff --git a/modules/ingestor/src/graph/sbom/common/machine_learning_model.rs b/modules/ingestor/src/graph/sbom/common/machine_learning_model.rs index 183bb985c..989ac20be 100644 --- a/modules/ingestor/src/graph/sbom/common/machine_learning_model.rs +++ b/modules/ingestor/src/graph/sbom/common/machine_learning_model.rs @@ -17,13 +17,52 @@ pub struct ModelCard { impl From<&Component> for ModelCard { fn from(c: &Component) -> Self { - let properties = Value::from(c.model_card.as_ref().and_then(|card| { - card.properties.as_ref().map(|v| { - v.iter() - .map(|p| (p.name.clone(), p.value.clone().into())) - .collect::>() - }) - })); + let properties = match c.model_card.as_ref() { + Some(card) => { + let mut map = Map::new(); + + if let Some(params) = &card.model_parameters { + if let Some(task) = ¶ms.task { + map.insert( + "primaryPurpose".to_string(), + Value::String(task.clone()), + ); + } + if let Some(approach) = ¶ms.approach + && let Some(type_val) = &approach.type_ + { + map.insert( + "typeOfModel".to_string(), + Value::String(type_val.clone()), + ); + } + } + + if let Some(considerations) = &card.considerations + && let Some(limitations) = &considerations.technical_limitations + && !limitations.is_empty() + { + map.insert( + "limitation".to_string(), + Value::String(limitations.join("; ")), + ); + } + + // Generic properties take precedence over structured fields + if let Some(props) = &card.properties { + for p in props { + map.insert(p.name.clone(), p.value.clone().into()); + } + } + + if map.is_empty() { + Value::Null + } else { + Value::Object(map) + } + } + None => Value::Null, + }; ModelCard { properties } } } diff --git a/modules/ingestor/src/service/sbom/cyclonedx.rs b/modules/ingestor/src/service/sbom/cyclonedx.rs index c824f96f9..b08990871 100644 --- a/modules/ingestor/src/service/sbom/cyclonedx.rs +++ b/modules/ingestor/src/service/sbom/cyclonedx.rs @@ -231,6 +231,170 @@ mod test { Ok(()) } + + #[test_context(TrustifyContext)] + #[test(tokio::test)] + async fn ingest_ai_cyclonedx_nvidia_properties( + ctx: &TrustifyContext, + ) -> Result<(), anyhow::Error> { + let graph = Graph::new(); + let data = document_bytes("cyclonedx/ai/nvidia_canary-1b-v2_aibom.json").await?; + + let ingestor = IngestorService::new(graph, ctx.storage.clone(), Default::default()); + + ctx.db + .transaction(async |tx| { + ingestor + .ingest( + &data, + Format::CycloneDX, + [("type", "cyclonedx"), ("kind", "aibom")], + None, + Cache::Skip, + tx, + ) + .await + }) + .await?; + + let models = sbom_ai::Entity::find().all(&ctx.db).await?; + assert_eq!(1, models.len()); + + let props = &models[0].properties; + + // Generic properties take precedence over structured model_parameters.task + // modelParameters.task = "text-generation" but generic property primaryPurpose = "automatic-speech-recognition" + assert_eq!( + props.get("primaryPurpose").and_then(|v| v.as_str()), + Some("automatic-speech-recognition"), + ); + + // typeOfModel comes from generic properties (no structured approach.type_ in fixture) + assert_eq!( + props.get("typeOfModel").and_then(|v| v.as_str()), + Some("transformer"), + ); + + // limitation comes from generic properties (no structured considerations in fixture) + assert!( + props.get("limitation").and_then(|v| v.as_str()).is_some(), + ); + + // Other generic properties are also present + assert_eq!( + props.get("bomFormat").and_then(|v| v.as_str()), + Some("CycloneDX"), + ); + + Ok(()) + } + + #[test_context(TrustifyContext)] + #[test(tokio::test)] + async fn ingest_ai_cyclonedx_ibm_properties( + ctx: &TrustifyContext, + ) -> Result<(), anyhow::Error> { + let graph = Graph::new(); + let data = + document_bytes("cyclonedx/ai/ibm-granite_granite-docling-258M_aibom.json").await?; + + let ingestor = IngestorService::new(graph, ctx.storage.clone(), Default::default()); + + ctx.db + .transaction(async |tx| { + ingestor + .ingest( + &data, + Format::CycloneDX, + [("type", "cyclonedx"), ("kind", "aibom")], + None, + Cache::Skip, + tx, + ) + .await + }) + .await?; + + let models = sbom_ai::Entity::find().all(&ctx.db).await?; + assert_eq!(1, models.len()); + + let props = &models[0].properties; + + // Generic property overrides structured model_parameters.task + // modelParameters.task = "text-generation" but generic property primaryPurpose = "image-text-to-text" + assert_eq!( + props.get("primaryPurpose").and_then(|v| v.as_str()), + Some("image-text-to-text"), + ); + + // typeOfModel from generic properties + assert_eq!( + props.get("typeOfModel").and_then(|v| v.as_str()), + Some("idefics3"), + ); + + // IBM fixture has no limitation property + assert!(props.get("limitation").is_none()); + + Ok(()) + } + + + #[test_context(TrustifyContext)] + #[test(tokio::test)] + async fn ingest_ai_cyclonedx_structured_fields( + ctx: &TrustifyContext, + ) -> Result<(), anyhow::Error> { + let graph = Graph::new(); + let data = + document_bytes("cyclonedx/ai/test_structured_model_card.json").await?; + + let ingestor = IngestorService::new(graph, ctx.storage.clone(), Default::default()); + + ctx.db + .transaction(async |tx| { + ingestor + .ingest( + &data, + Format::CycloneDX, + [("type", "cyclonedx"), ("kind", "aibom")], + None, + Cache::Skip, + tx, + ) + .await + }) + .await?; + + let models = sbom_ai::Entity::find().all(&ctx.db).await?; + assert_eq!(1, models.len()); + + let props = &models[0].properties; + + // Structured field: modelParameters.task → primaryPurpose + assert_eq!( + props.get("primaryPurpose").and_then(|v| v.as_str()), + Some("text-generation"), + ); + + // Structured field: modelParameters.approach.type → typeOfModel + assert_eq!( + props.get("typeOfModel").and_then(|v| v.as_str()), + Some("supervised-learning"), + ); + + // Structured field: considerations.technicalLimitations → limitation (joined) + assert_eq!( + props.get("limitation").and_then(|v| v.as_str()), + Some("Limited to English text; Max 2048 tokens"), + ); + + // No generic properties in this fixture, so no bomFormat etc. + assert!(props.get("bomFormat").is_none()); + + Ok(()) + } + #[test_context(TrustifyContext)] #[test(tokio::test)] async fn ingest_cryptographic_cyclonedx(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { From 5bba9716fe0563133f7204c88ad5004a7a4d9cb3 Mon Sep 17 00:00:00 2001 From: Itsik Kanias Date: Fri, 12 Jun 2026 18:41:37 +0300 Subject: [PATCH 2/2] feat(ingestor): extract additional CycloneDX 1.6 model card considerations Extract 5 additional CycloneDX 1.6 ModelCardConsiderations fields into sbom_ai.properties during AIBOM ingestion: ethicalConsiderations as safetyRiskAssessment (JSON array), fairnessAssessments (JSON array), performanceTradeoffs, useCases, and users (joined strings). Implements TC-4796 Co-Authored-By: Claude Opus 4.6 Assisted-by: Claude Code --- .../ai/test_structured_model_card.json | 29 ++++++ .../sbom/common/machine_learning_model.rs | 96 ++++++++++++++++--- .../ingestor/src/service/sbom/cyclonedx.rs | 70 ++++++++++++-- 3 files changed, 173 insertions(+), 22 deletions(-) diff --git a/etc/test-data/cyclonedx/ai/test_structured_model_card.json b/etc/test-data/cyclonedx/ai/test_structured_model_card.json index e3e0ccfa5..85c94ff60 100644 --- a/etc/test-data/cyclonedx/ai/test_structured_model_card.json +++ b/etc/test-data/cyclonedx/ai/test_structured_model_card.json @@ -57,6 +57,35 @@ "technicalLimitations": [ "Limited to English text", "Max 2048 tokens" + ], + "ethicalConsiderations": [ + { + "name": "Bias in training data", + "mitigationStrategy": "Regular auditing of model outputs" + }, + { + "name": "Privacy risks from memorization" + } + ], + "fairnessAssessments": [ + { + "groupAtRisk": "Non-English speakers", + "benefits": "Improved accessibility", + "harms": "Lower accuracy for underrepresented languages", + "mitigationStrategy": "Multilingual fine-tuning planned" + } + ], + "performanceTradeoffs": [ + "Speed vs accuracy on long sequences", + "Memory usage scales quadratically" + ], + "useCases": [ + "Conversational AI", + "Document summarization" + ], + "users": [ + "Developers", + "Researchers" ] } }, diff --git a/modules/ingestor/src/graph/sbom/common/machine_learning_model.rs b/modules/ingestor/src/graph/sbom/common/machine_learning_model.rs index 989ac20be..725640248 100644 --- a/modules/ingestor/src/graph/sbom/common/machine_learning_model.rs +++ b/modules/ingestor/src/graph/sbom/common/machine_learning_model.rs @@ -23,29 +23,95 @@ impl From<&Component> for ModelCard { if let Some(params) = &card.model_parameters { if let Some(task) = ¶ms.task { - map.insert( - "primaryPurpose".to_string(), - Value::String(task.clone()), - ); + map.insert("primaryPurpose".to_string(), Value::String(task.clone())); } if let Some(approach) = ¶ms.approach && let Some(type_val) = &approach.type_ + { + map.insert("typeOfModel".to_string(), Value::String(type_val.clone())); + } + } + + if let Some(considerations) = &card.considerations { + if let Some(limitations) = &considerations.technical_limitations + && !limitations.is_empty() { map.insert( - "typeOfModel".to_string(), - Value::String(type_val.clone()), + "limitation".to_string(), + Value::String(limitations.join("; ")), ); } - } - if let Some(considerations) = &card.considerations - && let Some(limitations) = &considerations.technical_limitations - && !limitations.is_empty() - { - map.insert( - "limitation".to_string(), - Value::String(limitations.join("; ")), - ); + if let Some(risks) = &considerations.ethical_considerations + && !risks.is_empty() + { + let arr: Vec = risks + .iter() + .map(|r| { + let mut obj = Map::new(); + if let Some(name) = &r.name { + obj.insert("name".to_string(), Value::String(name.clone())); + } + if let Some(ms) = &r.mitigation_strategy { + obj.insert( + "mitigationStrategy".to_string(), + Value::String(ms.clone()), + ); + } + Value::Object(obj) + }) + .collect(); + map.insert("safetyRiskAssessment".to_string(), Value::Array(arr)); + } + + if let Some(assessments) = &considerations.fairness_assessments + && !assessments.is_empty() + { + let arr: Vec = assessments + .iter() + .map(|a| { + let mut obj = Map::new(); + if let Some(v) = &a.group_at_risk { + obj.insert("groupAtRisk".to_string(), Value::String(v.clone())); + } + if let Some(v) = &a.benefits { + obj.insert("benefits".to_string(), Value::String(v.clone())); + } + if let Some(v) = &a.harms { + obj.insert("harms".to_string(), Value::String(v.clone())); + } + if let Some(v) = &a.mitigation_strategy { + obj.insert( + "mitigationStrategy".to_string(), + Value::String(v.clone()), + ); + } + Value::Object(obj) + }) + .collect(); + map.insert("fairnessAssessments".to_string(), Value::Array(arr)); + } + + if let Some(tradeoffs) = &considerations.performance_tradeoffs + && !tradeoffs.is_empty() + { + map.insert( + "performanceTradeoffs".to_string(), + Value::String(tradeoffs.join("; ")), + ); + } + + if let Some(use_cases) = &considerations.use_cases + && !use_cases.is_empty() + { + map.insert("useCases".to_string(), Value::String(use_cases.join("; "))); + } + + if let Some(users) = &considerations.users + && !users.is_empty() + { + map.insert("users".to_string(), Value::String(users.join("; "))); + } } // Generic properties take precedence over structured fields diff --git a/modules/ingestor/src/service/sbom/cyclonedx.rs b/modules/ingestor/src/service/sbom/cyclonedx.rs index b08990871..0f7a5d3aa 100644 --- a/modules/ingestor/src/service/sbom/cyclonedx.rs +++ b/modules/ingestor/src/service/sbom/cyclonedx.rs @@ -231,7 +231,6 @@ mod test { Ok(()) } - #[test_context(TrustifyContext)] #[test(tokio::test)] async fn ingest_ai_cyclonedx_nvidia_properties( @@ -276,9 +275,7 @@ mod test { ); // limitation comes from generic properties (no structured considerations in fixture) - assert!( - props.get("limitation").and_then(|v| v.as_str()).is_some(), - ); + assert!(props.get("limitation").and_then(|v| v.as_str()).is_some(),); // Other generic properties are also present assert_eq!( @@ -339,15 +336,13 @@ mod test { Ok(()) } - #[test_context(TrustifyContext)] #[test(tokio::test)] async fn ingest_ai_cyclonedx_structured_fields( ctx: &TrustifyContext, ) -> Result<(), anyhow::Error> { let graph = Graph::new(); - let data = - document_bytes("cyclonedx/ai/test_structured_model_card.json").await?; + let data = document_bytes("cyclonedx/ai/test_structured_model_card.json").await?; let ingestor = IngestorService::new(graph, ctx.storage.clone(), Default::default()); @@ -389,6 +384,67 @@ mod test { Some("Limited to English text; Max 2048 tokens"), ); + // Structured field: considerations.ethicalConsiderations → safetyRiskAssessment + let risks = props.get("safetyRiskAssessment").and_then(|v| v.as_array()); + assert!(risks.is_some()); + let risks = risks.unwrap(); + assert_eq!(risks.len(), 2); + assert_eq!( + risks[0].get("name").and_then(|v| v.as_str()), + Some("Bias in training data"), + ); + assert_eq!( + risks[0].get("mitigationStrategy").and_then(|v| v.as_str()), + Some("Regular auditing of model outputs"), + ); + assert_eq!( + risks[1].get("name").and_then(|v| v.as_str()), + Some("Privacy risks from memorization"), + ); + assert!(risks[1].get("mitigationStrategy").is_none()); + + // Structured field: considerations.fairnessAssessments → fairnessAssessments + let assessments = props.get("fairnessAssessments").and_then(|v| v.as_array()); + assert!(assessments.is_some()); + let assessments = assessments.unwrap(); + assert_eq!(assessments.len(), 1); + assert_eq!( + assessments[0].get("groupAtRisk").and_then(|v| v.as_str()), + Some("Non-English speakers"), + ); + assert_eq!( + assessments[0].get("benefits").and_then(|v| v.as_str()), + Some("Improved accessibility"), + ); + assert_eq!( + assessments[0].get("harms").and_then(|v| v.as_str()), + Some("Lower accuracy for underrepresented languages"), + ); + assert_eq!( + assessments[0] + .get("mitigationStrategy") + .and_then(|v| v.as_str()), + Some("Multilingual fine-tuning planned"), + ); + + // Structured field: considerations.performanceTradeoffs → performanceTradeoffs + assert_eq!( + props.get("performanceTradeoffs").and_then(|v| v.as_str()), + Some("Speed vs accuracy on long sequences; Memory usage scales quadratically"), + ); + + // Structured field: considerations.useCases → useCases + assert_eq!( + props.get("useCases").and_then(|v| v.as_str()), + Some("Conversational AI; Document summarization"), + ); + + // Structured field: considerations.users → users + assert_eq!( + props.get("users").and_then(|v| v.as_str()), + Some("Developers; Researchers"), + ); + // No generic properties in this fixture, so no bomFormat etc. assert!(props.get("bomFormat").is_none());