mirror of
https://github.com/BishopFox/sj.git
synced 2026-11-08 01:38:15 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1aea16c6e | ||
|
|
e5796ed66a | ||
|
|
ef5822c230 | ||
|
|
9111e90a20 | ||
|
|
04e0b22c47 | ||
|
|
3c8a2d3c3a | ||
|
|
64470d1995 | ||
|
|
4e9ae71caa | ||
|
|
85c4a44421 | ||
|
|
023e210b75 | ||
|
|
5f83f1cb26 | ||
|
|
6d262a369d | ||
|
|
97eab8c4ba | ||
|
|
6c7ee7d7da | ||
|
|
f2b722ca55 | ||
|
|
b0a281ccf1 | ||
|
|
c9f4472553 | ||
|
|
001f8f16b1 | ||
|
|
31aa3b3f2f | ||
|
|
186841c2ba | ||
|
|
7db065923a |
No files matched your search
+9
-1
@@ -63,4 +63,12 @@ changelog:
|
||||
regexp: "^(?i).*fix.*$"
|
||||
order: 1
|
||||
- title: Others
|
||||
order: 999
|
||||
order: 999
|
||||
|
||||
release:
|
||||
draft: true
|
||||
header: |
|
||||
### Did you know?
|
||||
|
||||
sj is now integrated into [Joro](https://github.com/BishopFox/joro)!
|
||||
You can run Swagger/OpenAPI exposure testing as part of a Joro workflow without invoking sj by hand.
|
||||
@@ -40,25 +40,23 @@ $ export PATH=$PATH:~/go/bin
|
||||
$ sj automate -u https://petstore.swagger.io/v2/swagger.json -qi -p http://127.0.0.1:8080
|
||||
|
||||
Gathering API details.
|
||||
Title: Swagger Petstore
|
||||
Description: This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.
|
||||
✓ GET 200 /v2/pet/findByStatus
|
||||
✓ GET 200 /v2/user/logout
|
||||
⚠ POST 400 /v2/user/createWithArray
|
||||
⚠ POST 400 /v2/store/order
|
||||
✗ GET 404 /v2/store/order/1
|
||||
⚠ POST 400 /v2/pet
|
||||
⚠ PUT 415 /v2/pet
|
||||
⚠ POST 400 /v2/user/createWithList
|
||||
✗ GET 404 /v2/user/bishopfox
|
||||
⚠ PUT 415 /v2/user/bishopfox
|
||||
⚠ POST 400 /v2/user
|
||||
⚠ POST 415 /v2/pet/1/uploadImage
|
||||
✓ GET 200 /v2/pet/findByTags
|
||||
✗ GET 404 /v2/pet/1
|
||||
⚠ POST 415 /v2/pet/1
|
||||
✓ GET 200 /v2/store/inventory
|
||||
✓ GET 200 /v2/user/login
|
||||
⚠ POST 500 /v2/pet
|
||||
⚠ PUT 500 /v2/pet
|
||||
✓ GET 200 /v2/pet/findByStatus
|
||||
✓ GET 200 /v2/pet/findByTags
|
||||
✓ GET 200 /v2/pet/1
|
||||
✓ POST 200 /v2/pet/1
|
||||
⚠ POST N/A /v2/pet/1/uploadImage
|
||||
✓ GET 200 /v2/store/inventory
|
||||
⚠ POST N/A /v2/store/order
|
||||
⚠ GET N/A /v2/store/order/1
|
||||
✓ POST 200 /v2/user
|
||||
⚠ POST N/A /v2/user/createWithArray
|
||||
⚠ POST N/A /v2/user/createWithList
|
||||
✓ GET 200 /v2/user/login
|
||||
✓ GET 200 /v2/user/logout
|
||||
✓ GET 200 /v2/user/bishopfox
|
||||
✓ PUT 200 /v2/user/bishopfox
|
||||
```
|
||||
|
||||
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:
|
||||
@@ -79,72 +77,95 @@ You can also request verbose output to see the partial (or full) response:
|
||||
$ sj automate -u https://petstore.swagger.io/v2/swagger.json -qi -p http://127.0.0.1:8080 -v
|
||||
|
||||
Gathering API details.
|
||||
Title: Swagger Petstore
|
||||
Description: This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.
|
||||
✗ GET 404 /v2/user/bishopfox
|
||||
{"code":1,"type":"error","message":"User not found
|
||||
⚠ PUT 415 /v2/user/bishopfox
|
||||
{"code":415,"type":"unknown","message":"com.sun.je
|
||||
✓ GET 200 /v2/user/logout
|
||||
{"code":200,"type":"unknown","message":"ok"}
|
||||
⚠ POST 400 /v2/user/createWithArray
|
||||
{"code":400,"type":"unknown","message":"bad input"
|
||||
⚠ POST 400 /v2/user/createWithList
|
||||
{"code":400,"type":"unknown","message":"bad input"
|
||||
✗ GET 404 /v2/pet/1
|
||||
{"code":1,"type":"error","message":"Pet not found"
|
||||
⚠ POST 415 /v2/pet/1
|
||||
{"code":415,"type":"unknown"}
|
||||
✓ GET 200 /v2/store/inventory
|
||||
{"sold":117,"string":26,"invalidStatus":1,"-1":1,"
|
||||
⚠ POST 400 /v2/store/order
|
||||
{"code":400,"type":"unknown","message":"bad input"
|
||||
✓ GET 200 /v2/user/login
|
||||
⚠ POST 500 /v2/pet
|
||||
{"code":500,"type":"unknown","message":"something
|
||||
⚠ PUT 500 /v2/pet
|
||||
{"code":500,"type":"unknown","message":"something
|
||||
✓ GET 200 /v2/pet/findByStatus
|
||||
[]
|
||||
✓ GET 200 /v2/pet/findByTags
|
||||
[]
|
||||
✓ GET 200 /v2/pet/1
|
||||
{"id":1,"category":{"id":1,"name":"cat"},"name":"d
|
||||
✓ POST 200 /v2/pet/1
|
||||
{"code":200,"type":"unknown","message":"1"}
|
||||
⚠ POST N/A /v2/pet/1/uploadImage
|
||||
✓ GET 200 /v2/store/inventory
|
||||
{"sold":115,"bishopfox":1,"SOLD":1,"string":224,"d
|
||||
⚠ POST N/A /v2/store/order
|
||||
⚠ GET N/A /v2/store/order/1
|
||||
✓ POST 200 /v2/user
|
||||
{"code":200,"type":"unknown","message":"1"}
|
||||
⚠ POST N/A /v2/user/createWithArray
|
||||
⚠ POST N/A /v2/user/createWithList
|
||||
✓ GET 200 /v2/user/login
|
||||
{"code":200,"type":"unknown","message":"logged in
|
||||
⚠ POST 400 /v2/pet
|
||||
{"code":400,"type":"unknown","message":"bad input"
|
||||
⚠ PUT 415 /v2/pet
|
||||
{"code":415,"type":"unknown","message":"com.sun.je
|
||||
✓ GET 200 /v2/pet/findByStatus
|
||||
[]
|
||||
✓ GET 200 /v2/pet/findByTags
|
||||
[]
|
||||
✗ GET 404 /v2/store/order/1
|
||||
{"code":1,"type":"error","message":"Order not foun
|
||||
⚠ POST 400 /v2/user
|
||||
{"code":400,"type":"unknown","message":"bad input"
|
||||
⚠ POST 415 /v2/pet/1/uploadImage
|
||||
{"code":415,"type":"unknown"}
|
||||
✓ GET 200 /v2/user/logout
|
||||
{"code":200,"type":"unknown","message":"ok"}
|
||||
✓ GET 200 /v2/user/bishopfox
|
||||
{"id":1,"username":"bishopfox","firstName":"bishop
|
||||
✓ PUT 200 /v2/user/bishopfox
|
||||
{"code":200,"type":"unknown","message":"1"}
|
||||
```
|
||||
|
||||
> Use the `prepare` command to prepare a list of commands for manual testing. Currently supports both `curl` and `sqlmap`. You will likely have to modify these slightly.
|
||||
|
||||
```bash
|
||||
$ sj prepare -u https://petstore.swagger.io/v2/swagger.json -qi -p http://127.0.0.1:8080
|
||||
$ sj prepare -u https://petstore.swagger.io/v2/swagger.json -qi -p http://127.0.0.1:8080
|
||||
|
||||
INFO[0000] Gathering API details.
|
||||
|
||||
Title: Swagger Petstore
|
||||
Description: This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/pet/{petId}"
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/pet/{petId}"
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/pet" -H 'Content-Type: application/json' -d '{"category":{"id":1,"name":"bishopfox"},"id":1,"name":"doggie","photoUrls":"https://bishopfox.com","status":"available","tags":[{"id":1,"name":"bishopfox"}]}'
|
||||
$ curl -X PUT "https://petstore.swagger.io/v2/pet" -H 'Content-Type: application/json' -d '{"category":{"id":1,"name":"bishopfox"},"id":1,"name":"doggie","photoUrls":"https://bishopfox.com","status":"available","tags":[{"id":1,"name":"bishopfox"}]}'
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/pet/findByStatus?status=1"
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/pet/findByTags?tags=1"
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/pet/1"
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/pet/1" -H 'Content-Type: application/x-www-form-urlencoded' -d 'name=bishopfox&status=bishopfox'
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/pet/1/uploadImage" -H 'Content-Type: application/x-www-form-urlencoded' -d 'additionalMetadata=bishopfox&file=1'
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/store/inventory"
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/user/createWithList" -d 'body=1'
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/store/order" -H 'Content-Type: application/json' -d '{"complete":true,"id":1,"petId":1,"quantity":1,"shipDate":"1990-01-01","status":"placed"}'
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/store/order/1"
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/user" -H 'Content-Type: application/json' -d '{"email":"[email protected]","firstName":"bishopfox","id":1,"lastName":"bishopfox","password":"bishopfox","phone":"bishopfox","userStatus":1,"username":"bishopfox"}'
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/user/createWithArray" -H 'Content-Type: application/json' -d '[{"email":"[email protected]","firstName":"bishopfox","id":1,"lastName":"bishopfox","password":"bishopfox","phone":"bishopfox","userStatus":1,"username":"bishopfox"}]'
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/user/createWithList" -H 'Content-Type: application/json' -d '[{"email":"[email protected]","firstName":"bishopfox","id":1,"lastName":"bishopfox","password":"bishopfox","phone":"bishopfox","userStatus":1,"username":"bishopfox"}]'
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/user/login?username=bishopfox&password=bishopfox"
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/user/logout"
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/user/createWithArray" -d 'body=1'
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/pet/findByStatus"
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/pet/findByTags"
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/store/order" -d 'petId=1&quantity=1&shipDate=bishopfox&status=bishopfox&complete=1&id=1&body='
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/pet/{petId}/uploadImage"
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/pet" -d 'photoUrls=1&tags=1&status=bishopfox&id=1&category=&name=doggie&body='
|
||||
$ curl -X PUT "https://petstore.swagger.io/v2/pet" -d 'id=1&category=&name=doggie&photoUrls=1&tags=1&status=bishopfox&body='
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/user/{username}"
|
||||
$ curl -X PUT "https://petstore.swagger.io/v2/user/{username}" -d 'email=bishopfox&password=bishopfox&phone=bishopfox&userStatus=1&id=1&username=bishopfox&firstName=bishopfox&lastName=bishopfox&body='
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/user/login"
|
||||
$ curl -X POST "https://petstore.swagger.io/v2/user" -d 'phone=bishopfox&userStatus=1&id=1&username=bishopfox&firstName=bishopfox&lastName=bishopfox&email=bishopfox&password=bishopfox&body='
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/store/order/{orderId}"
|
||||
$ curl -X GET "https://petstore.swagger.io/v2/user/bishopfox"
|
||||
$ curl -X PUT "https://petstore.swagger.io/v2/user/bishopfox" -H 'Content-Type: application/json' -d '{"email":"[email protected]","firstName":"bishopfox","id":1,"lastName":"bishopfox","password":"bishopfox","phone":"bishopfox","userStatus":1,"username":"bishopfox"}'
|
||||
```
|
||||
|
||||
### Multiple request body content types
|
||||
|
||||
An operation often declares the same body under several content types. By default `sj`
|
||||
sends the one most likely to be accepted, preferring `application/json`, then
|
||||
`application/x-www-form-urlencoded`, then `multipart/form-data`, then XML. The choice is
|
||||
deterministic, so repeated runs produce identical commands.
|
||||
|
||||
Because a JSON parser and an XML parser are different attack surface, `--all-content-types`
|
||||
sends every declared type instead of only the preferred one:
|
||||
|
||||
```bash
|
||||
$ sj prepare -l spec.yaml -T https://api.example.com -q --all-content-types
|
||||
|
||||
$ curl -X POST "https://api.example.com/multi" -H 'Content-Type: application/json' -d '{"name":"bishopfox","size":1}'
|
||||
$ curl -X POST "https://api.example.com/multi" -H 'Content-Type: application/x-www-form-urlencoded' -d 'name=bishopfox&size=1'
|
||||
$ curl -X POST "https://api.example.com/multi" -F 'name=bishopfox' -F 'size=1'
|
||||
$ curl -X POST "https://api.example.com/multi" -H 'Content-Type: application/xml' -d '<name>bishopfox</name><size>1</size>'
|
||||
```
|
||||
|
||||
Note that this multiplies the number of requests sent to the target, and that
|
||||
`--replay-proxy` receives every one of them.
|
||||
|
||||
To test a single specific encoding, pass it with `-H`. When the operation declares that
|
||||
type, `sj` sends the matching body under it:
|
||||
|
||||
```bash
|
||||
$ sj prepare -l spec.yaml -T https://api.example.com -q -H "Content-Type: application/xml"
|
||||
```
|
||||
|
||||
When the operation does *not* declare it, `sj` warns and sends the preferred body under
|
||||
your header anyway, which is useful for parser-differential testing. Content types `sj`
|
||||
cannot encode a body for (`application/octet-stream`, `text/plain`) are skipped rather
|
||||
than sent as an empty body under a misleading header.
|
||||
|
||||
> Use the `endpoints` command to generate a list of raw endpoints from the provided definition file.
|
||||
|
||||
```bash
|
||||
@@ -232,6 +253,7 @@ Available Commands:
|
||||
|
||||
Flags:
|
||||
-A, --agent string Set the User-Agent string. (default "Swagger Jacker (github.com/BishopFox/sj)")
|
||||
--all-content-types Send a separate request for every request body content type an operation declares, instead of only the preferred one.
|
||||
-b, --base-path string Set the API base path if not defined in the definition file (i.e. /V2/).
|
||||
-f, --format string Declare the format of the definition file (json/yaml/yml/js). (default "json")
|
||||
-H, --headers stringArray Add custom headers, separated by a colon ("Name: Value"). Multiple flags are accepted.
|
||||
|
||||
+463
-99
@@ -3,6 +3,8 @@ package cmd
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -15,118 +17,480 @@ var (
|
||||
basicAuth []byte
|
||||
basicAuthString string
|
||||
bearerToken string
|
||||
// authQueryParams holds API keys that a security scheme places in the query
|
||||
// string (apiKey with "in: query"). BuildRequestsFromPaths appends each one
|
||||
// to every operation's URL, mirroring how Headers carries header-based auth.
|
||||
authQueryParams []queryAuthParam
|
||||
// authCookieParams holds API keys that a security scheme places in a cookie
|
||||
// (apiKey with "in: cookie"). BuildRequestsFromPaths merges each one into the
|
||||
// single Cookie header it already builds from per-operation cookie params.
|
||||
authCookieParams []queryAuthParam
|
||||
)
|
||||
|
||||
// queryAuthParam is a single name/value pair to add to request query strings.
|
||||
type queryAuthParam struct {
|
||||
name string
|
||||
value string
|
||||
}
|
||||
|
||||
// securitySchemeSource returns the security-scheme definitions from a parsed
|
||||
// spec, normalized to the OpenAPI 3.x shape CheckSecuritySchemes understands. It
|
||||
// prefers the v3 location (components.securitySchemes) and falls back to the
|
||||
// Swagger 2.0 top-level securityDefinitions, so both spec versions are handled
|
||||
// by the same downstream switch. Returns nil when neither location defines any.
|
||||
func securitySchemeSource(spec map[string]interface{}) map[string]interface{} {
|
||||
if components, ok := spec["components"].(map[string]interface{}); ok && components != nil {
|
||||
if schemes, ok := components["securitySchemes"].(map[string]interface{}); ok && len(schemes) > 0 {
|
||||
return schemes
|
||||
}
|
||||
}
|
||||
|
||||
// Swagger 2.0 keeps schemes under a top-level securityDefinitions object.
|
||||
if defs, ok := spec["securityDefinitions"].(map[string]interface{}); ok && len(defs) > 0 {
|
||||
normalized := make(map[string]interface{}, len(defs))
|
||||
for name, def := range defs {
|
||||
if scheme, ok := def.(map[string]interface{}); ok {
|
||||
normalized[name] = normalizeV2Scheme(scheme)
|
||||
} else {
|
||||
normalized[name] = def
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeV2Scheme rewrites a Swagger 2.0 security scheme into the OpenAPI 3.x
|
||||
// shape. Swagger 2.0 expresses HTTP Basic as its own top-level type ("basic"),
|
||||
// whereas 3.x models it as type "http" with scheme "basic". apiKey schemes use
|
||||
// the same name/in/type fields in both versions, so they pass through unchanged;
|
||||
// oauth2 is left as-is (handled no differently than in a 3.x spec today).
|
||||
func normalizeV2Scheme(scheme map[string]interface{}) map[string]interface{} {
|
||||
if typ, _ := scheme["type"].(string); typ != "basic" {
|
||||
return scheme
|
||||
}
|
||||
converted := make(map[string]interface{}, len(scheme)+1)
|
||||
for k, v := range scheme {
|
||||
converted[k] = v
|
||||
}
|
||||
converted["type"] = "http"
|
||||
converted["scheme"] = "basic"
|
||||
return converted
|
||||
}
|
||||
|
||||
func CheckSecuritySchemes(spec map[string]interface{}) {
|
||||
components, ok := spec["components"].(map[string]interface{})
|
||||
if ok && components != nil {
|
||||
securitySchemes, ok := components["securitySchemes"].(map[string]interface{})
|
||||
if !ok || len(securitySchemes) == 0 {
|
||||
printInfo("No security schemes defined.\n")
|
||||
} else {
|
||||
printInfo("Found security schemes:\n")
|
||||
var apiKey string
|
||||
var apiKeyName string
|
||||
securitySchemes := securitySchemeSource(spec)
|
||||
if len(securitySchemes) == 0 {
|
||||
printInfo("No security schemes defined.\n")
|
||||
return
|
||||
}
|
||||
|
||||
for mechanism, value := range securitySchemes {
|
||||
printInfo(" - %s\n", mechanism)
|
||||
scheme, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
printInfo("Found security schemes:\n")
|
||||
var apiKey string
|
||||
var apiKeyName string
|
||||
|
||||
for mechanism, value := range securitySchemes {
|
||||
printInfo(" - %s\n", mechanism)
|
||||
scheme, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if typ, ok := scheme["type"].(string); ok {
|
||||
switch typ {
|
||||
case "http":
|
||||
schemeName, _ := scheme["scheme"].(string)
|
||||
switch strings.ToLower(schemeName) {
|
||||
case "basic":
|
||||
if quiet {
|
||||
autoApplyBasicAuth = "n"
|
||||
printWarn("A basic authentication header is accepted. Review the spec and craft a header manually using the -H flag.")
|
||||
} else {
|
||||
printInfo("Basic Authentication is accepted. Supply a username and password? (y/N)\n")
|
||||
fmt.Scanln(&autoApplyBasicAuth)
|
||||
autoApplyBasicAuth = strings.ToLower(autoApplyBasicAuth)
|
||||
if autoApplyBasicAuth == "y" {
|
||||
printInfo("Enter a username.")
|
||||
fmt.Scanln(&basicAuthUser)
|
||||
printInfo("Enter a password.")
|
||||
fmt.Scanln(&basicAuthPass)
|
||||
basicAuth = []byte(basicAuthUser + ":" + basicAuthPass)
|
||||
basicAuthString = base64.StdEncoding.EncodeToString(basicAuth)
|
||||
printInfo("Using %s as the Basic Auth value.\n", basicAuthString)
|
||||
Headers = append(Headers, "Authorization: Basic "+basicAuthString)
|
||||
} else {
|
||||
printWarn("A basic authentication header is accepted. Review the spec and craft a header manually using the -H flag.")
|
||||
}
|
||||
}
|
||||
case "bearer":
|
||||
if quiet {
|
||||
autoApplyBearer = "n"
|
||||
printWarn("A bearer token is accepted. Review the spec and craft a token manually using the -H flag.")
|
||||
} else {
|
||||
printInfo("A bearer token is accepted. Would you like to provide one? (y/N)\n")
|
||||
fmt.Scanln(&autoApplyBearer)
|
||||
autoApplyBearer = strings.ToLower(autoApplyBearer)
|
||||
if autoApplyBearer == "y" {
|
||||
printInfo("What value would you like to use for the Bearer Token? ")
|
||||
fmt.Scanln(&bearerToken)
|
||||
Headers = append(Headers, "Authorization: Bearer "+bearerToken)
|
||||
} else {
|
||||
printWarn("A bearer token is accepted. Review the spec and craft a token manually using the -H flag.")
|
||||
}
|
||||
}
|
||||
}
|
||||
case "apiKey":
|
||||
if inVal, ok := scheme["in"].(string); ok {
|
||||
switch inVal {
|
||||
case "query":
|
||||
printInfo("An API key can be provided via a parameter string. Would you like to apply one? (y/N)\n")
|
||||
if quiet {
|
||||
autoApplyAPIKey = "n"
|
||||
} else {
|
||||
fmt.Scanln(&autoApplyAPIKey)
|
||||
autoApplyAPIKey = strings.ToLower(autoApplyAPIKey)
|
||||
}
|
||||
|
||||
if typ, ok := scheme["type"].(string); ok {
|
||||
switch typ {
|
||||
case "http":
|
||||
if schemeType := scheme["scheme"]; schemeType != nil {
|
||||
switch schemeType {
|
||||
case "basic":
|
||||
if quiet {
|
||||
autoApplyBasicAuth = "n"
|
||||
printWarn("A basic authentication header is accepted. Review the spec and craft a header manually using the -H flag.")
|
||||
} else {
|
||||
printInfo("Basic Authentication is accepted. Supply a username and password? (y/N)\n")
|
||||
fmt.Scanln(&autoApplyBasicAuth)
|
||||
autoApplyBasicAuth = strings.ToLower(autoApplyBasicAuth)
|
||||
if autoApplyBasicAuth == "y" {
|
||||
printInfo("Enter a username.")
|
||||
fmt.Scanln(&basicAuthUser)
|
||||
printInfo("Enter a password.")
|
||||
fmt.Scanln(&basicAuthPass)
|
||||
basicAuth = []byte(basicAuthUser + ":" + basicAuthPass)
|
||||
basicAuthString = base64.StdEncoding.EncodeToString(basicAuth)
|
||||
printInfo("Using %s as the Basic Auth value.\n", basicAuthString)
|
||||
Headers = append(Headers, "Authorization: Basic "+basicAuthString)
|
||||
} else {
|
||||
printWarn("A basic authentication header is accepted. Review the spec and craft a header manually using the -H flag.")
|
||||
}
|
||||
}
|
||||
case "bearer":
|
||||
printWarn("A bearer token is accepted. Review the spec and craft a token manually using the -H flag.")
|
||||
if autoApplyAPIKey == "y" {
|
||||
if nameVal, ok := scheme["name"].(string); ok {
|
||||
apiKeyName = nameVal
|
||||
}
|
||||
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)
|
||||
// Record the key so BuildRequestsFromPaths adds it to
|
||||
// every request's query string, not just the diagnostics.
|
||||
authQueryParams = append(authQueryParams, queryAuthParam{name: apiKeyName, value: apiKey})
|
||||
}
|
||||
case "header":
|
||||
if nameVal, ok := scheme["name"].(string); ok {
|
||||
printInfo("An API key can be provided via the header %s. Would you like to apply one? (y/N)\n", nameVal)
|
||||
if quiet {
|
||||
autoApplyAPIKey = "n"
|
||||
} else {
|
||||
fmt.Scanln(&autoApplyAPIKey)
|
||||
autoApplyAPIKey = strings.ToLower(autoApplyAPIKey)
|
||||
}
|
||||
if autoApplyAPIKey == "y" {
|
||||
apiKeyName = nameVal
|
||||
printInfo("What value would you like to use for the API key (%s)?", apiKeyName)
|
||||
fmt.Scanln(&apiKey)
|
||||
Headers = append(Headers, nameVal+": "+apiKey)
|
||||
}
|
||||
}
|
||||
case "apiKey":
|
||||
if inVal, ok := scheme["in"].(string); ok {
|
||||
switch inVal {
|
||||
case "query":
|
||||
printInfo("An API key can be provided via a parameter string. Would you like to apply one? (y/N)\n")
|
||||
if quiet {
|
||||
autoApplyAPIKey = "n"
|
||||
} else {
|
||||
fmt.Scanln(&autoApplyAPIKey)
|
||||
autoApplyAPIKey = strings.ToLower(autoApplyAPIKey)
|
||||
}
|
||||
|
||||
if autoApplyAPIKey == "y" {
|
||||
if nameVal, ok := scheme["name"].(string); ok {
|
||||
apiKeyName = nameVal
|
||||
}
|
||||
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)
|
||||
}
|
||||
case "header":
|
||||
if mechanism == "bearer" {
|
||||
printInfo("A bearer token is accepted. Would you like to provide one? (y/N)\n")
|
||||
if quiet {
|
||||
autoApplyBearer = "n"
|
||||
} else {
|
||||
fmt.Scanln(&autoApplyBearer)
|
||||
autoApplyBearer = strings.ToLower(autoApplyBearer)
|
||||
}
|
||||
if autoApplyBearer == "y" {
|
||||
printInfo("What value would you like to use for the Bearer Token? ")
|
||||
fmt.Scanln(&bearerToken)
|
||||
Headers = append(Headers, "Authorization: Bearer "+bearerToken)
|
||||
} else {
|
||||
printWarn("A bearer token is accepted. Review the spec and craft a header manually using the -H flag.")
|
||||
}
|
||||
} else {
|
||||
if nameVal, ok := scheme["name"].(string); ok {
|
||||
printInfo("An API key can be provided via the header %s. Would you like to apply one? (y/N)\n", nameVal)
|
||||
if quiet {
|
||||
autoApplyAPIKey = "n"
|
||||
} else {
|
||||
fmt.Scanln(&autoApplyAPIKey)
|
||||
autoApplyAPIKey = strings.ToLower(autoApplyAPIKey)
|
||||
}
|
||||
if autoApplyAPIKey == "y" {
|
||||
apiKeyName = nameVal
|
||||
printInfo("What value would you like to use for the API key (%s)?", apiKeyName)
|
||||
fmt.Scanln(&apiKey)
|
||||
Headers = append(Headers, nameVal+": "+apiKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "cookie":
|
||||
if nameVal, ok := scheme["name"].(string); ok {
|
||||
printInfo("An API key can be provided via the cookie %s. Would you like to apply one? (y/N)\n", nameVal)
|
||||
if quiet {
|
||||
autoApplyAPIKey = "n"
|
||||
} else {
|
||||
fmt.Scanln(&autoApplyAPIKey)
|
||||
autoApplyAPIKey = strings.ToLower(autoApplyAPIKey)
|
||||
}
|
||||
if autoApplyAPIKey == "y" {
|
||||
apiKeyName = nameVal
|
||||
printInfo("What value would you like to use for the API key (%s)?", apiKeyName)
|
||||
fmt.Scanln(&apiKey)
|
||||
// Record the key so BuildRequestsFromPaths merges it into
|
||||
// the Cookie header it builds for every request.
|
||||
authCookieParams = append(authCookieParams, queryAuthParam{name: apiKeyName, value: apiKey})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bearerFormat, ok := scheme["bearerFormat"].(string); ok {
|
||||
printInfo(" - bearerFormat: %s\n", bearerFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bearerFormat, ok := scheme["bearerFormat"].(string); ok {
|
||||
printInfo(" - bearerFormat: %s\n", bearerFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// securityState classifies an operation's effective security requirement.
|
||||
type securityState int
|
||||
|
||||
const (
|
||||
// secUndeclared: neither the operation nor the spec declares any requirement.
|
||||
secUndeclared securityState = iota
|
||||
// secRequired: a requirement applies and anonymous access is not offered.
|
||||
secRequired
|
||||
// secOptional: a requirement applies, but an empty {} entry alongside it
|
||||
// means anonymous access is also accepted.
|
||||
secOptional
|
||||
// secPublic: an operation-level `security: []` explicitly disables auth,
|
||||
// overriding any spec-level default.
|
||||
secPublic
|
||||
)
|
||||
|
||||
// httpMethods is the set of path-item keys that denote an operation, so the
|
||||
// security summary skips siblings like "parameters", "summary", or "$ref".
|
||||
var httpMethods = map[string]bool{
|
||||
"get": true, "put": true, "post": true, "delete": true,
|
||||
"options": true, "head": true, "patch": true, "trace": true,
|
||||
}
|
||||
|
||||
// opSecurity is the effective security requirement resolved for one operation:
|
||||
// its classification, the scheme names any requirement references (deduped), any
|
||||
// referenced names with no matching definition, and the raw alternatives so a
|
||||
// credential check can honor AND within a requirement object and OR across them.
|
||||
type opSecurity struct {
|
||||
state securityState
|
||||
schemes []string
|
||||
undefined []string
|
||||
alternatives [][]string
|
||||
}
|
||||
|
||||
// resolveOperationSecurity computes the effective security for one operation.
|
||||
// The operation's own `security` wins when the key is present (an empty list is
|
||||
// an explicit public override); otherwise the spec-level `security` is inherited.
|
||||
// Each requirement object (scheme -> scopes) ANDs its schemes; alternative
|
||||
// objects OR; an empty {} object marks anonymous access as acceptable.
|
||||
func resolveOperationSecurity(spec, opMap map[string]interface{}) opSecurity {
|
||||
raw, fromOp := opMap["security"]
|
||||
if !fromOp {
|
||||
var present bool
|
||||
raw, present = spec["security"]
|
||||
if !present {
|
||||
return opSecurity{state: secUndeclared}
|
||||
}
|
||||
}
|
||||
|
||||
list, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
// A malformed security value is treated as undeclared rather than guessed at.
|
||||
return opSecurity{state: secUndeclared}
|
||||
}
|
||||
|
||||
if len(list) == 0 {
|
||||
// Empty at the operation level opts out of auth; inherited-empty just
|
||||
// means no spec-level default applies.
|
||||
if fromOp {
|
||||
return opSecurity{state: secPublic}
|
||||
}
|
||||
return opSecurity{state: secUndeclared}
|
||||
}
|
||||
|
||||
defined := securitySchemeSource(spec)
|
||||
|
||||
s := opSecurity{}
|
||||
seen := map[string]bool{}
|
||||
hasAnonymous := false
|
||||
for _, item := range list {
|
||||
obj, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(obj) == 0 {
|
||||
hasAnonymous = true
|
||||
continue
|
||||
}
|
||||
// Map order is not stable, so sort the ANDed scheme names for
|
||||
// deterministic labels and summary output.
|
||||
var alt []string
|
||||
for _, name := range slices.Sorted(maps.Keys(obj)) {
|
||||
alt = append(alt, name)
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
s.schemes = append(s.schemes, name)
|
||||
if defined[name] == nil {
|
||||
s.undefined = append(s.undefined, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(alt) > 0 {
|
||||
s.alternatives = append(s.alternatives, alt)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(s.alternatives) > 0 && hasAnonymous:
|
||||
s.state = secOptional
|
||||
case len(s.alternatives) > 0:
|
||||
s.state = secRequired
|
||||
case hasAnonymous:
|
||||
// Only empty {} objects: anonymous access with no scheme required.
|
||||
s.state = secPublic
|
||||
default:
|
||||
s.state = secUndeclared
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// label renders the effective requirement as a compact string: scheme names are
|
||||
// joined with "+" within an AND requirement and " | " across OR alternatives.
|
||||
// Public operations read "public"; undeclared ones yield an empty string so the
|
||||
// caller can omit the field entirely.
|
||||
func (s opSecurity) label() string {
|
||||
switch s.state {
|
||||
case secPublic:
|
||||
return "public"
|
||||
case secUndeclared:
|
||||
return ""
|
||||
}
|
||||
|
||||
alts := make([]string, 0, len(s.alternatives))
|
||||
for _, alt := range s.alternatives {
|
||||
alts = append(alts, strings.Join(alt, "+"))
|
||||
}
|
||||
joined := strings.Join(alts, " | ")
|
||||
if s.state == secOptional {
|
||||
return "optional: " + joined
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
// SummarizeOperationSecurity prints, to stderr, the effective security
|
||||
// requirement of every declared operation. It is computed from the spec alone
|
||||
// (no requests), so it runs for automate, endpoints, and prepare alike, and it
|
||||
// stays off stdout to keep that stream pipeable.
|
||||
func SummarizeOperationSecurity(spec map[string]interface{}) {
|
||||
paths, ok := spec["paths"].(map[string]interface{})
|
||||
if !ok || len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
type row struct {
|
||||
method string
|
||||
path string
|
||||
sec opSecurity
|
||||
}
|
||||
var rows []row
|
||||
for _, pathName := range slices.Sorted(maps.Keys(paths)) {
|
||||
pathItem, ok := paths[pathName].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, method := range slices.Sorted(maps.Keys(pathItem)) {
|
||||
if !httpMethods[strings.ToLower(method)] {
|
||||
continue
|
||||
}
|
||||
opMap, ok := pathItem[method].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row{strings.ToUpper(method), pathName, resolveOperationSecurity(spec, opMap)})
|
||||
}
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
printInfo("Operation security requirements:\n")
|
||||
for _, r := range rows {
|
||||
switch r.sec.state {
|
||||
case secRequired:
|
||||
printInfo(" %s %-6s %s requires: %s\n", red("✗"), r.method, r.path, r.sec.label())
|
||||
case secOptional:
|
||||
printInfo(" %s %-6s %s %s\n", yellow("⚠"), r.method, r.path, r.sec.label())
|
||||
case secPublic:
|
||||
printInfo(" %s %-6s %s PUBLIC (security: [])\n", yellow("⚠"), r.method, r.path)
|
||||
default:
|
||||
printInfo(" %s %-6s %s none declared\n", faint("-"), r.method, r.path)
|
||||
}
|
||||
if len(r.sec.undefined) > 0 {
|
||||
printWarn("%s %s references undefined scheme(s): %s", r.method, r.path, strings.Join(r.sec.undefined, ", "))
|
||||
}
|
||||
}
|
||||
printInfo("\n")
|
||||
}
|
||||
|
||||
// requirementSatisfiedByCredentials reports whether a supplied credential
|
||||
// already satisfies at least one of the operation's requirement alternatives, so
|
||||
// the missing-auth flag can be suppressed on authenticated runs. It checks the
|
||||
// base credential state (headers snapshotted before per-operation mutation, plus
|
||||
// the query/cookie auth globals) against each scheme's definition.
|
||||
func requirementSatisfiedByCredentials(s opSecurity, spec map[string]interface{}, userHeaders []string) bool {
|
||||
if len(s.alternatives) == 0 {
|
||||
return false
|
||||
}
|
||||
defined := securitySchemeSource(spec)
|
||||
for _, alt := range s.alternatives {
|
||||
satisfied := true
|
||||
for _, name := range alt {
|
||||
if !credentialSuppliedForScheme(defined, name, userHeaders) {
|
||||
satisfied = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if satisfied {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// credentialSuppliedForScheme reports whether a credential matching one named
|
||||
// scheme was supplied, by consulting the scheme's definition for where it lives
|
||||
// (header/bearer/basic in Headers, apiKey in query/cookie/header).
|
||||
func credentialSuppliedForScheme(defined map[string]interface{}, name string, userHeaders []string) bool {
|
||||
scheme, _ := defined[name].(map[string]interface{})
|
||||
if scheme == nil {
|
||||
return false
|
||||
}
|
||||
switch typ, _ := scheme["type"].(string); typ {
|
||||
case "http":
|
||||
switch s, _ := scheme["scheme"].(string); strings.ToLower(s) {
|
||||
case "bearer":
|
||||
return headerValuePrefixPresent(userHeaders, "authorization", "bearer ")
|
||||
case "basic":
|
||||
return headerValuePrefixPresent(userHeaders, "authorization", "basic ")
|
||||
default:
|
||||
// An unrecognized http scheme: any Authorization header counts.
|
||||
return headerPresent(userHeaders, "authorization")
|
||||
}
|
||||
case "apiKey":
|
||||
keyName, _ := scheme["name"].(string)
|
||||
if keyName == "" {
|
||||
return false
|
||||
}
|
||||
switch inVal, _ := scheme["in"].(string); inVal {
|
||||
case "header":
|
||||
return headerPresent(userHeaders, keyName)
|
||||
case "query":
|
||||
return slices.ContainsFunc(authQueryParams, func(p queryAuthParam) bool {
|
||||
return strings.EqualFold(p.name, keyName)
|
||||
})
|
||||
case "cookie":
|
||||
return slices.ContainsFunc(authCookieParams, func(p queryAuthParam) bool {
|
||||
return strings.EqualFold(p.name, keyName)
|
||||
})
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitHeader splits a "Key: Value" header entry, reporting whether a colon was
|
||||
// found. Key and value are trimmed.
|
||||
func splitHeader(h string) (key, value string, ok bool) {
|
||||
idx := strings.Index(h, ":")
|
||||
if idx == -1 {
|
||||
return "", "", false
|
||||
}
|
||||
return strings.TrimSpace(h[:idx]), strings.TrimSpace(h[idx+1:]), true
|
||||
}
|
||||
|
||||
// headerPresent reports whether headers carries a non-empty value for name
|
||||
// (case-insensitive key match).
|
||||
func headerPresent(headers []string, name string) bool {
|
||||
for _, h := range headers {
|
||||
if k, v, ok := splitHeader(h); ok && strings.EqualFold(k, name) && v != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// headerValuePrefixPresent reports whether headers carries name with a value
|
||||
// beginning valuePrefix (both compared case-insensitively).
|
||||
func headerValuePrefixPresent(headers []string, name, valuePrefix string) bool {
|
||||
for _, h := range headers {
|
||||
if k, v, ok := splitHeader(h); ok && strings.EqualFold(k, name) {
|
||||
if strings.HasPrefix(strings.ToLower(v), valuePrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -57,3 +57,413 @@ func TestCheckSecuritySchemesJSONDiagnostics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckSecuritySchemesSwaggerV2 verifies that Swagger 2.0 schemes defined
|
||||
// under the top-level securityDefinitions object are read, not only the
|
||||
// OpenAPI 3.x components.securitySchemes location.
|
||||
func TestCheckSecuritySchemesSwaggerV2(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{}{
|
||||
"swagger": "2.0",
|
||||
"securityDefinitions": map[string]interface{}{
|
||||
"apiKeyHeader": map[string]interface{}{
|
||||
"type": "apiKey",
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
},
|
||||
"basicAuth": map[string]interface{}{
|
||||
"type": "basic",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
stderrStr := string(stderrBytes)
|
||||
if strings.Contains(stderrStr, "No security schemes defined") {
|
||||
t.Fatalf("Swagger 2.0 securityDefinitions were not read: %q", stderrStr)
|
||||
}
|
||||
for _, expected := range []string{"Found security schemes", "apiKeyHeader", "basicAuth"} {
|
||||
if !strings.Contains(stderrStr, expected) {
|
||||
t.Errorf("stderr does not contain %q: %q", expected, stderrBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runCheckSecuritySchemes runs CheckSecuritySchemes in quiet/json mode with
|
||||
// stdout and stderr captured, and saves/restores every global the function
|
||||
// mutates so tests cannot pollute one another. It returns captured stdout and
|
||||
// stderr.
|
||||
func runCheckSecuritySchemes(t *testing.T, spec map[string]interface{}) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
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
|
||||
previousHeaders := Headers
|
||||
previousQueryParams := authQueryParams
|
||||
previousCookieParams := authCookieParams
|
||||
defer func() {
|
||||
os.Stdout, os.Stderr = previousStdout, previousStderr
|
||||
outputFormat, quiet = previousFormat, previousQuiet
|
||||
Headers = previousHeaders
|
||||
authQueryParams = previousQueryParams
|
||||
authCookieParams = previousCookieParams
|
||||
stdout.Close()
|
||||
stderr.Close()
|
||||
}()
|
||||
os.Stdout, os.Stderr = stdout, stderr
|
||||
outputFormat, quiet = "json", true
|
||||
// Start from a clean credential state so length assertions are meaningful.
|
||||
Headers = nil
|
||||
authQueryParams = nil
|
||||
authCookieParams = nil
|
||||
|
||||
CheckSecuritySchemes(spec)
|
||||
|
||||
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)
|
||||
}
|
||||
return string(stdoutBytes), string(stderrBytes)
|
||||
}
|
||||
|
||||
// TestCheckSecuritySchemesBearerQuiet verifies that a spec-compliant OpenAPI 3.x
|
||||
// bearer scheme (type: http, scheme: bearer) is warn-only in quiet mode and does
|
||||
// not attach an Authorization header without interactive input.
|
||||
func TestCheckSecuritySchemesBearerQuiet(t *testing.T) {
|
||||
_, stderr := runCheckSecuritySchemes(t, map[string]interface{}{
|
||||
"components": map[string]interface{}{
|
||||
"securitySchemes": map[string]interface{}{
|
||||
"ExampleBearer": map[string]interface{}{
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if !strings.Contains(stderr, "A bearer token is accepted") {
|
||||
t.Errorf("expected bearer warning, got: %q", stderr)
|
||||
}
|
||||
if len(Headers) != 0 {
|
||||
t.Errorf("quiet mode must not attach credentials, got Headers=%v", Headers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckSecuritySchemesBearerCasing verifies the HTTP scheme match is
|
||||
// case-insensitive: a capitalized "Bearer" is still recognized and warned about.
|
||||
// Under the previous exact-match switch no branch ran and nothing was emitted.
|
||||
func TestCheckSecuritySchemesBearerCasing(t *testing.T) {
|
||||
_, stderr := runCheckSecuritySchemes(t, map[string]interface{}{
|
||||
"components": map[string]interface{}{
|
||||
"securitySchemes": map[string]interface{}{
|
||||
"ExampleBearer": map[string]interface{}{
|
||||
"type": "http",
|
||||
"scheme": "Bearer",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if !strings.Contains(stderr, "A bearer token is accepted") {
|
||||
t.Errorf("capitalized scheme should be recognized as bearer, got: %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckSecuritySchemesApiKeyNamedBearer verifies that an apiKey-in-header
|
||||
// scheme whose map key happens to be "bearer" is treated as an API key, not as
|
||||
// bearer auth. This guards against regression of the removed name-based match.
|
||||
func TestCheckSecuritySchemesApiKeyNamedBearer(t *testing.T) {
|
||||
_, stderr := runCheckSecuritySchemes(t, map[string]interface{}{
|
||||
"components": map[string]interface{}{
|
||||
"securitySchemes": map[string]interface{}{
|
||||
"bearer": map[string]interface{}{
|
||||
"type": "apiKey",
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if !strings.Contains(stderr, "An API key can be provided via the header X-API-Key") {
|
||||
t.Errorf("apiKey scheme named \"bearer\" should use the apiKey-header path, got: %q", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "A bearer token is accepted") {
|
||||
t.Errorf("apiKey scheme named \"bearer\" must not trigger the bearer path, got: %q", stderr)
|
||||
}
|
||||
if len(Headers) != 0 {
|
||||
t.Errorf("quiet mode must not attach credentials, got Headers=%v", Headers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckSecuritySchemesApiKeyCookie verifies that an apiKey-in-cookie scheme
|
||||
// is recognized and prompted for, and that quiet mode records no cookie param.
|
||||
func TestCheckSecuritySchemesApiKeyCookie(t *testing.T) {
|
||||
_, stderr := runCheckSecuritySchemes(t, map[string]interface{}{
|
||||
"components": map[string]interface{}{
|
||||
"securitySchemes": map[string]interface{}{
|
||||
"cookieKey": map[string]interface{}{
|
||||
"type": "apiKey",
|
||||
"name": "session",
|
||||
"in": "cookie",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if !strings.Contains(stderr, "An API key can be provided via the cookie session") {
|
||||
t.Errorf("apiKey in: cookie should be recognized, got: %q", stderr)
|
||||
}
|
||||
if len(authCookieParams) != 0 {
|
||||
t.Errorf("quiet mode must not record cookie params, got %v", authCookieParams)
|
||||
}
|
||||
}
|
||||
|
||||
// bearerSpec returns a spec declaring a global bearerAuth requirement and the
|
||||
// matching scheme definition, with the supplied paths spliced in.
|
||||
func bearerSpec(paths map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"openapi": "3.0.0",
|
||||
"security": []interface{}{map[string]interface{}{"bearerAuth": []interface{}{}}},
|
||||
"components": map[string]interface{}{
|
||||
"securitySchemes": map[string]interface{}{
|
||||
"bearerAuth": map[string]interface{}{"type": "http", "scheme": "bearer"},
|
||||
"apiKeyAuth": map[string]interface{}{"type": "apiKey", "name": "X-API-Key", "in": "header"},
|
||||
},
|
||||
},
|
||||
"paths": paths,
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOperationSecurity(t *testing.T) {
|
||||
spec := bearerSpec(nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
op map[string]interface{}
|
||||
wantState securityState
|
||||
wantLabel string
|
||||
undefined []string
|
||||
}{
|
||||
{
|
||||
name: "inherits global requirement",
|
||||
op: map[string]interface{}{},
|
||||
wantState: secRequired,
|
||||
wantLabel: "bearerAuth",
|
||||
},
|
||||
{
|
||||
name: "operation-level public override",
|
||||
op: map[string]interface{}{"security": []interface{}{}},
|
||||
wantState: secPublic,
|
||||
wantLabel: "public",
|
||||
},
|
||||
{
|
||||
name: "operation-level different scheme",
|
||||
op: map[string]interface{}{"security": []interface{}{
|
||||
map[string]interface{}{"apiKeyAuth": []interface{}{}},
|
||||
}},
|
||||
wantState: secRequired,
|
||||
wantLabel: "apiKeyAuth",
|
||||
},
|
||||
{
|
||||
name: "optional when anonymous entry present",
|
||||
op: map[string]interface{}{"security": []interface{}{
|
||||
map[string]interface{}{},
|
||||
map[string]interface{}{"bearerAuth": []interface{}{}},
|
||||
}},
|
||||
wantState: secOptional,
|
||||
wantLabel: "optional: bearerAuth",
|
||||
},
|
||||
{
|
||||
name: "references undefined scheme",
|
||||
op: map[string]interface{}{"security": []interface{}{
|
||||
map[string]interface{}{"oldKey": []interface{}{}},
|
||||
}},
|
||||
wantState: secRequired,
|
||||
wantLabel: "oldKey",
|
||||
undefined: []string{"oldKey"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := resolveOperationSecurity(spec, tc.op)
|
||||
if got.state != tc.wantState {
|
||||
t.Errorf("state = %d, want %d", got.state, tc.wantState)
|
||||
}
|
||||
if got.label() != tc.wantLabel {
|
||||
t.Errorf("label = %q, want %q", got.label(), tc.wantLabel)
|
||||
}
|
||||
if strings.Join(got.undefined, ",") != strings.Join(tc.undefined, ",") {
|
||||
t.Errorf("undefined = %v, want %v", got.undefined, tc.undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOperationSecurityUndeclared verifies that with no spec-level and no
|
||||
// operation-level security, the operation is undeclared and yields no label.
|
||||
func TestResolveOperationSecurityUndeclared(t *testing.T) {
|
||||
spec := map[string]interface{}{"openapi": "3.0.0", "paths": map[string]interface{}{}}
|
||||
got := resolveOperationSecurity(spec, map[string]interface{}{})
|
||||
if got.state != secUndeclared {
|
||||
t.Errorf("state = %d, want secUndeclared", got.state)
|
||||
}
|
||||
if got.label() != "" {
|
||||
t.Errorf("label = %q, want empty", got.label())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirementSatisfiedByCredentials(t *testing.T) {
|
||||
spec := bearerSpec(nil)
|
||||
bearerOp := resolveOperationSecurity(spec, map[string]interface{}{})
|
||||
|
||||
if requirementSatisfiedByCredentials(bearerOp, spec, nil) {
|
||||
t.Error("bearer requirement must not be satisfied with no headers")
|
||||
}
|
||||
if !requirementSatisfiedByCredentials(bearerOp, spec, []string{"Authorization: Bearer abc"}) {
|
||||
t.Error("bearer requirement should be satisfied by an Authorization: Bearer header")
|
||||
}
|
||||
// A basic header does not satisfy a bearer requirement.
|
||||
if requirementSatisfiedByCredentials(bearerOp, spec, []string{"Authorization: Basic abc"}) {
|
||||
t.Error("basic header must not satisfy a bearer requirement")
|
||||
}
|
||||
|
||||
// apiKey-in-query requirement satisfied via authQueryParams.
|
||||
querySpec := map[string]interface{}{
|
||||
"components": map[string]interface{}{
|
||||
"securitySchemes": map[string]interface{}{
|
||||
"qk": map[string]interface{}{"type": "apiKey", "name": "api_key", "in": "query"},
|
||||
},
|
||||
},
|
||||
}
|
||||
queryOp := resolveOperationSecurity(querySpec, map[string]interface{}{
|
||||
"security": []interface{}{map[string]interface{}{"qk": []interface{}{}}},
|
||||
})
|
||||
|
||||
previousQueryParams := authQueryParams
|
||||
defer func() { authQueryParams = previousQueryParams }()
|
||||
|
||||
authQueryParams = nil
|
||||
if requirementSatisfiedByCredentials(queryOp, querySpec, nil) {
|
||||
t.Error("query apiKey requirement must not be satisfied with no params")
|
||||
}
|
||||
authQueryParams = []queryAuthParam{{name: "api_key", value: "x"}}
|
||||
if !requirementSatisfiedByCredentials(queryOp, querySpec, nil) {
|
||||
t.Error("query apiKey requirement should be satisfied by a recorded query param")
|
||||
}
|
||||
}
|
||||
|
||||
// runSummarizeOperationSecurity captures stdout and stderr around a call to
|
||||
// SummarizeOperationSecurity, restoring the streams afterward.
|
||||
func runSummarizeOperationSecurity(t *testing.T, spec map[string]interface{}) (stdout, stderr string) {
|
||||
t.Helper()
|
||||
|
||||
outFile, err := os.CreateTemp(t.TempDir(), "stdout")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errFile, err := os.CreateTemp(t.TempDir(), "stderr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
previousStdout, previousStderr := os.Stdout, os.Stderr
|
||||
defer func() {
|
||||
os.Stdout, os.Stderr = previousStdout, previousStderr
|
||||
outFile.Close()
|
||||
errFile.Close()
|
||||
}()
|
||||
os.Stdout, os.Stderr = outFile, errFile
|
||||
|
||||
SummarizeOperationSecurity(spec)
|
||||
|
||||
outBytes, err := os.ReadFile(outFile.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errBytes, err := os.ReadFile(errFile.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(outBytes), string(errBytes)
|
||||
}
|
||||
|
||||
func TestSummarizeOperationSecurity(t *testing.T) {
|
||||
spec := bearerSpec(map[string]interface{}{
|
||||
"/users": map[string]interface{}{
|
||||
"get": map[string]interface{}{},
|
||||
},
|
||||
"/health": map[string]interface{}{
|
||||
"get": map[string]interface{}{"security": []interface{}{}},
|
||||
},
|
||||
"/legacy": map[string]interface{}{
|
||||
"post": map[string]interface{}{"security": []interface{}{
|
||||
map[string]interface{}{"oldKey": []interface{}{}},
|
||||
}},
|
||||
},
|
||||
})
|
||||
|
||||
stdout, stderr := runSummarizeOperationSecurity(t, spec)
|
||||
|
||||
if stdout != "" {
|
||||
t.Fatalf("summary written to stdout: %q", stdout)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"Operation security requirements",
|
||||
"requires: bearerAuth",
|
||||
"PUBLIC",
|
||||
"references undefined scheme",
|
||||
"oldKey",
|
||||
} {
|
||||
if !strings.Contains(stderr, expected) {
|
||||
t.Errorf("stderr does not contain %q: %q", expected, stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-7
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -22,10 +23,10 @@ var automateCmd = &cobra.Command{
|
||||
Long: `The automate command sends a request to each discovered endpoint and returns the status code of the result.
|
||||
This enables the user to get a quick look at which endpoints require authentication and which ones do not. If a request
|
||||
responds in an abnormal way, manual testing should be conducted (prepare manual tests using the "prepare" command).`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if outfile != "" && strings.ToLower(outputFormat) != "" {
|
||||
if !strings.HasSuffix(strings.ToLower(outfile), "json") && strings.ToLower(outputFormat) != "json" {
|
||||
die("Only the JSON output format is supported at the moment.")
|
||||
return fmt.Errorf("only the JSON output format is supported at the moment")
|
||||
} else if strings.HasSuffix(strings.ToLower(outfile), "json") && strings.ToLower(outputFormat) == "console" {
|
||||
outputFormat = "json"
|
||||
}
|
||||
@@ -45,12 +46,15 @@ responds in an abnormal way, manual testing should be conducted (prepare manual
|
||||
|
||||
_, err := time.Parse("2006-01-02", customDate)
|
||||
if err != nil {
|
||||
die("An invalid date was supplied. Please supply a date in '2006-01-02' format.")
|
||||
return fmt.Errorf("an invalid date was supplied. Please supply a date in '2006-01-02' format")
|
||||
}
|
||||
|
||||
var bodyBytes []byte
|
||||
|
||||
client, replayClient := CheckAndConfigureProxy()
|
||||
client, replayClient, err := CheckAndConfigureProxy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.ToLower(outputFormat) != "json" {
|
||||
printInfo("\n")
|
||||
@@ -58,11 +62,14 @@ responds in an abnormal way, manual testing should be conducted (prepare manual
|
||||
}
|
||||
|
||||
if swaggerURL != "" {
|
||||
bodyBytes, _, _ = MakeRequest(client, "GET", swaggerURL, timeout, nil)
|
||||
bodyBytes, _, _, err = MakeRequest(client, "GET", swaggerURL, timeout, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
specFile, err := os.Open(localFile)
|
||||
if err != nil {
|
||||
die("Error opening file: %v", err)
|
||||
return fmt.Errorf("error opening file: %v", err)
|
||||
}
|
||||
// Set the base directory for resolving external refs
|
||||
specBaseDir = filepath.Dir(localFile)
|
||||
@@ -79,7 +86,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, replayClient)
|
||||
return GenerateRequests(bodyBytes, client, replayClient)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"mime/multipart"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The content types sj can encode a request body for.
|
||||
const (
|
||||
ctJSON = "application/json"
|
||||
ctForm = "application/x-www-form-urlencoded"
|
||||
ctMultipart = "multipart/form-data"
|
||||
ctXML = "application/xml"
|
||||
)
|
||||
|
||||
// bodyVariant is one declared request-body content type together with the exact
|
||||
// bytes sj would send for it. An operation that declares several content types
|
||||
// yields several variants and none is discarded, so the operator picks which
|
||||
// one goes on the wire rather than a map iteration picking for them.
|
||||
type bodyVariant struct {
|
||||
declared string // normalized spec key, e.g. "multipart/form-data"
|
||||
contentType string // wire Content-Type; multipart carries the boundary
|
||||
body string // exact bytes to send
|
||||
curlArgs string // body arguments only: " -d <quoted>" or the " -F ..." set
|
||||
curlBodyArg string // shell-quoted body, for the sqlmap --data rewrite; "" for multipart
|
||||
omitCurlCT bool // multipart only: curl picks its own boundary, so print no -H
|
||||
}
|
||||
|
||||
// normalizeContentType lowercases a declared content key and drops its
|
||||
// parameters, so "Application/JSON; charset=utf-8" is recognized as JSON.
|
||||
// Media types are case-insensitive and may carry parameters, but the encoder
|
||||
// lookup is an exact match.
|
||||
func normalizeContentType(key string) string {
|
||||
if idx := strings.Index(key, ";"); idx >= 0 {
|
||||
key = key[:idx]
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(key))
|
||||
}
|
||||
|
||||
// contentTypePriority ranks the body encodings sj can produce so that a run
|
||||
// sending a single variant picks the one most likely to be accepted. Lower
|
||||
// sorts first; a type sj has no encoder for reports false and yields no
|
||||
// variant, rather than a Content-Type header promising a body that was never
|
||||
// built.
|
||||
func contentTypePriority(declared string) (int, bool) {
|
||||
switch {
|
||||
// "*/*" is what springdoc and swagger-codegen emit for an untyped body.
|
||||
// Treating it as JSON keeps those specs producing a body instead of
|
||||
// regressing them to a bodiless request.
|
||||
case declared == ctJSON || declared == "*/*" || strings.HasSuffix(declared, "+json"):
|
||||
return 0, true
|
||||
case declared == ctForm:
|
||||
return 1, true
|
||||
case declared == ctMultipart:
|
||||
return 2, true
|
||||
case declared == ctXML || declared == "text/xml" || strings.HasSuffix(declared, "+xml"):
|
||||
return 3, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// newTextBodyVariant assembles a variant whose body is sent verbatim as a
|
||||
// single -d argument.
|
||||
func newTextBodyVariant(declared, wireType, body string) bodyVariant {
|
||||
quoted := shellSingleQuote(body)
|
||||
return bodyVariant{
|
||||
declared: declared,
|
||||
contentType: wireType,
|
||||
body: body,
|
||||
curlArgs: " -d " + quoted,
|
||||
curlBodyArg: quoted,
|
||||
}
|
||||
}
|
||||
|
||||
// newMultipartBodyVariant encodes obj as a multipart body. The wire
|
||||
// Content-Type carries sj's boundary, but the printed command gets only -F
|
||||
// arguments and no -H: curl generates a boundary of its own, so echoing sj's
|
||||
// would print a command that cannot reproduce the request.
|
||||
func newMultipartBodyVariant(declared string, obj map[string]interface{}) bodyVariant {
|
||||
keys := slices.Sorted(maps.Keys(obj))
|
||||
var buf bytes.Buffer
|
||||
var args strings.Builder
|
||||
mw := multipart.NewWriter(&buf)
|
||||
for _, k := range keys {
|
||||
_ = mw.WriteField(k, fmt.Sprintf("%v", obj[k]))
|
||||
args.WriteString(" -F " + shellSingleQuote(fmt.Sprintf("%s=%v", k, obj[k])))
|
||||
}
|
||||
_ = mw.Close()
|
||||
|
||||
return bodyVariant{
|
||||
declared: declared,
|
||||
contentType: mw.FormDataContentType(),
|
||||
body: buf.String(),
|
||||
curlArgs: args.String(),
|
||||
omitCurlCT: true,
|
||||
}
|
||||
}
|
||||
|
||||
// buildVariant encodes example under the declared content type. It reports
|
||||
// false for a type sj has no encoder for, and for a structured type whose
|
||||
// example is not an object: only JSON can carry a bare array or scalar body.
|
||||
func buildVariant(declared string, example interface{}) (bodyVariant, bool) {
|
||||
priority, ok := contentTypePriority(declared)
|
||||
if !ok {
|
||||
return bodyVariant{}, false
|
||||
}
|
||||
|
||||
if priority == 0 {
|
||||
encoded, err := json.Marshal(example)
|
||||
if err != nil {
|
||||
return bodyVariant{}, false
|
||||
}
|
||||
// "*/*" names no real media type, so send the body under the type it
|
||||
// was actually encoded as. A vendor type such as
|
||||
// "application/vnd.api+json" is kept as declared.
|
||||
wireType := declared
|
||||
if declared == "*/*" {
|
||||
wireType = ctJSON
|
||||
}
|
||||
return newTextBodyVariant(declared, wireType, string(encoded)), true
|
||||
}
|
||||
|
||||
obj, ok := example.(map[string]interface{})
|
||||
if !ok {
|
||||
return bodyVariant{}, false
|
||||
}
|
||||
|
||||
switch priority {
|
||||
case 1:
|
||||
return newTextBodyVariant(declared, declared, encodeFormBody(obj)), true
|
||||
case 2:
|
||||
return newMultipartBodyVariant(declared, obj), true
|
||||
case 3:
|
||||
return newTextBodyVariant(declared, declared, XmlFromObject(obj)), true
|
||||
}
|
||||
return bodyVariant{}, false
|
||||
}
|
||||
|
||||
// buildBodyVariants encodes every content type an OpenAPI v3 requestBody
|
||||
// declares. It mutates no package state and appends to no curl string, so the
|
||||
// bytes for each content type are built independently of how the request is
|
||||
// eventually emitted.
|
||||
func buildBodyVariants(spec, reqBody, ctxSpec map[string]interface{}) []bodyVariant {
|
||||
contentTypes, ok := reqBody["content"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var variants []bodyVariant
|
||||
// Sorted rather than ranged: a bare map range over the content types made
|
||||
// which body sj sent vary from one run to the next.
|
||||
for _, cType := range slices.Sorted(maps.Keys(contentTypes)) {
|
||||
ct, ok := contentTypes[cType].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
schema, ok := ct["schema"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
example := GenerateExample(ExpandSchema(spec, schema, map[string]bool{}, ctxSpec))
|
||||
if variant, ok := buildVariant(normalizeContentType(cType), example); ok {
|
||||
variants = append(variants, variant)
|
||||
}
|
||||
}
|
||||
return variants
|
||||
}
|
||||
|
||||
// orderVariants dedupes by declared type and orders by preference. The
|
||||
// comparison falls through to the type name so the result does not depend on
|
||||
// the order the variants were collected in: ranking alone leaves ties
|
||||
// (application/xml and text/xml share a rank) that would otherwise reintroduce
|
||||
// the nondeterminism this replaced.
|
||||
func orderVariants(variants []bodyVariant) []bodyVariant {
|
||||
seen := make(map[string]bool, len(variants))
|
||||
deduped := make([]bodyVariant, 0, len(variants))
|
||||
for _, v := range variants {
|
||||
if seen[v.declared] {
|
||||
continue
|
||||
}
|
||||
seen[v.declared] = true
|
||||
deduped = append(deduped, v)
|
||||
}
|
||||
|
||||
slices.SortFunc(deduped, func(a, b bodyVariant) int {
|
||||
pa, _ := contentTypePriority(a.declared)
|
||||
pb, _ := contentTypePriority(b.declared)
|
||||
if pa != pb {
|
||||
return pa - pb
|
||||
}
|
||||
return strings.Compare(a.declared, b.declared)
|
||||
})
|
||||
return deduped
|
||||
}
|
||||
|
||||
// forcedContentTypeWarned keys the "not declared" notice by the set of types
|
||||
// the operation declared, so each distinct message is shown once. printWarn
|
||||
// ignores quiet, and a large spec would otherwise repeat an identical warning
|
||||
// for every operation that shares the same declared types.
|
||||
var forcedContentTypeWarned = map[string]bool{}
|
||||
|
||||
// warnForcedContentType reports, at most once per distinct set of declared
|
||||
// types, that the operator's Content-Type is not among them.
|
||||
func warnForcedContentType(forced, sending string, variants []bodyVariant, relabeled bool) {
|
||||
declared := declaredTypes(variants)
|
||||
if forcedContentTypeWarned[declared] {
|
||||
return
|
||||
}
|
||||
forcedContentTypeWarned[declared] = true
|
||||
|
||||
if relabeled {
|
||||
printWarn("Content-Type '%s' is not declared by operations offering [%s]; sending their %s body under it.", forced, declared, sending)
|
||||
return
|
||||
}
|
||||
printWarn("Content-Type '%s' is not declared by operations offering [%s], and a multipart body cannot be relabeled; sending it as %s.", forced, declared, sending)
|
||||
}
|
||||
|
||||
// declaredTypes lists the variants' content types for an operator-facing message.
|
||||
func declaredTypes(variants []bodyVariant) string {
|
||||
names := make([]string, 0, len(variants))
|
||||
for _, v := range variants {
|
||||
names = append(names, v.declared)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// selectVariants narrows the declared variants to the ones sj will actually
|
||||
// send. Every declared type survives in variants; this is the single place the
|
||||
// operator's choice is applied.
|
||||
func selectVariants(variants []bodyVariant, forced string, all bool) []bodyVariant {
|
||||
if len(variants) == 0 {
|
||||
return nil
|
||||
}
|
||||
if all {
|
||||
return variants
|
||||
}
|
||||
if forced == "" {
|
||||
return variants[:1]
|
||||
}
|
||||
|
||||
wanted := normalizeContentType(forced)
|
||||
for _, v := range variants {
|
||||
if v.declared == wanted {
|
||||
// Matched, so the header and the body now agree. The wire type
|
||||
// stays the variant's own: a multipart match keeps its boundary
|
||||
// rather than the operator's boundary-less string, which would
|
||||
// make the body unparseable.
|
||||
return []bodyVariant{v}
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing declared matches, so the operator is deliberately sending a type
|
||||
// this operation does not document. Keep a body but ship it under their
|
||||
// header, on the wire and in the printed command alike. A multipart body
|
||||
// cannot be relabeled -- its boundary only means anything under
|
||||
// multipart/form-data -- so prefer any other variant to carry the label.
|
||||
choice := slices.IndexFunc(variants, func(v bodyVariant) bool {
|
||||
return v.declared != ctMultipart
|
||||
})
|
||||
if choice == -1 {
|
||||
warnForcedContentType(forced, variants[0].declared, variants, false)
|
||||
return variants[:1]
|
||||
}
|
||||
|
||||
warnForcedContentType(forced, variants[choice].declared, variants, true)
|
||||
mislabeled := variants[choice]
|
||||
mislabeled.contentType = forced
|
||||
return []bodyVariant{mislabeled}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeContentType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"application/json": "application/json",
|
||||
"Application/JSON": "application/json",
|
||||
"application/json; charset=utf-8": "application/json",
|
||||
" application/xml ": "application/xml",
|
||||
"multipart/form-data; boundary=ab": "multipart/form-data",
|
||||
"*/*": "*/*",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeContentType(in); got != want {
|
||||
t.Errorf("normalizeContentType(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentTypePriority(t *testing.T) {
|
||||
encodable := map[string]int{
|
||||
"application/json": 0,
|
||||
"application/vnd.api+json": 0,
|
||||
"application/problem+json": 0,
|
||||
"*/*": 0,
|
||||
"application/x-www-form-urlencoded": 1,
|
||||
"multipart/form-data": 2,
|
||||
"application/xml": 3,
|
||||
"text/xml": 3,
|
||||
"application/xhtml+xml": 3,
|
||||
}
|
||||
for ct, want := range encodable {
|
||||
got, ok := contentTypePriority(ct)
|
||||
if !ok {
|
||||
t.Errorf("contentTypePriority(%q): expected an encoder", ct)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("contentTypePriority(%q) = %d, want %d", ct, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A type sj cannot encode must yield no variant rather than a Content-Type
|
||||
// header promising a body that was never built.
|
||||
for _, ct := range []string{"application/octet-stream", "text/plain", "image/png"} {
|
||||
if _, ok := contentTypePriority(ct); ok {
|
||||
t.Errorf("contentTypePriority(%q): expected no encoder", ct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// multiTypeSpec is an operation declaring every encodable type plus one sj has
|
||||
// no encoder for.
|
||||
func multiTypeSpec() (map[string]interface{}, map[string]interface{}) {
|
||||
schema := map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
}
|
||||
reqBody := map[string]interface{}{
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{"schema": schema},
|
||||
"application/xml": map[string]interface{}{"schema": schema},
|
||||
"application/x-www-form-urlencoded": map[string]interface{}{"schema": schema},
|
||||
"multipart/form-data": map[string]interface{}{"schema": schema},
|
||||
"application/octet-stream": map[string]interface{}{"schema": schema},
|
||||
},
|
||||
}
|
||||
return map[string]interface{}{}, reqBody
|
||||
}
|
||||
|
||||
func declaredOrder(variants []bodyVariant) []string {
|
||||
out := make([]string, 0, len(variants))
|
||||
for _, v := range variants {
|
||||
out = append(out, v.declared)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBuildBodyVariantsKeepsEveryEncodableType(t *testing.T) {
|
||||
oldTestString := testString
|
||||
defer func() { testString = oldTestString }()
|
||||
testString = "bishopfox"
|
||||
|
||||
spec, reqBody := multiTypeSpec()
|
||||
variants := orderVariants(buildBodyVariants(spec, reqBody, spec))
|
||||
|
||||
want := []string{"application/json", "application/x-www-form-urlencoded", "multipart/form-data", "application/xml"}
|
||||
if got := declaredOrder(variants); !slices.Equal(got, want) {
|
||||
t.Fatalf("declared variants = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
byType := map[string]bodyVariant{}
|
||||
for _, v := range variants {
|
||||
byType[v.declared] = v
|
||||
}
|
||||
|
||||
if got, want := byType["application/json"].body, `{"name":"bishopfox"}`; got != want {
|
||||
t.Errorf("json body = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := byType["application/x-www-form-urlencoded"].body, "name=bishopfox"; got != want {
|
||||
t.Errorf("urlencoded body = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := byType["application/xml"].body, "<name>bishopfox</name>"; got != want {
|
||||
t.Errorf("xml body = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Multipart owns its wire format: the boundary belongs on the request but
|
||||
// not in the printed command, and the body is never a -d argument.
|
||||
mp := byType["multipart/form-data"]
|
||||
if !strings.HasPrefix(mp.contentType, "multipart/form-data; boundary=") {
|
||||
t.Errorf("multipart Content-Type = %q, want a boundary", mp.contentType)
|
||||
}
|
||||
if !mp.omitCurlCT {
|
||||
t.Error("multipart variant should not print its own Content-Type")
|
||||
}
|
||||
if mp.curlBodyArg != "" {
|
||||
t.Errorf("multipart curlBodyArg = %q, want empty", mp.curlBodyArg)
|
||||
}
|
||||
if !strings.Contains(mp.curlArgs, "-F ") || strings.Contains(mp.curlArgs, "-d ") {
|
||||
t.Errorf("multipart curlArgs = %q, want -F and no -d", mp.curlArgs)
|
||||
}
|
||||
|
||||
// The JSON variant keeps its -d even though multipart was built alongside
|
||||
// it: the old code carried a sticky multipart flag that suppressed it.
|
||||
if j := byType["application/json"]; !strings.Contains(j.curlArgs, "-d ") || j.curlBodyArg == "" {
|
||||
t.Errorf("json curlArgs = %q, curlBodyArg = %q: want a -d argument", j.curlArgs, j.curlBodyArg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildBodyVariantsIsDeterministic is the regression test for the bare map
|
||||
// range that made which body sj sent vary from one run to the next.
|
||||
func TestBuildBodyVariantsIsDeterministic(t *testing.T) {
|
||||
oldTestString := testString
|
||||
defer func() { testString = oldTestString }()
|
||||
testString = "bishopfox"
|
||||
|
||||
spec, reqBody := multiTypeSpec()
|
||||
want := declaredOrder(orderVariants(buildBodyVariants(spec, reqBody, spec)))
|
||||
|
||||
for i := 0; i < 50; i++ {
|
||||
got := declaredOrder(orderVariants(buildBodyVariants(spec, reqBody, spec)))
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("run %d produced %v, want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrderVariantsIgnoresCollectionOrder pins that ordering comes from the
|
||||
// preference table and the type name, not from the order the variants happened
|
||||
// to be appended in: ranking alone leaves ties.
|
||||
func TestOrderVariantsIgnoresCollectionOrder(t *testing.T) {
|
||||
mk := func(types ...string) []bodyVariant {
|
||||
out := make([]bodyVariant, 0, len(types))
|
||||
for _, ct := range types {
|
||||
out = append(out, bodyVariant{declared: ct})
|
||||
}
|
||||
return out
|
||||
}
|
||||
want := []string{"application/json", "multipart/form-data", "application/xml", "text/xml"}
|
||||
|
||||
forward := declaredOrder(orderVariants(mk("application/json", "text/xml", "application/xml", "multipart/form-data")))
|
||||
reverse := declaredOrder(orderVariants(mk("multipart/form-data", "application/xml", "text/xml", "application/json")))
|
||||
|
||||
if !slices.Equal(forward, want) || !slices.Equal(reverse, want) {
|
||||
t.Errorf("orderVariants depends on input order: forward %v, reverse %v, want %v", forward, reverse, want)
|
||||
}
|
||||
|
||||
// A type declared twice is sent once.
|
||||
if got := declaredOrder(orderVariants(mk("application/json", "application/json"))); len(got) != 1 {
|
||||
t.Errorf("duplicate declared type not deduped: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectVariants(t *testing.T) {
|
||||
oldWarned := forcedContentTypeWarned
|
||||
defer func() { forcedContentTypeWarned = oldWarned }()
|
||||
|
||||
variants := []bodyVariant{
|
||||
{declared: "application/json", contentType: "application/json"},
|
||||
{declared: "multipart/form-data", contentType: "multipart/form-data; boundary=abc"},
|
||||
{declared: "application/xml", contentType: "application/xml"},
|
||||
}
|
||||
|
||||
t.Run("default takes the preferred type", func(t *testing.T) {
|
||||
forcedContentTypeWarned = map[string]bool{}
|
||||
got := selectVariants(variants, "", false)
|
||||
if len(got) != 1 || got[0].declared != "application/json" {
|
||||
t.Errorf("got %v, want a single application/json variant", declaredOrder(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all keeps every type", func(t *testing.T) {
|
||||
forcedContentTypeWarned = map[string]bool{}
|
||||
if got := selectVariants(variants, "", true); len(got) != 3 {
|
||||
t.Errorf("got %v, want all 3", declaredOrder(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a forced type selects its variant", func(t *testing.T) {
|
||||
forcedContentTypeWarned = map[string]bool{}
|
||||
got := selectVariants(variants, "application/xml", false)
|
||||
if len(got) != 1 || got[0].declared != "application/xml" {
|
||||
t.Fatalf("got %v, want application/xml", declaredOrder(got))
|
||||
}
|
||||
if got[0].contentType != "application/xml" {
|
||||
t.Errorf("wire Content-Type = %q, want application/xml", got[0].contentType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a forced type is matched despite parameters", func(t *testing.T) {
|
||||
forcedContentTypeWarned = map[string]bool{}
|
||||
got := selectVariants(variants, "Application/XML; charset=utf-8", false)
|
||||
if len(got) != 1 || got[0].declared != "application/xml" {
|
||||
t.Errorf("got %v, want application/xml", declaredOrder(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("forced multipart keeps its boundary", func(t *testing.T) {
|
||||
forcedContentTypeWarned = map[string]bool{}
|
||||
got := selectVariants(variants, "multipart/form-data", false)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %v, want one variant", declaredOrder(got))
|
||||
}
|
||||
// Honoring the operator's boundary-less string literally would send a
|
||||
// body no server could parse.
|
||||
if got[0].contentType != "multipart/form-data; boundary=abc" {
|
||||
t.Errorf("wire Content-Type = %q, want the boundary-bearing value", got[0].contentType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an undeclared type relabels the preferred body", func(t *testing.T) {
|
||||
forcedContentTypeWarned = map[string]bool{}
|
||||
got := selectVariants(variants, "text/plain", false)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %v, want one variant", declaredOrder(got))
|
||||
}
|
||||
if got[0].declared != "application/json" {
|
||||
t.Errorf("relabeled %q, want the preferred application/json body", got[0].declared)
|
||||
}
|
||||
if got[0].contentType != "text/plain" {
|
||||
t.Errorf("wire Content-Type = %q, want the operator's text/plain", got[0].contentType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a multipart-only operation is never relabeled", func(t *testing.T) {
|
||||
forcedContentTypeWarned = map[string]bool{}
|
||||
only := []bodyVariant{{declared: "multipart/form-data", contentType: "multipart/form-data; boundary=abc"}}
|
||||
got := selectVariants(only, "text/plain", false)
|
||||
if len(got) != 1 || got[0].contentType != "multipart/form-data; boundary=abc" {
|
||||
t.Errorf("multipart body was relabeled: %q", got[0].contentType)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no declared body yields no variant", func(t *testing.T) {
|
||||
forcedContentTypeWarned = map[string]bool{}
|
||||
if got := selectVariants(nil, "application/json", false); got != nil {
|
||||
t.Errorf("got %v, want nil", declaredOrder(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
+51
-33
@@ -44,7 +44,7 @@ var bruteCmd = &cobra.Command{
|
||||
Use: "brute",
|
||||
Short: "Sends a series of automated requests to discover hidden API operation definitions.",
|
||||
Long: `The brute command sends requests to the target to find operation definitions based on commonly used file locations.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
|
||||
/* // NEED TO RE-IMPLEMENT RATE LIMIT
|
||||
if rateLimit <= 0 {
|
||||
@@ -58,7 +58,10 @@ var bruteCmd = &cobra.Command{
|
||||
}
|
||||
}
|
||||
|
||||
client, _ := CheckAndConfigureProxy()
|
||||
client, _, err := CheckAndConfigureProxy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var allURLs []string
|
||||
u, err := url.Parse(swaggerURL)
|
||||
@@ -80,7 +83,7 @@ var bruteCmd = &cobra.Command{
|
||||
} else {
|
||||
endpointList, err := os.Open(endpointWordlist)
|
||||
if err != nil {
|
||||
die("failed to open file: %s", err)
|
||||
return fmt.Errorf("failed to open file: %s", err)
|
||||
}
|
||||
defer endpointList.Close()
|
||||
|
||||
@@ -92,7 +95,7 @@ var bruteCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
die("failed to read words from file: %s", err)
|
||||
return fmt.Errorf("failed to read words from file: %s", err)
|
||||
}
|
||||
}
|
||||
if rateLimit > 0 && strings.ToLower(outputFormat) != "json" {
|
||||
@@ -101,7 +104,10 @@ var bruteCmd = &cobra.Command{
|
||||
printInfo("Sending %d requests. This could take a while...\n", len(allURLs))
|
||||
}
|
||||
|
||||
specFound, definitionFile := findDefinitionFile(allURLs, client)
|
||||
specFound, definitionFile, err := findDefinitionFile(allURLs, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if specFound {
|
||||
definedOperations, err := json.Marshal(definitionFile)
|
||||
if err != nil {
|
||||
@@ -126,7 +132,7 @@ var bruteCmd = &cobra.Command{
|
||||
}
|
||||
} else {
|
||||
if endpointOnly {
|
||||
return
|
||||
return nil
|
||||
} else {
|
||||
fmt.Println(string(definedOperations))
|
||||
}
|
||||
@@ -135,6 +141,7 @@ var bruteCmd = &cobra.Command{
|
||||
} else {
|
||||
printErr("\nNo definition file found for:\t%s", swaggerURL)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -166,45 +173,52 @@ func makeURLs(target string, basePath string, endpoints []string, fileExtension
|
||||
// maxFollowDepth bounds how far brute chases discovered spec references.
|
||||
const maxFollowDepth = 3
|
||||
|
||||
func findDefinitionFile(urls []string, client http.Client) (bool, *openapi3.T) {
|
||||
func findDefinitionFile(urls []string, client http.Client) (bool, *openapi3.T, error) {
|
||||
seen := make(map[string]bool)
|
||||
for i, url := range urls {
|
||||
if found, checkSpec := checkURLForSpec(url, client, seen, 0); found {
|
||||
return true, checkSpec
|
||||
found, checkSpec, err := checkURLForSpec(url, client, seen, 0)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if found {
|
||||
return true, checkSpec, nil
|
||||
}
|
||||
writeProgress("Request: %d", i+1)
|
||||
}
|
||||
return false, nil
|
||||
return false, nil, 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) {
|
||||
func checkURLForSpec(target string, client http.Client, seen map[string]bool, depth int) (bool, *openapi3.T, error) {
|
||||
if depth > maxFollowDepth || seen[target] || isExternalDemoSpecURL(target) {
|
||||
return false, nil
|
||||
return false, nil, 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)
|
||||
bodyBytes, bodyString, _, ctRaw, _, reqErr := MakeRequestFull(client, "GET", target, timeout, nil)
|
||||
if reqErr != nil {
|
||||
return false, nil, reqErr
|
||||
}
|
||||
if bodyBytes == nil {
|
||||
return false, nil
|
||||
return false, nil, 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
|
||||
return false, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
if looksLikeChallengeResponse(bodyString) {
|
||||
warnWAFChallengeOnce()
|
||||
return false, nil
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
// Swagger UI HTML page: follow the references it declares.
|
||||
@@ -216,14 +230,14 @@ func checkURLForSpec(target string, client http.Client, seen map[string]bool, de
|
||||
// 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
|
||||
return false, nil, nil
|
||||
}
|
||||
if bodyHasEmbeddedSpec(bodyString) {
|
||||
if jsonContent, ok := ExtractJSONFromJSSpec(bodyBytes); ok {
|
||||
checkSpec := UnmarshalSpec(jsonContent)
|
||||
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
|
||||
return true, checkSpec, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,10 +254,10 @@ func checkURLForSpec(target string, client http.Client, seen map[string]bool, de
|
||||
}
|
||||
|
||||
// Otherwise treat the body as a candidate spec and validate by parsing.
|
||||
checkSpec := UnmarshalSpec(bodyBytes)
|
||||
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
|
||||
return true, checkSpec, nil
|
||||
}
|
||||
|
||||
// Not a spec: may be a swagger-config document that references one.
|
||||
@@ -251,13 +265,17 @@ func checkURLForSpec(target string, client http.Client, seen map[string]bool, de
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func followReferences(refs []string, client http.Client, seen map[string]bool, depth int) (bool, *openapi3.T, error) {
|
||||
for _, ref := range refs {
|
||||
if found, checkSpec := checkURLForSpec(ref, client, seen, depth+1); found {
|
||||
return true, checkSpec
|
||||
found, checkSpec, err := checkURLForSpec(ref, client, seen, depth+1)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if found {
|
||||
return true, checkSpec, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -357,7 +375,7 @@ func ExtractSpecFromJS(bodyBytes []byte) []byte {
|
||||
return bodyBytes
|
||||
}
|
||||
|
||||
func UnmarshalSpec(bodyBytes []byte) (newDoc *openapi3.T) {
|
||||
func UnmarshalSpec(bodyBytes []byte) (*openapi3.T, error) {
|
||||
var doc openapi2.T
|
||||
var doc3 openapi3.T
|
||||
|
||||
@@ -373,19 +391,19 @@ func UnmarshalSpec(bodyBytes []byte) (newDoc *openapi3.T) {
|
||||
_ = json.Unmarshal(bodyBytes, &doc3)
|
||||
|
||||
if strings.HasPrefix(doc3.OpenAPI, "3") {
|
||||
newDoc := &doc3
|
||||
return newDoc
|
||||
return &doc3, nil
|
||||
} else if strings.HasPrefix(doc.Swagger, "2") {
|
||||
newDoc, err := openapi2conv.ToV3(&doc)
|
||||
if err != nil {
|
||||
printErr("Error converting v2 document to v3: %s", err)
|
||||
}
|
||||
return newDoc
|
||||
} else if os.Args[1] == "brute" {
|
||||
return newDoc, nil
|
||||
} else if Mode == "brute" {
|
||||
var noDoc openapi3.T
|
||||
return &noDoc
|
||||
return &noDoc, nil
|
||||
} else {
|
||||
die("Error parsing definition file.")
|
||||
return nil
|
||||
// Return a non-nil doc alongside the error so callers that ignore the
|
||||
// error don't dereference nil.
|
||||
return &openapi3.T{}, fmt.Errorf("error parsing definition file")
|
||||
}
|
||||
}
|
||||
+14
-7
@@ -19,7 +19,7 @@ var convertCmd = &cobra.Command{
|
||||
Use: "convert",
|
||||
Short: "Converts a Swagger definition file to an OpenAPI v3 definition file.",
|
||||
Long: `The convert command converts a provided definition file from the Swagger specification (v2) to the OpenAPI specification (v3) and stores it into an output file.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
|
||||
var bodyBytes []byte
|
||||
|
||||
@@ -29,7 +29,10 @@ var convertCmd = &cobra.Command{
|
||||
}
|
||||
}
|
||||
|
||||
client, _ := CheckAndConfigureProxy()
|
||||
client, _, err := CheckAndConfigureProxy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.ToLower(outputFormat) != "json" {
|
||||
printInfo("\n")
|
||||
@@ -37,11 +40,14 @@ var convertCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
if swaggerURL != "" {
|
||||
bodyBytes, _, _ = MakeRequest(client, "GET", swaggerURL, timeout, nil)
|
||||
bodyBytes, _, _, err = MakeRequest(client, "GET", swaggerURL, timeout, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
specFile, err := os.Open(localFile)
|
||||
if err != nil {
|
||||
die("Error opening definition file: %v", err)
|
||||
return fmt.Errorf("error opening definition file: %v", err)
|
||||
}
|
||||
|
||||
bodyBytes, _ = io.ReadAll(specFile)
|
||||
@@ -75,7 +81,7 @@ var convertCmd = &cobra.Command{
|
||||
}
|
||||
converted, err := json.Marshal(newDoc)
|
||||
if err != nil {
|
||||
die("Error converting definition file to v3: %v", err)
|
||||
return fmt.Errorf("error converting definition file to v3: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(string(converted), "}") {
|
||||
if outfile == "" {
|
||||
@@ -94,7 +100,7 @@ var convertCmd = &cobra.Command{
|
||||
case "yaml", "yml":
|
||||
converted, err := yaml.Marshal(newDoc)
|
||||
if err != nil {
|
||||
die("Error converting definition file to v3: %v", err)
|
||||
return fmt.Errorf("error converting definition file to v3: %v", err)
|
||||
}
|
||||
if outfile == "" {
|
||||
fmt.Println(string(converted))
|
||||
@@ -104,8 +110,9 @@ var convertCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
} else {
|
||||
die("Error parsing definition file.")
|
||||
return fmt.Errorf("error parsing definition file")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+23
-16
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -13,14 +14,17 @@ var endpointsCmd = &cobra.Command{
|
||||
Short: "Prints a list of endpoints from the target.",
|
||||
Long: `The endpoints command allows you to pull a list of endpoints out of a Swagger definition file.
|
||||
This list contains the raw endpoints (parameter values will not be appended or modified).`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if randomUserAgent {
|
||||
if UserAgent != "Swagger Jacker (github.com/BishopFox/sj)" {
|
||||
printWarn("A supplied User Agent was detected (%s) while supplying the 'random-user-agent' flag.", UserAgent)
|
||||
}
|
||||
}
|
||||
|
||||
client, _ := CheckAndConfigureProxy()
|
||||
client, _, err := CheckAndConfigureProxy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var bodyBytes []byte
|
||||
|
||||
@@ -28,22 +32,25 @@ This list contains the raw endpoints (parameter values will not be appended or m
|
||||
printInfo("Gathering endpoints.\n\n")
|
||||
|
||||
if swaggerURL != "" {
|
||||
bodyBytes, _, _ = MakeRequest(client, "GET", swaggerURL, timeout, nil)
|
||||
GenerateRequests(bodyBytes, client, nil)
|
||||
} else {
|
||||
specFile, err := os.Open(localFile)
|
||||
bodyBytes, _, _, err = MakeRequest(client, "GET", swaggerURL, timeout, nil)
|
||||
if err != nil {
|
||||
die("Error opening file: %v", err)
|
||||
return err
|
||||
}
|
||||
// Set the base directory for resolving external refs
|
||||
specBaseDir = filepath.Dir(localFile)
|
||||
if specBaseDir == "." {
|
||||
if absPath, err := filepath.Abs(localFile); err == nil {
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
}
|
||||
}
|
||||
bodyBytes, _ = io.ReadAll(specFile)
|
||||
GenerateRequests(bodyBytes, client, nil)
|
||||
return GenerateRequests(bodyBytes, client, nil)
|
||||
}
|
||||
|
||||
specFile, err := os.Open(localFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error opening file: %v", err)
|
||||
}
|
||||
// Set the base directory for resolving external refs
|
||||
specBaseDir = filepath.Dir(localFile)
|
||||
if specBaseDir == "." {
|
||||
if absPath, err := filepath.Abs(localFile); err == nil {
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
}
|
||||
}
|
||||
bodyBytes, _ = io.ReadAll(specFile)
|
||||
return GenerateRequests(bodyBytes, client, nil)
|
||||
},
|
||||
}
|
||||
+63
-27
@@ -13,14 +13,28 @@ type Result struct {
|
||||
Method string `json:"method"`
|
||||
Status int `json:"status"`
|
||||
Target string `json:"target"`
|
||||
// ContentType names the request body encoding this result came from. It is
|
||||
// omitted unless an operation was tested under more than one, so
|
||||
// single-variant output is unchanged.
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
// Security is the effective security requirement the spec declares for this
|
||||
// operation (e.g. "bearerAuth", "public"). Omitted when undeclared.
|
||||
Security string `json:"security,omitempty"`
|
||||
// MissingAuth is set when the spec marks this operation as requiring auth but
|
||||
// it returned a 2xx with no matching credential supplied — a possible broken
|
||||
// access control finding.
|
||||
MissingAuth bool `json:"missingAuth,omitempty"`
|
||||
}
|
||||
|
||||
type VerboseResult struct {
|
||||
Method string `json:"method"`
|
||||
Preview string `json:"preview"`
|
||||
Status int `json:"status"`
|
||||
Target string `json:"target"`
|
||||
Curl string `json:"curl"`
|
||||
Method string `json:"method"`
|
||||
Preview string `json:"preview"`
|
||||
Status int `json:"status"`
|
||||
Target string `json:"target"`
|
||||
Curl string `json:"curl"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
Security string `json:"security,omitempty"`
|
||||
MissingAuth bool `json:"missingAuth,omitempty"`
|
||||
}
|
||||
|
||||
// Diagnostic helpers — all write to stderr so stdout stays clean for piping.
|
||||
@@ -66,13 +80,8 @@ func printErr(format string, args ...interface{}) {
|
||||
fmt.Fprintf(os.Stderr, "%s %s\n", red("[✗]"), msg)
|
||||
}
|
||||
|
||||
func die(format string, args ...interface{}) {
|
||||
printErr(format, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// writeLog is the main dispatch function for endpoint results.
|
||||
func writeLog(sc int, target, method, errorMsg, response string) {
|
||||
func writeLog(sc int, target, method, errorMsg, response, security string, missingAuth bool) {
|
||||
var out io.Writer = os.Stdout
|
||||
tempResponsePreviewLength := responsePreviewLength
|
||||
|
||||
@@ -83,14 +92,16 @@ func writeLog(sc int, target, method, errorMsg, response string) {
|
||||
if outfile != "" {
|
||||
file, err := os.OpenFile(outfile, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Output file does not exist or cannot be created")
|
||||
os.Exit(1)
|
||||
// Package code must not exit the process; fall back to stdout so the
|
||||
// results are still emitted.
|
||||
printErr("Output file '%s' cannot be opened; writing to stdout instead.", outfile)
|
||||
} else {
|
||||
defer file.Close()
|
||||
out = file
|
||||
// Disable color when writing to a file.
|
||||
color.NoColor = true
|
||||
defer func() { color.NoColor = false }()
|
||||
}
|
||||
defer file.Close()
|
||||
out = file
|
||||
// Disable color when writing to a file.
|
||||
color.NoColor = true
|
||||
defer func() { color.NoColor = false }()
|
||||
}
|
||||
|
||||
preview := ""
|
||||
@@ -100,20 +111,25 @@ func writeLog(sc int, target, method, errorMsg, response string) {
|
||||
|
||||
switch sc {
|
||||
case 8899:
|
||||
// -q suppresses the spec metadata, leaving only the results array.
|
||||
title, description := specTitle, specDescription
|
||||
if quiet {
|
||||
title, description = "", ""
|
||||
}
|
||||
if verbose {
|
||||
logVerboseJSON(specTitle, specDescription, out)
|
||||
logVerboseJSON(title, description, out)
|
||||
} else {
|
||||
logJSON(specTitle, specDescription, out)
|
||||
logJSON(title, description, out)
|
||||
}
|
||||
default:
|
||||
logResult(sc, target, method, errorMsg, preview, out)
|
||||
logResult(sc, target, method, errorMsg, preview, security, missingAuth, out)
|
||||
}
|
||||
|
||||
responsePreviewLength = tempResponsePreviewLength
|
||||
}
|
||||
|
||||
// logResult renders a single endpoint result line.
|
||||
func logResult(sc int, target, method, errorMsg, preview string, out io.Writer) {
|
||||
func logResult(sc int, target, method, errorMsg, preview, security string, missingAuth bool, out io.Writer) {
|
||||
var sym string
|
||||
var painter func(a ...interface{}) string
|
||||
|
||||
@@ -140,11 +156,31 @@ func logResult(sc int, target, method, errorMsg, preview string, out io.Writer)
|
||||
statusStr = "---"
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s %-7s %-3s %s\n",
|
||||
// The description the document gives for this status, when it declared one.
|
||||
// It names what the API says the response means, which is often the only
|
||||
// hint about why an endpoint refused a request.
|
||||
annotation := ""
|
||||
if errorMsg != "" {
|
||||
annotation = " " + faint(errorMsg)
|
||||
}
|
||||
|
||||
// The security requirement the spec declares for this operation, so the row
|
||||
// shows what the operator should expect even before interpreting the status.
|
||||
if security != "" {
|
||||
annotation += " " + faint("[auth: "+security+"]")
|
||||
}
|
||||
// A required operation that answered 2xx without a credential is surfaced as a
|
||||
// possible broken access control finding, not just logged as another 200.
|
||||
if missingAuth {
|
||||
annotation += " " + red(fmt.Sprintf("[!] AUTH REQUIRED but %s with no credential -> possible broken access control", statusStr))
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s %-7s %-3s %s%s\n",
|
||||
painter(sym),
|
||||
painter(method),
|
||||
painter(statusStr),
|
||||
target,
|
||||
annotation,
|
||||
)
|
||||
fmt.Fprint(out, line)
|
||||
|
||||
@@ -155,8 +191,8 @@ func logResult(sc int, target, method, errorMsg, preview string, out io.Writer)
|
||||
|
||||
func logJSON(title, description string, out io.Writer) {
|
||||
output := struct {
|
||||
APITitle string `json:"apiTitle"`
|
||||
Description string `json:"description"`
|
||||
APITitle string `json:"apiTitle,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Results []Result `json:"results"`
|
||||
}{
|
||||
APITitle: title,
|
||||
@@ -169,8 +205,8 @@ func logJSON(title, description string, out io.Writer) {
|
||||
|
||||
func logVerboseJSON(title, description string, out io.Writer) {
|
||||
output := struct {
|
||||
APITitle string `json:"apiTitle"`
|
||||
Description string `json:"description"`
|
||||
APITitle string `json:"apiTitle,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Results []VerboseResult `json:"results"`
|
||||
}{
|
||||
APITitle: title,
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// captureJSONOutput runs writeLog's JSON branch and returns the decoded stdout.
|
||||
func captureJSONOutput(t *testing.T, isQuiet, isVerbose bool) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
stdout, err := os.CreateTemp(t.TempDir(), "stdout")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stdout.Close()
|
||||
|
||||
previousStdout := os.Stdout
|
||||
previousFormat, previousQuiet, previousVerbose := outputFormat, quiet, verbose
|
||||
previousTitle, previousDescription := specTitle, specDescription
|
||||
previousResults, previousVerboseResults := jsonResultArray, jsonVerboseResultArray
|
||||
defer func() {
|
||||
os.Stdout = previousStdout
|
||||
outputFormat, quiet, verbose = previousFormat, previousQuiet, previousVerbose
|
||||
specTitle, specDescription = previousTitle, previousDescription
|
||||
jsonResultArray, jsonVerboseResultArray = previousResults, previousVerboseResults
|
||||
}()
|
||||
|
||||
os.Stdout = stdout
|
||||
outputFormat, quiet, verbose = "json", isQuiet, isVerbose
|
||||
specTitle, specDescription = "Example API", "A long spec description."
|
||||
jsonResultArray = []Result{{Method: "GET", Status: 200, Target: "/pet/1"}}
|
||||
jsonVerboseResultArray = []VerboseResult{{Method: "GET", Status: 200, Target: "/pet/1"}}
|
||||
|
||||
writeLog(8899, "", "", "", "", "", false)
|
||||
|
||||
data, err := os.ReadFile(stdout.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v (%s)", err, data)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func TestQuietOmitsSpecMetadataJSON(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
verbose bool
|
||||
}{
|
||||
{"concise", false},
|
||||
{"verbose", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
decoded := captureJSONOutput(t, true, tc.verbose)
|
||||
|
||||
if _, ok := decoded["apiTitle"]; ok {
|
||||
t.Error("apiTitle should be omitted when the quiet flag is set")
|
||||
}
|
||||
if _, ok := decoded["description"]; ok {
|
||||
t.Error("description should be omitted when the quiet flag is set")
|
||||
}
|
||||
|
||||
results, ok := decoded["results"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("results missing or wrong type: %#v", decoded["results"])
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Errorf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecMetadataPresentWithoutQuiet(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
verbose bool
|
||||
}{
|
||||
{"concise", false},
|
||||
{"verbose", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
decoded := captureJSONOutput(t, false, tc.verbose)
|
||||
|
||||
if decoded["apiTitle"] != "Example API" {
|
||||
t.Errorf("expected apiTitle to be present, got %#v", decoded["apiTitle"])
|
||||
}
|
||||
if decoded["description"] != "A long spec description." {
|
||||
t.Errorf("expected description to be present, got %#v", decoded["description"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintSpecInfoQuietSuppressesConsoleOutput(t *testing.T) {
|
||||
stderr, err := os.CreateTemp(t.TempDir(), "stderr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stderr.Close()
|
||||
|
||||
previousStderr := os.Stderr
|
||||
previousFormat, previousQuiet := outputFormat, quiet
|
||||
defer func() {
|
||||
os.Stderr = previousStderr
|
||||
outputFormat, quiet = previousFormat, previousQuiet
|
||||
}()
|
||||
|
||||
os.Stderr = stderr
|
||||
outputFormat, quiet = "console", true
|
||||
|
||||
PrintSpecInfo(map[string]interface{}{
|
||||
"info": map[string]interface{}{
|
||||
"title": "Example API",
|
||||
"description": "A long spec description.",
|
||||
},
|
||||
})
|
||||
|
||||
data, err := os.ReadFile(stderr.Name())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) != 0 {
|
||||
t.Errorf("expected no console output when quiet is set, got %q", data)
|
||||
}
|
||||
}
|
||||
+25
-18
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -14,7 +15,7 @@ var prepareCmd = &cobra.Command{
|
||||
Short: "Prepares a set of commands for manual testing of each endpoint.",
|
||||
Long: `The prepare command prepares a set of commands for manual testing of each endpoint.
|
||||
This enables you to test specific API functions for common vulnerabilities or misconfigurations.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
|
||||
if randomUserAgent {
|
||||
if UserAgent != "Swagger Jacker (github.com/BishopFox/sj)" {
|
||||
@@ -24,32 +25,38 @@ This enables you to test specific API functions for common vulnerabilities or mi
|
||||
|
||||
_, err := time.Parse("2006-01-02", customDate)
|
||||
if err != nil {
|
||||
die("An invalid date was supplied. Please supply a date in '2006-01-02' format.")
|
||||
return fmt.Errorf("an invalid date was supplied. Please supply a date in '2006-01-02' format")
|
||||
}
|
||||
|
||||
client, _ := CheckAndConfigureProxy()
|
||||
client, _, err := CheckAndConfigureProxy()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printInfo("\n")
|
||||
printInfo("Gathering API details.\n\n")
|
||||
if swaggerURL != "" {
|
||||
bodyBytes, _, _ := MakeRequest(client, "GET", swaggerURL, timeout, nil)
|
||||
GenerateRequests(bodyBytes, client, nil)
|
||||
} else {
|
||||
specFile, err := os.Open(localFile)
|
||||
bodyBytes, _, _, err := MakeRequest(client, "GET", swaggerURL, timeout, nil)
|
||||
if err != nil {
|
||||
die("Error opening file: %v", err)
|
||||
return err
|
||||
}
|
||||
// Set the base directory for resolving external refs
|
||||
specBaseDir = filepath.Dir(localFile)
|
||||
if specBaseDir == "." {
|
||||
if absPath, err := filepath.Abs(localFile); err == nil {
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
}
|
||||
}
|
||||
|
||||
specBytes, _ := io.ReadAll(specFile)
|
||||
GenerateRequests(specBytes, client, nil)
|
||||
return GenerateRequests(bodyBytes, client, nil)
|
||||
}
|
||||
|
||||
specFile, err := os.Open(localFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error opening file: %v", err)
|
||||
}
|
||||
// Set the base directory for resolving external refs
|
||||
specBaseDir = filepath.Dir(localFile)
|
||||
if specBaseDir == "." {
|
||||
if absPath, err := filepath.Abs(localFile); err == nil {
|
||||
specBaseDir = filepath.Dir(absPath)
|
||||
}
|
||||
}
|
||||
|
||||
specBytes, _ := io.ReadAll(specFile)
|
||||
return GenerateRequests(specBytes, client, nil)
|
||||
},
|
||||
}
|
||||
var prepareFor string
|
||||
|
||||
+206
-117
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
@@ -9,8 +10,10 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -52,58 +55,14 @@ 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"
|
||||
}
|
||||
|
||||
// Handling of dangerous keywords
|
||||
u, err := url.Parse(target)
|
||||
if err != nil || u == nil {
|
||||
printWarn("Error parsing URL '%s': %v - skipping request.", target, err)
|
||||
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, ""
|
||||
} else {
|
||||
printWarn("Dangerous keyword '%s' detected in URL (%s). Do you still want to test this endpoint? (y/N)", v, target)
|
||||
fmt.Scanln(&userChoice)
|
||||
if strings.ToLower(userChoice) != "y" {
|
||||
if !riskSurveyed {
|
||||
avoidDangerousRequests = "y"
|
||||
printWarn("Do you want to avoid all dangerous requests? (Y/n)")
|
||||
fmt.Scanln(&avoidDangerousRequests)
|
||||
avoidDangerousRequests = strings.ToLower(avoidDangerousRequests)
|
||||
riskSurveyed = true
|
||||
}
|
||||
return nil, "", 0, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequest(method, target, reqData)
|
||||
if err != nil {
|
||||
if err != context.Canceled && err != io.EOF {
|
||||
die("Error: could not create HTTP request - %v", err)
|
||||
}
|
||||
return nil, "", 0, ""
|
||||
// applyHeaders writes the CLI headers and defaults onto req and returns the
|
||||
// User-Agent used, without touching package state. Keys match case-insensitively
|
||||
// because Header.Set canonicalizes. A non-empty userAgent forces that value.
|
||||
func applyHeaders(req *http.Request, userAgent string) string {
|
||||
forced := userAgent != ""
|
||||
acceptHdr, ctype := accept, contentType
|
||||
if !forced {
|
||||
userAgent = UserAgent
|
||||
}
|
||||
|
||||
for i := range Headers {
|
||||
@@ -116,56 +75,200 @@ func MakeRequestFull(client http.Client, method, target string, timeout int64, r
|
||||
key := strings.TrimSpace(Headers[i][:delimIndex])
|
||||
value := strings.TrimSpace(Headers[i][delimIndex+1:])
|
||||
|
||||
if key == "User-Agent" {
|
||||
UserAgent = value
|
||||
}
|
||||
if key == "Content-Type" {
|
||||
contentType = value
|
||||
}
|
||||
if key == "Accept" {
|
||||
accept = value
|
||||
switch {
|
||||
case strings.EqualFold(key, "User-Agent"):
|
||||
if !forced {
|
||||
userAgent = value
|
||||
}
|
||||
case strings.EqualFold(key, "Content-Type"):
|
||||
ctype = value
|
||||
case strings.EqualFold(key, "Accept"):
|
||||
acceptHdr = 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 {
|
||||
req.Header.Set("User-Agent", UserAgent)
|
||||
if randomUserAgent && !forced {
|
||||
userAgent = userAgents[rand.Intn(len(userAgents))]
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
if acceptHdr == "" {
|
||||
acceptHdr = "application/json, text/html, */*"
|
||||
}
|
||||
req.Header.Set("Accept", acceptHdr)
|
||||
|
||||
// A request that carries bytes has to declare a media type. http.NewRequest
|
||||
// sets ContentLength for the *bytes.Reader bodies sj builds, so this covers
|
||||
// every method that declared a body; POST keeps its historical default.
|
||||
if req.ContentLength > 0 || req.Method == "POST" {
|
||||
if ctype == "" {
|
||||
ctype = "application/json"
|
||||
}
|
||||
req.Header.Set("Content-Type", ctype)
|
||||
}
|
||||
|
||||
if accept == "" {
|
||||
req.Header.Set("Accept", "application/json, text/html, */*")
|
||||
} else {
|
||||
req.Header.Set("Accept", accept)
|
||||
return userAgent
|
||||
}
|
||||
|
||||
// approvedTarget is the URL the operator has already cleared for the operation
|
||||
// being built. sj can send one URL several times -- once per declared body
|
||||
// content type -- and the dangerous-keyword prompt belongs to the endpoint, not
|
||||
// to each encoding of it. A redirect lands on a different URL and is still
|
||||
// checked.
|
||||
var approvedTarget string
|
||||
|
||||
// dangerousRequestDeclined reports whether the operator chose to skip a target
|
||||
// whose URL contains a dangerous keyword, prompting them if they have not
|
||||
// already answered for this run.
|
||||
func dangerousRequestDeclined(target string) bool {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil || u == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if method == "POST" {
|
||||
if contentType == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
// Match whole words rather than substrings so "/store/order" is flagged
|
||||
// while "/border" is not.
|
||||
words := requestWords(u)
|
||||
for _, v := range dangerousStrings {
|
||||
if force || !words[v] || slices.ContainsFunc(safeWords, func(s string) bool { return strings.EqualFold(s, v) }) {
|
||||
continue
|
||||
}
|
||||
if avoidDangerousRequests == "y" {
|
||||
return true
|
||||
}
|
||||
|
||||
userChoice = ""
|
||||
printWarn("Dangerous keyword '%s' detected in URL (%s). Do you still want to test this endpoint? (y/N)", v, target)
|
||||
fmt.Scanln(&userChoice)
|
||||
if strings.ToLower(userChoice) != "y" {
|
||||
if !riskSurveyed {
|
||||
avoidDangerousRequests = "y"
|
||||
printWarn("Do you want to avoid all dangerous requests? (Y/n)")
|
||||
fmt.Scanln(&avoidDangerousRequests)
|
||||
avoidDangerousRequests = strings.ToLower(avoidDangerousRequests)
|
||||
riskSurveyed = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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, error) {
|
||||
bodyBytes, bodyString, status, _, _, err := MakeRequestFull(client, method, target, timeout, reqData)
|
||||
return bodyBytes, bodyString, status, err
|
||||
}
|
||||
|
||||
// splitWords lowercases s and splits it into words on non-alphanumeric
|
||||
// characters and camelCase boundaries, so "uploadImage" yields
|
||||
// ["upload", "image"] while "border" stays a single word.
|
||||
func splitWords(s string) []string {
|
||||
var words []string
|
||||
var current []rune
|
||||
flush := func() {
|
||||
if len(current) > 0 {
|
||||
words = append(words, strings.ToLower(string(current)))
|
||||
current = nil
|
||||
}
|
||||
}
|
||||
|
||||
var prev rune
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case unicode.IsUpper(r) && unicode.IsLower(prev):
|
||||
flush()
|
||||
current = append(current, r)
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||
current = append(current, r)
|
||||
default:
|
||||
flush()
|
||||
}
|
||||
prev = r
|
||||
}
|
||||
flush()
|
||||
|
||||
return words
|
||||
}
|
||||
|
||||
// requestWords returns the set of words appearing in a request's path and
|
||||
// query string. It reads the decoded forms so the dangerous-keyword scan
|
||||
// behaves identically whether or not a value required percent-encoding.
|
||||
func requestWords(u *url.URL) map[string]bool {
|
||||
words := make(map[string]bool)
|
||||
add := func(s string) {
|
||||
for _, w := range splitWords(s) {
|
||||
words[w] = true
|
||||
}
|
||||
}
|
||||
|
||||
add(u.Path)
|
||||
for name, values := range u.Query() {
|
||||
add(name)
|
||||
for _, v := range values {
|
||||
add(v)
|
||||
}
|
||||
}
|
||||
|
||||
return words
|
||||
}
|
||||
|
||||
// MakeRequestFull also returns the response Content-Type and the User-Agent sent.
|
||||
// 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, string, error) {
|
||||
if quiet {
|
||||
avoidDangerousRequests = "y"
|
||||
}
|
||||
|
||||
// Handling of dangerous keywords
|
||||
u, err := url.Parse(target)
|
||||
if err != nil || u == nil {
|
||||
printWarn("Error parsing URL '%s': %v - skipping request.", target, err)
|
||||
return nil, "", 0, "", "", nil
|
||||
}
|
||||
if Mode == "automate" && target != approvedTarget && dangerousRequestDeclined(target) {
|
||||
return nil, "", 0, "", "", nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Buffer the body: client.Do drains the reader, so a redirect would
|
||||
// otherwise be replayed without one.
|
||||
var reqBody []byte
|
||||
var body io.Reader
|
||||
if reqData != nil {
|
||||
reqBody, _ = io.ReadAll(reqData)
|
||||
body = bytes.NewReader(reqBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, target, body)
|
||||
if err != nil {
|
||||
if err != context.Canceled && err != io.EOF {
|
||||
return nil, "", 0, "", "", fmt.Errorf("could not create HTTP request - %v", err)
|
||||
}
|
||||
return nil, "", 0, "", "", nil
|
||||
}
|
||||
|
||||
sentUserAgent := applyHeaders(req, "")
|
||||
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
if err == context.DeadlineExceeded {
|
||||
printWarn("Error: %s - skipping request.", err)
|
||||
return nil, "", 0, ""
|
||||
return nil, "", 0, "", "", nil
|
||||
} 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.")
|
||||
return nil, "", 0, "", "", fmt.Errorf("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)
|
||||
return nil, "", 0, "", "", fmt.Errorf("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, "", "", nil
|
||||
} else {
|
||||
printErr("Error: response not received.\n%v", err)
|
||||
}
|
||||
return nil, "", 0, ""
|
||||
return nil, "", 0, "", "", nil
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
@@ -173,20 +276,29 @@ func MakeRequestFull(client http.Client, method, target string, timeout int64, r
|
||||
contentTypeHeader := resp.Header.Get("Content-Type")
|
||||
|
||||
if (resp.StatusCode == 301 || resp.StatusCode == 302) && strings.Contains(bodyString, "<html>") && depth < 10 {
|
||||
// A 301/302 without a usable Location is not followed.
|
||||
redirect, locErr := resp.Location()
|
||||
if locErr != nil || redirect == nil {
|
||||
requestStatus = resp.StatusCode
|
||||
depth = 0
|
||||
return bodyBytes, bodyString, requestStatus, contentTypeHeader, sentUserAgent, nil
|
||||
}
|
||||
depth += 1
|
||||
redirect, _ := resp.Location()
|
||||
var ct string
|
||||
bodyBytes, bodyString, requestStatus, ct = MakeRequestFull(client, method, redirect.Scheme+"://"+redirect.Host+redirect.Path, timeout, reqData)
|
||||
return bodyBytes, bodyString, requestStatus, ct
|
||||
var ct, ua string
|
||||
var rerr error
|
||||
// redirect.String() keeps the query string, and the buffered body is
|
||||
// replayed through a fresh reader.
|
||||
bodyBytes, bodyString, requestStatus, ct, ua, rerr = MakeRequestFull(client, method, redirect.String(), timeout, bytes.NewReader(reqBody))
|
||||
return bodyBytes, bodyString, requestStatus, ct, ua, rerr
|
||||
}
|
||||
|
||||
requestStatus = resp.StatusCode
|
||||
depth = 0
|
||||
|
||||
return bodyBytes, bodyString, requestStatus, contentTypeHeader
|
||||
return bodyBytes, bodyString, requestStatus, contentTypeHeader, sentUserAgent, nil
|
||||
}
|
||||
|
||||
func CheckAndConfigureProxy() (client http.Client, replayClient *http.Client) {
|
||||
func CheckAndConfigureProxy() (client http.Client, replayClient *http.Client, err error) {
|
||||
var proxyUrl *url.URL
|
||||
|
||||
transport := &http.Transport{}
|
||||
@@ -212,9 +324,9 @@ func CheckAndConfigureProxy() (client http.Client, replayClient *http.Client) {
|
||||
if insecure {
|
||||
replayTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
replayProxyUrl, err := url.Parse(replayProxy)
|
||||
if err != nil {
|
||||
die("Error parsing replay proxy URL: %v", err)
|
||||
replayProxyUrl, parseErr := url.Parse(replayProxy)
|
||||
if parseErr != nil {
|
||||
return client, replayClient, fmt.Errorf("error parsing replay proxy URL: %v", parseErr)
|
||||
}
|
||||
replayTransport.Proxy = http.ProxyURL(replayProxyUrl)
|
||||
replayClient = &http.Client{
|
||||
@@ -225,10 +337,11 @@ func CheckAndConfigureProxy() (client http.Client, replayClient *http.Client) {
|
||||
}
|
||||
}
|
||||
|
||||
return client, replayClient
|
||||
return client, replayClient, nil
|
||||
}
|
||||
|
||||
func ReplayRequest(replayClient *http.Client, method, target string, timeout int64, reqData io.Reader) {
|
||||
// userAgent is the one the replayed request carried, so the copy reproduces it.
|
||||
func ReplayRequest(replayClient *http.Client, method, target string, timeout int64, reqData io.Reader, userAgent string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -238,31 +351,7 @@ func ReplayRequest(replayClient *http.Client, method, target string, timeout int
|
||||
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)
|
||||
}
|
||||
}
|
||||
applyHeaders(req, userAgent)
|
||||
|
||||
resp, err := replayClient.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitWords(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{in: "", want: ""},
|
||||
{in: "/store/order", want: "store,order"},
|
||||
{in: "uploadImage", want: "upload,image"},
|
||||
{in: "createWithList", want: "create,with,list"},
|
||||
{in: "border", want: "border"},
|
||||
{in: "/pet/{petId}/uploadImage", want: "pet,pet,id,upload,image"},
|
||||
{in: "delete_all-now.txt", want: "delete,all,now,txt"},
|
||||
{in: "v2", want: "v2"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := strings.Join(splitWords(tc.in), ",")
|
||||
if got != tc.want {
|
||||
t.Errorf("splitWords(%q): expected %q, got %q", tc.in, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestWords(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
present []string
|
||||
absent []string
|
||||
}{
|
||||
{in: "https://h/v2/store/order", present: []string{"store", "order"}},
|
||||
{in: "https://h/v2/pet/1/uploadImage", present: []string{"upload", "image"}},
|
||||
{in: "https://h/border", absent: []string{"order"}},
|
||||
{in: "https://h/dataset", absent: []string{"set"}},
|
||||
{in: "https://h/search?action=delete", present: []string{"action", "delete"}},
|
||||
// A percent-encoded value must be scanned in its decoded form.
|
||||
{in: "https://h/search?q=please%20delete%20me", present: []string{"delete"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
u, err := url.Parse(tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse %q: %v", tc.in, err)
|
||||
}
|
||||
words := requestWords(u)
|
||||
for _, w := range tc.present {
|
||||
if !words[w] {
|
||||
t.Errorf("requestWords(%q): expected to find %q", tc.in, w)
|
||||
}
|
||||
}
|
||||
for _, w := range tc.absent {
|
||||
if words[w] {
|
||||
t.Errorf("requestWords(%q): did not expect to find %q", tc.in, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedirectPreservesBodyAndQuery covers the manual 301/302 follow in
|
||||
// MakeRequestFull: the body has to survive the hop (client.Do drains the
|
||||
// original reader) and so does the query string.
|
||||
func TestRedirectPreservesBodyAndQuery(t *testing.T) {
|
||||
oldMode, oldForce, oldDepth := Mode, force, depth
|
||||
oldHeaders, oldAccept, oldContentType := Headers, accept, contentType
|
||||
oldUA, oldRandomUA := UserAgent, randomUserAgent
|
||||
defer func() {
|
||||
Mode, force, depth = oldMode, oldForce, oldDepth
|
||||
Headers, accept, contentType = oldHeaders, oldAccept, oldContentType
|
||||
UserAgent, randomUserAgent = oldUA, oldRandomUA
|
||||
}()
|
||||
Mode = "automate"
|
||||
force = true
|
||||
depth = 0
|
||||
Headers = nil
|
||||
accept = ""
|
||||
contentType = ""
|
||||
UserAgent = "sj-test"
|
||||
randomUserAgent = false
|
||||
|
||||
var gotMethod, gotBody, gotQuery string
|
||||
landing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotMethod, gotBody, gotQuery = r.Method, string(b), r.URL.RawQuery
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer landing.Close()
|
||||
|
||||
// The recursion only triggers on a 301/302 whose body looks like HTML.
|
||||
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(io.Discard, r.Body)
|
||||
w.Header().Set("Location", landing.URL+"/landed?q=1")
|
||||
w.WriteHeader(http.StatusFound)
|
||||
w.Write([]byte("<html>moved</html>"))
|
||||
}))
|
||||
defer redirector.Close()
|
||||
|
||||
// ErrUseLastResponse matches the production client (CheckAndConfigureProxy)
|
||||
// and is what hands the redirect back to MakeRequestFull to follow itself.
|
||||
client := http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
|
||||
want := `{"name":"bishopfox"}`
|
||||
_, _, sc, _, _, _ := MakeRequestFull(client, "PUT", redirector.URL+"/first", 30, bytes.NewReader([]byte(want)))
|
||||
|
||||
if sc != http.StatusOK {
|
||||
t.Errorf("redirect not followed: status %d, want %d", sc, http.StatusOK)
|
||||
}
|
||||
if gotMethod != "PUT" {
|
||||
t.Errorf("redirected method: got %q, want %q", gotMethod, "PUT")
|
||||
}
|
||||
if gotBody != want {
|
||||
t.Errorf("redirected request lost its body: got %q, want %q", gotBody, want)
|
||||
}
|
||||
if gotQuery != "q=1" {
|
||||
t.Errorf("redirected request lost its query string: got %q, want %q", gotQuery, "q=1")
|
||||
}
|
||||
}
|
||||
+32
-6
@@ -1,9 +1,13 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var allContentTypes bool
|
||||
var apiTarget string
|
||||
var basePath string
|
||||
var customDate string
|
||||
@@ -13,12 +17,14 @@ var force bool
|
||||
var format string
|
||||
var insecure bool
|
||||
var localFile string
|
||||
var Mode string
|
||||
var outfile string
|
||||
var proxy string
|
||||
var replayProxy string
|
||||
var quiet bool
|
||||
var randomUserAgent bool
|
||||
var rateLimit int
|
||||
var rawValues bool
|
||||
var safeWords []string
|
||||
var swaggerURL string
|
||||
var timeout int64
|
||||
@@ -48,16 +54,34 @@ $ sj brute -u https://petstore.swagger.io
|
||||
Convert a Swagger (v2) definition file to an OpenAPI (v3) definition file:
|
||||
$ sj convert -u https://petstore.swagger.io/v2/swagger.json -o openapi.json`,
|
||||
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) < 1 {
|
||||
printErr("Command not specified. See the --help flag for usage.")
|
||||
}
|
||||
// PersistentPreRun captures the invoked subcommand name once, so the
|
||||
// shared request-generation code can branch on the mode without reaching
|
||||
// back into os.Args[1] (which panics or misbehaves when these functions
|
||||
// are called programmatically rather than through the CLI).
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
Mode = cmd.Name()
|
||||
},
|
||||
Version: "2.8.2",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("command not specified. See the --help flag for usage")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Version: "2.8.4",
|
||||
// Errors and usage are surfaced once by Execute() with our own styling, so
|
||||
// cobra should not also print them on a RunE failure.
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
// Execute runs the root command. It is the single place in the package that
|
||||
// terminates the process: command and library code return errors up to here
|
||||
// instead of calling os.Exit, which keeps that logic testable and reusable.
|
||||
func Execute() {
|
||||
cobra.CheckErr(rootCmd.Execute())
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
printErr("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -66,6 +90,7 @@ func init() {
|
||||
rootCmd.AddCommand(prepareCmd)
|
||||
rootCmd.AddCommand(bruteCmd)
|
||||
rootCmd.AddCommand(convertCmd)
|
||||
rootCmd.PersistentFlags().BoolVar(&allContentTypes, "all-content-types", false, "Send a separate request for every request body content type an operation declares, instead of only the preferred one.")
|
||||
rootCmd.PersistentFlags().StringVarP(&UserAgent, "agent", "A", "Swagger Jacker (github.com/BishopFox/sj)", "Set the User-Agent string.")
|
||||
rootCmd.PersistentFlags().StringVarP(&basePath, "base-path", "b", "", "Set the API base path if not defined in the definition file (i.e. /V2/).")
|
||||
rootCmd.PersistentFlags().StringVarP(&customURL, "custom-url", "c", "https://bishopfox.com", "Set a custom URL to test discovered URL parameters.")
|
||||
@@ -81,6 +106,7 @@ func init() {
|
||||
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().BoolVar(&rawValues, "raw-values", false, "Do not URL-encode generated parameter names and values. Useful for parser-differential and WAF testing.")
|
||||
// rootCmd.PersistentFlags().IntVarP(&rateLimit, "rate", "r", 15, "Limit the number of requests per second.") // NEED TO RE-IMPLEMENT RATE LIMIT
|
||||
rootCmd.PersistentFlags().StringArrayVarP(&safeWords, "safe-word", "s", nil, "Avoids 'dangerous word' check for the specified word(s). Multiple flags are accepted.")
|
||||
rootCmd.PersistentFlags().StringVarP(&apiTarget, "target", "T", "", "Manually set a target for the requests to be made if separate from the host the documentation resides on.")
|
||||
|
||||
+414
-231
@@ -3,13 +3,15 @@ package cmd
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -38,10 +40,10 @@ type SchemaNode struct {
|
||||
AdditionalProperties *SchemaNode
|
||||
}
|
||||
|
||||
func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, replayClient *http.Client) {
|
||||
func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, replayClient *http.Client) error {
|
||||
paths, ok := spec["paths"].(map[string]interface{})
|
||||
if !ok || paths == nil {
|
||||
die("Could not find any defined operations. Review the file manually.")
|
||||
return fmt.Errorf("could not find any defined operations. Review the file manually")
|
||||
}
|
||||
|
||||
pathKeys := make([]string, 0, len(paths))
|
||||
@@ -63,7 +65,6 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
}
|
||||
}
|
||||
|
||||
var errorDescriptions = make(map[any]string)
|
||||
for _, pathName := range pathKeys {
|
||||
pathItem := paths[pathName]
|
||||
if ops, ok := pathItem.(map[string]interface{}); ok {
|
||||
@@ -87,6 +88,10 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
Headers = append([]string(nil), userHeaders...)
|
||||
contentType = userContentType
|
||||
|
||||
// Scoped to this operation: a description belongs to the
|
||||
// response it was declared under, so one endpoint's "Not
|
||||
// found" must never be printed against another's.
|
||||
errorDescriptions := make(map[string]string)
|
||||
if responses, ok := opMap["responses"].(map[string]interface{}); ok {
|
||||
for status, respItem := range responses {
|
||||
if respMap, ok := respItem.(map[string]interface{}); ok {
|
||||
@@ -94,13 +99,44 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
errorDescriptions[status] = desc
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// The effective security the spec declares for this operation,
|
||||
// and whether an auth-required operation is being tested without a
|
||||
// matching credential (so a 2xx becomes a missing-auth finding).
|
||||
// userHeaders is the pre-mutation snapshot, so it carries any
|
||||
// auth-supplied header credential but not this op's header params.
|
||||
opSec := resolveOperationSecurity(spec, opMap)
|
||||
secLabel := opSec.label()
|
||||
authRequiredNoCred := opSec.state == secRequired &&
|
||||
!requirementSatisfiedByCredentials(opSec, spec, userHeaders)
|
||||
|
||||
targetURL := fmt.Sprintf("%s%s%s", apiTarget, basePath, pathName)
|
||||
// Apply any query-string API keys from a security scheme
|
||||
// before spec params and before curl is composed, so the
|
||||
// sent request and the printed command both carry the key.
|
||||
for _, qp := range authQueryParams {
|
||||
targetURL = appendQueryParam(targetURL, qp.name, qp.value)
|
||||
}
|
||||
curl := fmt.Sprintf("curl -X %s \"%s\"", strings.ToUpper(method), targetURL)
|
||||
var bodyData string
|
||||
// variants collects every body this operation declares, from
|
||||
// all three sources below. None is discarded; selectVariants
|
||||
// decides which ones are sent.
|
||||
var variants []bodyVariant
|
||||
// cookiePairs accumulates "in: cookie" parameters so they can be sent
|
||||
// as a single Cookie header. formBody accumulates "in: formData"
|
||||
// (Swagger v2) fields, which become one urlencoded variant.
|
||||
var cookiePairs []string
|
||||
// Seed with any cookie-based API keys from a security scheme
|
||||
// (apiKey with "in: cookie") so they fold into the same Cookie
|
||||
// header as the operation's own cookie params, mirroring how
|
||||
// authQueryParams seeds the query string above.
|
||||
for _, cp := range authCookieParams {
|
||||
cookiePairs = append(cookiePairs, encodePair(cp.name, cp.value))
|
||||
}
|
||||
var formBody string
|
||||
var hasFormData bool
|
||||
|
||||
// Extracts the expected parameters from the parameters object
|
||||
if params, ok := opMap["parameters"].([]interface{}); ok {
|
||||
@@ -120,7 +156,22 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
}
|
||||
|
||||
if name, ok := pMap["name"].(string); ok {
|
||||
in := pMap["in"].(string)
|
||||
in, hasIn := pMap["in"].(string)
|
||||
if !hasIn {
|
||||
continue
|
||||
}
|
||||
|
||||
// A Swagger v2 "in: body" parameter's schema is the entire request
|
||||
// body, so it is serialized as JSON rather than folded into form
|
||||
// fields. This covers non-object schemas (an array body) too.
|
||||
if in == "body" {
|
||||
bodySchema, _ := pMap["schema"].(map[string]interface{})
|
||||
expandedBody := ExpandSchema(spec, bodySchema, map[string]bool{}, paramContextSpec)
|
||||
if variant, ok := buildVariant(ctJSON, GenerateExample(expandedBody)); ok {
|
||||
variants = append(variants, variant)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle schema-based parameters (OpenAPI v3 and some v2)
|
||||
var handledAsObject bool // Track if we already handled this as an object
|
||||
@@ -133,27 +184,8 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
// Handle based on parameter location
|
||||
if in == "query" {
|
||||
// Query params with object schema: add each property to query string
|
||||
for propertyItem, propertyValue := range exampleMap {
|
||||
pVal := fmt.Sprintf("%v", propertyValue)
|
||||
if strings.Contains(curl, "?") || strings.Contains(targetURL, "?") {
|
||||
targetURL += fmt.Sprintf("&%s=%s", propertyItem, pVal)
|
||||
} else {
|
||||
targetURL += fmt.Sprintf("?%s=%s", propertyItem, pVal)
|
||||
}
|
||||
}
|
||||
handledAsObject = true
|
||||
} else if in == "body" {
|
||||
// Body params with object schema: add each property to body data
|
||||
for propertyItem, propertyValue := range exampleMap {
|
||||
pVal := fmt.Sprintf("%v", propertyValue)
|
||||
if strings.Contains(curl, "-d '") {
|
||||
bodyData += fmt.Sprintf("&%s=%s", propertyItem, pVal)
|
||||
curl = strings.TrimSuffix(curl, "'")
|
||||
curl += fmt.Sprintf("&%s=%s'", propertyItem, pVal)
|
||||
} else {
|
||||
bodyData += fmt.Sprintf("%s=%s", propertyItem, pVal)
|
||||
curl += fmt.Sprintf(" -d '%s=%s'", propertyItem, pVal)
|
||||
}
|
||||
for _, propertyItem := range slices.Sorted(maps.Keys(exampleMap)) {
|
||||
targetURL = appendQueryParam(targetURL, propertyItem, exampleMap[propertyItem])
|
||||
}
|
||||
handledAsObject = true
|
||||
}
|
||||
@@ -187,28 +219,33 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
pValue = "1"
|
||||
}
|
||||
|
||||
// An unconsumed object schema leaves pValue empty: "/pet/{id}" -> "/pet/".
|
||||
if !handledAsObject && pValue == "" {
|
||||
pValue = testString
|
||||
}
|
||||
|
||||
// Only process parameters that weren't already handled as objects
|
||||
if !handledAsObject {
|
||||
switch in {
|
||||
case "query":
|
||||
if strings.Contains(curl, "?") || strings.Contains(targetURL, "?") {
|
||||
targetURL += fmt.Sprintf("&%s=%s", name, pValue)
|
||||
} else {
|
||||
targetURL += fmt.Sprintf("?%s=%s", name, pValue)
|
||||
}
|
||||
targetURL = appendQueryParam(targetURL, name, pValue)
|
||||
case "path":
|
||||
targetURL = strings.Replace(targetURL, "{"+name+"}", pValue, 1)
|
||||
targetURL = replacePathParam(targetURL, name, pValue)
|
||||
case "header":
|
||||
curl += fmt.Sprintf(" -H \"%s: %s\"", name, pValue)
|
||||
case "body":
|
||||
if strings.Contains(curl, "-d '") {
|
||||
bodyData += fmt.Sprintf("&%s=%s", name, pValue)
|
||||
curl = strings.TrimSuffix(curl, "'")
|
||||
curl += fmt.Sprintf("&%s=%s'", name, pValue)
|
||||
} else {
|
||||
bodyData += fmt.Sprintf("%s=%s", name, pValue)
|
||||
curl += fmt.Sprintf(" -d '%s=%s'", name, pValue)
|
||||
}
|
||||
// Append to Headers so the value is actually sent (applyHeaders
|
||||
// applies every Headers entry to the request), and mirror it in
|
||||
// the printed command. A header value is not a URL component, so
|
||||
// it is shell-quoted for display but never percent-encoded.
|
||||
Headers = append(Headers, name+": "+pValue)
|
||||
curl += " -H " + shellSingleQuote(name+": "+pValue)
|
||||
case "cookie":
|
||||
// Accumulate cookie params; they are emitted as one Cookie
|
||||
// header after every parameter is known.
|
||||
cookiePairs = append(cookiePairs, encodePair(name, pValue))
|
||||
case "formData":
|
||||
// Swagger v2 form field: contributes to the urlencoded body.
|
||||
formBody = appendFormField(formBody, name, pValue)
|
||||
hasFormData = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,6 +253,20 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
}
|
||||
}
|
||||
|
||||
// Emit accumulated cookie parameters as a single Cookie header,
|
||||
// both on the request (via Headers) and in the printed command.
|
||||
if len(cookiePairs) > 0 {
|
||||
cookieValue := strings.Join(cookiePairs, "; ")
|
||||
Headers = append(Headers, "Cookie: "+cookieValue)
|
||||
curl += " -b " + shellSingleQuote(cookieValue)
|
||||
}
|
||||
|
||||
// Swagger v2 formData fields have no requestBody to declare a
|
||||
// content type, so they become an explicit urlencoded variant.
|
||||
if hasFormData {
|
||||
variants = append(variants, newTextBodyVariant(ctForm, ctForm, formBody))
|
||||
}
|
||||
|
||||
// Extracts the expected parameters from the requestBody object
|
||||
if reqBody, ok := opMap["requestBody"].(map[string]interface{}); ok {
|
||||
// Handle requestBody-level $ref (e.g., #/components/requestBodies/...)
|
||||
@@ -227,72 +278,7 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
reqBodyContextSpec = contextSpec
|
||||
}
|
||||
}
|
||||
|
||||
if contentTypes, ok := reqBody["content"].(map[string]interface{}); ok {
|
||||
for cType := range contentTypes {
|
||||
if contentType == "" {
|
||||
EnforceSingleContentType(cType)
|
||||
} else {
|
||||
EnforceSingleContentType(contentType)
|
||||
}
|
||||
|
||||
if ct, ok := contentTypes[cType].(map[string]interface{}); ok {
|
||||
if schema, ok := ct["schema"].(map[string]interface{}); ok {
|
||||
expanded := ExpandSchema(spec, schema, map[string]bool{}, reqBodyContextSpec)
|
||||
example := GenerateExample(expanded)
|
||||
|
||||
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" {
|
||||
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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the curl command with the final targetURL (which may have been modified with query params)
|
||||
// Extract and replace the URL in quotes
|
||||
curlParts := strings.SplitN(curl, "\"", 3)
|
||||
if len(curlParts) >= 3 {
|
||||
curl = curlParts[0] + "\"" + targetURL + "\"" + curlParts[2]
|
||||
variants = append(variants, buildBodyVariants(spec, reqBody, reqBodyContextSpec)...)
|
||||
}
|
||||
|
||||
logURL, parseErr := url.Parse(targetURL)
|
||||
@@ -300,98 +286,193 @@ func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, rep
|
||||
printWarn("Error parsing URL '%s': %v - skipping endpoint.", targetURL, parseErr)
|
||||
continue
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "automate":
|
||||
var postBodyData string
|
||||
if strings.ToLower(method) == "post" {
|
||||
postBodyData = bodyData
|
||||
|
||||
if Mode == "endpoints" {
|
||||
// endpoints only lists paths, so the body never matters and
|
||||
// one line per path+method is printed regardless of how many
|
||||
// content types the operation declares.
|
||||
fmt.Println(basePath + pathName)
|
||||
continue
|
||||
}
|
||||
|
||||
variants = orderVariants(variants)
|
||||
if Mode == "prepare" && strings.EqualFold(prepareFor, "sqlmap") {
|
||||
// sqlmap has no -F equivalent, so a multipart variant would
|
||||
// print a command that cannot be run.
|
||||
variants = slices.DeleteFunc(variants, func(v bodyVariant) bool {
|
||||
return v.declared == ctMultipart
|
||||
})
|
||||
}
|
||||
|
||||
selected := selectVariants(variants, contentType, allContentTypes)
|
||||
if len(selected) == 0 {
|
||||
// No declared body: a single bodyless request, as before.
|
||||
selected = []bodyVariant{{}}
|
||||
}
|
||||
|
||||
if Mode == "automate" {
|
||||
// Ask once per operation. sj may send this same URL once per
|
||||
// declared content type, and re-prompting for each one would
|
||||
// be noise; approvedTarget stops MakeRequestFull asking again.
|
||||
if dangerousRequestDeclined(targetURL) {
|
||||
continue
|
||||
}
|
||||
approvedTarget = targetURL
|
||||
}
|
||||
|
||||
// Snapshot the state every variant starts from. The loop below
|
||||
// rebuilds curl and Headers per variant so that what is printed
|
||||
// and what goes on the wire describe the same request.
|
||||
baseCurl := curl
|
||||
baseHeaders := append([]string(nil), Headers...)
|
||||
|
||||
// The body encoding is only worth reporting when an operation
|
||||
// was tested under more than one. Otherwise it would add a field
|
||||
// to every result row that says nothing the request did not
|
||||
// already imply, and change output every consumer already parses.
|
||||
reportContentType := len(selected) > 1
|
||||
|
||||
for _, v := range selected {
|
||||
// Re-copy rather than assign: EnforceSingleContentType
|
||||
// compacts Headers in place, so sharing a backing array would
|
||||
// let one variant rewrite the snapshot the next one starts from.
|
||||
Headers = append([]string(nil), baseHeaders...)
|
||||
curl = baseCurl
|
||||
|
||||
// A bodyless request declares no media type. Enforcing an
|
||||
// empty one would append a bare "Content-Type:" that
|
||||
// applyHeaders only replaces when the request carries bytes.
|
||||
if v.contentType != "" {
|
||||
EnforceSingleContentType(v.contentType)
|
||||
if !v.omitCurlCT {
|
||||
curl += " -H " + shellSingleQuote("Content-Type: "+v.contentType)
|
||||
}
|
||||
}
|
||||
curl += v.curlArgs
|
||||
|
||||
// Update the curl command with the final targetURL (which may have been modified with query params)
|
||||
// Extract and replace the URL in quotes
|
||||
curlParts := strings.SplitN(curl, "\"", 3)
|
||||
if len(curlParts) >= 3 {
|
||||
curl = curlParts[0] + "\"" + targetURL + "\"" + curlParts[2]
|
||||
}
|
||||
|
||||
_, resp, sc := MakeRequest(client, strings.ToUpper(method), targetURL, timeout, bytes.NewReader([]byte(postBodyData)))
|
||||
switch Mode {
|
||||
case "automate":
|
||||
// These are the same bytes the printed curl command was
|
||||
// composed from above, so what is shown and what goes on
|
||||
// the wire cannot disagree.
|
||||
sentBody := []byte(v.body)
|
||||
|
||||
tempResponsePreviewLength := responsePreviewLength
|
||||
if len(resp) <= responsePreviewLength {
|
||||
tempResponsePreviewLength = len(resp)
|
||||
}
|
||||
_, resp, sc, _, sentUA, reqErr := MakeRequestFull(client, strings.ToUpper(method), targetURL, timeout, bytes.NewReader(sentBody))
|
||||
if reqErr != nil {
|
||||
return reqErr
|
||||
}
|
||||
|
||||
var result []byte
|
||||
tempResponsePreviewLength := responsePreviewLength
|
||||
if len(resp) <= responsePreviewLength {
|
||||
tempResponsePreviewLength = len(resp)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
result, _ = json.Marshal(VerboseResult{Method: method, Preview: resp[:tempResponsePreviewLength], Status: sc, Target: logURL.Path, Curl: curl})
|
||||
} else {
|
||||
result, _ = json.Marshal(Result{Method: method, Status: sc, Target: logURL.Path})
|
||||
}
|
||||
// Only annotate responses that went wrong: a 2xx
|
||||
// description ("successful operation") only repeats what
|
||||
// the status already says. Descriptions are keyed by the
|
||||
// status as written in the document, with "default" as the
|
||||
// spec's catch-all.
|
||||
description := ""
|
||||
if sc < 200 || sc > 299 {
|
||||
desc, ok := errorDescriptions[strconv.Itoa(sc)]
|
||||
if !ok {
|
||||
desc = errorDescriptions["default"]
|
||||
}
|
||||
description = desc
|
||||
}
|
||||
|
||||
if getAccessibleEndpoints {
|
||||
if sc == 200 {
|
||||
accessibleEndpoints = append(accessibleEndpoints, logURL.Path)
|
||||
reportedCT := ""
|
||||
if reportContentType {
|
||||
reportedCT = v.declared
|
||||
}
|
||||
// A spec-declared-auth operation answering 2xx with no
|
||||
// credential supplied is a possible broken access control.
|
||||
missingAuth := authRequiredNoCred && sc >= 200 && sc <= 299
|
||||
var result []byte
|
||||
|
||||
if verbose {
|
||||
result, _ = json.Marshal(VerboseResult{Method: method, Preview: resp[:tempResponsePreviewLength], Status: sc, Target: logURL.Path, Curl: curl, ContentType: reportedCT, Security: secLabel, MissingAuth: missingAuth})
|
||||
} else {
|
||||
result, _ = json.Marshal(Result{Method: method, Status: sc, Target: logURL.Path, ContentType: reportedCT, Security: secLabel, MissingAuth: missingAuth})
|
||||
}
|
||||
|
||||
if getAccessibleEndpoints {
|
||||
if sc == 200 {
|
||||
accessibleEndpoints = append(accessibleEndpoints, logURL.Path)
|
||||
if jsonResultsStringArray == nil {
|
||||
jsonResultsStringArray = append(jsonResultsStringArray, string(result))
|
||||
} else {
|
||||
jsonResultsStringArray = append(jsonResultsStringArray, ","+string(result))
|
||||
}
|
||||
if outputFormat == "console" {
|
||||
writeLog(sc, logURL.Path, strings.ToUpper(method), description, resp[:tempResponsePreviewLength], secLabel, missingAuth)
|
||||
}
|
||||
if replayClient != nil {
|
||||
ReplayRequest(replayClient, strings.ToUpper(method), targetURL, timeout, bytes.NewReader(sentBody), sentUA)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if jsonResultsStringArray == nil {
|
||||
jsonResultsStringArray = append(jsonResultsStringArray, string(result))
|
||||
} else {
|
||||
jsonResultsStringArray = append(jsonResultsStringArray, ","+string(result))
|
||||
}
|
||||
if outputFormat == "console" {
|
||||
writeLog(sc, logURL.Path, strings.ToUpper(method), errorDescriptions[sc], resp[:tempResponsePreviewLength])
|
||||
writeLog(sc, logURL.Path, strings.ToUpper(method), description, resp[:tempResponsePreviewLength], secLabel, missingAuth)
|
||||
}
|
||||
if replayClient != nil {
|
||||
ReplayRequest(replayClient, strings.ToUpper(method), targetURL, timeout, bytes.NewReader([]byte(postBodyData)))
|
||||
ReplayRequest(replayClient, strings.ToUpper(method), targetURL, timeout, bytes.NewReader(sentBody), sentUA)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if jsonResultsStringArray == nil {
|
||||
jsonResultsStringArray = append(jsonResultsStringArray, string(result))
|
||||
} else {
|
||||
jsonResultsStringArray = append(jsonResultsStringArray, ","+string(result))
|
||||
}
|
||||
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":
|
||||
fmt.Println(basePath + pathName)
|
||||
case "prepare":
|
||||
var preparedCommand string = curl
|
||||
if strings.ToLower(prepareFor) == "sqlmap" {
|
||||
preparedCommand = strings.Replace(preparedCommand, "curl", "sqlmap", 1)
|
||||
preparedCommand = strings.Replace(preparedCommand, "-X "+strings.ToUpper(method), "--method="+strings.ToUpper(method)+" -u", 1)
|
||||
if bodyData != "" {
|
||||
preparedCommand = strings.Replace(preparedCommand, "-d '"+bodyData+"'", "--data='"+bodyData+"'", 1)
|
||||
case "prepare":
|
||||
var preparedCommand string = curl
|
||||
if strings.EqualFold(prepareFor, "sqlmap") {
|
||||
preparedCommand = strings.Replace(preparedCommand, "curl", "sqlmap", 1)
|
||||
preparedCommand = strings.Replace(preparedCommand, "-X "+strings.ToUpper(method), "--method="+strings.ToUpper(method)+" -u", 1)
|
||||
if v.curlBodyArg != "" {
|
||||
preparedCommand = strings.Replace(preparedCommand, "-d "+v.curlBodyArg, "--data="+v.curlBodyArg, 1)
|
||||
}
|
||||
preparedCommand = "$ " + preparedCommand
|
||||
} else if strings.EqualFold(prepareFor, "curl") {
|
||||
preparedCommand = "$ " + curl
|
||||
}
|
||||
preparedCommand = "$ " + preparedCommand
|
||||
} else if prepareFor == "curl" {
|
||||
preparedCommand = "$ " + curl
|
||||
fmt.Println(preparedCommand)
|
||||
}
|
||||
fmt.Println(preparedCommand)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if os.Args[1] == "automate" && outputFormat == "json" {
|
||||
if Mode == "automate" && outputFormat == "json" {
|
||||
for r := range jsonResultsStringArray {
|
||||
var result Result
|
||||
var verboseResult VerboseResult
|
||||
if verbose {
|
||||
err := json.Unmarshal([]byte(strings.TrimPrefix(jsonResultsStringArray[r], ",")), &verboseResult)
|
||||
if err != nil {
|
||||
die("Error marshalling JSON: %v", err)
|
||||
return fmt.Errorf("error unmarshalling JSON: %v", err)
|
||||
}
|
||||
jsonVerboseResultArray = append(jsonVerboseResultArray, verboseResult)
|
||||
} else {
|
||||
err := json.Unmarshal([]byte(strings.TrimPrefix(jsonResultsStringArray[r], ",")), &result)
|
||||
if err != nil {
|
||||
die("Error marshalling JSON: %v", err)
|
||||
return fmt.Errorf("error unmarshalling JSON: %v", err)
|
||||
}
|
||||
jsonResultArray = append(jsonResultArray, result)
|
||||
}
|
||||
}
|
||||
writeLog(8899, "", "", "", "")
|
||||
writeLog(8899, "", "", "", "", "", false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnforceSingleContentType(newContentType string) {
|
||||
@@ -422,17 +503,21 @@ func ExpandSchema(
|
||||
|
||||
if ref, ok := schema["$ref"].(string); ok {
|
||||
if visited[ref] {
|
||||
return &SchemaNode{Type: "object"} // break cycle
|
||||
return &SchemaNode{Type: "object"} // break cycle: ref is already on the current path
|
||||
}
|
||||
visited[ref] = true
|
||||
|
||||
// Resolve ref in the context spec (could be external)
|
||||
resolved, resolvedSpec := ResolveRefWithContext(contextSpec, ref)
|
||||
if resolved == nil {
|
||||
return &SchemaNode{Type: "object"}
|
||||
}
|
||||
// Continue expansion using the resolved spec as context
|
||||
return ExpandSchema(spec, resolved, visited, resolvedSpec)
|
||||
|
||||
// Mark ref as on the current expansion path, expand, then pop it so sibling
|
||||
// branches can reference the same schema without a false cycle-break.
|
||||
visited[ref] = true
|
||||
node := ExpandSchema(spec, resolved, visited, resolvedSpec)
|
||||
delete(visited, ref)
|
||||
return node
|
||||
}
|
||||
|
||||
node := &SchemaNode{
|
||||
@@ -482,21 +567,43 @@ func ExpandSchema(
|
||||
}
|
||||
}
|
||||
|
||||
// Handle allOf (merge all schemas)
|
||||
// Handle allOf (merge every subschema into this node; the node's own values win)
|
||||
if allOf, ok := schema["allOf"].([]interface{}); ok {
|
||||
merged := &SchemaNode{Type: "object", Properties: map[string]*SchemaNode{}, Required: map[string]bool{}}
|
||||
for _, entry := range allOf {
|
||||
if m, ok := entry.(map[string]interface{}); ok {
|
||||
sub := ExpandSchema(spec, m, visited, contextSpec)
|
||||
for k, v := range sub.Properties {
|
||||
merged.Properties[k] = v
|
||||
}
|
||||
for k, v := range sub.Required {
|
||||
merged.Required[k] = v
|
||||
m, ok := entry.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sub := ExpandSchema(spec, m, visited, contextSpec)
|
||||
for k, v := range sub.Properties {
|
||||
if _, exists := node.Properties[k]; !exists {
|
||||
node.Properties[k] = v
|
||||
}
|
||||
}
|
||||
for k, v := range sub.Required {
|
||||
node.Required[k] = v
|
||||
}
|
||||
if node.Type == "" {
|
||||
node.Type = sub.Type
|
||||
}
|
||||
if node.Enum == nil {
|
||||
node.Enum = sub.Enum
|
||||
}
|
||||
if node.Example == nil {
|
||||
node.Example = sub.Example
|
||||
}
|
||||
if node.Items == nil {
|
||||
node.Items = sub.Items
|
||||
}
|
||||
if node.AdditionalProperties == nil {
|
||||
node.AdditionalProperties = sub.AdditionalProperties
|
||||
}
|
||||
node.OneOf = append(node.OneOf, sub.OneOf...)
|
||||
node.AnyOf = append(node.AnyOf, sub.AnyOf...)
|
||||
}
|
||||
if node.Type == "" {
|
||||
node.Type = "object"
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// Handle oneOf (expand all options)
|
||||
@@ -576,7 +683,7 @@ func GenerateExample(node *SchemaNode) interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateRequests(bodyBytes []byte, client http.Client, replayClient *http.Client) {
|
||||
func GenerateRequests(bodyBytes []byte, client http.Client, replayClient *http.Client) error {
|
||||
// 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.
|
||||
@@ -587,11 +694,19 @@ func GenerateRequests(bodyBytes []byte, client http.Client, replayClient *http.C
|
||||
}
|
||||
|
||||
// Ingests the specification file
|
||||
spec := SafelyUnmarshalSpec(bodyBytes)
|
||||
spec, err := SafelyUnmarshalSpec(bodyBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Checks defined security schemes and prompts for authentication
|
||||
CheckSecuritySchemes(spec)
|
||||
|
||||
// Reports the effective security requirement of every declared operation so
|
||||
// the operator can see which are meant to be authenticated, which are public,
|
||||
// and which reference an undefined scheme.
|
||||
SummarizeOperationSecurity(spec)
|
||||
|
||||
u, parseErr := url.Parse(swaggerURL)
|
||||
if parseErr != nil {
|
||||
u = &url.URL{}
|
||||
@@ -633,44 +748,42 @@ func GenerateRequests(bodyBytes []byte, client http.Client, replayClient *http.C
|
||||
} else if v, ok := spec["openapi"].(string); ok && strings.HasPrefix(v, "3") {
|
||||
// OpenAPI (v3)
|
||||
if servers, ok := spec["servers"].([]interface{}); ok && len(servers) > 0 {
|
||||
if len(servers) > 1 {
|
||||
if !quiet && (os.Args[1] != "endpoints") && apiTarget == "" {
|
||||
printWarn("Multiple servers detected in documentation. You can manually set a server to test with the -T flag.\nThe detected servers are as follows:")
|
||||
for i := range servers {
|
||||
if srv, ok := servers[i].(map[string]interface{}); ok {
|
||||
if serverURL, ok := srv["url"].(string); ok {
|
||||
printInfo("%s\n", serverURL)
|
||||
}
|
||||
// When several servers are declared and the user hasn't pinned one
|
||||
// with -T, warn and fall through to the first declared server rather
|
||||
// than aborting the run.
|
||||
if len(servers) > 1 && !quiet && Mode != "endpoints" && apiTarget == "" {
|
||||
printWarn("Multiple servers detected in documentation. Defaulting to the first; set a specific server with the -T flag.\nThe detected servers are as follows:")
|
||||
for i := range servers {
|
||||
if srv, ok := servers[i].(map[string]interface{}); ok {
|
||||
if serverURL, ok := srv["url"].(string); ok {
|
||||
printInfo("%s\n", serverURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if srv, ok := servers[0].(map[string]interface{}); ok {
|
||||
if serverURL, ok := srv["url"].(string); ok {
|
||||
if strings.Contains(serverURL, "://") {
|
||||
// Full URL in server
|
||||
if parsedServerURL, err := url.Parse(serverURL); err == nil {
|
||||
basePath = normalizeBasePath(parsedServerURL.Path)
|
||||
if apiTarget == "" {
|
||||
apiTarget = parsedServerURL.Scheme + "://" + parsedServerURL.Host
|
||||
}
|
||||
}
|
||||
} else if serverURL == "/" {
|
||||
basePath = ""
|
||||
} else {
|
||||
// Relative URL - this becomes the basePath
|
||||
basePath = normalizeBasePath(serverURL)
|
||||
// Only try to construct apiTarget if -T wasn't used
|
||||
}
|
||||
if srv, ok := servers[0].(map[string]interface{}); ok {
|
||||
if serverURL, ok := srv["url"].(string); ok {
|
||||
if strings.Contains(serverURL, "://") {
|
||||
// Full URL in server
|
||||
if parsedServerURL, err := url.Parse(serverURL); err == nil {
|
||||
basePath = normalizeBasePath(parsedServerURL.Path)
|
||||
if apiTarget == "" {
|
||||
if u.Scheme != "" && u.Host != "" {
|
||||
apiTarget = u.Scheme + "://" + u.Host
|
||||
} else {
|
||||
// Local file with relative server URL and no -T flag
|
||||
// Only fail for commands that need full URLs
|
||||
if os.Args[1] != "endpoints" {
|
||||
die("Spec has relative server URL '%s' but no base URL available. Use -T to specify target server.", serverURL)
|
||||
}
|
||||
}
|
||||
apiTarget = parsedServerURL.Scheme + "://" + parsedServerURL.Host
|
||||
}
|
||||
}
|
||||
} else if serverURL == "/" {
|
||||
basePath = ""
|
||||
} else {
|
||||
// Relative URL - this becomes the basePath
|
||||
basePath = normalizeBasePath(serverURL)
|
||||
// Only try to construct apiTarget if -T wasn't used
|
||||
if apiTarget == "" {
|
||||
if u.Scheme != "" && u.Host != "" {
|
||||
apiTarget = u.Scheme + "://" + u.Host
|
||||
} else if Mode != "endpoints" {
|
||||
// Local file with relative server URL and no -T flag:
|
||||
// there is no host to resolve, so this can't proceed.
|
||||
return fmt.Errorf("spec has relative server URL '%s' but no base URL available. Use -T to specify target server", serverURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -683,22 +796,20 @@ func GenerateRequests(bodyBytes []byte, client http.Client, replayClient *http.C
|
||||
if apiTarget == "" {
|
||||
if u.Scheme != "" && u.Host != "" {
|
||||
apiTarget = u.Scheme + "://" + u.Host
|
||||
} else {
|
||||
// No server info and no URL to parse - require user to specify target
|
||||
// Only fail for commands that need full URLs
|
||||
if os.Args[1] != "endpoints" {
|
||||
die("No server information found in spec and no URL provided. Use -T to specify target server.")
|
||||
}
|
||||
} else if Mode != "endpoints" {
|
||||
// No server info and no URL to parse - require user to specify target.
|
||||
// Only fail for commands that need full URLs.
|
||||
return fmt.Errorf("no server information found in spec and no URL provided. Use -T to specify target server")
|
||||
}
|
||||
}
|
||||
|
||||
if os.Args[1] != "endpoints" {
|
||||
if Mode != "endpoints" {
|
||||
// Prints Title/Description/Version values if they exist
|
||||
PrintSpecInfo(spec)
|
||||
}
|
||||
|
||||
// Reviews all defined API routes and builds requests as defined
|
||||
BuildRequestsFromPaths(spec, client, replayClient)
|
||||
return BuildRequestsFromPaths(spec, client, replayClient)
|
||||
}
|
||||
|
||||
func ResolveRef(spec map[string]interface{}, ref string) map[string]interface{} {
|
||||
@@ -721,6 +832,75 @@ func normalizeBasePath(path string) string {
|
||||
return path
|
||||
}
|
||||
|
||||
// encodePair returns an encoded "name=value" pair for a query string or an
|
||||
// x-www-form-urlencoded body. This and replacePathParam are the only places
|
||||
// the --raw-values escape hatch is honored, so the printed curl command and
|
||||
// the request that is actually sent can never disagree.
|
||||
func encodePair(name string, value interface{}) string {
|
||||
v := fmt.Sprintf("%v", value)
|
||||
if rawValues {
|
||||
return name + "=" + v
|
||||
}
|
||||
return url.QueryEscape(name) + "=" + url.QueryEscape(v)
|
||||
}
|
||||
|
||||
// appendQueryParam returns rawURL with an encoded pair appended, using "?" or
|
||||
// "&" depending on whether rawURL already carries a query string.
|
||||
func appendQueryParam(rawURL, name string, value interface{}) string {
|
||||
sep := "?"
|
||||
if strings.Contains(rawURL, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return rawURL + sep + encodePair(name, value)
|
||||
}
|
||||
|
||||
// replacePathParam substitutes the first {name} placeholder in rawURL with a
|
||||
// path-escaped value so a "/", "?" or "#" cannot re-segment the URL.
|
||||
func replacePathParam(rawURL, name, value string) string {
|
||||
if !rawValues {
|
||||
value = url.PathEscape(value)
|
||||
}
|
||||
return strings.Replace(rawURL, "{"+name+"}", value, 1)
|
||||
}
|
||||
|
||||
// appendFormField returns an x-www-form-urlencoded body with an encoded pair
|
||||
// appended, separated by "&" when body is not empty.
|
||||
func appendFormField(body, name string, value interface{}) string {
|
||||
pair := encodePair(name, value)
|
||||
if body == "" {
|
||||
return pair
|
||||
}
|
||||
return body + "&" + pair
|
||||
}
|
||||
|
||||
// encodeFormBody serializes obj as an x-www-form-urlencoded body with keys in
|
||||
// sorted order so repeated runs produce identical output.
|
||||
func encodeFormBody(obj map[string]interface{}) string {
|
||||
var body string
|
||||
for _, k := range slices.Sorted(maps.Keys(obj)) {
|
||||
body = appendFormField(body, k, obj[k])
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// shellSingleQuote wraps s in single quotes for a printed shell command,
|
||||
// escaping any embedded single quote the POSIX way (close, escape, reopen) so
|
||||
// the command stays runnable. This is presentation only and is not affected
|
||||
// by --raw-values.
|
||||
func shellSingleQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// escapeXmlText returns s with XML metacharacters escaped so a value
|
||||
// containing '<', '>' or '&' cannot break out of its element.
|
||||
func escapeXmlText(s string) string {
|
||||
var b bytes.Buffer
|
||||
if err := xml.EscapeText(&b, []byte(s)); err != nil {
|
||||
return ""
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ResolveRefWithContext resolves a reference and returns both the resolved schema and the spec it came from
|
||||
func ResolveRefWithContext(spec map[string]interface{}, ref string) (map[string]interface{}, map[string]interface{}) {
|
||||
// Handle external references (e.g., "./schemas/user.yaml#/User")
|
||||
@@ -798,8 +978,8 @@ func ResolveExternalRef(ref string, baseDir string) map[string]interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
externalSpec := SafelyUnmarshalSpec(fileData)
|
||||
if externalSpec == nil {
|
||||
externalSpec, err := SafelyUnmarshalSpec(fileData)
|
||||
if err != nil || externalSpec == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -816,13 +996,15 @@ func ResolveExternalRef(ref string, baseDir string) map[string]interface{} {
|
||||
func PrintSpecInfo(spec map[string]interface{}) {
|
||||
info, ok := spec["info"].(map[string]interface{})
|
||||
if !ok || info == nil {
|
||||
printInfo("No information defined in the documentation.\n")
|
||||
if !quiet {
|
||||
printInfo("No information defined in the documentation.\n")
|
||||
}
|
||||
} else {
|
||||
title, ok := info["title"].(string)
|
||||
if ok && title != "" {
|
||||
if outputFormat == "json" {
|
||||
specTitle = title
|
||||
} else {
|
||||
} else if !quiet {
|
||||
printInfo("Title: %s\n", title)
|
||||
}
|
||||
}
|
||||
@@ -831,7 +1013,7 @@ func PrintSpecInfo(spec map[string]interface{}) {
|
||||
if ok && description != "" {
|
||||
if outputFormat == "json" {
|
||||
specDescription = description
|
||||
} else {
|
||||
} else if !quiet {
|
||||
printInfo("Description: %s\n", description)
|
||||
}
|
||||
}
|
||||
@@ -873,14 +1055,14 @@ func looksLikeJSSpec(b []byte) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func SafelyUnmarshalSpec(data []byte) map[string]interface{} {
|
||||
func SafelyUnmarshalSpec(data []byte) (map[string]interface{}, error) {
|
||||
|
||||
var doc map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
die("Failed to unmarshal API documentation: %v", err)
|
||||
return nil, fmt.Errorf("failed to unmarshal API documentation: %v", err)
|
||||
}
|
||||
|
||||
return doc
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -904,8 +1086,9 @@ func TrimHostScheme(apiTarget, fullUrlHost string) (host string) {
|
||||
func XmlFromObject(obj map[string]interface{}) string {
|
||||
var b strings.Builder
|
||||
|
||||
for k, v := range obj {
|
||||
switch val := v.(type) {
|
||||
// Sorted keys keep the generated body identical between runs.
|
||||
for _, k := range slices.Sorted(maps.Keys(obj)) {
|
||||
switch val := obj[k].(type) {
|
||||
case map[string]interface{}:
|
||||
b.WriteString("<" + k + ">")
|
||||
b.WriteString(XmlFromObject(val))
|
||||
@@ -916,12 +1099,12 @@ func XmlFromObject(obj map[string]interface{}) string {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
b.WriteString(XmlFromObject(m))
|
||||
} else {
|
||||
b.WriteString(XmlFromObject(m))
|
||||
b.WriteString(escapeXmlText(fmt.Sprintf("%v", item)))
|
||||
}
|
||||
b.WriteString("</" + k + ">")
|
||||
}
|
||||
default:
|
||||
b.WriteString(fmt.Sprintf("<%s>%v</%s>", k, val, k))
|
||||
b.WriteString("<" + k + ">" + escapeXmlText(fmt.Sprintf("%v", val)) + "</" + k + ">")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1081
-11
File diff suppressed because it is too large.
Load diff
@@ -4,7 +4,7 @@ go 1.25
|
||||
|
||||
require (
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/getkin/kin-openapi v0.144.0
|
||||
github.com/getkin/kin-openapi v0.149.0
|
||||
github.com/spf13/cobra v1.6.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -18,7 +18,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // 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/santhosh-tekuri/jsonschema/v6 v6.0.3 // 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
|
||||
|
||||
@@ -6,8 +6,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK
|
||||
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.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y=
|
||||
github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
|
||||
github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA=
|
||||
github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY=
|
||||
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=
|
||||
@@ -35,8 +35,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
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/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/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=
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Multi Content Type Test
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/multi:
|
||||
post:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Widget'
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Widget'
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Widget'
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Widget'
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
/vendor:
|
||||
post:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
requestBody:
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Widget'
|
||||
application/json; charset=utf-8:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Widget'
|
||||
/anytype:
|
||||
post:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
requestBody:
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: '#/components/schemas/Widget'
|
||||
/binary:
|
||||
post:
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
requestBody:
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
/described:
|
||||
get:
|
||||
responses:
|
||||
'404':
|
||||
description: Widget not found
|
||||
default:
|
||||
description: Unexpected error
|
||||
components:
|
||||
schemas:
|
||||
Widget:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
@@ -0,0 +1,64 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Security Requirements Test API
|
||||
version: 1.0.0
|
||||
description: Spec exercising global, per-operation, public, and undefined security.
|
||||
servers:
|
||||
- url: https://api.example.com/v1
|
||||
# Global default: every operation requires bearerAuth unless it overrides below.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/users:
|
||||
# Inherits the global bearerAuth requirement (no operation-level security).
|
||||
get:
|
||||
summary: List users
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
/health:
|
||||
# Explicitly public: an empty array overrides the global default.
|
||||
get:
|
||||
summary: Health check
|
||||
security: []
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
/keyed:
|
||||
# Operation-level override to a different, defined scheme.
|
||||
get:
|
||||
summary: Read with API key
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
/optional:
|
||||
# Anonymous access ({}) accepted alongside bearerAuth.
|
||||
get:
|
||||
summary: Optionally authenticated
|
||||
security:
|
||||
- {}
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
/legacy:
|
||||
# References a scheme that is not defined under securitySchemes.
|
||||
post:
|
||||
summary: Legacy endpoint
|
||||
security:
|
||||
- oldKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
apiKeyAuth:
|
||||
type: apiKey
|
||||
name: X-API-Key
|
||||
in: header
|
||||
Reference in new issue
Block a user