Skip to main content

sbom_tools/license/
policy.rs

1//! License policy evaluation.
2//!
3//! Checks component licenses against allow/deny/review lists,
4//! with glob pattern matching for license families.
5//!
6//! SPDX expressions are evaluated operand-by-operand:
7//! - `OR` is the licensee's choice — an expression is only denied if *every*
8//!   alternative hits the deny list ("MIT OR GPL-3.0-only" passes a `GPL-*`
9//!   deny; "GPL-2.0-only OR GPL-3.0-only" does not).
10//! - `AND` requires every operand to comply — one denied operand denies the
11//!   whole expression.
12//! - `WITH` exceptions match patterns against the base license ID
13//!   ("Apache-2.0 WITH LLVM-exception" matches an `Apache-2.0` pattern).
14//!
15//! Non-parseable expressions fall back to whole-string pattern matching.
16
17use crate::model::{LicenseExpression, LicenseFamily, NormalizedSbom};
18use serde::{Deserialize, Serialize};
19
20/// License policy configuration
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22pub struct LicensePolicyConfig {
23    /// Allowed license SPDX IDs (glob patterns supported: `BSD-*`)
24    #[serde(default)]
25    pub allow: Vec<String>,
26    /// Denied license SPDX IDs (glob patterns supported: `AGPL-*`)
27    #[serde(default)]
28    pub deny: Vec<String>,
29    /// Licenses that require manual review
30    #[serde(default)]
31    pub review: Vec<String>,
32    /// Fail on copyleft + proprietary conflicts in dependency tree
33    #[serde(default = "default_true")]
34    pub fail_on_conflict: bool,
35}
36
37fn default_true() -> bool {
38    true
39}
40
41impl LicensePolicyConfig {
42    /// Create a permissive policy that allows everything
43    #[must_use]
44    pub fn permissive() -> Self {
45        Self::default()
46    }
47
48    /// Create a strict policy that only allows common permissive licenses
49    #[must_use]
50    pub fn strict_permissive() -> Self {
51        Self {
52            allow: vec![
53                "MIT".to_string(),
54                "Apache-2.0".to_string(),
55                "BSD-2-Clause".to_string(),
56                "BSD-3-Clause".to_string(),
57                "ISC".to_string(),
58                "0BSD".to_string(),
59                "Unlicense".to_string(),
60                "CC0-1.0".to_string(),
61            ],
62            deny: vec![
63                "AGPL-*".to_string(),
64                "SSPL-*".to_string(),
65                "BSL-*".to_string(),
66            ],
67            review: vec![
68                "GPL-*".to_string(),
69                "LGPL-*".to_string(),
70                "MPL-*".to_string(),
71                "EPL-*".to_string(),
72                "CDDL-*".to_string(),
73            ],
74            fail_on_conflict: true,
75        }
76    }
77}
78
79/// Policy decision for a license
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub enum PolicyDecision {
82    /// License is explicitly allowed
83    Allowed,
84    /// License is explicitly denied
85    Denied,
86    /// License requires manual review
87    NeedsReview,
88    /// No policy rule matched — allowed by default
89    Unspecified,
90    /// No license declared
91    Undeclared,
92}
93
94/// A license policy violation for a specific component
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct LicensePolicyViolation {
97    /// Component name
98    pub component: String,
99    /// Component version
100    pub version: Option<String>,
101    /// The license expression that triggered the violation
102    pub license: String,
103    /// Policy decision
104    pub decision: PolicyDecision,
105    /// License family classification
106    pub family: LicenseFamily,
107}
108
109/// Overall license policy evaluation result
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct LicensePolicyResult {
112    /// Total components evaluated
113    pub total_components: usize,
114    /// Components that passed policy
115    pub allowed_count: usize,
116    /// Components with denied licenses
117    pub denied_count: usize,
118    /// Components requiring review
119    pub review_count: usize,
120    /// Components with no license declared
121    pub undeclared_count: usize,
122    /// Whether the policy passed (no denied licenses)
123    pub passed: bool,
124    /// All violations (denied + review + undeclared)
125    pub violations: Vec<LicensePolicyViolation>,
126}
127
128/// Check if a license ID matches a pattern (supports `*` glob at end)
129fn matches_pattern(license_id: &str, pattern: &str) -> bool {
130    if let Some(prefix) = pattern.strip_suffix('*') {
131        license_id
132            .get(..prefix.len())
133            .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
134    } else {
135        license_id.eq_ignore_ascii_case(pattern)
136    }
137}
138
139/// Check if a license ID matches any pattern in the list
140fn matches_any(license_id: &str, patterns: &[String]) -> bool {
141    patterns
142        .iter()
143        .any(|pattern| matches_pattern(license_id, pattern))
144}
145
146/// Extract the operand license ID (without any WITH exception)
147fn req_license_id(req: &spdx::LicenseReq) -> String {
148    req.license.to_string()
149}
150
151/// Evaluate a single license ID (or non-parseable expression) against the policy
152fn evaluate_license_id(license_id: &str, config: &LicensePolicyConfig) -> PolicyDecision {
153    if matches_any(license_id, &config.deny) {
154        return PolicyDecision::Denied;
155    }
156
157    if matches_any(license_id, &config.review) {
158        return PolicyDecision::NeedsReview;
159    }
160
161    if config.allow.is_empty() {
162        // No allow list means everything not denied/review is allowed
163        return PolicyDecision::Unspecified;
164    }
165
166    if matches_any(license_id, &config.allow) {
167        return PolicyDecision::Allowed;
168    }
169
170    // If allow list exists but license didn't match, it needs review
171    PolicyDecision::NeedsReview
172}
173
174/// Evaluate a single license expression against the policy
175fn evaluate_expression(expr: &LicenseExpression, config: &LicensePolicyConfig) -> PolicyDecision {
176    let Ok(parsed) = spdx::Expression::parse_mode(&expr.expression, spdx::ParseMode::LAX) else {
177        return evaluate_license_id(&expr.expression, config);
178    };
179
180    // Denied iff the expression cannot be satisfied while avoiding denied
181    // operands (OR alternatives are the licensee's choice)
182    if !parsed.evaluate(|req| !matches_any(&req_license_id(req), &config.deny)) {
183        return PolicyDecision::Denied;
184    }
185
186    if config.allow.is_empty() {
187        let clean = parsed.evaluate(|req| {
188            let id = req_license_id(req);
189            !matches_any(&id, &config.deny) && !matches_any(&id, &config.review)
190        });
191        if clean {
192            PolicyDecision::Unspecified
193        } else {
194            PolicyDecision::NeedsReview
195        }
196    } else {
197        let allowed = parsed.evaluate(|req| {
198            let id = req_license_id(req);
199            !matches_any(&id, &config.deny) && matches_any(&id, &config.allow)
200        });
201        if allowed {
202            PolicyDecision::Allowed
203        } else {
204            PolicyDecision::NeedsReview
205        }
206    }
207}
208
209/// Evaluate all component licenses against a policy.
210#[must_use]
211pub fn evaluate_license_policy(
212    sbom: &NormalizedSbom,
213    config: &LicensePolicyConfig,
214) -> LicensePolicyResult {
215    let mut allowed_count = 0;
216    let mut denied_count = 0;
217    let mut review_count = 0;
218    let mut undeclared_count = 0;
219    let mut violations = Vec::new();
220
221    for comp in sbom.components.values() {
222        if comp.licenses.declared.is_empty() && comp.licenses.concluded.is_none() {
223            undeclared_count += 1;
224            violations.push(LicensePolicyViolation {
225                component: comp.name.clone(),
226                version: comp.version.clone(),
227                license: "(undeclared)".to_string(),
228                decision: PolicyDecision::Undeclared,
229                family: LicenseFamily::Other,
230            });
231            continue;
232        }
233
234        let mut component_denied = false;
235        let mut component_review = false;
236
237        for license in comp.licenses.all_licenses() {
238            let decision = evaluate_expression(license, config);
239            match decision {
240                PolicyDecision::Denied => {
241                    component_denied = true;
242                    violations.push(LicensePolicyViolation {
243                        component: comp.name.clone(),
244                        version: comp.version.clone(),
245                        license: license.expression.clone(),
246                        decision: PolicyDecision::Denied,
247                        family: license.family(),
248                    });
249                }
250                PolicyDecision::NeedsReview => {
251                    component_review = true;
252                    violations.push(LicensePolicyViolation {
253                        component: comp.name.clone(),
254                        version: comp.version.clone(),
255                        license: license.expression.clone(),
256                        decision: PolicyDecision::NeedsReview,
257                        family: license.family(),
258                    });
259                }
260                PolicyDecision::Allowed | PolicyDecision::Unspecified => {}
261                PolicyDecision::Undeclared => {}
262            }
263        }
264
265        // Copyleft/proprietary conflicts deny the component, counted at most once
266        if config.fail_on_conflict && comp.licenses.has_conflicts() {
267            component_denied = true;
268            let license_str = comp
269                .licenses
270                .all_licenses()
271                .iter()
272                .map(|l| l.expression.as_str())
273                .collect::<Vec<_>>()
274                .join(" + ");
275            violations.push(LicensePolicyViolation {
276                component: comp.name.clone(),
277                version: comp.version.clone(),
278                license: format!("CONFLICT: {license_str}"),
279                decision: PolicyDecision::Denied,
280                family: LicenseFamily::Other,
281            });
282        }
283
284        if component_denied {
285            denied_count += 1;
286        } else if component_review {
287            review_count += 1;
288        } else {
289            allowed_count += 1;
290        }
291    }
292
293    let passed = denied_count == 0;
294
295    LicensePolicyResult {
296        total_components: sbom.components.len(),
297        allowed_count,
298        denied_count,
299        review_count,
300        undeclared_count,
301        passed,
302        violations,
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::model::Component;
310
311    fn make_sbom_with_licenses(licenses: &[&str]) -> NormalizedSbom {
312        let mut sbom = NormalizedSbom::default();
313        for (i, lic) in licenses.iter().enumerate() {
314            let mut comp = Component::new(format!("comp-{i}"), format!("id-{i}"));
315            if !lic.is_empty() {
316                comp.licenses
317                    .add_declared(LicenseExpression::new(lic.to_string()));
318            }
319            sbom.components.insert(comp.canonical_id.clone(), comp);
320        }
321        sbom
322    }
323
324    fn make_sbom_with_component(declared: &[&str], concluded: Option<&str>) -> NormalizedSbom {
325        let mut sbom = NormalizedSbom::default();
326        let mut comp = Component::new("comp-0".to_string(), "id-0".to_string());
327        for lic in declared {
328            comp.licenses
329                .add_declared(LicenseExpression::new((*lic).to_string()));
330        }
331        comp.licenses.concluded = concluded.map(|c| LicenseExpression::new(c.to_string()));
332        sbom.components.insert(comp.canonical_id.clone(), comp);
333        sbom
334    }
335
336    #[test]
337    fn permissive_policy_allows_all() {
338        let sbom = make_sbom_with_licenses(&["MIT", "Apache-2.0", "GPL-3.0-only"]);
339        let config = LicensePolicyConfig::permissive();
340        let result = evaluate_license_policy(&sbom, &config);
341        assert!(result.passed);
342        assert_eq!(result.denied_count, 0);
343    }
344
345    #[test]
346    fn strict_policy_denies_agpl() {
347        let sbom = make_sbom_with_licenses(&["MIT", "AGPL-3.0-only"]);
348        let config = LicensePolicyConfig::strict_permissive();
349        let result = evaluate_license_policy(&sbom, &config);
350        assert!(!result.passed);
351        assert_eq!(result.denied_count, 1);
352    }
353
354    #[test]
355    fn strict_policy_flags_gpl_for_review() {
356        let sbom = make_sbom_with_licenses(&["MIT", "GPL-3.0-only"]);
357        let config = LicensePolicyConfig::strict_permissive();
358        let result = evaluate_license_policy(&sbom, &config);
359        assert!(result.passed); // review doesn't fail
360        assert_eq!(result.review_count, 1);
361    }
362
363    #[test]
364    fn undeclared_licenses_flagged() {
365        let sbom = make_sbom_with_licenses(&["MIT", ""]);
366        let config = LicensePolicyConfig::strict_permissive();
367        let result = evaluate_license_policy(&sbom, &config);
368        assert_eq!(result.undeclared_count, 1);
369    }
370
371    #[test]
372    fn glob_pattern_matching() {
373        assert!(matches_pattern("BSD-2-Clause", "BSD-*"));
374        assert!(matches_pattern("AGPL-3.0-only", "AGPL-*"));
375        assert!(matches_pattern("agpl-3.0-only", "AGPL-*")); // case insensitive prefix
376        assert!(!matches_pattern("MIT", "BSD-*"));
377        assert!(matches_pattern("MIT", "MIT"));
378        assert!(matches_pattern("mit", "MIT")); // case insensitive
379    }
380
381    #[test]
382    fn conflict_fails_policy() {
383        let sbom = make_sbom_with_component(&["GPL-3.0-only", "Proprietary"], None);
384        let config = LicensePolicyConfig {
385            fail_on_conflict: true,
386            ..Default::default()
387        };
388        let result = evaluate_license_policy(&sbom, &config);
389        assert!(!result.passed);
390        assert_eq!(result.denied_count, 1);
391        assert!(result.violations.iter().any(|v| {
392            v.license.starts_with("CONFLICT:") && v.decision == PolicyDecision::Denied
393        }));
394    }
395
396    #[test]
397    fn fail_on_conflict_false_skips() {
398        let sbom = make_sbom_with_component(&["GPL-3.0-only", "Proprietary"], None);
399        let config = LicensePolicyConfig {
400            fail_on_conflict: false,
401            ..Default::default()
402        };
403        let result = evaluate_license_policy(&sbom, &config);
404        assert!(result.passed);
405        assert_eq!(result.denied_count, 0);
406    }
407
408    #[test]
409    fn concluded_only_license_evaluated() {
410        let sbom = make_sbom_with_component(&[], Some("AGPL-3.0-only"));
411        let config = LicensePolicyConfig::strict_permissive();
412        let result = evaluate_license_policy(&sbom, &config);
413        assert!(!result.passed);
414        assert_eq!(result.denied_count, 1);
415        assert_eq!(result.undeclared_count, 0);
416    }
417
418    #[test]
419    fn or_expression_denied_only_if_all_alternatives_denied() {
420        let config = LicensePolicyConfig {
421            deny: vec!["GPL-*".to_string()],
422            ..Default::default()
423        };
424
425        let choice = make_sbom_with_component(&["MIT OR GPL-3.0-only"], None);
426        let result = evaluate_license_policy(&choice, &config);
427        assert!(result.passed);
428        assert_eq!(result.denied_count, 0);
429
430        let no_choice = make_sbom_with_component(&["GPL-2.0-only OR GPL-3.0-only"], None);
431        let result = evaluate_license_policy(&no_choice, &config);
432        assert!(!result.passed);
433        assert_eq!(result.denied_count, 1);
434    }
435
436    #[test]
437    fn and_expression_denied_if_any_operand_denied() {
438        let config = LicensePolicyConfig {
439            deny: vec!["GPL-*".to_string()],
440            ..Default::default()
441        };
442        let sbom = make_sbom_with_component(&["MIT AND GPL-3.0-only"], None);
443        let result = evaluate_license_policy(&sbom, &config);
444        assert!(!result.passed);
445        assert_eq!(result.denied_count, 1);
446    }
447
448    #[test]
449    fn or_with_allow_list_chooses_allowed_branch() {
450        let config = LicensePolicyConfig {
451            allow: vec!["MIT".to_string()],
452            review: vec!["GPL-*".to_string()],
453            ..Default::default()
454        };
455        let sbom = make_sbom_with_component(&["MIT OR GPL-3.0-only"], None);
456        let result = evaluate_license_policy(&sbom, &config);
457        assert!(result.passed);
458        assert_eq!(result.allowed_count, 1);
459        assert_eq!(result.review_count, 0);
460    }
461
462    #[test]
463    fn with_exception_matches_base_id() {
464        let sbom = make_sbom_with_component(&["Apache-2.0 WITH LLVM-exception"], None);
465
466        let allow_config = LicensePolicyConfig {
467            allow: vec!["Apache-2.0".to_string()],
468            ..Default::default()
469        };
470        let result = evaluate_license_policy(&sbom, &allow_config);
471        assert_eq!(result.allowed_count, 1);
472
473        let deny_config = LicensePolicyConfig {
474            deny: vec!["Apache-2.0".to_string()],
475            ..Default::default()
476        };
477        let result = evaluate_license_policy(&sbom, &deny_config);
478        assert_eq!(result.denied_count, 1);
479    }
480
481    #[test]
482    fn non_spdx_falls_back_to_string_match() {
483        let config = LicensePolicyConfig {
484            deny: vec!["Commercial*".to_string()],
485            ..Default::default()
486        };
487        let sbom = make_sbom_with_component(&["Commercial EULA v2"], None);
488        let result = evaluate_license_policy(&sbom, &config);
489        assert!(!result.passed);
490        assert_eq!(result.denied_count, 1);
491    }
492
493    #[test]
494    fn allow_list_requires_match() {
495        let sbom = make_sbom_with_licenses(&["MIT", "Artistic-2.0"]);
496        let config = LicensePolicyConfig {
497            allow: vec!["MIT".to_string()],
498            ..Default::default()
499        };
500        let result = evaluate_license_policy(&sbom, &config);
501        assert_eq!(result.review_count, 1); // Artistic-2.0 not on allow list
502        assert_eq!(result.allowed_count, 1);
503    }
504}