mirror of
https://github.com/BishopFox/sj.git
synced 2026-11-09 09:59:37 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d9fa54a58 | ||
|
|
09a1183e6f | ||
|
|
90e9c78d9a | ||
|
|
3f99ffb48c | ||
|
|
331bb1a05c | ||
|
|
3c2a3b807d |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
+147
-48
@@ -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{
|
||||
@@ -54,9 +68,13 @@ var bruteCmd = &cobra.Command{
|
||||
target := u.Scheme + "://" + u.Host
|
||||
normalizedBasePath := normalizeBasePath(basePath)
|
||||
if endpointWordlist == "" {
|
||||
// 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, jsonEndpoints, "", false)...)
|
||||
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 {
|
||||
@@ -145,60 +163,98 @@ func makeURLs(target string, basePath string, endpoints []string, fileExtension
|
||||
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|const|var)\s+(\w+)\s*=\s*({.*?});`)
|
||||
matches := regexPattern.FindAllStringSubmatch(bodyString, -1)
|
||||
for _, match := range matches {
|
||||
if len(match) < 3 {
|
||||
continue
|
||||
}
|
||||
jsonContent := []byte(match[2])
|
||||
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
|
||||
}
|
||||
// Swagger UI commonly nests the spec one level deep under
|
||||
// keys like swaggerDoc (swagger-ui-express / NestJS),
|
||||
// spec (Swagger UI Bundle config), or openapi.
|
||||
var wrapper map[string]json.RawMessage
|
||||
if err := json.Unmarshal(jsonContent, &wrapper); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"swaggerDoc", "spec", "openapi"} {
|
||||
inner, ok := wrapper[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
checkSpec := UnmarshalSpec(inner)
|
||||
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
|
||||
@@ -210,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+22
@@ -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)
|
||||
}
|
||||
|
||||
+21
-67
@@ -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" && !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,91 +154,36 @@ 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
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
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:])
|
||||
if key == "User-Agent" {
|
||||
UserAgent = value
|
||||
}
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
// 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")
|
||||
return bodyBytes, bodyString, requestStatus, contentTypeHeader
|
||||
}
|
||||
|
||||
func CheckAndConfigureProxy() (client http.Client, replayClient *http.Client) {
|
||||
|
||||
+1
-1
@@ -53,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.6.2",
|
||||
Version: "2.8.0",
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
|
||||
+76
-6
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -49,6 +50,19 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
}
|
||||
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, rep
|
||||
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, rep
|
||||
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, rep
|
||||
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)))
|
||||
@@ -540,6 +577,15 @@ func GenerateExample(node *SchemaNode) interface{} {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -807,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{}
|
||||
|
||||
Reference in new issue
Block a user