From 3f1c79f19b3782833762479fea218127d97e8e00 Mon Sep 17 00:00:00 2001 From: Kentaro Hayashi Date: Wed, 16 Sep 2026 01:48:49 +0000 Subject: [PATCH 1/4] entry_mutator: keep trusted fields when stripping underscores Journald reserves the leading underscore for trusted fields, which a client cannot forge, but a client is free to send a user field with the same name minus the underscore. With `fields_strip_underscores true`, both names turned into the same key. Before: the trusted `_SYSTEMD_UNIT` and a client supplied `SYSTEMD_UNIT` were joined into one `SYSTEMD_UNIT` value, so any local process could hide its own value inside trusted journal metadata. After: the trusted field keeps the stripped name and the user field of that name is dropped. A trusted field that `field_map` sends to another name does not reserve the stripped name, so nothing is lost there. Co-Authored-By: Claude Signed-off-by: Kentaro Hayashi --- README.md | 7 ++++ lib/fluent/plugin/systemd/entry_mutator.rb | 22 +++++++++- test/plugin/systemd/test_entry_mutator.rb | 47 ++++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0bee6ba..17c1319 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,13 @@ If true, lowercase all non-mapped fields. Defaults to false. If true, strip leading underscores from all non-mapped fields. Defaults to false. +Journald reserves the leading underscore for its trusted fields, which a client cannot forge, + while a client is free to send a user field with the same name minus the underscore. +Stripping makes both names collide, so the trusted field wins and the user field of that name is dropped. +For example, if an entry holds `_SYSTEMD_UNIT` and a client supplied `SYSTEMD_UNIT`, +the result only holds the trusted value. +Map the trusted field to another name with `field_map` if you need to keep both. + ### Filter Example Given a systemd journal source entry: diff --git a/lib/fluent/plugin/systemd/entry_mutator.rb b/lib/fluent/plugin/systemd/entry_mutator.rb index 45a01a7..b9ec9bf 100644 --- a/lib/fluent/plugin/systemd/entry_mutator.rb +++ b/lib/fluent/plugin/systemd/entry_mutator.rb @@ -103,13 +103,20 @@ def map_fields(entry) # mapped - Optional hash that represents a previously mapped entry to # which the formatted fields will be added def format_fields(entry, mapped = nil) + reserved = reserved_field_names(entry) entry.each_with_object(mapped || {}) do |(fld, val), formatted_entry| # don't mess with explicitly mapped fields next if @map_src_fields.include?(fld) - fld = format_field_name(fld) + name = format_field_name(fld) + # A client may send `SYSTEMD_UNIT` but never `_SYSTEMD_UNIT`, so the + # trusted field keeps the name when stripping underscores makes the + # two collide. Otherwise any local process could fake the journal + # metadata that the trusted fields are supposed to guarantee. + next if !fld.start_with?('_') && reserved.include?(name) + # account for mapping (appending) to an existing systemd field - formatted_entry[fld] = join_if_needed([val, mapped[fld]]) + formatted_entry[name] = join_if_needed([val, mapped[name]]) end end @@ -128,6 +135,17 @@ def join_if_needed(values) values.join(' ') end + def reserved_field_names(entry) + return [] unless @opts.fields_strip_underscores + + entry.each_with_object([]) do |(fld, _val), names| + next unless fld.start_with?('_') + next if @map_src_fields.include?(fld) + + names << format_field_name(fld) + end + end + def format_field_name(name) name = name.gsub(/\A_+/, '') if @opts.fields_strip_underscores name = name.downcase if @opts.fields_lowercase diff --git a/test/plugin/systemd/test_entry_mutator.rb b/test/plugin/systemd/test_entry_mutator.rb index 7a6b7fb..bb860ce 100644 --- a/test/plugin/systemd/test_entry_mutator.rb +++ b/test/plugin/systemd/test_entry_mutator.rb @@ -41,6 +41,15 @@ class EntryTestData # string json form of `FIELD_MAP` FIELD_MAP_JSON = JSON.generate(FIELD_MAP).freeze + # entry where a client sent user fields named after the trusted fields + SPOOFED_ENTRY = { + '_SYSTEMD_UNIT' => 'user@1000.service', + 'SYSTEMD_UNIT' => 'sshd.service', + '_PID' => '777', + 'PID' => '1', + 'MESSAGE' => 'Accepted publickey for root' + }.freeze + # expected entry mutation results EXPECTED = { no_transform: { @@ -175,6 +184,34 @@ class EntryMutatorTest < Test::Unit::TestCase ] } + # mutate test data for `SPOOFED_ENTRY`, same form as `@mutate_tests`. + # A mutator without options skips formatting, so the "no stripping" case + # turns on `fields_lowercase` to reach the same code with stripping off. + @spoofed_tests = { + trusted_field_wins: [ + { fields_strip_underscores: true }, + { 'SYSTEMD_UNIT' => 'user@1000.service', 'PID' => '777', 'MESSAGE' => 'Accepted publickey for root' } + ], + trusted_field_wins_lowercased: [ + { fields_strip_underscores: true, fields_lowercase: true }, + { 'systemd_unit' => 'user@1000.service', 'pid' => '777', 'message' => 'Accepted publickey for root' } + ], + user_fields_kept_without_stripping: [ + { fields_lowercase: true }, + { + '_systemd_unit' => 'user@1000.service', 'systemd_unit' => 'sshd.service', + '_pid' => '777', 'pid' => '1', 'message' => 'Accepted publickey for root' + } + ], + mapped_trusted_field_frees_the_name: [ + { field_map: { '_SYSTEMD_UNIT' => 'unit' }, fields_strip_underscores: true }, + { + 'unit' => 'user@1000.service', 'SYSTEMD_UNIT' => 'sshd.service', + 'PID' => '777', 'MESSAGE' => 'Accepted publickey for root' + } + ] + } + data(@validation_tests) def test_validation(opt) assert_raise Fluent::ConfigError do @@ -213,4 +250,14 @@ def test_mutate_with_hash_entry(data) mutated = m.run(EntryTestData::ENTRY.to_h) assert_equal(expected, mutated) end + + # tests using an entry with user fields named after the trusted fields + + data(@spoofed_tests) + def test_mutate_with_spoofed_entry(data) + options, expected = data + m = Fluent::Plugin::SystemdEntryMutator.new(**options) + mutated = m.run(EntryTestData::SPOOFED_ENTRY) + assert_equal(expected, mutated) + end end From a88fabb8cbffa2bbb15616f82eca22179f2ee680 Mon Sep 17 00:00:00 2001 From: Kentaro Hayashi Date: Thu, 17 Sep 2026 11:08:06 +0900 Subject: [PATCH 2/4] Update lib/fluent/plugin/systemd/entry_mutator.rb Co-authored-by: Shizuo Fujita Signed-off-by: Kentaro Hayashi --- lib/fluent/plugin/systemd/entry_mutator.rb | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/fluent/plugin/systemd/entry_mutator.rb b/lib/fluent/plugin/systemd/entry_mutator.rb index b9ec9bf..2ae5277 100644 --- a/lib/fluent/plugin/systemd/entry_mutator.rb +++ b/lib/fluent/plugin/systemd/entry_mutator.rb @@ -138,11 +138,19 @@ def join_if_needed(values) def reserved_field_names(entry) return [] unless @opts.fields_strip_underscores - entry.each_with_object([]) do |(fld, _val), names| - next unless fld.start_with?('_') - next if @map_src_fields.include?(fld) + trusted_field_names(entry).each_with_object([]) do |fld, names| + name = format_field_name(fld) + next if @map_src_fields.include?(fld) && !Array(@opts.field_map[fld]).include?(name) + + names << name + end + end - names << format_field_name(fld) + # Journald never lets a client send a leading underscore, so the known + # trusted names stay reserved even when this entry does not carry them. + def trusted_field_names(entry) + entry.each_with_object(TRUSTED_FIELDS.dup) do |(fld, _val), flds| + flds << fld if fld.start_with?('_') end end From e8aa7ff356bcbcefc6b59d327b1a259285d5635f Mon Sep 17 00:00:00 2001 From: Kentaro Hayashi Date: Thu, 17 Sep 2026 11:10:25 +0900 Subject: [PATCH 3/4] entry_mutator: add missing require Signed-off-by: Kentaro Hayashi --- lib/fluent/plugin/systemd/entry_mutator.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/fluent/plugin/systemd/entry_mutator.rb b/lib/fluent/plugin/systemd/entry_mutator.rb index 2ae5277..194dff2 100644 --- a/lib/fluent/plugin/systemd/entry_mutator.rb +++ b/lib/fluent/plugin/systemd/entry_mutator.rb @@ -15,6 +15,7 @@ # limitations under the License. require 'fluent/config/error' +require 'systemd/journal/fields' module Fluent module Plugin @@ -41,6 +42,8 @@ class SystemdEntryMutator :fields_strip_underscores ) + TRUSTED_FIELDS = (Systemd::Journal::TRUSTED_FIELDS + Systemd::Journal::KERNEL_FIELDS).freeze + def self.default_opts Options.new({}, false, false, false) end From f0ec906f254cfee6eb6bb3bb19006d7bb0cb930b Mon Sep 17 00:00:00 2001 From: Kentaro Hayashi Date: Thu, 17 Sep 2026 11:18:27 +0900 Subject: [PATCH 4/4] rubocop: suppress ClassLength warning Signed-off-by: Kentaro Hayashi --- lib/fluent/plugin/systemd/entry_mutator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/fluent/plugin/systemd/entry_mutator.rb b/lib/fluent/plugin/systemd/entry_mutator.rb index 194dff2..8688617 100644 --- a/lib/fluent/plugin/systemd/entry_mutator.rb +++ b/lib/fluent/plugin/systemd/entry_mutator.rb @@ -34,7 +34,7 @@ module Plugin # "" => ["", ""], # "" => [""] # } - class SystemdEntryMutator + class SystemdEntryMutator # rubocop:disable Metrics/ClassLength Options = Struct.new( :field_map, :field_map_strict,