├── .gitignore ├── FILTER_EXPRESSIONS.md ├── LICENSE.txt ├── README.md ├── build.gradle.kts ├── build.sh ├── examples ├── conf.json ├── js │ ├── addons.mjs │ ├── basic.js │ ├── basic.mjs │ ├── common.js │ ├── common.mjs │ ├── crypto.mjs │ ├── drop.mjs │ ├── highlight.mjs │ ├── import.mjs │ ├── intercept.mjs │ ├── log.mjs │ ├── require.js │ └── rewrite_host.mjs └── python │ ├── addons.py │ ├── basic.py │ ├── common.py │ ├── crypto.py │ ├── drop.py │ ├── highlight.py │ ├── import.py │ ├── intercept.py │ ├── log.py │ └── rewrite_host.py ├── flake.lock ├── flake.nix ├── settings.gradle.kts └── src ├── integrationTest ├── data │ ├── testnpmpkg │ │ ├── index.js │ │ └── package.json │ └── testpythonpkg │ │ ├── .gitignore │ │ ├── pyproject.toml │ │ └── testpythonpkg │ │ └── __init__.py └── kotlin │ └── com │ └── carvesystems │ └── burpscript │ ├── JsImportIntegrationTest.kt │ ├── PythonImportIntegrationTest.kt │ └── util.kt ├── internal └── kotlin │ └── com │ └── carvesystems │ └── burpscript │ └── internal │ └── testing │ ├── matchers │ └── value │ │ └── ValueMatchers.kt │ ├── mocks.kt │ └── util.kt ├── main ├── antlr │ └── com │ │ └── carvesystems │ │ └── burpscript │ │ └── FilterExpression.g4 ├── kotlin │ └── com │ │ └── carvesystems │ │ └── burpscript │ │ ├── BaseSubscriber.kt │ │ ├── Config.kt │ │ ├── ContextBuilder.kt │ │ ├── Extension.kt │ │ ├── Filter.kt │ │ ├── JsContextBuilder.kt │ │ ├── Language.kt │ │ ├── Logger.kt │ │ ├── PathWatchEvent.kt │ │ ├── PythonContextBuilder.kt │ │ ├── SaveData.kt │ │ ├── ScopedPersistence.kt │ │ ├── Script.kt │ │ ├── ScriptCryptoHelper.kt │ │ ├── ScriptEvent.kt │ │ ├── ScriptHandler.kt │ │ ├── ScriptHelpers.kt │ │ ├── ScriptHttpRequest.kt │ │ ├── ScriptHttpResponse.kt │ │ ├── ScriptLoadEvent.kt │ │ ├── ScriptMap.kt │ │ ├── SimpleToolSource.kt │ │ ├── Strings.kt │ │ ├── WatchThread.kt │ │ ├── annotations.kt │ │ ├── interop │ │ ├── binary.kt │ │ ├── json.kt │ │ └── value.kt │ │ ├── ui │ │ ├── DocsTab.kt │ │ ├── DocsTabViewModel.kt │ │ ├── ScriptEntry.kt │ │ ├── ScriptEntryViewModel.kt │ │ ├── ScriptsTab.kt │ │ └── ScriptsTabViewModel.kt │ │ └── utils.kt └── resources │ └── com │ └── carvesystems │ └── burpscript │ └── ui │ └── strings.properties └── test └── kotlin └── com └── carvesystems └── burpscript ├── ConfigTest.kt ├── FilterExpressionTest.kt ├── JsScriptingTest.kt ├── LanguageTest.kt ├── ProjectConfig.kt ├── PythonScriptingTest.kt ├── SaveDataTest.kt ├── ScriptCryptoHelperTest.kt ├── ScriptHandlerTest.kt ├── ScriptMapTest.kt ├── ScriptTest.kt ├── TestUrlUtils.kt └── interop ├── BinaryTest.kt ├── JsonTest.kt └── ValueTest.kt /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/intellij,kotlin,gradle,vim 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=intellij,kotlin,gradle,vim 3 | 4 | ### Intellij ### 5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 7 | 8 | .idea 9 | 10 | # User-specific stuff 11 | .idea/**/workspace.xml 12 | .idea/**/tasks.xml 13 | .idea/**/usage.statistics.xml 14 | .idea/**/dictionaries 15 | .idea/**/shelf 16 | 17 | # AWS User-specific 18 | .idea/**/aws.xml 19 | 20 | # Generated files 21 | .idea/**/contentModel.xml 22 | 23 | # Sensitive or high-churn files 24 | .idea/**/dataSources/ 25 | .idea/**/dataSources.ids 26 | .idea/**/dataSources.local.xml 27 | .idea/**/sqlDataSources.xml 28 | .idea/**/dynamic.xml 29 | .idea/**/uiDesigner.xml 30 | .idea/**/dbnavigator.xml 31 | 32 | # Gradle 33 | .idea/**/gradle.xml 34 | .idea/**/libraries 35 | 36 | # Gradle and Maven with auto-import 37 | # When using Gradle or Maven with auto-import, you should exclude module files, 38 | # since they will be recreated, and may cause churn. Uncomment if using 39 | # auto-import. 40 | # .idea/artifacts 41 | # .idea/compiler.xml 42 | # .idea/jarRepositories.xml 43 | # .idea/modules.xml 44 | # .idea/*.iml 45 | # .idea/modules 46 | # *.iml 47 | # *.ipr 48 | 49 | # CMake 50 | cmake-build-*/ 51 | 52 | # Mongo Explorer plugin 53 | .idea/**/mongoSettings.xml 54 | 55 | # File-based project format 56 | *.iws 57 | 58 | # IntelliJ 59 | out/ 60 | 61 | # mpeltonen/sbt-idea plugin 62 | .idea_modules/ 63 | 64 | # JIRA plugin 65 | atlassian-ide-plugin.xml 66 | 67 | # Cursive Clojure plugin 68 | .idea/replstate.xml 69 | 70 | # SonarLint plugin 71 | .idea/sonarlint/ 72 | 73 | # Crashlytics plugin (for Android Studio and IntelliJ) 74 | com_crashlytics_export_strings.xml 75 | crashlytics.properties 76 | crashlytics-build.properties 77 | fabric.properties 78 | 79 | # Editor-based Rest Client 80 | .idea/httpRequests 81 | 82 | # Android studio 3.1+ serialized cache file 83 | .idea/caches/build_file_checksums.ser 84 | 85 | ### Intellij Patch ### 86 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 87 | 88 | # *.iml 89 | # modules.xml 90 | # .idea/misc.xml 91 | # *.ipr 92 | 93 | # Sonarlint plugin 94 | # https://plugins.jetbrains.com/plugin/7973-sonarlint 95 | .idea/**/sonarlint/ 96 | 97 | # SonarQube Plugin 98 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin 99 | .idea/**/sonarIssues.xml 100 | 101 | # Markdown Navigator plugin 102 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced 103 | .idea/**/markdown-navigator.xml 104 | .idea/**/markdown-navigator-enh.xml 105 | .idea/**/markdown-navigator/ 106 | 107 | # Cache file creation bug 108 | # See https://youtrack.jetbrains.com/issue/JBR-2257 109 | .idea/$CACHE_FILE$ 110 | 111 | # CodeStream plugin 112 | # https://plugins.jetbrains.com/plugin/12206-codestream 113 | .idea/codestream.xml 114 | 115 | # Azure Toolkit for IntelliJ plugin 116 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij 117 | .idea/**/azureSettings.xml 118 | 119 | ### Kotlin ### 120 | # Compiled class file 121 | *.class 122 | 123 | # Log file 124 | *.log 125 | 126 | # BlueJ files 127 | *.ctxt 128 | 129 | # Mobile Tools for Java (J2ME) 130 | .mtj.tmp/ 131 | 132 | # Package Files # 133 | *.jar 134 | *.war 135 | *.nar 136 | *.ear 137 | *.zip 138 | *.tar.gz 139 | *.rar 140 | 141 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 142 | hs_err_pid* 143 | replay_pid* 144 | 145 | ### Vim ### 146 | # Swap 147 | [._]*.s[a-v][a-z] 148 | !*.svg # comment out if you don't need vector files 149 | [._]*.sw[a-p] 150 | [._]s[a-rt-v][a-z] 151 | [._]ss[a-gi-z] 152 | [._]sw[a-p] 153 | 154 | # Session 155 | Session.vim 156 | Sessionx.vim 157 | 158 | # Temporary 159 | .netrwhist 160 | *~ 161 | # Auto-generated tag files 162 | tags 163 | # Persistent undo 164 | [._]*.un~ 165 | 166 | ### Gradle ### 167 | .gradle 168 | **/build/ 169 | !src/**/build/ 170 | 171 | # Ignore Gradle GUI config 172 | gradle-app.setting 173 | 174 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 175 | !gradle-wrapper.jar 176 | 177 | # Avoid ignore Gradle wrappper properties 178 | !gradle-wrapper.properties 179 | 180 | # Cache of project 181 | .gradletasknamecache 182 | 183 | # Eclipse Gradle plugin generated files 184 | # Eclipse Core 185 | .project 186 | # JDT-specific (Eclipse Java Development Tools) 187 | .classpath 188 | 189 | ### Gradle Patch ### 190 | # Java heap dump 191 | *.hprof 192 | 193 | bin/ 194 | gradle 195 | gradlew 196 | gradlew.bat 197 | 198 | 199 | # End of https://www.toptal.com/developers/gitignore/api/intellij,kotlin,gradle,vim 200 | -------------------------------------------------------------------------------- /FILTER_EXPRESSIONS.md: -------------------------------------------------------------------------------- 1 | # Filter Expressions 2 | 3 | Filter expressions are a Lisp-like DSL for selecting requests/responses that should be forwarded to a script. This document should contain most of the defined filter functions, but you can also search [Filter.kt](src/main/kotlin/com/carvesystems/burpscript/Filter.kt) for all potential functions. 4 | 5 | Filter expressions are pulled out of scripts via the `REQ_FILTER` and `RES_FILTER` strings exported from that script. See the [example scripts](examples/). 6 | 7 | Strings can be provided as raw strings (`r"..."`) or normal strings (`"..."`). Raw strings are not escaped at all and are generally useful for PATTERN args. 8 | 9 | - [and VARARG_STMT](#and-vararg_stmt) 10 | - [or VARARG_STMT](#or-vararg_stmt) 11 | - [not STMT](#not-stmt) 12 | - [in-scope](#in-scope) 13 | - [host-matches PATTERN](#host-matches-pattern) 14 | - [method-eq VARARG_STRING](#method-eq-vararg_string) 15 | - [path-contains PATTERN](#path-contains-pattern) 16 | - [path-matches PATTERN](#path-matches-pattern) 17 | - [file-ext-eq VARARG_STRING](#file-ext-eq-vararg_string) 18 | - [has-header VARARG_STRING](#has-header-vararg_string) 19 | - [body-contains PATTERN](#body-contains-pattern) 20 | - [body-matches PATTERN](#body-matches-pattern) 21 | - [has-cookie VARARG_STRING](#has-cookie-vararg_string) 22 | - [has-json-key VARARG_STRING](#has-json-key-vararg_string) 23 | - [has-query-param VARARG_STRING](#has-query-param-vararg_string) 24 | - [has-form-param VARARG_STRING](#has-form-param-vararg_string) 25 | - [query-param-matches STRING PATTERN](#query-param-matches-string-pattern) 26 | - [status-code-eq VARARG_INT](#status-code-eq-vararg_int) 27 | - [status-code-in INT INT](#status-code-in-int-int) 28 | - [has-attachment VARARG_STRING](#has-attachment-vararg_string) 29 | - [listener-port-eq VARARG_INT](#listener-port-eq-vararg_int) 30 | - [tool-source-eq VARARG_STRING](#tool-source-eq-vararg_string) 31 | - [from-proxy](#from-proxy) 32 | 33 | 34 | 35 | ## and VARARG_STMT 36 | 37 | Logical and of all passed statements 38 | 39 | ``` 40 | (and 41 | (path-contains "/api") 42 | (header-matches "Authorization" r"^Bearer\s+.*$" 43 | ... 44 | (in-scope) 45 | ) 46 | ``` 47 | 48 | ## or VARARG_STMT 49 | 50 | Logical or of all passed statements 51 | 52 | ``` 53 | (or 54 | (in-scope) 55 | (host-matches r".*\.google\.com") 56 | ... 57 | (has-header "X-Secret-Key") 58 | ) 59 | ``` 60 | 61 | ## not STMT 62 | 63 | Negates the given statement 64 | 65 | ``` 66 | (not (has-header "Authorization")) 67 | ``` 68 | 69 | ## in-scope 70 | 71 | Checks if the request is in scope 72 | 73 | ``` 74 | (in-scope) 75 | ``` 76 | 77 | ## host-matches PATTERN 78 | 79 | Check if the host portion of the URL matches the provided pattern. 80 | 81 | *Note* - This does not inspect the `Host` header directly. Although Burp may construct a URL for a request using the Host header and the URL portion of the request, the host portion of the constructed URL _may differ_ from what the client sends in the Host header. In particular: If a transparent ["Invisible"](https://portswigger.net/burp/documentation/desktop/tools/proxy/invisible) proxy is used, the host in the URL will correspond with a hostname that is specified in the [Redirect settings](https://portswigger.net/burp/documentation/desktop/settings/tools/proxy#request-handling). 82 | 83 | *Note* - Following form the above, when used as a response filter (`RES_FILTER`), the URL corresponding to the initiating request is used. 84 | 85 | ``` 86 | (host-matches r".*\.google\.com$") 87 | ``` 88 | 89 | ## method-eq VARARG_STRING 90 | 91 | Checks if the request method is one of the provided strings. Note this is case-sensitive. 92 | 93 | ``` 94 | (method-eq "PUT" "POST") 95 | ``` 96 | 97 | ## path-contains PATTERN 98 | 99 | Checks if the request path contains the given pattern 100 | 101 | ``` 102 | (path-contains "foo.*bar") 103 | ``` 104 | 105 | ## path-matches PATTERN 106 | 107 | Checks if the request path matches the given pattern 108 | 109 | ``` 110 | (path-matches r"^foo.*bar$") 111 | ``` 112 | 113 | ## file-ext-eq VARARG_STRING 114 | 115 | Checks if the requested file extension is any of the given strings. 116 | 117 | ``` 118 | (file-ext-eq ".php" ".js" ... ".html") 119 | ``` 120 | 121 | ## has-header VARARG_STRING 122 | 123 | Checks if the request/response has the given header. Header names are case-sensitive. 124 | 125 | ``` 126 | (has-header "Authorization") 127 | ``` 128 | 129 | ## header-matches STRING PATTERN 130 | 131 | Checks if the request/response has a header that matches the provided pattern. Header names are case-sensitive. 132 | 133 | ``` 134 | (header-matches "Content-Type" r".*application/json.*") 135 | ``` 136 | 137 | ## body-contains PATTERN 138 | 139 | Checks if the body contains the given pattern 140 | 141 | ``` 142 | (body-contains "\"isAdmin\":\\s+false") 143 | ``` 144 | 145 | ## body-matches PATTERN 146 | 147 | Checks if the entire body matches the provided pattern 148 | 149 | ``` 150 | (body-matches "^[0-9]+$") 151 | ``` 152 | 153 | ## has-cookie VARARG_STRING 154 | 155 | Searched for any of the provided cookie names in the req/res body. Returns true if any of the provided cookies exist. 156 | 157 | 158 | ``` 159 | (has-json-key "user.isSuperAdmin") 160 | ``` 161 | 162 | 163 | 164 | 165 | ## has-json-key VARARG_STRING 166 | 167 | Searched for any of the provided JSON keys in the req/res body. This function supported dotted syntax for JSON keys to search for nested keys. Returns true if any of the provided keys match. 168 | 169 | 170 | ``` 171 | (has-json-key "user.isSuperAdmin") 172 | ``` 173 | 174 | 175 | ## has-query-param VARARG_STRING 176 | 177 | Checks that the given query parameter exists 178 | 179 | ``` 180 | (has-query-param "id") 181 | ``` 182 | 183 | ## has-form-param VARARG_STRING 184 | 185 | Checks that the request has the given form parameter 186 | 187 | ``` 188 | (has-form-param "id" "identifier") 189 | ``` 190 | 191 | 192 | ## query-param-matches STRING PATTERN 193 | 194 | Checks that the given query parameter matches the given pattern. If the parameter doesn't exist this evaluates to false. 195 | 196 | ``` 197 | (query-param-matches "id" r"[0-9]+") 198 | ``` 199 | 200 | ## status-code-eq VARARG_INT 201 | 202 | Only applicable as a Response filter, checks that the status code is one of the provided codes 203 | 204 | ``` 205 | (status-code-eq 200 201) 206 | ``` 207 | 208 | ## status-code-in INT INT 209 | 210 | Checks that the status code is in the given range (inclusive) 211 | 212 | ``` 213 | (status-code-in 200 299) 214 | ``` 215 | 216 | ## has-attachment VARARG_STRING 217 | 218 | Attachments are custom pieces of data attached to a request/response by other scripts. This checks to see if the given attachment key exists. 219 | 220 | ``` 221 | (has-attachment "such" "attachments") 222 | ``` 223 | 224 | ## listener-port-eq VARARG_INT 225 | 226 | Checks that the request was to a listener with one of the provided ports 227 | 228 | ``` 229 | (listener-port-eq 9090) 230 | ``` 231 | 232 | ## tool-source-eq VARARG_STRING 233 | 234 | Returns true if the tool source is one of the provided sources. Valid sources: 235 | 236 | - "Suite" 237 | - "Target" 238 | - "Proxy" 239 | - "Scanner" 240 | - "Intruder" 241 | - "Repeater" 242 | - "Logger" 243 | - "Sequencer" 244 | - "Decoder" 245 | - "Comparer" 246 | - "Extensions" 247 | - "Recorded login replayer" 248 | - "Organizer" 249 | 250 | The match is case-insensitive 251 | 252 | ``` 253 | (tool-source-eq "Suite") 254 | ``` 255 | 256 | ## from-proxy 257 | 258 | Returns true of the request/response is associated with the Proxy tool type. This is also controllable via the UI, but it may be useful to have here too. 259 | 260 | ``` 261 | (from-proxy) 262 | ``` 263 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Burpscript 2 | 3 | Burpscript adds dynamic scripting abilities to Burp Suite, allowing you to write scripts in Python or Javascript to manipulate HTTP requests and responses. 4 | 5 | Features: 6 | 7 | - Python 3 and JavaScript support 8 | - Manipulate requests and responses from the Proxy or other tools such as the Repeater 9 | - Conditionally drop requests & responses, or send them to the Intercept tab for manual inspection 10 | - Hot reloading of scripts on file change 11 | - Quickly enable/disable scripts 12 | - Built-in cryptographic utilities 13 | - Filter DSL for easily determining if the plugin's registered handlers should handle a request or response 14 | 15 | ## Installation 16 | 17 | The best way to build this project is to enter the `nix` development environment and then use the build script. 18 | 19 | ```sh 20 | $ nix develop 21 | # Build with Python support 22 | $ ./build.sh 23 | # Build with both Python and JavaScript Support 24 | $ ./build.sh --js --python 25 | ``` 26 | 27 | If you don't want to use `nix`, you'll need to have `gradle` installed and can just run `./build.sh` without it, but this may cause issues with different `gradle` versions. 28 | 29 | The resulting jar file will be in `build/libs/burpscript-plugin-.jar`, which you can then install into Burp Suite through the Extensions -> Add window. For more information, see [Managing extensions](https://portswigger.net/burp/documentation/desktop/extensions/managing-extensions). 30 | 31 | ## Usage 32 | 33 | Burpscript supports writing scripts in JavaScript or Python. When a script is added, Burpscript will call specially named handler functions defined in the script when a request or response is received, allowing scripts an opportunity to manipulate them as they pass through the proxy. Scripts can also define filter expressions using a [Lisp-like DSL](FILTER_EXPRESSIONS.md) to determine which requests and responses they should be applied to. 34 | 35 | **References** 36 | 37 | - The [examples](examples) directory 38 | - The [ScriptHttpRequest](src/main/kotlin/com/carvesystems/burpscript/ScriptHttpRequest.kt) and [ScriptHttpResponse](src/main/kotlin/com/carvesystems/burpscript/ScriptHttpResponse.kt) classes. These define the API that scripts can use to modify requests and responses. 39 | - [Burp Montoya API Javadoc](https://portswigger.github.io/burp-extensions-montoya-api/javadoc/burp/api/montoya/MontoyaApi.html). In particular, [HttpRequestToBeSent](https://portswigger.github.io/burp-extensions-montoya-api/javadoc/burp/api/montoya/http/handler/HttpRequestToBeSent.html) and [HttpResponseReceived](https://portswigger.github.io/burp-extensions-montoya-api/javadoc/burp/api/montoya/http/handler/HttpResponseReceived.html). 40 | 41 | ### Python 42 | 43 | Python scripts look like this. Examples can be found in the [examples](examples/python) directory, and for more information about how Python behaves when interacting with Java, see the [GraalVM interop reference](https://www.graalvm.org/latest/reference-manual/python/Interoperability/). 44 | 45 | ```python 46 | REQ_FILTER = """...""" 47 | RES_FILTER = """...""" 48 | 49 | def initialize(): 50 | print("Initialized Python script") 51 | 52 | 53 | def cleanup(): 54 | print("Cleaning up Python script") 55 | 56 | 57 | def on_request(req): 58 | print(f"{req.method()} - {req.url()}") 59 | return req.withBody("Modified") 60 | 61 | 62 | def on_response(res): 63 | print(f"{res.statusCode()} - {res.reasonPhrase()}") 64 | ``` 65 | 66 | ### JavaScript 67 | 68 | Scripts can be written as either ES6 or CommonJS style modules. Examples can be found in the [examples](examples/js) directory, and for more information about how JavaScript behaves when interacting with Java, see the [GraalVM interop reference](https://www.graalvm.org/latest/reference-manual/js/Interoperability/). 69 | 70 | Scripts with the file extension `.mjs`, are treated as ES6 modules, where exported handlers look like this: 71 | 72 | ```javascript 73 | export const RES_FILTER = "..." 74 | export const REQ_FILTER = "..." 75 | 76 | export function initialize() { 77 | console.log("Initialized the JavaScript module"); 78 | } 79 | 80 | export function cleanup() { 81 | console.log("Cleaning up JavaScript"); 82 | } 83 | 84 | export function onRequest(req) { 85 | console.log(`${req.method()} - ${req.url()}`) 86 | return req.withBody("Modified") 87 | } 88 | 89 | export function onResponse(res) { 90 | console.log(`${res.statusCode()} - ${res.reasonPhrase()}`); 91 | return res; 92 | } 93 | ``` 94 | 95 | Scripts with the extension`.js`, are treated as CommonJS modules, where handlers are exported with `module.exports`: 96 | 97 | ```javascript 98 | module.exports = { 99 | RES_FILTER: "...", 100 | REQ_FILTER: "...", 101 | initialize: function() { 102 | ... 103 | }, 104 | cleanup: function() { 105 | ... 106 | }, 107 | onRequest: function(req) { 108 | ... 109 | }, 110 | onResponse: function(res) { 111 | ... 112 | } 113 | } 114 | ``` 115 | 116 | ### Addons 117 | 118 | Scripts may also define handlers using an "Addon" style, similar to Mitmproxy. Each addon can define their own filter expressions and handlers. This is useful for organizing complex scripts or sharing addons between different scripts. 119 | 120 | **Python** 121 | ```python 122 | class AddonOne: 123 | REQ_FILTER = "..." 124 | def on_request(self, req): 125 | ... 126 | 127 | class AddonTwo: 128 | RES_FILTER = "..." 129 | def on_response(self, res): 130 | ... 131 | 132 | addons = [AddonOne(), AddonTwo()] 133 | ``` 134 | 135 | **JavaScript** 136 | ```javascript 137 | class AddonOne { 138 | // The methods must be declared this way 139 | onRequest = function(req) { 140 | ... 141 | } 142 | } 143 | 144 | class AddonTwo { 145 | RES_FILTER = "..." 146 | onResponse = function(res) { 147 | ... 148 | } 149 | } 150 | 151 | export const addons = [new AddonOne(), new AddonTwo()] 152 | ``` 153 | 154 | ### Script Globals 155 | 156 | Scripts have the following global parameters available: 157 | 158 | - `api` - a [MontoyaApi]((https://portswigger.github.io/burp-extensions-montoya-api/javadoc/burp/api/montoya/MontoyaApi.html)) instance 159 | - `helpers` - an instance of [ScriptHelpers](src/main/kotlin/com/carvesystems/burpscript/ScriptHelpers.kt) 160 | - `log` - an instance of [ScriptLogger](src/main/kotlin/com/carvesystems/burpscript/Logger.kt) 161 | 162 | ### Printing 163 | 164 | Scripts can print messages to the Burpscript Extension tab using the `log` object, or with `console.log` in JavaScript, and `print` in Python. Regular messages go to the Output tab, and errors and exceptions go to the Errors tab (see [Managing extensions](https://portswigger.net/burp/documentation/desktop/extensions/managing-extensions)) 165 | 166 | **Python** 167 | ```python 168 | log.info("This is an info message") 169 | log.error("This is an error message", Exception("Oh no!")) # Goes to Errors tab 170 | print("This is an info message") 171 | ``` 172 | 173 | **JavaScript** 174 | ```javascript 175 | log.info("This is an info message"); 176 | log.error("This is an exception", new Error("On no!")); // Goes to Errors tab 177 | console.log("This is an info message"); 178 | console.error("This is an error message"); // Goes to Errors tab 179 | ``` 180 | 181 | ### Using Java 182 | 183 | Java classes can also be imported and used directly from scripts. In python the `java` module can be imported. In JavaScript, the `Java` global object is available. These can be used to import Java types and use them in scripts. 184 | 185 | **Python** 186 | ```python 187 | import java 188 | 189 | HttpParameter = java.type("burp.api.montoya.http.message.params.HttpParameter") 190 | HttpParameterType = java.type("burp.api.montoya.http.message.params.HttpParameterType") 191 | 192 | def on_request(req): 193 | return req.withParameter( 194 | HttpParameter.parameter("__carve", "injected", HttpParameterType.URL) 195 | ) 196 | ``` 197 | 198 | **JavaScript** 199 | ```javascript 200 | const HttpParameter = Java.type("burp.api.montoya.http.message.params.HttpParameter") 201 | const HttpParameterType = Java.type("burp.api.montoya.http.message.params.HttpParameterType") 202 | 203 | export function onRequest(req) { 204 | return req.withParameter( 205 | HttpParameter.parameter("__carve", "injected", HttpParameterType.URL) 206 | ) 207 | } 208 | ``` 209 | 210 | ### Importing 211 | 212 | Scripts can import other modules that reside in the same directory. Importing 3rd party modules is also supported, however, there are language-specific limitations. 213 | 214 | **Python** 215 | 216 | Here is an example of importing a utility module that resides in the same directory as the script module: 217 | 218 | ```python 219 | # ./myutils.py 220 | def do_something(): 221 | ... 222 | ``` 223 | 224 | ```python 225 | # ./script.py 226 | from myutils import do_something 227 | ``` 228 | 229 | Python standard library modules and 3rd party pypi modules can be imported as well. However, not all modules are supported. 230 | In particular, modules that depend on native code may fail to import. For the most reliable support, we recommend using [GraalPy](https://www.graalvm.org/python/). 231 | 232 | To allow BurpScript to resolve Python module imports, ensure that the Python interpreter executable, and Python path variables are set in the [BurpScript configuration file](examples/conf.json). 233 | 234 | ```bash 235 | $ python -m venv burpscript-env 236 | $ source burpscript-env/bin/activate 237 | (burpscript-env) $ pip install pyjwt 238 | ``` 239 | 240 | ```json 241 | { 242 | "python": { 243 | "executablePath": "/path/to/burpscript-env/bin/python", 244 | "pythonPath": "/path/to/burpscript-env/lib/python3.11/site-packages" 245 | } 246 | } 247 | ``` 248 | 249 | ```python 250 | # script.py 251 | import jwt 252 | 253 | def on_request(req): 254 | token = jwt.encode({"some": "payload"}, "secret", algorithm="HS256") 255 | return req.withHeader("Authorization", f"Bearer {token}") 256 | ``` 257 | 258 | **JavaScript (CommonJS)** 259 | 260 | CommonJS module style (`.js`) scripts can import other modules using the `require` function. 261 | 262 | ```javascript 263 | // ./myutils.js 264 | module.exports = { 265 | doSomething: function() { 266 | ... 267 | } 268 | } 269 | ``` 270 | 271 | ```javascript 272 | // ./script.js 273 | const { doSomething } = require('./myutils.js') 274 | ``` 275 | 276 | Limited support for 3rd party NPM modules is also available, however, modules that depend on Node.js built-ins, such as `fs`, and `buffer`, are not supported. See the [GraalVM JavaScript documentation](https://docs.oracle.com/en/graalvm/enterprise/21/docs/reference-manual/js/NodeJSvsJavaScriptContext/#java-libraries) for more information about the limitations. To use 3rd party NPM modules, ensure that the `node_modules` directory is present in the same directory as the script module. 277 | 278 | ```bash 279 | $ npm i lodash 280 | $ ls 281 | node_modules/ package.json package-lock.json script.js 282 | ``` 283 | 284 | ```javascript 285 | // script.js 286 | const _ = require('lodash') 287 | 288 | module.exports = { 289 | onRequest: function(req) { 290 | return req.withHeader("X-RAND", `${_.random(0, 100)}`) 291 | } 292 | } 293 | ``` 294 | 295 | **JavaScript (ES6)** 296 | 297 | If the script is written using the ES6 module style (`.mjs`), path-adjacent `.mjs` files can be imported using the `import` statement. 298 | 299 | ```javascript 300 | // ./myutils.mjs 301 | export function doSomething() { 302 | ... 303 | } 304 | ``` 305 | 306 | ```javascript 307 | // ./script.mjs 308 | import { doSomething } from './myutils.mjs' 309 | ``` 310 | 311 | 312 | ### Limitations 313 | 314 | There are some limitations with the polyglot API and how values are handled between the script and JVM. If you run into issues with this, it may be difficult to debug exactly what has gone wrong. We're working on helper functions to make these issues easier to deal with. Also, sometimes `import` statements in Python don't work. If you run into such issues, sometimes it may be easier to use `helpers.exec(...)` or `helpers.execStdin(...)`. 315 | 316 | ## Filter Expressions 317 | 318 | Filter expressions are a Lisp-like DSL for selecting requests/responses that should be forwarded on to a script. See [FILTER_EXPRESSIONS.md](FILTER_EXPRESSIONS.md) for documentation. 319 | 320 | ## Configuration 321 | 322 | Configuration is available via the `${XDG_CONFIG_HOME:-$HOME/.config}/burpscript/conf.json` file. An example config is shown in [the examples dir](examples/conf.json). 323 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask 2 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 3 | import java.util.* 4 | 5 | val burpVersion = "2025.4" 6 | val graalVersion = "24.2.1" 7 | val kotlinxVersion = "1.8.1" 8 | val kotestVersion = "5.9.1" 9 | 10 | plugins { 11 | java 12 | `java-library` 13 | kotlin("jvm") version "2.1.10" 14 | id("antlr") 15 | id("com.github.johnrengelman.shadow") version "8.1.1" 16 | kotlin("plugin.serialization") version "2.1.10" 17 | } 18 | 19 | val pluginVersion = "0.9.0" 20 | 21 | group = "com.carvesystems.burpscript" 22 | version = pluginVersion 23 | 24 | repositories { 25 | mavenCentral() 26 | gradlePluginPortal() 27 | } 28 | 29 | val isRunningInIntelliJ: Boolean = System.getProperty("idea.active") == "true" 30 | val isTestTaskRequested: Boolean = gradle.startParameter.taskNames.any { 31 | it.lowercase(Locale.getDefault()).contains("test") || it == "check" 32 | } 33 | 34 | val enableLangPy = 35 | System.getProperty("burpscript.langPython", "on") == "on" 36 | || isRunningInIntelliJ 37 | || isTestTaskRequested 38 | 39 | val enableLangJs = 40 | System.getProperty("burpscript.langJs", "off") == "on" 41 | || isRunningInIntelliJ 42 | || isTestTaskRequested 43 | 44 | val langDeps = mutableListOf( 45 | "org.graalvm.polyglot:polyglot:$graalVersion", 46 | ) 47 | 48 | if (enableLangPy) { 49 | langDeps.add("org.graalvm.polyglot:python-community:$graalVersion") 50 | langDeps.add("org.graalvm.polyglot:llvm-community:$graalVersion") 51 | println("Python language enabled") 52 | } 53 | 54 | if (enableLangJs) { 55 | langDeps.add("org.graalvm.polyglot:js-community:$graalVersion") 56 | println("JavaScript language enabled") 57 | } 58 | 59 | val testDeps = listOf( 60 | "io.kotest:kotest-assertions-core-jvm:$kotestVersion", 61 | "io.kotest:kotest-assertions-json:$kotestVersion", 62 | "io.kotest:kotest-framework-engine-jvm:$kotestVersion", 63 | "io.kotest:kotest-property-jvm:$kotestVersion", 64 | "io.kotest:kotest-runner-junit5:$kotestVersion", 65 | "io.mockk:mockk:1.14.2", 66 | ) 67 | 68 | val generatedDir: File = layout.buildDirectory.file("generated/source/burpscript/main/kotlin").get().asFile 69 | 70 | val internalTarget = "internal" 71 | 72 | val integrationTestTarget = "integrationTest" 73 | 74 | sourceSets { 75 | main { 76 | java { 77 | srcDirs( 78 | "src/main/kotlin", 79 | generatedDir 80 | ) 81 | } 82 | } 83 | 84 | create(internalTarget) { 85 | java { 86 | srcDir("src/internal/kotlin") 87 | } 88 | } 89 | } 90 | 91 | dependencies { 92 | antlr("org.antlr:antlr4:4.10.1") 93 | 94 | api("net.portswigger.burp.extensions:montoya-api:$burpVersion") 95 | implementation(kotlin("stdlib")) 96 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxVersion") 97 | langDeps.forEach { dependencies.add("implementation", it) } 98 | 99 | "${internalTarget}Implementation"(project) 100 | langDeps.forEach { dependencies.add("${internalTarget}Implementation", it) } 101 | testDeps.forEach { dependencies.add("${internalTarget}Implementation", it) } 102 | } 103 | 104 | testing { 105 | suites { 106 | withType { 107 | useJUnitJupiter() 108 | 109 | dependencies { 110 | implementation(project()) 111 | implementation(sourceSets[internalTarget].output) 112 | langDeps.forEach { implementation(it) } 113 | testDeps.forEach { implementation(it) } 114 | } 115 | } 116 | 117 | register(integrationTestTarget) { 118 | sources { 119 | java { 120 | srcDir("src/integrationTest/kotlin") 121 | } 122 | } 123 | 124 | targets { 125 | all { 126 | testTask.configure { 127 | shouldRunAfter("test") 128 | } 129 | } 130 | } 131 | } 132 | } 133 | } 134 | 135 | java { 136 | sourceCompatibility = JavaVersion.VERSION_22 137 | } 138 | 139 | kotlin { 140 | compilerOptions { 141 | jvmTarget.set(JvmTarget.JVM_22) 142 | } 143 | } 144 | 145 | tasks { 146 | 147 | withType { 148 | arguments.addAll(arrayOf("-visitor", "-no-listener")) 149 | } 150 | 151 | compileKotlin { 152 | dependsOn("generateGrammarSource") 153 | } 154 | 155 | compileTestKotlin { 156 | dependsOn("generateTestGrammarSource") 157 | } 158 | 159 | named("compileIntegrationTestKotlin") { 160 | dependsOn("generateIntegrationTestGrammarSource") 161 | } 162 | 163 | named("compileInternalKotlin") { 164 | dependsOn("generateInternalGrammarSource") 165 | } 166 | 167 | val checkLanguage by registering { 168 | if (!(enableLangPy || enableLangJs)) { 169 | error("A guest language must be configured") 170 | } 171 | } 172 | 173 | shadowJar { 174 | archiveBaseName.set("burpscript-plugin") 175 | archiveClassifier.set("") 176 | archiveVersion.set(pluginVersion) 177 | mergeServiceFiles() 178 | } 179 | 180 | jar { 181 | dependsOn(checkLanguage) 182 | } 183 | 184 | check { 185 | dependsOn(named(integrationTestTarget)) 186 | } 187 | } 188 | 189 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | 4 | executable="$0" 5 | 6 | langSet=0 7 | 8 | LPY=Python 9 | usePy=0 10 | 11 | LJS=Js 12 | useJs=0 13 | 14 | usage() { 15 | printf "Usage: %s\n\n" "$executable" 16 | printf "Build the Burp plugin jar\n\n" 17 | printf " -p/--python\tBuild with Python support\n" 18 | printf " -j/--js\tBuild with JavaScript support\n" 19 | } 20 | 21 | for arg in "$@"; do 22 | shift 23 | case "$arg" in 24 | "--help") set -- "$@" "-h" ;; 25 | "--js") set -- "$@" "-j" ;; 26 | "--python") set -- "$@" "-p" ;; 27 | *) set -- "$@" "$arg" ;; 28 | esac 29 | done 30 | 31 | 32 | while getopts hjp opt; 33 | do 34 | case $opt in 35 | p) langSet=1; usePy=1;; 36 | j) langSet=1; useJs=1;; 37 | h) usage; exit 0;; 38 | ?) usage; exit 2;; 39 | esac 40 | done 41 | 42 | if [ "$langSet" -eq "0" ]; then 43 | # Default to Python 44 | echo "Defaulting to Python only support" 45 | usePy=1 46 | fi 47 | 48 | args="" 49 | 50 | if [ "$usePy" -eq "1" ]; then 51 | args="$args -Dburpscript.lang$LPY=on" 52 | else 53 | args="$args -Dburpscript.lang$LPY=off" 54 | fi 55 | 56 | if [ "$useJs" -eq "1" ]; then 57 | args="$args -Dburpscript.lang$LJS=on" 58 | else 59 | args="$args -Dburpscript.lang$LJS=off" 60 | fi 61 | 62 | args="$args shadowJar -x check" 63 | 64 | if gradle $args; then 65 | ver=$(grep "pluginVersion =" build.gradle.kts | cut -d'=' -f2 | tr -d '" ') 66 | printf "JAR file can be found at build/libs/burpscript-plugin-%s.jar\n" "$ver" 67 | fi 68 | -------------------------------------------------------------------------------- /examples/conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "python": { 3 | "executablePath": "/path/to/venv/bin/python3", 4 | "pythonPath": "/path/to/venv/lib/python3.11/site-packages:/path/to/venv/lib64/python3.11/site-packages", 5 | "contextOptions": [ 6 | { 7 | "opt": "python.option", 8 | "value": "such wow" 9 | } 10 | ] 11 | }, 12 | 13 | "js": { 14 | "contextOptions": [ 15 | { 16 | "opt": "js.option", 17 | "value": "such wow" 18 | } 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/js/addons.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Can also define classes that have all of the same keys and put an instance 3 | * of them in `addons` 4 | */ 5 | 6 | class Addon { 7 | 8 | RES_FILTER = "(and true)" 9 | REQ_FILTER = "(and true)" 10 | 11 | // Note that methods must be declared this way 12 | onRequest = function(req) { 13 | log.info("Addon.onRequest"); 14 | } 15 | 16 | /* 17 | onRequest(req) { 18 | log.info("THIS DOESN'T WORK"); 19 | } 20 | */ 21 | 22 | onResponse = function(res) { 23 | log.info("Addon.onResponse"); 24 | } 25 | 26 | } 27 | 28 | class AnotherAddon { 29 | 30 | RES_FILTER = "(and true)" 31 | REQ_FILTER = "(and true)" 32 | 33 | onRequest = function(req) { 34 | log.info("AnotherAddon.onRequest"); 35 | return req; 36 | } 37 | 38 | onResponse = function(res) { 39 | log.info("AnotherAddon.onResponse"); 40 | return res; 41 | } 42 | 43 | } 44 | 45 | /** 46 | * This provides the plugin with your additional addons. Addons are executed in this order 47 | */ 48 | export const addons = [new Addon(), new AnotherAddon()]; 49 | -------------------------------------------------------------------------------- /examples/js/basic.js: -------------------------------------------------------------------------------- 1 | /** 2 | * CommonJS style module (must be .js) 3 | */ 4 | 5 | const RES_FILTER = "(and true)" 6 | const REQ_FILTER = "(and true)" 7 | 8 | const HttpParameter = Java.type("burp.api.montoya.http.message.params.HttpParameter") 9 | const HttpParameterType = Java.type("burp.api.montoya.http.message.params.HttpParameterType") 10 | 11 | function initialize() { 12 | log.info("Initialized the JavaScript module"); 13 | } 14 | 15 | function cleanup() { 16 | log.info("Cleaning up JavaScript"); 17 | } 18 | 19 | function onRequest(req) { 20 | log.info(`${req.method()} - ${req.url()}`) 21 | return req.withParameter( 22 | HttpParameter.parameter("__ivision", "injected", HttpParameterType.URL) 23 | ) 24 | } 25 | 26 | function onResponse(res) { 27 | log.info(`${res.statusCode()} - ${res.reasonPhrase()}`); 28 | return res; 29 | } 30 | 31 | module.exports = { 32 | RES_FILTER, 33 | REQ_FILTER, 34 | initialize, 35 | cleanup, 36 | onRequest, 37 | onResponse 38 | } 39 | -------------------------------------------------------------------------------- /examples/js/basic.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Everything can be defined at the top level for quick scripts that only 3 | * perform a single action. 4 | * 5 | * ES6-style module (must be .mjs) 6 | */ 7 | export const RES_FILTER = "(and true)" 8 | export const REQ_FILTER = "(and true)" 9 | 10 | const HttpParameter = Java.type("burp.api.montoya.http.message.params.HttpParameter") 11 | const HttpParameterType = Java.type("burp.api.montoya.http.message.params.HttpParameterType") 12 | 13 | export function initialize() { 14 | log.info("Initialized the JavaScript module"); 15 | } 16 | 17 | export function cleanup() { 18 | log.info("Cleaning up JavaScript"); 19 | } 20 | 21 | export function onRequest(req) { 22 | log.info(`${req.method()} - ${req.url()}`) 23 | return req.withParameter( 24 | HttpParameter.parameter("__ivision", "injected", HttpParameterType.URL) 25 | ) 26 | } 27 | 28 | export function onResponse(res) { 29 | log.info(`${res.statusCode()} - ${res.reasonPhrase()}`); 30 | return res; 31 | } 32 | -------------------------------------------------------------------------------- /examples/js/common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Commmon things that can be imported by scripts with `require`. 3 | */ 4 | 5 | function doSomething() { 6 | console.log('Doing something...'); 7 | } 8 | 9 | module.exports = { doSomething } 10 | -------------------------------------------------------------------------------- /examples/js/common.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Commmon things that can be imported by scripts with `import` 3 | */ 4 | 5 | export function doSomething() { 6 | console.log("Doing something"); 7 | } 8 | -------------------------------------------------------------------------------- /examples/js/crypto.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | Usage of built-in crypto functions (ScriptCryptoHelper) 3 | **/ 4 | 5 | const CLIENT_PRIV_KEY = ` 6 | -----BEGIN PRIVATE KEY----- 7 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCYGaqn86RVCoUP 8 | mu8YoosxUDDvd47v7va7/u1C34B/BIyWq0v7MG8Y/HjkTohrRahiwNydLz66xXQL 9 | y5zRFTHwbwZ6MXXVn0Qo4f9MqSWveDW5ihXM+Q1yriAMwG6D512pu7Suw0b82Hd0 10 | 2xLY05QJ42YGR3T2DUJXL4U1K+xsu+XN4ylCCDcERgjYWoQ+Ir/IjvVYhPeBYTof 11 | 2obddOEfwaBuWbadfkhQPCCIyTxAdxxDLEbEpnFLrF3Nva5SLn3uCEGaWG4HvdKM 12 | yJ9TmBj5TtVbLHVhTYYhtsaMjaqnlJerp4UnoJ+asbbjzkP+tREeLQtXwS2wndp+ 13 | K2T9bA7bAgMBAAECggEACiT0F92NIUrhUwgfWEJHDFPv35jWxLPoauN2yZYEiPQx 14 | uD7Wg3tYfY8hNQDz4ku0DloUnLsw8N4IflznKZ7DROjywqWX2VaVAjEIiQFjDQ/0 15 | bVqDV7doqTRp2M/gzxVYTuDBDULi8iwx025lFGcQIZS0EkkjyOFbglseBEzYqOvI 16 | 8gb14je1FkadssdSY8AJY/pQ2OdfM+p7tf8TodrlsEzuvRdFX7E+4rI2woyEzSYu 17 | D4uNH0CiC1OhWB2ckFoL/tKH3D95jAY1WAdM295xkvLQpZRkENlrvYrtHvwvktXF 18 | 1yqzObtijEeJhEqbm53KTdFU0ZFpFtlZnj1/e5MrEQKBgQDMslvoBVuFiJgCdT+R 19 | 5VOn3BoBh0B8XF++xwjr5F8HwAJw1hdf1oDJMmFmJ3npFBMoWXA7gPfX8gpu91ys 20 | x575xrXo/mtOZUcN+En/n4MNDuWDN5AjsLREPexrvIZ5rYP+BZoh2UkeKjWVot98 21 | XQJtTT/4umcGjhCunI4ayjedkwKBgQC+OKReRrawyALVKsMGyGoOQlWg/Vsy+N0x 22 | DgpDIA998COCpH/O1/BI8d04QZu8Q3eeiYfDLcrAtcdgiE+DVGh9Xg1BMeYbgXIt 23 | lAwDYEEBSruS5FrrapTjigaGCnX4DDNZlDdMrGyw1yY5Cs8KvLua/s5YjPCTPGmm 24 | 5dMSQ3LWmQKBgHuoV/svmV1u6h26BQA3ILVsMs2vjlZSW4jdplcS7BG7ff36Z75+ 25 | z+g7pjlXKb+TYAtlFHbt70umLYVhq7u5ECHmWCh74glHB4i58MIa88lksWP2of3d 26 | ltkO648eIcLJ/s3rRnSiVhiB+UL/VLFFYtzy6O1ydiCwnAVQEEzA0p4/AoGAFnTn 27 | ar3caYhjVTkkJxPX+XD5XPUsJBtfOaBXs88AJTUJbC3xbMDvfB0Zqb+NHC+22n+Q 28 | CInKau/K5umQwYdggpRs6ipy6QJiMWFN/cQKSJXDCTduSGafxzEPThnEDZGbKlMm 29 | KCYe+s2blJZjFPhtCYJVZ/zTlf5G1s5BGeHel9kCgYBjhvI9d2yqZZ66fGkQDhFj 30 | 244k1h2rPMq19/AiNjb6WgWZnEhE0YJnE+AxfHEw09t2hPrDPuH+XDsBKhzUMVXo 31 | hBFyUITkrLhyNHY7RFG06cwDYNK5PAxzC8Jy5lZCB8CoPDMeClwWz2+SNEd1CmKj 32 | izvvI8GSCdVzqugOIS6ltg== 33 | -----END PRIVATE KEY----- 34 | ` 35 | 36 | const CLIENT_PUB_KEY = ` 37 | -----BEGIN PUBLIC KEY----- 38 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmBmqp/OkVQqFD5rvGKKL 39 | MVAw73eO7+72u/7tQt+AfwSMlqtL+zBvGPx45E6Ia0WoYsDcnS8+usV0C8uc0RUx 40 | 8G8GejF11Z9EKOH/TKklr3g1uYoVzPkNcq4gDMBug+ddqbu0rsNG/Nh3dNsS2NOU 41 | CeNmBkd09g1CVy+FNSvsbLvlzeMpQgg3BEYI2FqEPiK/yI71WIT3gWE6H9qG3XTh 42 | H8Ggblm2nX5IUDwgiMk8QHccQyxGxKZxS6xdzb2uUi597ghBmlhuB73SjMifU5gY 43 | +U7VWyx1YU2GIbbGjI2qp5SXq6eFJ6CfmrG2485D/rURHi0LV8EtsJ3afitk/WwO 44 | 2wIDAQAB 45 | -----END PUBLIC KEY----- 46 | ` 47 | 48 | class RSACrypto { 49 | /** 50 | In this example, information is sent back to the client, 51 | encrypted using the client's public RSA key. The script 52 | intercepts and modifies the data bound for the client. 53 | **/ 54 | 55 | RESP_FILTER = ` 56 | (and 57 | (has-header "X-Encrypted-Data") 58 | (path-contains "/rsa-example") 59 | ) 60 | ` 61 | 62 | onResponse = function(resp) { 63 | const decRsa = helpers.getCryptoHelper().newCipher("RSA/ECB/PKCS1Padding"); 64 | decRsa.setKey(CLIENT_PRIV_KEY); 65 | 66 | const encRsa = helpers.getCryptoHelper().newCipher("RSA/ECB/PKCS1Padding"); 67 | encRsa.setKey(CLIENT_PUB_KEY); 68 | 69 | // Uint8Array.fromBase64 is not supported by graaljs, use helpers 70 | var data = helpers.unb64( 71 | resp.header("X-Encrypted-Data").value() 72 | ); 73 | 74 | // TextEncoder is not supported by graaljs 75 | const decrypted = String.fromCharCode( 76 | ...decRsa.decrypt(data) 77 | ); 78 | 79 | const obj = JSON.parse(decrypted); 80 | console.log(`Decrypted data: ${decrypted}`); 81 | 82 | obj["authorized"] = true; 83 | 84 | const encrypted = encRsa.encrypt( 85 | Uint8Array.from(JSON.stringify(obj)) 86 | ); 87 | 88 | // Uint8Array.toBase64 is not supported by graaljs, use helpers 89 | data = helpers.b64(encrypted); 90 | return resp.withUpdatedHeader("X-Encrypted-Data", data); 91 | } 92 | } 93 | 94 | export const addons = [ new RSACrypto() ]; 95 | -------------------------------------------------------------------------------- /examples/js/drop.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Conditionally drop requests and responses 3 | */ 4 | 5 | export const REQ_FILTER = `(path-matches r"^/logout$")` 6 | export const RES_FILTER = `(path-matches r"^/login$")` 7 | 8 | export function onRequest(req) { 9 | log.info("Dropping logout") 10 | return req.drop() 11 | } 12 | 13 | export function onResponse(res) { 14 | try { 15 | const obj = res.bodyToJson(); 16 | if (obj.status == "failed") { 17 | log.info("Dropping failed login response") 18 | return res.drop() 19 | } 20 | } catch (e) { 21 | log.error(e) 22 | } 23 | 24 | return res 25 | } 26 | -------------------------------------------------------------------------------- /examples/js/highlight.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Set highlight color of a request, add notes. These are visible in the Proxy history. 3 | */ 4 | 5 | export const REQ_FILTER = `(path-matches r"/api/endpoint")` 6 | 7 | export function onRequest(req) { 8 | helpers.setHighlight(req, "yellow") 9 | return req 10 | } 11 | 12 | export function onResponse(res) { 13 | if (res.statusCode() == 200) { 14 | helpers.setNotes(res, "Yep") 15 | } else { 16 | helpers.setNotes(res, "Nope") 17 | } 18 | return res 19 | } 20 | -------------------------------------------------------------------------------- /examples/js/import.mjs: -------------------------------------------------------------------------------- 1 | import { doSomething } from "./common.mjs"; 2 | 3 | export function onRequest(req) { 4 | doSomething(); 5 | return req; 6 | } 7 | 8 | export function onResponse(res) { 9 | return res; 10 | } -------------------------------------------------------------------------------- /examples/js/intercept.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Conditionally intercept requests and responses 3 | */ 4 | export const REQ_FILTER = `(path-matches r"^/login$")` 5 | export const RES_FILTER = REQ_FILTER 6 | 7 | export function onRequest(req) { 8 | log.info("Intercepting login request") 9 | return req.intercept() 10 | } 11 | 12 | export function onResponse(res) { 13 | log.info("Intercepting login response") 14 | return res.intercept() 15 | } 16 | -------------------------------------------------------------------------------- /examples/js/log.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * `log` global prints messages to the Output and Errors tab in Extensions -> Burp Scripting 3 | * console.log, console.error, console.warn, console.info are also available 4 | * 5 | * [logging.mjs][INFO] - GET - https://www.ivision.com/ 6 | * [logging.mjs][INFO] - Logs INFO 7 | * [logging.mjs][DEBUG] - Got a response 8 | * [logging.mjs][INFO] - {'key': 'value'} 9 | * [logging.mjs][ERROR] - oh no 10 | * TypeError: Cannot set property 'foo' of null 11 | */ 12 | 13 | export function onRequest(req) { 14 | log.info(`${req.method()} - ${req.url()}`) 15 | console.log("Logs INFO") 16 | return req 17 | } 18 | 19 | export function onResponse(res) { 20 | log.info(`${res.statusCode()} - ${res.reasonPhrase()}`); 21 | const obj = res.bodyToJson(); 22 | console.log(obj); 23 | try { 24 | const obj = null; 25 | obj.foo = "bar"; 26 | } catch (error) { 27 | log.error("oh no", error); 28 | } 29 | return res; 30 | } 31 | -------------------------------------------------------------------------------- /examples/js/require.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Import from CommonJs module 3 | */ 4 | 5 | const { doSomething } = require('./common.js'); 6 | 7 | function initialize() { 8 | log.info("Initialized the JavaScript module"); 9 | } 10 | 11 | function cleanup() { 12 | log.info("Cleaning up JavaScript"); 13 | } 14 | 15 | function onRequest(req) { 16 | doSomething(); 17 | return req; 18 | } 19 | 20 | function onResponse(res) { 21 | return res 22 | } 23 | 24 | module.exports = { initialize, cleanup, onRequest, onResponse } 25 | 26 | -------------------------------------------------------------------------------- /examples/js/rewrite_host.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Re-write request Host header. 3 | * 4 | * This can be useful in situations where you are using a transparent "Invisible" proxy 5 | * but for one reason or another, it isn't convenient or possible to get the client to 6 | * send requests with the Host header corresponding to the actual target. 7 | * 8 | * Similarly, Burp can be configured to forward requests to specific targets, but it 9 | * does not rewrite the Host header to the forward destination. 10 | * 11 | * - https://portswigger.net/burp/documentation/desktop/tools/proxy/invisible 12 | * - https://portswigger.net/burp/documentation/desktop/settings/tools/proxy#request-handling 13 | */ 14 | 15 | export const REQ_FILTER = `(header-matches "Host" ".*not-ivision.com.*")` 16 | 17 | export function onRequest(req) { 18 | console.log(req.url()) 19 | const fromHost = req.header("Host").value() 20 | const toHost = "ivision.com" 21 | console.log(`Rewriting Host header from ${fromHost} to ${toHost}`) 22 | return req.withUpdatedHeader("Host", toHost) 23 | } -------------------------------------------------------------------------------- /examples/python/addons.py: -------------------------------------------------------------------------------- 1 | 2 | class Addon: 3 | 4 | REQ_FILTER = "(and true)" 5 | RES_FILTER = "(and true)" 6 | 7 | def on_request(self, req): 8 | pass 9 | 10 | def on_response(self, res): 11 | pass 12 | 13 | class AnotherAddon: 14 | 15 | REQ_FILTER = "(and true)" 16 | RES_FILTER = "(and true)" 17 | 18 | def on_request(self, req): 19 | pass 20 | 21 | def on_response(self, res): 22 | pass 23 | 24 | # Addons are executed in this order: 25 | addons = [Addon(), AnotherAddon()] 26 | -------------------------------------------------------------------------------- /examples/python/basic.py: -------------------------------------------------------------------------------- 1 | import java 2 | 3 | HttpParameter = java.type("burp.api.montoya.http.message.params.HttpParameter") 4 | HttpParameterType = java.type("burp.api.montoya.http.message.params.HttpParameterType") 5 | 6 | REQ_FILTER = """ 7 | (and 8 | (in-scope) 9 | (path-contains "api") 10 | ) 11 | """ 12 | 13 | RES_FILTER = """ 14 | (header-matches "Content-Type" r"application/json") 15 | """ 16 | 17 | def initialize(): 18 | log.info("Initialized Python script") 19 | 20 | 21 | def cleanup(): 22 | log.info("Cleaning up Python script") 23 | 24 | 25 | def on_request(req): 26 | log.info(f"{req.method()} - {req.url()}") 27 | return req.withParameter( 28 | HttpParameter.parameter("__ivision", "injected", HttpParameterType.URL) 29 | ) 30 | 31 | 32 | def on_response(res): 33 | log.info(f"{res.statusCode()} - {res.reasonPhrase()}") 34 | 35 | -------------------------------------------------------------------------------- /examples/python/common.py: -------------------------------------------------------------------------------- 1 | 2 | def do_something(): 3 | print("Doing something") 4 | -------------------------------------------------------------------------------- /examples/python/crypto.py: -------------------------------------------------------------------------------- 1 | """ 2 | Usage of built-in crypto functions (ScriptCryptoHelper) 3 | """ 4 | 5 | import json 6 | from base64 import b64encode, b64decode 7 | 8 | 9 | CLIENT_PRIV_KEY = """ 10 | -----BEGIN PRIVATE KEY----- 11 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCYGaqn86RVCoUP 12 | mu8YoosxUDDvd47v7va7/u1C34B/BIyWq0v7MG8Y/HjkTohrRahiwNydLz66xXQL 13 | y5zRFTHwbwZ6MXXVn0Qo4f9MqSWveDW5ihXM+Q1yriAMwG6D512pu7Suw0b82Hd0 14 | 2xLY05QJ42YGR3T2DUJXL4U1K+xsu+XN4ylCCDcERgjYWoQ+Ir/IjvVYhPeBYTof 15 | 2obddOEfwaBuWbadfkhQPCCIyTxAdxxDLEbEpnFLrF3Nva5SLn3uCEGaWG4HvdKM 16 | yJ9TmBj5TtVbLHVhTYYhtsaMjaqnlJerp4UnoJ+asbbjzkP+tREeLQtXwS2wndp+ 17 | K2T9bA7bAgMBAAECggEACiT0F92NIUrhUwgfWEJHDFPv35jWxLPoauN2yZYEiPQx 18 | uD7Wg3tYfY8hNQDz4ku0DloUnLsw8N4IflznKZ7DROjywqWX2VaVAjEIiQFjDQ/0 19 | bVqDV7doqTRp2M/gzxVYTuDBDULi8iwx025lFGcQIZS0EkkjyOFbglseBEzYqOvI 20 | 8gb14je1FkadssdSY8AJY/pQ2OdfM+p7tf8TodrlsEzuvRdFX7E+4rI2woyEzSYu 21 | D4uNH0CiC1OhWB2ckFoL/tKH3D95jAY1WAdM295xkvLQpZRkENlrvYrtHvwvktXF 22 | 1yqzObtijEeJhEqbm53KTdFU0ZFpFtlZnj1/e5MrEQKBgQDMslvoBVuFiJgCdT+R 23 | 5VOn3BoBh0B8XF++xwjr5F8HwAJw1hdf1oDJMmFmJ3npFBMoWXA7gPfX8gpu91ys 24 | x575xrXo/mtOZUcN+En/n4MNDuWDN5AjsLREPexrvIZ5rYP+BZoh2UkeKjWVot98 25 | XQJtTT/4umcGjhCunI4ayjedkwKBgQC+OKReRrawyALVKsMGyGoOQlWg/Vsy+N0x 26 | DgpDIA998COCpH/O1/BI8d04QZu8Q3eeiYfDLcrAtcdgiE+DVGh9Xg1BMeYbgXIt 27 | lAwDYEEBSruS5FrrapTjigaGCnX4DDNZlDdMrGyw1yY5Cs8KvLua/s5YjPCTPGmm 28 | 5dMSQ3LWmQKBgHuoV/svmV1u6h26BQA3ILVsMs2vjlZSW4jdplcS7BG7ff36Z75+ 29 | z+g7pjlXKb+TYAtlFHbt70umLYVhq7u5ECHmWCh74glHB4i58MIa88lksWP2of3d 30 | ltkO648eIcLJ/s3rRnSiVhiB+UL/VLFFYtzy6O1ydiCwnAVQEEzA0p4/AoGAFnTn 31 | ar3caYhjVTkkJxPX+XD5XPUsJBtfOaBXs88AJTUJbC3xbMDvfB0Zqb+NHC+22n+Q 32 | CInKau/K5umQwYdggpRs6ipy6QJiMWFN/cQKSJXDCTduSGafxzEPThnEDZGbKlMm 33 | KCYe+s2blJZjFPhtCYJVZ/zTlf5G1s5BGeHel9kCgYBjhvI9d2yqZZ66fGkQDhFj 34 | 244k1h2rPMq19/AiNjb6WgWZnEhE0YJnE+AxfHEw09t2hPrDPuH+XDsBKhzUMVXo 35 | hBFyUITkrLhyNHY7RFG06cwDYNK5PAxzC8Jy5lZCB8CoPDMeClwWz2+SNEd1CmKj 36 | izvvI8GSCdVzqugOIS6ltg== 37 | -----END PRIVATE KEY----- 38 | """ 39 | 40 | CLIENT_PUB_KEY = """ 41 | -----BEGIN PUBLIC KEY----- 42 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmBmqp/OkVQqFD5rvGKKL 43 | MVAw73eO7+72u/7tQt+AfwSMlqtL+zBvGPx45E6Ia0WoYsDcnS8+usV0C8uc0RUx 44 | 8G8GejF11Z9EKOH/TKklr3g1uYoVzPkNcq4gDMBug+ddqbu0rsNG/Nh3dNsS2NOU 45 | CeNmBkd09g1CVy+FNSvsbLvlzeMpQgg3BEYI2FqEPiK/yI71WIT3gWE6H9qG3XTh 46 | H8Ggblm2nX5IUDwgiMk8QHccQyxGxKZxS6xdzb2uUi597ghBmlhuB73SjMifU5gY 47 | +U7VWyx1YU2GIbbGjI2qp5SXq6eFJ6CfmrG2485D/rURHi0LV8EtsJ3afitk/WwO 48 | 2wIDAQAB 49 | -----END PUBLIC KEY----- 50 | """ 51 | 52 | 53 | class RSACrypto: 54 | """ 55 | In this example, information is sent back to the client, 56 | encrypted using the client's public RSA key. The script 57 | intercepts and modifies the data bound for the client. 58 | """ 59 | 60 | RESP_FILTER = """ 61 | (and 62 | (has-header "X-Encrypted-Data") 63 | (path-contains "/rsa-example") 64 | ) 65 | """ 66 | 67 | def on_response(self, resp): 68 | dec_rsa = helpers.getCryptoHelper().newCipher("RSA/ECB/PKCS1Padding") 69 | dec_rsa.setKey(CLIENT_PRIV_KEY) 70 | 71 | enc_rsa = helpers.getCryptoHelper().newCipher("RSA/ECB/PKCS1Padding") 72 | enc_rsa.setKey(CLIENT_PUB_KEY) 73 | 74 | data = b64decode( 75 | resp.header("X-Encrypted-Data").value() 76 | ) 77 | 78 | # `decrypt` returns a Java byte array, convert this to python bytes object 79 | decrypted = bytes(dec_rsa.decrypt(data)) 80 | obj = json.loads( 81 | decrypted.decode() 82 | ) 83 | print(f"Decrypted data: {obj}") 84 | 85 | obj["authorized"] = True 86 | 87 | # `encrypt` returns a Java byte array, convert this to python bytes object 88 | encrypted = bytes(enc_rsa.encrypt(json.dumps(obj).encode())) 89 | 90 | data = b64encode(encrypted).decode() 91 | return resp.withUpdatedHeader("X-Encrypted-Data", data) 92 | 93 | 94 | addons = [ RSACrypto() ] 95 | -------------------------------------------------------------------------------- /examples/python/drop.py: -------------------------------------------------------------------------------- 1 | """ 2 | Conditionally drop requests and responses 3 | """ 4 | 5 | REQ_FILTER = """ 6 | (path-matches r"^/logout$") 7 | """ 8 | 9 | RES_FILTER = """ 10 | (path-matches r"^/login$") 11 | """ 12 | 13 | def on_request(req): 14 | log.info("Dropping logout") 15 | return req.drop() 16 | 17 | def on_response(res): 18 | try: 19 | obj = res.bodyToJson() 20 | if obj.get("status") == "failed": 21 | log.info("Dropping failed login response") 22 | return res.drop() 23 | except Exception as e: 24 | pass 25 | 26 | return res 27 | -------------------------------------------------------------------------------- /examples/python/highlight.py: -------------------------------------------------------------------------------- 1 | """ 2 | Set highlight color of a request, add notes. These are visible in the Proxy history. 3 | """ 4 | 5 | REQ_FILTER = """ 6 | (path-matches r"/api/endpoint") 7 | """ 8 | 9 | def on_request(req): 10 | helpers.setHighlight(req, "yellow") 11 | return req 12 | 13 | def on_response(res): 14 | if res.statusCode() == 200: 15 | helpers.setNotes(res, "Yep") 16 | else: 17 | helpers.setNotes(res, "Nope") 18 | return res 19 | -------------------------------------------------------------------------------- /examples/python/import.py: -------------------------------------------------------------------------------- 1 | 2 | from common import do_something 3 | 4 | def on_request(req): 5 | do_something() 6 | return req 7 | 8 | 9 | def on_response(res): 10 | return res 11 | 12 | -------------------------------------------------------------------------------- /examples/python/intercept.py: -------------------------------------------------------------------------------- 1 | """ 2 | Conditionally intercept requests and responses 3 | """ 4 | 5 | RES_FILTER = REQ_FILTER = """ 6 | (path-matches r"^/login$") 7 | """ 8 | 9 | def on_request(req): 10 | log.info("Intercepting login request") 11 | return req.intercept() 12 | 13 | 14 | def on_response(res): 15 | log.info("Intercepting login response") 16 | return res.intercept() 17 | -------------------------------------------------------------------------------- /examples/python/log.py: -------------------------------------------------------------------------------- 1 | """ 2 | `log` global prints messages to the Output and Errors tab in Extensions -> Burp Scripting 3 | print() sends messages there too. 4 | 5 | [logging.py][INFO] - GET - https://www.ivision.com/ 6 | [logging.py][INFO] - Logs INFO 7 | [logging.py][DEBUG] - Got a response 8 | [logging.py][INFO] - {'key': 'value'} 9 | [logging.py][ERROR] - oh no 10 | ZeroDivisionError: division by zero 11 | """ 12 | 13 | # Enable log.debug() 14 | log.enableDebug(True) 15 | 16 | def on_request(req): 17 | log.info(f"{req.method()} - {req.url()}") 18 | print("Logs INFO") 19 | return req 20 | 21 | 22 | def on_response(res): 23 | log.debug("Got a response") 24 | obj = res.bodyToJson() 25 | print(obj) 26 | try: 27 | x = 1 / 0 28 | except Exception as ex: 29 | # Logs to Errors tab of the extension 30 | log.error("oh no", ex) 31 | return res 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /examples/python/rewrite_host.py: -------------------------------------------------------------------------------- 1 | """ 2 | Re-write request Host header. 3 | 4 | This can be useful in situations where you are using a transparent "Invisible" proxy 5 | but for one reason or another, it isn't convenient or possible to get the client to 6 | send requests with the Host header corresponding to the actual target. 7 | 8 | Similarly, Burp can be configured to forward requests to specific targets, but it 9 | does not rewrite the Host header to the forward destination. 10 | 11 | - https://portswigger.net/burp/documentation/desktop/tools/proxy/invisible 12 | - https://portswigger.net/burp/documentation/desktop/settings/tools/proxy#request-handling 13 | """ 14 | 15 | REQ_FILTER = """ 16 | (header-matches "Host" ".*not-ivision.com.*") 17 | """ 18 | 19 | def on_request(req): 20 | from_host = req.header("Host").value() 21 | to_host = "ivision.com" 22 | print(f"Rewriting host header from {from_host} to {to_host}") 23 | return req.withUpdatedHeader("Host", to_host) 24 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1710146030, 9 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1731603435, 24 | "narHash": "sha256-CqCX4JG7UiHvkrBTpYC3wcEurvbtTADLbo3Ns2CEoL8=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "8b27c1239e5c421a2bbc2c65d52e4a6fbf2ff296", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "24.11", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Burpscript"; 3 | inputs = { 4 | nixpkgs.url = "github:NixOS/nixpkgs/24.11"; 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | }; 7 | 8 | outputs = { self, nixpkgs, flake-utils, ... }: 9 | 10 | flake-utils.lib.eachDefaultSystem (system: 11 | let 12 | pkgs = nixpkgs.legacyPackages.${system}; 13 | in { 14 | devShells.default = let 15 | packages = with pkgs; [ 16 | jdk22 17 | gradle 18 | kotlin 19 | 20 | # For tests 21 | python3 22 | nodePackages.npm 23 | ]; 24 | in pkgs.mkShell { 25 | inherit packages; 26 | 27 | shellHook = '' 28 | export JAVA_HOME=${pkgs.jdk22} 29 | export PATH=${pkgs.lib.makeBinPath packages}:$PATH 30 | export BURPSCRIPT_NIX=1 31 | ''; 32 | }; 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/integrationTest/data/testnpmpkg/index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | doSomething: function() { 4 | return "did something"; 5 | } 6 | } 7 | 8 | -------------------------------------------------------------------------------- /src/integrationTest/data/testnpmpkg/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "testnpmpkg", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "author": "", 7 | "license": "" 8 | } 9 | -------------------------------------------------------------------------------- /src/integrationTest/data/testpythonpkg/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | *.egg-info/ -------------------------------------------------------------------------------- /src/integrationTest/data/testpythonpkg/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "burpscript-test-python-pkg" 3 | version = "0.1" 4 | description = "" 5 | readme = "" 6 | authors = [{name = ""}] 7 | license = {text = ""} 8 | dependencies = [ 9 | ] 10 | -------------------------------------------------------------------------------- /src/integrationTest/data/testpythonpkg/testpythonpkg/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivision-research/burpscript/f5be83ae89470305374a40e1b2921fec979206ca/src/integrationTest/data/testpythonpkg/testpythonpkg/__init__.py -------------------------------------------------------------------------------- /src/integrationTest/kotlin/com/carvesystems/burpscript/JsImportIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import com.carvesystems.burpscript.internal.testing.matchers.value.shouldBe 4 | import com.carvesystems.burpscript.internal.testing.tempdir 5 | import io.kotest.core.spec.style.StringSpec 6 | import io.kotest.matchers.booleans.shouldBeTrue 7 | import org.graalvm.polyglot.Source 8 | 9 | class JsImportIntegrationTest : StringSpec() { 10 | init { 11 | "requires from node_modules" { 12 | tempdir { cwd -> 13 | val pkg = IntegrationTestEnv.resolveTestData("testnpmpkg") 14 | 15 | IntegrationTestEnv.shellExec("cd '$cwd' && npm install '$pkg'") 16 | 17 | val ctx = JsContextBuilder().withImportPath(cwd).build() 18 | 19 | val import = """ 20 | const { doSomething } = require('testnpmpkg'); 21 | module.exports = { doSomething }; 22 | """.trimIndent() 23 | val mod = ctx.eval(Source.newBuilder("js", import, "test.js").build()) 24 | mod.hasMember("doSomething").shouldBeTrue() 25 | mod.getMember("doSomething").execute().shouldBe("did something") 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/integrationTest/kotlin/com/carvesystems/burpscript/PythonImportIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import com.carvesystems.burpscript.internal.testing.tempdir 4 | import io.kotest.core.spec.style.StringSpec 5 | import io.kotest.matchers.booleans.shouldBeTrue 6 | import io.kotest.matchers.string.shouldNotBeEmpty 7 | import java.nio.file.Path 8 | 9 | class PythonImportIntegrationTest : StringSpec() { 10 | init { 11 | "imports from stdlib" { 12 | pythonenv { env -> 13 | val ctx1 = env.contextBuilder.build() 14 | ctx1.eval("python", "import base64") 15 | ctx1.eval("python", "import urllib.parse") 16 | 17 | val ctx2 = env.contextBuilder.withImportPath(Path.of("path/to/something")).build() 18 | ctx2.eval("python", "import base64") 19 | ctx2.eval("python", "import urllib.parse") 20 | } 21 | } 22 | 23 | "imports from site" { 24 | pythonenv { env -> 25 | env.install(IntegrationTestEnv.resolveTestData("testpythonpkg").toString()) 26 | 27 | val ctx = env.contextBuilder.build() 28 | ctx.eval("python", "import testpythonpkg") 29 | 30 | val ctx2 = env.contextBuilder.withImportPath(Path.of("path/to/something")).build() 31 | ctx2.eval("python", "import testpythonpkg") 32 | } 33 | } 34 | } 35 | } 36 | 37 | private inline fun pythonenv(block: (env: PythonEnv) -> Unit) { 38 | tempdir { tmp -> 39 | val envPath = tmp.resolve("burpscript-venv") 40 | block(PythonEnv(envPath)) 41 | } 42 | } 43 | 44 | private class PythonEnv(val path: Path) { 45 | val pythonExe = path.resolve("bin/python").toString() 46 | val contextBuilder: PythonContextBuilder 47 | 48 | init { 49 | val res = IntegrationTestEnv.shellExec("python -m venv '$path'") 50 | res.ok().shouldBeTrue() 51 | 52 | val pythonPath = execVenv( 53 | "python -c \"import site; print(':'.join(site.getsitepackages()))\"" 54 | ).trim() 55 | pythonPath.shouldNotBeEmpty() 56 | contextBuilder = PythonContextBuilder( 57 | PythonLangOptions( 58 | executable = pythonExe, 59 | pythonPath = pythonPath 60 | ) 61 | ) 62 | } 63 | 64 | fun install(vararg packages: String) { 65 | val pkgs = packages.joinToString(" ") { "'$it'" } 66 | execVenv("pip install $pkgs") 67 | } 68 | 69 | private fun execVenv(cmd: String): String { 70 | val activate = path.resolve("bin/activate") 71 | val res = IntegrationTestEnv.shellExec("source $activate && $cmd") 72 | res.ok().shouldBeTrue() 73 | return res.getStdoutString() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/integrationTest/kotlin/com/carvesystems/burpscript/util.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import java.io.File 4 | import java.nio.file.Path 5 | import kotlin.io.path.absolute 6 | 7 | object IntegrationTestEnv { 8 | private val projectRoot: String by lazy { 9 | // Assume cwd is the project root, if unset. This is the case when running tests from Gradle or intellij 10 | System.getProperty("burpscript.testing.projectRoot", "") 11 | } 12 | 13 | fun resolveTestData(first: String, vararg others: String): Path { 14 | return Path.of(projectRoot, "src", "integrationTest", "data", first, *others).absolute() 15 | } 16 | 17 | fun shellExec(cmd: String): ExecResult { 18 | return if (System.getenv("BURPSCRIPT_NIX") != null) { 19 | exec("bash", "-c", cmd) 20 | } else { 21 | exec("nix", "develop", "--command", "bash", "-c", cmd) 22 | } 23 | } 24 | 25 | private fun exec(program: String, vararg args: String): ExecResult { 26 | val pb = with(ProcessBuilder(program, *args)) { 27 | redirectError(ProcessBuilder.Redirect.PIPE) 28 | redirectOutput(ProcessBuilder.Redirect.PIPE) 29 | if (projectRoot.isNotEmpty()) { 30 | directory(File(projectRoot)) 31 | } 32 | start() 33 | } 34 | val input = pb.outputStream 35 | input.close() 36 | val out = pb.inputStream.readAllBytes() 37 | val err = pb.errorStream.readAllBytes() 38 | val code = pb.waitFor() 39 | 40 | val res = ExecResult(code, out, err) 41 | if (!res.ok()) { 42 | println("Failed to execute $program ${args.joinToString(" ")}") 43 | println("stdout: ${res.getStdoutString()}") 44 | println("stderr: ${res.getStderrString()}") 45 | } 46 | 47 | return res 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/internal/kotlin/com/carvesystems/burpscript/internal/testing/matchers/value/ValueMatchers.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.internal.testing.matchers.value 2 | 3 | import com.carvesystems.burpscript.interop.asAny 4 | import com.carvesystems.burpscript.interop.toList 5 | import com.carvesystems.burpscript.interop.toMap 6 | import io.kotest.matchers.collections.shouldContainExactly 7 | import io.kotest.matchers.collections.shouldNotContainExactly 8 | import io.kotest.matchers.maps.shouldContainExactly 9 | import io.kotest.matchers.maps.shouldNotContainExactly 10 | import io.kotest.matchers.shouldBe 11 | import io.kotest.matchers.shouldNotBe 12 | import org.graalvm.polyglot.Value 13 | 14 | 15 | infix fun Value.shouldBe(expected: T) = this.asAny() shouldBe expected 16 | infix fun Value.shouldNotBe(expected: T) = this.asAny() shouldNotBe expected 17 | 18 | fun Value.shouldBeTrue() = this.asBoolean() shouldBe true 19 | fun Value.shouldBeFalse() = this.asBoolean() shouldBe false 20 | 21 | infix fun Value.shouldContainExactly(expected: Iterable) = 22 | this.toList() shouldContainExactly expected 23 | 24 | fun Value.shouldContainExactly(vararg expected: T) = 25 | this.toList() shouldContainExactly expected.toList() 26 | 27 | infix fun Value.shouldNotContainExactly(expected: Iterable) = 28 | this.toList() shouldNotContainExactly expected 29 | 30 | fun Value.shouldNotContainExactly(vararg expected: T) = 31 | this.toList() shouldNotContainExactly expected.toList() 32 | 33 | infix fun Value.shouldContainExactly(expected: Map) = 34 | this.toMap() shouldContainExactly expected 35 | 36 | infix fun Value.shouldNotContainExactly(expected: Map) = 37 | this.toMap() shouldNotContainExactly expected -------------------------------------------------------------------------------- /src/internal/kotlin/com/carvesystems/burpscript/internal/testing/mocks.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.internal.testing 2 | 3 | import burp.api.montoya.core.ByteArray as BurpByteArray 4 | import burp.api.montoya.core.Range 5 | import burp.api.montoya.internal.MontoyaObjectFactory 6 | import io.mockk.MockKMatcherScope 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import java.util.regex.Pattern 10 | 11 | private fun co(stubBlock: MockKMatcherScope.() -> T) { 12 | every(stubBlock) answers { callOriginal() } 13 | } 14 | 15 | class SimpleArray(private val arr: kotlin.ByteArray) : BurpByteArray { 16 | override fun iterator(): MutableIterator = 17 | arr.toMutableList().iterator() 18 | 19 | override fun getByte(index: Int): Byte = arr[index] 20 | 21 | override fun setByte(index: Int, value: Byte) { 22 | arr[index] = value 23 | } 24 | 25 | override fun setByte(index: Int, value: Int) { 26 | arr[index] = value.toByte() 27 | } 28 | 29 | override fun setBytes(index: Int, vararg data: Byte) { 30 | for (idx in index until index + data.size) { 31 | arr[idx] = data[idx] 32 | } 33 | } 34 | 35 | override fun setBytes(index: Int, vararg data: Int) { 36 | for (idx in index until index + data.size) { 37 | arr[idx] = data[idx].toByte() 38 | } 39 | } 40 | 41 | override fun setBytes(index: Int, byteArray: BurpByteArray) { 42 | for (idx in index until index + byteArray.length()) { 43 | arr[idx] = byteArray.getByte(idx) 44 | } 45 | } 46 | 47 | override fun length(): Int = arr.size 48 | 49 | override fun getBytes(): kotlin.ByteArray = arr 50 | 51 | override fun subArray(startIndexInclusive: Int, endIndexExclusive: Int): BurpByteArray = 52 | SimpleArray(arr.slice(startIndexInclusive until endIndexExclusive).toByteArray()) 53 | 54 | override fun subArray(range: Range): BurpByteArray = subArray(range.startIndexInclusive(), range.endIndexExclusive()) 55 | 56 | override fun copy(): BurpByteArray = SimpleArray(arr.clone()) 57 | 58 | override fun copyToTempFile(): BurpByteArray { 59 | TODO("wtf") 60 | } 61 | 62 | override fun indexOf(searchTerm: BurpByteArray): Int { 63 | TODO("unneeded") 64 | } 65 | 66 | override fun indexOf(searchTerm: String?): Int { 67 | TODO("unneeded") 68 | } 69 | 70 | override fun indexOf(searchTerm: BurpByteArray?, caseSensitive: Boolean): Int { 71 | TODO("unneeded") 72 | } 73 | 74 | override fun indexOf(searchTerm: String?, caseSensitive: Boolean): Int { 75 | TODO("unneeded") 76 | } 77 | 78 | override fun indexOf( 79 | searchTerm: BurpByteArray?, 80 | caseSensitive: Boolean, 81 | startIndexInclusive: Int, 82 | endIndexExclusive: Int 83 | ): Int { 84 | TODO("unneeded") 85 | } 86 | 87 | override fun indexOf( 88 | searchTerm: String?, 89 | caseSensitive: Boolean, 90 | startIndexInclusive: Int, 91 | endIndexExclusive: Int 92 | ): Int { 93 | TODO("unneeded") 94 | } 95 | 96 | override fun indexOf(pattern: Pattern?): Int { 97 | TODO("unneeded") 98 | } 99 | 100 | override fun indexOf(pattern: Pattern?, startIndexInclusive: Int, endIndexExclusive: Int): Int { 101 | TODO("unneeded") 102 | } 103 | 104 | override fun countMatches(searchTerm: BurpByteArray?): Int { 105 | TODO("unneeded") 106 | } 107 | 108 | override fun countMatches(searchTerm: String?): Int { 109 | TODO("unneeded") 110 | } 111 | 112 | override fun countMatches(searchTerm: BurpByteArray?, caseSensitive: Boolean): Int { 113 | TODO("unneeded") 114 | } 115 | 116 | override fun countMatches(searchTerm: String?, caseSensitive: Boolean): Int { 117 | TODO("unneeded") 118 | } 119 | 120 | override fun countMatches( 121 | searchTerm: BurpByteArray?, 122 | caseSensitive: Boolean, 123 | startIndexInclusive: Int, 124 | endIndexExclusive: Int 125 | ): Int { 126 | TODO("unneeded") 127 | } 128 | 129 | override fun countMatches( 130 | searchTerm: String?, 131 | caseSensitive: Boolean, 132 | startIndexInclusive: Int, 133 | endIndexExclusive: Int 134 | ): Int { 135 | TODO("unneeded") 136 | } 137 | 138 | override fun countMatches(pattern: Pattern?): Int { 139 | TODO("unneeded") 140 | } 141 | 142 | override fun countMatches(pattern: Pattern?, startIndexInclusive: Int, endIndexExclusive: Int): Int { 143 | TODO("unneeded") 144 | } 145 | 146 | override fun withAppended(vararg data: Byte): BurpByteArray { 147 | TODO("unneeded") 148 | } 149 | 150 | override fun withAppended(vararg data: Int): BurpByteArray { 151 | TODO("unneeded") 152 | } 153 | 154 | override fun withAppended(text: String?): BurpByteArray { 155 | TODO("unneeded") 156 | } 157 | 158 | override fun withAppended(byteArray: BurpByteArray?): BurpByteArray { 159 | TODO("unneeded") 160 | } 161 | } 162 | 163 | fun mockObjectFactory(): MontoyaObjectFactory { 164 | val mock = mockk(relaxed = true) 165 | every { 166 | mock.byteArrayOfLength(any()) 167 | } answers { 168 | val size = it.invocation.args.first() as Int 169 | SimpleArray(ByteArray(size)) 170 | } 171 | every { 172 | mock.byteArray(any()) 173 | } answers { 174 | val bytes = it.invocation.args.first() as ByteArray 175 | SimpleArray(bytes) 176 | } 177 | return mock 178 | } -------------------------------------------------------------------------------- /src/internal/kotlin/com/carvesystems/burpscript/internal/testing/util.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.internal.testing 2 | 3 | import java.io.File 4 | import java.nio.file.Files 5 | import java.nio.file.Path 6 | import kotlin.io.path.ExperimentalPathApi 7 | import kotlin.io.path.deleteIfExists 8 | import kotlin.io.path.deleteRecursively 9 | 10 | 11 | @OptIn(ExperimentalPathApi::class) 12 | inline fun tempdir(block: (Path) -> Unit) { 13 | val dir = Files.createTempDirectory("burpscript-tests").toAbsolutePath() 14 | 15 | try { 16 | block(dir) 17 | } finally { 18 | dir.deleteRecursively() 19 | } 20 | } 21 | 22 | inline fun tempfile(filename: String, block: (Path) -> Unit) { 23 | tempfiles(filename) { 24 | block(it.first()) 25 | } 26 | } 27 | 28 | inline fun tempfiles(vararg filenames: String, block: (Array) -> Unit) { 29 | val files = filenames.map { File(it) } 30 | val paths = 31 | files.map { 32 | Files.createTempFile(it.nameWithoutExtension, ".${it.extension}").toAbsolutePath() 33 | }.toTypedArray() 34 | 35 | try { 36 | block(paths) 37 | } finally { 38 | paths.forEach { it.deleteIfExists() } 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/main/antlr/com/carvesystems/burpscript/FilterExpression.g4: -------------------------------------------------------------------------------- 1 | grammar FilterExpression; 2 | 3 | @header { 4 | package com.carvesystems.burpscript; 5 | } 6 | 7 | expression 8 | : statement 9 | ; 10 | 11 | statement 12 | : '(' FUNCNAME ')' 13 | | '(' FUNCNAME arg+ ')' 14 | ; 15 | 16 | arg 17 | : literal 18 | | statement 19 | ; 20 | 21 | 22 | literal 23 | : STRING 24 | | RAW_STRING 25 | | NUMBER 26 | | array 27 | | BOOLEAN 28 | ; 29 | 30 | array 31 | : '[' STRING (',' STRING)* ']' 32 | | '[' NUMBER (',' NUMBER)* ']' 33 | | '[' BOOLEAN (',' BOOLEAN)* ']' 34 | | '[' ']' 35 | ; 36 | 37 | BOOLEAN 38 | : 'true' 39 | | 'false' 40 | ; 41 | 42 | RAW_STRING 43 | : 'r' STRING; 44 | 45 | STRING 46 | : '"' (~'"' | '\\' '"')* '"' 47 | ; 48 | 49 | NUMBER 50 | : Integer 51 | | HexNumber 52 | | BinNumber 53 | ; 54 | 55 | FUNCNAME 56 | : [a-zA-Z0-9][-a-zA-Z0-9]*[a-zA-Z0-9] 57 | ; 58 | 59 | fragment BinNumber 60 | : '0b' [0-1]+ 61 | ; 62 | 63 | fragment HexNumber 64 | : '0x' '0'+ 65 | | '-'? '0x' [0-9a-fA-F][0-9a-fA-F]* 66 | ; 67 | 68 | fragment Integer 69 | : '0' 70 | | '-'? [1-9][0-9]* 71 | ; 72 | 73 | WS 74 | : [ \t\n\r]+ -> skip 75 | ; 76 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/BaseSubscriber.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import java.util.concurrent.Flow 4 | 5 | abstract class BaseSubscriber : Flow.Subscriber { 6 | private var _sub: Flow.Subscription? = null 7 | 8 | protected val sub: Flow.Subscription 9 | get() = _sub ?: throw RuntimeException("not subscribed") 10 | 11 | override fun onComplete() { 12 | } 13 | 14 | override fun onError(throwable: Throwable?) { 15 | } 16 | 17 | override fun onSubscribe(subscription: Flow.Subscription) { 18 | _sub = subscription 19 | subscription.request(1) 20 | } 21 | 22 | protected fun requestAnother() { 23 | sub.request(1) 24 | } 25 | 26 | fun cancel() { 27 | _sub?.cancel() 28 | } 29 | 30 | 31 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/Config.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import com.carvesystems.burpscript.interop.fromJsonAs 4 | import kotlinx.serialization.SerialName 5 | import kotlinx.serialization.Serializable 6 | import java.nio.file.Files 7 | import java.nio.file.Path 8 | import java.nio.file.Paths 9 | import kotlin.io.path.exists 10 | import java.nio.charset.Charset 11 | 12 | object Config { 13 | val burpScript by lazy { 14 | readConfig() ?: ScriptConfig() 15 | } 16 | 17 | fun readConfig(): ScriptConfig? { 18 | val confFile = configFile() ?: return null 19 | 20 | if (!confFile.exists()) { 21 | return null 22 | } 23 | 24 | return try { 25 | parse(Files.readString(confFile)) 26 | } catch (e: Exception) { 27 | null 28 | } 29 | } 30 | 31 | fun parse(json: String): ScriptConfig? = 32 | try { 33 | fromJsonAs(json) 34 | } catch (e: Exception) { 35 | LogManager.getLogger("Config").error("failed to parse config: $json", e) 36 | null 37 | } 38 | 39 | fun configFile(): Path? { 40 | val cfgDir = System.getenv("XDG_CONFIG_HOME")?.let { 41 | Paths.get(it) 42 | } ?: System.getProperty("user.home")?.let { 43 | Paths.get(it, ".config") 44 | } 45 | 46 | return cfgDir?.resolve(Paths.get("burpscript", "conf.json")) 47 | } 48 | } 49 | 50 | @Serializable 51 | data class LangOpt( 52 | @SerialName("opt") val option: String, 53 | val value: String 54 | ) 55 | 56 | @Serializable 57 | data class JsLangOptions(val contextOptions: List? = null) 58 | 59 | @Serializable 60 | data class PythonLangOptions( 61 | @SerialName("executablePath") val executable: String? = defaultSystemPythonExe, 62 | @SerialName("pythonPath") val pythonPath: String? = defaultSystemPythonPath, 63 | val contextOptions: List? = null, 64 | ) { 65 | companion object { 66 | // Get a path to the Python executable for locating packages. The priority is: 67 | // 68 | // 1. polyglot.python.Executable property 69 | // 2. `python` in $PATH 70 | // 3. `python3` in $PATH 71 | // 4. Output of `which python` 72 | // 5. Output of `which python3` 73 | private val defaultSystemPythonExe by lazy { 74 | getSystemPythonExePath() 75 | } 76 | 77 | // PYTHONPATH 78 | private val defaultSystemPythonPath by lazy { 79 | getSystemPythonPath() 80 | } 81 | 82 | private fun getSystemPythonExePath(): String? = 83 | System.getProperty("polyglot.python.Executable") 84 | ?: pythonExeFromPath("python") 85 | ?: pythonExeFromPath("python3") 86 | ?: pythonExeFromWhich("python") 87 | ?: pythonExeFromWhich("python3") 88 | 89 | private fun pythonExeFromPath(exeName: String): String? { 90 | return System.getenv("PATH")?.let { path -> 91 | val iter = path.split(':').iterator() 92 | while (iter.hasNext()) { 93 | val dir = Paths.get(iter.next()) 94 | if (!dir.isAbsolute) { 95 | continue 96 | } 97 | val python = dir.resolve(exeName) 98 | if (python.exists()) { 99 | return python.toString() 100 | } 101 | } 102 | null 103 | } 104 | } 105 | 106 | private fun pythonExeFromWhich(exeName: String): String? { 107 | val proc = try { 108 | with(ProcessBuilder("which", exeName)) { 109 | redirectError(ProcessBuilder.Redirect.PIPE) 110 | redirectOutput(ProcessBuilder.Redirect.PIPE) 111 | redirectInput(ProcessBuilder.Redirect.DISCARD) 112 | start() 113 | } 114 | } catch (e: Exception) { 115 | return null 116 | } 117 | 118 | val status = proc.waitFor() 119 | if (status != 0) { 120 | return null 121 | } 122 | 123 | return proc.inputStream.readAllBytes().toString(Charset.defaultCharset()).trim() 124 | } 125 | 126 | private fun getSystemPythonPath(): String? = 127 | pythonPathFromEnv() 128 | 129 | private fun pythonPathFromEnv(): String? = 130 | System.getenv("PYTHONPATH") 131 | } 132 | } 133 | 134 | @Serializable 135 | data class ScriptConfig( 136 | val python: PythonLangOptions = PythonLangOptions(), 137 | val js: JsLangOptions = JsLangOptions(), 138 | ) 139 | 140 | 141 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/ContextBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import org.graalvm.polyglot.Context 4 | import java.nio.file.Path 5 | 6 | interface ContextBuilder { 7 | fun withBindings(vararg bindings: Pair): ContextBuilder 8 | fun withImportPath(path: Path): ContextBuilder { 9 | return this 10 | } 11 | 12 | fun withConsoleLogger(logger: ScriptLogger): ContextBuilder { 13 | return this 14 | } 15 | 16 | fun build(): Context 17 | 18 | class Default(private val language: Language) : ContextBuilder { 19 | private val globalBindings = mutableMapOf() 20 | 21 | override fun withBindings(vararg bindings: Pair): ContextBuilder { 22 | globalBindings.putAll(bindings.toList()) 23 | return this 24 | } 25 | 26 | override fun build(): Context { 27 | val ctx = Context.newBuilder(language.id).apply { 28 | allowAllAccess(true) 29 | }.build() 30 | 31 | ctx.getBindings(language.id).apply { 32 | globalBindings.forEach { (key, value) -> 33 | putMember(key, value) 34 | } 35 | } 36 | 37 | return ctx 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/Extension.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import burp.api.montoya.BurpExtension 4 | import burp.api.montoya.MontoyaApi 5 | import com.carvesystems.burpscript.ui.DocsTab 6 | import com.carvesystems.burpscript.ui.DocsTabViewModel 7 | import com.carvesystems.burpscript.ui.ScriptsTab 8 | import com.carvesystems.burpscript.ui.ScriptsTabViewModel 9 | import java.util.concurrent.Flow 10 | import java.util.concurrent.SubmissionPublisher 11 | import javax.swing.JTabbedPane 12 | 13 | class Extension : BurpExtension { 14 | 15 | private var watchThread: WatchThread? = null 16 | 17 | override fun initialize(api: MontoyaApi) { 18 | LogManager.initialize(api) 19 | initializeUtils(api) 20 | 21 | val watchEvents = SubmissionPublisher() 22 | val scriptEvents = SubmissionPublisher() 23 | val loadEvents = SubmissionPublisher() 24 | 25 | SaveData.initialize(api, scriptEvents) 26 | 27 | setupFileWatch(watchEvents, scriptEvents) 28 | initializeUi(api, scriptEvents, loadEvents) 29 | 30 | val scriptHandler = ScriptHandler(api, scriptEvents, watchEvents, loadEvents) 31 | 32 | val ext = api.extension() 33 | 34 | ext.setName(Strings.get("extension_name")) 35 | 36 | ext.registerUnloadingHandler { 37 | watchEvents.close() 38 | scriptEvents.close() 39 | watchThread?.interrupt() 40 | scriptHandler.close() 41 | watchThread = null 42 | } 43 | } 44 | 45 | private fun setupFileWatch(sub: SubmissionPublisher, pub: Flow.Publisher) { 46 | 47 | watchThread?.interrupt() 48 | watchThread = WatchThread(sub, pub).apply { start() } 49 | } 50 | 51 | private fun initializeUi( 52 | api: MontoyaApi, 53 | pub: SubmissionPublisher, 54 | loadPub: SubmissionPublisher 55 | ) { 56 | 57 | val extTab = JTabbedPane() 58 | 59 | val scriptsTab = ScriptsTab(api, ScriptsTabViewModel(api, pub, loadPub)) 60 | val docsTab = DocsTab(api, DocsTabViewModel()) 61 | 62 | 63 | extTab.addTab(Strings.get("scripts_tab_name"), scriptsTab) 64 | extTab.addTab(Strings.get("docs_tab_name"), docsTab) 65 | 66 | val ui = api.userInterface() 67 | ui.registerSuiteTab( 68 | Strings.get("tab_name"), extTab 69 | ) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/JsContextBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import org.graalvm.polyglot.Context 4 | import org.graalvm.polyglot.Value 5 | import java.nio.file.Path 6 | 7 | class JsContextBuilder( 8 | private val langOptions: JsLangOptions = Config.burpScript.js 9 | ) : ContextBuilder { 10 | private val globalBindings: MutableMap = mutableMapOf() 11 | private var consoleLogger: ScriptLogger? = null 12 | private var importPath: Path? = null 13 | 14 | override fun withBindings(vararg bindings: Pair): ContextBuilder { 15 | globalBindings.putAll(bindings.toList()) 16 | return this 17 | } 18 | 19 | override fun withImportPath(path: Path): ContextBuilder { 20 | importPath = path 21 | return this 22 | } 23 | 24 | override fun withConsoleLogger(logger: ScriptLogger): ContextBuilder { 25 | consoleLogger = logger 26 | return this 27 | } 28 | 29 | override fun build(): Context = 30 | Context.newBuilder("js").apply { 31 | allowAllAccess(true) 32 | updateContextBuilder(this) 33 | }.build().apply { 34 | addBindings(this.getBindings("js")) 35 | } 36 | 37 | private fun updateContextBuilder(ctx: Context.Builder) { 38 | ctx.allowExperimentalOptions(true) 39 | 40 | // https://docs.oracle.com/en/graalvm/enterprise/21/docs/reference-manual/js/Modules/ 41 | // https://docs.oracle.com/en/graalvm/enterprise/21/docs/reference-manual/js/NodeJSvsJavaScriptContext 42 | // https://github.com/oracle/graaljs/blob/master/graal-js/src/com.oracle.truffle.js/src/com/oracle/truffle/js/runtime/JSContextOptions.java 43 | ctx.option("js.esm-eval-returns-exports", "true") 44 | // TextEncoder() is in development but not supported as of 24.1.1 45 | //ctx.option("js.text-encoding", "true") 46 | importPath?.let { path -> 47 | ctx.option("js.commonjs-require", "true") 48 | ctx.option("js.commonjs-require-cwd", path.toString()) 49 | } 50 | 51 | langOptions.contextOptions?.forEach { 52 | try { 53 | ctx.option(it.option, it.value) 54 | } catch (e: Exception) { 55 | LogManager.getLogger(this).error( 56 | "failed to set ${it.option} on context" 57 | ) 58 | } 59 | } 60 | } 61 | 62 | private fun addBindings(bindings: Value) { 63 | consoleLogger?.let { bindings.putMember("console", JsBindings.Console(it)) } 64 | globalBindings.forEach { (n, v) -> bindings.putMember(n, v) } 65 | } 66 | } 67 | 68 | object JsBindings { 69 | class Console(private val log: ScriptLogger) { 70 | @ScriptApi 71 | fun log(msg: Value) = log.info(msg) 72 | 73 | @ScriptApi 74 | fun warn(msg: Value) = log.message("WARN", msg) 75 | 76 | @ScriptApi 77 | fun error(msg: Value) = log.error(msg) 78 | 79 | @ScriptApi 80 | fun debug(msg: Value) = log.debug(msg) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/Language.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import com.carvesystems.burpscript.interop.AsStringSerializer 4 | import kotlinx.serialization.Serializable 5 | import org.graalvm.polyglot.Engine 6 | import java.nio.file.Path 7 | import kotlin.io.path.extension 8 | 9 | 10 | @Serializable(with = LanguageSerializer::class) 11 | sealed class Language(val id: String) { 12 | @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") 13 | @Serializable(with = LanguageSerializer::class) 14 | data object Python : Language("python") { 15 | override fun toString(): String = id 16 | } 17 | @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") 18 | @Serializable(with = LanguageSerializer::class) 19 | data object JavaScript : Language("js") { 20 | override fun toString(): String = id 21 | } 22 | @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") 23 | @Serializable(with = LanguageSerializer::class) 24 | class EngineSupported(id: String) : Language(id) { 25 | override fun toString(): String = id 26 | } 27 | 28 | companion object { 29 | fun fromPath(path: Path): Language = 30 | when (path.extension) { 31 | "py" -> Python 32 | "js", "mjs" -> JavaScript 33 | else -> { 34 | val lang = Engine.create().languages.keys.find { 35 | it == path.extension 36 | } ?: throw IllegalArgumentException("Unsupported language") 37 | 38 | EngineSupported(lang) 39 | } 40 | } 41 | 42 | fun fromString(id: String): Language = 43 | when (id) { 44 | Python.id, "py" -> Python 45 | JavaScript.id, "mjs" -> JavaScript 46 | else -> EngineSupported(id) 47 | } 48 | } 49 | } 50 | 51 | object LanguageSerializer : AsStringSerializer(Language::fromString) 52 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/Logger.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import burp.api.montoya.MontoyaApi 4 | import burp.api.montoya.logging.Logging 5 | import com.carvesystems.burpscript.interop.toException 6 | import org.graalvm.polyglot.Value 7 | 8 | class Logger(private val name: String) { 9 | fun info(msg: String) { 10 | LogManager.log("[$name] - $msg") 11 | } 12 | 13 | fun debug(msg: String) { 14 | if (LogManager.isDebug) { 15 | LogManager.log("[$name] - $msg") 16 | } 17 | } 18 | 19 | fun error(msg: String, e: Throwable? = null) { 20 | LogManager.logErr("[$name] - $msg", e) 21 | } 22 | 23 | companion object { 24 | 25 | fun forClass(cls: Class<*>): Logger = Logger(cls.simpleName) 26 | 27 | } 28 | } 29 | 30 | class ScriptLogger(private val name: String) { 31 | private var yesDebug = false 32 | 33 | @ScriptApi 34 | fun info(msg: Value) = message("INFO", msg) 35 | 36 | @ScriptApi 37 | fun debug(msg: Value) { 38 | if (yesDebug) { 39 | message("DEBUG", msg) 40 | } 41 | } 42 | 43 | @ScriptApi 44 | fun error(msg: Value, ex: Value? = null) { 45 | LogManager.logErr("[$name][ERROR] - $msg", ex?.toException()) 46 | } 47 | 48 | @ScriptApi 49 | fun message(prefix: String, msg: Value) = 50 | LogManager.log("[$name][$prefix] - $msg") 51 | 52 | @ScriptApi 53 | fun enableDebug(enable: Boolean = true): Boolean { 54 | val prev = yesDebug 55 | yesDebug = enable 56 | return prev 57 | } 58 | } 59 | 60 | object LogManager { 61 | private var burpLogger: Logging? = null 62 | var isDebug = true 63 | 64 | fun initialize(api: MontoyaApi) { 65 | burpLogger = api.logging() 66 | } 67 | 68 | internal fun log(msg: String) { 69 | burpLogger?.logToOutput(msg) 70 | } 71 | 72 | internal fun logErr(msg: String, e: Throwable? = null) { 73 | burpLogger?.let { 74 | if (e != null) { 75 | it.logToError(msg, e) 76 | } else { 77 | it.logToError(msg) 78 | } 79 | } 80 | } 81 | 82 | fun getLogger(name: String): Logger = Logger(name) 83 | fun getLogger(inst: Any): Logger = getLogger(inst::class.java) 84 | fun getLogger(cls: Class<*>): Logger = Logger.forClass(cls) 85 | 86 | fun getScriptLogger(name: String): ScriptLogger = ScriptLogger(name) 87 | fun getScriptLogger(inst: Any): Logger = getScriptLogger(inst::class.java) 88 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/PathWatchEvent.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import java.nio.file.Path 4 | 5 | /** 6 | * Events specifying what the watcher saw 7 | */ 8 | sealed class PathWatchEvent() { 9 | 10 | /** 11 | * Published when the [Path] has been modified 12 | */ 13 | class Modified(val path: Path) : PathWatchEvent() 14 | 15 | /** 16 | * Published when the [Path] has been removed 17 | */ 18 | class Removed(val path: Path) : PathWatchEvent() 19 | 20 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/PythonContextBuilder.kt: -------------------------------------------------------------------------------- 1 | /** 2 | * Import path 3 | * - https://github.com/oracle/graal/issues/5043 4 | * GraalVM supports using _some_ python modules that rely on native C extensions, 5 | * but these must be built using the GraalVM python runtime (graalpy): 6 | * - https://docs.oracle.com/en/graalvm/enterprise/21/docs/reference-manual/python/FAQ/#does-modulepackage-xyz-work-on-graalvms-python-runtime 7 | * 8 | * Type mapping & interop: 9 | * https://www.graalvm.org/latest/reference-manual/python/Interoperability/ 10 | * https://www.graalvm.org/jdk24/reference-manual/espresso/interoperability/ 11 | * https://www.graalvm.org/sdk/javadoc/org/graalvm/polyglot/HostAccess.Builder.html#targetTypeMapping(java.lang.Class,java.lang.Class,java.util.function.Predicate,java.util.function.Function,org.graalvm.polyglot.HostAccess.TargetMappingPrecedence) 12 | * https://github.com/oracle/graalpython/issues/248 13 | * https://github.com/oracle/graalpython/blob/master/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/runtime/interop/InteropByteArray.java 14 | * https://github.com/oracle/graalpython/blob/86885bfc1236f7ae4575f9ed43b60958cc9e9388/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/modules/JavaModuleBuiltins.java#L79 15 | * https://github.com/oracle/graalpython/blob/86885bfc1236f7ae4575f9ed43b60958cc9e9388/graalpython/com.oracle.graal.python/src/com/oracle/graal/python/builtins/objects/bytes/BytesNodes.java#L574 16 | * https://github.com/oracle/graal/issues/2139#issuecomment-1904152100 17 | */ 18 | 19 | package com.carvesystems.burpscript 20 | 21 | 22 | import com.carvesystems.burpscript.interop.CallableValue 23 | import org.graalvm.polyglot.Context 24 | import org.graalvm.polyglot.Value 25 | import java.nio.file.Path 26 | 27 | class PythonContextBuilder( 28 | private val langOptions: PythonLangOptions = Config.burpScript.python 29 | ) : ContextBuilder { 30 | private val logger = LogManager.getLogger(this) 31 | private val globalBindings = mutableMapOf() 32 | private var printLogger: ScriptLogger? = null 33 | private val importPaths: LinkedHashSet = LinkedHashSet() 34 | 35 | override fun withBindings(vararg bindings: Pair): ContextBuilder { 36 | globalBindings.putAll(bindings.toList()) 37 | return this 38 | } 39 | 40 | override fun withConsoleLogger(logger: ScriptLogger): ContextBuilder { 41 | printLogger = logger 42 | return this 43 | } 44 | 45 | override fun withImportPath(path: Path): ContextBuilder { 46 | importPaths.add(path) 47 | return this 48 | } 49 | 50 | override fun build(): Context = 51 | Context.newBuilder(Language.Python.id).apply { 52 | allowAllAccess(true) 53 | updateContextBuilder(this) 54 | }.build().apply { 55 | addBindings(this.getBindings(Language.Python.id)) 56 | } 57 | 58 | private fun updateContextBuilder(ctx: Context.Builder) { 59 | var pythonPath = importPaths.joinToString(":") 60 | 61 | langOptions.pythonPath?.let { 62 | pythonPath = if (pythonPath.isNotBlank()) { 63 | "$pythonPath:$it" 64 | } else { 65 | it 66 | } 67 | } 68 | 69 | if (pythonPath.isNotBlank()) { 70 | ctx.option("python.PythonPath", pythonPath) 71 | logger.debug("Python path is: $pythonPath") 72 | } 73 | 74 | if (langOptions.executable != null) { 75 | // If the host system has a python interpreter, make the embedded interpreter 76 | // think that it is running within the host's python environment. This doesn't 77 | // actually use the pythonExe to execute python, it only helps resolve modules. 78 | // https://blogs.oracle.com/javamagazine/post/java-graalvm-polyglot-python-r 79 | // https://docs.oracle.com/en/graalvm/jdk/20/docs/reference-manual/python/Packages/#including-packages-in-a-java-application 80 | ctx.option("python.ForceImportSite", "true") 81 | ctx.option("python.Executable", langOptions.executable) 82 | ctx.option("python.NativeModules", "true") 83 | ctx.option("python.UseSystemToolchain", "false") 84 | logger.debug("Python executable is: ${langOptions.executable}") 85 | } else { 86 | logger.info( 87 | "Python interpreter was not found in PATH. You will be unable to import modules from your python environment" 88 | ) 89 | } 90 | 91 | langOptions.contextOptions?.forEach { 92 | try { 93 | ctx.option(it.option, it.value) 94 | } catch (e: Exception) { 95 | logger.error( 96 | "failed to set ${it.option} on context" 97 | ) 98 | } 99 | } 100 | } 101 | 102 | private fun addBindings(bindings: Value) { 103 | printLogger?.let { log -> 104 | bindings.putMember( 105 | "print", 106 | PythonBindings.print(log) 107 | ) 108 | } 109 | globalBindings.forEach { (n, v) -> bindings.putMember(n, v) } 110 | } 111 | } 112 | 113 | object PythonBindings { 114 | fun print(logger: ScriptLogger) = 115 | CallableValue { args -> logger.info(args.first()) } 116 | } 117 | 118 | 119 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/SaveData.kt: -------------------------------------------------------------------------------- 1 | @file:UseSerializers(PathSerializer::class) 2 | 3 | package com.carvesystems.burpscript 4 | 5 | import burp.api.montoya.MontoyaApi 6 | import burp.api.montoya.persistence.PersistedList 7 | import com.carvesystems.burpscript.interop.PathSerializer 8 | import com.carvesystems.burpscript.interop.fromJsonAs 9 | import com.carvesystems.burpscript.interop.toJson 10 | import kotlinx.serialization.SerialName 11 | import kotlinx.serialization.Serializable 12 | import kotlinx.serialization.Transient 13 | import kotlinx.serialization.UseSerializers 14 | import java.nio.file.Path 15 | import java.util.* 16 | import java.util.concurrent.Flow 17 | import java.util.concurrent.Flow.Publisher 18 | import java.util.concurrent.Flow.Subscriber 19 | 20 | 21 | object SaveData { 22 | 23 | private var persist: ScopedPersistence? = null 24 | private val scriptEventSub = ScriptEventsSub() 25 | private val scripts = mutableListOf() 26 | private const val SCRIPTS_KEY = "scripts" 27 | 28 | fun initialize(api: MontoyaApi, scriptEvents: Publisher) { 29 | if (persist == null) { 30 | persist = ScopedPersistence.get(api.persistence(), SaveData::class) 31 | loadPersisted() 32 | scriptEvents.subscribe(scriptEventSub) 33 | } 34 | } 35 | 36 | fun forEachScript(f: (SavedScript) -> Unit) { 37 | synchronized(scripts) { 38 | scripts.forEach(f) 39 | } 40 | } 41 | 42 | fun withScripts(f: (List) -> Unit) { 43 | synchronized(scripts) { 44 | f(scripts) 45 | } 46 | } 47 | 48 | private fun loadPersisted() { 49 | val data = persist?.extensionData() ?: return 50 | synchronized(scripts) { 51 | data.getStringList(SCRIPTS_KEY)?.forEach { 52 | scripts.add(fromJsonAs(it)) 53 | } 54 | } 55 | } 56 | 57 | private fun persistScripts() { 58 | val data = persist?.extensionData() ?: return 59 | synchronized(scripts) { 60 | val pl = PersistedList.persistedStringList() 61 | pl.addAll(scripts.map { 62 | toJson(it) 63 | }) 64 | data.setStringList(SCRIPTS_KEY, pl) 65 | } 66 | } 67 | 68 | private fun withScriptIdx(id: UUID, cb: (Int) -> Unit): Boolean { 69 | synchronized(scripts) { 70 | val idx = scripts.indexOfFirst { it.id == id } 71 | return if (idx != -1) { 72 | cb(idx) 73 | true 74 | } else { 75 | false 76 | } 77 | } 78 | } 79 | 80 | private fun onRemoved(evt: ScriptEvent.RemoveScript) { 81 | withScriptIdx(evt.id) { 82 | scripts.removeAt(it) 83 | persistScripts() 84 | } 85 | } 86 | 87 | private fun onScriptSet(evt: ScriptEvent.SetScript) { 88 | val had = withScriptIdx(evt.id) { 89 | scripts[it] = SavedScript(evt.path, evt.id, evt.language, evt.opts) 90 | } 91 | if (!had) { 92 | scripts.add(SavedScript(evt.path, evt.id, evt.language, evt.opts)) 93 | } 94 | persistScripts() 95 | } 96 | 97 | private fun onOptionsUpdated(evt: ScriptEvent.OptionsUpdated) { 98 | withScriptIdx(evt.id) { 99 | val orig = scripts[it] 100 | scripts[it] = SavedScript(orig.path, evt.id, orig.language, evt.opts) 101 | persistScripts() 102 | } 103 | } 104 | 105 | private class ScriptEventsSub : Subscriber { 106 | private lateinit var sub: Flow.Subscription 107 | override fun onComplete() { 108 | } 109 | 110 | override fun onError(throwable: Throwable?) { 111 | } 112 | 113 | override fun onNext(item: ScriptEvent?) { 114 | sub.request(1) 115 | if (item == null) { 116 | return 117 | } 118 | when (item) { 119 | is ScriptEvent.RemoveScript -> onRemoved(item) 120 | is ScriptEvent.SetScript -> onScriptSet(item) 121 | is ScriptEvent.OptionsUpdated -> onOptionsUpdated(item) 122 | } 123 | } 124 | 125 | override fun onSubscribe(subscription: Flow.Subscription) { 126 | sub = subscription 127 | sub.request(1) 128 | } 129 | } 130 | 131 | } 132 | 133 | @Serializable() 134 | data class SavedScript( 135 | @SerialName("m") val idMsb: Long, 136 | @SerialName("l") val idLsb: Long, 137 | @SerialName("p") val path: Path, 138 | @SerialName("g") val language: Language, 139 | @SerialName("o") val opts: Script.Options, 140 | ) { 141 | @Transient 142 | val id = UUID(idMsb, idLsb) 143 | 144 | constructor(path: Path, id: UUID, language: Language, opts: Script.Options) : 145 | this(id.mostSignificantBits, id.leastSignificantBits, path, language, opts) 146 | 147 | override fun hashCode(): Int = id.hashCode() 148 | override fun equals(other: Any?): Boolean = id == other 149 | } 150 | 151 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/ScopedPersistence.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import com.carvesystems.burpscript.interop.* 4 | import burp.api.montoya.persistence.PersistedObject 5 | import burp.api.montoya.persistence.Persistence 6 | import burp.api.montoya.persistence.Preferences 7 | import java.nio.charset.StandardCharsets 8 | import java.security.MessageDigest 9 | 10 | /** 11 | * Prevents key collisions for extension data and passes [Preferences] through 12 | * untouched 13 | */ 14 | class ScopedPersistence private constructor(private val wrapped: Persistence, private val namespace: String) : 15 | Persistence { 16 | 17 | 18 | override fun extensionData(): PersistedObject { 19 | val extData = wrapped.extensionData() 20 | 21 | return extData.getChildObject(namespace) ?: run { 22 | val newObj = PersistedObject.persistedObject() 23 | extData.setChildObject(namespace, newObj) 24 | newObj 25 | } 26 | } 27 | 28 | override fun preferences(): Preferences = 29 | wrapped.preferences() 30 | 31 | 32 | companion object { 33 | 34 | fun get(persistence: Persistence, inst: Any): ScopedPersistence = get(persistence, inst.javaClass) 35 | fun get(persistence: Persistence, cls: Class<*>): ScopedPersistence = 36 | ScopedPersistence(persistence, hash(cls.name)) 37 | 38 | private fun hash(name: String): String = 39 | MessageDigest.getInstance("MD5").let { 40 | it.update(name.toByteArray(StandardCharsets.UTF_8)) 41 | it.digest().toHex() 42 | } 43 | 44 | } 45 | 46 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/ScriptEvent.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import java.nio.file.Path 4 | import java.util.* 5 | 6 | sealed class ScriptEvent(val id: UUID) { 7 | 8 | /** 9 | * Published to set a script 10 | * 11 | * This is used for both new scripts and updates to scripts 12 | */ 13 | class SetScript(id: UUID, val path: Path, val language: Language, val opts: Script.Options) : ScriptEvent(id) 14 | 15 | /** 16 | * Published to permanently remove a script 17 | */ 18 | class RemoveScript(id: UUID) : ScriptEvent(id) 19 | 20 | /** 21 | * Published to update the options for the given script 22 | */ 23 | class OptionsUpdated(id: UUID, val opts: Script.Options) : ScriptEvent(id) 24 | 25 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/ScriptHelpers.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import com.carvesystems.burpscript.interop.* 4 | import burp.api.montoya.core.Annotations 5 | import burp.api.montoya.core.HighlightColor 6 | import org.graalvm.polyglot.Value 7 | import java.io.File 8 | import java.io.InputStream 9 | import java.nio.charset.Charset 10 | import java.nio.file.Files 11 | import java.nio.file.Paths 12 | import java.nio.file.StandardOpenOption 13 | import java.util.* 14 | import java.util.regex.Pattern 15 | 16 | /** 17 | * Result of executing an external command 18 | */ 19 | class ExecResult( 20 | @ScriptApi val code: Int, 21 | @ScriptApi val stdout: ByteArray, 22 | @ScriptApi val stderr: ByteArray, 23 | ) { 24 | /** 25 | * Helper to check that the status code is 0 26 | */ 27 | @ScriptApi 28 | fun ok(): Boolean = code == 0 29 | 30 | /** 31 | * Gets standard output as a string in the default charset 32 | */ 33 | @ScriptApi 34 | fun getStdoutString(): String = stdout.toString(Charset.defaultCharset()) 35 | 36 | /** 37 | * Gets standard error as a string in the default charset 38 | */ 39 | @ScriptApi 40 | fun getStderrString(): String = stderr.toString(Charset.defaultCharset()) 41 | } 42 | 43 | /** 44 | * Helpers to pass to scripts to perform some common actions 45 | */ 46 | class ScriptHelpers { 47 | 48 | private val kvStore = ScriptMap() 49 | 50 | /** 51 | * Helper to compile a Java [Pattern] 52 | * 53 | * The only benefit to this over the native regular expression interfaces 54 | * is just a uniform regex interface 55 | */ 56 | @ScriptApi 57 | fun compilePattern(regex: String): Pattern = Pattern.compile(regex) 58 | 59 | /** 60 | * Parse a filter expression that can be applied to requests or responses 61 | */ 62 | @ScriptApi 63 | fun parseFilter(code: String): FilterExpression? = try { 64 | FilterExpression.parse(code) 65 | } catch (e: Exception) { 66 | LogManager.getLogger(this).error("failed to parse filter:\n$code", e) 67 | null 68 | } 69 | 70 | @ScriptApi 71 | fun b64(value: AnyBinary): String = 72 | Base64.getEncoder().encode(value.asAnyBinaryToByteArray()).decodeToString() 73 | 74 | @ScriptApi 75 | fun unb64(asB64: String): UnsignedByteArray = 76 | Base64.getDecoder().decode(asB64).toUnsignedByteArray() 77 | 78 | @ScriptApi 79 | fun hex(value: AnyBinary): String = 80 | value.asAnyBinaryToByteArray().toHex() 81 | 82 | @ScriptApi 83 | fun unhex(asHex: String): UnsignedByteArray = 84 | asHex.decodeHex().toUnsignedByteArray() 85 | 86 | @ScriptApi 87 | fun getCryptoHelper(): ScriptCryptoHelper { 88 | return ScriptCryptoHelper() 89 | } 90 | 91 | @ScriptApi 92 | fun appendNotes(annotation: Annotations, notes: String) { 93 | val currentNotes = annotation.notes() ?: "" 94 | val newNotes = if (currentNotes.isNotEmpty()) { 95 | "$currentNotes; $notes" 96 | } else { 97 | notes 98 | } 99 | annotation.setNotes(newNotes) 100 | } 101 | 102 | @ScriptApi 103 | fun appendNotes(req: ScriptHttpRequest, notes: String) { 104 | appendNotes(req.annotations(), notes) 105 | } 106 | 107 | @ScriptApi 108 | fun appendNotes(res: ScriptHttpResponse, notes: String) { 109 | appendNotes(res.annotations(), notes) 110 | } 111 | 112 | @ScriptApi 113 | fun setNotes(res: ScriptHttpResponse, notes: String) { 114 | res.annotations().apply { 115 | setNotes(notes) 116 | } 117 | } 118 | 119 | @ScriptApi 120 | fun setNotes(req: ScriptHttpRequest, notes: String) { 121 | req.annotations().apply { 122 | setNotes(notes) 123 | } 124 | } 125 | 126 | /** 127 | * Set the highlight color for the request 128 | */ 129 | @ScriptApi 130 | fun setHighlight(req: ScriptHttpRequest, color: HighlightColor) { 131 | req.annotations().apply { 132 | setHighlightColor(color) 133 | } 134 | } 135 | 136 | /** 137 | * Set the highlight color for the request from a string 138 | * 139 | * Valid values for [color] are: 140 | * 141 | * - "Red" 142 | * - "Orange" 143 | * - "Yellow" 144 | * - "Green" 145 | * - "Cyan" 146 | * - "Blue" 147 | * - "Pink" 148 | * - "Magenta" 149 | * - "Gray" 150 | */ 151 | @ScriptApi 152 | fun setHighlight(req: ScriptHttpRequest, color: String) { 153 | setHighlight(req, getColor(color)) 154 | } 155 | 156 | /** 157 | * Set the highlight color for the response 158 | */ 159 | @ScriptApi 160 | fun setHighlight(res: ScriptHttpResponse, color: HighlightColor) { 161 | res.annotations().apply { 162 | setHighlightColor(color) 163 | } 164 | } 165 | 166 | /** 167 | * Set the highlight color for the response from a string 168 | * 169 | * Valid values for [color] are: 170 | * 171 | * - "Red" 172 | * - "Orange" 173 | * - "Yellow" 174 | * - "Green" 175 | * - "Cyan" 176 | * - "Blue" 177 | * - "Pink" 178 | * - "Magenta" 179 | * - "Gray" 180 | */ 181 | @ScriptApi 182 | fun setHighlight(res: ScriptHttpResponse, color: String) { 183 | setHighlight(res, getColor(color)) 184 | } 185 | 186 | private fun doExecStdin(stdin: ByteArray?, program: String, vararg args: String): ExecResult { 187 | val pb = with(ProcessBuilder(program, *args)) { 188 | redirectError(ProcessBuilder.Redirect.PIPE) 189 | redirectOutput(ProcessBuilder.Redirect.PIPE) 190 | start() 191 | } 192 | val input = pb.outputStream 193 | stdin?.let { 194 | input.write(it) 195 | input.flush() 196 | } 197 | input.close() 198 | val out = pb.inputStream.readAllBytes() 199 | val err = pb.errorStream.readAllBytes() 200 | val code = pb.waitFor() 201 | return ExecResult(code, out, err) 202 | } 203 | 204 | private fun doExec(program: String, vararg args: String): ExecResult = doExecStdin(null, program, *args) 205 | 206 | /** 207 | * Get a Java [File] object for the given path 208 | */ 209 | @ScriptApi 210 | fun getFile(fileName: String): File = File(fileName) 211 | 212 | /** 213 | * Read all bytes from the given [InputStream] into a byte array 214 | */ 215 | @ScriptApi 216 | fun readBytes(inputStream: InputStream): ByteArray = inputStream.readAllBytes() 217 | 218 | /** 219 | * Read the given [InputStream] into a string 220 | */ 221 | @ScriptApi 222 | fun readString(inputStream: InputStream): String = readBytes(inputStream).toString(Charset.defaultCharset()) 223 | 224 | @ScriptApi 225 | fun appendFile(fileName: String, content: String) { 226 | appendFile(fileName, content.toByteArray(Charset.defaultCharset())) 227 | } 228 | 229 | @ScriptApi 230 | fun appendFile(fileName: String, content: ByteArray) { 231 | Files.write( 232 | Paths.get(fileName), content, StandardOpenOption.WRITE, StandardOpenOption.APPEND, StandardOpenOption.CREATE 233 | ) 234 | } 235 | 236 | @ScriptApi 237 | fun writeFile(fileName: String, content: String) { 238 | writeFile(fileName, content.toByteArray(Charset.defaultCharset())) 239 | } 240 | 241 | @ScriptApi 242 | fun writeFile(fileName: String, content: ByteArray) { 243 | Files.write( 244 | Paths.get(fileName), 245 | content, 246 | StandardOpenOption.WRITE, 247 | StandardOpenOption.CREATE, 248 | StandardOpenOption.TRUNCATE_EXISTING 249 | ) 250 | } 251 | 252 | /** 253 | * Execute an external command 254 | */ 255 | @ScriptApi 256 | fun exec(program: String, vararg args: String): ExecResult = try { 257 | doExec(program, *args) 258 | } catch (e: Exception) { 259 | LogManager.getLogger(this).error("failed to exec $program $args", e) 260 | throw e 261 | } 262 | 263 | /** 264 | * Execute an external command with the given standard input 265 | */ 266 | @ScriptApi 267 | fun execStdin(stdin: Value, program: String, vararg args: String): ExecResult = try { 268 | doExecStdin(stdin.asAnyBinaryToByteArray(), program, *args) 269 | } catch (e: Exception) { 270 | LogManager.getLogger(this).error("failed to exec $program $args", e) 271 | throw e 272 | } 273 | 274 | private fun getColor(color: String): HighlightColor = if (color[0].isLowerCase()) { 275 | HighlightColor.highlightColor("${color[0].uppercase()}${color.substring(1)}") 276 | } else { 277 | HighlightColor.highlightColor(color) 278 | } 279 | 280 | /** 281 | * Provide access to a key/value store that persists data across reloads 282 | */ 283 | @ScriptApi 284 | fun getKVStore(): ScriptMap = kvStore 285 | } 286 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/ScriptLoadEvent.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import java.util.* 4 | 5 | /** 6 | * Events for script loading status 7 | */ 8 | sealed class ScriptLoadEvent(val id: UUID) { 9 | 10 | /** 11 | * Published when the script successfully loads 12 | * 13 | * This is published on both initial load and reloads 14 | */ 15 | class LoadSucceeded(id: UUID) : ScriptLoadEvent(id) 16 | 17 | /** 18 | * Published when a script fails to load 19 | */ 20 | @Suppress("UNUSED_PARAMETER") 21 | class LoadFailed(id: UUID, reason: Throwable? = null) : ScriptLoadEvent(id) 22 | 23 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/ScriptMap.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | 4 | class ScriptMap : HashMap { 5 | constructor() : super() 6 | 7 | constructor(map: Map) : super(map) 8 | 9 | @ScriptApi 10 | fun getDotted(key: String): Any? = 11 | getParent(key)[finalKey(key)] 12 | 13 | @ScriptApi 14 | fun getDottedString(key: String): String = 15 | getDottedAs(key) 16 | 17 | @ScriptApi 18 | fun getDottedBoolean(key: String): Boolean = 19 | getDottedAs(key) 20 | 21 | @ScriptApi 22 | fun getDottedNumber(key: String): Number = 23 | getDottedAs(key) 24 | 25 | 26 | @ScriptApi 27 | fun put(key: String, value: Iterable<*>?) { 28 | put(key, value?.toList()) 29 | } 30 | 31 | @ScriptApi 32 | fun put(key: String, value: Iterator<*>?) { 33 | put(key, value?.asSequence()?.toList()) 34 | } 35 | 36 | @ScriptApi 37 | fun putDotted(key: String, value: Iterable<*>?) { 38 | getParent(key).put(finalKey(key), value) 39 | } 40 | 41 | @ScriptApi 42 | fun putDotted(key: String, value: Iterator<*>?) { 43 | getParent(key).put(finalKey(key), value) 44 | } 45 | 46 | @ScriptApi 47 | fun putDotted(key: String, value: String?) { 48 | getParent(key).put(finalKey(key), value) 49 | } 50 | 51 | @ScriptApi 52 | fun putDotted(key: String, value: Number?) { 53 | getParent(key).put(finalKey(key), value) 54 | } 55 | 56 | @ScriptApi 57 | fun putDotted(key: String, value: Boolean?) { 58 | getParent(key).put(finalKey(key), value) 59 | } 60 | 61 | @ScriptApi 62 | fun putDotted(key: String, value: Map<*, *>?) { 63 | getParent(key).put(finalKey(key), value) 64 | } 65 | 66 | @ScriptApi 67 | fun putDotted(key: String, value: Collection<*>?) { 68 | getParent(key).put(finalKey(key), value) 69 | } 70 | 71 | fun getDottedAs(key: String): T = 72 | @Suppress("UNCHECKED_CAST") 73 | (getDotted(key) as T) 74 | 75 | fun getAs(key: String): T? = 76 | try { 77 | @Suppress("UNCHECKED_CAST") 78 | get(key) as T 79 | } catch (e: Exception) { 80 | null 81 | } 82 | 83 | fun maybeGetDottedAs(key: String): T? = 84 | try { 85 | getDottedAs(key) 86 | } catch (e: Exception) { 87 | null 88 | } 89 | 90 | private fun finalKey(dottedKey: String): String = 91 | dottedKey.split('.').last() 92 | 93 | @ScriptApi 94 | fun hasDotted(dottedKey: String): Boolean { 95 | var obj: Map = this 96 | val iter = dottedKey.split('.').iterator() 97 | while (iter.hasNext()) { 98 | val key = iter.next() 99 | if (!iter.hasNext()) { 100 | return obj.containsKey(key) 101 | } 102 | obj = try { 103 | @Suppress("UNCHECKED_CAST") 104 | obj[key] as Map 105 | } catch (e: ClassCastException) { 106 | return false 107 | } 108 | } 109 | return false 110 | } 111 | 112 | private fun getParent(dottedKey: String): MutableMap { 113 | var obj: MutableMap = this 114 | val idx = dottedKey.lastIndexOf('.') 115 | if (idx == -1) { 116 | return obj 117 | } 118 | val parentKey = dottedKey.substring(0, idx) 119 | val iter = parentKey.split('.').iterator() 120 | while (iter.hasNext()) { 121 | val key = iter.next() 122 | try { 123 | @Suppress("UNCHECKED_CAST") 124 | obj = obj[key] as MutableMap 125 | } catch (e: ClassCastException) { 126 | throw IllegalArgumentException("Cannot set value at $dottedKey") 127 | } 128 | } 129 | return obj 130 | } 131 | } 132 | 133 | fun scriptMapOf(vararg pairs: Pair): ScriptMap = 134 | if (pairs.isNotEmpty()) pairs.toMap(ScriptMap()) else ScriptMap() 135 | 136 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/SimpleToolSource.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import burp.api.montoya.core.ToolSource 4 | import burp.api.montoya.core.ToolType 5 | 6 | class SimpleToolSource(private val tt: ToolType) : ToolSource { 7 | override fun toolType(): ToolType = tt 8 | override fun isFromTool(vararg toolType: ToolType): Boolean = 9 | toolType.any { it == tt } 10 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/Strings.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import java.util.* 4 | 5 | object Strings { 6 | private val bundle = ResourceBundle.getBundle( 7 | "${javaClass.packageName}.ui.strings" 8 | ) 9 | 10 | fun get(key: String): String = 11 | bundle.getString(key) 12 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/WatchThread.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import java.nio.file.* 4 | import java.security.MessageDigest 5 | import java.util.* 6 | import java.util.concurrent.Flow 7 | import java.util.concurrent.SubmissionPublisher 8 | import java.util.concurrent.TimeUnit 9 | import kotlin.io.path.exists 10 | import kotlin.io.path.isDirectory 11 | 12 | 13 | /** 14 | * Thread to watch for changes to files 15 | */ 16 | class WatchThread( 17 | private val outgoingWatchEvents: SubmissionPublisher, 18 | private val incomingFileEvents: Flow.Publisher 19 | ) : Thread() { 20 | 21 | private val watch = FileSystems.getDefault().newWatchService() 22 | private val sub = Subscriber() 23 | private val watchedDirs = mutableListOf() 24 | private val logger = LogManager.getLogger(this) 25 | 26 | init { 27 | SaveData.forEachScript { 28 | addPath(it.id, it.path) 29 | } 30 | } 31 | 32 | 33 | override fun run() { 34 | incomingFileEvents.subscribe(sub) 35 | 36 | while (!interrupted()) { 37 | val key = try { 38 | watch.poll(250, TimeUnit.MILLISECONDS) ?: continue 39 | } catch (e: InterruptedException) { 40 | break 41 | } 42 | synchronized(watchedDirs) { 43 | for (evt in key.pollEvents()) { 44 | handleEvent(evt) 45 | } 46 | } 47 | key.reset() 48 | } 49 | 50 | sub.cancel() 51 | } 52 | 53 | private fun handleEvent(evt: WatchEvent<*>) { 54 | when (evt.kind()) { 55 | StandardWatchEventKinds.ENTRY_MODIFY -> { 56 | onEntryModify(evt) 57 | } 58 | 59 | else -> {} 60 | } 61 | } 62 | 63 | private fun onEntryModify(evt: WatchEvent<*>) { 64 | val path = try { 65 | evt.context() as Path 66 | } catch (e: ClassCastException) { 67 | return 68 | } 69 | 70 | if (path.isDirectory()) { 71 | return 72 | } 73 | 74 | // Sometimes two modify events will be fired, one when an empty file 75 | // is created and one when it gets content. The downside to this check 76 | // is that if we ever delete the contents of the file the change is ignored 77 | if (path.exists() && Files.size(path) == 0L) { 78 | return 79 | } 80 | 81 | synchronized(watchedDirs) { 82 | for (wd in watchedDirs) { 83 | val script = wd.maybeGetScript(path) ?: continue 84 | 85 | if (script.updateModified()) { 86 | logger.debug("Tracked file $path changed") 87 | signalChanged(wd.resolve(path)) 88 | break 89 | } 90 | } 91 | } 92 | } 93 | 94 | 95 | private fun signalChanged(path: Path) { 96 | if (!outgoingWatchEvents.hasSubscribers()) { 97 | return 98 | } 99 | 100 | val evt = if (path.exists()) { 101 | PathWatchEvent.Modified(path) 102 | } else { 103 | PathWatchEvent.Removed(path) 104 | } 105 | 106 | 107 | outgoingWatchEvents.submit(evt) 108 | 109 | } 110 | 111 | private fun onNewPath(evt: ScriptEvent.SetScript) { 112 | addPath(evt.id, evt.path) 113 | } 114 | 115 | private fun addPath(id: UUID, path: Path) { 116 | val parent = path.parent 117 | 118 | synchronized(watchedDirs) { 119 | val wd = watchedDirs.find { 120 | it.isDir(parent) 121 | } ?: run { 122 | val key = parent.register(watch, StandardWatchEventKinds.ENTRY_MODIFY) 123 | val wd = WatchedDir(parent, key) 124 | watchedDirs.add(wd) 125 | wd 126 | } 127 | wd.add(id, path) 128 | } 129 | } 130 | 131 | private fun onRemovePath(evt: ScriptEvent.RemoveScript) { 132 | 133 | synchronized(watchedDirs) { 134 | val wd = watchedDirs.find { 135 | it.hasScript(evt.id) 136 | } ?: return 137 | 138 | if (!wd.remove(evt.id)) { 139 | return 140 | } 141 | 142 | if (!wd.hasFiles()) { 143 | wd.cancel() 144 | watchedDirs.remove(wd) 145 | } 146 | } 147 | } 148 | 149 | private inner class Subscriber : BaseSubscriber() { 150 | 151 | override fun onNext(item: ScriptEvent?) { 152 | sub.request(1) 153 | if (item == null) { 154 | return 155 | } 156 | when (item) { 157 | is ScriptEvent.SetScript -> onNewPath(item) 158 | is ScriptEvent.RemoveScript -> onRemovePath(item) 159 | is ScriptEvent.OptionsUpdated -> {} 160 | } 161 | } 162 | } 163 | } 164 | 165 | private class WatchedScript( 166 | val id: UUID, 167 | val path: Path, 168 | var hash: ByteArray, 169 | ) { 170 | 171 | fun updateModified(): Boolean { 172 | 173 | val newHash = hashFile(path) 174 | if (newHash.contentEquals(hash)) { 175 | return false 176 | } 177 | 178 | hash = newHash 179 | return true 180 | } 181 | 182 | } 183 | 184 | private class WatchedDir( 185 | private val dir: Path, 186 | private val watchKey: WatchKey, 187 | private val files: MutableList = mutableListOf(), 188 | ) { 189 | 190 | private val logger = LogManager.getLogger(this) 191 | 192 | fun isDir(path: Path): Boolean = dir == path 193 | 194 | /// Return the [WatchedScript] for the given [Path] if we're watching 195 | /// the file. 196 | fun maybeGetScript(path: Path): WatchedScript? = 197 | if (path.isAbsolute) { 198 | files.find { it.path == path } 199 | } else { 200 | files.find { it.path.fileName == path.fileName } 201 | } 202 | 203 | fun cancel() { 204 | watchKey.cancel() 205 | } 206 | 207 | fun hasFiles(): Boolean = files.isNotEmpty() 208 | fun hasScript(id: UUID): Boolean = files.any { it.id == id } 209 | 210 | /** 211 | * Remove the given file and return whether there are still files being 212 | * watched 213 | */ 214 | fun remove(id: UUID): Boolean = 215 | files.removeIf { it.id == id } 216 | 217 | fun add(id: UUID, file: Path) { 218 | files.add(WatchedScript(id, file, hashFile(file))) 219 | } 220 | 221 | fun resolve(path: Path): Path = dir.resolve(path) 222 | 223 | 224 | } 225 | 226 | private fun hashFile(file: Path): ByteArray = 227 | MessageDigest.getInstance("MD5").digest( 228 | Files.readAllBytes(file) 229 | ) 230 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/annotations.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | /** 4 | * Marks a method or field as part of the scripting API 5 | */ 6 | annotation class ScriptApi 7 | 8 | /** 9 | * Marks a method or field as an experimental part of the scripting API. 10 | * This means the api is subject to change or removal in future versions. 11 | */ 12 | annotation class ExperimentalScriptApi 13 | 14 | /** 15 | * Marks a method as internal and not intended for use by scripts 16 | */ 17 | annotation class Internal 18 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/interop/binary.kt: -------------------------------------------------------------------------------- 1 | /** 2 | * Kotlin Bytes are signed. Guest bytes are, by convention, and necessity (python), 3 | * unsigned. 4 | * 5 | * Notes: 6 | * - Polyglot does not have the in-built mechanics to automatically convert the 7 | * experimental UByteArray to Value, so this cannot be used. 8 | * - Polyglot Proxy objects are intended to let hosts define their own guest types. 9 | * These have limitations. You cannot define a ProxyArray, such that, e.g. a python 10 | * guest thinks that it is a `bytes` object. Additionally, by design, proxy types 11 | * are not automatically mapped to host types, which perhaps means they cannot be 12 | * passed back into a host function? 13 | * https://github.com/oracle/graal/issues/2139#issuecomment-1904152100 14 | * - At any rate, the best we can do given the limited ability to define custom type 15 | * mapping is to explicitly convert between host (signed) and guest (unsigned) bytes 16 | * at the script api boundaries we control. 17 | * 18 | * - A targetTypeMapping would at least allow host functions to accept unsigned bytes 19 | * - https://github.com/oracle/graal/issues/2118 20 | */ 21 | 22 | package com.carvesystems.burpscript.interop 23 | 24 | import burp.api.montoya.core.ByteArray as BurpByteArray 25 | import org.graalvm.polyglot.Value 26 | import java.util.* 27 | 28 | /** 29 | * Annotates a return type that is to be interpreted by guest languages as an iterable 30 | * of unsigned bytes. 31 | * 32 | * Intended to be converted by guests to their native byte array type. 33 | * 34 | * Python: 35 | * bytes(unsigned_byte_array) 36 | * 37 | * JavaScript: 38 | * Uint8Array(unsignedByteArray) 39 | */ 40 | typealias UnsignedByteArray = Array 41 | 42 | /** 43 | * Annotates an argument type passed in from a guest language, which is 44 | * interpreted, by the host, as binary data. 45 | * 46 | * Allowed inputs: 47 | * - A string containing a base64-encoded binary blob ("aGVsbG8K"). 48 | * - A string containing a hex-encoded binary blob ("68656c6c6f"). 49 | * - An iterable of unsigned integer values. This can be a guest 50 | * language array, list, iterator, [bytes] (python), etc. 51 | * 52 | */ 53 | typealias AnyBinary = Value 54 | 55 | /** 56 | * Convert or decode a Value from a guest type to a ByteArray. 57 | * 58 | * - If the Value is a string, it is decoded as a base64 or hex-encoded 59 | * binary blob. 60 | * - If the Value is an iterable, it is converted to a ByteArray by 61 | * interpreting integer values as unsigned bytes. 62 | */ 63 | fun Value.asAnyBinaryToByteArray(): ByteArray = 64 | if (isString) { 65 | asString().decodeAsByteArray() 66 | } else { 67 | toByteArray() 68 | } 69 | 70 | /** 71 | * Reinterpret a guest unsigned byte value (e.g. > 127) to a host signed byte 72 | */ 73 | fun Value.reinterpretAsSignedByte(): Byte = asInt().toByte() 74 | 75 | /** 76 | * Value 77 | */ 78 | 79 | /** 80 | * Convert guest iterable types to ByteArray, reinterpreting unsigned to signed 81 | */ 82 | fun Value.toByteArray(): ByteArray { 83 | if (hasArrayElements()) { 84 | return ByteArray(arraySize.toInt()) { idx -> 85 | val value = getArrayElement(idx.toLong()) 86 | value.reinterpretAsSignedByte() 87 | } 88 | } else if (hasIterator()) { 89 | val lst = mutableListOf() 90 | val it = iterator 91 | while (it.hasIteratorNextElement()) { 92 | val next = it.iteratorNextElement 93 | lst.add(next.reinterpretAsSignedByte()) 94 | } 95 | return lst.toByteArray() 96 | } else if (isIterator) { 97 | val lst = mutableListOf() 98 | while (hasIteratorNextElement()) { 99 | val next = iteratorNextElement 100 | lst.add(next.reinterpretAsSignedByte()) 101 | } 102 | return lst.toByteArray() 103 | } 104 | throw IllegalArgumentException("can't make a ByteArray from type $this") 105 | } 106 | 107 | /** 108 | * Convert guest iterable types to montoya ByteArray, reinterpreting unsigned to signed 109 | */ 110 | fun Value.toBurpByteArray(): BurpByteArray { 111 | if (hasArrayElements()) { 112 | return BurpByteArray.byteArrayOfLength(arraySize.toInt()).also { 113 | for (idx in 0 until arraySize) { 114 | val value = getArrayElement(idx) 115 | it.setByte(idx.toInt(), value.reinterpretAsSignedByte()) 116 | } 117 | } 118 | } else if (hasIterator()) { 119 | val lst = mutableListOf() 120 | val it = iterator 121 | while (it.hasIteratorNextElement()) { 122 | val next = it.iteratorNextElement 123 | lst.add(next.reinterpretAsSignedByte()) 124 | } 125 | return BurpByteArray.byteArrayOfLength(lst.size).also { 126 | for (idx in lst.indices) { 127 | it.setByte(idx, lst[idx]) 128 | } 129 | } 130 | } else if (isIterator) { 131 | val lst = mutableListOf() 132 | while (hasIteratorNextElement()) { 133 | val next = iteratorNextElement 134 | lst.add(next.reinterpretAsSignedByte()) 135 | } 136 | return BurpByteArray.byteArrayOfLength(lst.size).also { 137 | for (idx in lst.indices) { 138 | it.setByte(idx, lst[idx]) 139 | } 140 | } 141 | } 142 | throw IllegalArgumentException("can't make a Montoya ByteArray from type $this") 143 | } 144 | 145 | /** 146 | * ByteArray 147 | */ 148 | 149 | fun ByteArray.toUnsignedByteArray(): UnsignedByteArray = 150 | map { it.toUByte().toInt() }.toTypedArray() 151 | 152 | fun ByteArray.toBurpByteArray(): BurpByteArray = 153 | BurpByteArray.byteArray(*this) 154 | 155 | fun ByteArray.toHex(): String = 156 | joinToString(separator = "") { eachByte -> "%02x".format(eachByte) } 157 | 158 | fun ByteArray.toBase64(): String = Base64.getEncoder().encodeToString(this) 159 | 160 | /** 161 | * Montoya ByteArray 162 | */ 163 | 164 | fun BurpByteArray.toByteArray(): ByteArray = ByteArray(length()) { idx -> 165 | getByte(idx) 166 | } 167 | 168 | fun BurpByteArray.toUnsignedByteArray(): UnsignedByteArray = 169 | Array(length()) { idx -> getByte(idx).toUByte().toInt() } 170 | 171 | fun burpByteArrayOf(vararg bytes: Byte): BurpByteArray = BurpByteArray.byteArray(*bytes) 172 | 173 | /** 174 | * String 175 | */ 176 | 177 | private val HEX_CHARS = "0123456789abcdef" 178 | private fun hexCharIdx(c: Char): Int { 179 | for (i in 0.until(16)) { 180 | if (c == HEX_CHARS[i]) { 181 | return i 182 | } 183 | } 184 | throw IllegalArgumentException("couldn't find $c in $HEX_CHARS") 185 | } 186 | 187 | fun String.decodeHex(): ByteArray { 188 | if (this.length.and(1) != 0) { 189 | throw IllegalArgumentException("length of $this is not divisible by 2") 190 | } 191 | val b = ByteArray(this.length / 2) 192 | val lc = this.lowercase() 193 | 194 | for ((j, i) in lc.indices.step(2).withIndex()) { 195 | val idx = hexCharIdx(lc[i]) 196 | val idx2 = hexCharIdx(lc[i + 1]) 197 | b[j] = idx.and(0x0F).shl(4).or(idx2.and(0x0F)).toByte() 198 | } 199 | return b 200 | } 201 | 202 | fun String.decodeBase64(): ByteArray = Base64.getDecoder().decode(this) 203 | 204 | fun String.isHex(): Boolean = this.lowercase().all { 205 | it in HEX_CHARS 206 | } 207 | 208 | /** 209 | * Decode a string containing binary hex, or base64 data into a byte array 210 | */ 211 | fun String.decodeAsByteArray(): ByteArray = 212 | try { 213 | this.decodeHex() 214 | } catch (e: IllegalArgumentException) { 215 | this.decodeBase64() 216 | } 217 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/interop/json.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.interop 2 | 3 | import com.carvesystems.burpscript.LogManager 4 | import com.carvesystems.burpscript.ScriptMap 5 | import kotlinx.serialization.KSerializer 6 | import kotlinx.serialization.SerializationException 7 | import kotlinx.serialization.descriptors.PrimitiveKind 8 | import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 9 | import kotlinx.serialization.encodeToString 10 | import kotlinx.serialization.encoding.Decoder 11 | import kotlinx.serialization.encoding.Encoder 12 | import kotlinx.serialization.json.* 13 | import kotlinx.serialization.modules.SerializersModule 14 | import kotlinx.serialization.modules.contextual 15 | import org.graalvm.polyglot.Value 16 | import java.nio.file.Path 17 | 18 | /** 19 | * Serialize and deserialize to and from unknown types 20 | * 21 | * - Numeric types are narrowed to int, long, or double. Arbitrary precision is not supported 22 | * - Json objects are deserialized to ScriptMaps, for convenience 23 | * - Serialization incurs copy overhead 24 | */ 25 | object AnySerializer : KSerializer { 26 | // Really should be a KSerializer, but this is incompatible with registering it in 27 | // the serializer module. 28 | // https://github.com/Kotlin/kotlinx.serialization/issues/296 29 | // 30 | private val delegateSerializer = JsonElement.serializer() 31 | override val descriptor = delegateSerializer.descriptor 32 | override fun serialize(encoder: Encoder, value: Any) { 33 | encoder.encodeSerializableValue(delegateSerializer, value.toJsonElement()) 34 | } 35 | 36 | override fun deserialize(decoder: Decoder): Any { 37 | val jsonPrimitive = decoder.decodeSerializableValue(delegateSerializer) 38 | return jsonPrimitive.toAny()!! // Can actually be null 39 | } 40 | 41 | // 42 | // Any -> json 43 | // 44 | 45 | private fun Any?.toJsonElement(): JsonElement = when (this) { 46 | null -> JsonNull 47 | is String -> JsonPrimitive(this) 48 | is Number -> JsonPrimitive(this) 49 | is Boolean -> JsonPrimitive(this) 50 | is Map<*, *> -> toJsonObject() 51 | is Iterable<*> -> toJsonArray() 52 | else -> throw SerializationException("Unsupported type $this - ${this.javaClass}") 53 | } 54 | 55 | private fun Map<*, *>.toJsonObject(): JsonObject = 56 | JsonObject(this.entries.associate { it.key.toString() to it.value.toJsonElement() }) 57 | 58 | private fun Iterable<*>.toJsonArray(): JsonArray = JsonArray(this.map { it.toJsonElement() }) 59 | 60 | // 61 | // Json -> Any 62 | // 63 | 64 | private fun JsonElement.toAny(): Any? = when (this) { 65 | is JsonPrimitive -> toAny() 66 | is JsonObject -> toMap() 67 | is JsonArray -> toList() 68 | } 69 | 70 | private fun JsonPrimitive.toAny(): Any? = when { 71 | this is JsonNull -> null 72 | this.isString -> this.content 73 | else -> { 74 | booleanOrNull 75 | ?: maybeToNumber() 76 | // The JsonElement serializer permits bare text without signaling a syntax error. 77 | // A JsonLiteral is constructed, but it doesn't belong to any particular type. 78 | // Seems like kotlinx should treat this as a syntax error. 79 | ?: throw SerializationException("Unexpected JSON token at offset 0: $this") 80 | } 81 | } 82 | 83 | private fun JsonPrimitive.maybeToNumber(): Number? { 84 | intOrNull?.let { return it } 85 | longOrNull?.let { return it } 86 | doubleOrNull?.let { return it } 87 | return null 88 | } 89 | 90 | private fun JsonObject.toMap(): Map = entries.associateTo(ScriptMap()) { 91 | when (val jsonElement = it.value) { 92 | is JsonPrimitive -> it.key to jsonElement.toAny() 93 | is JsonObject -> it.key to jsonElement.toMap() 94 | is JsonArray -> it.key to jsonElement.toList() 95 | } 96 | } 97 | 98 | private fun JsonArray.toList(): List = this.map { it.toAny() } 99 | } 100 | 101 | object ScriptMapSerializer : KSerializer { 102 | private val delegateSerializer = AnySerializer 103 | override val descriptor = delegateSerializer.descriptor 104 | override fun serialize(encoder: Encoder, value: ScriptMap) { 105 | encoder.encodeSerializableValue(delegateSerializer, value) 106 | } 107 | 108 | override fun deserialize(decoder: Decoder): ScriptMap = 109 | decoder.decodeSerializableValue(delegateSerializer) as ScriptMap 110 | } 111 | 112 | /** Serialize a Value to json 113 | * 114 | * Deserializing a Value from json is not supported. This is because host type 115 | * access is not enabled by the default context (Value.asValue()). 116 | * 117 | * Host access must be enabled explicitly within a context, such as with: 118 | * val obj = Context.newBuilder().allowAllAccess(true).build().asValue(fromJson("{...}")) 119 | */ 120 | object ValueSerializer : KSerializer { 121 | private val delegateSerializer = JsonElement.serializer() 122 | override val descriptor = delegateSerializer.descriptor 123 | override fun serialize(encoder: Encoder, value: Value) { 124 | encoder.encodeSerializableValue(delegateSerializer, value.toJsonElement()) 125 | } 126 | 127 | override fun deserialize(decoder: Decoder): Value = 128 | throw SerializationException( 129 | "Deserializing ${Value::class.qualifiedName} from json is not supported" 130 | ) 131 | 132 | private fun Value.toJsonElement(): JsonElement = 133 | when { 134 | isNull -> JsonNull 135 | isBoolean -> JsonPrimitive(asBoolean()) 136 | isNumber -> JsonPrimitive(asNumber()) 137 | isString -> JsonPrimitive(asString()) 138 | hasArrayElements() -> toJsonArray() 139 | hasHashEntries() -> toJsonObject() 140 | else -> throw IllegalArgumentException("Unsupported type: $this") 141 | } 142 | 143 | private fun Value.toJsonObject(): JsonObject { 144 | val map = mutableMapOf() 145 | val keys = hashKeysIterator 146 | while (keys.hasIteratorNextElement()) { 147 | val key = keys.iteratorNextElement.toString() 148 | try { 149 | map[key] = getHashValue(key).toJsonElement() 150 | } catch (e: Exception) { 151 | val hostType = if (isHostObject) asHostObject()?.javaClass?.simpleName else "Unknown" 152 | LogManager.getLogger("toMap").error( 153 | "dropping value at $key because value is unexpected type $hostType - $this" 154 | ) 155 | } 156 | } 157 | return JsonObject(map) 158 | } 159 | 160 | private fun Value.toJsonArray(): JsonArray { 161 | val lst = mutableListOf() 162 | for (idx in 0 until arraySize) { 163 | try { 164 | lst.add(getArrayElement(idx).toJsonElement()) 165 | } catch (e: Exception) { 166 | val hostType = if (isHostObject) asHostObject()?.javaClass?.simpleName else "Unknown" 167 | LogManager.getLogger("toJsonArray").error( 168 | "dropping value at $idx because value is unexpected type $hostType - $this" 169 | ) 170 | } 171 | } 172 | return JsonArray(lst) 173 | } 174 | } 175 | 176 | /** 177 | * Serialize and deserialize a type as a string 178 | */ 179 | abstract class AsStringSerializer : KSerializer { 180 | private val fromString: (String) -> T 181 | private val toString: (T) -> String 182 | 183 | constructor(fromString: (String) -> T, toString: (T) -> String) { 184 | this.fromString = fromString 185 | this.toString = toString 186 | } 187 | 188 | constructor(fromString: (String) -> T) : this(fromString, Any?::toString as (T) -> String) 189 | 190 | override val descriptor = 191 | PrimitiveSerialDescriptor("AsString", PrimitiveKind.STRING) 192 | 193 | override fun serialize(encoder: Encoder, value: T) = encoder.encodeString(toString(value)) 194 | 195 | override fun deserialize(decoder: Decoder): T = fromString(decoder.decodeString()) 196 | } 197 | 198 | /** 199 | * Serialize path polymorphic types as strings. 200 | * 201 | * Since path is polymorphic, it's awkward to register as contextual... 202 | * 203 | * Put this at the top of the file defining the serialized class that has Path members: 204 | * @file:UseSerializers(PathSerializer::class) 205 | */ 206 | object PathSerializer : AsStringSerializer(Path::of) 207 | 208 | // 209 | // JSON utilities 210 | // 211 | 212 | val scriptSerializers = SerializersModule { 213 | contextual(ValueSerializer) 214 | contextual(ScriptMapSerializer) 215 | contextual(AnySerializer) 216 | } 217 | 218 | val scriptJson = Json { 219 | encodeDefaults = true 220 | serializersModule = scriptSerializers 221 | } 222 | 223 | /** 224 | * Deserialize an unknown type from JSON. 225 | * - Numeric types are narrowed to int, long, or double. 226 | * - Json objects are deserialized to ScriptMaps, for convenience 227 | */ 228 | fun fromJson(value: String, format: Json = scriptJson): Any? = 229 | if (value.trim().isEmpty()) { 230 | // json.loads and JSON.parse don't do this, but it seems better than a noisy exception 231 | null 232 | } else { 233 | format.decodeFromString(value) 234 | } 235 | 236 | /** 237 | * Deserialize a known, or partially known type from JSON. 238 | * 239 | * The type can be any @Serializable, provided the serializer is registered. 240 | * The full type of a generic does not need to be specified: 241 | * - Map 242 | * - List 243 | */ 244 | inline fun fromJsonAs(value: String, format: Json = scriptJson): T = 245 | format.decodeFromString(value) 246 | 247 | /** 248 | * Deserialize a known, or partially known type from JSON. Null is returned on deserialization failure. 249 | */ 250 | inline fun maybeFromJsonAs(value: String, format: Json = scriptJson): T? = 251 | runCatching { fromJsonAs(value, format) }.getOrNull() 252 | 253 | /** 254 | * Serialize an object to JSON 255 | * 256 | * Can be any @Serializable or primitive type. 257 | */ 258 | inline fun toJson(value: T, format: Json = scriptJson): String = 259 | format.encodeToString(value) 260 | 261 | inline fun parse(value: String): T { 262 | return Json.decodeFromString(value) 263 | } 264 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/interop/value.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.interop 2 | 3 | import org.graalvm.polyglot.Value 4 | import org.graalvm.polyglot.proxy.ProxyExecutable 5 | 6 | /** Convert to the narrowest number type that can represent the value */ 7 | fun Value.asNumber(): Number = when { 8 | fitsInInt() -> asInt() 9 | fitsInLong() -> asLong() 10 | else -> asDouble() 11 | } 12 | 13 | fun Value.asAny(): Any? = when { 14 | isNull -> null 15 | isString -> asString() 16 | isBoolean -> asBoolean() 17 | isNumber -> asNumber() 18 | hasHashEntries() -> toMap() 19 | hasArrayElements() -> toList() 20 | isHostObject -> asHostObject() 21 | else -> throw IllegalArgumentException("Unsupported type: $this") 22 | } 23 | 24 | fun Value.toMap(): Map { 25 | if (!hasHashEntries()) { 26 | throw IllegalArgumentException("Value cannot be converted to a map: $this") 27 | } 28 | 29 | val map = mutableMapOf() 30 | val keys = hashKeysIterator 31 | while (keys.hasIteratorNextElement()) { 32 | val key = keys.iteratorNextElement.asAny()!! 33 | map[key] = getHashValue(key).asAny() 34 | } 35 | return map 36 | } 37 | 38 | fun Value.toList(): List = 39 | if (hasArrayElements()) { 40 | List(arraySize.toInt()) { getArrayElement(it.toLong()).asAny() } 41 | } else { 42 | throw IllegalArgumentException("Value cannot be converted to a list: $this") 43 | } 44 | 45 | fun Value.toException(): Throwable? = 46 | when { 47 | isException -> try { 48 | throwException() 49 | } catch (e: Throwable) { 50 | e 51 | } 52 | else -> null // Exception(toString()) 53 | } 54 | 55 | 56 | /** 57 | * Makes a free function callable from script 58 | */ 59 | class CallableValue(val func: (args: List) -> T) : ProxyExecutable { 60 | override fun execute(vararg arguments: Value): Any = func(arguments.toList()) 61 | } 62 | -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/ui/DocsTab.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.ui 2 | 3 | import burp.api.montoya.MontoyaApi 4 | import java.awt.BorderLayout 5 | import java.awt.Color 6 | import javax.swing.JPanel 7 | import javax.swing.JScrollPane 8 | import javax.swing.JTextPane 9 | import javax.swing.text.SimpleAttributeSet 10 | import javax.swing.text.StyleConstants 11 | 12 | 13 | class DocsTab( 14 | private val api: MontoyaApi, 15 | private val viewModel: DocsTabViewModel 16 | ) : JPanel() { 17 | private val textDisplay = JTextPane() 18 | 19 | init { 20 | setupUi() 21 | } 22 | 23 | private fun setupUi() { 24 | 25 | layout = BorderLayout() 26 | 27 | val docs = viewModel.getFilterExpressionDocs().sortedBy { 28 | it.name 29 | } 30 | textDisplay.isEditable = false 31 | 32 | val styledDoc = textDisplay.styledDocument 33 | 34 | val defaultSize = textDisplay.font.size 35 | 36 | val funcDefStyle = SimpleAttributeSet() 37 | StyleConstants.setFontFamily(funcDefStyle, "monospace") 38 | StyleConstants.setFontSize(funcDefStyle, defaultSize + 2) 39 | 40 | val funcStyle = SimpleAttributeSet(funcDefStyle) 41 | StyleConstants.setBold(funcStyle, true) 42 | 43 | val argStyle = SimpleAttributeSet(funcDefStyle) 44 | 45 | val argNameStyle = SimpleAttributeSet(argStyle) 46 | StyleConstants.setForeground(argNameStyle, Color.decode("0xB16286")) 47 | StyleConstants.setItalic(argNameStyle, true) 48 | 49 | val argTypeStyle = SimpleAttributeSet(funcDefStyle) 50 | StyleConstants.setForeground(argTypeStyle, Color.decode("0xD65D0E")) 51 | 52 | val keywordStyle = SimpleAttributeSet(funcDefStyle) 53 | StyleConstants.setForeground(keywordStyle, Color.decode("0xD79921")) 54 | 55 | val docStyle = SimpleAttributeSet() 56 | 57 | docs.forEach { 58 | 59 | styledDoc.insertString(styledDoc.length, "(", funcDefStyle) 60 | styledDoc.insertString(styledDoc.length, it.name, funcStyle) 61 | 62 | it.args.forEach { arg -> 63 | 64 | styledDoc.insertString(styledDoc.length, " ${arg.name}", argNameStyle) 65 | styledDoc.insertString(styledDoc.length, ": ", funcDefStyle) 66 | 67 | if (arg.isVararg) { 68 | styledDoc.insertString(styledDoc.length, "vararg ", keywordStyle) 69 | } 70 | styledDoc.insertString(styledDoc.length, arg.type.toString(), argTypeStyle) 71 | } 72 | styledDoc.insertString(styledDoc.length, ")\n", funcDefStyle) 73 | 74 | 75 | it.shortDoc?.let { shortDoc -> 76 | styledDoc.insertString(styledDoc.length, "$shortDoc\n\n", docStyle) 77 | } ?: run { 78 | styledDoc.insertString(styledDoc.length, "\n\n", docStyle) 79 | } 80 | } 81 | 82 | api.userInterface().applyThemeToComponent(textDisplay) 83 | api.userInterface().applyThemeToComponent(this) 84 | val scrollPane = JScrollPane(textDisplay) 85 | scrollPane.isWheelScrollingEnabled = true 86 | scrollPane.verticalScrollBarPolicy = JScrollPane.VERTICAL_SCROLLBAR_ALWAYS 87 | scrollPane.horizontalScrollBarPolicy = JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED 88 | add(scrollPane, BorderLayout.CENTER) 89 | 90 | 91 | } 92 | 93 | // Taken from StackOverflow https://stackoverflow.com/questions/30590031/jtextpane-line-wrap-behavior 94 | //class WrappingTextPane() : JTextPane() { 95 | // init { 96 | // editorKit = WrapEditorKit() 97 | // } 98 | 99 | // private inner class WrapEditorKit : StyledEditorKit() { 100 | // private val defaultFactory: ViewFactory = WrapColumnFactory() 101 | // override fun getViewFactory(): ViewFactory { 102 | // return defaultFactory 103 | // } 104 | // } 105 | 106 | // private inner class WrapColumnFactory : ViewFactory { 107 | // override fun create(element: Element): View { 108 | // val kind: String = element.name 109 | // when (kind) { 110 | // AbstractDocument.ContentElementName -> return WrapLabelView(element) 111 | // AbstractDocument.ParagraphElementName -> return ParagraphView(element) 112 | // AbstractDocument.SectionElementName -> return BoxView(element, View.Y_AXIS) 113 | // StyleConstants.ComponentElementName -> return ComponentView(element) 114 | // StyleConstants.IconElementName -> return IconView(element) 115 | // } 116 | 117 | // return LabelView(element) 118 | // } 119 | // } 120 | 121 | // private inner class WrapLabelView(element: Element?) : LabelView(element) { 122 | // override fun getMinimumSpan(axis: Int): Float { 123 | // return when (axis) { 124 | // View.X_AXIS -> 0F 125 | // View.Y_AXIS -> super.getMinimumSpan(axis) 126 | // else -> throw IllegalArgumentException("Invalid axis: $axis") 127 | // } 128 | // } 129 | // } 130 | //} 131 | 132 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/ui/DocsTabViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.ui 2 | 3 | import com.carvesystems.burpscript.FilterFunctionDoc 4 | import com.carvesystems.burpscript.getFunctionDocs 5 | 6 | class DocsTabViewModel { 7 | 8 | fun getFilterExpressionDocs(): List = 9 | getFunctionDocs() 10 | 11 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/ui/ScriptEntry.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.ui 2 | 3 | import burp.api.montoya.MontoyaApi 4 | import com.carvesystems.burpscript.LogManager 5 | import com.carvesystems.burpscript.ScopedPersistence 6 | import com.carvesystems.burpscript.Strings 7 | import java.awt.Color 8 | import java.awt.Dimension 9 | import java.io.File 10 | import javax.swing.* 11 | import javax.swing.border.EmptyBorder 12 | import javax.swing.border.LineBorder 13 | import kotlin.io.path.exists 14 | 15 | class ScriptEntry( 16 | private val api: MontoyaApi, 17 | private val viewModel: ScriptEntryViewModel, 18 | ) : JPanel() { 19 | 20 | private val enabledCheckBox = JCheckBox(Strings.get("enabled")) 21 | private val inScopeOnlyCheckBox = JCheckBox(Strings.get("in_scope_only")) 22 | private val proxyOnlyCheckBox = JCheckBox(Strings.get("proxy_only")) 23 | private val filePath = JTextField() 24 | private val removeButton = JButton(Strings.get("remove")) 25 | private val browseButton = JButton(Strings.get("browse")) 26 | private val burpUi = api.userInterface() 27 | private val logger = LogManager.getLogger(this) 28 | val id = viewModel.scriptId 29 | 30 | private val viewModelCallbacks = Callbacks() 31 | private var myLastDir: String? = null 32 | 33 | init { 34 | setupUi() 35 | viewModel.setCallbacks(viewModelCallbacks) 36 | } 37 | 38 | private fun setupUi() { 39 | 40 | layout = BoxLayout(this, BoxLayout.Y_AXIS) 41 | setBorder(Color.BLACK) 42 | 43 | 44 | val top = JPanel() 45 | top.layout = BoxLayout(top, BoxLayout.X_AXIS) 46 | 47 | val bottom = JPanel() 48 | bottom.layout = BoxLayout(bottom, BoxLayout.X_AXIS) 49 | 50 | setupEnableCheckbox(top) 51 | setupInScopeCheckbox(top) 52 | setupProxyOnlyCheckbox(top) 53 | setupRemoveButton(top) 54 | setupFileBrowsing(bottom) 55 | 56 | add(top) 57 | add(bottom) 58 | burpUi.applyThemeToComponent(this) 59 | } 60 | 61 | private fun setBorder(color: Color) { 62 | border = BorderFactory.createCompoundBorder( 63 | EmptyBorder(8, 8, 8, 8), 64 | LineBorder(color, 2, true) 65 | ) 66 | 67 | } 68 | 69 | private fun setupProxyOnlyCheckbox(parent: JPanel) { 70 | proxyOnlyCheckBox.isSelected = viewModel.opts.proxyOnly 71 | 72 | proxyOnlyCheckBox.addChangeListener { 73 | val value = proxyOnlyCheckBox.isSelected 74 | viewModel.setProxyOnly(value) 75 | } 76 | 77 | parent.add(proxyOnlyCheckBox) 78 | } 79 | 80 | private fun setupRemoveButton(parent: JPanel) { 81 | removeButton.addActionListener { 82 | viewModel.deleted() 83 | } 84 | parent.add(removeButton) 85 | } 86 | 87 | private fun setupFileBrowsing(parent: JPanel) { 88 | filePath.isEditable = false 89 | viewModel.path?.let { 90 | filePath.text = it.toString() 91 | } 92 | filePath.maximumSize = Dimension( 93 | Int.MAX_VALUE, 94 | browseButton.maximumSize.height 95 | ) 96 | browseButton.isEnabled = !viewModel.opts.active 97 | browseButton.addActionListener { onBrowse() } 98 | parent.add(filePath) 99 | parent.add(browseButton) 100 | } 101 | 102 | private fun setupInScopeCheckbox(parent: JPanel) { 103 | 104 | inScopeOnlyCheckBox.isSelected = viewModel.opts.inScopeOnly 105 | 106 | inScopeOnlyCheckBox.addChangeListener { 107 | val value = inScopeOnlyCheckBox.isSelected 108 | viewModel.setInScopeOnly(value) 109 | } 110 | 111 | parent.add(inScopeOnlyCheckBox) 112 | 113 | } 114 | 115 | private fun setupEnableCheckbox(parent: JPanel) { 116 | 117 | val initActive = viewModel.opts.active 118 | enabledCheckBox.isSelected = initActive 119 | enabledCheckBox.isEnabled = initActive 120 | 121 | enabledCheckBox.addChangeListener { 122 | val active = enabledCheckBox.isSelected 123 | viewModel.setActive(active) 124 | if (active) { 125 | disableInputItems() 126 | } else { 127 | enableInputItems() 128 | } 129 | } 130 | 131 | parent.add(enabledCheckBox) 132 | 133 | } 134 | 135 | private fun disableInputItems() { 136 | browseButton.isEnabled = false 137 | } 138 | 139 | private fun enableInputItems() { 140 | browseButton.isEnabled = true 141 | } 142 | 143 | private fun enableEnableCheckbox() { 144 | enabledCheckBox.isEnabled = true 145 | } 146 | 147 | private fun canEnable(): Boolean { 148 | return filePath.text.isNotEmpty() 149 | } 150 | 151 | private fun onBrowse() { 152 | val pers = ScopedPersistence.get(api.persistence(), this) 153 | val data = try { 154 | pers.extensionData() 155 | } catch (e: Exception) { 156 | logger.error("failed to get last dir", e) 157 | null 158 | } 159 | 160 | val lastDir = myLastDir ?: data?.getString(LAST_DIR_KEY) ?: System.getProperty("user.home") 161 | 162 | val chooser = JFileChooser(File(lastDir)) 163 | val option = chooser.showOpenDialog(this) 164 | 165 | if (option != JFileChooser.APPROVE_OPTION) { 166 | return 167 | } 168 | 169 | val asPath = chooser.selectedFile.toPath() 170 | 171 | if (asPath.exists()) { 172 | val dir = asPath.parent.toString() 173 | myLastDir = dir 174 | data?.setString(LAST_DIR_KEY, dir) 175 | } else { 176 | return 177 | } 178 | 179 | filePath.text = asPath.toString() 180 | viewModel.setFile(asPath) 181 | 182 | if (canEnable()) { 183 | enableEnableCheckbox() 184 | } 185 | } 186 | 187 | private inner class Callbacks : ScriptEntryViewModel.Callbacks { 188 | override fun onLoadFailed() { 189 | enabledCheckBox.isSelected = false 190 | enabledCheckBox.isEnabled = false 191 | browseButton.isEnabled = true 192 | setBorder(Color.RED) 193 | } 194 | 195 | override fun onLoadSucceeded() { 196 | enabledCheckBox.isEnabled = true 197 | setBorder(Color.BLACK) 198 | } 199 | } 200 | 201 | companion object { 202 | private const val LAST_DIR_KEY = "last_dir" 203 | } 204 | 205 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/ui/ScriptEntryViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.ui 2 | 3 | import com.carvesystems.burpscript.* 4 | import java.nio.file.Path 5 | import java.util.* 6 | import java.util.concurrent.Flow 7 | import java.util.concurrent.SubmissionPublisher 8 | 9 | class ScriptEntryViewModel( 10 | private val loadEvents: Flow.Publisher, 11 | private val scriptEventPub: SubmissionPublisher, 12 | val scriptId: UUID = UUID.randomUUID(), 13 | var opts: Script.Options = Script.Options(), 14 | var path: Path? = null, 15 | ) { 16 | 17 | private val loadEventsSub = LoadEventSub() 18 | 19 | init { 20 | loadEvents.subscribe(loadEventsSub) 21 | } 22 | 23 | private var callbacks: Callbacks? = null 24 | 25 | fun deleted() { 26 | val evt = ScriptEvent.RemoveScript(scriptId) 27 | loadEventsSub.cancel() 28 | sendEvent(evt) 29 | } 30 | 31 | fun setCallbacks(cb: Callbacks) { 32 | callbacks = cb 33 | } 34 | 35 | fun setFile(file: Path?) { 36 | 37 | path = file 38 | 39 | file?.let { 40 | val lang = Language.fromPath(it) 41 | val evt = ScriptEvent.SetScript(scriptId, it, lang, opts) 42 | sendEvent(evt) 43 | } ?: run { 44 | if (opts.active) { 45 | opts = opts.copy(active = false) 46 | sendOpts() 47 | } 48 | } 49 | } 50 | 51 | fun setProxyOnly(proxyOnly: Boolean) { 52 | opts = opts.copy(proxyOnly = proxyOnly) 53 | sendOpts() 54 | } 55 | 56 | fun setInScopeOnly(inScopeOnly: Boolean) { 57 | opts = opts.copy(inScopeOnly = inScopeOnly) 58 | sendOpts() 59 | } 60 | 61 | fun setActive(active: Boolean) { 62 | opts = opts.copy(active = active) 63 | sendOpts() 64 | } 65 | 66 | private fun sendOpts() { 67 | val evt = ScriptEvent.OptionsUpdated(scriptId, opts) 68 | sendEvent(evt) 69 | } 70 | 71 | private fun sendEvent(evt: ScriptEvent) { 72 | scriptEventPub.submit(evt) 73 | } 74 | 75 | interface Callbacks { 76 | fun onLoadFailed() 77 | fun onLoadSucceeded() 78 | } 79 | 80 | private inner class LoadEventSub : BaseSubscriber() { 81 | override fun onNext(item: ScriptLoadEvent?) { 82 | requestAnother() 83 | if (item == null) { 84 | return 85 | } 86 | when (item) { 87 | is ScriptLoadEvent.LoadFailed -> { 88 | if (item.id == scriptId) { 89 | callbacks?.onLoadFailed() 90 | } 91 | 92 | } 93 | 94 | is ScriptLoadEvent.LoadSucceeded -> { 95 | if (item.id == scriptId) { 96 | callbacks?.onLoadSucceeded() 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/ui/ScriptsTab.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.ui 2 | 3 | import burp.api.montoya.MontoyaApi 4 | import com.carvesystems.burpscript.SaveData 5 | import com.carvesystems.burpscript.Strings 6 | import java.util.* 7 | import javax.swing.BoxLayout 8 | import javax.swing.JButton 9 | import javax.swing.JPanel 10 | import javax.swing.JScrollPane 11 | 12 | class ScriptsTab( 13 | private val api: MontoyaApi, 14 | private val viewModel: ScriptsTabViewModel, 15 | ) : JPanel() { 16 | 17 | private val addButton = JButton(Strings.get("add")) 18 | private val scriptEntries = JPanel() 19 | 20 | private val burpUi = api.userInterface() 21 | 22 | init { 23 | setupUi() 24 | 25 | viewModel.setOnRemove() { onRemove(it) } 26 | } 27 | 28 | private fun setupUi() { 29 | 30 | SaveData.forEachScript { 31 | val vm = viewModel.getScriptEntryViewModel(it.id, it.opts, it.path) 32 | val script = ScriptEntry(api, vm) 33 | scriptEntries.add(script) 34 | } 35 | 36 | layout = BoxLayout(this, BoxLayout.Y_AXIS) 37 | 38 | val top = JPanel() 39 | top.layout = BoxLayout(top, BoxLayout.X_AXIS) 40 | 41 | 42 | setupAddButton(top) 43 | add(top) 44 | 45 | setupScriptEntries() 46 | 47 | 48 | burpUi.applyThemeToComponent(this) 49 | } 50 | 51 | private fun onRemove(id: UUID) { 52 | scriptEntries.components.mapIndexed { i, it -> 53 | if (it is ScriptEntry && it.id == id) { 54 | i 55 | } else { 56 | -1 57 | } 58 | }.map { 59 | if (it != -1) { 60 | scriptEntries.remove(it) 61 | } 62 | } 63 | updateList() 64 | } 65 | 66 | private fun updateList() { 67 | revalidate() 68 | repaint() 69 | } 70 | 71 | private fun setupAddButton(parent: JPanel) { 72 | 73 | scriptEntries.layout = BoxLayout(scriptEntries, BoxLayout.Y_AXIS) 74 | 75 | addButton.addActionListener { 76 | val vm = viewModel.getScriptEntryViewModel() 77 | val script = ScriptEntry(api, vm) 78 | scriptEntries.add(script) 79 | updateList() 80 | } 81 | 82 | parent.add(addButton) 83 | 84 | } 85 | 86 | private fun setupScriptEntries() { 87 | val scroll = JScrollPane(scriptEntries) 88 | add(scroll) 89 | } 90 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/ui/ScriptsTabViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.ui 2 | 3 | import burp.api.montoya.MontoyaApi 4 | import com.carvesystems.burpscript.Script 5 | import com.carvesystems.burpscript.ScriptEvent 6 | import com.carvesystems.burpscript.ScriptLoadEvent 7 | import java.nio.file.Path 8 | import java.util.* 9 | import java.util.concurrent.Flow 10 | import java.util.concurrent.Flow.Subscriber 11 | import java.util.concurrent.SubmissionPublisher 12 | 13 | class ScriptsTabViewModel( 14 | private val api: MontoyaApi, 15 | private val scriptEventsPub: SubmissionPublisher, 16 | private val loadPub: Flow.Publisher 17 | ) { 18 | 19 | private var onRemove: ((id: UUID) -> Unit)? = null 20 | private val scriptEventsSub = ScriptEventsSub() 21 | 22 | init { 23 | scriptEventsPub.subscribe(scriptEventsSub) 24 | } 25 | 26 | fun setOnRemove(onRemove: ((id: UUID) -> Unit)) { 27 | this.onRemove = onRemove 28 | } 29 | 30 | fun getScriptEntryViewModel(): ScriptEntryViewModel = 31 | ScriptEntryViewModel(loadPub, scriptEventsPub) 32 | 33 | fun getScriptEntryViewModel(id: UUID, opts: Script.Options, path: Path?): ScriptEntryViewModel = 34 | ScriptEntryViewModel(loadPub, scriptEventsPub, id, opts, path) 35 | 36 | private inner class ScriptEventsSub : Subscriber { 37 | 38 | lateinit var sub: Flow.Subscription 39 | 40 | override fun onComplete() { 41 | } 42 | 43 | override fun onError(throwable: Throwable?) { 44 | } 45 | 46 | override fun onNext(item: ScriptEvent?) { 47 | sub.request(1) 48 | if (item == null) { 49 | return 50 | } 51 | 52 | when (item) { 53 | is ScriptEvent.RemoveScript -> { 54 | onRemove?.let { it(item.id) } 55 | } 56 | 57 | else -> {} 58 | } 59 | 60 | } 61 | 62 | override fun onSubscribe(subscription: Flow.Subscription) { 63 | sub = subscription 64 | sub.request(1) 65 | } 66 | 67 | } 68 | 69 | companion object { 70 | private const val SCRIPTS_KEY = "scripts" 71 | } 72 | 73 | 74 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/carvesystems/burpscript/utils.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import burp.api.montoya.MontoyaApi 4 | import burp.api.montoya.utilities.Utilities 5 | 6 | lateinit var burpUtils: Utilities 7 | 8 | fun initializeUtils(api: MontoyaApi) { 9 | burpUtils = api.utilities() 10 | } -------------------------------------------------------------------------------- /src/main/resources/com/carvesystems/burpscript/ui/strings.properties: -------------------------------------------------------------------------------- 1 | browse=Browse 2 | enabled=Enabled 3 | file=File 4 | in_scope_only=In Scope Only 5 | tab_name=Scripting 6 | scripts_tab_name=Scripts 7 | docs_tab_name=Docs 8 | remove=Remove 9 | add=Add 10 | extension_name=Burp Scripting 11 | proxy_only=Proxy Only 12 | request_modified=req modified 13 | response_modified=res modified 14 | both_modified=req/res modified 15 | # Filter expression docs 16 | noDoc="No documentation" 17 | and-doc=Logical AND of all expressions and booleans provided 18 | or-doc=Logical OR of all expressions and booleans provided 19 | not-doc=Negates the provided expression or boolean 20 | host-matches-doc=Checks if the host matches the given pattern 21 | method-eq-doc=Checks if the HTTP method matches one of the arguments 22 | path-contains-doc=Checks if the URL path contains the given pattern 23 | path-matches-doc=Checks if the URL path matches the given pattern exactly 24 | file-ext-eq-doc=Checks if the requested file extension is any of the given strings 25 | has-header-doc=Checks if the request/response has the given header 26 | body-contains-doc=Checks if the body contains the given pattern 27 | body-matches-doc=Checks if the entire body matches the provided pattern 28 | has-json-key-doc=Searches for any of the provided JSON keys in the req/res body. This function supported dotted syntax for JSON keys to search for nested keys 29 | has-query-param-doc=Checks that the given query parameter exists 30 | has-form-param-doc=Checks that the request has the given form parameter 31 | query-param-matches-doc=Checks that the given query parameter matches the given pattern 32 | status-code-eq-doc=Only applicable as a Response filter, checks that the status code is one of the provided codes 33 | status-code-in-doc=Checks that the status code is in the given range (inclusive) 34 | has-attachment-doc=Checks if the given attachment key exists 35 | listener-port-eq-doc=Checks that the request was to a listener with one of the provided ports 36 | tool-source-eq-doc=Checks if the tool source is one of the provided sources 37 | from-proxy-doc=Checks if the request/response is from the proxy tool 38 | in-scope-doc=Checks if the request/response is in scope 39 | header-matches-doc=Checks if the header matches the given pattern 40 | has-cookie-doc=Checks if the request/response contains any of the provided cookies 41 | -------------------------------------------------------------------------------- /src/test/kotlin/com/carvesystems/burpscript/ConfigTest.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.engine.spec.tempdir 5 | import io.kotest.matchers.equality.shouldBeEqualToComparingFields 6 | import io.kotest.matchers.nulls.shouldBeNull 7 | import io.mockk.clearMocks 8 | import io.mockk.every 9 | import io.mockk.spyk 10 | import java.nio.file.Files 11 | import java.nio.file.Paths 12 | 13 | 14 | class ConfigTest : StringSpec() { 15 | lateinit var cfg: Config 16 | 17 | init { 18 | val configDir = tempdir() 19 | val valid = configDir.toPath().resolve("config.json") 20 | Files.writeString( 21 | valid, """ 22 | { 23 | "python": { 24 | "executablePath": "/usr/bin/python3", 25 | "contextOptions": [ 26 | {"opt": "opt1", "value": "val1"}, 27 | {"opt": "opt2", "value": "val2"} 28 | ] 29 | } 30 | } 31 | """ 32 | ) 33 | 34 | val invalid = configDir.toPath().resolve("invalid.json") 35 | Files.writeString(invalid, "asdf") 36 | 37 | beforeSpec { 38 | cfg = spyk() 39 | } 40 | 41 | afterTest { 42 | clearMocks(cfg) 43 | } 44 | 45 | "configs" { 46 | every { 47 | cfg.configFile() 48 | } returns valid 49 | 50 | cfg.readConfig()!!.shouldBeEqualToComparingFields( 51 | ScriptConfig( 52 | python = PythonLangOptions( 53 | executable = "/usr/bin/python3", 54 | contextOptions = listOf( 55 | LangOpt("opt1", "val1"), 56 | LangOpt("opt2", "val2") 57 | ) 58 | ) 59 | ) 60 | ) 61 | } 62 | 63 | "fails okay" { 64 | every { 65 | cfg.configFile() 66 | } returns null 67 | cfg.readConfig().shouldBeNull() 68 | 69 | every { 70 | cfg.configFile() 71 | } returns Paths.get("nonexistent") 72 | cfg.readConfig().shouldBeNull() 73 | 74 | every { 75 | cfg.configFile() 76 | } returns invalid 77 | cfg.readConfig().shouldBeNull() 78 | } 79 | 80 | "deserialization" { 81 | val cfg1 = Config.parse( 82 | """ 83 | { 84 | "python": { 85 | "executablePath": "/home/you/venv/bin/python", 86 | "pythonPath": "/home/you/venv/lib/python3.11/site-packages", 87 | "contextOptions": [ 88 | {"opt": "opt1", "value": "val1"}, 89 | {"opt": "opt2", "value": "val2"} 90 | ] 91 | } 92 | } 93 | """ 94 | )!! 95 | cfg1.shouldBeEqualToComparingFields( 96 | ScriptConfig( 97 | python = PythonLangOptions( 98 | executable = "/home/you/venv/bin/python", 99 | pythonPath = "/home/you/venv/lib/python3.11/site-packages", 100 | contextOptions = listOf( 101 | LangOpt("opt1", "val1"), 102 | LangOpt("opt2", "val2") 103 | ) 104 | ) 105 | ) 106 | ) 107 | 108 | val cfg2 = Config.parse( 109 | """ 110 | { 111 | "js": { 112 | "contextOptions": [ 113 | {"opt": "opt1", "value": "val1"}, 114 | {"opt": "opt2", "value": "val2"} 115 | ] 116 | } 117 | } 118 | """ 119 | )!! 120 | cfg2.shouldBeEqualToComparingFields( 121 | ScriptConfig( 122 | js = JsLangOptions( 123 | contextOptions = listOf( 124 | LangOpt("opt1", "val1"), 125 | LangOpt("opt2", "val2") 126 | ) 127 | ) 128 | ) 129 | ) 130 | 131 | val empty = Config.parse("{}")!! 132 | empty.shouldBeEqualToComparingFields(ScriptConfig()) 133 | 134 | Config.parse("asdf").shouldBeNull() 135 | Config.parse("""{"wrong": "field"}""").shouldBeNull() 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/test/kotlin/com/carvesystems/burpscript/JsScriptingTest.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import com.carvesystems.burpscript.interop.fromJson 4 | import com.carvesystems.burpscript.internal.testing.matchers.value.shouldContainExactly 5 | import com.carvesystems.burpscript.internal.testing.matchers.value.shouldBe 6 | import com.carvesystems.burpscript.internal.testing.tempdir 7 | import io.kotest.core.spec.style.StringSpec 8 | import io.kotest.matchers.booleans.shouldBeTrue 9 | import io.mockk.every 10 | import io.mockk.mockk 11 | import io.mockk.slot 12 | import io.mockk.verify 13 | import org.graalvm.polyglot.Context 14 | import org.graalvm.polyglot.Source 15 | import org.graalvm.polyglot.Value 16 | import kotlin.io.path.writeText 17 | 18 | 19 | class JsScriptingTest : StringSpec() { 20 | private lateinit var ctx: Context 21 | 22 | companion object { 23 | const val TEST_FUNC = "test_func" 24 | } 25 | 26 | private fun exec(script: String, vararg args: Any): Value { 27 | val src = Source.newBuilder("js", script, "test-script.mjs").build() 28 | val parsed = ctx.eval(src) 29 | parsed.hasMember(TEST_FUNC).shouldBeTrue() 30 | val func = parsed.getMember(TEST_FUNC) 31 | return func.execute(*args) 32 | } 33 | 34 | init { 35 | beforeSpec { 36 | ctx = JsContextBuilder().withBindings("helpers" to ScriptHelpers()).build() 37 | } 38 | 39 | "can use json like object" { 40 | val script = """ 41 | export function $TEST_FUNC(req) { 42 | const obj = req.bodyToJson(); 43 | obj["obj"]["key"] = "modified"; 44 | obj["obj"]["new"] = "value"; 45 | return req.withJson(obj); 46 | } 47 | """.trimIndent() 48 | 49 | val req = mockk() 50 | val value = slot() 51 | every { 52 | req.bodyToJson() 53 | } returns fromJson( 54 | """ 55 | { 56 | "foo": "bar", 57 | "obj": { 58 | "key": "value" 59 | } 60 | } 61 | """.trimIndent() 62 | ) 63 | every { 64 | req.withJson(capture(value)) 65 | } returns req 66 | 67 | exec(script, req) 68 | verify { 69 | req.withJson(capture(value)) 70 | } 71 | value.captured.shouldContainExactly( 72 | mapOf( 73 | "foo" to "bar", "obj" to mapOf( 74 | "key" to "modified", "new" to "value" 75 | ) 76 | ) 77 | ) 78 | } 79 | } 80 | } 81 | 82 | class JsContextTest : StringSpec() { 83 | init { 84 | "import adjacent to script" { 85 | tempdir { importPath -> 86 | val ctx = JsContextBuilder().withImportPath(importPath).build() 87 | 88 | val toImportMjs = importPath.resolve("common.mjs") 89 | toImportMjs.writeText( 90 | """ 91 | export function doSomething() { 92 | return "did something"; 93 | } 94 | """.trimIndent() 95 | ) 96 | 97 | val import = """ 98 | import { doSomething } from './${toImportMjs.fileName}'; 99 | export { doSomething }; 100 | """.trimIndent() 101 | val mod = ctx.eval(Source.newBuilder("js", import, "test.mjs").build()) 102 | mod.hasMember("doSomething").shouldBeTrue() 103 | mod.getMember("doSomething").execute().shouldBe("did something") 104 | } 105 | } 106 | 107 | "require adjacent to script" { 108 | tempdir { importPath -> 109 | val toRequireCJs = importPath.resolve("common.js") 110 | toRequireCJs.writeText( 111 | """ 112 | function doSomething() { 113 | return "did something"; 114 | } 115 | module.exports = { doSomething }; 116 | """.trimIndent() 117 | ) 118 | 119 | val ctx = JsContextBuilder().withImportPath(importPath).build() 120 | 121 | val require = """ 122 | const { doSomething } = require('./${toRequireCJs.fileName}'); 123 | module.exports = { doSomething }; 124 | """.trimIndent() 125 | val mod = ctx.eval(Source.newBuilder("js", require, "test.js").build()) 126 | mod.hasMember("doSomething").shouldBeTrue() 127 | mod.getMember("doSomething").execute().shouldBe("did something") 128 | } 129 | } 130 | } 131 | } 132 | 133 | class JsBindingsTest : StringSpec() { 134 | init { 135 | "console" { 136 | val logger = mockk() 137 | val log = slot() 138 | val error = slot() 139 | val debug = slot() 140 | every { 141 | logger.info(capture(log)) 142 | } returns Unit 143 | every { 144 | logger.error(capture(error)) 145 | } returns Unit 146 | every { 147 | logger.debug(capture(debug)) 148 | } returns Unit 149 | 150 | val ctx = JsContextBuilder().withConsoleLogger(logger).build() 151 | val script = """ 152 | console.log("log"); 153 | console.error("error"); 154 | console.debug("debug"); 155 | """.trimIndent() 156 | ctx.eval("js", script) 157 | verify { 158 | logger.info(capture(log)) 159 | } 160 | log.captured.shouldBe("log") 161 | error.captured.shouldBe("error") 162 | debug.captured.shouldBe("debug") 163 | } 164 | } 165 | } 166 | 167 | -------------------------------------------------------------------------------- /src/test/kotlin/com/carvesystems/burpscript/LanguageTest.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import com.carvesystems.burpscript.interop.fromJsonAs 4 | import com.carvesystems.burpscript.interop.toJson 5 | import io.kotest.assertions.json.shouldEqualJson 6 | import io.kotest.core.spec.style.StringSpec 7 | import io.kotest.matchers.shouldBe 8 | import java.nio.file.Path 9 | 10 | class LanguageTest : StringSpec() { 11 | init { 12 | "serialization" { 13 | toJson(Language.Python).shouldEqualJson("\"python\"") 14 | fromJsonAs("\"python\"").shouldBe(Language.Python) 15 | } 16 | 17 | "fromPath" { 18 | Language.fromPath(Path.of("test.py")).shouldBe(Language.Python) 19 | Language.fromPath(Path.of("test.js")).shouldBe(Language.JavaScript) 20 | Language.fromPath(Path.of("test.mjs")).shouldBe(Language.JavaScript) 21 | } 22 | 23 | "fromString" { 24 | Language.fromString("python").shouldBe(Language.Python) 25 | Language.fromString("js").shouldBe(Language.JavaScript) 26 | Language.fromString("mjs").shouldBe(Language.JavaScript) 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/carvesystems/burpscript/ProjectConfig.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import burp.api.montoya.internal.ObjectFactoryLocator 4 | import com.carvesystems.burpscript.internal.testing.mockObjectFactory 5 | import io.kotest.core.config.AbstractProjectConfig 6 | 7 | class ProjectConfig : AbstractProjectConfig() { 8 | 9 | override suspend fun beforeProject() { 10 | ObjectFactoryLocator.FACTORY = mockObjectFactory() 11 | } 12 | 13 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/carvesystems/burpscript/PythonScriptingTest.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import burp.api.montoya.core.Annotations 4 | import burp.api.montoya.core.ByteArray 5 | import burp.api.montoya.core.ToolType 6 | import burp.api.montoya.http.handler.HttpRequestToBeSent 7 | import com.carvesystems.burpscript.interop.fromJson 8 | import com.carvesystems.burpscript.interop.toByteArray 9 | import com.carvesystems.burpscript.internal.testing.matchers.value.shouldBe 10 | import com.carvesystems.burpscript.internal.testing.matchers.value.shouldContainExactly 11 | import com.carvesystems.burpscript.internal.testing.tempdir 12 | import io.kotest.core.spec.style.StringSpec 13 | import io.kotest.matchers.booleans.shouldBeTrue 14 | import io.kotest.matchers.should 15 | import io.kotest.matchers.shouldBe 16 | import io.mockk.* 17 | import org.graalvm.polyglot.Context 18 | import org.graalvm.polyglot.Source 19 | import org.graalvm.polyglot.Value 20 | import kotlin.io.path.writeText 21 | 22 | class PythonScriptingTest : StringSpec() { 23 | private lateinit var ctx: Context 24 | 25 | companion object { 26 | const val TEST_FUNCTION = "test_func" 27 | const val MESSAGE_ID = 1 28 | } 29 | 30 | private fun exec(script: String, vararg args: Any): Value { 31 | val src = Source.newBuilder("python", script, "test-script.py").build() 32 | val parsed = ctx.eval(src) 33 | parsed.hasMember(TEST_FUNCTION).shouldBeTrue() 34 | val value = parsed.getMember(TEST_FUNCTION) 35 | return value.execute(*args) 36 | } 37 | 38 | private fun mockRequest(): Pair { 39 | val req = mockk(relaxed = true) 40 | val annotations = mockk(relaxed = true) 41 | 42 | every { 43 | req.annotations() 44 | } returns annotations 45 | 46 | every { 47 | req.messageId() 48 | } returns MESSAGE_ID 49 | 50 | every { 51 | req.toolSource() 52 | } returns SimpleToolSource(ToolType.PROXY) 53 | 54 | val wrapped = ScriptHttpRequestImpl.wrap(req) 55 | return wrapped to req 56 | } 57 | 58 | init { 59 | 60 | beforeSpec { 61 | ctx = PythonContextBuilder() 62 | .withBindings("helpers" to ScriptHelpers()) 63 | .build() 64 | } 65 | 66 | "withBytes allows passing byte arrays" { 67 | val script = """ 68 | |def $TEST_FUNCTION(req): 69 | | return req.withBytes(b'\x00\x7f\x80\xff') 70 | """.trimMargin() 71 | 72 | val (wrapped, req) = mockRequest() 73 | val bytesInput = slot() 74 | 75 | every { 76 | req.withBody(capture(bytesInput)) 77 | } returns req 78 | 79 | exec(script, wrapped) 80 | 81 | verify { 82 | req.annotations() 83 | req.toolSource() 84 | req.messageId() 85 | req.withBody(capture(bytesInput)) 86 | } 87 | confirmVerified(req) 88 | 89 | val expected = byteArrayOf(0x00, 0x7F, -128, -1) 90 | 91 | val bytes = bytesInput.captured 92 | 93 | bytes.length().shouldBe(expected.size) 94 | 95 | bytes.forEachIndexed { idx, value -> 96 | value.shouldBe(expected[idx]) 97 | } 98 | } 99 | 100 | "can pass Python arrays to withJson" { 101 | val script = """ 102 | |def $TEST_FUNCTION(req): 103 | | return req.withJson( 104 | | [ True, None, False, { 'inner': 'value' } ] 105 | | ) 106 | """.trimMargin() 107 | 108 | val req = mockk() 109 | val value = slot() 110 | every { 111 | req.withJson(capture(value)) 112 | } returns req 113 | 114 | exec(script, req) 115 | verify { 116 | req.withJson(capture(value)) 117 | } 118 | 119 | val json: Value = value.captured 120 | json.shouldContainExactly(true, null, false, mapOf("inner" to "value")) 121 | } 122 | 123 | "can pass Python dicts to withJson" { 124 | val script = """ 125 | |def $TEST_FUNCTION(req): 126 | | return req.withJson({ 127 | | 'string': 'string', 128 | | 'dict': { 129 | | 'double': 1.2, 130 | | 'number': 12 131 | | }, 132 | | 'arr': [ True, None, False, { 'inner': 'value' } ] 133 | | }) 134 | """.trimMargin() 135 | 136 | val req = mockk() 137 | val value = slot() 138 | every { 139 | req.withJson(capture(value)) 140 | } returns req 141 | 142 | exec(script, req) 143 | verify { 144 | req.withJson(capture(value)) 145 | } 146 | 147 | val json: Value = value.captured 148 | json.shouldContainExactly( 149 | mapOf( 150 | "string" to "string", 151 | "dict" to mapOf( 152 | "double" to 1.2, 153 | "number" to 12 154 | ), 155 | "arr" to listOf(true, null, false, mapOf("inner" to "value")) 156 | ) 157 | ) 158 | } 159 | 160 | "can use json like dict" { 161 | val script = """ 162 | def $TEST_FUNCTION(req): 163 | d = req.bodyToJson() 164 | d["obj"]["key"] = "modified" 165 | d["obj"]["new"] = "value" 166 | return req.withJson(d) 167 | """.trimIndent() 168 | 169 | val req = mockk() 170 | val value = slot() 171 | every { 172 | req.bodyToJson() 173 | } returns fromJson( 174 | """ 175 | { 176 | "foo": "bar", 177 | "obj": { 178 | "key": "unmodified" 179 | } 180 | } 181 | """.trimIndent() 182 | ) 183 | every { 184 | req.withJson(capture(value)) 185 | } returns req 186 | 187 | exec(script, req) 188 | verify { 189 | req.withJson(capture(value)) 190 | } 191 | value.captured.shouldContainExactly( 192 | mapOf( 193 | "foo" to "bar", 194 | "obj" to mapOf( 195 | "key" to "modified", 196 | "new" to "value" 197 | ) 198 | ) 199 | ) 200 | } 201 | } 202 | } 203 | 204 | class PythonBindingsTest : StringSpec() { 205 | init { 206 | "print" { 207 | val logger = mockk() 208 | val msg = slot() 209 | every { 210 | logger.info(capture(msg)) 211 | } returns Unit 212 | 213 | val ctx = PythonContextBuilder().withConsoleLogger(logger).build() 214 | 215 | val script = """print("hello")""" 216 | ctx.eval("python", script) 217 | msg.captured.shouldBe("hello") 218 | } 219 | } 220 | } 221 | 222 | -------------------------------------------------------------------------------- /src/test/kotlin/com/carvesystems/burpscript/SaveDataTest.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import com.carvesystems.burpscript.interop.fromJsonAs 4 | import com.carvesystems.burpscript.interop.toJson 5 | import io.kotest.assertions.json.shouldEqualJson 6 | import io.kotest.core.spec.style.StringSpec 7 | import io.kotest.matchers.equality.shouldBeEqualToComparingFields 8 | import java.nio.file.Path 9 | import java.util.* 10 | 11 | class SaveDataTest : StringSpec() { 12 | init { 13 | "serialization" { 14 | val path = Path.of("path") 15 | val id = UUID.randomUUID() 16 | val lang = Language.Python 17 | val opts = Script.Options(active = true, inScopeOnly = false, proxyOnly = true) 18 | val saved = SavedScript(path, id, lang, opts) 19 | 20 | toJson(saved).shouldEqualJson( 21 | """ 22 | { 23 | "m": ${id.mostSignificantBits}, 24 | "l": ${id.leastSignificantBits}, 25 | "p": "$path", 26 | "g": "$lang", 27 | "o": { 28 | "a": true, 29 | "i": false, 30 | "p": true 31 | } 32 | } 33 | """ 34 | ) 35 | 36 | val loaded = fromJsonAs( 37 | """ 38 | { 39 | "m": ${id.mostSignificantBits}, 40 | "l": ${id.leastSignificantBits}, 41 | "p": "$path", 42 | "g": "$lang", 43 | "o": { 44 | "a": true, 45 | "i": false, 46 | "p": true 47 | } 48 | } 49 | """ 50 | ) 51 | loaded.shouldBeEqualToComparingFields(saved) 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/carvesystems/burpscript/ScriptHandlerTest.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import com.carvesystems.burpscript.internal.testing.tempfiles 4 | import io.kotest.core.spec.style.StringSpec 5 | import io.kotest.matchers.* 6 | import io.kotest.matchers.collections.shouldMatchEach 7 | import io.mockk.* 8 | import java.util.* 9 | import java.util.concurrent.Executor 10 | import java.util.concurrent.Flow 11 | import java.util.concurrent.SubmissionPublisher 12 | import kotlin.io.path.writeText 13 | 14 | class ScriptHandlerTest : StringSpec() { 15 | init { 16 | beforeSpec { 17 | mockkObject(SaveData) 18 | } 19 | 20 | afterTest { 21 | clearMocks(SaveData) 22 | } 23 | 24 | "loads saved scripts" { 25 | tempfiles("good.py", "bad.py") { files -> 26 | val (goodFile, badFile) = files 27 | 28 | goodFile.writeText("print('hello')") 29 | badFile.writeText("/") 30 | 31 | val goodScript = SavedScript(goodFile, UUID.randomUUID(), Language.Python, Script.Options()) 32 | val badScript = SavedScript(badFile, UUID.randomUUID(), Language.Python, Script.Options()) 33 | 34 | val capturedCb = slot<(SavedScript) -> Unit>() 35 | every { 36 | SaveData.forEachScript(capture(capturedCb)) 37 | } answers { 38 | capturedCb.invoke(goodScript) 39 | capturedCb.invoke(badScript) 40 | } 41 | 42 | val loadEvents = CaptureSub() 43 | val loadEventPub = SyncPub().apply { 44 | subscribe(loadEvents) 45 | } 46 | 47 | ScriptHandler(mockk(relaxed = true), mockk(relaxed = true), mockk(relaxed = true), loadEventPub) 48 | 49 | loadEvents.captured().shouldMatchEach( 50 | { it shouldBe ScriptLoadEvent.LoadSucceeded(goodScript.id) }, 51 | { it shouldBe ScriptLoadEvent.LoadFailed(badScript.id) } 52 | ) 53 | } 54 | } 55 | 56 | "loads new scripts" { 57 | tempfiles("good.py", "bad.py") { files -> 58 | val (goodFile, badFile) = files 59 | 60 | goodFile.writeText("print('hello')") 61 | badFile.writeText("/") 62 | 63 | val loadEvents = CaptureSub() 64 | val scriptEventPub = SyncPub() 65 | val loadEventPub = SyncPub().apply { 66 | subscribe(loadEvents) 67 | } 68 | 69 | @Suppress("UNUSED_VARIABLE") 70 | val handler = ScriptHandler(mockk(relaxed = true), scriptEventPub, mockk(relaxed = true), loadEventPub) 71 | 72 | val goodScript = ScriptEvent.SetScript(UUID.randomUUID(), goodFile, Language.Python, Script.Options()) 73 | val badScript = ScriptEvent.SetScript(UUID.randomUUID(), badFile, Language.Python, Script.Options()) 74 | 75 | scriptEventPub.submit(goodScript) 76 | scriptEventPub.submit(badScript) 77 | 78 | loadEvents.captured().shouldMatchEach( 79 | { it shouldBe ScriptLoadEvent.LoadSucceeded(goodScript.id) }, 80 | { it shouldBe ScriptLoadEvent.LoadFailed(badScript.id) } 81 | ) 82 | } 83 | } 84 | 85 | "reloads scripts" { 86 | tempfiles("saved.py", "new.py") { files -> 87 | val (savedFile, newFile) = files 88 | 89 | // 90 | // At first, files contain issues preventing them from loading 91 | // 92 | 93 | savedFile.writeText("/") 94 | newFile.writeText("/") 95 | 96 | val savedScript = SavedScript(savedFile, UUID.randomUUID(), Language.Python, Script.Options()) 97 | val newScript = ScriptEvent.SetScript(UUID.randomUUID(), newFile, Language.Python, Script.Options()) 98 | 99 | val capturedCb = slot<(SavedScript) -> Unit>() 100 | every { 101 | SaveData.forEachScript(capture(capturedCb)) 102 | } answers { 103 | capturedCb.invoke(savedScript) 104 | } 105 | 106 | val loadEvents = CaptureSub() 107 | val scriptEventPub = SyncPub() 108 | val watchEventPub = SyncPub() 109 | val loadEventPub = SyncPub().apply { 110 | subscribe(loadEvents) 111 | } 112 | 113 | @Suppress("UNUSED_VARIABLE") 114 | val handler = ScriptHandler(mockk(relaxed = true), scriptEventPub, watchEventPub, loadEventPub) 115 | 116 | scriptEventPub.submit(newScript) 117 | 118 | loadEvents.captured().shouldMatchEach( 119 | { it shouldBe ScriptLoadEvent.LoadFailed(savedScript.id) }, 120 | { it shouldBe ScriptLoadEvent.LoadFailed(newScript.id) } 121 | ) 122 | 123 | // 124 | // But then, they are modified and can be reloaded 125 | // 126 | 127 | savedFile.writeText("print('Yes')") 128 | newFile.writeText("print('Good')") 129 | 130 | watchEventPub.submit(PathWatchEvent.Modified(savedFile)) 131 | watchEventPub.submit(PathWatchEvent.Modified(newFile)) 132 | 133 | loadEvents.captured().shouldMatchEach( 134 | { it shouldBe ScriptLoadEvent.LoadSucceeded(savedScript.id) }, 135 | { it shouldBe ScriptLoadEvent.LoadSucceeded(newScript.id) } 136 | ) 137 | } 138 | } 139 | } 140 | 141 | private inner class SyncPub : SubmissionPublisher( 142 | Executor { command -> command.run() }, 143 | Flow.defaultBufferSize() 144 | ) 145 | 146 | private inner class CaptureSub : BaseSubscriber() { 147 | private var events = mutableListOf() 148 | 149 | fun captured(): List { 150 | val res = events 151 | events = mutableListOf() 152 | return res 153 | } 154 | 155 | override fun onNext(item: T?) { 156 | requestAnother() 157 | if (item == null) { 158 | return 159 | } 160 | events.add(item) 161 | } 162 | } 163 | } 164 | 165 | private infix fun ScriptLoadEvent.shouldBe(other: ScriptLoadEvent) { 166 | this.id shouldBe other.id 167 | this::class shouldBe other::class 168 | } 169 | -------------------------------------------------------------------------------- /src/test/kotlin/com/carvesystems/burpscript/ScriptMapTest.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import io.kotest.core.spec.style.StringSpec 4 | import io.kotest.matchers.booleans.shouldBeFalse 5 | import io.kotest.matchers.booleans.shouldBeTrue 6 | import io.kotest.matchers.shouldBe 7 | 8 | class ScriptMapTest : StringSpec({ 9 | 10 | "hasDotted returns true if keys exist" { 11 | val so = scriptMapOf( 12 | "level1" to mapOf("level2" to "cool") 13 | ) 14 | so.hasDotted("level1.level2").shouldBeTrue() 15 | so.hasDotted("level1.nokey").shouldBeFalse() 16 | so.hasDotted("level1").shouldBeTrue() 17 | so.hasDotted("nokey").shouldBeFalse() 18 | } 19 | 20 | "putDotted places objects correctly" { 21 | val so = scriptMapOf( 22 | "level1" to scriptMapOf( 23 | "level2" to HashMap() 24 | ) 25 | ) 26 | so.putDotted("level1.newkey", "value") 27 | so.putDotted("level1.level2.boolkey", true) 28 | so.putDotted("level1.level2.longkey", 12L) 29 | so.putDotted("level1.level2.intkey", 12) 30 | so.putDotted("level1.level2.realKey", 123.456) 31 | 32 | so.getDottedString("level1.newkey").shouldBe("value") 33 | so.getDottedBoolean("level1.level2.boolkey").shouldBeTrue() 34 | so.getDottedNumber("level1.level2.longkey").shouldBe(12L) 35 | so.getDottedNumber("level1.level2.intkey").shouldBe(12) 36 | so.getDottedNumber("level1.level2.realKey").shouldBe(123.456) 37 | } 38 | }) -------------------------------------------------------------------------------- /src/test/kotlin/com/carvesystems/burpscript/TestUrlUtils.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript 2 | 3 | import burp.api.montoya.core.ByteArray 4 | import burp.api.montoya.utilities.URLUtils 5 | import burp.api.montoya.utilities.URLEncoding 6 | import java.net.URLDecoder 7 | import java.net.URLEncoder 8 | import java.nio.charset.StandardCharsets 9 | 10 | class TestUrlUtils : URLUtils { 11 | override fun decode(string: String?): String = 12 | URLDecoder.decode(string, StandardCharsets.UTF_8) 13 | 14 | override fun decode(byteArray: ByteArray?): ByteArray { 15 | TODO("Not yet implemented") 16 | } 17 | 18 | override fun encode(byteArray: ByteArray?): ByteArray { 19 | TODO("Not yet implemented") 20 | } 21 | 22 | override fun encode(string: String?, encoding: URLEncoding): String = encode(string) 23 | 24 | override fun encode(string: String?): String = URLEncoder.encode(string, StandardCharsets.UTF_8) 25 | } 26 | -------------------------------------------------------------------------------- /src/test/kotlin/com/carvesystems/burpscript/interop/BinaryTest.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.interop 2 | 3 | import com.carvesystems.burpscript.JsContextBuilder 4 | import com.carvesystems.burpscript.PythonContextBuilder 5 | import io.kotest.core.spec.style.StringSpec 6 | import io.kotest.matchers.booleans.shouldBeFalse 7 | import io.kotest.matchers.booleans.shouldBeTrue 8 | import io.kotest.matchers.collections.shouldContainExactly 9 | import io.kotest.matchers.shouldBe 10 | import org.graalvm.polyglot.Context 11 | 12 | class BinaryValueTest : StringSpec() { 13 | private lateinit var ctx: Context 14 | 15 | init { 16 | beforeSpec { 17 | ctx = Context.newBuilder().allowAllAccess(true).build() 18 | } 19 | 20 | "Value.reinterpretAsSignedByte" { 21 | ctx.asValue(0xFF).reinterpretAsSignedByte().shouldBe(-1) 22 | } 23 | 24 | "Value.asAnyBinaryToByteArray decodes base64 and bin hex to binary" { 25 | ctx.asValue( 26 | "AQID" 27 | ).asAnyBinaryToByteArray().shouldBe( 28 | byteArrayOf(1, 2, 3) 29 | ) 30 | ctx.asValue( 31 | "FFFEFD" 32 | ).asAnyBinaryToByteArray().shouldBe( 33 | byteArrayOf(-1, -2, -3) 34 | ) 35 | } 36 | 37 | "Value.asAnyBinaryToByteArray converts arrays and iterables to signed host bytes" { 38 | ctx.asValue( 39 | byteArrayOf(1, 2, 3) 40 | ).asAnyBinaryToByteArray().shouldBe( 41 | byteArrayOf(1, 2, 3) 42 | ) 43 | ctx.asValue( 44 | arrayOf(0xFF, 0xFE, 0xFD) 45 | ).asAnyBinaryToByteArray().toList().shouldContainExactly( 46 | listOf(-1, -2, -3) 47 | ) 48 | ctx.asValue( 49 | listOf(1, 2, 3) as Iterable 50 | ).asAnyBinaryToByteArray().shouldBe( 51 | byteArrayOf(1, 2, 3) 52 | ) 53 | } 54 | 55 | "Value.toByteArray converts arrays to signed host bytes" { 56 | ctx.asValue( 57 | arrayOf(1, 2, 3) 58 | ).toByteArray().toList().shouldContainExactly( 59 | listOf(1, 2, 3) 60 | ) 61 | ctx.asValue( 62 | arrayOf(0xFF, 0xFE, 0xFD) 63 | ).toByteArray().toList().shouldContainExactly( 64 | listOf(-1, -2, -3) 65 | ) 66 | } 67 | 68 | "Value.toByteArray converts iterators to signed host bytes" { 69 | ctx.asValue( 70 | listOf(1, 2, 3).iterator() 71 | ).toByteArray().toList().shouldContainExactly( 72 | listOf(1, 2, 3) 73 | ) 74 | ctx.asValue( 75 | listOf(0xFF, 0xFE, 0xFD).iterator() 76 | ).toByteArray().toList().shouldContainExactly( 77 | listOf(-1, -2, -3) 78 | ) 79 | } 80 | 81 | "Value.toByteArray converts iterables to signed host bytes" { 82 | ctx.asValue( 83 | listOf(1, 2, 3) as Iterable 84 | ).toByteArray().toList().shouldContainExactly(listOf(1, 2, 3)) 85 | ctx.asValue( 86 | listOf(0xFF, 0xFE, 0xFD) as Iterable 87 | ).toByteArray().toList().shouldContainExactly( 88 | listOf(-1, -2, -3) 89 | ) 90 | } 91 | 92 | "Value.toBurpByteArray converts arrays to signed burp bytes" { 93 | ctx.asValue( 94 | arrayOf(1, 2, 3) 95 | ).toBurpByteArray().toList().shouldContainExactly(listOf(1, 2, 3)) 96 | ctx.asValue( 97 | arrayOf(0xFF, 0xFE, 0xFD) 98 | ).toBurpByteArray().toList().shouldContainExactly( 99 | listOf(-1, -2, -3) 100 | ) 101 | } 102 | 103 | "Value.toBurpByteArray converts iterators to signed burp bytes" { 104 | ctx.asValue( 105 | listOf(1, 2, 3).iterator() 106 | ).toBurpByteArray().toList().shouldContainExactly(listOf(1, 2, 3)) 107 | ctx.asValue( 108 | listOf(0xFF, 0xFE, 0xFD).iterator() 109 | ).toBurpByteArray().toList().shouldContainExactly( 110 | listOf(-1, -2, -3) 111 | ) 112 | } 113 | 114 | "Value.toBurpByteArray converts iterables to signed burp bytes" { 115 | ctx.asValue( 116 | listOf(1, 2, 3) as Iterable 117 | ).toBurpByteArray().toList().shouldContainExactly(listOf(1, 2, 3)) 118 | ctx.asValue( 119 | listOf(0xFF, 0xFE, 0xFD) as Iterable 120 | ).toBurpByteArray().toList().shouldContainExactly( 121 | listOf(-1, -2, -3) 122 | ) 123 | } 124 | 125 | "ByteArray converts to guest unsigned bytes" { 126 | byteArrayOf(1, 2, 3).toUnsignedByteArray().toList().shouldContainExactly( 127 | listOf(1, 2, 3) 128 | ) 129 | byteArrayOf(-1, -2, -3).toUnsignedByteArray().toList().shouldContainExactly( 130 | listOf(0xFF, 0xFE, 0xFD) 131 | ) 132 | } 133 | 134 | "BurpByteArray converts to guest unsigned bytes" { 135 | burpByteArrayOf(1, 2, 3).toUnsignedByteArray().toList().shouldContainExactly( 136 | listOf(1, 2, 3) 137 | ) 138 | burpByteArrayOf(-1, -2, -3).toUnsignedByteArray().toList().shouldContainExactly( 139 | listOf(0xFF, 0xFE, 0xFD) 140 | ) 141 | } 142 | } 143 | } 144 | 145 | class BinaryByteArrayTest : StringSpec() { 146 | init { 147 | "ByteArray converts to guest unsigned bytes" { 148 | byteArrayOf(1, 2, 3).toUnsignedByteArray().toList().shouldContainExactly( 149 | listOf(1, 2, 3) 150 | ) 151 | byteArrayOf(-1, -2, -3).toUnsignedByteArray().toList().shouldContainExactly( 152 | listOf(0xFF, 0xFE, 0xFD) 153 | ) 154 | } 155 | } 156 | } 157 | 158 | class BinaryBurpByteArrayTest : StringSpec() { 159 | init { 160 | "BurpByteArray converts to guest unsigned bytes" { 161 | burpByteArrayOf(1, 2, 3).toUnsignedByteArray().toList().shouldContainExactly( 162 | listOf(1, 2, 3) 163 | ) 164 | burpByteArrayOf(-1, -2, -3).toUnsignedByteArray().toList().shouldContainExactly( 165 | listOf(0xFF, 0xFE, 0xFD) 166 | ) 167 | } 168 | } 169 | } 170 | 171 | class BinaryStringTest : StringSpec() { 172 | init { 173 | "isHex" { 174 | "0123456789abcdef".isHex().shouldBeTrue() 175 | "0123456789ABCDEF".isHex().shouldBeTrue() 176 | "".isHex().shouldBeTrue() // Is it? 177 | "0123456789ABCDEFG".isHex().shouldBeFalse() 178 | } 179 | 180 | "decodeHex" { 181 | "00010203040506070809".decodeHex().shouldBe(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)) 182 | "".decodeHex().shouldBe(byteArrayOf()) 183 | } 184 | 185 | "unsigned decodeHex" { 186 | "7F808182".decodeHex().shouldBe(byteArrayOf(127, -128, -127, -126)) 187 | } 188 | 189 | "hex decodeAsByteArray" { 190 | "00010203040506070809".decodeAsByteArray().shouldBe(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)) 191 | "".decodeAsByteArray().shouldBe(byteArrayOf()) 192 | } 193 | 194 | "base64 decodeAsByteArray" { 195 | "AH+A/w==".decodeAsByteArray().shouldBe(byteArrayOf(0, 127, -128, -1)) 196 | } 197 | } 198 | } 199 | 200 | class BinaryPythonInteropTest : StringSpec() { 201 | private lateinit var ctx: Context 202 | 203 | init { 204 | beforeSpec { 205 | ctx = PythonContextBuilder().build() 206 | } 207 | 208 | "python bytes to host bytes" { 209 | val script = """b'\x00\x7f\x80\xff'""" 210 | 211 | val ret = ctx.eval("python", script) 212 | val hostBytes = ret.toByteArray() 213 | hostBytes.shouldBe(byteArrayOf(0x00, 0x7F, -128, -1)) 214 | } 215 | 216 | "host bytes to python bytes and back to host bytes" { 217 | // 218 | // guest bytes are not really python "bytes", but this 219 | // at least validates that if we pass in a guest bytes 220 | // it can be converted to a native python bytes array. 221 | // 222 | val script = """ 223 | bytes 224 | """.trimIndent() 225 | 226 | val func = ctx.eval("python", script) 227 | val ret = func.execute(byteArrayOf(0x00, 0x7F, -128, -1).toUnsignedByteArray()) 228 | val hostBytes = ret.toByteArray() 229 | hostBytes.shouldBe(byteArrayOf(0x00, 0x7F, -128, -1)) 230 | } 231 | } 232 | } 233 | 234 | class BinaryJsInteropTest : StringSpec() { 235 | private lateinit var ctx: Context 236 | 237 | init { 238 | beforeSpec { 239 | ctx = JsContextBuilder().build() 240 | } 241 | 242 | "js Uint8Array to host bytes" { 243 | val script = """ 244 | Uint8Array.of(0x00, 0x7F, 0x80, 0xFF) 245 | """.trimIndent() 246 | 247 | val ret = ctx.eval("js", script) 248 | val hostBytes = ret.toByteArray() 249 | hostBytes.shouldBe(byteArrayOf(0x00, 0x7F, -128, -1)) 250 | } 251 | 252 | "host bytes to js bytes and back to host bytes" { 253 | val script = """ 254 | (function(bs) { 255 | return new Uint8Array(bs); 256 | }) 257 | """.trimIndent() 258 | 259 | val func = ctx.eval("js", script) 260 | val ret = func.execute(byteArrayOf(0x00, 0x7F, -128, -1).toUnsignedByteArray()) 261 | val hostBytes = ret.toByteArray() 262 | hostBytes.shouldBe(byteArrayOf(0x00, 0x7F, -128, -1)) 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/test/kotlin/com/carvesystems/burpscript/interop/JsonTest.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.interop 2 | 3 | 4 | import com.carvesystems.burpscript.ScriptMap 5 | import io.kotest.assertions.json.shouldEqualJson 6 | import io.kotest.assertions.throwables.shouldThrow 7 | import io.kotest.core.spec.style.StringSpec 8 | import io.kotest.matchers.collections.shouldContainExactly 9 | import io.kotest.matchers.maps.shouldContainExactly 10 | import io.kotest.matchers.nulls.shouldBeNull 11 | import io.kotest.matchers.nulls.shouldNotBeNull 12 | import io.kotest.matchers.shouldBe 13 | import io.kotest.matchers.types.shouldBeInstanceOf 14 | import kotlinx.serialization.SerializationException 15 | import org.graalvm.polyglot.Context 16 | import javax.lang.model.type.NullType 17 | 18 | 19 | class JsonTest : StringSpec() { 20 | private lateinit var ctx: Context 21 | 22 | init { 23 | beforeSpec { 24 | ctx = Context.newBuilder().allowAllAccess(true).build() 25 | } 26 | 27 | "Any fromJson" { 28 | // Parse bare primitives like json.loads and JSON.parse 29 | fromJson("null").shouldBeNull() 30 | fromJson("true").shouldBe(true) 31 | fromJson("false").shouldBe(false) 32 | fromJson("42").shouldBe(42) 33 | fromJson("\"hello\"").shouldBe("hello") 34 | fromJson("3.14").shouldBe(3.14) 35 | 36 | // This is unique behavior, don't throw an exception on empty values 37 | fromJson("").shouldBeNull() 38 | fromJson("\r\n").shouldBeNull() 39 | 40 | // Preserve numeric types, rather than converting to double 41 | val arr = fromJson("[1, 2.3, 4000000000000000000]") as List<*> 42 | arr.shouldContainExactly(listOf(1, 2.3, 4000000000000000000L)) 43 | 44 | val arr2 = fromJson("""[{"key": "value"}]""") as List<*> 45 | arr2.shouldContainExactly(listOf(mapOf("key" to "value"))) 46 | 47 | val obj = fromJson("""{"key": "value"}""") as Map<*, *> 48 | obj.toMap().shouldContainExactly(mapOf("key" to "value")) 49 | 50 | val obj2 = fromJson("""{"key": null}""") as Map<*, *> 51 | obj2.toMap().shouldContainExactly(mapOf("key" to null)) 52 | 53 | // Behaviors work when nested 54 | val nested = fromJson("""{"key": {"nested": [1, 2.3, 4000000000000000000]}}""") as Map<*, *> 55 | nested.toMap().shouldContainExactly( 56 | mapOf("key" to mapOf("nested" to listOf(1, 2.3, 4000000000000000000L))) 57 | ) 58 | 59 | // Big numbers are truncated 60 | fromJson("1234567890123456789012345678912345678901234567890123456789.123456789012345678901234567890123456789012345678901234567890") 61 | .shouldBe(1.2345678901234568E57) 62 | 63 | shouldThrow { 64 | fromJson("{ \"x\" ") 65 | } 66 | shouldThrow { 67 | fromJson("gobldegook") 68 | } 69 | } 70 | 71 | "fromJsonAs" { 72 | fromJsonAs("\"hello\"").shouldBe("hello") 73 | fromJsonAs("true").shouldBe(true) 74 | fromJsonAs("42").shouldBe(42) 75 | fromJsonAs("3.14").shouldBe(3.14) 76 | 77 | fromJsonAs>("[1, 2, 3]").shouldContainExactly(listOf(1, 2, 3)) 78 | 79 | fromJsonAs>("""{"key": "value"}""") 80 | .shouldContainExactly(mapOf("key" to "value")) 81 | 82 | fromJsonAs>("""{"key": "value"}""") 83 | .shouldContainExactly(mapOf("key" to "value")) 84 | 85 | fromJsonAs>("""{"key": null}""") 86 | .shouldContainExactly(mapOf("key" to null)) 87 | 88 | fromJsonAs>>("""[{"key": "value"}]""") 89 | .shouldContainExactly(listOf(mapOf("key" to "value"))) 90 | 91 | fromJsonAs("""{"key": "value"}""") 92 | .shouldContainExactly(mapOf("key" to "value")) 93 | } 94 | 95 | "maybeFromJsonAs" { 96 | maybeFromJsonAs("\"hello\"").shouldNotBeNull() 97 | maybeFromJsonAs>("\"hello\"").shouldBeNull() 98 | } 99 | 100 | "Any toJson" { 101 | toJson(null).shouldEqualJson("null") 102 | toJson(true).shouldEqualJson("true") 103 | toJson(false).shouldEqualJson("false") 104 | toJson(42).shouldEqualJson("42") 105 | toJson(3.14).shouldEqualJson("3.14") 106 | toJson("hello").shouldEqualJson("\"hello\"") 107 | 108 | toJson(listOf(1, 2, 3)).shouldEqualJson("[1,2,3]") 109 | toJson(mapOf("key" to "value")).shouldEqualJson("""{"key":"value"}""") 110 | toJson(mapOf("key" to null)).shouldEqualJson("""{"key":null}""") 111 | } 112 | 113 | "Value toJson" { 114 | toJson(ctx.asValue(null)).shouldEqualJson("null") 115 | toJson(ctx.asValue(true)).shouldEqualJson("true") 116 | toJson(ctx.asValue(123)).shouldEqualJson("123") 117 | toJson(ctx.asValue(3.14)).shouldEqualJson("3.14") 118 | toJson(ctx.asValue("hello")).shouldEqualJson("\"hello\"") 119 | toJson(ctx.asValue(listOf(1, 2.3, 4))).shouldEqualJson("[1,2.3,4]") 120 | toJson(ctx.asValue(mapOf("key" to "value"))).shouldEqualJson("""{"key":"value"}""") 121 | } 122 | 123 | "fromJson makes ScriptMaps" { 124 | fromJson("{}").shouldBeInstanceOf() 125 | 126 | val arr = fromJson("[{\"key\": \"value\"}]") as List<*> 127 | arr[0].shouldBeInstanceOf() 128 | 129 | val nested = fromJson("""{"key": {"nested": "value"}}""") as ScriptMap 130 | nested.getDottedAs("key").getDotted("nested").shouldBe("value") 131 | } 132 | 133 | "ScriptMaps all they way down" { 134 | val nestedMap = fromJson("""{"key": {"nested": "value"}}""") as ScriptMap 135 | nestedMap.getDottedAs("key").getDotted("nested").shouldBe("value") 136 | 137 | // Parsing nested values behaves like fromJson(), preserving numeric types and making ScriptMaps 138 | val nestedNumbers = fromJson("""{"key": {"nested": [1, 2.3, 4000000000000000000]}}""") as ScriptMap 139 | nestedNumbers.getDottedAs("key").getDottedAs>("nested") 140 | .shouldContainExactly(listOf(1, 2.3, 4000000000000000000L)) 141 | 142 | val nestedMaps = fromJson("""{"key": {"nested": {"key": "value"}}}""") as ScriptMap 143 | nestedMaps.getDottedAs("key").getDottedAs("nested") 144 | .shouldContainExactly(mapOf("key" to "value")) 145 | } 146 | } 147 | } 148 | 149 | -------------------------------------------------------------------------------- /src/test/kotlin/com/carvesystems/burpscript/interop/ValueTest.kt: -------------------------------------------------------------------------------- 1 | package com.carvesystems.burpscript.interop 2 | 3 | import com.carvesystems.burpscript.internal.testing.matchers.value.shouldBe 4 | import io.kotest.core.spec.style.StringSpec 5 | import io.kotest.matchers.shouldBe 6 | import org.graalvm.polyglot.Context 7 | 8 | class ValueTest : StringSpec() { 9 | private lateinit var ctx: Context 10 | 11 | init { 12 | 13 | beforeSpec { 14 | ctx = Context.newBuilder().allowAllAccess(true).build() 15 | } 16 | 17 | "asNumber" { 18 | ctx.asValue(Int.MAX_VALUE).shouldBe(Int.MAX_VALUE) 19 | ctx.asValue(Long.MAX_VALUE).shouldBe(Long.MAX_VALUE) 20 | ctx.asValue(42.0).shouldBe(42.0) 21 | } 22 | 23 | "toException" { 24 | ctx.asValue(Exception("test")).toException()!!.message.shouldBe("test") 25 | 26 | val pyCtx = Context.newBuilder("python").allowAllAccess(true).build() 27 | val pyException = pyCtx.eval("python", "Exception('test')") 28 | pyException.toException()!!.message.shouldBe("Exception: test") 29 | 30 | val jsCtx = Context.newBuilder("js").allowAllAccess(true).build() 31 | val jsException = jsCtx.eval("js", "new Error('test')") 32 | jsException.toException()!!.message.shouldBe("Error: test") 33 | } 34 | } 35 | } 36 | 37 | --------------------------------------------------------------------------------