1use std::collections::BTreeMap;
13
14use pcre2::bytes::Regex;
15
16use crate::constraint::{Constraint, Op};
17use crate::package::Package;
18use crate::platform::is_platform_package;
19use crate::policy_config::{
20 advisory_ignore_list_for_block, advisory_ignore_severity_for_block, flat_ignore_for_block,
21 IgnoreMap, PolicyConfig,
22};
23use crate::pool::{Pool, Repository, Request};
24use crate::repository::{AdvisoriesByName, Advisory, ComposerRepository, FilterEntry};
25
26#[derive(Debug, thiserror::Error)]
27#[error("{0}")]
28pub struct FilterError(pub String, pub crate::repository::RepoErrorKind);
29
30impl From<crate::repository::RepoError> for FilterError {
33 fn from(e: crate::repository::RepoError) -> FilterError {
34 let kind = e.1;
35 FilterError(e.0, kind)
36 }
37}
38
39fn package_names_regexp(names: &[String]) -> Option<Regex> {
41 if names.is_empty() {
42 return None;
43 }
44 let parts: Vec<String> = names
45 .iter()
46 .map(|n| crate::version::preg_quote(n).replace("\\*", ".*"))
47 .collect();
48 pcre2::bytes::RegexBuilder::new()
49 .caseless(true)
50 .build(&format!("^(?:{})\\z", parts.join("|")))
51 .ok()
52}
53
54fn matches_regex(re: &Option<Regex>, name: &str) -> bool {
55 re.as_ref()
56 .is_some_and(|r| r.is_match(name.as_bytes()).unwrap_or(false))
57}
58
59pub(crate) fn constraints_by_name(
63 packages: &[usize],
64 arena: &[Package],
65) -> Vec<(String, Constraint)> {
66 let mut by_name: Vec<(String, Vec<Constraint>)> = Vec::new();
67 for &idx in packages {
68 let p = &arena[idx];
69 if p.alias_of.is_some() && p.root_package_alias {
70 continue;
71 }
72 let c = Constraint::new(Op::Eq, &p.version);
73 match by_name.iter_mut().find(|(n, _)| *n == p.name) {
74 Some((_, list)) => {
75 if !list.iter().any(|existing| existing == &c) {
77 list.push(c);
78 }
79 }
80 None => by_name.push((p.name.clone(), vec![c])),
81 }
82 }
83 by_name
84 .into_iter()
85 .map(|(n, list)| (n, Constraint::create(list, false)))
86 .collect()
87}
88
89fn composer_repos(repositories: &[Repository]) -> Vec<&ComposerRepository> {
90 repositories
91 .iter()
92 .filter_map(|r| match r {
93 Repository::Composer(c) => Some(c.as_ref()),
94 _ => None,
95 })
96 .collect()
97}
98
99pub(crate) fn security_advisories_for_constraints(
103 repositories: &[Repository],
104 map: &[(String, Constraint)],
105 allow_partial: bool,
106 ignore_unreachable: bool,
107 unreachable: &mut Vec<String>,
108) -> Result<AdvisoriesByName, FilterError> {
109 let mut all: AdvisoriesByName = Vec::new();
110 for repo in composer_repos(repositories) {
111 let result = repo.has_security_advisories().and_then(|has| {
114 if has {
115 repo.get_security_advisories(map, allow_partial)
116 .map(|(_, a)| a)
117 } else {
118 Ok(Vec::new())
119 }
120 });
121 match result {
122 Ok(advisories) => {
123 for (name, list) in advisories {
124 match all.iter_mut().find(|(n, _)| *n == name) {
125 Some((_, existing)) => existing.extend(list),
126 None => all.push((name, list)),
127 }
128 }
129 }
130 Err(e) if e.is_transport() && ignore_unreachable => unreachable.push(e.0),
131 Err(e) => return Err(FilterError::from(e)),
132 }
133 }
134 Ok(all)
135}
136
137fn needs_complete_advisory_load(
140 advisories: &AdvisoriesByName,
141 ignore_list: &[(String, Option<String>)],
142) -> bool {
143 if advisories.is_empty() {
144 return false;
145 }
146 if advisories
147 .iter()
148 .all(|(_, list)| list.iter().all(|a| a.complete.is_some()))
149 {
150 return false;
151 }
152 ignore_list.iter().any(|(id, _)| !id.starts_with("PKSA-"))
153}
154
155fn process_advisories(
157 all: AdvisoriesByName,
158 ignore_list: &[(String, Option<String>)],
159 ignored_severities: &[(String, Option<String>)],
160) -> AdvisoriesByName {
161 if ignore_list.is_empty() && ignored_severities.is_empty() {
162 return all;
163 }
164 let ignored = |key: &str| ignore_list.iter().any(|(k, _)| k == key);
165 let mut out: AdvisoriesByName = Vec::new();
166 for (package, list) in all {
167 for advisory in list {
168 let mut active = true;
169 if ignored(&package) || ignored(&advisory.advisory_id) {
170 active = false;
171 }
172 if let Some(c) = &advisory.complete {
173 if c.severity
174 .as_ref()
175 .is_some_and(|s| ignored_severities.iter().any(|(k, _)| k == s))
176 {
177 active = false;
178 }
179 if c.cve.as_ref().is_some_and(|cve| ignored(cve)) {
180 active = false;
181 }
182 if c.source_remote_ids.iter().any(|id| ignored(id)) {
183 active = false;
184 }
185 }
186 if active {
187 match out.iter_mut().find(|(n, _)| *n == package) {
188 Some((_, v)) => v.push(advisory),
189 None => out.push((package.clone(), vec![advisory])),
190 }
191 }
192 }
193 }
194 out
195}
196
197fn is_abandoned(p: &Package) -> bool {
200 match p.raw.get("abandoned") {
201 Some(serde_json::Value::Bool(b)) => *b,
202 Some(serde_json::Value::String(s)) => !s.is_empty(),
203 _ => false,
204 }
205}
206
207pub fn security_advisory_filter(
210 pool: Pool,
211 arena: &[Package],
212 repositories: &[Repository],
213 request: &Request,
214 policy: &PolicyConfig,
215 warnings: &mut Vec<String>,
216) -> Result<Pool, FilterError> {
217 if !policy.advisories.block {
218 return Ok(pool);
219 }
220 let ignore_list = advisory_ignore_list_for_block(&policy.advisories);
221 let ignore_unreachable = policy.ignore_unreachable.update;
222 let candidates: Vec<usize> = pool
223 .packages
224 .iter()
225 .copied()
226 .filter(|&idx| {
227 let p = &arena[idx];
228 !matches!(p.origin, crate::package::Origin::Root)
229 && !is_platform_package(&p.name)
230 && !request.is_locked_package(idx)
231 })
232 .collect();
233 let map = constraints_by_name(&candidates, arena);
234 let mut unreachable = Vec::new();
235 let mut all = security_advisories_for_constraints(
236 repositories,
237 &map,
238 true,
239 ignore_unreachable,
240 &mut unreachable,
241 )?;
242 if needs_complete_advisory_load(&all, &ignore_list) {
243 unreachable.clear();
244 all = security_advisories_for_constraints(
245 repositories,
246 &map,
247 false,
248 ignore_unreachable,
249 &mut unreachable,
250 )?;
251 }
252 if ignore_unreachable && !unreachable.is_empty() {
253 warnings.push("Security advisory data could not be fetched from some repositories (ignored per policy.ignore-unreachable); matches may be incomplete:".into());
254 for r in &unreachable {
255 warnings.push(format!(" - {r}"));
256 }
257 }
258 let advisory_map = process_advisories(
259 all,
260 &ignore_list,
261 &advisory_ignore_severity_for_block(&policy.advisories),
262 );
263 let abandoned_ignore: Vec<String> = flat_ignore_for_block(&policy.abandoned.ignore)
264 .into_iter()
265 .map(|(n, _)| n)
266 .collect();
267 let abandoned_re = package_names_regexp(&abandoned_ignore);
268 let mut kept: Vec<usize> = Vec::with_capacity(pool.packages.len());
269 let mut security_removed: BTreeMap<String, Vec<(String, Vec<String>)>> = BTreeMap::new();
270 let mut abandoned_removed: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
271 for &idx in &pool.packages {
272 let p = &arena[idx];
273 if policy.abandoned.block && is_abandoned(p) && !matches_regex(&abandoned_re, &p.name) {
274 for name in p.names(false) {
275 abandoned_removed
276 .entry(name)
277 .or_default()
278 .insert(p.version.clone(), p.pretty_version.clone());
279 }
280 continue;
281 }
282 let matching = matching_advisories(p, &advisory_map);
283 if !matching.is_empty() {
284 let ids: Vec<String> = matching.iter().map(|a| a.advisory_id.clone()).collect();
285 for name in p.names(false) {
286 let list = security_removed.entry(name).or_default();
287 match list.iter_mut().find(|(v, _)| *v == p.version) {
288 Some(slot) => slot.1 = ids.clone(),
289 None => list.push((p.version.clone(), ids.clone())),
290 }
291 }
292 continue;
293 }
294 kept.push(idx);
295 }
296 if kept.len() == pool.packages.len() {
297 return Ok(pool);
298 }
299 let mut out = pool.with_packages(kept, arena);
300 out.security_removed = security_removed;
301 out.abandoned_removed = abandoned_removed;
302 Ok(out)
303}
304
305fn matching_advisories<'a>(p: &Package, advisory_map: &'a AdvisoriesByName) -> Vec<&'a Advisory> {
308 if p.is_dev() {
309 return Vec::new();
310 }
311 let constraint = Constraint::new(Op::Eq, &p.version);
312 let mut out = Vec::new();
313 for name in p.names(false) {
314 let Some((_, list)) = advisory_map.iter().find(|(n, _)| *n == name) else {
315 continue;
316 };
317 for a in list {
318 if a.affected_versions.matches(&constraint) {
319 out.push(a);
320 }
321 }
322 }
323 out
324}
325
326pub fn filter_list_filter(
331 pool: Pool,
332 arena: &[Package],
333 repositories: &[Repository],
334 request: &Request,
335 policy: &PolicyConfig,
336 block_scope: &str,
337 warnings: &mut Vec<String>,
338) -> Result<Pool, FilterError> {
339 if !policy.custom_lists.is_empty() {
343 for repo in composer_repos(repositories) {
344 if let Ok(lists) = repo.get_filter_lists() {
345 if let Some(l) = lists.iter().find(|l| policy.custom_lists.contains(l)) {
346 return Err(FilterError(
347 format!("custom policy list \"{l}\" is not supported by vivacity yet"),
348 crate::repository::RepoErrorKind::Data,
349 ));
350 }
351 }
352 }
353 }
354 let check_locked_against_install = block_scope == "update";
355 let configured: Vec<String> = if policy.malware_blocks(block_scope) {
356 vec!["malware".to_owned()]
357 } else {
358 Vec::new()
359 };
360 let install_lists: Vec<String> =
361 if check_locked_against_install && policy.malware_blocks("install") {
362 vec!["malware".to_owned()]
363 } else {
364 Vec::new()
365 };
366 let mut union: Vec<String> = configured.clone();
367 for l in &install_lists {
368 if !union.contains(l) {
369 union.push(l.clone());
370 }
371 }
372 if union.is_empty() {
373 return Ok(pool);
374 }
375 let mut ignore_unreachable = policy.ignore_unreachable.for_block_scope(block_scope);
376 if check_locked_against_install {
377 ignore_unreachable = ignore_unreachable && policy.ignore_unreachable.install;
378 }
379 let filterable: Vec<usize> = pool
380 .packages
381 .iter()
382 .copied()
383 .filter(|&idx| {
384 let p = &arena[idx];
385 !matches!(p.origin, crate::package::Origin::Root) && !is_platform_package(&p.name)
386 })
387 .collect();
388 let map = constraints_by_name(&filterable, arena);
389 let mut by_list: Vec<(String, Vec<FilterEntry>)> = Vec::new();
391 let mut unreachable = Vec::new();
392 for repo in composer_repos(repositories) {
393 let provider_lists = match repo.get_filter_lists() {
397 Ok(l) => l,
398 Err(e) if e.is_transport() && ignore_unreachable => {
399 unreachable.push(e.0);
400 continue;
401 }
402 Err(e) => return Err(FilterError::from(e)),
403 };
404 let relevant: Vec<String> = union
405 .iter()
406 .filter(|l| provider_lists.contains(l))
407 .cloned()
408 .collect();
409 if relevant.is_empty() {
410 continue;
411 }
412 match repo.get_filter(&map, &relevant) {
413 Ok(filter) => {
414 for (list, entries) in filter {
415 if !union.contains(&list) || !provider_lists.contains(&list) {
416 continue;
417 }
418 for entry in entries {
419 let Some((_, wanted)) = map.iter().find(|(n, _)| *n == entry.package_name)
420 else {
421 continue;
422 };
423 if !entry.constraint.matches(wanted) {
424 continue;
425 }
426 match by_list.iter_mut().find(|(l, _)| *l == list) {
427 Some((_, v)) => v.push(entry),
428 None => by_list.push((list.clone(), vec![entry])),
429 }
430 }
431 }
432 }
433 Err(e) if e.is_transport() && ignore_unreachable => unreachable.push(e.0),
434 Err(e) => return Err(FilterError::from(e)),
435 }
436 }
437 by_list.sort_by(|(a, _), (b, _)| a.cmp(b));
438 if std::env::var_os("VIVACITY_TRACE").is_some() {
439 eprintln!(
440 "trace: filter lists {union:?} → {} entries ({} names queried)",
441 by_list.iter().map(|(_, e)| e.len()).sum::<usize>(),
442 map.len()
443 );
444 }
445 if !unreachable.is_empty() {
446 warnings.push("Filter list data could not be fetched from some sources (ignored per policy.ignore-unreachable); matches may be incomplete:".into());
447 for r in &unreachable {
448 warnings.push(format!(" - {r}"));
449 }
450 }
451 let mut filter_map: BTreeMap<String, Vec<(String, Vec<FilterEntry>)>> = BTreeMap::new();
453 for (list, entries) in &by_list {
454 for e in entries {
455 let lists = filter_map.entry(e.package_name.clone()).or_default();
456 match lists.iter_mut().find(|(l, _)| l == list) {
457 Some((_, v)) => v.push(e.clone()),
458 None => lists.push((list.clone(), vec![e.clone()])),
459 }
460 }
461 }
462 if filter_map.is_empty() {
463 return Ok(pool);
464 }
465 let locked_versions: BTreeMap<String, Vec<String>> = if check_locked_against_install {
466 let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();
467 for idx in request.locked_repository.iter().flatten() {
468 let p = &arena[*idx];
469 m.entry(p.name.clone()).or_default().push(p.version.clone());
470 }
471 m
472 } else {
473 BTreeMap::new()
474 };
475 let mut kept: Vec<usize> = Vec::with_capacity(pool.packages.len());
476 let mut removed: crate::pool::FilterListRemoved = pool.filter_list_removed.clone();
477 for &idx in &pool.packages {
478 let p = &arena[idx];
479 if matches!(p.origin, crate::package::Origin::Root) || is_platform_package(&p.name) {
480 kept.push(idx);
481 continue;
482 }
483 let locked_equivalent = check_locked_against_install
484 && (request.is_locked_package(idx)
485 || locked_versions
486 .get(&p.name)
487 .is_some_and(|v| v.contains(&p.version)));
488 let (lists, scope) = if locked_equivalent {
489 (&install_lists, "install")
490 } else {
491 (&configured, block_scope)
492 };
493 let matching = matching_entries(p, &filter_map, lists, policy, scope);
494 if matching.is_empty() {
495 kept.push(idx);
496 continue;
497 }
498 for name in p.names(false) {
499 let versions = removed.entry(name).or_default();
500 match versions.iter_mut().find(|(v, _)| *v == p.version) {
501 Some((_, e)) => *e = matching.clone(),
502 None => versions.push((p.version.clone(), matching.clone())),
503 }
504 }
505 }
506 let mut out = pool.with_packages(kept, arena);
507 out.filter_list_removed = removed;
508 Ok(out)
509}
510
511fn matching_entries(
514 p: &Package,
515 filter_map: &BTreeMap<String, Vec<(String, Vec<FilterEntry>)>>,
516 active_lists: &[String],
517 policy: &PolicyConfig,
518 _scope: &str,
519) -> Vec<FilterEntry> {
520 if filter_map.is_empty() || active_lists.is_empty() {
521 return Vec::new();
522 }
523 let malware_active = active_lists.iter().any(|l| l == "malware");
524 let ignore_source = &policy.malware.ignore_source;
525 let ignore_map: &IgnoreMap = &policy.malware.ignore;
526 let ignored_names: Vec<String> = flat_ignore_for_block(ignore_map)
527 .into_iter()
528 .map(|(n, _)| n)
529 .collect();
530 let ignored_re = package_names_regexp(&ignored_names);
531 let constraint = Constraint::new(Op::Eq, &p.version);
532 let mut out = Vec::new();
533 for name in p.names(false) {
534 let Some(lists) = filter_map.get(&name) else {
535 continue;
536 };
537 for (list, entries) in lists {
538 if !active_lists.contains(list) {
539 continue;
540 }
541 let entries: Vec<&FilterEntry> = entries
543 .iter()
544 .filter(|e| {
545 !(list == "malware"
546 && malware_active
547 && e.source.as_ref().is_some_and(|s| ignore_source.contains(s)))
548 })
549 .collect();
550 if matches_regex(&ignored_re, &name) && list == "malware" {
551 let ignored = ignore_map.iter().any(|(_, rules)| {
554 rules.iter().any(|r| {
555 r.on_block
556 && matches_regex(
557 &package_names_regexp(std::slice::from_ref(&r.package_name)),
558 &name,
559 )
560 && r.constraint.matches(&constraint)
561 })
562 });
563 if ignored {
564 continue;
565 }
566 }
567 for e in entries {
568 if e.constraint.matches(&constraint) {
569 out.push(e.clone());
570 }
571 }
572 }
573 }
574 out
575}
576
577pub fn locked_removed_problem_text(pool: &Pool, p: &Package) -> String {
581 let mut lists: Vec<(String, Vec<String>)> = Vec::new();
582 if let Some(versions) = pool.filter_list_removed.get(&p.name) {
583 for (v, entries) in versions {
584 if *v != p.version {
585 continue;
586 }
587 for e in entries {
588 let source = e
589 .source
590 .as_ref()
591 .filter(|s| !s.is_empty())
592 .map(|s| format!(" reported by {s}"))
593 .unwrap_or_default();
594 let url = e
595 .url
596 .as_ref()
597 .filter(|s| !s.is_empty())
598 .map(|s| format!(" (see {s})"))
599 .unwrap_or_default();
600 let reason = e
601 .reason
602 .as_ref()
603 .filter(|s| !s.is_empty())
604 .map(|s| format!(" reason: {s}"))
605 .unwrap_or_default();
606 let text = format!("{source}{url}{reason}");
607 match lists.iter_mut().find(|(l, _)| *l == e.list_name) {
608 Some((_, v)) => v.push(text),
609 None => lists.push((e.list_name.clone(), vec![text])),
610 }
611 }
612 }
613 }
614 let filters: Vec<String> = lists
615 .iter()
616 .map(|(l, entries)| {
617 let action = if l == "malware" {
618 "flagged as "
619 } else {
620 "filtered by "
621 };
622 format!("{action}{l}{}", entries.join(", "))
623 })
624 .collect();
625 let ignore_paths: Vec<String> = lists
626 .iter()
627 .map(|(l, _)| format!("\"policy.{l}.ignore\""))
628 .collect();
629 let off_paths: Vec<String> = lists
630 .iter()
631 .map(|(l, _)| format!("\"policy.{l}.block\""))
632 .collect();
633 format!(
634 "- Package {} {} (in the lock file) was not loaded, because it was {}. To ignore filters for this package, add the package to the {} config. To turn the feature off entirely, you can set {} to false.",
635 p.name,
636 p.pretty_version,
637 filters.join(", "),
638 ignore_paths.join(" and "),
639 off_paths.join(" and ")
640 )
641}