Skip to content

Commit a4b9eda

Browse files
committed
[cleaner] Add user-defined regexp patterns matching
Add Regexp prepper/parser/mapping to allow users to declare own patterns with sensitive data to clean. The feature is enabled by presence of /etc/sos/cleaner/regexp_patterns.conf file. Man pages updated, avocado tests added. Resolves: #4394 Closes: #4404 Assisted-by: Claude (Anthropic AI) Signed-off-by: Pavel Moravec <pmoravec@redhat.com>
1 parent 045bb37 commit a4b9eda

13 files changed

Lines changed: 733 additions & 6 deletions

File tree

man/en/sos-clean.1

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ sos_clean, sos_mask \- Obfuscate sensitive data from one or more sos reports
88
[\-\-skip-cleaning-files|\-\-skip-masking-files]
99
[\-\-keywords]
1010
[\-\-keyword-file]
11+
[\-\-regexp-file]
1112
[\-\-map-file]
1213
[\-\-jobs]
1314
[\-\-no-update]
@@ -62,7 +63,7 @@ the target archive, so only use this option when absolutely necessary or you hav
6263
trust in the party/parties that may handle the generated report.
6364

6465
Valid values for this option are currently: \fBhostname\fR, \fBip\fR, \fBipv6\fR,
65-
\fBmac\fR, \fBkeyword\fR, and \fBusername\fR.
66+
\fBmac\fR, \fBkeyword\fR, \fBusername\fR, and \fBregexp\fR.
6667
.TP
6768
.B \-\-skip-cleaning-files, \-\-skip-masking-files FILES
6869
Provide a comma-delimited list of files inside an archive, that cleaner should skip in cleaning.
@@ -84,6 +85,32 @@ both standalone words and in substring matches.
8485
Provide a file that contains a list of keywords that should be obfuscated. Each word must
8586
be specified on a newline within the file.
8687
.TP
88+
.B \-\-regexp-file FILE
89+
Provide a file containing custom regular expression patterns for obfuscating sensitive data
90+
that is not covered by the built-in parsers. Each line in the file should follow the format:
91+
92+
\fBkeyword pattern\fR
93+
94+
Where \fBkeyword\fR is a lowercase alphanumeric identifier (ending with a letter, not a digit)
95+
and \fBpattern\fR is a Python regular expression containing exactly one capturing group () that
96+
marks the sensitive data to obfuscate. Use non-capturing groups (?:...) for additional matching
97+
logic.
98+
99+
Example pattern file entries:
100+
101+
shorthost host=([^,\s]+)(?:,|$)
102+
apikey api(?:_key|key)=([a-zA-Z0-9]+)
103+
104+
The first example matches "host=foobar," and obfuscates "foobar" as "obfuscatedshorthost0".
105+
The second matches both "api_key=" and "apikey=" and obfuscates the value.
106+
107+
Keywords cannot be reserved names (host, hostname, domain, ip, ipv6, mac, word, keyword,
108+
user, username) and must not end with digits to avoid ambiguity.
109+
110+
Lines starting with '#' are treated as comments and are ignored.
111+
112+
Default: /etc/sos/cleaner/regexp_patterns.conf
113+
.TP
87114
.B \-\-map-file FILE
88115
Provide a location to a valid mapping file to use as a reference for existing obfuscation pairs.
89116
If one is found, the contents are loaded before parsing is started. This allows consistency between

sos/cleaner/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from sos.cleaner.parsers.keyword_parser import SoSKeywordParser
3131
from sos.cleaner.parsers.username_parser import SoSUsernameParser
3232
from sos.cleaner.parsers.ipv6_parser import SoSIPv6Parser
33+
from sos.cleaner.parsers.regexp_parser import SoSRegexpParser
3334
from sos.cleaner.archives.sos import (SoSReportArchive, SoSReportDirectory,
3435
SoSCollectorArchive,
3536
SoSCollectorDirectory)
@@ -122,6 +123,7 @@ class SoSCleaner(SoSComponent):
122123
'map_file': '/etc/sos/cleaner/default_mapping',
123124
'no_update': False,
124125
'keep_binary_files': False,
126+
'regexp_file': '/etc/sos/cleaner/regexp_patterns.conf',
125127
'target': '',
126128
'usernames': [],
127129
'treat_certificates': 'obfuscate'
@@ -177,6 +179,7 @@ def __init__(self, parser=None, args=None, cmdline=None, in_place=False,
177179
SoSMacParser,
178180
SoSKeywordParser,
179181
SoSUsernameParser,
182+
SoSRegexpParser,
180183
]
181184
parser_names = [
182185
cls.__name__ for cls in parser_classes
@@ -328,6 +331,12 @@ def add_parser_options(cls, parser):
328331
clean_grp.add_argument('--keyword-file', default=None,
329332
dest='keyword_file',
330333
help='Provide a file a keywords to obfuscate')
334+
clean_grp.add_argument(
335+
'--regexp-file',
336+
default='/etc/sos/cleaner/regexp_patterns.conf',
337+
dest='regexp_file',
338+
help='Provide a file of regular expressions to obfuscate'
339+
)
331340
clean_grp.add_argument('--map-file', dest='map_file',
332341
default='/etc/sos/cleaner/default_mapping',
333342
help=('Provide a previously generated mapping '
@@ -737,6 +746,8 @@ def _prepare_archive_with_prepper(self, archive, prepper):
737746
_parser.mapping.add_regex_item(ritem)
738747
_parser.mapping.initializing = False
739748
_parser.mapping.generate_compiled_regexes()
749+
# Allow prepper to configure its parser after initialization
750+
prepper.set_parser(_parser)
740751
# we must initialize stuff inside (cloned processes') archive - REALLY?
741752
archive.set_parsers(self.parsers)
742753

sos/cleaner/mappings/__init__.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,19 @@ def add_sanitised_item_to_dataset(self, item):
9898
if self.compile_regexes:
9999
self.add_regex_item(item)
100100

101+
def _read_item_from_cache_file(self, fname):
102+
"""Read an item from a cache file.
103+
104+
Default format: plain text file containing only the item itself.
105+
106+
Override this method in subclasses that use a different cache format.
107+
108+
:param fname: Full path to the cache file
109+
:returns: The item to add to the dataset
110+
"""
111+
with open(fname, 'r', encoding='utf-8') as f:
112+
return f.read()
113+
101114
def load_new_entries_from_dir(self):
102115
# Load all new items from the cache_dir. "New" = any numbered file not
103116
# lower than self.cache_counter.
@@ -112,13 +125,25 @@ def load_new_entries_from_dir(self):
112125
num_files.sort(key=int)
113126
for file_name in num_files:
114127
fname = os.path.join(self.cache_dir, file_name)
115-
with open(fname, 'r', encoding='utf-8') as f:
116-
item = f.read()
117-
if not self.dataset.get(item, False):
128+
item = self._read_item_from_cache_file(fname)
129+
if item and not self.dataset.get(item, False):
118130
self.add_sanitised_item_to_dataset(item)
119131
if num_files:
120132
self.cache_counter = int(num_files[-1]) # last/biggest number
121133

134+
def _write_item_to_cache_file(self, item, tmpfile):
135+
"""Write an item to a cache file.
136+
137+
Default format: plain text file containing only the item itself.
138+
139+
Override this method in subclasses that use a different cache format.
140+
141+
:param item: The item to write to the cache
142+
:param tmpfile: The NamedTemporaryFile to write to
143+
"""
144+
with open(tmpfile.name, 'w', encoding='utf-8') as f:
145+
f.write(item)
146+
122147
def add(self, item):
123148
"""Add a particular item to the map, generating an obfuscated pair
124149
for it.
@@ -135,8 +160,7 @@ def add(self, item):
135160
if not tmpfile:
136161
# pylint: disable=consider-using-with
137162
tmpfile = tempfile.NamedTemporaryFile(dir=self.cache_dir)
138-
with open(tmpfile.name, 'w', encoding='utf-8') as f:
139-
f.write(item)
163+
self._write_item_to_cache_file(item, tmpfile)
140164
try:
141165
self.cache_counter += 1
142166
os.link(tmpfile.name,

sos/cleaner/mappings/regexp_map.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
# Copyright 2026 Red Hat, Inc. Pavel Moravec <pmoravec@redhat.com>
2+
3+
# This file is part of the sos project: https://github.com/sosreport/sos
4+
#
5+
# This copyrighted material is made available to anyone wishing to use,
6+
# modify, copy, or redistribute it subject to the terms and conditions of
7+
# version 2 of the GNU General Public License.
8+
#
9+
# See the LICENSE file in the source distribution for further information.
10+
11+
import json
12+
import logging
13+
import re
14+
from sos.cleaner.mappings import SoSMap
15+
16+
17+
class SoSRegexpMap(SoSMap):
18+
"""Runtime obfuscation mapping for user-defined regex patterns.
19+
20+
The class maintains per-keyword counters, generates obfuscated
21+
values, persists state. See RegexpPrepper for pattern
22+
loading/validation.
23+
24+
Does not define patterns - relies on RegexpPrepper loading from
25+
--regexp-file. Parser finds matches, this map generates
26+
obfuscation.
27+
28+
Obfuscation: Each keyword has independent counter starting at 0.
29+
Example:
30+
Pattern: shorthost host=([^,\\s]+)(?:,|$)
31+
Input: "host=foobar,os=rhel9"
32+
Output: "host=obfuscatedshorthost0,os=rhel9"
33+
Next: "host=obfuscatedshorthost1,..."
34+
35+
Persistence:
36+
- Map file: {"foobar": "obfuscatedshorthost5"} → counter
37+
restored to 6
38+
- Cache files (multi-process): ["keyword", "item"] JSON arrays,
39+
DIFFERENT from other Map classes.
40+
41+
Keywords cannot end with digits to avoid ambiguity parsing
42+
obfuscated values: for keyword "api2", "obfuscatedapi25" can be
43+
interpreted as either:
44+
- keyword="api2", counter=5, or
45+
- keyword="api", counter=25
46+
"""
47+
48+
# Regexp patterns define their own matching boundaries, so we don't
49+
# need word boundaries or token lookup.
50+
match_full_words_only = False
51+
use_token_lookup = False
52+
compile_regexes = False # Prepper does its own compile
53+
54+
# Pattern to parse obfuscated values: obfuscated + keyword + counter
55+
# Greedy [a-z0-9]* stops at last letter before trailing digits
56+
# Examples: obfuscatedshorthost10 → keyword="shorthost", counter=10
57+
# obfuscatedapi2key5 → keyword="api2key", counter=5
58+
_OBFUSCATED_PATTERN = re.compile(
59+
r'^obfuscated([a-z0-9]*[a-z])(\d+)$'
60+
)
61+
62+
def __init__(self, workdir, _static_regex=None):
63+
# Initialize these BEFORE calling super().__init__() because the
64+
# parent will call load_entries() -> load_new_entries_from_dir()
65+
# which needs them
66+
# Map of keyword -> counter for each keyword type
67+
self.keyword_counts = {}
68+
# Map of item -> keyword (so we know which keyword matched)
69+
self.item_keywords = {}
70+
# Logger for warning/error messages
71+
self.soslog = logging.getLogger('sos')
72+
73+
super().__init__(workdir, _static_regex)
74+
75+
# Initialize keywords' counters from any pre-loaded dataset
76+
self._initialize_counters_from_dataset()
77+
78+
def _initialize_counters_from_dataset(self):
79+
"""Initialize keyword counters from pre-loaded dataset.
80+
81+
If dataset was loaded from a previous run (via --map-file or cache
82+
dir), we need to initialize counters to avoid generating duplicate
83+
obfuscated values.
84+
85+
Parses existing obfuscated values like "obfuscatedshorthost5" to
86+
extract the keyword and counter, then sets each keyword's counter
87+
to max(existing) + 1.
88+
89+
This method RESETS keyword_counts from scratch based on dataset,
90+
so it can be called multiple times (e.g., after conf_update).
91+
"""
92+
# Reset counters - we'll rebuild from dataset
93+
self.keyword_counts = {}
94+
95+
for obfuscated_value in self.dataset.values():
96+
match = self._OBFUSCATED_PATTERN.match(obfuscated_value)
97+
if match:
98+
keyword = match.group(1)
99+
counter = int(match.group(2))
100+
# Set counter to max(current, this_counter + 1) for next
101+
# available value
102+
next_value = self.keyword_counts.get(keyword, 0)
103+
self.keyword_counts[keyword] = max(next_value, counter + 1)
104+
else:
105+
# Malformed obfuscated value - log warning
106+
self.soslog.warning(
107+
f"Cannot extract keyword from obfuscated value "
108+
f"'{obfuscated_value}' - skipping its counter "
109+
f"initialization"
110+
)
111+
112+
def conf_update(self, config):
113+
"""Override to extract keyword-to-item associations from map file.
114+
115+
When loading from a previous run's map file, we need to restore
116+
the item→keyword associations so that load_new_entries_from_dir()
117+
can properly re-sanitize items from cache.
118+
119+
Parses obfuscated values like "obfuscatedshorthost5" to extract
120+
the keyword, then associates it with the original item.
121+
"""
122+
for item, obfuscated_value in config.items():
123+
match = self._OBFUSCATED_PATTERN.match(obfuscated_value)
124+
if match:
125+
keyword = match.group(1)
126+
self.item_keywords[item] = keyword
127+
else:
128+
# Malformed obfuscated value - log warning
129+
self.soslog.warning(
130+
f"Cannot extract keyword from obfuscated value "
131+
f"'{obfuscated_value}' for item '{item}'"
132+
)
133+
134+
# Call parent to update dataset
135+
super().conf_update(config)
136+
137+
# Re-initialize counters from the updated dataset to ensure
138+
# keyword_counts reflects all items from the map file
139+
self._initialize_counters_from_dataset()
140+
141+
def _read_item_from_cache_file(self, fname):
142+
"""Read item and keyword from JSON cache file.
143+
144+
Cache files for regexp map are JSON arrays with format:
145+
["keyword", "item"]
146+
147+
Example: ["myshorthost", "foobar"]
148+
149+
This preserves the keyword association needed for proper obfuscation.
150+
151+
:param fname: Full path to the cache file
152+
:returns: The item to add to the dataset, or None to skip
153+
"""
154+
try:
155+
with open(fname, 'r', encoding='utf-8') as f:
156+
data = json.load(f)
157+
if isinstance(data, list) and len(data) == 2:
158+
keyword, item = data
159+
# Restore the keyword association
160+
self.item_keywords[item] = keyword
161+
return item
162+
163+
self.soslog.warning(
164+
f"Cache file {fname} has invalid format, "
165+
f"expected [keyword, item] array, skipping"
166+
)
167+
return None
168+
except json.JSONDecodeError:
169+
self.soslog.warning(
170+
f"Cache file {fname} contains invalid JSON, skipping"
171+
)
172+
return None
173+
174+
def _write_item_to_cache_file(self, item, tmpfile):
175+
"""Write item and keyword as JSON array to cache file.
176+
177+
Format: [keyword, item]
178+
Example: ["myshorthost", "foobar"]
179+
180+
:param item: The item to write
181+
:param tmpfile: The temporary file to write to
182+
"""
183+
# Get the keyword for this item - must exist, set by parser
184+
keyword = self.item_keywords[item]
185+
186+
# Write JSON array: [keyword, item]
187+
with open(tmpfile.name, 'w', encoding='utf-8') as f:
188+
json.dump([keyword, item], f)
189+
190+
def set_keyword_for_item(self, item, keyword):
191+
"""Associate an item with its keyword for obfuscation.
192+
193+
This should be called when adding items to the map, so that
194+
sanitize_item knows which keyword pattern matched.
195+
"""
196+
self.item_keywords[item] = keyword
197+
198+
def sanitize_item(self, item):
199+
if item in self.dataset:
200+
return self.dataset[item]
201+
202+
# Get the keyword for this item
203+
# This should always be present - if not, it's a bug in the parser
204+
keyword = self.item_keywords.get(item, None)
205+
if keyword is None:
206+
# This should never happen - log error and use fallback
207+
self.soslog.error(
208+
f"Regexp item '{item}' has no associated keyword. "
209+
f"This indicates a bug in the regexp parser. "
210+
f"Using fallback name 'unknown'."
211+
)
212+
keyword = 'unknown'
213+
214+
# Get and increment counter for this keyword
215+
count = self.keyword_counts.get(keyword, 0)
216+
_ob_item = f"obfuscated{keyword}{count}"
217+
self.keyword_counts[keyword] = count + 1
218+
219+
return _ob_item

0 commit comments

Comments
 (0)