Compare commits

...
12 Commits
11 changed files with 263 additions and 41 deletions

No files matched your search

+18
View File
@@ -30,6 +30,24 @@ archives:
- goos: windows
format: zip
dockers_v2:
- images:
- "ghcr.io/bishopfox/sj"
dockerfile: Dockerfile
platforms:
- linux/amd64
- linux/arm64
tags:
- "{{ .Version }}"
- "latest"
labels:
org.opencontainers.image.title: "{{ .ProjectName }}"
org.opencontainers.image.description: "Swagger Jacker"
org.opencontainers.image.version: "{{ .Version }}"
org.opencontainers.image.revision: "{{ .FullCommit }}"
org.opencontainers.image.source: "https://github.com/BishopFox/sj"
org.opencontainers.image.licenses: "MIT"
changelog:
disable: false
sort: asc
+7
View File
@@ -0,0 +1,7 @@
FROM gcr.io/distroless/static-debian12:nonroot
ARG TARGETOS
ARG TARGETARCH
COPY ${TARGETOS}/${TARGETARCH}/sj /usr/local/bin/sj
ENTRYPOINT ["/usr/local/bin/sj"]
+13
View File
@@ -61,6 +61,18 @@ Description: This is a sample server Petstore server. You can find out more abo
✓ GET 200 /v2/user/login
```
You can use the `--replay-proxy` flag to replay matched requests through a separate proxy (e.g., Burp Suite). This lets you route all traffic through one proxy (or direct) while only sending interesting results to your interception proxy:
```bash
$ sj automate -u https://petstore.swagger.io/v2/swagger.json -qi --replay-proxy http://127.0.0.1:8080
```
You can also combine it with `--proxy` to route scanning traffic through a different proxy while replaying matches to Burp:
```bash
$ sj automate -u https://petstore.swagger.io/v2/swagger.json -qi -p http://proxy:9090 --replay-proxy http://127.0.0.1:8080
```
You can also request verbose output to see the partial (or full) response:
```bash
@@ -229,6 +241,7 @@ Flags:
-o, --outfile string Output the results to a file. Only supported for the 'automate' and 'brute' commands at this time.
-p, --proxy string Proxy host and port. Example: http://127.0.0.1:8080 (default "NOPROXY")
-q, --quiet Do not prompt for user input - uses default values for all requests.
--replay-proxy string Replay matched requests using this proxy.
--randomize-user-agent Randomizes the user agent string. Default is 'false'.
-s, --safe-word stringArray Avoids 'dangerous word' check for the specified word(s). Multiple flags are accepted.
-T, --target string Manually set a target for the requests to be made if separate from the host the documentation resides on.
+2 -2
View File
@@ -52,7 +52,7 @@ responds in an abnormal way, manual testing should be conducted (prepare manual
var bodyBytes []byte
client := CheckAndConfigureProxy()
client, replayClient := CheckAndConfigureProxy()
if strings.ToLower(outputFormat) != "json" {
fmt.Printf("\n")
@@ -81,7 +81,7 @@ responds in an abnormal way, manual testing should be conducted (prepare manual
log.Info("Sending requests at a rate of ", rateLimit, " requests per second.")
}
*/
GenerateRequests(bodyBytes, client)
GenerateRequests(bodyBytes, client, replayClient)
},
}
+57 -19
View File
@@ -44,7 +44,7 @@ var bruteCmd = &cobra.Command{
}
}
client := CheckAndConfigureProxy()
client, _ := CheckAndConfigureProxy()
var allURLs []string
u, err := url.Parse(swaggerURL)
@@ -52,12 +52,13 @@ var bruteCmd = &cobra.Command{
printWarn("Error parsing URL: %s", err)
}
target := u.Scheme + "://" + u.Host
normalizedBasePath := normalizeBasePath(basePath)
if endpointWordlist == "" {
allURLs = append(allURLs, makeURLs(target, priorityURLs, "", true)...)
allURLs = append(allURLs, makeURLs(target, jsonEndpoints, "", false)...)
allURLs = append(allURLs, makeURLs(target, javascriptEndpoints, ".js", false)...)
allURLs = append(allURLs, makeURLs(target, jsonEndpoints, ".json", false)...)
allURLs = append(allURLs, makeURLs(target, jsonEndpoints, "/", false)...)
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, priorityURLs, "", true)...)
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, jsonEndpoints, "", false)...)
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, javascriptEndpoints, ".js", false)...)
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, jsonEndpoints, ".json", false)...)
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, jsonEndpoints, "/", false)...)
} else {
endpointList, err := os.Open(endpointWordlist)
if err != nil {
@@ -68,7 +69,7 @@ var bruteCmd = &cobra.Command{
scanner := bufio.NewScanner(endpointList)
for scanner.Scan() {
endpoint := scanner.Text()
fullURL := target + endpoint
fullURL := target + normalizedBasePath + endpoint
allURLs = append(allURLs, fullURL)
}
@@ -119,7 +120,7 @@ var bruteCmd = &cobra.Command{
},
}
func makeURLs(target string, endpoints []string, fileExtension string, skipPrefix bool) []string {
func makeURLs(target string, basePath string, endpoints []string, fileExtension string, skipPrefix bool) []string {
urls := []string{}
if !skipPrefix {
for _, dir := range prefixDirs {
@@ -127,7 +128,7 @@ func makeURLs(target string, endpoints []string, fileExtension string, skipPrefi
if dir == "" && endpoint == "" {
continue
}
targetURL := target + dir + endpoint + fileExtension
targetURL := target + basePath + dir + endpoint + fileExtension
urls = append(urls, targetURL)
}
@@ -137,7 +138,7 @@ func makeURLs(target string, endpoints []string, fileExtension string, skipPrefi
if endpoint == "" {
continue
}
targetURL := target + endpoint + fileExtension
targetURL := target + basePath + endpoint + fileExtension
urls = append(urls, targetURL)
}
}
@@ -159,16 +160,10 @@ func findDefinitionFile(urls []string, client http.Client) (bool, *openapi3.T) {
}
}
} else if strings.Contains(ct, "application/javascript") {
bodyBytes, bodyString, _ := MakeRequest(client, "GET", url, timeout, nil)
bodyBytes, _, _ := MakeRequest(client, "GET", url, timeout, nil)
if bodyBytes != nil {
regexPattern := regexp.MustCompile(`(?s)let\s+(\w+)\s*=\s*({.*?});`)
matches := regexPattern.FindAllStringSubmatch(bodyString, -1)
for _, match := range matches {
if len(match) < 3 {
continue
}
jsonContent := match[2]
checkSpec := UnmarshalSpec([]byte(jsonContent))
if jsonContent, ok := ExtractJSONFromJSSpec(bodyBytes); ok {
checkSpec := UnmarshalSpec(jsonContent)
if strings.HasPrefix(checkSpec.OpenAPI, "2") || strings.HasPrefix(checkSpec.OpenAPI, "3") {
printInfo("\nFound operation definitions embedded in JavaScript file at %s\n", url)
return true, checkSpec
@@ -191,6 +186,49 @@ func init() {
bruteCmd.Flags().BoolVarP(&endpointOnly, "endpoint-only", "e", false, "Only return the identified endpoint")
}
// ExtractJSONFromJSSpec scans a Swagger-UI-style JavaScript bundle for an
// OpenAPI/Swagger document assigned to a top-level var/let/const. If the
// captured value is itself a wrapper object (swagger-ui-express / swagger-ui
// bundle config), it unwraps one level via the swaggerDoc/spec/openapi keys.
// Returns the extracted JSON bytes and true on success, or the original
// bytes and false on failure.
func ExtractJSONFromJSSpec(bodyBytes []byte) ([]byte, bool) {
re := regexp.MustCompile(`(?s)(?:let|const|var)\s+(\w+)\s*=\s*({.*?});`)
matches := re.FindAllStringSubmatch(string(bodyBytes), -1)
for _, m := range matches {
if len(m) < 3 {
continue
}
candidate := []byte(m[2])
if looksLikeAPISpec(candidate) {
return candidate, true
}
var wrapper map[string]json.RawMessage
if err := json.Unmarshal(candidate, &wrapper); err != nil {
continue
}
for _, key := range []string{"swaggerDoc", "spec", "openapi"} {
if inner, ok := wrapper[key]; ok && looksLikeAPISpec(inner) {
return inner, true
}
}
}
return bodyBytes, false
}
func looksLikeAPISpec(b []byte) bool {
var probe struct {
OpenAPI string `json:"openapi"`
Swagger string `json:"swagger"`
}
if err := json.Unmarshal(b, &probe); err != nil {
return false
}
return strings.HasPrefix(probe.OpenAPI, "3") ||
strings.HasPrefix(probe.OpenAPI, "2") ||
strings.HasPrefix(probe.Swagger, "2")
}
func ExtractSpecFromJS(bodyBytes []byte) []byte {
var openApiIndex int
var specClose int
+1 -1
View File
@@ -29,7 +29,7 @@ var convertCmd = &cobra.Command{
}
}
client := CheckAndConfigureProxy()
client, _ := CheckAndConfigureProxy()
if strings.ToLower(outputFormat) != "json" {
fmt.Printf("\n")
+3 -3
View File
@@ -21,7 +21,7 @@ This list contains the raw endpoints (parameter values will not be appended or m
}
}
client := CheckAndConfigureProxy()
client, _ := CheckAndConfigureProxy()
var bodyBytes []byte
@@ -30,7 +30,7 @@ This list contains the raw endpoints (parameter values will not be appended or m
if swaggerURL != "" {
bodyBytes, _, _ = MakeRequest(client, "GET", swaggerURL, timeout, nil)
GenerateRequests(bodyBytes, client)
GenerateRequests(bodyBytes, client, nil)
} else {
specFile, err := os.Open(localFile)
if err != nil {
@@ -44,7 +44,7 @@ This list contains the raw endpoints (parameter values will not be appended or m
}
}
bodyBytes, _ = io.ReadAll(specFile)
GenerateRequests(bodyBytes, client)
GenerateRequests(bodyBytes, client, nil)
}
},
}
+3 -3
View File
@@ -29,13 +29,13 @@ This enables you to test specific API functions for common vulnerabilities or mi
os.Exit(1)
}
client := CheckAndConfigureProxy()
client, _ := CheckAndConfigureProxy()
fmt.Printf("\n")
printInfo("Gathering API details.\n\n")
if swaggerURL != "" {
bodyBytes, _, _ := MakeRequest(client, "GET", swaggerURL, timeout, nil)
GenerateRequests(bodyBytes, client)
GenerateRequests(bodyBytes, client, nil)
} else {
specFile, err := os.Open(localFile)
if err != nil {
@@ -50,7 +50,7 @@ This enables you to test specific API functions for common vulnerabilities or mi
}
specBytes, _ := io.ReadAll(specFile)
GenerateRequests(specBytes, client)
GenerateRequests(specBytes, client, nil)
}
},
}
+69 -3
View File
@@ -65,7 +65,7 @@ func MakeRequest(client http.Client, method, target string, timeout int64, reqDa
}
endpoint := u.RawPath + "?" + u.RawQuery
for _, v := range dangerousStrings {
if os.Args[1] == "automate" && strings.Contains(endpoint, v) && !strings.Contains(strings.Join(safeWords, ","), v) {
if os.Args[1] == "automate" && !force && strings.Contains(endpoint, v) && !strings.Contains(strings.Join(safeWords, ","), v) {
userChoice = ""
if avoidDangerousRequests == "y" {
return nil, "", 0
@@ -232,7 +232,7 @@ func CheckContentType(client http.Client, target string) string {
return resp.Header.Get("Content-Type")
}
func CheckAndConfigureProxy() (client http.Client) {
func CheckAndConfigureProxy() (client http.Client, replayClient *http.Client) {
var proxyUrl *url.URL
transport := &http.Transport{}
@@ -252,5 +252,71 @@ func CheckAndConfigureProxy() (client http.Client) {
return http.ErrUseLastResponse
},
}
return client
if replayProxy != "" {
replayTransport := &http.Transport{}
if insecure {
replayTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
replayProxyUrl, err := url.Parse(replayProxy)
if err != nil {
die("Error parsing replay proxy URL: %v", err)
}
replayTransport.Proxy = http.ProxyURL(replayProxyUrl)
replayClient = &http.Client{
Transport: replayTransport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
return client, replayClient
}
func ReplayRequest(replayClient *http.Client, method, target string, timeout int64, reqData io.Reader) {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
req, err := http.NewRequest(method, target, reqData)
if err != nil {
printWarn("Replay: error creating request for %s - %v", target, err)
return
}
for i := range Headers {
delimIndex := strings.Index(Headers[i], ":")
if delimIndex == -1 {
continue
}
key := strings.TrimSpace(Headers[i][:delimIndex])
value := strings.TrimSpace(Headers[i][delimIndex+1:])
req.Header.Set(key, value)
}
req.Header.Set("User-Agent", UserAgent)
if accept == "" {
req.Header.Set("Accept", "application/json, text/html, */*")
} else {
req.Header.Set("Accept", accept)
}
if method == "POST" {
if contentType == "" {
req.Header.Set("Content-Type", "application/json")
} else {
req.Header.Set("Content-Type", contentType)
}
}
resp, err := replayClient.Do(req.WithContext(ctx))
if err != nil {
printWarn("Replay: error sending request to %s - %v", target, err)
return
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
fmt.Fprintf(os.Stderr, "[Replay] %s %s -> %d\n", method, target, resp.StatusCode)
}
+5 -1
View File
@@ -9,11 +9,13 @@ var basePath string
var customDate string
var customEmail string
var customURL string
var force bool
var format string
var insecure bool
var localFile string
var outfile string
var proxy string
var replayProxy string
var quiet bool
var randomUserAgent bool
var rateLimit int
@@ -51,7 +53,7 @@ $ sj convert -u https://petstore.swagger.io/v2/swagger.json -o openapi.json`,
printErr("Command not specified. See the --help flag for usage.")
}
},
Version: "2.5.1",
Version: "2.7.0",
}
func Execute() {
@@ -69,12 +71,14 @@ func init() {
rootCmd.PersistentFlags().StringVarP(&customURL, "custom-url", "c", "https://bishopfox.com", "Set a custom URL to test discovered URL parameters.")
rootCmd.PersistentFlags().StringVarP(&customDate, "custom-date", "d", "1990-01-01", "A custom date to test discovered date parameters.")
rootCmd.PersistentFlags().StringVar(&customEmail, "custom-email", "[email protected]", "A custom email address to test discovered email parameters.")
rootCmd.PersistentFlags().BoolVar(&force, "force", false, "Send requests without prompting, even if dangerous keywords are detected.")
rootCmd.PersistentFlags().StringVarP(&format, "format", "f", "json", "Declare the format of the definition file (json/yaml/yml/js).")
rootCmd.PersistentFlags().StringArrayVarP(&Headers, "headers", "H", nil, "Add custom headers, separated by a colon (\"Name: Value\"). Multiple flags are accepted.")
rootCmd.PersistentFlags().BoolVarP(&insecure, "insecure", "i", false, "Ignores server certificate validation.")
rootCmd.PersistentFlags().StringVarP(&localFile, "local-file", "l", "", "Loads the documentation from a local file.")
rootCmd.PersistentFlags().StringVarP(&outfile, "outfile", "o", "", "Output the results to a file. Only supported for the 'automate' and 'brute' commands at this time.")
rootCmd.PersistentFlags().StringVarP(&proxy, "proxy", "p", "NOPROXY", "Proxy host and port. Example: http://127.0.0.1:8080")
rootCmd.PersistentFlags().StringVar(&replayProxy, "replay-proxy", "", "Replay matched requests using this proxy.")
rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "Do not prompt for user input - uses default values for all requests.")
rootCmd.PersistentFlags().BoolVar(&randomUserAgent, "randomize-user-agent", false, "Randomizes the user agent string. Default is 'false'.")
// rootCmd.PersistentFlags().IntVarP(&rateLimit, "rate", "r", 15, "Limit the number of requests per second.") // NEED TO RE-IMPLEMENT RATE LIMIT
+85 -9
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/url"
"os"
@@ -37,7 +38,7 @@ type SchemaNode struct {
AdditionalProperties *SchemaNode
}
func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, replayClient *http.Client) {
paths, ok := spec["paths"].(map[string]interface{})
if !ok || paths == nil {
die("Could not find any defined operations. Review the file manually.")
@@ -49,6 +50,19 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
}
slices.Sort(pathKeys)
// Snapshot CLI-supplied headers so per-endpoint Content-Type
// mutations from EnforceSingleContentType don't leak across endpoints.
userHeaders := append([]string(nil), Headers...)
var userContentType string
for _, h := range userHeaders {
if strings.HasPrefix(strings.ToLower(h), "content-type:") {
if idx := strings.Index(h, ":"); idx >= 0 {
userContentType = strings.TrimSpace(h[idx+1:])
}
break
}
}
var errorDescriptions = make(map[any]string)
for _, pathName := range pathKeys {
pathItem := paths[pathName]
@@ -68,6 +82,11 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
continue
default:
if opMap, ok := op.(map[string]interface{}); ok {
// Reset header state so the previous endpoint's
// Content-Type doesn't leak into this one.
Headers = append([]string(nil), userHeaders...)
contentType = userContentType
if responses, ok := opMap["responses"].(map[string]interface{}); ok {
for status, respItem := range responses {
if respMap, ok := respItem.(map[string]interface{}); ok {
@@ -225,23 +244,42 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
if cType == "application/json" {
bodyBytes, err := json.Marshal(example)
if err == nil {
bodyData = string(bodyBytes)
curl += fmt.Sprintf(" -H \"Content-Type: application/json\" -d '%s'", bodyBytes)
}
}
if cType == "application/xml" || cType == "text/xml" {
if obj, ok := example.(map[string]interface{}); ok {
xml := XmlFromObject(obj)
bodyData = xml
curl += fmt.Sprintf(" -H \"Content-Type: %s\" -d '%s'", cType, xml)
}
}
if cType == "application/x-www-form-urlencoded" || cType == "multipart/form-data" {
if cType == "application/x-www-form-urlencoded" {
if obj, ok := example.(map[string]interface{}); ok {
var formParts []string
for k, v := range obj {
formParts = append(formParts, fmt.Sprintf("%s=%v", k, v))
}
formData := strings.Join(formParts, "&")
curl += fmt.Sprintf(" -H \"Content-Type: %s\" -d '%s'", cType, formData)
bodyData = strings.Join(formParts, "&")
curl += fmt.Sprintf(" -H \"Content-Type: %s\" -d '%s'", cType, bodyData)
}
}
if cType == "multipart/form-data" {
if obj, ok := example.(map[string]interface{}); ok {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for k, v := range obj {
_ = mw.WriteField(k, fmt.Sprintf("%v", v))
}
_ = mw.Close()
bodyData = buf.String()
// Replace the bare multipart/form-data Content-Type
// (set earlier in the loop) with a boundary-aware one.
EnforceSingleContentType(mw.FormDataContentType())
for k, v := range obj {
curl += fmt.Sprintf(" -F \"%s=%v\"", k, v)
}
}
}
}
@@ -265,9 +303,8 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
switch os.Args[1] {
case "automate":
var postBodyData string
if strings.ToLower(method) == "post" && strings.Contains(curl, "-d") {
dataIndex := strings.Index(curl, "'")
postBodyData = curl[dataIndex+1 : len(curl)-1]
if strings.ToLower(method) == "post" {
postBodyData = bodyData
}
_, resp, sc := MakeRequest(client, strings.ToUpper(method), targetURL, timeout, bytes.NewReader([]byte(postBodyData)))
@@ -296,6 +333,9 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
if outputFormat == "console" {
writeLog(sc, logURL.Path, strings.ToUpper(method), errorDescriptions[sc], resp[:tempResponsePreviewLength])
}
if replayClient != nil {
ReplayRequest(replayClient, strings.ToUpper(method), targetURL, timeout, bytes.NewReader([]byte(postBodyData)))
}
}
} else {
if jsonResultsStringArray == nil {
@@ -306,6 +346,9 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client) {
if outputFormat == "console" {
writeLog(sc, logURL.Path, strings.ToUpper(method), errorDescriptions[sc], resp[:tempResponsePreviewLength])
}
if replayClient != nil {
ReplayRequest(replayClient, strings.ToUpper(method), targetURL, timeout, bytes.NewReader([]byte(postBodyData)))
}
}
case "endpoints":
@@ -533,7 +576,16 @@ func GenerateExample(node *SchemaNode) interface{} {
}
}
func GenerateRequests(bodyBytes []byte, client http.Client) {
func GenerateRequests(bodyBytes []byte, client http.Client, replayClient *http.Client) {
// Swagger UI bundles serve the spec wrapped in JavaScript (e.g.
// swagger-ui-init.js). Detect that and unwrap before parsing so the
// downstream YAML/JSON unmarshal succeeds.
if looksLikeJSSpec(bodyBytes) {
if extracted, ok := ExtractJSONFromJSSpec(bodyBytes); ok {
bodyBytes = extracted
}
}
// Ingests the specification file
spec := SafelyUnmarshalSpec(bodyBytes)
@@ -650,7 +702,7 @@ func GenerateRequests(bodyBytes []byte, client http.Client) {
}
// Reviews all defined API routes and builds requests as defined
BuildRequestsFromPaths(spec, client)
BuildRequestsFromPaths(spec, client, replayClient)
}
func ResolveRef(spec map[string]interface{}, ref string) map[string]interface{} {
@@ -801,6 +853,30 @@ func SetScheme(swaggerURL string) (scheme string) {
return scheme
}
// looksLikeJSSpec is a cheap content sniff so we only run the JS-extraction
// regex against bodies that plausibly contain JavaScript. Normal JSON
// (starts with `{`/`[`) and YAML (starts with `openapi:`/`swagger:`/`---`/`#`)
// fall through unchanged.
func looksLikeJSSpec(b []byte) bool {
if strings.HasSuffix(strings.ToLower(swaggerURL), ".js") ||
strings.HasSuffix(strings.ToLower(localFile), ".js") ||
strings.ToLower(format) == "js" {
return true
}
trimmed := bytes.TrimLeft(b, " \t\r\n")
prefixes := [][]byte{
[]byte("var "), []byte("let "), []byte("const "),
[]byte("(function"), []byte("window."),
[]byte("//"), []byte("/*"),
}
for _, p := range prefixes {
if bytes.HasPrefix(trimmed, p) {
return true
}
}
return false
}
func SafelyUnmarshalSpec(data []byte) map[string]interface{} {
var doc map[string]interface{}