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