feat(audit): add audit.ignorePrune to remove stale ignored GHSAs (#11384)
Add audit.ignorePrune (default false). When enabled, `pnpm audit --fix` diffs the ignored-GHSA list against the current audit report and drops entries whose GHSA id no longer appears in it, persisting the retained list back to pnpm-workspace.yaml. GHSA ids are compared after normalization, and retained entries are rewritten in canonical spelling and deduplicated. Implemented in both stacks: the TypeScript CLI reads the canonical audit.ignorePrune setting into the reader-derived auditIgnorePrune config field, and pacquet mirrors it with Config::audit_ignore_prune plus a prune_ignored_ghsas port of the same split, wired into the --fix path ahead of the existing advisory filter. Closes pnpm/pnpm#11237 --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
1 parent
e630a0492a
commit
fb0f338cdf
29 files changed
+4739
-43
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"@pnpm/deps.compliance.commands": minor
|
||||
"@pnpm/config.reader": minor
|
||||
"@pnpm/types": minor
|
||||
"pnpm": minor
|
||||
"pacquet": minor
|
||||
---
|
||||
|
||||
Added the `audit.ignorePrune` setting. When set to `true`, `pnpm audit --fix` removes ignored GHSA entries that no longer appear in the audit report.
|
||||
Generated
+3
@@ -2940,6 +2940,9 @@ importers:
|
||||
'@pnpm/store.path':
|
||||
specifier: workspace:*
|
||||
version: link:../../../store/path
|
||||
'@pnpm/text.sanitize':
|
||||
specifier: workspace:*
|
||||
version: link:../../../text/sanitize
|
||||
'@pnpm/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../../core/types
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use crate::{State, cli_args::install::resolve_bool_override};
|
||||
use crate::{
|
||||
State,
|
||||
cli_args::{install::resolve_bool_override, sanitize::sanitize_inline},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::{Args, ValueEnum};
|
||||
use derive_more::{Display, Error};
|
||||
@@ -38,7 +41,7 @@ mod version_ranges;
|
||||
pub(crate) use fix::{
|
||||
AuditFixObserver, PackumentPublishInfo, VulnerabilityGuard, fetch_publish_times,
|
||||
filter_advisories_for_fix, fix_override, fix_with_update, format_fix_with_update_output,
|
||||
ignore_vulnerabilities, interactive_select,
|
||||
ignore_vulnerabilities, interactive_select, prune_ignored_ghsas,
|
||||
};
|
||||
pub(crate) use paths::{AuditPathIndex, PathInfo, build_audit_path_index, package_version};
|
||||
pub(crate) use render::{
|
||||
@@ -260,6 +263,45 @@ impl AuditArgs {
|
||||
.await;
|
||||
|
||||
if let Some(fix_method) = fix_method {
|
||||
// Remove ignored GHSAs that no longer appear in the report before
|
||||
// filtering. Mirrors pnpm's `audit.ignorePrune` handling in the
|
||||
// `audit` command handler.
|
||||
if state.config.audit_ignore_prune.unwrap_or(false)
|
||||
&& !state.config.audit_config.ignore_ghsas.is_empty()
|
||||
{
|
||||
let configured_ghsas = &state.config.audit_config.ignore_ghsas;
|
||||
let prune = prune_ignored_ghsas(configured_ghsas, &report);
|
||||
if !prune.pruned.is_empty() {
|
||||
// The pruned ids keep their original spelling from the
|
||||
// repository-controlled workspace manifest, so strip
|
||||
// control characters before they reach the terminal.
|
||||
println!(
|
||||
"Removed {} unused ignored GHSA{}: {}",
|
||||
prune.pruned.len(),
|
||||
if prune.pruned.len() == 1 { "" } else { "s" },
|
||||
prune
|
||||
.pruned
|
||||
.iter()
|
||||
.map(|ghsa| sanitize_inline(ghsa))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
);
|
||||
}
|
||||
// Persist even when nothing was removed: `retained` may
|
||||
// still differ from the configured list (deduplicated or
|
||||
// case-normalized), and the file should always reflect the
|
||||
// canonical form.
|
||||
if &prune.retained != configured_ghsas {
|
||||
pnpm_workspace_manifest_writer::set_audit_ignore_ghsas(
|
||||
&settings_dir,
|
||||
&prune.retained,
|
||||
)
|
||||
.map_err(|err| {
|
||||
miette::Report::new(err)
|
||||
.wrap_err("write auditConfig.ignoreGhsas to pnpm-workspace.yaml")
|
||||
})?;
|
||||
}
|
||||
}
|
||||
// Pre-filter by audit-level and ignored GHSAs so the interactive
|
||||
// prompt and both fix methods see the same advisory set the
|
||||
// override path's fixable filter would.
|
||||
|
||||
@@ -40,6 +40,44 @@ pub(crate) fn filter_advisories_for_fix(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `auditConfig.ignoreGhsas` entries split by whether their GHSA id still
|
||||
/// appears in the audit report.
|
||||
pub(crate) struct PruneIgnoredGhsasResult {
|
||||
pub(crate) pruned: Vec<String>,
|
||||
pub(crate) retained: Vec<String>,
|
||||
}
|
||||
|
||||
/// Split `ignored_ghsas` into those still present in `report` — normalized
|
||||
/// to their canonical spelling and deduplicated (`retained`) — and those
|
||||
/// that aren't, in their original spelling (`pruned`). Mirrors pnpm's
|
||||
/// `pruneIgnoredGhsas`.
|
||||
pub(crate) fn prune_ignored_ghsas(
|
||||
ignored_ghsas: &[String],
|
||||
report: &AuditReport,
|
||||
) -> PruneIgnoredGhsasResult {
|
||||
let advisory_ghsa_ids = report
|
||||
.advisories
|
||||
.values()
|
||||
.filter(|advisory| !advisory.github_advisory_id.is_empty())
|
||||
.map(|advisory| normalize_ghsa_id(&advisory.github_advisory_id))
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let mut retained_seen = HashSet::new();
|
||||
let mut retained = Vec::new();
|
||||
let mut pruned = Vec::new();
|
||||
for ghsa in ignored_ghsas {
|
||||
let normalized = normalize_ghsa_id(ghsa);
|
||||
if advisory_ghsa_ids.contains(&normalized) {
|
||||
if retained_seen.insert(normalized.clone()) {
|
||||
retained.push(normalized);
|
||||
}
|
||||
} else {
|
||||
pruned.push(ghsa.clone());
|
||||
}
|
||||
}
|
||||
PruneIgnoredGhsasResult { pruned, retained }
|
||||
}
|
||||
|
||||
/// Build the `name@vulnerable_versions → ^patched` override map from the
|
||||
/// fixable advisories (those with an inferred patched range). Keyed by a
|
||||
/// `BTreeMap` so the output is sorted, mirroring pnpm's `sortDirectKeys`.
|
||||
|
||||
@@ -951,6 +951,260 @@ fn audit_fix_override_with_no_fixable_vulnerabilities_makes_no_changes() {
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_fix_ignore_prune_removes_unused_ignored_ghsas() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
// GHSA-test-1111-2222 exists in the report; GHSA-test-9999-9999 doesn't.
|
||||
let mock = audit_mock(
|
||||
&mut registry,
|
||||
&advisory_response("vulnerable", 123, "high", "<2.0.0", "test", "GHSA-test-1111-2222"),
|
||||
)
|
||||
.create();
|
||||
write_audit_workspace(
|
||||
&workspace,
|
||||
®istry.url(),
|
||||
"audit:\n ignorePrune: true\nauditConfig:\n ignoreGhsas:\n - GHSA-test-1111-2222\n - GHSA-test-9999-9999\n",
|
||||
);
|
||||
|
||||
let output = pacquet.arg("audit").arg("--fix").output().expect("run pacquet audit --fix");
|
||||
|
||||
assert_success(&output);
|
||||
assert!(
|
||||
stdout(&output).contains("Removed 1 unused ignored GHSA: GHSA-test-9999-9999"),
|
||||
"stdout should report the removed GHSA:\n{}",
|
||||
stdout(&output),
|
||||
);
|
||||
let manifest =
|
||||
fs::read_to_string(workspace.join("pnpm-workspace.yaml")).expect("read workspace manifest");
|
||||
assert!(
|
||||
manifest.contains("GHSA-test-1111-2222") && !manifest.contains("GHSA-test-9999-9999"),
|
||||
"manifest should retain the still-relevant GHSA and drop the unused one:\n{manifest}",
|
||||
);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_fix_ignore_prune_disabled_by_default_keeps_all_ignored_ghsas() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let mock = audit_mock(
|
||||
&mut registry,
|
||||
&advisory_response("vulnerable", 123, "high", "<2.0.0", "test", "GHSA-test-1111-2222"),
|
||||
)
|
||||
.create();
|
||||
write_audit_workspace(
|
||||
&workspace,
|
||||
®istry.url(),
|
||||
"auditConfig:\n ignoreGhsas:\n - GHSA-test-1111-2222\n - GHSA-test-9999-9999\n",
|
||||
);
|
||||
|
||||
let output = pacquet.arg("audit").arg("--fix").output().expect("run pacquet audit --fix");
|
||||
|
||||
assert_success(&output);
|
||||
assert!(!stdout(&output).contains("unused ignored GHSA"));
|
||||
let manifest =
|
||||
fs::read_to_string(workspace.join("pnpm-workspace.yaml")).expect("read workspace manifest");
|
||||
assert!(
|
||||
manifest.contains("GHSA-test-9999-9999"),
|
||||
"manifest should keep the unused GHSA since pruning is disabled:\n{manifest}",
|
||||
);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_fix_ignore_prune_normalizes_ghsa_casing() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let mock = audit_mock(
|
||||
&mut registry,
|
||||
&advisory_response("vulnerable", 123, "high", "<2.0.0", "test", "GHSA-test-1111-2222"),
|
||||
)
|
||||
.create();
|
||||
write_audit_workspace(
|
||||
&workspace,
|
||||
®istry.url(),
|
||||
"audit:\n ignorePrune: true\nauditConfig:\n ignoreGhsas:\n - ghsa-test-1111-2222\n - GHSA-TEST-9999-9999\n",
|
||||
);
|
||||
|
||||
let output = pacquet.arg("audit").arg("--fix").output().expect("run pacquet audit --fix");
|
||||
|
||||
assert_success(&output);
|
||||
// Retained entries are rewritten to their canonical spelling regardless
|
||||
// of the casing the user originally ignored them with, and deduplicated
|
||||
// — the exact list must be just the one canonical, still-relevant id.
|
||||
assert_eq!(audit_config_ignore_ghsas(&workspace), vec!["GHSA-test-1111-2222".to_string()]);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_fix_ignore_prune_persists_canonical_form_even_when_nothing_is_removed() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let mock = audit_mock(
|
||||
&mut registry,
|
||||
&advisory_response("vulnerable", 123, "high", "<2.0.0", "test", "GHSA-test-1111-2222"),
|
||||
)
|
||||
.create();
|
||||
// Both entries match the same advisory (a differently-cased duplicate)
|
||||
// — nothing gets removed, but the stored list should still collapse to
|
||||
// the single canonical entry.
|
||||
write_audit_workspace(
|
||||
&workspace,
|
||||
®istry.url(),
|
||||
"audit:\n ignorePrune: true\nauditConfig:\n ignoreGhsas:\n - ghsa-test-1111-2222\n - GHSA-TEST-1111-2222\n",
|
||||
);
|
||||
|
||||
let output = pacquet.arg("audit").arg("--fix").output().expect("run pacquet audit --fix");
|
||||
|
||||
assert_success(&output);
|
||||
assert!(!stdout(&output).contains("unused ignored GHSA"));
|
||||
assert_eq!(audit_config_ignore_ghsas(&workspace), vec!["GHSA-test-1111-2222".to_string()]);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_fix_ignore_prune_removes_a_comment_attached_to_the_removed_entry() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let mock = audit_mock(
|
||||
&mut registry,
|
||||
&advisory_response("vulnerable", 123, "high", "<2.0.0", "test", "GHSA-test-1111-2222"),
|
||||
)
|
||||
.create();
|
||||
write_audit_workspace(
|
||||
&workspace,
|
||||
®istry.url(),
|
||||
"audit:\n ignorePrune: true\nauditConfig:\n ignoreGhsas:\n - GHSA-test-1111-2222\n # Expired GHSA, should not be ignored\n - GHSA-test-9999-9999 # trailing comment, should also go\n",
|
||||
);
|
||||
|
||||
let output = pacquet.arg("audit").arg("--fix").output().expect("run pacquet audit --fix");
|
||||
|
||||
assert_success(&output);
|
||||
let manifest =
|
||||
fs::read_to_string(workspace.join("pnpm-workspace.yaml")).expect("read workspace manifest");
|
||||
// Both the preceding comment and the trailing same-line comment attached
|
||||
// to the removed entry must go with it.
|
||||
assert!(
|
||||
!manifest.contains("Expired GHSA") && !manifest.contains("trailing comment"),
|
||||
"manifest should drop the comments along with the entry they were attached to:\n{manifest}",
|
||||
);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_fix_ignore_prune_removes_all_when_none_are_relevant() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let mock = audit_mock(
|
||||
&mut registry,
|
||||
&advisory_response("vulnerable", 123, "high", "<2.0.0", "test", "GHSA-test-1111-2222"),
|
||||
)
|
||||
.create();
|
||||
write_audit_workspace(
|
||||
&workspace,
|
||||
®istry.url(),
|
||||
"audit:\n ignorePrune: true\nauditConfig:\n ignoreGhsas:\n - GHSA-test-9999-0001\n - GHSA-test-9999-0002\n",
|
||||
);
|
||||
|
||||
let output = pacquet.arg("audit").arg("--fix").output().expect("run pacquet audit --fix");
|
||||
|
||||
assert_success(&output);
|
||||
let manifest =
|
||||
fs::read_to_string(workspace.join("pnpm-workspace.yaml")).expect("read workspace manifest");
|
||||
assert!(
|
||||
!manifest.contains("ignoreGhsas:"),
|
||||
"manifest should drop ignoreGhsas once every entry is pruned:\n{manifest}",
|
||||
);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_fix_ignore_prune_edits_an_inline_audit_config_in_place() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let mock = audit_mock(
|
||||
&mut registry,
|
||||
&advisory_response("vulnerable", 123, "high", "<2.0.0", "test", "GHSA-test-1111-2222"),
|
||||
)
|
||||
.create();
|
||||
write_audit_workspace(
|
||||
&workspace,
|
||||
®istry.url(),
|
||||
"audit:\n ignorePrune: true\nauditConfig: { ignoreGhsas: [GHSA-test-1111-2222, GHSA-test-9999-9999] }\n",
|
||||
);
|
||||
|
||||
let output = pacquet.arg("audit").arg("--fix").output().expect("run pacquet audit --fix");
|
||||
|
||||
assert_success(&output);
|
||||
let actual_manifest =
|
||||
fs::read_to_string(workspace.join("pnpm-workspace.yaml")).expect("read workspace manifest");
|
||||
let expected_manifest = "fetchRetries: 0\naudit:\n ignorePrune: true\nauditConfig: { ignoreGhsas: [ GHSA-test-1111-2222 ] }\n";
|
||||
eprintln!("actual manifest:\n{actual_manifest}\nexpected manifest:\n{expected_manifest}");
|
||||
assert_eq!(actual_manifest, expected_manifest);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_fix_ignore_prune_updates_the_canonical_audit_ignore_list() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let mock = audit_mock(
|
||||
&mut registry,
|
||||
&advisory_response("vulnerable", 123, "high", "<2.0.0", "test", "GHSA-test-1111-2222"),
|
||||
)
|
||||
.create();
|
||||
write_audit_workspace(
|
||||
&workspace,
|
||||
®istry.url(),
|
||||
"audit:\n ignorePrune: true\n ignore:\n - GHSA-test-1111-2222\n - GHSA-test-9999-9999\n",
|
||||
);
|
||||
|
||||
let output = pacquet.arg("audit").arg("--fix").output().expect("run pacquet audit --fix");
|
||||
|
||||
assert_success(&output);
|
||||
let actual_manifest =
|
||||
fs::read_to_string(workspace.join("pnpm-workspace.yaml")).expect("read workspace manifest");
|
||||
// The retained list must land back on the canonical `audit.ignore` that
|
||||
// supplied it — writing the deprecated `auditConfig.ignoreGhsas` instead
|
||||
// would let the unchanged canonical list shadow the prune on the next
|
||||
// read and restore the stale id.
|
||||
let expected_manifest =
|
||||
"fetchRetries: 0\naudit:\n ignorePrune: true\n ignore:\n - GHSA-test-1111-2222\n";
|
||||
eprintln!("actual manifest:\n{actual_manifest}\nexpected manifest:\n{expected_manifest}");
|
||||
assert_eq!(actual_manifest, expected_manifest);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_fix_ignore_prune_sanitizes_the_removed_ids_in_output() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
let mut registry = mockito::Server::new();
|
||||
let mock = audit_mock(
|
||||
&mut registry,
|
||||
&advisory_response("vulnerable", 123, "high", "<2.0.0", "test", "GHSA-test-1111-2222"),
|
||||
)
|
||||
.create();
|
||||
// The stale entry carries an ANSI escape from the repository-controlled
|
||||
// manifest; the removal message must strip it before the terminal.
|
||||
write_audit_workspace(
|
||||
&workspace,
|
||||
®istry.url(),
|
||||
"audit:\n ignorePrune: true\nauditConfig:\n ignoreGhsas:\n - GHSA-test-1111-2222\n - \"GHSA-test-9999-9999\\e[31m\"\n",
|
||||
);
|
||||
|
||||
let output = pacquet.arg("audit").arg("--fix").output().expect("run pacquet audit --fix");
|
||||
|
||||
assert_success(&output);
|
||||
let out = stdout(&output);
|
||||
assert!(
|
||||
out.contains("Removed 1 unused ignored GHSA: GHSA-test-9999-9999[31m"),
|
||||
"stdout should report the removed GHSA with its control characters stripped:\n{out}",
|
||||
);
|
||||
assert!(!out.contains('\u{1b}'), "stdout must not carry the escape character:\n{out}");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_fix_rejects_invalid_method() {
|
||||
let CommandTempCwd { mut pacquet, workspace, root: _root, .. } = CommandTempCwd::init();
|
||||
@@ -1219,6 +1473,28 @@ fn advisory_response(
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse `workspace`'s `pnpm-workspace.yaml` and return the exact
|
||||
/// `auditConfig.ignoreGhsas` list (empty when the key is absent).
|
||||
fn audit_config_ignore_ghsas(workspace: &Path) -> Vec<String> {
|
||||
#[derive(Default, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct OnlyAuditConfig {
|
||||
audit_config: AuditConfig,
|
||||
}
|
||||
#[derive(Default, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
struct AuditConfig {
|
||||
ignore_ghsas: Vec<String>,
|
||||
}
|
||||
|
||||
let text =
|
||||
fs::read_to_string(workspace.join("pnpm-workspace.yaml")).expect("read workspace manifest");
|
||||
serde_saphyr::from_str::<OnlyAuditConfig>(&text)
|
||||
.expect("parse pnpm-workspace.yaml")
|
||||
.audit_config
|
||||
.ignore_ghsas
|
||||
}
|
||||
|
||||
fn write_audit_workspace(workspace: &Path, registry_url: &str, workspace_yaml: &str) {
|
||||
write_audit_workspace_with_npmrc(
|
||||
workspace,
|
||||
|
||||
@@ -56,6 +56,7 @@ const TYPED_WORKSPACE_MANIFEST_KEYS: &[&str] = &[
|
||||
/// fields.
|
||||
const CONFIG_ONLY_SETTING_KEYS: &[&str] = &[
|
||||
"allowNew",
|
||||
"auditIgnorePrune",
|
||||
"authConfig",
|
||||
"autoConfirmAllPrompts",
|
||||
"bin",
|
||||
|
||||
@@ -2249,6 +2249,10 @@ pub struct Config {
|
||||
/// `auditConfig` config for `pnpm audit`.
|
||||
pub audit_config: AuditConfig,
|
||||
|
||||
/// `audit.ignorePrune` from `pnpm-workspace.yaml`. See
|
||||
/// [`AuditSettings::ignore_prune`].
|
||||
pub audit_ignore_prune: Option<bool>,
|
||||
|
||||
/// `versioning` from `pnpm-workspace.yaml`: native workspace release
|
||||
/// management, consumed by `pnpm change` and the bare `pnpm version -r`.
|
||||
pub versioning: pnpm_versioning::VersioningSettings,
|
||||
@@ -2521,6 +2525,7 @@ impl Config {
|
||||
level: self.audit_level,
|
||||
ignore: (!self.audit_config.ignore_ghsas.is_empty())
|
||||
.then(|| self.audit_config.ignore_ghsas.clone()),
|
||||
ignore_prune: self.audit_ignore_prune,
|
||||
};
|
||||
(audit != AuditSettings::default()).then_some(audit)
|
||||
}
|
||||
|
||||
@@ -832,6 +832,13 @@ pub struct AuditSettings {
|
||||
/// [`AuditConfig::ignore_ghsas`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ignore: Option<Vec<String>>,
|
||||
|
||||
/// When `true`, `pnpm audit --fix` removes entries from the ignore
|
||||
/// list that no longer appear in the audit report, so a re-introduced
|
||||
/// vulnerability under the same GHSA ID gets re-evaluated instead of
|
||||
/// staying silently suppressed.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ignore_prune: Option<bool>,
|
||||
}
|
||||
|
||||
/// `update` entry: settings that tune `pnpm update` (and `pnpm
|
||||
@@ -1877,6 +1884,9 @@ impl WorkspaceSettings {
|
||||
}
|
||||
config.audit_config.ignore_ghsas = ignore;
|
||||
}
|
||||
if let Some(prune) = audit.ignore_prune {
|
||||
config.audit_ignore_prune = Some(prune);
|
||||
}
|
||||
}
|
||||
if let Some(v) = self.versioning {
|
||||
config.versioning = v;
|
||||
|
||||
@@ -200,6 +200,7 @@ fn create_config(
|
||||
verify_deps_before_run: Default::default(),
|
||||
audit_level: None,
|
||||
audit_config: Default::default(),
|
||||
audit_ignore_prune: None,
|
||||
trust_policy_exclude: None,
|
||||
trust_policy_ignore_after: None,
|
||||
resolution_mode: Default::default(),
|
||||
|
||||
@@ -423,15 +423,41 @@ fn override_keys_in_text(text: &str) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set `auditConfig.ignoreGhsas:` to `ghsas` (the complete desired list),
|
||||
/// creating the `auditConfig:` block or the nested `ignoreGhsas:` key when
|
||||
/// absent. An empty `ghsas` removes the `auditConfig:` block. `pnpm audit
|
||||
/// --ignore` calls this with the merged ignore list. Returns whether anything
|
||||
/// Set the ignore list to `ghsas` (the complete desired list) in whichever
|
||||
/// spelling the manifest uses — the canonical `audit.ignore` wins over the
|
||||
/// deprecated `auditConfig.ignoreGhsas`, matching the reader's precedence,
|
||||
/// so a stale canonical list can't shadow the update on the next read. When
|
||||
/// both spellings are present, the shadowed deprecated list is removed as
|
||||
/// part of the write. `auditConfig.ignoreGhsas` is created when neither is
|
||||
/// present. An empty `ghsas` removes the list, dropping its block when
|
||||
/// nothing else remains in it. `pnpm audit --ignore` and `audit.ignorePrune`
|
||||
/// call this with the complete desired list. Returns whether anything
|
||||
/// changed.
|
||||
pub(crate) fn set_audit_ignore_ghsas(
|
||||
manifest: &mut Manifest,
|
||||
ghsas: &[String],
|
||||
) -> Result<bool, Box<yamlpatch::Error>> {
|
||||
if manifest.audit_ignore.is_some() {
|
||||
let mut changed = if ghsas.is_empty() {
|
||||
remove_block_list_key(manifest, "audit", "ignore");
|
||||
manifest.audit_ignore = None;
|
||||
true
|
||||
} else if manifest.audit_ignore.as_deref() == Some(ghsas) {
|
||||
false
|
||||
} else {
|
||||
let new_text = upsert_sequence_entry(manifest.text(), "audit", "ignore", ghsas);
|
||||
manifest.set_text(new_text);
|
||||
manifest.audit_ignore = Some(ghsas.to_vec());
|
||||
true
|
||||
};
|
||||
if manifest.audit_ignore_ghsas.is_some() {
|
||||
remove_block_list_key(manifest, "auditConfig", "ignoreGhsas");
|
||||
manifest.audit_ignore_ghsas = None;
|
||||
changed = true;
|
||||
}
|
||||
return Ok(changed);
|
||||
}
|
||||
|
||||
const BLOCK: &str = "auditConfig";
|
||||
let current = manifest.audit_ignore_ghsas.as_deref().unwrap_or_default();
|
||||
|
||||
@@ -440,18 +466,12 @@ pub(crate) fn set_audit_ignore_ghsas(
|
||||
if locate(text, &[BLOCK]).is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let keys = mapping_keys(text, &[BLOCK]);
|
||||
// Nothing to remove if `ignoreGhsas` isn't present — and crucially,
|
||||
// don't touch sibling `auditConfig` keys.
|
||||
if !keys.iter().any(|key| key == "ignoreGhsas") {
|
||||
if !mapping_keys(text, &[BLOCK]).iter().any(|key| key == "ignoreGhsas") {
|
||||
return Ok(false);
|
||||
}
|
||||
if keys.iter().all(|key| key == "ignoreGhsas") {
|
||||
manifest.set_text(remove_top_level_block(text, BLOCK));
|
||||
manifest.top_level_keys.retain(|key| key != BLOCK);
|
||||
} else {
|
||||
manifest.set_text(remove_mapping_entries(text, &[BLOCK], &["ignoreGhsas".to_string()]));
|
||||
}
|
||||
remove_block_list_key(manifest, BLOCK, "ignoreGhsas");
|
||||
manifest.audit_ignore_ghsas = None;
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -475,6 +495,28 @@ pub(crate) fn set_audit_ignore_ghsas(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Remove `block.key` from the document — the whole `block:` when the key is
|
||||
/// its only entry, so no empty mapping is left behind. Sibling keys of
|
||||
/// `block` are never touched. A missing block or key is a no-op.
|
||||
fn remove_block_list_key(manifest: &mut Manifest, block: &str, key: &str) {
|
||||
let text = manifest.text();
|
||||
if locate(text, &[block]).is_none() {
|
||||
return;
|
||||
}
|
||||
let keys = mapping_keys(text, &[block]);
|
||||
if !keys.iter().any(|k| k == key) {
|
||||
return;
|
||||
}
|
||||
if keys.iter().all(|k| k == key) {
|
||||
let new_text = remove_top_level_block(text, block);
|
||||
manifest.set_text(new_text);
|
||||
manifest.top_level_keys.retain(|k| k != block);
|
||||
} else {
|
||||
let new_text = remove_mapping_entries(text, &[block], &[key.to_string()]);
|
||||
manifest.set_text(new_text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the top-level `minimumReleaseAgeExclude:` block to `items` (the
|
||||
/// complete desired list), creating or replacing it, and removing it when
|
||||
/// `items` is empty. The caller is responsible for merging with the existing
|
||||
|
||||
@@ -552,12 +552,16 @@ where
|
||||
write_or_remove_manifest(&path, manifest)
|
||||
}
|
||||
|
||||
/// Set `dir`'s `pnpm-workspace.yaml` `auditConfig.ignoreGhsas:` to `ghsas`
|
||||
/// (the complete desired list), creating the file/block if absent and
|
||||
/// removing the `auditConfig:` block when `ghsas` is empty. Preserves the
|
||||
/// rest of the document's formatting and writes the file back only when
|
||||
/// something actually changed. Used by `pnpm audit --ignore` /
|
||||
/// `--ignore-unfixable` to persist suppressed advisories.
|
||||
/// Set `dir`'s `pnpm-workspace.yaml` audit ignore list to `ghsas` (the
|
||||
/// complete desired list), targeting whichever spelling the manifest uses —
|
||||
/// the canonical `audit.ignore` wins over the deprecated
|
||||
/// `auditConfig.ignoreGhsas`, matching the reader's precedence, and the
|
||||
/// shadowed deprecated list is removed when both are present — creating the
|
||||
/// file plus an `auditConfig:` block when neither is present.
|
||||
/// Preserves the rest of the document's formatting and writes the file back
|
||||
/// only when something actually changed. Used by `pnpm audit --ignore` /
|
||||
/// `--ignore-unfixable` and the `audit.ignorePrune` cleanup to persist
|
||||
/// suppressed advisories.
|
||||
pub fn set_audit_ignore_ghsas(
|
||||
dir: &Path,
|
||||
ghsas: &[String],
|
||||
@@ -582,7 +586,7 @@ pub fn set_audit_ignore_ghsas(
|
||||
|
||||
if let Some(key) = unsupported_inline_key(
|
||||
manifest.text(),
|
||||
&[&["auditConfig"], &["auditConfig", "ignoreGhsas"]],
|
||||
&[&["auditConfig"], &["auditConfig", "ignoreGhsas"], &["audit"], &["audit", "ignore"]],
|
||||
) {
|
||||
return Err(UpdateWorkspaceManifestError::UnsupportedInlineBlock { path, key });
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ pub(crate) struct Manifest {
|
||||
/// `auditConfig.ignoreGhsas:` list. Consulted to detect a no-op write
|
||||
/// of an already-present list.
|
||||
pub(crate) audit_ignore_ghsas: Option<Vec<String>>,
|
||||
/// `audit.ignore:` list — the canonical spelling, which wins over
|
||||
/// `auditConfig.ignoreGhsas` when both are present.
|
||||
pub(crate) audit_ignore: Option<Vec<String>>,
|
||||
/// `minimumReleaseAgeExclude:` list. Consulted to detect a no-op write
|
||||
/// of an already-present list.
|
||||
pub(crate) minimum_release_age_exclude: Option<Vec<String>>,
|
||||
@@ -60,6 +63,8 @@ struct CatalogData {
|
||||
overrides: Option<IndexMap<String, OverrideValue>>,
|
||||
#[serde(default, rename = "auditConfig")]
|
||||
audit_config: Option<AuditConfigData>,
|
||||
#[serde(default)]
|
||||
audit: Option<AuditData>,
|
||||
#[serde(default, rename = "minimumReleaseAgeExclude")]
|
||||
minimum_release_age_exclude: Option<Vec<String>>,
|
||||
}
|
||||
@@ -71,6 +76,13 @@ struct AuditConfigData {
|
||||
ignore_ghsas: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// The `audit` slice consulted for no-op detection and target selection.
|
||||
#[derive(Default, Deserialize)]
|
||||
struct AuditData {
|
||||
#[serde(default)]
|
||||
ignore: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// An `allowBuilds` value, tolerant of the string form pnpm also accepts
|
||||
/// (a version spec) so decoding a manifest that uses it doesn't fail. Only
|
||||
/// the boolean shape is retained — the only shape `pnpm approve-builds`
|
||||
@@ -123,6 +135,7 @@ impl Manifest {
|
||||
overrides: None,
|
||||
non_scalar_overrides: HashSet::new(),
|
||||
audit_ignore_ghsas: None,
|
||||
audit_ignore: None,
|
||||
minimum_release_age_exclude: None,
|
||||
});
|
||||
}
|
||||
@@ -165,6 +178,7 @@ impl Manifest {
|
||||
.collect()
|
||||
});
|
||||
let audit_ignore_ghsas = data.audit_config.and_then(|config| config.ignore_ghsas);
|
||||
let audit_ignore = data.audit.and_then(|audit| audit.ignore);
|
||||
|
||||
Ok(Manifest {
|
||||
text,
|
||||
@@ -177,6 +191,7 @@ impl Manifest {
|
||||
overrides,
|
||||
non_scalar_overrides,
|
||||
audit_ignore_ghsas,
|
||||
audit_ignore,
|
||||
minimum_release_age_exclude: data.minimum_release_age_exclude,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -860,24 +860,46 @@ fn ignore_ghsas_empty_with_sibling_only_is_a_noop() {
|
||||
assert_eq!(out, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_ghsas_targets_the_canonical_audit_ignore_list() {
|
||||
let original = "audit:\n ignorePrune: true\n ignore:\n - GHSA-aaaa-bbbb-cccc\n";
|
||||
let out = run_ignore_ghsas(Some(original), &["GHSA-dddd-eeee-ffff"]).expect("written");
|
||||
assert_eq!(out, "audit:\n ignorePrune: true\n ignore:\n - GHSA-dddd-eeee-ffff\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_ghsas_removes_the_shadowed_deprecated_list_when_both_are_present() {
|
||||
let original = "audit:\n ignore:\n - GHSA-aaaa-bbbb-cccc\nauditConfig:\n ignoreGhsas:\n - GHSA-1111-2222-3333\n";
|
||||
let out = run_ignore_ghsas(Some(original), &["GHSA-dddd-eeee-ffff"]).expect("written");
|
||||
assert_eq!(out, "audit:\n ignore:\n - GHSA-dddd-eeee-ffff\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_ghsas_empty_removes_audit_ignore_and_keeps_siblings() {
|
||||
let original = "audit:\n ignorePrune: true\n ignore:\n - GHSA-aaaa-bbbb-cccc\n";
|
||||
let out = run_ignore_ghsas(Some(original), &[]).expect("written");
|
||||
assert_eq!(out, "audit:\n ignorePrune: true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_ghsas_empty_removes_the_audit_block_when_ignore_is_its_only_key() {
|
||||
let original = "packages:\n - '.'\naudit:\n ignore:\n - GHSA-aaaa-bbbb-cccc\n";
|
||||
let out = run_ignore_ghsas(Some(original), &[]).expect("written");
|
||||
assert_eq!(out, "packages:\n - '.'\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_ghsas_edits_an_inline_flow_audit_config() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let path = dir.path().join(WORKSPACE_MANIFEST_FILENAME);
|
||||
fs::write(
|
||||
&path,
|
||||
"auditConfig: { cleanupUnusedIgnoredGhsas: true, ignoreGhsas: [GHSA-aaaa-bbbb-cccc] }\n",
|
||||
)
|
||||
.expect("seed");
|
||||
fs::write(&path, "auditConfig: { other: keep, ignoreGhsas: [GHSA-aaaa-bbbb-cccc] }\n")
|
||||
.expect("seed");
|
||||
|
||||
crate::set_audit_ignore_ghsas(dir.path(), &["GHSA-dddd-eeee-ffff".to_string()])
|
||||
.expect("set_audit_ignore_ghsas succeeds");
|
||||
|
||||
let after = fs::read_to_string(&path).expect("read manifest");
|
||||
assert_eq!(
|
||||
after,
|
||||
"auditConfig: { cleanupUnusedIgnoredGhsas: true, ignoreGhsas: [ GHSA-dddd-eeee-ffff ] }\n",
|
||||
);
|
||||
assert_eq!(after, "auditConfig: { other: keep, ignoreGhsas: [ GHSA-dddd-eeee-ffff ] }\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -39,6 +39,7 @@ export type OptionsFromRootManifest = {
|
||||
registriesByScope?: Record<string, string>
|
||||
registriesByPrefix?: Record<string, string>
|
||||
registryOptionsByUrl?: Record<string, RegistryOptions>
|
||||
auditIgnorePrune?: boolean
|
||||
} & Pick<PnpmSettings, 'configDependencies' | 'auditConfig' | 'pnprServer' | 'updateConfig'>
|
||||
|
||||
interface GetOptionsFromPnpmSettingsOptions {
|
||||
@@ -332,6 +333,9 @@ function translateUpdateSettings (pnpmSettings: PnpmSettings, settings: OptionsF
|
||||
*/
|
||||
function translateAuditSettings (pnpmSettings: PnpmSettings, settings: OptionsFromRootManifest): void {
|
||||
delete (settings as { audit?: unknown }).audit
|
||||
// `auditIgnorePrune` is derived from `audit.ignorePrune` below; a raw
|
||||
// top-level key of that name is not a setting in either CLI.
|
||||
delete settings.auditIgnorePrune
|
||||
const audit = pnpmSettings.audit
|
||||
if (audit == null) return
|
||||
assertObjectSetting(audit, 'audit')
|
||||
@@ -351,6 +355,10 @@ function translateAuditSettings (pnpmSettings: PnpmSettings, settings: OptionsFr
|
||||
}
|
||||
;(settings as { auditLevel?: string }).auditLevel = audit.level
|
||||
}
|
||||
if (audit.ignorePrune != null) {
|
||||
assertBoolean(audit.ignorePrune, 'audit.ignorePrune')
|
||||
settings.auditIgnorePrune = audit.ignorePrune
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -372,15 +380,17 @@ export function toUpdateSettings (updateConfig: OptionsFromRootManifest['updateC
|
||||
|
||||
/**
|
||||
* The `audit` settings the CLI acts on, re-joined from the internal
|
||||
* `auditConfig` / `auditLevel` pair {@link translateAuditSettings} splits the
|
||||
* section into — the view `pnpm config get audit` prints. An empty ignore
|
||||
* list reads as unset. `undefined` when nothing is set.
|
||||
* `auditConfig` / `auditLevel` / `auditIgnorePrune` settings
|
||||
* {@link translateAuditSettings} splits the section into — the view
|
||||
* `pnpm config get audit` prints. An empty ignore list reads as unset.
|
||||
* `undefined` when nothing is set.
|
||||
*/
|
||||
export function toAuditSettings ({ auditConfig, auditLevel }: { auditConfig?: AuditConfig, auditLevel?: AuditLevel }): AuditSettings | undefined {
|
||||
export function toAuditSettings ({ auditConfig, auditLevel, auditIgnorePrune }: { auditConfig?: AuditConfig, auditLevel?: AuditLevel, auditIgnorePrune?: boolean }): AuditSettings | undefined {
|
||||
const ignore = auditConfig?.ignoreGhsas
|
||||
const audit: AuditSettings = {
|
||||
...(auditLevel != null ? { level: auditLevel } : {}),
|
||||
...(ignore != null && ignore.length > 0 ? { ignore } : {}),
|
||||
...(auditIgnorePrune != null ? { ignorePrune: auditIgnorePrune } : {}),
|
||||
}
|
||||
return Object.keys(audit).length > 0 ? audit : undefined
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ const _proofTypedWorkspaceManifestKeysAreExhaustive: ProofTypedWorkspaceManifest
|
||||
*/
|
||||
const CONFIG_ONLY_SETTING_KEYS = [
|
||||
'allowNew',
|
||||
'auditIgnorePrune',
|
||||
'authConfig',
|
||||
'autoConfirmAllPrompts',
|
||||
'bin',
|
||||
|
||||
@@ -132,6 +132,17 @@ test('getOptionsFromPnpmSettings() maps the "audit" settings section to auditCon
|
||||
expect(globalWarn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('getOptionsFromPnpmSettings() maps "audit.ignorePrune" to auditIgnorePrune', () => {
|
||||
const options = getOptionsFromPnpmSettings(process.cwd(), {
|
||||
audit: {
|
||||
ignore: ['GHSA-1'],
|
||||
ignorePrune: true,
|
||||
},
|
||||
})
|
||||
expect(options.auditConfig).toStrictEqual({ ignoreGhsas: ['GHSA-1'] })
|
||||
expect(options.auditIgnorePrune).toBe(true)
|
||||
})
|
||||
|
||||
test('getOptionsFromPnpmSettings() never leaks the raw "audit" key into the options', () => {
|
||||
const options = getOptionsFromPnpmSettings(process.cwd(), {
|
||||
audit: {
|
||||
@@ -179,6 +190,12 @@ test('getOptionsFromPnpmSettings() throws when "audit.ignore" is not a string ar
|
||||
} as any)).toThrow(/audit\.ignore/) // eslint-disable-line
|
||||
})
|
||||
|
||||
test('getOptionsFromPnpmSettings() throws when "audit.ignorePrune" is not a boolean', () => {
|
||||
expect(() => getOptionsFromPnpmSettings(process.cwd(), {
|
||||
audit: { ignorePrune: 'yes' },
|
||||
} as any)).toThrow(/audit\.ignorePrune/) // eslint-disable-line
|
||||
})
|
||||
|
||||
test('getOptionsFromPnpmSettings() throws on an invalid "audit.level"', () => {
|
||||
expect(() => getOptionsFromPnpmSettings(process.cwd(), {
|
||||
audit: { level: 'severe' },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { updateWorkspaceManifest } from '@pnpm/workspace.workspace-manifest-writ
|
||||
export interface WriteSettingsOptions {
|
||||
updatedSettings?: PnpmSettings
|
||||
updatedOverrides?: Record<string, string>
|
||||
updatedAuditIgnoreGhsas?: string[]
|
||||
addedMinimumReleaseAgeExcludes?: string[]
|
||||
deletedLegacyKeys?: string[]
|
||||
rootProjectManifest?: ProjectManifest
|
||||
@@ -15,6 +16,7 @@ export async function writeSettings (opts: WriteSettingsOptions): Promise<void>
|
||||
await updateWorkspaceManifest(opts.workspaceDir, {
|
||||
updatedFields: opts.updatedSettings,
|
||||
updatedOverrides: opts.updatedOverrides,
|
||||
updatedAuditIgnoreGhsas: opts.updatedAuditIgnoreGhsas,
|
||||
addedMinimumReleaseAgeExcludes: opts.addedMinimumReleaseAgeExcludes,
|
||||
deletedLegacyKeys: opts.deletedLegacyKeys,
|
||||
})
|
||||
|
||||
@@ -202,6 +202,13 @@ export interface AuditSettings {
|
||||
* `auditConfig.ignoreGhsas`.
|
||||
*/
|
||||
ignore?: string[]
|
||||
/**
|
||||
* When `true`, `pnpm audit --fix` removes entries from the ignore list that
|
||||
* no longer appear in the audit report, so a re-introduced vulnerability
|
||||
* under the same GHSA ID gets re-evaluated instead of staying silently
|
||||
* suppressed.
|
||||
*/
|
||||
ignorePrune?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateSettings {
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"@pnpm/npm-package-arg": "catalog:",
|
||||
"@pnpm/object.key-sorting": "workspace:*",
|
||||
"@pnpm/store.path": "workspace:*",
|
||||
"@pnpm/text.sanitize": "workspace:*",
|
||||
"@pnpm/types": "workspace:*",
|
||||
"@pnpm/workspace.project-manifest-reader": "workspace:*",
|
||||
"@zkochan/table": "catalog:",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { checkbox, Separator } from '@inquirer/prompts'
|
||||
import { docsUrl, interactivePromptPageSize, TABLE_OPTIONS } from '@pnpm/cli.utils'
|
||||
import { type Config, type ConfigContext, types as allTypes, type UniversalOptions } from '@pnpm/config.reader'
|
||||
import { writeSettings } from '@pnpm/config.writer'
|
||||
import { audit, type AuditAdvisory, type AuditLevelNumber, type AuditLevelString, type AuditReport, type AuditVulnerabilityCounts, type IgnoredAuditVulnerabilityCounts, normalizeGhsaId } from '@pnpm/deps.compliance.audit'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { type InstallCommandOptions, update } from '@pnpm/installing.commands'
|
||||
import { globalInfo } from '@pnpm/logger'
|
||||
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
|
||||
import { sanitizeInline } from '@pnpm/text.sanitize'
|
||||
import type { RegistriesByScope } from '@pnpm/types'
|
||||
import { table } from '@zkochan/table'
|
||||
import chalk, { type ChalkInstance } from 'chalk'
|
||||
@@ -17,6 +19,7 @@ import { fix } from './fix.js'
|
||||
import { fixWithUpdate, type FixWithUpdateResult } from './fixWithUpdate.js'
|
||||
import { getAuditFixChoices } from './getAuditFixChoices.js'
|
||||
import { ignore } from './ignore.js'
|
||||
import { pruneIgnoredGhsas } from './pruneIgnoredGhsas.js'
|
||||
import { correctInferredPatchedVersions, createPublishTimesFetcher, type PublishTimesFetcher } from './publishTimes.js'
|
||||
import { auditSignatures } from './signatures.js'
|
||||
|
||||
@@ -177,6 +180,7 @@ export type AuditOptions = Pick<UniversalOptions, 'dir'> & {
|
||||
*/
|
||||
getPublishTimes?: PublishTimesFetcher
|
||||
} & Pick<Config, 'auditConfig'
|
||||
| 'auditIgnorePrune'
|
||||
| 'auditLevel'
|
||||
| 'minimumReleaseAge'
|
||||
| 'ca'
|
||||
@@ -271,6 +275,37 @@ export async function handler (opts: AuditOptions, params: string[] = []): Promi
|
||||
throw new PnpmError('INVALID_FIX_OPTION', `Invalid value for --fix: ${opts.fix as string}. Should be one of "override" or "update"`)
|
||||
}
|
||||
if (fixMethod != null) {
|
||||
if (opts.auditIgnorePrune && opts.auditConfig?.ignoreGhsas?.length) {
|
||||
const configuredGhsas = opts.auditConfig.ignoreGhsas
|
||||
const { pruned, retained } = pruneIgnoredGhsas(configuredGhsas, auditReport)
|
||||
if (pruned.length > 0) {
|
||||
// The pruned ids keep their original spelling from the
|
||||
// repository-controlled workspace manifest, so strip control
|
||||
// characters before they reach the terminal.
|
||||
globalInfo(`Removed ${pruned.length} unused ignored GHSA${pruned.length === 1 ? '' : 's'}: ${pruned.map(sanitizeInline).join(', ')}`)
|
||||
}
|
||||
// Persist even when nothing was removed: `retained` may still differ
|
||||
// from the configured list (deduplicated or case-normalized), and the
|
||||
// file should always reflect the canonical form.
|
||||
const retainedDiffers = retained.length !== configuredGhsas.length ||
|
||||
retained.some((ghsa, index) => ghsa !== configuredGhsas[index])
|
||||
if (retainedDiffers) {
|
||||
// Written through the dedicated ignore-list update so the retained
|
||||
// list lands on whichever spelling the manifest uses — replacing
|
||||
// only `auditConfig` would let a canonical `audit.ignore` list
|
||||
// shadow the pruned result on the next read.
|
||||
await writeSettings({
|
||||
...opts,
|
||||
workspaceDir: opts.workspaceDir ?? opts.rootProjectManifestDir,
|
||||
updatedAuditIgnoreGhsas: retained,
|
||||
})
|
||||
// Update opts for subsequent operations
|
||||
opts.auditConfig = {
|
||||
...opts.auditConfig,
|
||||
ignoreGhsas: retained.length > 0 ? retained : undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pre-filter by auditLevel and ignoreGhsas so the interactive prompt
|
||||
// and the update-method path see the same set of advisories that
|
||||
// fix.ts's getFixableAdvisories filters for the override path.
|
||||
|
||||
@@ -39,16 +39,15 @@ export async function ignore (opts: IgnoreVulnerabilitiesOptions): Promise<strin
|
||||
}
|
||||
}
|
||||
|
||||
const newIgnoreGhsas = currentUniqueGhsas.size > 0 ? Array.from(currentUniqueGhsas) : undefined
|
||||
const diffGhsas = difference(newIgnoreGhsas ?? [], currentGhsas)
|
||||
const newIgnoreGhsas = Array.from(currentUniqueGhsas)
|
||||
const diffGhsas = difference(newIgnoreGhsas, currentGhsas)
|
||||
// Written through the dedicated ignore-list update so the merged list
|
||||
// lands on whichever spelling the manifest uses — replacing only
|
||||
// `auditConfig` would let a canonical `audit.ignore` list shadow the
|
||||
// added ids on the next read.
|
||||
await writeSettings({
|
||||
...opts,
|
||||
updatedSettings: {
|
||||
auditConfig: {
|
||||
...opts.auditConfig,
|
||||
ignoreGhsas: newIgnoreGhsas,
|
||||
},
|
||||
},
|
||||
updatedAuditIgnoreGhsas: newIgnoreGhsas,
|
||||
})
|
||||
return [...diffGhsas]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AuditReport } from '@pnpm/deps.compliance.audit'
|
||||
import { normalizeGhsaId } from '@pnpm/deps.compliance.audit'
|
||||
|
||||
export interface PruneIgnoredGhsasResult {
|
||||
pruned: string[]
|
||||
retained: string[]
|
||||
}
|
||||
|
||||
export function pruneIgnoredGhsas (
|
||||
ignoredGhsas: string[],
|
||||
auditReport: AuditReport
|
||||
): PruneIgnoredGhsasResult {
|
||||
if (!ignoredGhsas?.length) {
|
||||
return { pruned: [], retained: [] }
|
||||
}
|
||||
|
||||
const advisoryGhsaIds = new Set<string>(
|
||||
Object.values(auditReport.advisories)
|
||||
.filter(({ github_advisory_id: ghsaId }) => ghsaId)
|
||||
.map(({ github_advisory_id: ghsaId }) => normalizeGhsaId(ghsaId))
|
||||
)
|
||||
|
||||
const retainedGhsas = new Set<string>()
|
||||
const pruned: string[] = []
|
||||
for (const ghsa of ignoredGhsas) {
|
||||
const normalized = normalizeGhsaId(ghsa)
|
||||
if (advisoryGhsaIds.has(normalized)) {
|
||||
retainedGhsas.add(normalized)
|
||||
} else {
|
||||
pruned.push(ghsa)
|
||||
}
|
||||
}
|
||||
|
||||
return { pruned, retained: Array.from(retainedGhsas) }
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { existsSync as fsExistsSync } from 'node:fs'
|
||||
import fs, { existsSync as fsExistsSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from '@jest/globals'
|
||||
import type { AuditAdvisory } from '@pnpm/deps.compliance.audit'
|
||||
import { audit } from '@pnpm/deps.compliance.commands'
|
||||
import { type LogBase, streamParser } from '@pnpm/logger'
|
||||
import { fixtures } from '@pnpm/test-fixtures'
|
||||
import { getMockAgent, setupMockAgent, teardownMockAgent } from '@pnpm/testing.mock-agent'
|
||||
import { readYamlFileSync } from 'read-yaml-file'
|
||||
@@ -14,14 +15,25 @@ import * as responses from './utils/responses/index.js'
|
||||
|
||||
const f = fixtures(import.meta.dirname)
|
||||
|
||||
const collectedInfos: string[] = []
|
||||
|
||||
beforeEach(async () => {
|
||||
collectedInfos.length = 0
|
||||
streamParser.on('data', collectInfos as (msg: LogBase) => void)
|
||||
await setupMockAgent()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
streamParser.removeListener('data', collectInfos as (msg: LogBase) => void)
|
||||
await teardownMockAgent()
|
||||
})
|
||||
|
||||
function collectInfos (msg: LogBase & { message?: string }): void {
|
||||
if (msg.level === 'info' && typeof msg.message === 'string') {
|
||||
collectedInfos.push(msg.message)
|
||||
}
|
||||
}
|
||||
|
||||
test('overrides are added for vulnerable dependencies', async () => {
|
||||
const tmp = f.prepare('has-vulnerabilities')
|
||||
|
||||
@@ -250,6 +262,273 @@ test('audit --fix respects auditLevel and only fixes matching severities', async
|
||||
expect(manifest.overrides?.['url-parse@<1.5.6']).toBeFalsy()
|
||||
})
|
||||
|
||||
test('audit.ignorePrune removes ignored GHSAs that are no longer in the report', async () => {
|
||||
const tmp = f.prepare('has-vulnerabilities-with-ignored-ghsas')
|
||||
|
||||
getMockAgent().get(AUDIT_REGISTRY.replace(/\/$/, ''))
|
||||
.intercept({ path: '/-/npm/v1/security/advisories/bulk', method: 'POST' })
|
||||
.reply(200, responses.ALL_VULN_RESP)
|
||||
|
||||
// GHSA-42xw-2xvc-qx8m exists in the report (axios <=0.18.0)
|
||||
// GHSA-xxxx-xxxx-xxxx does NOT exist in the report - should be removed
|
||||
const { exitCode } = await audit.handler({
|
||||
...AUDIT_REGISTRY_OPTS,
|
||||
auditLevel: 'moderate',
|
||||
auditConfig: {
|
||||
ignoreGhsas: [
|
||||
'GHSA-42xw-2xvc-qx8m',
|
||||
'GHSA-xxxx-xxxx-xxxx',
|
||||
],
|
||||
},
|
||||
auditIgnorePrune: true,
|
||||
dir: tmp,
|
||||
rootProjectManifestDir: tmp,
|
||||
fix: true,
|
||||
})
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
const manifest = readYamlFileSync<{ auditConfig?: { ignoreGhsas?: string[] } }>(path.join(tmp, 'pnpm-workspace.yaml'))
|
||||
expect(manifest.auditConfig?.ignoreGhsas).toContain('GHSA-42xw-2xvc-qx8m')
|
||||
expect(manifest.auditConfig?.ignoreGhsas).not.toContain('GHSA-xxxx-xxxx-xxxx')
|
||||
|
||||
// The preceding comment and the trailing same-line comment attached to
|
||||
// the removed entry must both go with it.
|
||||
const rawContent = fs.readFileSync(path.join(tmp, 'pnpm-workspace.yaml'), 'utf8')
|
||||
expect(rawContent).not.toContain('Expired GHSA')
|
||||
expect(rawContent).not.toContain('trailing comment')
|
||||
|
||||
expect(collectedInfos).toContain('Removed 1 unused ignored GHSA: GHSA-xxxx-xxxx-xxxx')
|
||||
})
|
||||
|
||||
test('audit.ignorePrune is disabled by default - no pruning', async () => {
|
||||
const tmp = f.prepare('has-vulnerabilities-with-ignored-ghsas')
|
||||
|
||||
getMockAgent().get(AUDIT_REGISTRY.replace(/\/$/, ''))
|
||||
.intercept({ path: '/-/npm/v1/security/advisories/bulk', method: 'POST' })
|
||||
.reply(200, responses.ALL_VULN_RESP)
|
||||
|
||||
// Without audit.ignorePrune: true, pruning should NOT run
|
||||
const { exitCode } = await audit.handler({
|
||||
...AUDIT_REGISTRY_OPTS,
|
||||
auditLevel: 'moderate',
|
||||
auditConfig: {
|
||||
ignoreGhsas: [
|
||||
'GHSA-42xw-2xvc-qx8m',
|
||||
'GHSA-xxxx-xxxx-xxxx',
|
||||
],
|
||||
},
|
||||
dir: tmp,
|
||||
rootProjectManifestDir: tmp,
|
||||
fix: true,
|
||||
})
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
// When pruning doesn't run, the auditConfig stays unchanged
|
||||
const manifest = readYamlFileSync<{ auditConfig?: { ignoreGhsas?: string[] } }>(path.join(tmp, 'pnpm-workspace.yaml'))
|
||||
expect(manifest.auditConfig?.ignoreGhsas).toContain('GHSA-xxxx-xxxx-xxxx')
|
||||
})
|
||||
|
||||
// GHSA ids are case-insensitive; lowercase version should match uppercase in report
|
||||
test('audit.ignorePrune handles case normalization', async () => {
|
||||
const tmp = f.prepare('has-vulnerabilities-with-ignored-ghsas')
|
||||
|
||||
getMockAgent().get(AUDIT_REGISTRY.replace(/\/$/, ''))
|
||||
.intercept({ path: '/-/npm/v1/security/advisories/bulk', method: 'POST' })
|
||||
.reply(200, responses.ALL_VULN_RESP)
|
||||
|
||||
const { exitCode } = await audit.handler({
|
||||
...AUDIT_REGISTRY_OPTS,
|
||||
auditLevel: 'moderate',
|
||||
auditConfig: {
|
||||
ignoreGhsas: [
|
||||
'ghsa-42xw-2xvc-qx8m', // lowercase, should be retained
|
||||
'GHSA-XXXX-XXXX-XXXX', // uppercase, NOT in report - should be removed
|
||||
],
|
||||
},
|
||||
auditIgnorePrune: true,
|
||||
dir: tmp,
|
||||
rootProjectManifestDir: tmp,
|
||||
fix: true,
|
||||
})
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
const manifest = readYamlFileSync<{ auditConfig?: { ignoreGhsas?: string[] } }>(path.join(tmp, 'pnpm-workspace.yaml'))
|
||||
// Retained entries are written in their canonical form regardless of the
|
||||
// casing the user originally ignored them with.
|
||||
expect(manifest.auditConfig?.ignoreGhsas).toEqual(['GHSA-42xw-2xvc-qx8m'])
|
||||
})
|
||||
|
||||
test('audit.ignorePrune persists the canonical form even when nothing is removed', async () => {
|
||||
const tmp = f.prepare('has-vulnerabilities-with-ignored-ghsas')
|
||||
|
||||
getMockAgent().get(AUDIT_REGISTRY.replace(/\/$/, ''))
|
||||
.intercept({ path: '/-/npm/v1/security/advisories/bulk', method: 'POST' })
|
||||
.reply(200, responses.ALL_VULN_RESP)
|
||||
|
||||
// Both entries match the same advisory (a differently-cased duplicate) —
|
||||
// nothing gets removed, but the stored list should still collapse to the
|
||||
// single canonical entry.
|
||||
const { exitCode } = await audit.handler({
|
||||
...AUDIT_REGISTRY_OPTS,
|
||||
auditLevel: 'moderate',
|
||||
auditConfig: {
|
||||
ignoreGhsas: [
|
||||
'ghsa-42xw-2xvc-qx8m',
|
||||
'GHSA-42XW-2XVC-QX8M',
|
||||
],
|
||||
},
|
||||
auditIgnorePrune: true,
|
||||
dir: tmp,
|
||||
rootProjectManifestDir: tmp,
|
||||
fix: true,
|
||||
})
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(collectedInfos.some((message) => message.includes('unused ignored GHSA'))).toBe(false)
|
||||
|
||||
const manifest = readYamlFileSync<{ auditConfig?: { ignoreGhsas?: string[] } }>(path.join(tmp, 'pnpm-workspace.yaml'))
|
||||
expect(manifest.auditConfig?.ignoreGhsas).toEqual(['GHSA-42xw-2xvc-qx8m'])
|
||||
})
|
||||
|
||||
test('audit.ignorePrune removes all entries when none are relevant', async () => {
|
||||
const tmp = f.prepare('has-vulnerabilities-with-ignored-ghsas')
|
||||
|
||||
getMockAgent().get(AUDIT_REGISTRY.replace(/\/$/, ''))
|
||||
.intercept({ path: '/-/npm/v1/security/advisories/bulk', method: 'POST' })
|
||||
.reply(200, responses.ALL_VULN_RESP)
|
||||
|
||||
// Only GHSAs that don't exist in the report - all should be pruned
|
||||
const { exitCode } = await audit.handler({
|
||||
...AUDIT_REGISTRY_OPTS,
|
||||
auditLevel: 'moderate',
|
||||
auditConfig: {
|
||||
ignoreGhsas: [
|
||||
'GHSA-xxxx-0000-0001',
|
||||
'GHSA-xxxx-0000-0002',
|
||||
],
|
||||
},
|
||||
auditIgnorePrune: true,
|
||||
dir: tmp,
|
||||
rootProjectManifestDir: tmp,
|
||||
fix: true,
|
||||
})
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
const manifest = readYamlFileSync<{ auditConfig?: { ignoreGhsas?: string[] } }>(path.join(tmp, 'pnpm-workspace.yaml'))
|
||||
expect(manifest.auditConfig?.ignoreGhsas).toBeUndefined()
|
||||
})
|
||||
|
||||
test('audit.ignorePrune edits an inline (flow-style) auditConfig in place', async () => {
|
||||
const tmp = f.prepare('has-vulnerabilities-with-ignored-ghsas')
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, 'pnpm-workspace.yaml'),
|
||||
'packages:\n - \'.\'\nsharedWorkspaceLockfile: false\nauditConfig: { ignoreGhsas: [GHSA-42xw-2xvc-qx8m, GHSA-xxxx-xxxx-xxxx] }\n'
|
||||
)
|
||||
|
||||
getMockAgent().get(AUDIT_REGISTRY.replace(/\/$/, ''))
|
||||
.intercept({ path: '/-/npm/v1/security/advisories/bulk', method: 'POST' })
|
||||
.reply(200, responses.ALL_VULN_RESP)
|
||||
|
||||
const { exitCode } = await audit.handler({
|
||||
...AUDIT_REGISTRY_OPTS,
|
||||
auditLevel: 'moderate',
|
||||
auditConfig: {
|
||||
ignoreGhsas: [
|
||||
'GHSA-42xw-2xvc-qx8m',
|
||||
'GHSA-xxxx-xxxx-xxxx',
|
||||
],
|
||||
},
|
||||
auditIgnorePrune: true,
|
||||
dir: tmp,
|
||||
rootProjectManifestDir: tmp,
|
||||
fix: true,
|
||||
})
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
// The retained GHSA is edited in place inside the flow-style block, rather
|
||||
// than the whole auditConfig being reformatted into block style.
|
||||
const rawContent = fs.readFileSync(path.join(tmp, 'pnpm-workspace.yaml'), 'utf8')
|
||||
expect(rawContent).toContain(
|
||||
'auditConfig: { ignoreGhsas: [ GHSA-42xw-2xvc-qx8m ] }'
|
||||
)
|
||||
|
||||
const manifest = readYamlFileSync<{ auditConfig?: { ignoreGhsas?: string[] } }>(path.join(tmp, 'pnpm-workspace.yaml'))
|
||||
expect(manifest.auditConfig?.ignoreGhsas).toEqual(['GHSA-42xw-2xvc-qx8m'])
|
||||
})
|
||||
|
||||
test('audit.ignorePrune updates the canonical audit.ignore list', async () => {
|
||||
const tmp = f.prepare('has-vulnerabilities-with-ignored-ghsas')
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, 'pnpm-workspace.yaml'),
|
||||
'packages:\n - \'.\'\nsharedWorkspaceLockfile: false\naudit:\n ignorePrune: true\n ignore:\n - GHSA-42xw-2xvc-qx8m\n - GHSA-xxxx-xxxx-xxxx\n'
|
||||
)
|
||||
|
||||
getMockAgent().get(AUDIT_REGISTRY.replace(/\/$/, ''))
|
||||
.intercept({ path: '/-/npm/v1/security/advisories/bulk', method: 'POST' })
|
||||
.reply(200, responses.ALL_VULN_RESP)
|
||||
|
||||
// `auditConfig`/`auditIgnorePrune` are the internal fields the config
|
||||
// reader derives from the manifest's `audit` section.
|
||||
const { exitCode } = await audit.handler({
|
||||
...AUDIT_REGISTRY_OPTS,
|
||||
auditLevel: 'moderate',
|
||||
auditConfig: {
|
||||
ignoreGhsas: [
|
||||
'GHSA-42xw-2xvc-qx8m',
|
||||
'GHSA-xxxx-xxxx-xxxx',
|
||||
],
|
||||
},
|
||||
auditIgnorePrune: true,
|
||||
dir: tmp,
|
||||
rootProjectManifestDir: tmp,
|
||||
fix: true,
|
||||
})
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
// The retained list must land back on the canonical `audit.ignore` that
|
||||
// supplied it — writing the deprecated `auditConfig.ignoreGhsas` instead
|
||||
// would let the unchanged canonical list shadow the prune on the next
|
||||
// read and restore the stale id.
|
||||
const manifest = readYamlFileSync<{ audit?: { ignorePrune?: boolean, ignore?: string[] }, auditConfig?: unknown }>(path.join(tmp, 'pnpm-workspace.yaml'))
|
||||
expect(manifest.audit).toStrictEqual({ ignorePrune: true, ignore: ['GHSA-42xw-2xvc-qx8m'] })
|
||||
expect(manifest.auditConfig).toBeUndefined()
|
||||
})
|
||||
|
||||
test('audit.ignorePrune sanitizes the removed ids in the log message', async () => {
|
||||
const tmp = f.prepare('has-vulnerabilities-with-ignored-ghsas')
|
||||
|
||||
getMockAgent().get(AUDIT_REGISTRY.replace(/\/$/, ''))
|
||||
.intercept({ path: '/-/npm/v1/security/advisories/bulk', method: 'POST' })
|
||||
.reply(200, responses.ALL_VULN_RESP)
|
||||
|
||||
// The stale entry carries an ANSI escape from the repository-controlled
|
||||
// manifest; the removal message must strip it before the terminal.
|
||||
const { exitCode } = await audit.handler({
|
||||
...AUDIT_REGISTRY_OPTS,
|
||||
auditLevel: 'moderate',
|
||||
auditConfig: {
|
||||
ignoreGhsas: [
|
||||
'GHSA-42xw-2xvc-qx8m',
|
||||
'GHSA-xxxx-xxxx-xxxx\u001b[31m',
|
||||
],
|
||||
},
|
||||
auditIgnorePrune: true,
|
||||
dir: tmp,
|
||||
rootProjectManifestDir: tmp,
|
||||
fix: true,
|
||||
})
|
||||
|
||||
expect(exitCode).toBe(0)
|
||||
expect(collectedInfos).toContain('Removed 1 unused ignored GHSA: GHSA-xxxx-xxxx-xxxx[31m')
|
||||
expect(collectedInfos.every((message) => !message.includes('\u001b'))).toBe(true)
|
||||
})
|
||||
|
||||
function advisory (moduleName: string, vulnerableVersions: string, patchedVersions?: string): AuditAdvisory {
|
||||
return {
|
||||
findings: [],
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "has-vulnerabilities",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"karma": "~2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"axios": "0.15"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"sync-exec": "0.6.2"
|
||||
}
|
||||
}
|
||||
+3665
File diff suppressed because it is too large.
Load diff
+8
@@ -0,0 +1,8 @@
|
||||
packages:
|
||||
- '.'
|
||||
sharedWorkspaceLockfile: false
|
||||
auditConfig:
|
||||
ignoreGhsas:
|
||||
- GHSA-42xw-2xvc-qx8m
|
||||
# Expired GHSA, should not be ignored
|
||||
- GHSA-xxxx-xxxx-xxxx # trailing comment, should also go
|
||||
@@ -91,6 +91,9 @@
|
||||
{
|
||||
"path": "../../../testing/registry-mock"
|
||||
},
|
||||
{
|
||||
"path": "../../../text/sanitize"
|
||||
},
|
||||
{
|
||||
"path": "../../../workspace/project-manifest-reader"
|
||||
},
|
||||
|
||||
@@ -48,6 +48,12 @@ export async function updateWorkspaceManifest (dir: string, opts: {
|
||||
updatedFields?: Partial<WorkspaceManifest>
|
||||
updatedCatalogs?: Catalogs
|
||||
updatedOverrides?: Record<string, string>
|
||||
/**
|
||||
* The complete desired audit ignore list, written to whichever spelling
|
||||
* the manifest uses — see {@link setAuditIgnoreGhsas}. An empty array
|
||||
* removes the list.
|
||||
*/
|
||||
updatedAuditIgnoreGhsas?: string[]
|
||||
addedMinimumReleaseAgeExcludes?: string[]
|
||||
deletedLegacyKeys?: string[]
|
||||
fileName?: FileName
|
||||
@@ -113,6 +119,9 @@ export async function updateWorkspaceManifest (dir: string, opts: {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opts.updatedAuditIgnoreGhsas != null) {
|
||||
shouldBeUpdated = setAuditIgnoreGhsas(manifest, opts.updatedAuditIgnoreGhsas) || shouldBeUpdated
|
||||
}
|
||||
if (opts.resolvedPackageVersions != null) {
|
||||
if (opts.minimumReleaseAgeExcludePrune) {
|
||||
shouldBeUpdated = pruneMinimumReleaseAgeExcludes(manifest, opts.resolvedPackageVersions) || shouldBeUpdated
|
||||
@@ -291,6 +300,55 @@ function addPackageReference (packageReferences: Record<string, Set<string>>, pk
|
||||
// patterns always stay — they are forward-looking and can't be proven stale.
|
||||
// Entries that fail to parse stay untouched so cleanup never breaks an
|
||||
// install.
|
||||
/**
|
||||
* Set the audit ignore list to `ghsas` (the complete desired list) in
|
||||
* whichever spelling the manifest uses — the canonical `audit.ignore` wins
|
||||
* over the deprecated `auditConfig.ignoreGhsas`, matching the reader's
|
||||
* precedence, so a stale canonical list can't shadow the update on the next
|
||||
* read. When both spellings are present, the shadowed deprecated list is
|
||||
* removed as part of the write. `auditConfig.ignoreGhsas` is created when
|
||||
* neither is present. An empty `ghsas` removes the list, dropping its parent
|
||||
* block when nothing else remains in it. Returns whether anything changed.
|
||||
*/
|
||||
function setAuditIgnoreGhsas (manifest: Partial<WorkspaceManifest>, ghsas: string[]): boolean {
|
||||
let changed = false
|
||||
if (manifest.audit?.ignore != null) {
|
||||
if (ghsas.length === 0) {
|
||||
delete manifest.audit.ignore
|
||||
if (Object.keys(manifest.audit).length === 0) {
|
||||
delete manifest.audit
|
||||
}
|
||||
changed = true
|
||||
} else if (!equals(manifest.audit.ignore, ghsas)) {
|
||||
manifest.audit.ignore = ghsas
|
||||
changed = true
|
||||
}
|
||||
if (manifest.auditConfig?.ignoreGhsas != null) {
|
||||
changed = removeAuditConfigIgnoreGhsas(manifest) || changed
|
||||
}
|
||||
return changed
|
||||
}
|
||||
if (ghsas.length === 0) {
|
||||
return removeAuditConfigIgnoreGhsas(manifest)
|
||||
}
|
||||
if (equals(manifest.auditConfig?.ignoreGhsas, ghsas)) {
|
||||
return false
|
||||
}
|
||||
manifest.auditConfig = { ...manifest.auditConfig, ignoreGhsas: ghsas }
|
||||
return true
|
||||
}
|
||||
|
||||
function removeAuditConfigIgnoreGhsas (manifest: Partial<WorkspaceManifest>): boolean {
|
||||
if (manifest.auditConfig?.ignoreGhsas == null) {
|
||||
return false
|
||||
}
|
||||
delete manifest.auditConfig.ignoreGhsas
|
||||
if (Object.keys(manifest.auditConfig).length === 0) {
|
||||
delete manifest.auditConfig
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function pruneMinimumReleaseAgeExcludes (
|
||||
manifest: Partial<WorkspaceManifest> & { minimumReleaseAgeExclude?: string[] },
|
||||
resolvedPackageVersions: ReadonlyMap<string, ReadonlySet<string>>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { expect, test } from '@jest/globals'
|
||||
import { WORKSPACE_MANIFEST_FILENAME } from '@pnpm/constants'
|
||||
import { tempDir } from '@pnpm/prepare-temp-dir'
|
||||
import { updateWorkspaceManifest } from '@pnpm/workspace.workspace-manifest-writer'
|
||||
import { readYamlFileSync } from 'read-yaml-file'
|
||||
import { writeYamlFileSync } from 'write-yaml-file'
|
||||
|
||||
test('write the list to the deprecated auditConfig.ignoreGhsas when that is where it lives', async () => {
|
||||
const dir = tempDir(false)
|
||||
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
|
||||
writeYamlFileSync(filePath, {
|
||||
auditConfig: { ignoreGhsas: ['GHSA-aaaa-bbbb-cccc'] },
|
||||
})
|
||||
await updateWorkspaceManifest(dir, {
|
||||
updatedAuditIgnoreGhsas: ['GHSA-dddd-eeee-ffff'],
|
||||
})
|
||||
expect(readYamlFileSync(filePath)).toStrictEqual({
|
||||
auditConfig: { ignoreGhsas: ['GHSA-dddd-eeee-ffff'] },
|
||||
})
|
||||
})
|
||||
|
||||
test('create auditConfig.ignoreGhsas when neither spelling is present', async () => {
|
||||
const dir = tempDir(false)
|
||||
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
|
||||
writeYamlFileSync(filePath, {
|
||||
packages: ['.'],
|
||||
})
|
||||
await updateWorkspaceManifest(dir, {
|
||||
updatedAuditIgnoreGhsas: ['GHSA-dddd-eeee-ffff'],
|
||||
})
|
||||
expect(readYamlFileSync(filePath)).toStrictEqual({
|
||||
packages: ['.'],
|
||||
auditConfig: { ignoreGhsas: ['GHSA-dddd-eeee-ffff'] },
|
||||
})
|
||||
})
|
||||
|
||||
test('write the list to the canonical audit.ignore when that is where it lives', async () => {
|
||||
const dir = tempDir(false)
|
||||
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
|
||||
writeYamlFileSync(filePath, {
|
||||
audit: { ignorePrune: true, ignore: ['GHSA-aaaa-bbbb-cccc'] },
|
||||
})
|
||||
await updateWorkspaceManifest(dir, {
|
||||
updatedAuditIgnoreGhsas: ['GHSA-dddd-eeee-ffff'],
|
||||
})
|
||||
expect(readYamlFileSync(filePath)).toStrictEqual({
|
||||
audit: { ignorePrune: true, ignore: ['GHSA-dddd-eeee-ffff'] },
|
||||
})
|
||||
})
|
||||
|
||||
test('remove the shadowed deprecated list when both spellings are present', async () => {
|
||||
const dir = tempDir(false)
|
||||
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
|
||||
writeYamlFileSync(filePath, {
|
||||
audit: { ignore: ['GHSA-aaaa-bbbb-cccc'] },
|
||||
auditConfig: { ignoreGhsas: ['GHSA-1111-2222-3333'] },
|
||||
})
|
||||
await updateWorkspaceManifest(dir, {
|
||||
updatedAuditIgnoreGhsas: ['GHSA-dddd-eeee-ffff'],
|
||||
})
|
||||
expect(readYamlFileSync(filePath)).toStrictEqual({
|
||||
audit: { ignore: ['GHSA-dddd-eeee-ffff'] },
|
||||
})
|
||||
})
|
||||
|
||||
test('an empty list removes audit.ignore and keeps its siblings', async () => {
|
||||
const dir = tempDir(false)
|
||||
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
|
||||
writeYamlFileSync(filePath, {
|
||||
audit: { ignorePrune: true, ignore: ['GHSA-aaaa-bbbb-cccc'] },
|
||||
})
|
||||
await updateWorkspaceManifest(dir, {
|
||||
updatedAuditIgnoreGhsas: [],
|
||||
})
|
||||
expect(readYamlFileSync(filePath)).toStrictEqual({
|
||||
audit: { ignorePrune: true },
|
||||
})
|
||||
})
|
||||
|
||||
test('an empty list removes the audit block when ignore is its only key', async () => {
|
||||
const dir = tempDir(false)
|
||||
const filePath = path.join(dir, WORKSPACE_MANIFEST_FILENAME)
|
||||
writeYamlFileSync(filePath, {
|
||||
packages: ['.'],
|
||||
audit: { ignore: ['GHSA-aaaa-bbbb-cccc'] },
|
||||
})
|
||||
await updateWorkspaceManifest(dir, {
|
||||
updatedAuditIgnoreGhsas: [],
|
||||
})
|
||||
expect(readYamlFileSync(filePath)).toStrictEqual({
|
||||
packages: ['.'],
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user