Compare commits

...
22 Commits
Author SHA1 Message Date
Tony West 4f256efca6 Incremented version string 2026-09-11 21:44:23 -04:00
Tony West 48c4d83f54 fix: Moved remaining diagnostic output to stderr so stdout stays pipeable 2026-09-11 21:44:15 -04:00
Tony West 365f68696f Merge pull request #59 from emilloc/fix/auth-stderr
fix: send authentication diagnostics to stderr
2026-09-11 21:36:58 -04:00
rootkid fbf484c3a1 fix: send authentication diagnostics to stderr 2026-09-10 18:56:14 -06:00
Tony West bc708d69f8 Incremented version string 2026-08-24 17:34:11 -04:00
Tony West 56346e759e Merge pull request #58 from BishopFox/dependabot/go_modules/github.com/getkin/kin-openapi-0.144.0
Bump github.com/getkin/kin-openapi from 0.132.0 to 0.144.0
2026-08-24 17:33:38 -04:00
dependabot[bot] a69b4dcae1 Bump github.com/getkin/kin-openapi from 0.132.0 to 0.144.0
Bumps [github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi) from 0.132.0 to 0.144.0.
- [Release notes](https://github.com/getkin/kin-openapi/releases)
- [Commits](https://github.com/getkin/kin-openapi/compare/v0.132.0...v0.144.0)

---
updated-dependencies:
- dependency-name: github.com/getkin/kin-openapi
  dependency-version: 0.144.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-19 17:26:47 +00:00
Tony West 7d9fa54a58 Incremented version string 2026-07-22 08:52:23 -04:00
Tony West 09a1183e6f Added more discoverability features and basic WAF detection to the brute command 2026-07-22 08:52:05 -04:00
Tony West 90e9c78d9a Incremented version string 2026-07-09 11:30:07 -04:00
Tony West 3f99ffb48c Added docker support for releases 2026-07-09 11:29:51 -04:00
Tony West 331bb1a05c Incremented version string 2026-05-13 19:31:55 -04:00
Tony West 3c2a3b807d added fix to auto-extract JS-wrapped specs in automate/endpoints/prepare; fixed POST body leak, Content-Type carryover, and multipart boundary issues 2026-05-13 19:31:22 -04:00
Tony West 7ef8abdb8e Incremented version string 2026-05-13 14:47:17 -04:00
Tony West f1f9e19422 Improved detection for JS specs wrapped in swaggerDoc/spec/openapi 2026-05-13 14:46:47 -04:00
Tony West 0d6776b758 Incremented version string 2026-03-24 16:00:16 -04:00
Tony West 8f4e4a5d9f Fixes #55 2026-03-24 16:00:07 -04:00
Tony West 55baa79785 Incremented version string 2026-03-20 11:37:19 -04:00
Tony West 5bdafb64d8 Merge pull request #54 from bsysop/main
Adds new features from pull request
2026-03-20 11:35:58 -04:00
Guillermo Gregorio 27b59354fc Adds --replay-proxy flag to replay matched requests through a separate proxy 2026-03-19 22:04:42 -03:00
Guillermo Gregorio bee5088fcf Adds --force flag to bypass dangerous keyword checks 2026-03-13 14:56:15 -03:00
Tony West 1eda960182 Fixed bug where custom headers were not sent with the brute command 2026-03-13 00:13:54 -04:00
17 changed files with 758 additions and 185 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.
+10 -12
View File
@@ -22,16 +22,14 @@ func CheckSecuritySchemes(spec map[string]interface{}) {
if ok && components != nil {
securitySchemes, ok := components["securitySchemes"].(map[string]interface{})
if !ok || len(securitySchemes) == 0 {
fmt.Println("No security schemes defined.")
printInfo("No security schemes defined.\n")
} else {
if outputFormat != "json" {
fmt.Println("Found security schemes:")
}
printInfo("Found security schemes:\n")
var apiKey string
var apiKeyName string
for mechanism, value := range securitySchemes {
fmt.Printf(" - %s\n", mechanism)
printInfo(" - %s\n", mechanism)
scheme, ok := value.(map[string]interface{})
if !ok {
continue
@@ -47,13 +45,13 @@ func CheckSecuritySchemes(spec map[string]interface{}) {
autoApplyBasicAuth = "n"
printWarn("A basic authentication header is accepted. Review the spec and craft a header manually using the -H flag.")
} else {
fmt.Println("Basic Authentication is accepted. Supply a username and password? (y/N)")
printInfo("Basic Authentication is accepted. Supply a username and password? (y/N)\n")
fmt.Scanln(&autoApplyBasicAuth)
autoApplyBasicAuth = strings.ToLower(autoApplyBasicAuth)
if autoApplyBasicAuth == "y" {
fmt.Printf("Enter a username.")
printInfo("Enter a username.")
fmt.Scanln(&basicAuthUser)
fmt.Printf("Enter a password.")
printInfo("Enter a password.")
fmt.Scanln(&basicAuthPass)
basicAuth = []byte(basicAuthUser + ":" + basicAuthPass)
basicAuthString = base64.StdEncoding.EncodeToString(basicAuth)
@@ -83,7 +81,7 @@ func CheckSecuritySchemes(spec map[string]interface{}) {
if nameVal, ok := scheme["name"].(string); ok {
apiKeyName = nameVal
}
fmt.Printf("What value would you like to use for the API key (%s)?", apiKeyName)
printInfo("What value would you like to use for the API key (%s)?", apiKeyName)
fmt.Scanln(&apiKey)
printInfo("Using %s=%s as the API key in all requests.\n", apiKeyName, apiKey)
}
@@ -97,7 +95,7 @@ func CheckSecuritySchemes(spec map[string]interface{}) {
autoApplyBearer = strings.ToLower(autoApplyBearer)
}
if autoApplyBearer == "y" {
fmt.Printf("What value would you like to use for the Bearer Token? ")
printInfo("What value would you like to use for the Bearer Token? ")
fmt.Scanln(&bearerToken)
Headers = append(Headers, "Authorization: Bearer "+bearerToken)
} else {
@@ -114,7 +112,7 @@ func CheckSecuritySchemes(spec map[string]interface{}) {
}
if autoApplyAPIKey == "y" {
apiKeyName = nameVal
fmt.Printf("What value would you like to use for the API key (%s)?", apiKeyName)
printInfo("What value would you like to use for the API key (%s)?", apiKeyName)
fmt.Scanln(&apiKey)
Headers = append(Headers, nameVal+": "+apiKey)
}
@@ -126,7 +124,7 @@ func CheckSecuritySchemes(spec map[string]interface{}) {
}
if bearerFormat, ok := scheme["bearerFormat"].(string); ok {
fmt.Println(" - bearerFormat:", bearerFormat)
printInfo(" - bearerFormat: %s\n", bearerFormat)
}
}
}
+59
View File
@@ -0,0 +1,59 @@
package cmd
import (
"os"
"strings"
"testing"
)
func TestCheckSecuritySchemesJSONDiagnostics(t *testing.T) {
stdout, err := os.CreateTemp(t.TempDir(), "stdout")
if err != nil {
t.Fatal(err)
}
stderr, err := os.CreateTemp(t.TempDir(), "stderr")
if err != nil {
t.Fatal(err)
}
previousStdout, previousStderr := os.Stdout, os.Stderr
previousFormat, previousQuiet := outputFormat, quiet
defer func() {
os.Stdout, os.Stderr = previousStdout, previousStderr
outputFormat, quiet = previousFormat, previousQuiet
stdout.Close()
stderr.Close()
}()
os.Stdout, os.Stderr = stdout, stderr
outputFormat, quiet = "json", true
CheckSecuritySchemes(map[string]interface{}{
"components": map[string]interface{}{
"securitySchemes": map[string]interface{}{
"ExampleBearer": map[string]interface{}{
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
},
},
},
})
stdoutBytes, err := os.ReadFile(stdout.Name())
if err != nil {
t.Fatal(err)
}
if len(stdoutBytes) != 0 {
t.Fatalf("authentication diagnostics written to stdout: %q", stdoutBytes)
}
stderrBytes, err := os.ReadFile(stderr.Name())
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"ExampleBearer", "bearerFormat: JWT"} {
if !strings.Contains(string(stderrBytes), expected) {
t.Errorf("stderr does not contain %q: %q", expected, stderrBytes)
}
}
}
+4 -6
View File
@@ -1,7 +1,6 @@
package cmd
import (
"fmt"
"io"
"os"
"path/filepath"
@@ -46,16 +45,15 @@ responds in an abnormal way, manual testing should be conducted (prepare manual
_, err := time.Parse("2006-01-02", customDate)
if err != nil {
fmt.Println("An invalid date was supplied. Please supply a date in '2006-01-02' format.")
os.Exit(1)
die("An invalid date was supplied. Please supply a date in '2006-01-02' format.")
}
var bodyBytes []byte
client := CheckAndConfigureProxy()
client, replayClient := CheckAndConfigureProxy()
if strings.ToLower(outputFormat) != "json" {
fmt.Printf("\n")
printInfo("\n")
printInfo("Gathering API details.\n")
}
@@ -81,7 +79,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)
},
}
+158 -40
View File
@@ -21,9 +21,23 @@ import (
var endpointOnly bool
var endpointWordlist string
// sawWAFChallenge ensures the WAF/challenge warning is emitted only once per run.
var sawWAFChallenge bool
// warnWAFChallengeOnce warns, once, that responses look like a WAF challenge.
func warnWAFChallengeOnce() {
if sawWAFChallenge {
return
}
sawWAFChallenge = true
printWarn("Some responses look like a Cloudflare/WAF browser challenge (e.g. an HTTP 403 \"Just a moment...\" interstitial), so requests are likely being blocked before reaching the app.")
printWarn("To scan a challenge-protected target, capture a solved browser session and replay it, e.g.:\n\tsj brute -u %s -A \"<your browser User-Agent>\" -H \"Cookie: cf_clearance=<value>; __cf_bm=<value>\"\n(the cf_clearance cookie is bound to your IP + User-Agent, so run from the same host and use the exact browser User-Agent).", swaggerURL)
}
var prefixDirs []string = []string{"", "/swagger", "/swagger/docs", "/swagger/latest", "/swagger/v1", "/swagger/v2", "/swagger/v3", "/swagger/static", "/swagger/ui", "/swagger-ui", "/api-docs", "/api-docs/v1", "/api-docs/v2", "/apidocs", "/api", "/api/v1", "/api/v2", "/api/v3", "/v1", "/v2", "/v3", "/doc", "/docs", "/docs/swagger", "/docs/swagger/v1", "/docs/swagger/v2", "/docs/swagger-ui", "/docs/swagger-ui/v1", "/docs/swagger-ui/v2", "/docs/v1", "/docs/v2", "/docs/v3", "/public", "/redoc"}
var jsonEndpoints []string = []string{"", "/index", "/swagger", "/swagger-ui", "/swagger-resources", "/swagger-config", "/openapi", "/api", "/api-docs", "/apidocs", "/v1", "/v2", "/v3", "/doc", "/docs", "/apispec", "/apispec_1", "/api-merged"}
var javascriptEndpoints []string = []string{"/swagger-ui-init", "/swagger-ui-bundle", "/swagger-ui-standalone-preset", "/swagger-ui", "/swagger-ui.min", "/swagger-ui-es-bundle-core", "/swagger-ui-es-bundle", "/swagger-ui-standalone-preset", "/swagger-ui-layout", "/swagger-ui-plugins"}
var htmlEndpoints []string = []string{"/index", "/swagger", "/swagger-ui", "/api-docs"}
var priorityURLs []string = []string{"/swagger.json", "/openapi.json", "/api-docs", "/swagger", "/docs", "/api/swagger.json", "/api/openapi.json", "/api-docs/swagger.json", "/api/schema/", "/webjars/swagger-ui/index.html", "/API/swagger/ui/index", "/swagger/ui/index", "/v2/swagger.json", "/v2/openapi.json", "/v2/api-docs", "/v3/api-docs", "/v3/openapi.json", "/public/api-merged.json", "/analytics/v1/swagger", "/api.json", "/api/4.0/swagger.json", "/api/api-doc/openapi.json", "/api/api-doc/openapi.yaml", "/api/doc.json", "/api/docs.json", "/api/swagger", "/api/swagger/ui/index", "/api/v1/swagger", "/api/v2/api-docs", "/api/v2/openapi.json", "/api/v2/swagger.json", "/api/v3/api-docs", "/api/v3/apispec", "/api/workorder/openapi.json", "/apidocs", "/audiences/v1/swagger", "/audittrail/v1/swagger", "/certification/v1/swagger", "/citrixapi/store/swagger.json", "/conferencetool/v1/swagger", "/course/v1/swagger", "/dcl_swagger.yaml", "/doc/doc.json", "/doc/swagger.json", "/docs/swagger.json", "/docs/v1/swagger.json", "/ecommerce/v1/swagger", "/enrollment/v1/swagger", "/externalids/v1/swagger", "/impact/v1/swagger", "/learn/v1/swagger", "/learningplan/v1/swagger", "/manage/v1/swagger", "/management/info", "/marketplace/v1/swagger", "/messenger/v1/swagger", "/notifications/v1/swagger", "/openapi", "/openapi/spec.json", "/otj/v1/swagger", "/pages/v1/swagger", "/poweruser/v1/swagger", "/proctoring/v1/swagger", "/report/v1/swagger", "/swagger-ui/index.html", "/swagger-ui/openapi.json", "/swagger.yaml", "/swagger/0.1.0/swagger.json", "/swagger/doc.json", "/swagger/latest/swagger.json", "/swagger/swagger.json", "/swagger/test/swagger.json", "/swagger/ui/index.html", "/swagger/v1/openapiv2.json", "/swagger/v1/swagger.json", "/swagger/v2/swagger.json", "/swagger/v4/swagger.json", "/v1/openapi.json", "/v1/swagger", "/v1/swagger.json", "/swagger/docs/v1", "/swagger/docs/v1.json", "/Api/swagger/docs/v1", "/swagger/v1/swagger.json", "/api/api-docs/swagger.json", "/api/docs/", "/api/docs", "/swagger-ui"}
var bruteCmd = &cobra.Command{
@@ -44,7 +58,7 @@ var bruteCmd = &cobra.Command{
}
}
client := CheckAndConfigureProxy()
client, _ := CheckAndConfigureProxy()
var allURLs []string
u, err := url.Parse(swaggerURL)
@@ -52,12 +66,17 @@ 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)...)
// HTML UI entrypoints first: one hit cascades to its JS initializer
// and spec, so UI-referenced specs are found early. Then the priority
// list, then the JS and JSON sweeps.
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, htmlEndpoints, ".html", false)...)
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, priorityURLs, "", true)...)
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, javascriptEndpoints, ".js", false)...)
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, jsonEndpoints, "", 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 +87,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 +138,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 +146,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,49 +156,105 @@ 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)
}
}
return urls
}
func findDefinitionFile(urls []string, client http.Client) (bool, *openapi3.T) {
// maxFollowDepth bounds how far brute chases discovered spec references.
const maxFollowDepth = 3
func findDefinitionFile(urls []string, client http.Client) (bool, *openapi3.T) {
seen := make(map[string]bool)
for i, url := range urls {
ct := CheckContentType(client, url)
if strings.Contains(ct, "application/json") {
bodyBytes, _, _ := MakeRequest(client, "GET", url, timeout, nil)
if bodyBytes != nil {
checkSpec := UnmarshalSpec(bodyBytes)
if (strings.HasPrefix(checkSpec.OpenAPI, "2") || strings.HasPrefix(checkSpec.OpenAPI, "3")) && checkSpec.Paths != nil {
fmt.Println("")
printInfo("Definition file found: %s\n", url)
if found, checkSpec := checkURLForSpec(url, client, seen, 0); found {
return true, checkSpec
}
writeProgress("Request: %d", i+1)
}
return false, nil
}
// checkURLForSpec fetches one URL and validates it as an OpenAPI/Swagger
// definition. For a Swagger UI page, initializer script, or swagger-config JSON,
// it follows the referenced URLs (bounded by maxFollowDepth and a shared visited
// set) so specs on non-standard paths are still found.
func checkURLForSpec(target string, client http.Client, seen map[string]bool, depth int) (bool, *openapi3.T) {
if depth > maxFollowDepth || seen[target] || isExternalDemoSpecURL(target) {
return false, nil
}
seen[target] = true
// One request so body and Content-Type come from the same response (two
// separate requests can desync behind a WAF/CDN).
bodyBytes, bodyString, _, ctRaw := MakeRequestFull(client, "GET", target, timeout, nil)
if bodyBytes == nil {
return false, nil
}
ct := strings.ToLower(ctRaw)
// Skip content types that cannot be a spec or reference one.
for _, skip := range []string{"image/", "video/", "audio/", "font/", "text/css", "application/zip", "application/pdf"} {
if strings.Contains(ct, skip) {
return false, nil
}
}
if looksLikeChallengeResponse(bodyString) {
warnWAFChallengeOnce()
return false, nil
}
// Swagger UI HTML page: follow the references it declares.
if strings.Contains(ct, "text/html") || looksLikeHTMLDocument(bodyString) {
return followReferences(ExtractSpecURLsFromHTML(bodyString, target), client, seen, depth)
}
// JavaScript initializer: embedded spec, or the spec url it configures.
// Library bundles are excluded to avoid their internal url: strings.
if strings.Contains(ct, "javascript") || strings.HasSuffix(strings.ToLower(urlPath(target)), ".js") {
if !looksLikeSwaggerInitCandidate(target) {
return false, nil
}
if bodyHasEmbeddedSpec(bodyString) {
if jsonContent, ok := ExtractJSONFromJSSpec(bodyBytes); ok {
checkSpec := UnmarshalSpec(jsonContent)
if strings.HasPrefix(checkSpec.OpenAPI, "2") || strings.HasPrefix(checkSpec.OpenAPI, "3") {
printInfo("Found operation definitions embedded in JavaScript file at %s\n", target)
return true, checkSpec
}
}
} else if strings.Contains(ct, "application/javascript") {
bodyBytes, bodyString, _ := 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 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
}
}
}
var refs []string
for _, ref := range extractJSSpecURLs(bodyString) {
if !looksLikeSpecReference(ref) {
continue
}
if abs, ok := resolveReferenceURL(target, ref); ok {
refs = append(refs, abs)
}
}
if i == len(urls) {
fmt.Printf("\033[2K\r%s%d\n", "Request: ", i+1)
} else {
fmt.Printf("\033[2K\r%s%d", "Request: ", i+1)
return followReferences(dedupeURLs(refs), client, seen, depth)
}
// Otherwise treat the body as a candidate spec and validate by parsing.
checkSpec := UnmarshalSpec(bodyBytes)
if (strings.HasPrefix(checkSpec.OpenAPI, "2") || strings.HasPrefix(checkSpec.OpenAPI, "3")) && checkSpec.Paths != nil {
printInfo("Definition file found: %s\n", target)
return true, checkSpec
}
// Not a spec: may be a swagger-config document that references one.
return followReferences(ExtractSpecURLsFromHTML(bodyString, target), client, seen, depth)
}
// followReferences recurses into a set of discovered spec/reference URLs.
func followReferences(refs []string, client http.Client, seen map[string]bool, depth int) (bool, *openapi3.T) {
for _, ref := range refs {
if found, checkSpec := checkURLForSpec(ref, client, seen, depth+1); found {
return true, checkSpec
}
}
return false, nil
@@ -191,6 +266,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
@@ -260,7 +378,7 @@ func UnmarshalSpec(bodyBytes []byte) (newDoc *openapi3.T) {
} else if strings.HasPrefix(doc.Swagger, "2") {
newDoc, err := openapi2conv.ToV3(&doc)
if err != nil {
fmt.Printf("Error converting v2 document to v3: %s\n", err)
printErr("Error converting v2 document to v3: %s", err)
}
return newDoc
} else if os.Args[1] == "brute" {
+3 -3
View File
@@ -29,10 +29,10 @@ var convertCmd = &cobra.Command{
}
}
client := CheckAndConfigureProxy()
client, _ := CheckAndConfigureProxy()
if strings.ToLower(outputFormat) != "json" {
fmt.Printf("\n")
printInfo("\n")
printInfo("Gathering API details.\n\n")
}
@@ -65,7 +65,7 @@ var convertCmd = &cobra.Command{
} else if strings.HasPrefix(doc.Swagger, "2") {
newDoc, err := openapi2conv.ToV3(&doc)
if err != nil {
fmt.Printf("Error converting v2 document to v3: %s\n", err)
printErr("Error converting v2 document to v3: %s", err)
}
switch format {
+240
View File
@@ -0,0 +1,240 @@
package cmd
import (
"net/url"
"regexp"
"strings"
)
// Helpers for following spec references out of Swagger UI HTML pages and their
// initializer scripts. Extraction is permissive: candidates are captured and
// validated by the caller (fetch + parse), so custom document names still work.
// url / "url" / configUrl / specUrl assignment values (also each entry of a urls: [...] array).
var htmlSpecKeyRE = regexp.MustCompile(`(?i)"?(?:config|spec)?url"?\s*:\s*["']([^"']+)["']`)
// spec-url / href / src attribute values.
var htmlSpecAttrRE = regexp.MustCompile(`(?i)(?:spec-url|href|src)\s*=\s*["']([^"']+)["']`)
// Swashbuckle discoveryPaths: arrayFrom('a|b') first argument.
var swashbuckleDiscoveryRE = regexp.MustCompile(`(?i)discoveryPaths\s*:\s*arrayFrom\(\s*['"]([^'"]+)['"]`)
// url: assignments inside an initializer script; a { or , before url avoids matching unrelated keys.
var jsSpecURLAssignRE = []*regexp.Regexp{
regexp.MustCompile(`(?i)(?:{\s*|,\s*)url\s*:\s*"([^"]+)"`),
regexp.MustCompile(`(?i)(?:{\s*|,\s*)url\s*:\s*'([^']+)'`),
regexp.MustCompile(`(?i)(?:{\s*|,\s*)"url"\s*:\s*"([^"]+)"`),
}
// ExtractSpecURLsFromHTML returns the spec/initializer URLs referenced by a
// Swagger UI page (or swagger-config JSON), resolved against pageURL and deduped.
func ExtractSpecURLsFromHTML(htmlContent, pageURL string) []string {
var out []string
add := func(ref string) {
if abs, ok := resolveReferenceURL(pageURL, ref); ok {
out = append(out, abs)
}
}
for _, m := range htmlSpecKeyRE.FindAllStringSubmatch(htmlContent, -1) {
if len(m) > 1 && looksLikeSpecReference(m[1]) {
add(m[1])
}
}
// Keep spec-like href/src refs, plus initializer scripts (followed for the url they configure).
for _, m := range htmlSpecAttrRE.FindAllStringSubmatch(htmlContent, -1) {
if len(m) > 1 && (looksLikeSpecReference(m[1]) || looksLikeSwaggerInitCandidate(m[1])) {
add(m[1])
}
}
// Swashbuckle discoveryPaths are relative to the site root.
if dm := swashbuckleDiscoveryRE.FindStringSubmatch(htmlContent); len(dm) > 1 {
if paths := strings.Split(dm[1], "|"); len(paths) > 0 {
first := strings.TrimSpace(paths[0])
if first != "" {
if !strings.HasPrefix(first, "/") && !strings.Contains(first, "://") {
first = "/" + first
}
add(first)
}
}
}
return dedupeURLs(out)
}
// extractJSSpecURLs pulls url: assignments from an initializer script body.
func extractJSSpecURLs(js string) []string {
var out []string
for _, re := range jsSpecURLAssignRE {
for _, m := range re.FindAllStringSubmatch(js, -1) {
if len(m) > 1 {
out = append(out, m[1])
}
}
}
return out
}
// looksLikeSpecReference accepts references that plausibly point at an
// OpenAPI/Swagger document (including custom .json/.yaml names) and rejects UI
// assets and bundles. Matches are still validated by fetching and parsing.
func looksLikeSpecReference(ref string) bool {
s := strings.TrimSpace(ref)
if s == "" {
return false
}
lo := strings.ToLower(s)
if strings.HasPrefix(lo, "javascript:") || strings.HasPrefix(lo, "data:") || strings.HasPrefix(lo, "#") {
return false
}
for _, ext := range []string{".js", ".css", ".map", ".html", ".htm", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf"} {
if strings.HasSuffix(lo, ext) {
return false
}
}
if strings.Contains(lo, "swagger-ui-bundle") || strings.Contains(lo, "swagger-ui-standalone") {
return false
}
if strings.Contains(lo, "api-docs") || strings.Contains(lo, "api_docs") || strings.Contains(lo, "apidocs") {
return true
}
if strings.Contains(lo, "openapi") {
return true
}
if strings.Contains(lo, "swagger.json") || strings.Contains(lo, "swagger.yaml") || strings.Contains(lo, "swagger.yml") {
return true
}
if strings.Contains(lo, "api-json") || strings.Contains(lo, "api_json") {
return true
}
if strings.HasSuffix(lo, ".json") || strings.HasSuffix(lo, ".yaml") || strings.HasSuffix(lo, ".yml") {
return true
}
if strings.HasSuffix(lo, "-json") || strings.HasSuffix(lo, "_json") {
return true
}
if strings.Contains(lo, "swagger-resources") {
return true
}
return false
}
// Library bundles are never followed as a config source: they are large and full of internal url: strings.
var swaggerUILibraryScripts = []string{
"swagger-ui-bundle", "swagger-ui-standalone", "swagger-ui-es-bundle",
"swagger-ui-layout", "swagger-ui-plugins", "swagger-ui.min", "swagger-ui.js",
"redoc.standalone", "redoc.min", "redoc.js",
}
// looksLikeSwaggerInitCandidate reports whether a .js reference is a plausible
// initializer (swagger-initializer.js, swagger-ui-init.js, index.js, ...) rather
// than a library bundle.
func looksLikeSwaggerInitCandidate(rawURL string) bool {
p := strings.ToLower(urlPath(rawURL))
if !strings.HasSuffix(p, ".js") {
return false
}
for _, lib := range swaggerUILibraryScripts {
if strings.Contains(p, lib) {
return false
}
}
return true
}
// urlPath returns the path component of a URL, or the input unchanged if it does
// not parse (so relative references still work).
func urlPath(raw string) string {
if u, err := url.Parse(raw); err == nil && u.Path != "" {
return u.Path
}
return raw
}
// bodyHasEmbeddedSpec is a cheap sniff for a spec inlined in a JavaScript body.
func bodyHasEmbeddedSpec(body string) bool {
lo := strings.ToLower(body)
return strings.Contains(lo, `"paths"`) && (strings.Contains(lo, `"openapi"`) || strings.Contains(lo, `"swagger"`))
}
// looksLikeHTMLDocument reports whether a response body is an HTML document.
func looksLikeHTMLDocument(body string) bool {
t := strings.TrimSpace(strings.ToLower(body))
return strings.HasPrefix(t, "<!doctype") || strings.HasPrefix(t, "<html")
}
// Substrings found in Cloudflare/WAF browser-challenge interstitial pages.
var challengeMarkers = []string{
"just a moment...",
"_cf_chl_opt",
"challenge-platform",
"cf-browser-verification",
"cf_chl_",
"enable javascript and cookies to continue",
"checking your browser before accessing",
"attention required! | cloudflare",
"ddos protection by cloudflare",
"/cdn-cgi/challenge-platform",
}
// looksLikeChallengeResponse reports whether a response body is a WAF/anti-bot challenge page.
func looksLikeChallengeResponse(body string) bool {
lo := strings.ToLower(body)
for _, m := range challengeMarkers {
if strings.Contains(lo, m) {
return true
}
}
return false
}
// isExternalDemoSpecURL guards against following the SwaggerUIBundle default demo specs.
func isExternalDemoSpecURL(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
switch strings.ToLower(u.Hostname()) {
case "petstore.swagger.io", "generator.swagger.io", "swagger.io", "www.swagger.io":
return true
}
return false
}
// resolveReferenceURL resolves a (possibly relative) reference against pageURL.
func resolveReferenceURL(pageURL, ref string) (string, bool) {
ref = strings.TrimSpace(ref)
if ref == "" {
return "", false
}
base, err := url.Parse(pageURL)
if err != nil {
return "", false
}
r, err := url.Parse(ref)
if err != nil {
return "", false
}
resolved := base.ResolveReference(r)
if resolved.Scheme == "" || resolved.Host == "" {
return "", false
}
return resolved.String(), true
}
// dedupeURLs removes duplicate and empty entries, preserving order.
func dedupeURLs(in []string) []string {
seen := make(map[string]bool, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
+4 -5
View File
@@ -1,7 +1,6 @@
package cmd
import (
"fmt"
"io"
"os"
"path/filepath"
@@ -21,16 +20,16 @@ This list contains the raw endpoints (parameter values will not be appended or m
}
}
client := CheckAndConfigureProxy()
client, _ := CheckAndConfigureProxy()
var bodyBytes []byte
fmt.Printf("\n")
printInfo("\n")
printInfo("Gathering endpoints.\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 {
@@ -44,7 +43,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)
}
},
}
+22
View File
@@ -30,16 +30,38 @@ var yellow = color.New(color.FgYellow, color.Bold).SprintFunc()
var red = color.New(color.FgRed, color.Bold).SprintFunc()
var faint = color.New(color.Faint).SprintFunc()
// progressPending is set while an unterminated progress line is on screen, so
// the diagnostic helpers can clear it before printing.
var progressPending bool
// writeProgress renders an overwriting progress line on stderr (the stream the
// diagnostic helpers use, so clearing is reliable).
func writeProgress(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "\r\033[2K"+format, args...)
progressPending = true
}
// clearProgressLine erases a pending progress line so a diagnostic prints cleanly.
func clearProgressLine() {
if progressPending {
fmt.Fprint(os.Stderr, "\r\033[2K")
progressPending = false
}
}
func printInfo(format string, args ...interface{}) {
clearProgressLine()
fmt.Fprintf(os.Stderr, format, args...)
}
func printWarn(format string, args ...interface{}) {
clearProgressLine()
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, "%s %s\n", yellow("[!]"), msg)
}
func printErr(format string, args ...interface{}) {
clearProgressLine()
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, "%s %s\n", red("[✗]"), msg)
}
+5 -7
View File
@@ -1,7 +1,6 @@
package cmd
import (
"fmt"
"io"
"os"
"path/filepath"
@@ -25,17 +24,16 @@ This enables you to test specific API functions for common vulnerabilities or mi
_, err := time.Parse("2006-01-02", customDate)
if err != nil {
fmt.Println("An invalid date was supplied. Please supply a date in '2006-01-02' format.")
os.Exit(1)
die("An invalid date was supplied. Please supply a date in '2006-01-02' format.")
}
client := CheckAndConfigureProxy()
client, _ := CheckAndConfigureProxy()
fmt.Printf("\n")
printInfo("\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 +48,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)
}
},
}
+90 -57
View File
@@ -52,7 +52,16 @@ var (
userChoice string
)
// MakeRequest returns the body bytes, body string, and status code. It wraps
// MakeRequestFull for callers that do not need the Content-Type.
func MakeRequest(client http.Client, method, target string, timeout int64, reqData io.Reader) ([]byte, string, int) {
bodyBytes, bodyString, status, _ := MakeRequestFull(client, method, target, timeout, reqData)
return bodyBytes, bodyString, status
}
// MakeRequestFull also returns the response Content-Type. Taking body and
// Content-Type from one response avoids a second request per URL.
func MakeRequestFull(client http.Client, method, target string, timeout int64, reqData io.Reader) ([]byte, string, int, string) {
if quiet {
avoidDangerousRequests = "y"
}
@@ -61,14 +70,14 @@ func MakeRequest(client http.Client, method, target string, timeout int64, reqDa
u, err := url.Parse(target)
if err != nil || u == nil {
printWarn("Error parsing URL '%s': %v - skipping request.", target, err)
return nil, "", 0
return nil, "", 0, ""
}
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
return nil, "", 0, ""
} else {
printWarn("Dangerous keyword '%s' detected in URL (%s). Do you still want to test this endpoint? (y/N)", v, target)
fmt.Scanln(&userChoice)
@@ -80,7 +89,7 @@ func MakeRequest(client http.Client, method, target string, timeout int64, reqDa
avoidDangerousRequests = strings.ToLower(avoidDangerousRequests)
riskSurveyed = true
}
return nil, "", 0
return nil, "", 0, ""
}
}
}
@@ -94,7 +103,7 @@ func MakeRequest(client http.Client, method, target string, timeout int64, reqDa
if err != context.Canceled && err != io.EOF {
die("Error: could not create HTTP request - %v", err)
}
return nil, "", 0
return nil, "", 0, ""
}
for i := range Headers {
@@ -145,81 +154,39 @@ func MakeRequest(client http.Client, method, target string, timeout int64, reqDa
resp, err := client.Do(req.WithContext(ctx))
if err == context.DeadlineExceeded {
printWarn("Error: %s - skipping request.", err)
return nil, "", 0
return nil, "", 0, ""
} else if err != nil && err != context.Canceled && err != io.EOF {
if (strings.Contains(fmt.Sprint(err), "tls") || strings.Contains(fmt.Sprint(err), "x509")) && !strings.Contains(fmt.Sprint(err), "user canceled") {
die("Try supplying the --insecure flag.")
} else if strings.Contains(fmt.Sprint(err), "tcp") && strings.Contains(fmt.Sprint(err), "no such host") {
die("The target '%s' is not reachable. Check the declared host(s) and supply a target manually using -T if needed.", u.Scheme+"://"+u.Host)
} else if strings.Contains(fmt.Sprint(err), "user canceled") {
return nil, "skipped", 1
return nil, "skipped", 1, ""
} else {
printErr("Error: response not received.\n%v", err)
}
return nil, "", 0
return nil, "", 0, ""
}
bodyBytes, _ := io.ReadAll(resp.Body)
bodyString := string(bodyBytes)
contentTypeHeader := resp.Header.Get("Content-Type")
if (resp.StatusCode == 301 || resp.StatusCode == 302) && strings.Contains(bodyString, "<html>") && depth < 10 {
depth += 1
redirect, _ := resp.Location()
bodyBytes, bodyString, requestStatus = MakeRequest(client, method, redirect.Scheme+"://"+redirect.Host+redirect.Path, timeout, reqData)
return bodyBytes, bodyString, requestStatus
var ct string
bodyBytes, bodyString, requestStatus, ct = MakeRequestFull(client, method, redirect.Scheme+"://"+redirect.Host+redirect.Path, timeout, reqData)
return bodyBytes, bodyString, requestStatus, ct
}
requestStatus = resp.StatusCode
depth = 0
return bodyBytes, bodyString, requestStatus
return bodyBytes, bodyString, requestStatus, contentTypeHeader
}
func CheckContentType(client http.Client, target string) string {
u, err := url.Parse(target)
if err != nil || u == nil {
printWarn("Error parsing URL '%s': %v - skipping request.", target, err)
return ""
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
req, err := http.NewRequest("GET", target, nil)
if err != nil {
if err != context.Canceled && err != io.EOF {
die("Error: could not create HTTP request - %v", err)
}
return ""
}
// User-Agent handling
if randomUserAgent {
rand.New(rand.NewSource(time.Now().UnixNano()))
UserAgent = userAgents[rand.Intn(len(userAgents))]
req.Header.Set("User-Agent", UserAgent)
} else if UserAgent != "Swagger Jacker (github.com/BishopFox/sj)" {
req.Header.Set("User-Agent", UserAgent)
}
resp, err := client.Do(req.WithContext(ctx))
if err == context.DeadlineExceeded {
printWarn("Error: %s - skipping request.", err)
return ""
} else if err != nil && err != context.Canceled && err != io.EOF {
if (strings.Contains(fmt.Sprint(err), "tls") || strings.Contains(fmt.Sprint(err), "x509")) && !strings.Contains(fmt.Sprint(err), "user canceled") {
die("Try supplying the --insecure flag.")
} else if strings.Contains(fmt.Sprint(err), "tcp") && strings.Contains(fmt.Sprint(err), "no such host") {
die("The target '%s' is not reachable. Check the declared host(s) and supply a target manually using -T if needed.", u.Scheme+"://"+u.Host)
} else {
printErr("Error: response not received.\n%v", err)
}
return ""
}
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{}
@@ -239,5 +206,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.0",
Version: "2.8.2",
}
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
+89 -18
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)
@@ -587,11 +639,7 @@ func GenerateRequests(bodyBytes []byte, client http.Client) {
for i := range servers {
if srv, ok := servers[i].(map[string]interface{}); ok {
if serverURL, ok := srv["url"].(string); ok {
if strings.Contains(serverURL, "://") {
fmt.Println(serverURL)
} else {
fmt.Println(apiTarget + serverURL)
}
printInfo("%s\n", serverURL)
}
}
}
@@ -650,7 +698,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{} {
@@ -775,7 +823,7 @@ func PrintSpecInfo(spec map[string]interface{}) {
if outputFormat == "json" {
specTitle = title
} else {
fmt.Printf("Title: %s\n", title)
printInfo("Title: %s\n", title)
}
}
@@ -784,7 +832,7 @@ func PrintSpecInfo(spec map[string]interface{}) {
if outputFormat == "json" {
specDescription = description
} else {
fmt.Printf("Description: %s\n", description)
printInfo("Description: %s\n", description)
}
}
}
@@ -801,12 +849,35 @@ 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{}
if err := yaml.Unmarshal(data, &doc); err != nil {
fmt.Printf("Failed to unmarshal API documentation: %v\n", err)
os.Exit(1)
die("Failed to unmarshal API documentation: %v", err)
}
return doc
+9 -12
View File
@@ -1,28 +1,25 @@
module github.com/BishopFox/sj
go 1.22.5
toolchain go1.23.4
go 1.25
require (
github.com/fatih/color v1.18.0
github.com/getkin/kin-openapi v0.132.0
github.com/getkin/kin-openapi v0.144.0
github.com/spf13/cobra v1.6.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/oasdiff/yaml v0.1.1 // indirect
github.com/oasdiff/yaml3 v0.0.14 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
+22 -24
View File
@@ -1,56 +1,54 @@
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y=
github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=