|
| 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