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