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