''' Git Bridge extension for Burp Suite Pro The Git Bridge plugin lets Burp users store and share findings and other Burp items via git. Users can right-click supported items in Burp to send them to a git repo and use the Git Bridge tab to send items back to their respective Burp tools. For more information see https://github.com/jfoote/burp-git-bridge. This extension is a PoC. Right now only Repeater and Scanner are supported, and the code could use refactoring. If you're interested in a more polished version or more features let me know, or better yet consider sending me a pull request. Thanks for checking it out. Jonathan Foote jmfoote@loyola.edu 2015-04-21 ''' from burp import IBurpExtender, ITab, IHttpListener, IMessageEditorController, IContextMenuFactory, IScanIssue, IHttpService, IHttpRequestResponse from java.awt import Component from java.awt.event import ActionListener from java.io import PrintWriter from java.util import ArrayList, List from java.net import URL from javax.swing import JScrollPane, JSplitPane, JTabbedPane, JTable, SwingUtilities, JPanel, JButton, JLabel, JMenuItem, BoxLayout from javax.swing.table import AbstractTableModel from threading import Lock import datetime, os, hashlib import sys ''' Entry point for Burp Git Bridge extension. ''' class BurpExtender(IBurpExtender): ''' Entry point for plugin; creates UI and Log ''' def registerExtenderCallbacks(self, callbacks): # Assign stdout/stderr for debugging and set extension name sys.stdout = callbacks.getStdout() sys.stderr = callbacks.getStderr() callbacks.setExtensionName("Git Bridge") # Create major objects and load user data self.log = Log(callbacks) self.ui = BurpUi(callbacks, self.log) self.log.setUi(self.ui) self.log.reload() ''' Classes that support logging of data to in-Burp extension UI as well as the underlying git repo ''' class LogEntry(object): ''' Hacky dictionary used to store Burp tool data. Objects of this class are stored in the Java-style table represented in the Burp UI table. They are created by the BurpUi when a user sends Burp tool data to Git Bridge, or by Git Bridge when a user's git repo is reloaded into Burp. ''' def __init__(self, *args, **kwargs): self.__dict__ = kwargs # Hash most of the tool data to uniquely identify this entry. # Note: Could be more pythonic. md5 = hashlib.md5() for k, v in self.__dict__.iteritems(): if v and k != "messages": if not getattr(v, "__getitem__", False): v = str(v) md5.update(k) md5.update(v[:2048]) self.md5 = md5.hexdigest() class Log(): ''' Log of burp activity: this class encapsulates both the Burp UI log and the git repo log. A single object of this class is created when the extension is loaded. It is used by BurpExtender when it logs input events or the in-Burp Git Bridge log is reloaded from the underlying git repo. ''' def __init__(self, callbacks): ''' Creates GUI log and git log objects ''' self.ui = None self._callbacks = callbacks self._helpers = callbacks.getHelpers() self.gui_log = GuiLog(callbacks) self.git_log = GitLog(callbacks) def setUi(self, ui): ''' There is a circular dependency between the Log and Burp GUI objects: the GUI needs a handle to the Log to add new Burp tool data, and the Log needs a handle to the GUI to update in the in-GUI table. The GUI takes the Log in its constructor, and this function gives the Log a handle to the GUI. ''' self.ui = ui self.gui_log.ui = ui def reload(self): ''' Reloads the Log from on the on-disk git repo. ''' self.gui_log.clear() for entry in self.git_log.entries(): self.gui_log.add_entry(entry) def add_repeater_entry(self, messageInfo): ''' Loads salient info from the Burp-supplied messageInfo object and stores it to the GUI and Git logs ''' timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") service = messageInfo.getHttpService() entry = LogEntry(tool="repeater", host=service.getHost(), port=service.getPort(), protocol=service.getProtocol(), url=str(self._helpers.analyzeRequest(messageInfo).getUrl()), timestamp=timestamp, who=self.git_log.whoami(), request=messageInfo.getRequest(), response=messageInfo.getResponse()) self.gui_log.add_entry(entry) self.git_log.add_repeater_entry(entry) def add_scanner_entry(self, scanIssue): ''' Loads salient info from the Burp-supplied scanInfo object and stores it to the GUI and Git logs ''' timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") # Gather info from messages. Oi, ugly. messages = [] for message in scanIssue.getHttpMessages(): service = message.getHttpService() msg_entry = LogEntry(tool="scanner_message", host=service.getHost(), port=service.getPort(), protocol=service.getProtocol(), comment=message.getComment(), highlight=message.getHighlight(), request=message.getRequest(), response=message.getResponse(), timestamp=timestamp) messages.append(msg_entry) # Gather info for scan issue service = scanIssue.getHttpService() entry = LogEntry(tool="scanner", timestamp=timestamp, who=self.git_log.whoami(), messages=messages, host=service.getHost(), port=service.getPort(), protocol=service.getProtocol(), confidence=scanIssue.getConfidence(), issue_background=scanIssue.getIssueBackground(), issue_detail=scanIssue.getIssueDetail(), issue_name=scanIssue.getIssueName(), issue_type=scanIssue.getIssueType(), remediation_background=scanIssue.getRemediationBackground(), remediation_detail=scanIssue.getRemediationDetail(), severity=scanIssue.getSeverity(), url=str(scanIssue.getUrl())) self.gui_log.add_entry(entry) self.git_log.add_scanner_entry(entry) def remove(self, entry): ''' Removes the supplied entry from the Log ''' self.git_log.remove(entry) self.gui_log.remove_entry(entry) class GuiLog(AbstractTableModel): ''' Acts as an AbstractTableModel for the table that is shown in the UI tab: when this data structure changes, the in-UI table is updated. ''' def __init__(self, callbacks): ''' Creates a Java-style ArrayList to hold LogEntries that appear in the table ''' self.ui = None self._log = ArrayList() self._lock = Lock() self._callbacks = callbacks self._helpers = callbacks.getHelpers() def clear(self): ''' Clears all entries from the table ''' self._lock.acquire() last = self._log.size() if last > 0: self._log.clear() self.fireTableRowsDeleted(0, last-1) # Note: if callees modify table this could deadlock self._lock.release() def add_entry(self, entry): ''' Adds entry to the table ''' self._lock.acquire() row = self._log.size() self._log.add(entry) # Note: if callees modify table this could deadlock self.fireTableRowsInserted(row, row) self._lock.release() def remove_entry(self, entry): ''' Removes entry from the table ''' self._lock.acquire() for i in range(0, len(self._log)): ei = self._log[i] if ei.md5 == entry.md5: self._log.remove(i) break self.fireTableRowsDeleted(i, i) self._lock.release() def getRowCount(self): ''' Used by the Java Swing UI ''' try: return self._log.size() except: return 0 def getColumnCount(self): ''' Used by the Java Swing UI ''' return 5 def getColumnName(self, columnIndex): ''' Used by the Java Swing UI ''' cols = ["Time added", "Tool", "URL", "Issue", "Who"] try: return cols[columnIndex] except KeyError: return "" def get(self, rowIndex): ''' Gets the LogEntry at rowIndex ''' return self._log.get(rowIndex) def getValueAt(self, rowIndex, columnIndex): ''' Used by the Java Swing UI ''' logEntry = self._log.get(rowIndex) if columnIndex == 0: return logEntry.timestamp elif columnIndex == 1: return logEntry.tool.capitalize() elif columnIndex == 2: return logEntry.url elif columnIndex == 3: if logEntry.tool == "scanner": return logEntry.issue_name else: return "N/A" elif columnIndex == 4: return logEntry.who return "" import os, subprocess class GitLog(object): ''' Represents the underlying Git Repo that stores user information. Used by the Log object. As it stands, uses only a single git repo at a fixed path. ''' def __init__(self, callbacks): ''' Creates the git repo if it doesn't exist ''' self.callbacks = callbacks # Set directory paths and if necessary, init git repo home = os.path.expanduser("~") self.repo_path = os.path.join(home, ".burp-git-bridge") if not os.path.exists(self.repo_path): subprocess.check_call(["git", "init", self.repo_path], cwd=home) def add_repeater_entry(self, entry): ''' Adds a LogEntry containing Burp Repeater data to the git repo ''' # Make directory for this entry entry_dir = os.path.join(self.repo_path, entry.md5) if not os.path.exists(entry_dir): os.mkdir(entry_dir) # Add and commit repeater data to git repo self.write_entry(entry, entry_dir) subprocess.check_call(["git", "commit", "-m", "Added Repeater entry"], cwd=self.repo_path) def add_scanner_entry(self, entry): ''' Adds a LogEntry containing Burp Scanner data to the git repo ''' # Create dir hierarchy for this issue entry_dir = os.path.join(self.repo_path, entry.md5) # Log this entry; log 'messages' to its own subdir messages = entry.messages del entry.__dict__["messages"] self.write_entry(entry, entry_dir) messages_dir = os.path.join(entry_dir, "messages") if not os.path.exists(messages_dir): os.mkdir(messages_dir) lpath = os.path.join(messages_dir, ".burp-list") open(lpath, "wt") subprocess.check_call(["git", "add", lpath], cwd=self.repo_path) i = 0 for message in messages: message_dir = os.path.join(messages_dir, str(i)) if not os.path.exists(message_dir): os.mkdir(message_dir) self.write_entry(message, message_dir) i += 1 subprocess.check_call(["git", "commit", "-m", "Added scanner entry"], cwd=self.repo_path) def write_entry(self, entry, entry_dir): ''' Stores a LogEntry to entry_dir and adds it to git repo. ''' if not os.path.exists(entry_dir): os.mkdir(entry_dir) for filename, data in entry.__dict__.iteritems(): if not data: data = "" if not getattr(data, "__getitem__", False): data = str(data) path = os.path.join(entry_dir, filename) with open(path, "wb") as fp: fp.write(data) fp.flush() fp.close() subprocess.check_call(["git", "add", path], cwd=self.repo_path) def entries(self): ''' Generator; yields a LogEntry for each entry in the on-disk git repo ''' def load_entry(entry_path): ''' Loads a single entry from the path. Could be a "list" entry (see below) ''' entry = LogEntry() for filename in os.listdir(entry_path): file_path = os.path.join(entry_path, filename) if os.path.isdir(file_path): if ".burp-list" in os.listdir(file_path): list_entry = load_list(file_path) entry.__dict__[filename] = list_entry else: sub_entry = load_entry(file_path) entry.__dict__[filename] = sub_entry else: entry.__dict__[filename] = open(file_path, "rb").read() return entry def load_list(entry_path): ''' Loads a "list" entry (corresponds to a python list, or a Java ArrayList, such as the "messages" member of a Burp Scanner Issue). ''' entries = [] for filename in os.listdir(entry_path): file_path = os.path.join(entry_path, filename) if filename == ".burp-list": continue entries.append(load_entry(file_path)) return entries # Process each of the directories in the underlying git repo for entry_dir in os.listdir(self.repo_path): if entry_dir == ".git": continue entry_path = os.path.join(self.repo_path, entry_dir) if not os.path.isdir(entry_path): continue entry = load_entry(entry_path) yield entry def whoami(self): ''' Returns user.name from the underlying git repo. Used to note who created or modified an entry. ''' return subprocess.check_output(["git", "config", "user.name"], cwd=self.repo_path) def remove(self, entry): ''' Removes the given LogEntry from the underlying git repo. ''' entry_path = os.path.join(self.repo_path, entry.md5) subprocess.check_output(["git", "rm", "-rf", entry_path], cwd=self.repo_path) subprocess.check_call(["git", "commit", "-m", "Removed entry at %s" % entry_path], cwd=self.repo_path) ''' Implementation of extension's UI. ''' class BurpUi(ITab): ''' The collection of objects that make up this extension's Burp UI. Created by BurpExtender. ''' def __init__(self, callbacks, log): ''' Creates GUI objects, registers right-click handlers, and adds the extension's tab to the Burp UI. ''' # Create split pane with top and bottom panes self._splitpane = JSplitPane(JSplitPane.VERTICAL_SPLIT) self.bottom_pane = UiBottomPane(callbacks, log) self.top_pane = UiTopPane(callbacks, self.bottom_pane, log) self.bottom_pane.setLogTable(self.top_pane.logTable) self._splitpane.setLeftComponent(self.top_pane) self._splitpane.setRightComponent(self.bottom_pane) # Create right-click handler self.log = log rc_handler = RightClickHandler(callbacks, log) callbacks.registerContextMenuFactory(rc_handler) # Add the plugin's custom tab to Burp's UI callbacks.customizeUiComponent(self._splitpane) callbacks.addSuiteTab(self) def getTabCaption(self): return "Git" def getUiComponent(self): return self._splitpane class RightClickHandler(IContextMenuFactory): ''' Creates menu items for Burp UI right-click menus. ''' def __init__(self, callbacks, log): self.callbacks = callbacks self.log = log def createMenuItems(self, invocation): ''' Invoked by Burp when a right-click menu is created; adds Git Bridge's options to the menu. ''' context = invocation.getInvocationContext() tool = invocation.getToolFlag() if tool == self.callbacks.TOOL_REPEATER: if context in [invocation.CONTEXT_MESSAGE_EDITOR_REQUEST, invocation.CONTEXT_MESSAGE_VIEWER_RESPONSE]: item = JMenuItem("Send to Git Bridge") item.addActionListener(self.RepeaterHandler(self.callbacks, invocation, self.log)) items = ArrayList() items.add(item) return items elif tool == self.callbacks.TOOL_SCANNER: if context in [invocation.CONTEXT_SCANNER_RESULTS]: item = JMenuItem("Send to Git Bridge") item.addActionListener(self.ScannerHandler(self.callbacks, invocation, self.log)) items = ArrayList() items.add(item) return items else: # TODO: add support for other tools pass class ScannerHandler(ActionListener): ''' Handles selection of the 'Send to Git Bridge' menu item when shown on a Scanner right click menu. ''' def __init__(self, callbacks, invocation, log): self.callbacks = callbacks self.invocation = invocation self.log = log def actionPerformed(self, actionEvent): for issue in self.invocation.getSelectedIssues(): self.log.add_scanner_entry(issue) class RepeaterHandler(ActionListener): ''' Handles selection of the 'Send to Git Bridge' menu item when shown on a Repeater right click menu. ''' def __init__(self, callbacks, invocation, log): self.callbacks = callbacks self.invocation = invocation self.log = log def actionPerformed(self, actionEvent): for message in self.invocation.getSelectedMessages(): self.log.add_repeater_entry(message) class UiBottomPane(JTabbedPane, IMessageEditorController): ''' The bottom pane in the this extension's UI tab. It shows detail of whatever is selected in the top pane. ''' def __init__(self, callbacks, log): self.commandPanel = CommandPanel(callbacks, log) self.addTab("Git Bridge Commands", self.commandPanel) self._requestViewer = callbacks.createMessageEditor(self, False) self._responseViewer = callbacks.createMessageEditor(self, False) self._issueViewer = callbacks.createMessageEditor(self, False) callbacks.customizeUiComponent(self) def setLogTable(self, log_table): ''' Passes the Log table to the "Send to Tools" component so it can grab the selected rows ''' self.commandPanel.log_table = log_table def show_log_entry(self, log_entry): ''' Shows the log entry in the bottom pane of the UI ''' self.removeAll() self.addTab("Git Bridge Commands", self.commandPanel) if getattr(log_entry, "request", False): self.addTab("Request", self._requestViewer.getComponent()) self._requestViewer.setMessage(log_entry.request, True) if getattr(log_entry, "response", False): self.addTab("Response", self._responseViewer.getComponent()) self._responseViewer.setMessage(log_entry.response, False) if log_entry.tool == "scanner": self.addTab("Issue Summary", self._issueViewer.getComponent()) self._issueViewer.setMessage(self.getScanIssueSummary(log_entry), False) self._currentlyDisplayedItem = log_entry def getScanIssueSummary(self, log_entry): ''' A quick hack to generate a plaintext summary of a Scanner issue. This is shown in the bottom pane of the Git Bridge tab when a Scanner item is selected. ''' out = [] for key, val in sorted(log_entry.__dict__.items()): if key in ["messages", "tool", "md5"]: continue out.append("%s: %s" % (key, val)) return "\n\n".join(out) ''' The three methods below implement IMessageEditorController st. requests and responses are shown in the UI pane ''' def getHttpService(self): return self._currentlyDisplayedItem.requestResponse.getHttpService() def getRequest(self): return self._currentlyDisplayedItem.requestResponse.getRequest() def getResponse(self): return self._currentlyDisplayedItem.getResponse() class UiTopPane(JTabbedPane): ''' The top pane in this extension's UI tab. It shows the in-Burp version of the Git Repo. ''' def __init__(self, callbacks, bottom_pane, log): self.logTable = UiLogTable(callbacks, bottom_pane, log.gui_log) scrollPane = JScrollPane(self.logTable) self.addTab("Repo", scrollPane) callbacks.customizeUiComponent(self) class UiLogTable(JTable): ''' Table of log entries that are shown in the top pane of the UI when the corresponding tab is selected. Note, as a JTable, this stays synchronized with the underlying ArrayList. ''' def __init__(self, callbacks, bottom_pane, gui_log): self.setAutoCreateRowSorter(True) self.bottom_pane = bottom_pane self._callbacks = callbacks self.gui_log = gui_log self.setModel(gui_log) callbacks.customizeUiComponent(self) def getSelectedEntries(self): return [self.gui_log.get(i) for i in self.getSelectedRows()] def changeSelection(self, row, col, toggle, extend): ''' Displays the selected item in the content pane ''' JTable.changeSelection(self, row, col, toggle, extend) self.bottom_pane.show_log_entry(self.gui_log.get(row)) class CommandPanel(JPanel, ActionListener): ''' This is the "Git Bridge Commands" Panel shown in the bottom of the Git Bridge tab. ''' def __init__(self, callbacks, log): self.callbacks = callbacks self.log = log self.log_table = None # to be set by caller self.setLayout(BoxLayout(self, BoxLayout.PAGE_AXIS)) label = JLabel("Reload from Git Repo:") button = JButton("Reload") button.addActionListener(CommandPanel.ReloadAction(log)) self.add(label) self.add(button) label = JLabel("Send selected entries to respective Burp tools:") button = JButton("Send") button.addActionListener(CommandPanel.SendAction(self)) self.add(label) self.add(button) label = JLabel("Remove selected entries from Git Repo:") button = JButton("Remove") button.addActionListener(CommandPanel.RemoveAction(self, log)) self.add(label) self.add(button) # TODO: maybe add a git command box class ReloadAction(ActionListener): ''' Handles when the "Reload" button is clicked. ''' def __init__(self, log): self.log = log def actionPerformed(self, event): self.log.reload() class SendAction(ActionListener): ''' Handles when the "Send to Tools" button is clicked. ''' def __init__(self, panel): self.panel = panel def actionPerformed(self, actionEvent): ''' Iterates over each entry that is selected in the UI table and calls the proper Burp "send to" callback with the entry data. ''' for entry in self.panel.log_table.getSelectedEntries(): if entry.tool == "repeater": https = (entry.protocol == "https") self.panel.callbacks.sendToRepeater(entry.host, int(entry.port), https, entry.request, entry.timestamp) elif entry.tool == "scanner": issue = BurpLogScanIssue(entry) self.panel.callbacks.addScanIssue(issue) class RemoveAction(ActionListener): ''' Handles when the "Send to Tools" button is clicked. ''' def __init__(self, panel, log): self.panel = panel self.log = log def actionPerformed(self, event): ''' Iterates over each entry that is selected in the UI table and removes it from the Log. ''' entries = self.panel.log_table.getSelectedEntries() for entry in entries: self.log.remove(entry) ''' Burp Interoperability Class Definitions ''' class BurpLogHttpService(IHttpService): ''' Burp expects the object passed to "addScanIssue" to include a member that implements this interface; that is what this object is used for. ''' def __init__(self, host, port, protocol): self._host = host self._port = port self._protocol = protocol def getHost(self): return self._host def getPort(self): return int(self._port) def getProtocol(self): return self._protocol class BurpLogHttpRequestResponse(IHttpRequestResponse): ''' Burp expects the object passed to "addScanIssue" to include a member that implements this interface; that is what this object is used for. ''' def __init__(self, entry): self.entry = entry def getRequest(self): return self.entry.request def getResponse(self): return self.entry.response def getHttpService(self): return BurpLogHttpService(self.entry.host, self.entry.port, self.entry.protocol) class BurpLogScanIssue(IScanIssue): ''' Passed to addScanItem. Note that a pythonic solution that dynamically creates method based on LogEntry attributes via functools.partial will not work here as the interface classes supplied by Burp (IScanIssue, etc.) include read-only attributes corresponding to strings that would be used by such a solution. ''' def __init__(self, entry): self.entry = entry self.messages = [BurpLogHttpRequestResponse(m) for m in self.entry.messages] self.service = BurpLogHttpService(self.entry.host, self.entry.port, self.entry.protocol) def getHttpMessages(self): return self.messages def getHttpService(self): return self.service def getConfidence(self): return self.entry.confidence def getIssueBackground(self): return self.entry.issue_background def getIssueDetail(self): return self.entry.issue_detail def getIssueName(self): return self.entry.issue_name def getIssueType(self): return self.entry.issue_type def getRemediationDetail(self): return self.entry.remediation_detail def getSeverity(self): return self.entry.severity def getUrl(self): return URL(self.entry.url)