Skip to content

Commit 88d1f02

Browse files
kazu11max17claude
andcommitted
v0.1.2: Add XXE protection, disclaimer, and publish metadata
- Add defense-in-depth DOCTYPE/ENTITY rejection (case-insensitive) and 50MB input size limit to all parsers - Add legal disclaimer to all output formats (Table/JSON/SARIF/HTML/CRA) - Add homepage/documentation to Cargo.toml workspace metadata - Bump version to 0.1.2 - Add 5 security validation tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2c35ecc commit 88d1f02

11 files changed

Lines changed: 153 additions & 11 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ members = ["crates/shieldbom-core", "crates/shieldbom-cli", "crates/shieldbom-se
33
resolver = "2"
44

55
[workspace.package]
6-
version = "0.1.1"
6+
version = "0.1.2"
77
edition = "2021"
88
license = "Apache-2.0"
99
repository = "https://github.com/kazu11max17/shieldbom"
10+
homepage = "https://github.com/kazu11max17/shieldbom"
11+
documentation = "https://docs.rs/shieldbom"
1012
description = "SBOM management CLI for embedded & IoT — SPDX/CycloneDX parsing, OSV.dev vulnerability scanning, EU CRA compliance"
1113

1214
[workspace.dependencies]

crates/shieldbom-cli/src/cli.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,17 @@ use shieldbom::report::OutputFormat;
77

88
/// ShieldBOM - SBOM vulnerability scanner for embedded/IoT software
99
#[derive(Parser)]
10-
#[command(name = "shieldbom", version, about, long_about = None)]
10+
#[command(
11+
name = "shieldbom",
12+
version,
13+
about,
14+
long_about = None,
15+
after_help = "\
16+
EXIT CODES:
17+
0 No vulnerabilities or license issues found above threshold
18+
1 Vulnerabilities or license issues found above threshold
19+
2 Error (invalid input, parse failure, network error)"
20+
)]
1121
pub struct Cli {
1222
#[command(subcommand)]
1323
pub command: Commands,

crates/shieldbom-core/src/models.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ pub struct AnalysisReport {
160160
pub license_issues: Vec<LicenseIssue>,
161161
pub stats: AnalysisStats,
162162
pub timestamp: DateTime<Utc>,
163+
pub disclaimer: String,
163164
}
164165

165166
impl AnalysisReport {
@@ -179,6 +180,7 @@ impl AnalysisReport {
179180
license_issues,
180181
stats,
181182
timestamp: Utc::now(),
183+
disclaimer: crate::report::DISCLAIMER.to_string(),
182184
}
183185
}
184186

crates/shieldbom-core/src/parser/cyclonedx.rs

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,16 @@ struct CdxHash {
5656
content: Option<String>,
5757
}
5858

59+
const MAX_SBOM_SIZE: usize = 50 * 1024 * 1024; // 50MB
60+
5961
pub fn parse_json(content: &str) -> Result<ParsedSbom> {
62+
if content.len() > MAX_SBOM_SIZE {
63+
return Err(crate::errors::ShieldBomError::ParseError(
64+
"SBOM file exceeds maximum size of 50MB".to_string(),
65+
)
66+
.into());
67+
}
68+
6069
let doc: CdxDocument = serde_json::from_str(content)
6170
.map_err(|e| crate::errors::ShieldBomError::ParseError(format!("CycloneDX JSON: {e}")))?;
6271

@@ -78,7 +87,34 @@ pub fn parse_json(content: &str) -> Result<ParsedSbom> {
7887
}
7988

8089
pub fn parse_xml(content: &str) -> Result<ParsedSbom> {
81-
// For XML, we use a simplified approach: deserialize via quick-xml
90+
// Security: quick-xml 0.36 does not support DTD processing or external entity
91+
// expansion, making it inherently safe against XXE and Billion Laughs attacks.
92+
// As defense-in-depth we also reject documents containing DOCTYPE declarations
93+
// and enforce a maximum input size.
94+
95+
if content.len() > MAX_SBOM_SIZE {
96+
return Err(crate::errors::ShieldBomError::ParseError(
97+
"SBOM file exceeds maximum size of 50MB".to_string(),
98+
)
99+
.into());
100+
}
101+
102+
// Defense-in-depth: reject any input that contains a DOCTYPE or ENTITY declaration.
103+
// Even though quick-xml ignores DTDs, blocking them outright prevents future
104+
// regressions or parser-swap surprises.
105+
fn contains_ci(haystack: &[u8], needle: &[u8]) -> bool {
106+
haystack
107+
.windows(needle.len())
108+
.any(|w| w.eq_ignore_ascii_case(needle))
109+
}
110+
if contains_ci(content.as_bytes(), b"<!DOCTYPE") || contains_ci(content.as_bytes(), b"<!ENTITY")
111+
{
112+
return Err(crate::errors::ShieldBomError::ParseError(
113+
"XML DOCTYPE/ENTITY declarations are not allowed for security reasons".to_string(),
114+
)
115+
.into());
116+
}
117+
82118
let doc: CdxXmlDocument = quick_xml::de::from_str(content)
83119
.map_err(|e| crate::errors::ShieldBomError::ParseError(format!("CycloneDX XML: {e}")))?;
84120

@@ -200,3 +236,51 @@ struct CdxXmlLicense {
200236
id: Option<String>,
201237
name: Option<String>,
202238
}
239+
240+
#[cfg(test)]
241+
mod tests {
242+
use super::*;
243+
244+
#[test]
245+
fn parse_xml_rejects_doctype() {
246+
let xml = r#"<?xml version="1.0"?>
247+
<!DOCTYPE bom [<!ENTITY xxe "test">]>
248+
<bom xmlns="http://cyclonedx.org/schema/bom/1.4">
249+
<components/>
250+
</bom>"#;
251+
let err = parse_xml(xml).unwrap_err();
252+
assert!(err.to_string().contains("DOCTYPE/ENTITY"));
253+
}
254+
255+
#[test]
256+
fn parse_xml_rejects_entity() {
257+
let xml = r#"<?xml version="1.0"?>
258+
<!ENTITY xxe SYSTEM "file:///etc/passwd">
259+
<bom xmlns="http://cyclonedx.org/schema/bom/1.4">
260+
<components/>
261+
</bom>"#;
262+
let err = parse_xml(xml).unwrap_err();
263+
assert!(err.to_string().contains("DOCTYPE/ENTITY"));
264+
}
265+
266+
#[test]
267+
fn parse_xml_rejects_doctype_mixed_case() {
268+
let xml = "<!DocType foo><bom></bom>";
269+
let err = parse_xml(xml).unwrap_err();
270+
assert!(err.to_string().contains("DOCTYPE/ENTITY"));
271+
}
272+
273+
#[test]
274+
fn parse_xml_rejects_oversized_input() {
275+
let big = "x".repeat(51 * 1024 * 1024);
276+
let err = parse_xml(&big).unwrap_err();
277+
assert!(err.to_string().contains("50MB"));
278+
}
279+
280+
#[test]
281+
fn parse_json_rejects_oversized_input() {
282+
let big = "x".repeat(51 * 1024 * 1024);
283+
let err = parse_json(&big).unwrap_err();
284+
assert!(err.to_string().contains("50MB"));
285+
}
286+
}

crates/shieldbom-core/src/parser/spdx.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,16 @@ struct SpdxChecksum {
4848
checksum_value: Option<String>,
4949
}
5050

51+
const MAX_SBOM_SIZE: usize = 50 * 1024 * 1024; // 50MB
52+
5153
pub fn parse_json(content: &str) -> Result<ParsedSbom> {
54+
if content.len() > MAX_SBOM_SIZE {
55+
return Err(crate::errors::ShieldBomError::ParseError(
56+
"SBOM file exceeds maximum size of 50MB".to_string(),
57+
)
58+
.into());
59+
}
60+
5261
let doc: SpdxDocument = serde_json::from_str(content)
5362
.map_err(|e| crate::errors::ShieldBomError::ParseError(format!("SPDX JSON: {e}")))?;
5463

@@ -100,6 +109,13 @@ pub fn parse_json(content: &str) -> Result<ParsedSbom> {
100109
}
101110

102111
pub fn parse_tag_value(content: &str) -> Result<ParsedSbom> {
112+
if content.len() > MAX_SBOM_SIZE {
113+
return Err(crate::errors::ShieldBomError::ParseError(
114+
"SBOM file exceeds maximum size of 50MB".to_string(),
115+
)
116+
.into());
117+
}
118+
103119
let mut components = Vec::new();
104120
let mut current: Option<TagValueBuilder> = None;
105121

crates/shieldbom-core/src/report/cra.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ mod tests {
466466
let html = render_cra(&report).unwrap();
467467

468468
assert!(html.contains("Generated by ShieldBOM"));
469-
assert!(html.contains("does not constitute legal advice"));
469+
assert!(html.contains("does not constitute a complete security assessment, legal advice"));
470470
}
471471

472472
#[test]

crates/shieldbom-core/src/report/cra_template.html

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -415,10 +415,7 @@ <h2><span class="section-num">6</span> Technical Documentation Reference</h2>
415415
<footer>
416416
Generated by ShieldBOM v{{ version }} &mdash; {{ timestamp }}
417417
<div class="disclaimer">
418-
This report is generated automatically based on SBOM analysis and vulnerability scanning.
419-
It is intended to support EU CRA compliance efforts but does not constitute legal advice.
420-
Conformity assessment items marked N/A require manual verification by the manufacturer.
421-
Consult qualified legal counsel for binding interpretations of CRA obligations.
418+
DISCLAIMER: This report is provided "AS IS" without warranty of any kind. Vulnerability results are based on publicly available data sources (e.g., OSV.dev, NVD) which may be incomplete or delayed. The absence of reported vulnerabilities does not guarantee that the software is free of security issues. This tool assists with security analysis but does not constitute a complete security assessment, legal advice, or certification of regulatory compliance (including EU CRA conformity). Users are solely responsible for their own compliance determinations. Always perform additional security assessments as appropriate.
422419
</div>
423420
</footer>
424421

crates/shieldbom-core/src/report/mod.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ use colored::Colorize;
77

88
use crate::models::{AnalysisReport, Severity};
99

10+
/// Disclaimer text included in all output formats.
11+
pub const DISCLAIMER: &str = "DISCLAIMER: This report is provided \"AS IS\" without warranty of any kind. Vulnerability results are based on publicly available data sources (e.g., OSV.dev, NVD) which may be incomplete or delayed. The absence of reported vulnerabilities does not guarantee that the software is free of security issues. This tool assists with security analysis but does not constitute a complete security assessment, legal advice, or certification of regulatory compliance (including EU CRA conformity). Users are solely responsible for their own compliance determinations. Always perform additional security assessments as appropriate.";
12+
1013
/// Truncate a string at a safe UTF-8 char boundary.
1114
fn truncate_str(s: &str, max_chars: usize) -> String {
1215
let mut chars = s.chars();
@@ -49,7 +52,18 @@ pub fn render(report: &AnalysisReport, format: OutputFormat) -> Result<()> {
4952
println!("{output}");
5053
Ok(())
5154
}
55+
}?;
56+
57+
// Print disclaimer to stderr for all formats.
58+
// For structured outputs (JSON, SARIF) this avoids corrupting stdout.
59+
// For table output, render_table already prints it, so we skip.
60+
// For HTML/CRA, the disclaimer is embedded in the template footer AND printed to stderr.
61+
match format {
62+
OutputFormat::Table => {} // already printed inside render_table
63+
_ => eprintln!("\n{}", report.disclaimer),
5264
}
65+
66+
Ok(())
5367
}
5468

5569
fn render_table(report: &AnalysisReport) -> Result<()> {
@@ -119,6 +133,8 @@ fn render_table(report: &AnalysisReport) -> Result<()> {
119133
println!("{}", "No issues found.".green().bold());
120134
}
121135

136+
eprintln!("\n{DISCLAIMER}");
137+
122138
Ok(())
123139
}
124140

crates/shieldbom-core/src/report/sarif.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ struct SarifRun {
5252
#[serde(skip_serializing_if = "Vec::is_empty")]
5353
artifacts: Vec<SarifArtifact>,
5454
results: Vec<SarifResult>,
55+
/// Run-level properties (disclaimer, etc.)
56+
#[serde(skip_serializing_if = "Option::is_none")]
57+
properties: Option<SarifRunPropertyBag>,
58+
}
59+
60+
/// Property bag for run-level metadata.
61+
#[derive(Debug, Serialize, PartialEq)]
62+
struct SarifRunPropertyBag {
63+
disclaimer: String,
5564
}
5665

5766
#[derive(Debug, Serialize, PartialEq)]
@@ -274,6 +283,9 @@ impl SarifLog {
274283
},
275284
artifacts,
276285
results,
286+
properties: Some(SarifRunPropertyBag {
287+
disclaimer: report.disclaimer.clone(),
288+
}),
277289
}],
278290
}
279291
}

0 commit comments

Comments
 (0)