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:
Nicolas Le CamandZoltan Kochan authored and GitHub committed 2026-08-25 14:39:20 +02:00
1 parent e630a0492a
commit fb0f338cdf
29 files changed
+4739 -43

No files matched your search

+9
View File
@@ -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.
+3
View File
@@ -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
+44 -2
View File
@@ -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.
+38
View File
@@ -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`.
+276
View File
@@ -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,
&registry.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,
&registry.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,
&registry.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,
&registry.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,
&registry.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,
&registry.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,
&registry.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,
&registry.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,
&registry.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,
+1
View File
@@ -56,6 +56,7 @@ const TYPED_WORKSPACE_MANIFEST_KEYS: &[&str] = &[
/// fields.
const CONFIG_ONLY_SETTING_KEYS: &[&str] = &[
"allowNew",
"auditIgnorePrune",
"authConfig",
"autoConfirmAllPrompts",
"bin",
+5
View File
@@ -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)
}
+10
View File
@@ -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' },
+2
View File
@@ -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,
})
+7
View File
@@ -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: [],
@@ -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"
}
}
@@ -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: ['.'],
})
})