From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 1DF091FF09D for ; Sun, 01 Nov 2026 22:40:12 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 56049217C3; Sun, 01 Nov 2026 22:38:55 +0100 (CET) From: Thomas Lamprecht To: pve-devel@lists.proxmox.com Subject: [PATCH common 03/10] schema: add helpers to redact sensitive values Date: Sun, 1 Nov 2026 22:34:52 +0100 Message-ID: <20261101213835.353604-4-t.lamprecht@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261101213835.353604-1-t.lamprecht@proxmox.com> References: <20261101213835.353604-1-t.lamprecht@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793569120952 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.409 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: HTOXSXLP2YIRZGR3QMGIAJJ7ZYJEO2IR X-Message-ID-Hash: HTOXSXLP2YIRZGR3QMGIAJJ7ZYJEO2IR X-MailFrom: t.lamprecht@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: The system report includes config files verbatim. Redacting them by parsing and writing them out again would drop or normalize invalid lines, which are often what support needs to see. So redaction works on the raw text of key/value config files and only replaces the values of properties marked as sensitive, in every section of the file. For property strings only the sensitive keys are replaced, the rest of the value stays readable. Empty values are kept, as they reveal nothing and help to spot configuration errors. The value helper also takes parsed values, including array references and unset values, so it can be reused for masking API output. Signed-off-by: Thomas Lamprecht --- src/PVE/JSONSchema.pm | 43 ++++++++++++ test/json-schema-test.pl | 148 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) diff --git a/src/PVE/JSONSchema.pm b/src/PVE/JSONSchema.pm index e4225df..12ef6c2 100644 --- a/src/PVE/JSONSchema.pm +++ b/src/PVE/JSONSchema.pm @@ -2656,6 +2656,49 @@ sub dump_config { return $data; } +sub redact_sensitive_value($schema, $value, $placeholder) { + return $value if !defined($value); + return [map { redact_sensitive_value($schema, $_, $placeholder) } $value->@*] + if ref($value) eq 'ARRAY'; + return $value if $value !~ m/\S/; + return $placeholder if $schema->{sensitive}; + return redact_sensitive_value($schema->{items}, $value, $placeholder) if $schema->{items}; + + my $format = $schema->{format}; + $format = get_format($format) if defined($format) && !ref($format); + return $value if ref($format) ne 'HASH'; + + my ($default_key) = grep { $format->{$_}->{default_key} } keys $format->%*; + my $key_schema = sub($key) { + my $key_schema = $format->{$key} // return; + return defined($key_schema->{alias}) ? $format->{ $key_schema->{alias} } : $key_schema; + }; + + my @parts = split(/,/, $value, -1); + for my $part (@parts) { + if (my ($key, $key_value) = $part =~ m/^([^=]+)=(.*)$/) { + my $schema = $key_schema->($key) or next; + $part = "$key=" . redact_sensitive_value($schema, $key_value, $placeholder); + } elsif (defined($default_key)) { + $part = redact_sensitive_value($format->{$default_key}, $part, $placeholder); + } + } + return join(',', @parts); +} + +sub redact_raw_config($schema, $raw, $placeholder) { + my $props = $schema->{properties} or die "got strange schema\n"; + + my @lines = split(/\n/, $raw, -1); + for my $line (@lines) { + my ($prefix, $key, $value) = $line =~ m/^(\s*(\S+?):\s*)(.*)$/ or next; + my $prop = $props->{$key} or next; + $prop = $props->{ $prop->{alias} } // next if defined($prop->{alias}); + $line = $prefix . redact_sensitive_value($prop, $value, $placeholder); + } + return join("\n", @lines); +} + # helpers used to generate our manual pages my $find_schema_default_key = sub { diff --git a/test/json-schema-test.pl b/test/json-schema-test.pl index f1c968f..28c8efd 100755 --- a/test/json-schema-test.pl +++ b/test/json-schema-test.pl @@ -863,4 +863,152 @@ for my $test ($check_one_of->@*) { }; } +PVE::JSONSchema::register_format( + 'test-redact', + { + name => { type => 'string', default_key => 1, optional => 1 }, + secret => { type => 'string', sensitive => 1, optional => 1 }, + 'old-secret' => { alias => 'secret' }, + }, +); + +my $redact_value_tests = [ + { + name => 'sensitive value', + schema => { type => 'string', sensitive => 1 }, + in => 'hunter2', + out => '***', + }, + { + name => 'other value', + schema => { type => 'string' }, + in => 'visible', + out => 'visible', + }, + { + name => 'sensitive sub-key', + schema => { type => 'string', format => 'test-redact' }, + in => 'name=foo,secret=bar', + out => 'name=foo,secret=***', + }, + { + name => 'sensitive sub-key via alias', + schema => { type => 'string', format => 'test-redact' }, + in => 'foo,old-secret=bar', + out => 'foo,old-secret=***', + }, + { + name => 'sensitive default key', + schema => { + type => 'string', + format => { secret => { type => 'string', default_key => 1, sensitive => 1 } }, + }, + in => 'bar,,', + out => '***,,', + }, + { + name => 'format without sensitive sub-keys', + schema => { type => 'string', format => { name => { type => 'string' } } }, + in => 'not=a,valid', + out => 'not=a,valid', + }, + { + name => 'empty sensitive value', + schema => { type => 'string', sensitive => 1 }, + in => '', + out => '', + }, + { + name => 'nested format', + schema => { + type => 'string', + format => { + inner => { type => 'string', format => 'test-redact' }, + other => { type => 'string' }, + }, + }, + in => 'other=x,inner=secret=bar', + out => 'other=x,inner=secret=***', + }, + { + name => 'unset value', + schema => { type => 'string', sensitive => 1 }, + in => undef, + out => undef, + }, + { + name => 'array item', + schema => { type => 'array', items => { type => 'string', format => 'test-redact' } }, + in => 'foo,secret=bar', + out => 'foo,secret=***', + }, + { + name => 'array of items', + schema => { type => 'array', items => { type => 'string', format => 'test-redact' } }, + in => ['foo,secret=bar', 'baz'], + out => ['foo,secret=***', 'baz'], + }, + { + name => 'sensitive array', + schema => { type => 'array', sensitive => 1, items => { type => 'string' } }, + in => ['foo', 'bar'], + out => ['***', '***'], + }, +]; + +for my $test ($redact_value_tests->@*) { + my $res = PVE::JSONSchema::redact_sensitive_value($test->{schema}, $test->{in}, '***'); + is_deeply($res, $test->{out}, "redact $test->{name}"); +} + +my $redact_config_schema = { + type => 'object', + properties => { + password => { type => 'string', sensitive => 1 }, + 'old-password' => { type => 'string', alias => 'password' }, + options => { type => 'string', format => 'test-redact' }, + name => { type => 'string' }, + tokens => { type => 'array', items => { type => 'string', sensitive => 1 } }, + }, +}; +my $redact_config_in = <<'EOF'; +#password: in a comment +password: hunter2 +old-password: aliased +options: foo,secret=bar +name: visible +tokens: first +tokens: second +invalid line + +[snapshot] + password: older +[special:cloudinit] +password: applied +password: +EOF +my $redact_config_out = <<'EOF'; +#password: in a comment +password: *** +old-password: *** +options: foo,secret=*** +name: visible +tokens: *** +tokens: *** +invalid line + +[snapshot] + password: *** +[special:cloudinit] +password: *** +password: +EOF +is( + PVE::JSONSchema::redact_raw_config($redact_config_schema, $redact_config_in, '***'), + $redact_config_out, + 'redact key/value config', +); +eval { PVE::JSONSchema::redact_raw_config({ type => 'object' }, $redact_config_in, '***') }; +like($@, qr/strange schema/, 'redact key/value config fails without properties'); + done_testing(); -- 2.47.3