From ca52968626bb8cf7d538cdd765a724e131cfe6dd Mon Sep 17 00:00:00 2001 From: vesp Date: Tue, 13 Jan 2026 11:27:05 +0100 Subject: [PATCH] Add sensitive variable redaction in history --- src/models.py | 7 ++- src/variable_substitution.py | 115 +++++++++++++++++++++++++++++++++++ src/widgets/history-item.ui | 13 ++++ src/widgets/history_item.py | 5 ++ src/window.py | 35 ++++++++++- 5 files changed, 170 insertions(+), 5 deletions(-) diff --git a/src/models.py b/src/models.py index 75fa0c5..e79cbb6 100644 --- a/src/models.py +++ b/src/models.py @@ -80,6 +80,7 @@ class HistoryEntry: response: Optional[HttpResponse] error: Optional[str] # Error message if request failed id: str = None # Unique identifier for the entry + has_redacted_variables: bool = False # True if sensitive variables were redacted def __post_init__(self): """Generate UUID if id not provided.""" @@ -94,7 +95,8 @@ class HistoryEntry: 'timestamp': self.timestamp, 'request': self.request.to_dict(), 'response': self.response.to_dict() if self.response else None, - 'error': self.error + 'error': self.error, + 'has_redacted_variables': self.has_redacted_variables } @classmethod @@ -105,7 +107,8 @@ class HistoryEntry: timestamp=data['timestamp'], request=HttpRequest.from_dict(data['request']), response=HttpResponse.from_dict(data['response']) if data.get('response') else None, - error=data.get('error') + error=data.get('error'), + has_redacted_variables=data.get('has_redacted_variables', False) ) diff --git a/src/variable_substitution.py b/src/variable_substitution.py index 16194b3..a5b84e2 100644 --- a/src/variable_substitution.py +++ b/src/variable_substitution.py @@ -127,3 +127,118 @@ class VariableSubstitution: ) return new_request, all_undefined + + @staticmethod + def substitute_excluding_variables(text: str, variables: Dict[str, str], excluded_vars: List[str]) -> Tuple[str, List[str]]: + """ + Replace {{variable_name}} with values, but skip excluded variables (leave as {{var}}). + + Args: + text: The text containing variable placeholders + variables: Dictionary mapping variable names to their values + excluded_vars: List of variable names to skip (leave as placeholders) + + Returns: + Tuple of (substituted_text, list_of_undefined_variables) + """ + if not text: + return text, [] + + undefined_vars = [] + + def replace_var(match): + var_name = match.group(1) + + # Skip excluded variables - leave as {{var_name}} + if var_name in excluded_vars: + return match.group(0) # Return original {{var_name}} + + # Substitute non-excluded variables + if var_name in variables: + value = variables[var_name] + # Check if value is empty/None - treat as undefined for warning purposes + if not value: + undefined_vars.append(var_name) + return "" + return value + else: + # Variable not defined - track it and replace with empty string + undefined_vars.append(var_name) + return "" + + substituted = VariableSubstitution.VARIABLE_PATTERN.sub(replace_var, text) + return substituted, undefined_vars + + @staticmethod + def substitute_request_excluding_sensitive( + request: HttpRequest, + environment: Environment, + sensitive_variables: List[str] + ) -> Tuple[HttpRequest, Set[str], bool]: + """ + Substitute variables in request, but exclude sensitive variables (leave as {{var}}). + + This is useful for saving requests to history without exposing sensitive values. + Sensitive variables remain in their {{variable_name}} placeholder form. + + Args: + request: The HTTP request with variable placeholders + environment: The environment containing variable values + sensitive_variables: List of variable names to exclude from substitution + + Returns: + Tuple of (redacted_request, set_of_undefined_variables, has_sensitive_vars) + - redacted_request: Request with only non-sensitive variables substituted + - set_of_undefined_variables: Variables that were referenced but not defined + - has_sensitive_vars: True if any sensitive variables were found and redacted + """ + all_undefined = set() + + # Substitute URL (excluding sensitive variables) + new_url, url_undefined = VariableSubstitution.substitute_excluding_variables( + request.url, environment.variables, sensitive_variables + ) + all_undefined.update(url_undefined) + + # Substitute headers (both keys and values, excluding sensitive variables) + new_headers = {} + for key, value in request.headers.items(): + new_key, key_undefined = VariableSubstitution.substitute_excluding_variables( + key, environment.variables, sensitive_variables + ) + new_value, value_undefined = VariableSubstitution.substitute_excluding_variables( + value, environment.variables, sensitive_variables + ) + all_undefined.update(key_undefined) + all_undefined.update(value_undefined) + new_headers[new_key] = new_value + + # Substitute body (excluding sensitive variables) + new_body, body_undefined = VariableSubstitution.substitute_excluding_variables( + request.body, environment.variables, sensitive_variables + ) + all_undefined.update(body_undefined) + + # Create new HttpRequest with redacted values + redacted_request = HttpRequest( + method=request.method, + url=new_url, + headers=new_headers, + body=new_body, + syntax=request.syntax + ) + + # Check if any sensitive variables were actually used in the request + all_vars_in_request = set() + all_vars_in_request.update(VariableSubstitution.find_variables(request.url)) + all_vars_in_request.update(VariableSubstitution.find_variables(request.body)) + for key, value in request.headers.items(): + all_vars_in_request.update(VariableSubstitution.find_variables(key)) + all_vars_in_request.update(VariableSubstitution.find_variables(value)) + + # Determine if any sensitive variables were actually present + has_sensitive_vars = bool( + set(sensitive_variables) & all_vars_in_request + ) + + return redacted_request, all_undefined, has_sensitive_vars diff --git a/src/widgets/history-item.ui b/src/widgets/history-item.ui index de60bd2..d7e9e4f 100644 --- a/src/widgets/history-item.ui +++ b/src/widgets/history-item.ui @@ -62,6 +62,19 @@ + + + + dialog-information-symbolic + Sensitive variables were redacted from this history entry + center + False + + + + diff --git a/src/widgets/history_item.py b/src/widgets/history_item.py index 77c521a..6befc62 100644 --- a/src/widgets/history_item.py +++ b/src/widgets/history_item.py @@ -34,6 +34,7 @@ class HistoryItem(Gtk.Box): url_label = Gtk.Template.Child() timestamp_label = Gtk.Template.Child() status_label = Gtk.Template.Child() + redaction_indicator = Gtk.Template.Child() delete_button = Gtk.Template.Child() request_headers_label = Gtk.Template.Child() request_body_scroll = Gtk.Template.Child() @@ -77,6 +78,10 @@ class HistoryItem(Gtk.Box): self.timestamp_label.set_text(timestamp_str) + # Show redaction indicator if sensitive variables were redacted + if self.entry.has_redacted_variables: + self.redaction_indicator.set_visible(True) + # Status if self.entry.response: status_text = f"{self.entry.response.status_code} {self.entry.response.status_text}" diff --git a/src/window.py b/src/window.py index 7bda58d..8c34013 100644 --- a/src/window.py +++ b/src/window.py @@ -569,12 +569,41 @@ class RosterWindow(Adw.ApplicationWindow): if tab: tab.response = response - # Create history entry (save the substituted request with actual values) + # Create history entry with redacted sensitive variables + # Determine which request to save to history + request_for_history = modified_request + has_redacted = False + + if widget.selected_environment_id: + env = widget.get_selected_environment() + if env and widget.project_id: + # Get the project's sensitive variables list + projects = self.project_manager.load_projects() + sensitive_vars = [] + for p in projects: + if p.id == widget.project_id: + sensitive_vars = p.sensitive_variables + break + + # Create redacted request (only non-sensitive variables substituted) + if sensitive_vars: + from .variable_substitution import VariableSubstitution + request_for_history, _, has_redacted = VariableSubstitution.substitute_request_excluding_sensitive( + modified_request, env, sensitive_vars + ) + else: + # No sensitive variables defined, use fully substituted request + request_for_history = substituted_request + elif env: + # Environment selected but no project - use fully substituted request + request_for_history = substituted_request + entry = HistoryEntry( timestamp=datetime.now().isoformat(), - request=substituted_request, + request=request_for_history, response=response, - error=error + error=error, + has_redacted_variables=has_redacted ) self.history_manager.add_entry(entry)