Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Quality of Life features to assume role policies. Added Find and Replace, Undo & Redo, and line wrapping for the Session Policy text field. #48

Merged
merged 8 commits into from
Jan 10, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JRadioButtonMenuItem;
import java.util.regex.PatternSyntaxException;

/**
* The complex class which enforces logic and syncs up the configuration model
Expand All @@ -54,6 +55,8 @@ public class AWSSignerController {
private final BurpTabPanel view;
private final AWSSignerConfiguration model;

//Need this for regex match and replace
private RegexHandler regexHandler;
private final static String INIT_PROFILE_NAME = "Profile 1";

//Need these because we have to add and remove when setting up / updating combo boxes
Expand All @@ -63,6 +66,8 @@ public class AWSSignerController {
public AWSSignerController(BurpTabPanel view, AWSSignerConfiguration model) {
this.view = view;
this.model = model;
this.regexHandler = new RegexHandler(view.assumeRoleSessionPolicyTextArea);
UndoRedoManager.addUndoRedoFunctionality(view.assumeRoleSessionPolicyTextArea);

initListeners();

Expand Down Expand Up @@ -383,6 +388,29 @@ private void initListeners() {

}));

// AssumeRole Session Policy Find Button
view.assumeRoleSessionPolicyFindButton.addActionListener(e -> {
String regex = view.assumeRoleSessionPolicyRegexField.getText().trim();
try {
regexHandler.findAndHighlightNext(regex); // Highlight matches and cycle
} catch (PatternSyntaxException ex) {
logError("Invalid regex: " + ex.getMessage());
}
});

// AssumeRole Session Policy Replace Button
view.assumeRoleSessionPolicyReplaceButton.addActionListener(e -> {
String replacement = view.assumeRoleSessionPolicyReplacementField.getText();
regexHandler.replaceCurrentMatch(replacement); // Replace the current match
});

// AssumeRole Session Policy Replace All Button
view.assumeRoleSessionPolicyReplaceAllButton.addActionListener(e -> {
String replacement = view.assumeRoleSessionPolicyReplacementField.getText();
regexHandler.replaceAllMatches(replacement); // Replace all matches
});


//Command Duration text field
view.commandCommandTextField.addFocusListener(new TextComponentFocusListener<>(this, "Command Command", CommandProfile::setCommand));

Expand Down
208 changes: 208 additions & 0 deletions src/main/java/com/netspi/awssigner/controller/RegexHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package com.netspi.awssigner.controller;

import javax.swing.*;
import javax.swing.text.*;
import java.awt.*;
import java.util.*;
import java.util.regex.*;
import static com.netspi.awssigner.log.LogWriter.logError;

/**
* Handles regex-based search, highlighting, and replacement for a JTextArea.
*/
public class RegexHandler {

private final JTextArea textArea;
private final Highlighter highlighter;
private final java.util.List<int[]> matchPositions;
private int currentMatchIndex = -1;
private String currentRegex; // Store the last used regex pattern

public RegexHandler(JTextArea textArea) {
this.textArea = textArea;
this.highlighter = textArea.getHighlighter();
this.matchPositions = new ArrayList<>();
this.currentRegex = null;
}

public void findAndHighlightNext(String regex) throws PatternSyntaxException {
if (currentRegex == null || !currentRegex.equals(regex)) {
// Perform a new search if the regex changes
findAllMatches(regex);
currentMatchIndex = 0; // Start with the first match
} else if (!matchPositions.isEmpty()) {
// Cycle to the next match if there are matches
currentMatchIndex = (currentMatchIndex + 1) % matchPositions.size();
}

if (!matchPositions.isEmpty()) {
updateHighlights(); // Highlight matches and the current match
}
}

/**
* Finds all matches and stores their positions.
*
* @param regex The regex pattern to search for
* @throws PatternSyntaxException if the regex is invalid
*/
private void findAllMatches(String regex) throws PatternSyntaxException {
clearHighlights();
matchPositions.clear();

currentRegex = regex; // Store the current regex
String content = textArea.getText();
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(content);

while (matcher.find()) {
int start = matcher.start();
int end = matcher.end();
matchPositions.add(new int[]{start, end});
}

currentMatchIndex = matchPositions.isEmpty() ? -1 : 0; // Reset match index
}

/**
* Replaces the current highlighted match with the given replacement.
*
* @param replacement The text to replace the current match
*/
public void replaceCurrentMatch(String replacement) {
if (!hasCurrentMatch()) {
logError("No current match to replace.");
return;
}

try {
String content = textArea.getText();
int[] currentMatch = matchPositions.get(currentMatchIndex);
int start = currentMatch[0];
int end = currentMatch[1];

// Replace the current match using a quoted replacement
String updatedContent = content.substring(0, start)
+ Matcher.quoteReplacement(replacement)
+ content.substring(end);
textArea.setText(updatedContent);

// Adjust subsequent match positions
int adjustment = replacement.length() - (end - start);
matchPositions.remove(currentMatchIndex);

for (int i = currentMatchIndex; i < matchPositions.size(); i++) {
matchPositions.get(i)[0] += adjustment;
matchPositions.get(i)[1] += adjustment;
}

// Highlight remaining matches
highlightAllMatches();

// Highlight the next match if any
if (!matchPositions.isEmpty()) {
currentMatchIndex = currentMatchIndex % matchPositions.size();
highlightCurrentMatch();
} else {
currentMatchIndex = -1; // Reset if no matches remain
}
} catch (Exception e) {
logError("Error replacing current match: " + e.getMessage());
}
}

/**
* Highlights all matches in yellow, except the current match.
*/
private void highlightAllMatches() {
clearHighlights(); // Clear existing highlights
try {
for (int i = 0; i < matchPositions.size(); i++) {
if (i != currentMatchIndex) { // Skip the current match
int[] match = matchPositions.get(i);
highlighter.addHighlight(match[0], match[1], new DefaultHighlighter.DefaultHighlightPainter(Color.YELLOW));
}
}
} catch (BadLocationException e) {
logError("Error highlighting matches: " + e.getMessage());
}
}

/**
* Highlights the current match in orange.
*/
private void highlightCurrentMatch() {
if (!hasCurrentMatch()) {
return;
}

try {
int[] currentMatch = matchPositions.get(currentMatchIndex);
highlighter.addHighlight(currentMatch[0], currentMatch[1], new DefaultHighlighter.DefaultHighlightPainter(Color.ORANGE));
textArea.setCaretPosition(currentMatch[0]); // Move caret to the current match
} catch (BadLocationException e) {
logError("Error highlighting current match: " + e.getMessage());
}
}

/**
* Highlights all matches and the current match.
*/
private void updateHighlights() {
highlightAllMatches(); // Highlight all matches in yellow, except the current one
highlightCurrentMatch(); // Highlight the current match in orange
}

/**
* Replaces all matches with the given replacement.
*
* @param replacement The text to replace all matches
*/
public void replaceAllMatches(String replacement) {
if (matchPositions.isEmpty() || currentRegex == null) {
logError("No matches to replace.");
return;
}

try {
String content = textArea.getText();
// Use quoted replacement for safety
String updatedContent = content.replaceAll(currentRegex, Matcher.quoteReplacement(replacement));
textArea.setText(updatedContent);

clearHighlights();
matchPositions.clear();
} catch (Exception e) {
logError("Error replacing all matches: " + e.getMessage());
}
}

/**
* Clears all highlights in the text area.
*/
private void clearHighlights() {
try {
highlighter.removeAllHighlights();
} catch (Exception e) {
logError("Error clearing highlights: " + e.getMessage());
}
}

/**
* Checks if a current match is highlighted.
*
* @return True if a current match exists, otherwise false
*/
public boolean hasCurrentMatch() {
return currentMatchIndex >= 0 && currentMatchIndex < matchPositions.size();
}

/**
* Gets the total number of matches found.
*
* @return The count of matches
*/
public int getMatchCount() {
return matchPositions.size();
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we made the right choice by reverting most of the changes in this file, but we're back to the case where a new DocumentListener is added each time the text field gains focus. I think this might still work, but I don't know if we'd run into issues like before where someone clicks in and out of the field over and over.

I think there are two routes for this class. If we want to keep the API signature the same, we could do this:

package com.netspi.awssigner.controller;

import static com.netspi.awssigner.log.LogWriter.*;
import com.netspi.awssigner.model.Profile;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.util.Optional;
import java.util.function.BiConsumer;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.JTextComponent;
import java.util.Set;
import java.util.HashSet;

class TextComponentFocusListener<T extends Profile> implements FocusListener {

    private final AWSSignerController controller;
    private final String propertyLoggingName;
    private final BiConsumer<T, String> updateFunction;
    private Optional<Profile> currentProfileOptional = Optional.empty();
    private String previousValue = "";
    private static final Set<JTextComponent> initializedTextComponents = new HashSet<>();

    public TextComponentFocusListener(AWSSignerController controller, String propertyLoggingName, BiConsumer<T, String> updateFunction) {
        this.controller = controller;
        this.propertyLoggingName = propertyLoggingName;
        this.updateFunction = updateFunction;
    }

    @Override
    public void focusGained(FocusEvent e) {
        JTextComponent textComponent = (JTextComponent) e.getComponent();
        currentProfileOptional = controller.getCurrentSelectedProfile();
        previousValue = textComponent.getText();

        // Add DocumentListener only once per component
        if (!initializedTextComponents.contains(textComponent)) {
            textComponent.getDocument().addDocumentListener(new DocumentListener() {
                @Override
                public void insertUpdate(DocumentEvent e) {
                    textChanged(textComponent);
                }

                @Override
                public void removeUpdate(DocumentEvent e) {
                    textChanged(textComponent);
                }

                @Override
                public void changedUpdate(DocumentEvent e) {
                    // Typically not used for plain text components
                }
            });
            initializedTextComponents.add(textComponent);
        }

        logDebug("Profile " + propertyLoggingName + " Text Field focus gained. Cause: " + e.getCause() + " ID:" + e.getID() + " Current value: " + previousValue);
    }

    @Override
    public void focusLost(FocusEvent e) {
        JTextComponent textComponent = (JTextComponent) e.getComponent();
        logDebug("Profile " + propertyLoggingName + " Text Field focus lost. Cause: " + e.getCause() + " ID:" + e.getID());
        String currentText = textComponent.getText();
        if (!previousValue.equals(currentText)) {
            updateProfile(currentText);
        }
    }

    private void textChanged(JTextComponent textComponent) {
        String currentText = textComponent.getText();
        if (!previousValue.equals(currentText)) {
            previousValue = currentText;
            updateProfile(currentText);
        }
    }

    private void updateProfile(String currentText) {
        if (currentProfileOptional.isPresent()) {
            Profile currentProfile = currentProfileOptional.get();
            logInfo("Profile " + currentProfile.getName() + " " + propertyLoggingName + " text changed. New Value: " + currentText);
            updateFunction.accept((T) currentProfile, currentText);
            controller.updateProfileStatus();
        } else {
            logDebug("Profile " + propertyLoggingName + " changed, but no profile selected. Ignoring.");
        }
    }
}

The above should work but the use of a Set for tracking feels like a bit of overkill. The advantage is that it's "drop-in" for the current class and wouldn't require other changes. The alternative would be to a larger change to the class like so:

package com.netspi.awssigner.controller;

import static com.netspi.awssigner.log.LogWriter.*;
import com.netspi.awssigner.model.Profile;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.util.Optional;
import java.util.function.BiConsumer;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.JTextComponent;

class TextComponentChangeListener<T extends Profile> implements FocusListener, DocumentListener {

    private final AWSSignerController controller;
    private final JTextComponent textComponent;
    private final String propertyLoggingName;
    private final BiConsumer<T, String> updateFunction;
    private String previousValue = "";

    public TextComponentChangeListener(AWSSignerController controller, JTextComponent textComponent, String propertyLoggingName, BiConsumer<T, String> updateFunction) {
        this.controller = controller;
        this.textComponent = textComponent;
        this.propertyLoggingName = propertyLoggingName;
        this.updateFunction = updateFunction;

        // Initialize previousValue and add listeners once
        this.previousValue = textComponent.getText();
        this.textComponent.addFocusListener(this);
        this.textComponent.getDocument().addDocumentListener(this);
    }

    @Override
    public void focusGained(FocusEvent e) {
        previousValue = textComponent.getText(); // Update previous value on focus gain
        logDebug("Profile " + propertyLoggingName + " Text Field focus gained. Cause: " + e.getCause() + " ID:" + e.getID() + " Current value: " + previousValue);
    }

    @Override
    public void focusLost(FocusEvent e) {
        logDebug("Profile " + propertyLoggingName + " Text Field focus lost. Cause: " + e.getCause() + " ID:" + e.getID());
        String currentText = textComponent.getText();
        if (!previousValue.equals(currentText)) {
            updateProfile(currentText);
        }
    }

    // DocumentListener methods
    @Override
    public void insertUpdate(DocumentEvent e) {
        textChanged();
    }

    @Override
    public void removeUpdate(DocumentEvent e) {
        textChanged();
    }

    @Override
    public void changedUpdate(DocumentEvent e) {
        // Typically not used for plain text components
    }

    private void textChanged() {
        String currentText = textComponent.getText();
        if (!previousValue.equals(currentText)) {
            previousValue = currentText;
            updateProfile(currentText);
        }
    }

    private void updateProfile(String currentText) {
        Optional<Profile> currentProfileOptional = controller.getCurrentSelectedProfile();
        if (currentProfileOptional.isPresent()) {
            Profile currentProfile = currentProfileOptional.get();
            try {
                @SuppressWarnings("unchecked")
                T profile = (T) currentProfile;
                logInfo("Profile " + currentProfile.getName() + " " + propertyLoggingName + " text changed. New Value: " + currentText);
                updateFunction.accept(profile, currentText);
                controller.updateProfileStatus();
            } catch (ClassCastException e) {
                logError("Type mismatch: Cannot cast " + currentProfile.getClass().getName() + " to the expected type.");
            }
        } else {
            logDebug("Profile " + propertyLoggingName + " changed, but no profile selected. Ignoring.");
        }
    }
}

I think this is a bit better because now the class is both a FocusListener and DocumentListener. We've already renamed the class to TextComponentChangeListener to reflect this. Because we're changing that class, we'd need to rename the file and update how it's used in AWSSignerController too:

private void initListeners() {
    // ...

    // Profile Region text field
    new TextComponentChangeListener<>(this, view.profileRegionTextField, "Region", Profile::setRegion);

    // Profile Service text field
    new TextComponentChangeListener<>(this, view.profileServiceTextField, "Service", Profile::setService);

    // Profile Key Id text field
    new TextComponentChangeListener<>(this, view.profileKeyIdTextField, "Key Id", Profile::setKeyId);

    // Static Credentials Access Key text field
    new TextComponentChangeListener<>(this, view.staticAccessKeyTextField, "Static Credentials Access Key", StaticCredentialsProfile::setAccessKey);

    // Static Credentials Secret Key text field
    new TextComponentChangeListener<>(this, view.staticSecretKeyTextField, "Static Credentials Secret Key", StaticCredentialsProfile::setSecretKey);

    // Static Credentials Session Token text field
    new TextComponentChangeListener<>(this, view.staticSessionTokenTextField, "Static Credentials Session Token", StaticCredentialsProfile::setSessionToken);

    // AssumeRole Role ARN text field
    new TextComponentChangeListener<>(this, view.assumeRoleRoleArnTextField, "AssumeRole Role ARN", AssumeRoleProfile::setRoleArn);

    // AssumeRole Session Name text field
    new TextComponentChangeListener<>(this, view.assumeRoleSessionNameTextField, "AssumeRole Session Name", AssumeRoleProfile::setSessionName);

    // AssumeRole External Id text field
    new TextComponentChangeListener<>(this, view.assumeRoleExternalIdTextField, "AssumeRole External Id", AssumeRoleProfile::setExternalId);

    // AssumeRole Duration text field
    new TextComponentChangeListener<>(this, view.assumeRoleDurationTextField, "AssumeRole Duration Seconds", AssumeRoleProfile::setDurationSecondsFromText);

    // AssumeRole Session Policy text area
    new TextComponentChangeListener<>(this, view.assumeRoleSessionPolicyTextArea, "AssumeRole Session Policy", AssumeRoleProfile::setSessionPolicy);

    // Command Command text field
    new TextComponentChangeListener<>(this, view.commandCommandTextField, "Command Command", CommandProfile::setCommand);

    // Command Duration text field
    new TextComponentChangeListener<>(this, view.commandDurationTextField, "Command Duration Seconds", CommandProfile::setDurationSecondsFromText);

    // ...
}

Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,4 @@ private void updateProfile(String currentText) {
logDebug("Profile " + propertyLoggingName + " focus lost, but no profile selected. Ignoring.");
}
}
}
}
52 changes: 52 additions & 0 deletions src/main/java/com/netspi/awssigner/controller/UndoRedoManager.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.netspi.awssigner.controller;

import javax.swing.*;
import javax.swing.text.JTextComponent;
import javax.swing.undo.UndoManager;
import java.awt.Toolkit;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.awt.event.ActionEvent;

public class UndoRedoManager {

public static void addUndoRedoFunctionality(JTextComponent textComponent) {
final UndoManager undoManager = new UndoManager();

// Add UndoableEditListener to the document
textComponent.getDocument().addUndoableEditListener(e -> undoManager.addEdit(e.getEdit()));

// Get the platform-specific menu shortcut key mask
int menuShortcutKeyMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx();

// Undo key stroke
KeyStroke undoKeyStroke = KeyStroke.getKeyStroke(KeyEvent.VK_Z, menuShortcutKeyMask);

// Redo key strokes
KeyStroke redoKeyStroke1 = KeyStroke.getKeyStroke(KeyEvent.VK_Z, menuShortcutKeyMask | InputEvent.SHIFT_DOWN_MASK);
KeyStroke redoKeyStroke2 = KeyStroke.getKeyStroke(KeyEvent.VK_Z, menuShortcutKeyMask | InputEvent.SHIFT_DOWN_MASK);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like these 2 keystrokes are the same. Maybe we wanted:

        KeyStroke redoKeyStroke1 = KeyStroke.getKeyStroke(KeyEvent.VK_Y, menuShortcutKeyMask);
        KeyStroke redoKeyStroke2 = KeyStroke.getKeyStroke(KeyEvent.VK_Z, menuShortcutKeyMask | InputEvent.SHIFT_DOWN_MASK);

That should make the 2 redo hotkeys: CTRL+Y and CTRL+SHIFT+Z (or using Command on Mac). I think that should match people's expectations.


// Bind the undo action
textComponent.getInputMap().put(undoKeyStroke, "Undo");
textComponent.getActionMap().put("Undo", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
if (undoManager.canUndo()) {
undoManager.undo();
}
}
});

// Bind the redo actions
textComponent.getInputMap().put(redoKeyStroke1, "Redo");
textComponent.getInputMap().put(redoKeyStroke2, "Redo");
textComponent.getActionMap().put("Redo", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
if (undoManager.canRedo()) {
undoManager.redo();
}
}
});
}
}
Loading