1use crate::errors::RustinelError;
2use crate::risk::ProjectRisk;
3use crate::signals::RiskSignal;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9pub struct Policy {
10 pub version: Option<u32>,
11 pub profile: Option<PolicyProfile>,
12 pub risk: Option<RiskPolicy>,
13 pub advisories: Option<AdvisoriesPolicy>,
14 pub signals: Option<SignalsPolicy>,
15 pub licenses: Option<LicensesPolicy>,
16 pub allow: Option<ListPolicy>,
17 pub deny: Option<ListPolicy>,
18}
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21pub struct PolicyProfile {
22 pub name: String,
23}
24
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26pub struct RiskPolicy {
27 pub max_project_score: Option<u8>,
28 pub max_package_score: Option<u8>,
29 pub fail_on_delta_above: Option<i32>,
30 pub warn_on_delta_above: Option<i32>,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct AdvisoriesPolicy {
35 #[serde(default)]
36 pub fail_on: Vec<String>,
37 #[serde(default)]
38 pub warn_on: Vec<String>,
39 #[serde(default)]
40 pub ignore: Vec<String>,
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct SignalsPolicy {
45 pub fail_on_yanked: Option<bool>,
46 pub warn_on_build_rs: Option<bool>,
47 pub require_review_on_build_rs: Option<bool>,
48 pub require_review_on_native_ffi: Option<bool>,
49 pub fail_on_denied_license: Option<bool>,
50 pub warn_on_unknown_license: Option<bool>,
51 pub fail_on_unknown_license: Option<bool>,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct LicensesPolicy {
56 #[serde(default)]
57 pub allow: Vec<String>,
58 #[serde(default)]
59 pub deny: Vec<String>,
60}
61
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63pub struct ListPolicy {
64 #[serde(default)]
65 pub crates: Vec<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct PolicyDecision {
70 pub decision: Decision,
71 pub profile: String,
72 pub violations: Vec<String>,
73 pub warnings: Vec<String>,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub review_items: Vec<String>,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub ignored_advisories: Vec<String>,
80}
81
82#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
83#[serde(rename_all = "snake_case")]
84pub enum Decision {
85 Pass,
86 Warn,
87 Fail,
88 ReviewRequired,
89}
90
91impl Decision {
92 pub fn as_str(&self) -> &'static str {
93 match self {
94 Decision::Pass => "pass",
95 Decision::Warn => "warn",
96 Decision::Fail => "fail",
97 Decision::ReviewRequired => "review_required",
98 }
99 }
100}
101
102struct Effective {
105 profile: String,
106 max_project_score: u8,
107 max_package_score: u8,
108 fail_on_delta_above: i32,
109 warn_on_delta_above: i32,
110 adv_fail_on: Vec<String>,
111 adv_warn_on: Vec<String>,
112 adv_ignore: Vec<String>,
113 warn_on_build_rs: bool,
114 require_review_on_build_rs: bool,
115 require_review_on_native_ffi: bool,
116 fail_on_yanked: bool,
117 fail_on_denied_license: bool,
118 warn_on_unknown_license: bool,
119 fail_on_unknown_license: bool,
120 license_allow: Vec<String>,
121 license_deny: Vec<String>,
122 allow_crates: Vec<String>,
123}
124
125impl Effective {
126 fn from(policy: Option<&Policy>) -> Self {
127 let profile_name = policy
128 .and_then(|p| p.profile.as_ref())
129 .map(|p| p.name.clone())
130 .unwrap_or_else(|| "balanced".into());
131 let mut eff = Self::defaults_for(&profile_name);
132
133 let Some(policy) = policy else { return eff };
134
135 if let Some(r) = &policy.risk {
136 if let Some(v) = r.max_project_score {
137 eff.max_project_score = v;
138 }
139 if let Some(v) = r.max_package_score {
140 eff.max_package_score = v;
141 }
142 if let Some(v) = r.fail_on_delta_above {
143 eff.fail_on_delta_above = v;
144 }
145 if let Some(v) = r.warn_on_delta_above {
146 eff.warn_on_delta_above = v;
147 }
148 }
149 if let Some(a) = &policy.advisories {
150 if !a.fail_on.is_empty() {
151 eff.adv_fail_on = a.fail_on.clone();
152 }
153 if !a.warn_on.is_empty() {
154 eff.adv_warn_on = a.warn_on.clone();
155 }
156 eff.adv_ignore = a.ignore.clone();
157 }
158 if let Some(s) = &policy.signals {
159 if let Some(v) = s.warn_on_build_rs {
160 eff.warn_on_build_rs = v;
161 }
162 if let Some(v) = s.require_review_on_build_rs {
163 eff.require_review_on_build_rs = v;
164 }
165 if let Some(v) = s.require_review_on_native_ffi {
166 eff.require_review_on_native_ffi = v;
167 }
168 if let Some(v) = s.fail_on_yanked {
169 eff.fail_on_yanked = v;
170 }
171 if let Some(v) = s.fail_on_denied_license {
172 eff.fail_on_denied_license = v;
173 }
174 if let Some(v) = s.warn_on_unknown_license {
175 eff.warn_on_unknown_license = v;
176 }
177 if let Some(v) = s.fail_on_unknown_license {
178 eff.fail_on_unknown_license = v;
179 }
180 }
181 if let Some(l) = &policy.licenses {
182 if !l.allow.is_empty() {
185 eff.license_allow = l.allow.clone();
186 }
187 if !l.deny.is_empty() {
188 eff.license_deny = l.deny.clone();
189 }
190 }
191 if let Some(a) = &policy.allow {
192 eff.allow_crates = a.crates.clone();
193 }
194 eff
195 }
196
197 fn defaults_for(profile: &str) -> Self {
198 let base = Self {
200 profile: profile.to_string(),
201 max_project_score: 70,
202 max_package_score: 85,
203 fail_on_delta_above: 35,
204 warn_on_delta_above: 10,
205 adv_fail_on: vec!["critical".into(), "high".into()],
206 adv_warn_on: vec!["medium".into(), "low".into()],
207 adv_ignore: vec![],
208 warn_on_build_rs: false,
211 require_review_on_build_rs: false,
212 require_review_on_native_ffi: true,
213 fail_on_yanked: true,
214 fail_on_denied_license: true,
215 warn_on_unknown_license: true,
216 fail_on_unknown_license: false,
217 license_allow: vec![],
218 license_deny: vec!["GPL-3.0".into(), "AGPL-3.0".into()],
219 allow_crates: vec![],
220 };
221 match profile {
222 "strict" => Self {
223 max_project_score: 50,
224 max_package_score: 70,
225 fail_on_delta_above: 20,
226 warn_on_delta_above: 5,
227 adv_fail_on: vec!["critical".into(), "high".into(), "medium".into()],
228 adv_warn_on: vec!["low".into()],
229 require_review_on_build_rs: true,
230 warn_on_unknown_license: false,
231 fail_on_unknown_license: true,
232 ..base
233 },
234 "permissive" => Self {
235 max_project_score: 90,
236 max_package_score: 95,
237 fail_on_delta_above: 60,
238 warn_on_delta_above: 25,
239 adv_fail_on: vec!["critical".into()],
240 adv_warn_on: vec!["high".into(), "medium".into(), "low".into()],
241 require_review_on_native_ffi: false,
242 fail_on_yanked: false,
243 license_deny: vec!["AGPL-3.0".into()],
244 ..base
245 },
246 _ => base, }
248 }
249}
250
251pub fn evaluate(
254 risk: &ProjectRisk,
255 signals: &[RiskSignal],
256 delta: Option<i32>,
257 policy: Option<&Policy>,
258) -> Result<PolicyDecision, RustinelError> {
259 let eff = Effective::from(policy);
260
261 let mut violations = Vec::new();
262 let mut warnings = Vec::new();
263 let mut review_items = Vec::new();
264 let mut ignored_advisories: Vec<String> = Vec::new();
265
266 if risk.score > eff.max_project_score {
268 violations.push(format!(
269 "project risk score {} exceeds threshold {}",
270 risk.score, eff.max_project_score
271 ));
272 }
273 if risk.max_package_score > eff.max_package_score {
274 if let Some(top) = risk.packages.iter().max_by_key(|p| p.score) {
275 violations.push(format!(
276 "package `{}` score {} exceeds per-package threshold {}",
277 top.package, top.score, eff.max_package_score
278 ));
279 }
280 }
281
282 if let Some(delta) = delta {
284 if delta > eff.fail_on_delta_above {
285 violations.push(format!(
286 "risk score increased by {delta}, above fail threshold {}",
287 eff.fail_on_delta_above
288 ));
289 } else if delta > eff.warn_on_delta_above {
290 warnings.push(format!(
291 "risk score increased by {delta}, above warn threshold {}",
292 eff.warn_on_delta_above
293 ));
294 }
295 }
296
297 for signal in signals {
299 let crate_name = crate_name_of(&signal.package);
300 let allowlisted = eff.allow_crates.iter().any(|c| c == crate_name);
301
302 if signal.id.starts_with("advisory_") {
303 let advisory_id = signal.id.trim_start_matches("advisory_");
304 if eff
305 .adv_ignore
306 .iter()
307 .any(|i| i.eq_ignore_ascii_case(advisory_id))
308 {
309 warnings.push(format!(
310 "advisory {advisory_id} for `{}` is ignored by policy",
311 signal.package
312 ));
313 ignored_advisories.push(advisory_id.to_string());
314 continue;
315 }
316 let sev = signal.severity.as_str().to_string();
317 if eff.adv_fail_on.iter().any(|s| s.eq_ignore_ascii_case(&sev)) && !allowlisted {
318 violations.push(format!("{} ({}) on `{}`", advisory_id, sev, signal.package));
319 } else if eff.adv_warn_on.iter().any(|s| s.eq_ignore_ascii_case(&sev)) || allowlisted {
320 warnings.push(format!("{} ({}) on `{}`", advisory_id, sev, signal.package));
321 }
322 continue;
323 }
324
325 let is_baseline_info =
329 signal.severity == crate::signals::Severity::Info && signal.id != "license_detected";
330 if is_baseline_info {
331 continue;
332 }
333
334 match signal.id.as_str() {
335 "suspicious_source_exfil" => {
338 if eff.profile == "strict" && !allowlisted {
339 violations.push(format!(
340 "`{}` source matches a secret-exfiltration malware pattern",
341 signal.package
342 ));
343 } else {
344 review_items.push(format!(
345 "`{}` source matches a secret-exfiltration malware pattern",
346 signal.package
347 ));
348 }
349 }
350 "build_script_suspicious" => {
353 if eff.profile == "strict" && !allowlisted {
354 violations.push(format!(
355 "`{}` has a suspicious build script (network/payload)",
356 signal.package
357 ));
358 } else {
359 review_items.push(format!(
360 "`{}` has a suspicious build script (network/payload)",
361 signal.package
362 ));
363 }
364 }
365 "possible_typosquat" => {
367 if eff.profile == "strict" && !allowlisted {
368 violations.push(format!(
369 "`{}` looks like a typosquat of a popular crate",
370 signal.package
371 ));
372 } else {
373 review_items.push(format!(
374 "`{}` looks like a typosquat of a popular crate",
375 signal.package
376 ));
377 }
378 }
379 "build_script_present" => {
380 if eff.require_review_on_build_rs && !allowlisted {
381 review_items.push(format!("`{}` ships a build script", signal.package));
382 } else if eff.warn_on_build_rs {
383 warnings.push(format!("`{}` ships a build script", signal.package));
384 }
385 }
386 "native_ffi_detected" => {
387 if allowlisted {
388 } else if eff.require_review_on_native_ffi {
390 review_items.push(format!("`{}` is a native/FFI dependency", signal.package));
391 } else {
392 warnings.push(format!("`{}` is a native/FFI dependency", signal.package));
393 }
394 }
395 "license_unknown" => {
396 if eff.fail_on_unknown_license && !allowlisted {
397 violations.push(format!("`{}` has an unknown license", signal.package));
398 } else if eff.warn_on_unknown_license {
399 warnings.push(format!("`{}` has an unknown license", signal.package));
400 }
401 }
402 "license_detected" => {
403 if let Some(license) = license_from_signal(signal) {
404 match license_verdict(&license, &eff.license_allow, &eff.license_deny) {
405 LicenseVerdict::Denied => {
406 if eff.fail_on_denied_license && !allowlisted {
407 violations.push(format!(
408 "`{}` uses denied license {license}",
409 signal.package
410 ));
411 } else {
412 warnings.push(format!(
413 "`{}` uses denied license {license}",
414 signal.package
415 ));
416 }
417 }
418 LicenseVerdict::NotAllowed => {
419 warnings.push(format!(
420 "`{}` license {license} is not on the allow list",
421 signal.package
422 ));
423 }
424 LicenseVerdict::Ok => {}
425 }
426 }
427 }
428 "yanked_crate" => {
429 if allowlisted {
430 } else if eff.fail_on_yanked {
432 violations.push(format!("`{}` is yanked", signal.package));
433 } else {
434 warnings.push(format!("`{}` is yanked", signal.package));
435 }
436 }
437 "denied_crate" => {
440 violations.push(format!(
441 "dependency `{}` is on the policy deny list",
442 crate_name_of(&signal.package)
443 ));
444 }
445 "env_gated_payload" => {
448 if eff.profile == "strict" && !allowlisted {
449 violations.push(format!(
450 "`{}` source gates a download-and-execute on an environment variable",
451 signal.package
452 ));
453 } else {
454 review_items.push(format!(
455 "`{}` source gates a download-and-execute on an environment variable",
456 signal.package
457 ));
458 }
459 }
460 "suspicious_exfil_domain" => {
463 if eff.profile == "strict" && !allowlisted {
464 violations.push(format!(
465 "`{}` source references a data-exfiltration domain",
466 signal.package
467 ));
468 } else {
469 review_items.push(format!(
470 "`{}` source references a data-exfiltration domain",
471 signal.package
472 ));
473 }
474 }
475 "owners_changed" => {
478 if eff.profile == "strict" && !allowlisted {
479 violations.push(format!(
480 "`{}` crates.io owners changed since the trust baseline",
481 signal.package
482 ));
483 } else {
484 review_items.push(format!(
485 "`{}` crates.io owners changed since the trust baseline",
486 signal.package
487 ));
488 }
489 }
490 "source_substitution" => {
493 if eff.profile == "strict" && !allowlisted {
494 violations.push(format!(
495 "`{}` resolves from a non-crates.io source (possible dependency confusion)",
496 signal.package
497 ));
498 } else {
499 review_items.push(format!(
500 "`{}` resolves from a non-crates.io source (possible dependency confusion)",
501 signal.package
502 ));
503 }
504 }
505 "freshly_published" => {
508 if eff.profile == "strict" && !allowlisted {
509 review_items.push(format!(
510 "`{}` was published very recently (little time for review)",
511 signal.package
512 ));
513 } else {
514 warnings.push(format!(
515 "`{}` was published very recently (little time for review)",
516 signal.package
517 ));
518 }
519 }
520 _ => {}
521 }
522 }
523
524 dedup(&mut violations);
525 dedup(&mut warnings);
526 dedup(&mut review_items);
527 dedup(&mut ignored_advisories);
528
529 let decision = if !violations.is_empty() {
530 Decision::Fail
531 } else if !review_items.is_empty() {
532 Decision::ReviewRequired
533 } else if !warnings.is_empty() {
534 Decision::Warn
535 } else {
536 Decision::Pass
537 };
538
539 Ok(PolicyDecision {
540 decision,
541 profile: eff.profile,
542 violations,
543 warnings,
544 review_items,
545 ignored_advisories,
546 })
547}
548
549fn dedup(items: &mut Vec<String>) {
550 let mut seen = std::collections::BTreeSet::new();
551 items.retain(|i| seen.insert(i.clone()));
552}
553
554fn crate_name_of(package: &str) -> &str {
555 package.split('@').next().unwrap_or(package)
556}
557
558const LICENSE_PREFIX: &str = "declared license: ";
559
560fn license_from_signal(signal: &RiskSignal) -> Option<String> {
561 signal.evidence.iter().find_map(|e| {
562 e.summary
563 .strip_prefix(LICENSE_PREFIX)
564 .map(|s| s.to_string())
565 })
566}
567
568pub fn license_summary(license: &str) -> String {
571 format!("{LICENSE_PREFIX}{license}")
572}
573
574#[derive(Debug, PartialEq, Eq)]
575enum LicenseVerdict {
576 Ok,
577 NotAllowed,
578 Denied,
579}
580
581fn license_verdict(expr: &str, allow: &[String], deny: &[String]) -> LicenseVerdict {
585 let contains = |list: &[String], lic: &str| {
589 let fam = license_family(lic);
590 list.iter()
591 .any(|x| license_family(x).eq_ignore_ascii_case(fam))
592 };
593
594 if !deny.is_empty() && !satisfiable(expr, &|lic| !contains(deny, lic)) {
596 return LicenseVerdict::Denied;
597 }
598 if !allow.is_empty() && !satisfiable(expr, &|lic| contains(allow, lic)) {
601 return LicenseVerdict::NotAllowed;
602 }
603 LicenseVerdict::Ok
604}
605
606fn license_family(id: &str) -> &str {
610 let id = id.strip_suffix('+').unwrap_or(id);
611 let id = id.strip_suffix("-or-later").unwrap_or(id);
612 id.strip_suffix("-only").unwrap_or(id)
613}
614
615pub(crate) fn satisfiable(expr: &str, pred: &dyn Fn(&str) -> bool) -> bool {
618 let toks = tokenize_spdx(expr);
619 if toks.is_empty() {
620 return true; }
622 let mut p = SpdxParser {
623 toks: &toks,
624 pos: 0,
625 };
626 match p.parse_expr(pred) {
627 Some(v) if p.pos == p.toks.len() => v,
628 _ => toks.iter().any(|t| matches!(t, SpdxTok::Lic(l) if pred(l))),
630 }
631}
632
633#[derive(Debug, PartialEq, Eq)]
634enum SpdxTok {
635 LParen,
636 RParen,
637 And,
638 Or,
639 With,
640 Lic(String),
641}
642
643fn tokenize_spdx(expr: &str) -> Vec<SpdxTok> {
644 let mut toks = Vec::new();
645 let mut word = String::new();
646 let flush = |word: &mut String, toks: &mut Vec<SpdxTok>| {
647 if !word.is_empty() {
648 match word.as_str() {
649 "OR" => toks.push(SpdxTok::Or),
650 "AND" => toks.push(SpdxTok::And),
651 "WITH" => toks.push(SpdxTok::With),
652 _ => toks.push(SpdxTok::Lic(std::mem::take(word))),
653 }
654 word.clear();
655 }
656 };
657 for ch in expr.chars() {
658 match ch {
659 '(' => {
660 flush(&mut word, &mut toks);
661 toks.push(SpdxTok::LParen);
662 }
663 ')' => {
664 flush(&mut word, &mut toks);
665 toks.push(SpdxTok::RParen);
666 }
667 c if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '+' || c == '_' => {
668 word.push(c);
669 }
670 _ => flush(&mut word, &mut toks),
671 }
672 }
673 flush(&mut word, &mut toks);
674 toks
675}
676
677struct SpdxParser<'a> {
678 toks: &'a [SpdxTok],
679 pos: usize,
680}
681
682impl<'a> SpdxParser<'a> {
683 fn peek(&self) -> Option<&SpdxTok> {
684 self.toks.get(self.pos)
685 }
686
687 fn parse_expr(&mut self, pred: &dyn Fn(&str) -> bool) -> Option<bool> {
689 let mut acc = self.parse_term(pred)?;
690 while matches!(self.peek(), Some(SpdxTok::Or)) {
691 self.pos += 1;
692 let rhs = self.parse_term(pred)?;
693 acc = acc || rhs;
694 }
695 Some(acc)
696 }
697
698 fn parse_term(&mut self, pred: &dyn Fn(&str) -> bool) -> Option<bool> {
700 let mut acc = self.parse_factor(pred)?;
701 while matches!(self.peek(), Some(SpdxTok::And)) {
702 self.pos += 1;
703 let rhs = self.parse_factor(pred)?;
704 acc = acc && rhs;
705 }
706 Some(acc)
707 }
708
709 fn parse_factor(&mut self, pred: &dyn Fn(&str) -> bool) -> Option<bool> {
711 match self.peek() {
712 Some(SpdxTok::LParen) => {
713 self.pos += 1;
714 let v = self.parse_expr(pred)?;
715 if matches!(self.peek(), Some(SpdxTok::RParen)) {
716 self.pos += 1;
717 Some(v)
718 } else {
719 None
720 }
721 }
722 Some(SpdxTok::Lic(name)) => {
723 let v = pred(name);
724 self.pos += 1;
725 if matches!(self.peek(), Some(SpdxTok::With)) {
727 self.pos += 1;
728 if matches!(self.peek(), Some(SpdxTok::Lic(_))) {
729 self.pos += 1;
730 }
731 }
732 Some(v)
733 }
734 _ => None,
735 }
736 }
737}
738
739pub fn parse_policy_toml(input: &str) -> Result<Policy, RustinelError> {
740 toml::from_str(input).map_err(|e| RustinelError::InvalidPolicy(e.to_string()))
741}
742
743#[cfg(test)]
744mod tests {
745 use super::*;
746 use crate::risk::{PackageRisk, RiskLevel};
747 use crate::signals::{Evidence, Severity};
748
749 fn risk(score: u8, max_pkg: u8) -> ProjectRisk {
750 ProjectRisk {
751 score,
752 level: crate::risk::level_for_score(score),
753 max_package_score: max_pkg,
754 packages: vec![PackageRisk {
755 package: "x@1".into(),
756 score: max_pkg,
757 level: RiskLevel::Low,
758 }],
759 }
760 }
761
762 #[test]
763 fn fails_above_project_threshold() {
764 let policy = parse_policy_toml("[risk]\nmax_project_score = 10\n").unwrap();
765 let d = evaluate(&risk(40, 0), &[], None, Some(&policy)).unwrap();
766 assert_eq!(d.decision, Decision::Fail);
767 assert!(!d.violations.is_empty());
768 }
769
770 #[test]
771 fn passes_below_threshold() {
772 let policy = parse_policy_toml("[risk]\nmax_project_score = 90\n").unwrap();
773 let d = evaluate(&risk(40, 50), &[], None, Some(&policy)).unwrap();
774 assert_eq!(d.decision, Decision::Pass);
775 }
776
777 #[test]
778 fn native_ffi_requires_review_under_balanced() {
779 let sig = RiskSignal {
780 id: "native_ffi_detected".into(),
781 package: "openssl-sys@0.9.99".into(),
782 severity: Severity::High,
783 weight: 20,
784 confidence: 0.9,
785 evidence: vec![],
786 recommendation: String::new(),
787 };
788 let d = evaluate(&risk(20, 20), std::slice::from_ref(&sig), None, None).unwrap();
789 assert_eq!(d.decision, Decision::ReviewRequired);
790 }
791
792 #[test]
793 fn allowlisted_native_ffi_is_silent() {
794 let sig = RiskSignal {
797 id: "native_ffi_detected".into(),
798 package: "openssl-sys@0.9.99".into(),
799 severity: Severity::High,
800 weight: 20,
801 confidence: 0.9,
802 evidence: vec![],
803 recommendation: String::new(),
804 };
805 let policy = parse_policy_toml("[allow]\ncrates = [\"openssl-sys\"]\n").unwrap();
806 let d = evaluate(
807 &risk(20, 20),
808 std::slice::from_ref(&sig),
809 None,
810 Some(&policy),
811 )
812 .unwrap();
813 assert_eq!(d.decision, Decision::Pass);
814 assert!(d.warnings.is_empty() && d.review_items.is_empty());
815 }
816
817 #[test]
818 fn denied_crate_signal_fails_and_allowlist_does_not_override() {
819 let sig = RiskSignal {
820 id: "denied_crate".into(),
821 package: "foo@1.0.0".into(),
822 severity: Severity::High,
823 weight: 0,
824 confidence: 1.0,
825 evidence: vec![],
826 recommendation: String::new(),
827 };
828 let policy = parse_policy_toml("[allow]\ncrates = [\"foo\"]\n").unwrap();
830 let d = evaluate(&risk(0, 0), std::slice::from_ref(&sig), None, Some(&policy)).unwrap();
831 assert_eq!(d.decision, Decision::Fail);
832 assert!(d.violations.iter().any(|v| v.contains("deny list")));
833 }
834
835 #[test]
836 fn advisory_high_fails_balanced() {
837 let sig = RiskSignal {
838 id: "advisory_RUSTSEC-2099-0001".into(),
839 package: "vuln@1.0.0".into(),
840 severity: Severity::High,
841 weight: 30,
842 confidence: 1.0,
843 evidence: vec![],
844 recommendation: String::new(),
845 };
846 let d = evaluate(&risk(30, 30), std::slice::from_ref(&sig), None, None).unwrap();
847 assert_eq!(d.decision, Decision::Fail);
848 }
849
850 #[test]
851 fn ignored_advisory_downgrades_to_warning() {
852 let policy = parse_policy_toml("[advisories]\nignore = [\"RUSTSEC-2099-0001\"]\n").unwrap();
853 let sig = RiskSignal {
854 id: "advisory_RUSTSEC-2099-0001".into(),
855 package: "vuln@1.0.0".into(),
856 severity: Severity::High,
857 weight: 30,
858 confidence: 1.0,
859 evidence: vec![],
860 recommendation: String::new(),
861 };
862 let d = evaluate(
863 &risk(30, 30),
864 std::slice::from_ref(&sig),
865 None,
866 Some(&policy),
867 )
868 .unwrap();
869 assert_eq!(d.decision, Decision::Warn);
870 }
871
872 #[test]
873 fn denied_license_fails() {
874 let sig = RiskSignal {
875 id: "license_detected".into(),
876 package: "gpl-crate@1.0.0".into(),
877 severity: Severity::Info,
878 weight: 0,
879 confidence: 1.0,
880 evidence: vec![Evidence::new("manifest", license_summary("GPL-3.0"))],
881 recommendation: String::new(),
882 };
883 let d = evaluate(&risk(0, 0), std::slice::from_ref(&sig), None, None).unwrap();
884 assert_eq!(d.decision, Decision::Fail);
885 }
886
887 #[test]
888 fn spdx_expression_semantics() {
889 let deny = vec!["GPL-3.0".to_string(), "AGPL-3.0".to_string()];
890 let allow = vec![
891 "MIT".to_string(),
892 "Apache-2.0".to_string(),
893 "BSD-3-Clause".to_string(),
894 ];
895 assert_eq!(
897 license_verdict("MIT OR GPL-3.0", &[], &deny),
898 LicenseVerdict::Ok
899 );
900 assert_eq!(
902 license_verdict("GPL-3.0", &[], &deny),
903 LicenseVerdict::Denied
904 );
905 assert_eq!(
907 license_verdict("MIT AND GPL-3.0", &[], &deny),
908 LicenseVerdict::Denied
909 );
910 assert_eq!(
912 license_verdict("MIT OR Apache-2.0", &allow, &deny),
913 LicenseVerdict::Ok
914 );
915 assert_eq!(
917 license_verdict("WTFPL", &allow, &deny),
918 LicenseVerdict::NotAllowed
919 );
920 assert_eq!(
922 license_verdict("(MIT OR Apache-2.0) AND BSD-3-Clause", &allow, &deny),
923 LicenseVerdict::Ok
924 );
925 assert_eq!(
926 license_verdict("Apache-2.0 WITH LLVM-exception", &allow, &deny),
927 LicenseVerdict::Ok
928 );
929 }
930
931 #[test]
932 fn allowlisted_crate_downgrades_advisory() {
933 let policy = parse_policy_toml("[allow]\ncrates = [\"vuln\"]\n").unwrap();
934 let sig = RiskSignal {
935 id: "advisory_RUSTSEC-2099-0001".into(),
936 package: "vuln@1.0.0".into(),
937 severity: Severity::High,
938 weight: 30,
939 confidence: 1.0,
940 evidence: vec![],
941 recommendation: String::new(),
942 };
943 let d = evaluate(
944 &risk(30, 30),
945 std::slice::from_ref(&sig),
946 None,
947 Some(&policy),
948 )
949 .unwrap();
950 assert_eq!(d.decision, Decision::Warn);
951 }
952
953 #[test]
954 fn delta_fail_threshold() {
955 let policy = parse_policy_toml("[risk]\nfail_on_delta_above = 10\n").unwrap();
956 let d = evaluate(&risk(0, 0), &[], Some(45), Some(&policy)).unwrap();
957 assert_eq!(d.decision, Decision::Fail);
958 }
959}