Compare commits

..
69 Commits
Author SHA1 Message Date
Tony West e1aea16c6e Incremented version string 2026-10-29 21:26:40 -04:00
Tony West e5796ed66a Merge pull request #60 from BishopFox/dependabot/go_modules/github.com/getkin/kin-openapi-0.149.0
chore(deps): bump github.com/getkin/kin-openapi from 0.144.0 to 0.149.0
2026-10-29 21:25:55 -04:00
dependabot[bot] ef5822c230 chore(deps): bump github.com/getkin/kin-openapi from 0.144.0 to 0.149.0
Bumps [github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi) from 0.144.0 to 0.149.0.
- [Release notes](https://github.com/getkin/kin-openapi/releases)
- [Commits](https://github.com/getkin/kin-openapi/compare/v0.144.0...v0.149.0)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-19 17:55:21 +00:00
Tony West 9111e90a20 Updated release section to include mention of joro 2026-10-02 16:56:27 -04:00
Tony West 04e0b22c47 Added release section to enable draft releases 2026-10-02 16:49:02 -04:00
Tony West 3c8a2d3c3a Incremented version string 2026-10-02 16:44:16 -04:00
Tony West 64470d1995 fix: Degrade instead of dying on multiple/relative servers; remove package die() 2026-10-02 00:06:21 -04:00
Tony West 4e9ae71caa fix: Derive command mode from cobra instead of os.Args[1] 2026-10-01 23:39:31 -04:00
Tony West 85c4a44421 fix: Read per-operation security requirements, ignored until now 2026-10-01 23:02:59 -04:00
Tony West 023e210b75 fix: Apply bearer auth from spec-compliant http/bearer schemes 2026-10-01 20:53:08 -04:00
Tony West 5f83f1cb26 fix: Read Swagger 2.0 securityDefinitions and apply apiKey query params 2026-10-01 20:41:21 -04:00
Tony West 6d262a369d fix: Keep all declared request body content types instead of last-wins 2026-10-01 20:29:13 -04:00
Tony West 97eab8c4ba Updated documentation 2026-09-29 18:16:03 -04:00
Tony West 6c7ee7d7da fix: Send declared request bodies on every method, not just POST 2026-09-29 18:15:57 -04:00
Tony West f2b722ca55 fix: Send in:header, in:cookie, and in:formData parameters, not just render them in curl 2026-09-29 17:34:12 -04:00
Tony West b0a281ccf1 Updated documentation 2026-09-29 16:58:39 -04:00
Tony West c9f4472553 fix: Escape parameter values in URLs and generated curl commands 2026-09-29 16:58:32 -04:00
Tony West 001f8f16b1 fix: Scope the schema cycle guard to the current ref path so sibling expand 2026-09-29 14:50:45 -04:00
Tony West 31aa3b3f2f fix: Merge allOf subschemas into the schema node instead of replacing it 2026-09-29 14:32:20 -04:00
Tony West 186841c2ba fix: Omit the API title and description from output when -q is used 2026-09-29 13:47:59 -04:00
Tony West 7db065923a fix: Honor lowercase -H keys and never send an empty parameter value 2026-09-29 12:46:21 -04:00
Tony West 4f256efca6 Incremented version string 2026-09-11 21:44:23 -04:00
Tony West 48c4d83f54 fix: Moved remaining diagnostic output to stderr so stdout stays pipeable 2026-09-11 21:44:15 -04:00
Tony West 365f68696f Merge pull request #59 from emilloc/fix/auth-stderr
fix: send authentication diagnostics to stderr
2026-09-11 21:36:58 -04:00
rootkid fbf484c3a1 fix: send authentication diagnostics to stderr 2026-09-10 18:56:14 -06:00
Tony West bc708d69f8 Incremented version string 2026-08-24 17:34:11 -04:00
Tony West 56346e759e Merge pull request #58 from BishopFox/dependabot/go_modules/github.com/getkin/kin-openapi-0.144.0
Bump github.com/getkin/kin-openapi from 0.132.0 to 0.144.0
2026-08-24 17:33:38 -04:00
dependabot[bot] a69b4dcae1 Bump github.com/getkin/kin-openapi from 0.132.0 to 0.144.0
Bumps [github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi) from 0.132.0 to 0.144.0.
- [Release notes](https://github.com/getkin/kin-openapi/releases)
- [Commits](https://github.com/getkin/kin-openapi/compare/v0.132.0...v0.144.0)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-19 17:26:47 +00:00
Tony West 7d9fa54a58 Incremented version string 2026-07-22 08:52:23 -04:00
Tony West 09a1183e6f Added more discoverability features and basic WAF detection to the brute command 2026-07-22 08:52:05 -04:00
Tony West 90e9c78d9a Incremented version string 2026-07-09 11:30:07 -04:00
Tony West 3f99ffb48c Added docker support for releases 2026-07-09 11:29:51 -04:00
Tony West 331bb1a05c Incremented version string 2026-05-13 19:31:55 -04:00
Tony West 3c2a3b807d added fix to auto-extract JS-wrapped specs in automate/endpoints/prepare; fixed POST body leak, Content-Type carryover, and multipart boundary issues 2026-05-13 19:31:22 -04:00
Tony West 7ef8abdb8e Incremented version string 2026-05-13 14:47:17 -04:00
Tony West f1f9e19422 Improved detection for JS specs wrapped in swaggerDoc/spec/openapi 2026-05-13 14:46:47 -04:00
Tony West 0d6776b758 Incremented version string 2026-03-24 16:00:16 -04:00
Tony West 8f4e4a5d9f Fixes #55 2026-03-24 16:00:07 -04:00
Tony West 55baa79785 Incremented version string 2026-03-20 11:37:19 -04:00
Tony West 5bdafb64d8 Merge pull request #54 from bsysop/main
Adds new features from pull request
2026-03-20 11:35:58 -04:00
Guillermo Gregorio 27b59354fc Adds --replay-proxy flag to replay matched requests through a separate proxy 2026-03-19 22:04:42 -03:00
Guillermo Gregorio bee5088fcf Adds --force flag to bypass dangerous keyword checks 2026-03-13 14:56:15 -03:00
Tony West 1eda960182 Fixed bug where custom headers were not sent with the brute command 2026-03-13 00:13:54 -04:00
Tony West e805a7d190 Incremented version string 2026-02-27 22:11:10 -05:00
Tony West 18b20ad476 Merge pull request #52 from aringo-bf/feature/ref-resolution-hardening
Added several features/tests/bug fixes.
2026-02-27 22:10:01 -05:00
Tony West 5115b3cba3 Apply suggestions from code review 2026-02-27 22:06:13 -05:00
aringo-bf 942ad84c54 fix: reconcile merged ref-hardening changes with current logging helpers 2026-02-27 16:06:56 -06:00
aringo-bf 878f7dcd39 fix: normalize server base paths for v3 parsing 2026-02-27 16:05:18 -06:00
aringo-bf 275f426fc2 fix: extract path from absolute server URLs when -T flag is used
High priority fix:
- OpenAPI v3 specs with absolute server URLs (e.g., https://api.example.com/v1)
  now preserve the path component (/v1) when -T flag overrides the host
- Parse absolute server URL to extract path and set as basePath when apiTarget
  is already set by -T flag
- Impact: -T https://staging.example.com with spec servers[0].url =
  https://api.example.com/v1 now produces https://staging.example.com/v1/users
  (was https://staging.example.com/users)

Test coverage:
- Added TestOpenAPIv3AbsoluteServerURLWithTargetFlag to verify path extraction
- Test validates both basePath extraction and apiTarget preservation
- All 10 tests pass (9 PASS, 1 SKIP)
2026-02-27 16:04:42 -06:00
aringo-bf 6d027a66ce fix: preserve basePath with -T flag and allow endpoints with relative URLs
1. -T flag now preserves server path prefixes from spec
2. endpoints command works with relative server URLs
3. Enhanced tests to assert actual runtime behavior
2026-02-27 16:04:02 -06:00
aringo-bf 703039dc75 fix: JSON curl quoting bug and relative server URL handling
High priority fix:
1. Remove trailing double-quote from JSON curl generation
   - Changed -d '%s'" to -d '%s' on line 216
   - Fixes shell syntax errors in prepare output
   - Fixes malformed POST bodies in automate (was stripping " but leaving ')
   - Impact: JSON POST requests now valid shell and valid HTTP bodies

Medium priority fix:
2. Handle relative server URLs in local file mode
   - Detect when spec has relative URL (e.g., /api/v1) and no base available
   - Fail fast with clear error: "Use -T to specify target server"
   - Prevents silent generation of invalid URLs (://...)
   - Impact: Clear user guidance instead of skipped endpoints

Testing:
- Verified JSON curl has proper quoting: -d '{"key":"val"}'
- Verified bash syntax validation passes
- Added TestJSONCurlQuoting and TestRelativeServerURLHandling
- All 8 tests pass (7 PASS, 1 SKIP)
- Added test_relative_server.yaml fixture
2026-02-27 16:03:01 -06:00
aringo-bf e2a94378bf fix: resolve 4 critical reference resolution bugs
1. External parameter/requestBody refs preserve context for nested refs
2. Nested external refs resolve relative to parent file directory
3. Swagger v2 local files use proper URL schemes from spec
4. Added comprehensive Go test suite with 6 test functions
2026-02-27 16:02:55 -06:00
aringo-bf c9fa4f1257 fix: resolve all 5 auditor-reported bugs
Fixed external refs, nested refs, query object params, extra body fields, and default values
2026-02-27 16:02:51 -06:00
aringo-bf 90f0870bfb feat: comprehensive spec reference resolution improvements
Enhanced ResolveRef for Swagger v2 and OpenAPI v3, added external file references, implemented oneOf/anyOf/additionalProperties, refactored parameter handling, added test suite
2026-02-27 16:02:47 -06:00
Tony West 122d9d0114 Fixed sorting to ensure output is consistent across runs 2026-02-26 11:34:23 -05:00
Tony West ac50c4da75 Fixed typo in usage example 2026-02-26 11:14:19 -05:00
Tony West 6eea0f8a77 Improved output and removed logrus dependency 2026-02-26 11:10:04 -05:00
Tony West bdea5822e8 Incremented version string 2026-02-26 10:34:50 -05:00
Tony West 7bb228d81c Fixed infinite loop when encountering circular redirects 2026-02-26 10:18:37 -05:00
Tony West 3258ca341f Fixed missing length check issue 2026-02-26 10:12:36 -05:00
Tony West 58d4e91760 Fixed variable shadowing issue 2026-02-26 10:05:56 -05:00
Tony West 1f5a946011 Converted status code logic to switch for readability 2026-02-26 10:02:32 -05:00
Tony West 143fdfda1b Merge pull request #50 from aringo-bf/fix/httpbasic-segfault-and-quiet-flag
Fix HTTPBasic segfault and --quiet flag issues
2026-02-26 09:10:36 -05:00
aringo-bf a33eef47f4 Fix HTTPBasic segfault and --quiet flag issues
This commit fixes three critical bugs that caused segmentation faults:

1. HTTPBasic auth ignores --quiet flag
   - HTTPBasic was the only auth type that did not respect --quiet
   - Added quiet flag check to skip prompts and show warning instead
   - Brings HTTPBasic in line with other auth types

2. Nil pointer dereference in http.NewRequest error handling
   - When http.NewRequest failed with context.Canceled or io.EOF,
     code continued with nil req object
   - Restructured error handling to return early when req is nil

3. Unchecked url.Parse errors causing nil pointer panics
   - Multiple url.Parse calls ignored errors, allowing nil derefs
   - Added proper error checking before accessing parsed URL fields

Fixes segmentation fault when processing OpenAPI specs with HTTPBasic
auth in automated/quiet mode.

Bumps version to 2.3.1
2026-02-25 21:57:34 -06:00
Tony West 4c466112ee Added custom date, email, and URL flags 2026-02-03 17:10:52 -05:00
Tony West 1195ff7fbf Incremented version string 2026-02-03 13:09:21 -05:00
Tony West bd3265d0a4 Improved handling of schema references 2026-02-03 13:08:53 -05:00
Tony West d9fac2e9fe Updated version string 2026-01-28 14:30:49 -05:00
Tony West 10fe0376c0 Added the ability to customize the value used for testing identified string parameters 2026-01-28 14:30:25 -05:00
33 changed files with 6330 additions and 1024 deletions

No files matched your search

+27 -1
View File
@@ -30,6 +30,24 @@ archives:
- goos: windows
format: zip
dockers_v2:
- images:
- "ghcr.io/bishopfox/sj"
dockerfile: Dockerfile
platforms:
- linux/amd64
- linux/arm64
tags:
- "{{ .Version }}"
- "latest"
labels:
org.opencontainers.image.title: "{{ .ProjectName }}"
org.opencontainers.image.description: "Swagger Jacker"
org.opencontainers.image.version: "{{ .Version }}"
org.opencontainers.image.revision: "{{ .FullCommit }}"
org.opencontainers.image.source: "https://github.com/BishopFox/sj"
org.opencontainers.image.licenses: "MIT"
changelog:
disable: false
sort: asc
@@ -45,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.
+7
View File
@@ -0,0 +1,7 @@
FROM gcr.io/distroless/static-debian12:nonroot
ARG TARGETOS
ARG TARGETARCH
COPY ${TARGETOS}/${TARGETARCH}/sj /usr/local/bin/sj
ENTRYPOINT ["/usr/local/bin/sj"]
+120 -41
View File
@@ -37,58 +37,135 @@ $ export PATH=$PATH:~/go/bin
> Use the `automate` command to send a series of requests to each defined endpoint and analyze the status code of each response.
```bash
$ sj automate -u https://petstore.swagger.io/v2/swagger.json -qi -p http://127.0.0.1:8080
$ sj automate -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.
INFO Endpoint accessible! Method=GET Status=200 Target=/v2/v2/pet/findByTags
INFO Endpoint accessible! Method=GET Status=200 Target=/v2/v2/pet/1
WARN Manual testing may be required. Method=POST Status=415 Target=/v2/v2/pet/1
WARN Manual testing may be required. Method=POST Status=400 Target=/v2/v2/store/order
WARN Manual testing may be required. Method=POST Status=400 Target=/v2/v2/user/createWithList
INFO Endpoint accessible! Method=GET Status=200 Target=/v2/v2/user/login
INFO Endpoint accessible! Method=GET Status=200 Target=/v2/v2/user/logout
WARN Manual testing may be required. Method=POST Status=415 Target=/v2/v2/pet/1/uploadImage
WARN Manual testing may be required. Method=POST Status=400 Target=/v2/v2/pet
WARN Manual testing may be required. Method=PUT Status=415 Target=/v2/v2/pet
INFO Endpoint accessible! Method=GET Status=200 Target=/v2/v2/pet/findByStatus
INFO Endpoint accessible! Method=GET Status=200 Target=/v2/v2/store/inventory
INFO Endpoint accessible! Method=GET Status=200 Target=/v2/v2/store/order/1
ERRO Endpoint not found. Method=GET Status=404 Target=/v2/v2/user/bishopfox
WARN Manual testing may be required. Method=PUT Status=415 Target=/v2/v2/user/bishopfox
WARN Manual testing may be required. Method=POST Status=400 Target=/v2/v2/user/createWithArray
WARN Manual testing may be required. Method=POST Status=400 Target=/v2/v2/user
Gathering API details.
⚠ 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:
```bash
$ sj automate -u https://petstore.swagger.io/v2/swagger.json -qi --replay-proxy http://127.0.0.1:8080
```
You can also combine it with `--proxy` to route scanning traffic through a different proxy while replaying matches to Burp:
```bash
$ sj automate -u https://petstore.swagger.io/v2/swagger.json -qi -p http://proxy:9090 --replay-proxy http://127.0.0.1:8080
```
You can also request verbose output to see the partial (or full) response:
```bash
$ sj automate -u https://petstore.swagger.io/v2/swagger.json -qi -p http://127.0.0.1:8080 -v
Gathering API details.
⚠ 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
✓ 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
@@ -176,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.
@@ -185,6 +263,7 @@ Flags:
-o, --outfile string Output the results to a file. Only supported for the 'automate' and 'brute' commands at this time.
-p, --proxy string Proxy host and port. Example: http://127.0.0.1:8080 (default "NOPROXY")
-q, --quiet Do not prompt for user input - uses default values for all requests.
--replay-proxy string Replay matched requests using this proxy.
--randomize-user-agent Randomizes the user agent string. Default is 'false'.
-s, --safe-word stringArray Avoids 'dangerous word' check for the specified word(s). Multiple flags are accepted.
-T, --target string Manually set a target for the requests to be made if separate from the host the documentation resides on.
+463 -98
View File
@@ -3,9 +3,9 @@ package cmd
import (
"encoding/base64"
"fmt"
"maps"
"slices"
"strings"
log "github.com/sirupsen/logrus"
)
var (
@@ -17,115 +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
)
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 {
fmt.Println("No security schemes defined.")
} else {
if outputFormat != "json" {
fmt.Println("Found security schemes:")
// 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
}
var apiKey string
var apiKeyName string
}
return normalized
}
for mechanism, value := range securitySchemes {
fmt.Printf(" - %s\n", mechanism)
scheme, ok := value.(map[string]interface{})
if !ok {
continue
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{}) {
securitySchemes := securitySchemeSource(spec)
if len(securitySchemes) == 0 {
printInfo("No security schemes defined.\n")
return
}
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":
fmt.Println("Basic Authentication is accepted. Supply a username and password? (y/N)")
fmt.Scanln(&autoApplyBasicAuth)
autoApplyBasicAuth = strings.ToLower(autoApplyBasicAuth)
if autoApplyBasicAuth == "y" {
fmt.Printf("Enter a username.")
fmt.Scanln(&basicAuthUser)
fmt.Printf("Enter a password.")
fmt.Scanln(&basicAuthPass)
basicAuth = []byte(basicAuthUser + ":" + basicAuthPass)
basicAuthString = base64.StdEncoding.EncodeToString(basicAuth)
log.Infof("Using %s as the Basic Auth value.", basicAuthString)
Headers = append(Headers, "Authorization: Basic "+basicAuthString)
} else {
log.Warn("A basic authentication header is accepted. Review the spec and craft a header manually using the -H flag.")
}
case "bearer":
log.Warn("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":
log.Infof("An API key can be provided via a parameter string. Would you like to apply one? (y/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
}
fmt.Printf("What value would you like to use for the API key (%s)?", apiKeyName)
fmt.Scanln(&apiKey)
log.Infof("Using %s=%s as the API key in all requests.", apiKeyName, apiKey)
}
case "header":
if mechanism == "bearer" {
log.Infof("A bearer token is accepted. Would you like to provide one? (y/N)")
if quiet {
autoApplyBearer = "n"
} else {
fmt.Scanln(&autoApplyBearer)
autoApplyBearer = strings.ToLower(autoApplyBearer)
}
if autoApplyBearer == "y" {
fmt.Printf("What value would you like to use for the Bearer Token? ")
fmt.Scanln(&bearerToken)
Headers = append(Headers, "Authorization: Bearer "+bearerToken)
} else {
log.Warn("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 {
log.Infof("An API key can be provided via the header %s. Would you like to apply one? (y/N)", nameVal)
if quiet {
autoApplyAPIKey = "n"
} else {
fmt.Scanln(&autoApplyAPIKey)
autoApplyAPIKey = strings.ToLower(autoApplyAPIKey)
}
if autoApplyAPIKey == "y" {
apiKeyName = nameVal
fmt.Printf("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 {
fmt.Println(" - bearerFormat:", 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
}
+469
View File
@@ -0,0 +1,469 @@
package cmd
import (
"os"
"strings"
"testing"
)
func TestCheckSecuritySchemesJSONDiagnostics(t *testing.T) {
stdout, err := os.CreateTemp(t.TempDir(), "stdout")
if err != nil {
t.Fatal(err)
}
stderr, err := os.CreateTemp(t.TempDir(), "stderr")
if err != nil {
t.Fatal(err)
}
previousStdout, previousStderr := os.Stdout, os.Stderr
previousFormat, previousQuiet := outputFormat, quiet
defer func() {
os.Stdout, os.Stderr = previousStdout, previousStderr
outputFormat, quiet = previousFormat, previousQuiet
stdout.Close()
stderr.Close()
}()
os.Stdout, os.Stderr = stdout, stderr
outputFormat, quiet = "json", true
CheckSecuritySchemes(map[string]interface{}{
"components": map[string]interface{}{
"securitySchemes": map[string]interface{}{
"ExampleBearer": map[string]interface{}{
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
},
},
},
})
stdoutBytes, err := os.ReadFile(stdout.Name())
if err != nil {
t.Fatal(err)
}
if len(stdoutBytes) != 0 {
t.Fatalf("authentication diagnostics written to stdout: %q", stdoutBytes)
}
stderrBytes, err := os.ReadFile(stderr.Name())
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"ExampleBearer", "bearerFormat: JWT"} {
if !strings.Contains(string(stderrBytes), expected) {
t.Errorf("stderr does not contain %q: %q", expected, stderrBytes)
}
}
}
// 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)
}
}
}
+31 -11
View File
@@ -4,16 +4,17 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
log "github.com/sirupsen/logrus"
)
var getAccessibleEndpoints bool
var outputFormat string
var responsePreviewLength int
var testString string
var verbose bool
var automateCmd = &cobra.Command{
@@ -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" {
log.Fatal("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"
}
@@ -39,25 +40,43 @@ responds in an abnormal way, manual testing should be conducted (prepare manual
if randomUserAgent {
if UserAgent != "Swagger Jacker (github.com/BishopFox/sj)" {
log.Warnf("A supplied User Agent was detected (%s) while supplying the 'random-user-agent' flag.", UserAgent)
printWarn("A supplied User Agent was detected (%s) while supplying the 'random-user-agent' flag.", UserAgent)
}
}
_, err := time.Parse("2006-01-02", customDate)
if err != nil {
return fmt.Errorf("an invalid date was supplied. Please supply a date in '2006-01-02' format")
}
var bodyBytes []byte
client := CheckAndConfigureProxy()
client, replayClient, err := CheckAndConfigureProxy()
if err != nil {
return err
}
if strings.ToLower(outputFormat) != "json" {
fmt.Printf("\n")
log.Infof("Gathering API details.\n")
printInfo("\n")
printInfo("Gathering API details.\n")
}
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 {
log.Fatal("Error opening file:", err)
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)
@@ -67,13 +86,14 @@ 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)
return GenerateRequests(bodyBytes, client, replayClient)
},
}
func init() {
automateCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "F", "console", "The output format. Only 'console' (default) and 'json' are supported at the moment.")
automateCmd.PersistentFlags().BoolVar(&getAccessibleEndpoints, "get-accessible-endpoints", false, "Only output the accessible endpoints (those that return a 200 status code).")
automateCmd.PersistentFlags().StringVar(&testString, "test-string", "bishopfox", "The string to use when testing endpoints with string values.")
automateCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "enable verbose mode, which shows a preview of each response.")
automateCmd.PersistentFlags().IntVar(&responsePreviewLength, "response-preview-length", 50, "sets the response preview length when using verbose output.")
+275
View File
@@ -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}
}
+267
View File
@@ -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))
}
})
}
+207 -70
View File
@@ -16,112 +16,136 @@ import (
"github.com/getkin/kin-openapi/openapi3"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
log "github.com/sirupsen/logrus"
)
var endpointOnly bool
var endpointWordlist string
// sawWAFChallenge ensures the WAF/challenge warning is emitted only once per run.
var sawWAFChallenge bool
// warnWAFChallengeOnce warns, once, that responses look like a WAF challenge.
func warnWAFChallengeOnce() {
if sawWAFChallenge {
return
}
sawWAFChallenge = true
printWarn("Some responses look like a Cloudflare/WAF browser challenge (e.g. an HTTP 403 \"Just a moment...\" interstitial), so requests are likely being blocked before reaching the app.")
printWarn("To scan a challenge-protected target, capture a solved browser session and replay it, e.g.:\n\tsj brute -u %s -A \"<your browser User-Agent>\" -H \"Cookie: cf_clearance=<value>; __cf_bm=<value>\"\n(the cf_clearance cookie is bound to your IP + User-Agent, so run from the same host and use the exact browser User-Agent).", swaggerURL)
}
var prefixDirs []string = []string{"", "/swagger", "/swagger/docs", "/swagger/latest", "/swagger/v1", "/swagger/v2", "/swagger/v3", "/swagger/static", "/swagger/ui", "/swagger-ui", "/api-docs", "/api-docs/v1", "/api-docs/v2", "/apidocs", "/api", "/api/v1", "/api/v2", "/api/v3", "/v1", "/v2", "/v3", "/doc", "/docs", "/docs/swagger", "/docs/swagger/v1", "/docs/swagger/v2", "/docs/swagger-ui", "/docs/swagger-ui/v1", "/docs/swagger-ui/v2", "/docs/v1", "/docs/v2", "/docs/v3", "/public", "/redoc"}
var jsonEndpoints []string = []string{"", "/index", "/swagger", "/swagger-ui", "/swagger-resources", "/swagger-config", "/openapi", "/api", "/api-docs", "/apidocs", "/v1", "/v2", "/v3", "/doc", "/docs", "/apispec", "/apispec_1", "/api-merged"}
var javascriptEndpoints []string = []string{"/swagger-ui-init", "/swagger-ui-bundle", "/swagger-ui-standalone-preset", "/swagger-ui", "/swagger-ui.min", "/swagger-ui-es-bundle-core", "/swagger-ui-es-bundle", "/swagger-ui-standalone-preset", "/swagger-ui-layout", "/swagger-ui-plugins"}
var htmlEndpoints []string = []string{"/index", "/swagger", "/swagger-ui", "/api-docs"}
var priorityURLs []string = []string{"/swagger.json", "/openapi.json", "/api-docs", "/swagger", "/docs", "/api/swagger.json", "/api/openapi.json", "/api-docs/swagger.json", "/api/schema/", "/webjars/swagger-ui/index.html", "/API/swagger/ui/index", "/swagger/ui/index", "/v2/swagger.json", "/v2/openapi.json", "/v2/api-docs", "/v3/api-docs", "/v3/openapi.json", "/public/api-merged.json", "/analytics/v1/swagger", "/api.json", "/api/4.0/swagger.json", "/api/api-doc/openapi.json", "/api/api-doc/openapi.yaml", "/api/doc.json", "/api/docs.json", "/api/swagger", "/api/swagger/ui/index", "/api/v1/swagger", "/api/v2/api-docs", "/api/v2/openapi.json", "/api/v2/swagger.json", "/api/v3/api-docs", "/api/v3/apispec", "/api/workorder/openapi.json", "/apidocs", "/audiences/v1/swagger", "/audittrail/v1/swagger", "/certification/v1/swagger", "/citrixapi/store/swagger.json", "/conferencetool/v1/swagger", "/course/v1/swagger", "/dcl_swagger.yaml", "/doc/doc.json", "/doc/swagger.json", "/docs/swagger.json", "/docs/v1/swagger.json", "/ecommerce/v1/swagger", "/enrollment/v1/swagger", "/externalids/v1/swagger", "/impact/v1/swagger", "/learn/v1/swagger", "/learningplan/v1/swagger", "/manage/v1/swagger", "/management/info", "/marketplace/v1/swagger", "/messenger/v1/swagger", "/notifications/v1/swagger", "/openapi", "/openapi/spec.json", "/otj/v1/swagger", "/pages/v1/swagger", "/poweruser/v1/swagger", "/proctoring/v1/swagger", "/report/v1/swagger", "/swagger-ui/index.html", "/swagger-ui/openapi.json", "/swagger.yaml", "/swagger/0.1.0/swagger.json", "/swagger/doc.json", "/swagger/latest/swagger.json", "/swagger/swagger.json", "/swagger/test/swagger.json", "/swagger/ui/index.html", "/swagger/v1/openapiv2.json", "/swagger/v1/swagger.json", "/swagger/v2/swagger.json", "/swagger/v4/swagger.json", "/v1/openapi.json", "/v1/swagger", "/v1/swagger.json", "/swagger/docs/v1", "/swagger/docs/v1.json", "/Api/swagger/docs/v1", "/swagger/v1/swagger.json", "/api/api-docs/swagger.json", "/api/docs/", "/api/docs", "/swagger-ui"}
var bruteCmd = &cobra.Command{
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 {
log.Fatal("Invalid rate supplied. Must be a positive number")
die("Invalid rate supplied. Must be a positive number")
}
*/
if randomUserAgent {
if UserAgent != "Swagger Jacker (github.com/BishopFox/sj)" {
log.Warnf("A supplied User Agent was detected (%s) while supplying the 'random-user-agent' flag.", UserAgent)
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 allURLs []string
u, err := url.Parse(swaggerURL)
if err != nil {
log.Warnf("Error parsing URL:%s\n", err)
printWarn("Error parsing URL: %s", err)
}
target := u.Scheme + "://" + u.Host
normalizedBasePath := normalizeBasePath(basePath)
if endpointWordlist == "" {
allURLs = append(allURLs, makeURLs(target, priorityURLs, "", true)...)
allURLs = append(allURLs, makeURLs(target, jsonEndpoints, "", false)...)
allURLs = append(allURLs, makeURLs(target, javascriptEndpoints, ".js", false)...)
allURLs = append(allURLs, makeURLs(target, jsonEndpoints, ".json", false)...)
allURLs = append(allURLs, makeURLs(target, jsonEndpoints, "/", false)...)
// HTML UI entrypoints first: one hit cascades to its JS initializer
// and spec, so UI-referenced specs are found early. Then the priority
// list, then the JS and JSON sweeps.
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, htmlEndpoints, ".html", false)...)
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, priorityURLs, "", true)...)
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, javascriptEndpoints, ".js", false)...)
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, jsonEndpoints, "", false)...)
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, jsonEndpoints, ".json", false)...)
allURLs = append(allURLs, makeURLs(target, normalizedBasePath, jsonEndpoints, "/", false)...)
} else {
endpointList, err := os.Open(endpointWordlist)
if err != nil {
log.Fatalf("failed to open file: %s", err)
return fmt.Errorf("failed to open file: %s", err)
}
defer endpointList.Close()
scanner := bufio.NewScanner(endpointList)
for scanner.Scan() {
endpoint := scanner.Text()
fullURL := target + endpoint
fullURL := target + normalizedBasePath + endpoint
allURLs = append(allURLs, fullURL)
}
if err := scanner.Err(); err != nil {
log.Fatalf("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" {
log.Infof("Sending %d requests at a rate of %d requests per second. This could take a while...\n", len(allURLs), rateLimit)
printInfo("Sending %d requests at a rate of %d requests per second. This could take a while...\n", len(allURLs), rateLimit)
} else {
log.Infof("Sending %d requests. This could take a while...\n", len(allURLs))
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 {
log.Errorf("Error parsing definition file:%s\n", err)
printErr("Error parsing definition file: %s", err)
}
if outfile != "" {
file, err := os.OpenFile(outfile, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Errorf("Error opening file: %s\n", err)
printErr("Error opening file: %s", err)
}
defer file.Close()
_, err = file.Write(definedOperations)
if err != nil {
log.Errorf("Error writing file: %s\n", err)
printErr("Error writing file: %s", err)
} else {
f, _ := filepath.Abs(outfile)
log.Infof("Wrote file to %s\n", f)
printInfo("Wrote file to %s\n", f)
}
} else {
if endpointOnly {
return
return nil
} else {
fmt.Println(string(definedOperations))
}
}
// TODO: Check if (future implementation) automate flag is true and if so than call the 'sj automate' command with the discovered definition file.
} else {
log.Errorf("\nNo definition file found for:\t%s\n", swaggerURL)
printErr("\nNo definition file found for:\t%s", swaggerURL)
}
return nil
},
}
func makeURLs(target string, endpoints []string, fileExtension string, skipPrefix bool) []string {
func makeURLs(target string, basePath string, endpoints []string, fileExtension string, skipPrefix bool) []string {
urls := []string{}
if !skipPrefix {
for _, dir := range prefixDirs {
@@ -129,7 +153,7 @@ func makeURLs(target string, endpoints []string, fileExtension string, skipPrefi
if dir == "" && endpoint == "" {
continue
}
targetURL := target + dir + endpoint + fileExtension
targetURL := target + basePath + dir + endpoint + fileExtension
urls = append(urls, targetURL)
}
@@ -139,49 +163,119 @@ func makeURLs(target string, endpoints []string, fileExtension string, skipPrefi
if endpoint == "" {
continue
}
targetURL := target + endpoint + fileExtension
targetURL := target + basePath + endpoint + fileExtension
urls = append(urls, targetURL)
}
}
return urls
}
func findDefinitionFile(urls []string, client http.Client) (bool, *openapi3.T) {
// maxFollowDepth bounds how far brute chases discovered spec references.
const maxFollowDepth = 3
func findDefinitionFile(urls []string, client http.Client) (bool, *openapi3.T, error) {
seen := make(map[string]bool)
for i, url := range urls {
ct := CheckContentType(client, url)
if strings.Contains(ct, "application/json") {
bodyBytes, _, _ := MakeRequest(client, "GET", url, timeout, nil)
if bodyBytes != nil {
checkSpec := UnmarshalSpec(bodyBytes)
if (strings.HasPrefix(checkSpec.OpenAPI, "2") || strings.HasPrefix(checkSpec.OpenAPI, "3")) && checkSpec.Paths != nil {
fmt.Println("")
log.Infof("Definition file found: %s\n", url)
return true, checkSpec
}
}
} else if strings.Contains(ct, "application/javascript") {
bodyBytes, bodyString, _ := MakeRequest(client, "GET", url, timeout, nil)
if bodyBytes != nil {
regexPattern := regexp.MustCompile(`(?s)let\s+(\w+)\s*=\s*({.*?});`)
matches := regexPattern.FindAllStringSubmatch(bodyString, -1)
for _, match := range matches {
jsonContent := match[2]
checkSpec := UnmarshalSpec([]byte(jsonContent))
if strings.HasPrefix(checkSpec.OpenAPI, "2") || strings.HasPrefix(checkSpec.OpenAPI, "3") {
log.Infof("\nFound operation definitions embedded in JavaScript file at %s\n", url)
return true, checkSpec
}
}
}
found, checkSpec, err := checkURLForSpec(url, client, seen, 0)
if err != nil {
return false, nil, err
}
if i == len(urls) {
fmt.Printf("\033[2K\r%s%d\n", "Request: ", i+1)
} else {
fmt.Printf("\033[2K\r%s%d", "Request: ", i+1)
if found {
return true, checkSpec, nil
}
writeProgress("Request: %d", i+1)
}
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, error) {
if depth > maxFollowDepth || seen[target] || isExternalDemoSpecURL(target) {
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, _, reqErr := MakeRequestFull(client, "GET", target, timeout, nil)
if reqErr != nil {
return false, nil, reqErr
}
if bodyBytes == 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, nil
}
}
return false, nil
if looksLikeChallengeResponse(bodyString) {
warnWAFChallengeOnce()
return false, nil, nil
}
// Swagger UI HTML page: follow the references it declares.
if strings.Contains(ct, "text/html") || looksLikeHTMLDocument(bodyString) {
return followReferences(ExtractSpecURLsFromHTML(bodyString, target), client, seen, depth)
}
// JavaScript initializer: embedded spec, or the spec url it configures.
// Library bundles are excluded to avoid their internal url: strings.
if strings.Contains(ct, "javascript") || strings.HasSuffix(strings.ToLower(urlPath(target)), ".js") {
if !looksLikeSwaggerInitCandidate(target) {
return false, nil, nil
}
if bodyHasEmbeddedSpec(bodyString) {
if jsonContent, ok := ExtractJSONFromJSSpec(bodyBytes); ok {
checkSpec, _ := UnmarshalSpec(jsonContent)
if strings.HasPrefix(checkSpec.OpenAPI, "2") || strings.HasPrefix(checkSpec.OpenAPI, "3") {
printInfo("Found operation definitions embedded in JavaScript file at %s\n", target)
return true, checkSpec, nil
}
}
}
var refs []string
for _, ref := range extractJSSpecURLs(bodyString) {
if !looksLikeSpecReference(ref) {
continue
}
if abs, ok := resolveReferenceURL(target, ref); ok {
refs = append(refs, abs)
}
}
return followReferences(dedupeURLs(refs), client, seen, depth)
}
// Otherwise treat the body as a candidate spec and validate by parsing.
checkSpec, _ := UnmarshalSpec(bodyBytes)
if (strings.HasPrefix(checkSpec.OpenAPI, "2") || strings.HasPrefix(checkSpec.OpenAPI, "3")) && checkSpec.Paths != nil {
printInfo("Definition file found: %s\n", target)
return true, checkSpec, nil
}
// Not a spec: may be a swagger-config document that references one.
return followReferences(ExtractSpecURLsFromHTML(bodyString, target), client, seen, depth)
}
// followReferences recurses into a set of discovered spec/reference URLs.
func followReferences(refs []string, client http.Client, seen map[string]bool, depth int) (bool, *openapi3.T, error) {
for _, ref := range refs {
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, nil
}
func init() {
@@ -190,6 +284,49 @@ func init() {
bruteCmd.Flags().BoolVarP(&endpointOnly, "endpoint-only", "e", false, "Only return the identified endpoint")
}
// ExtractJSONFromJSSpec scans a Swagger-UI-style JavaScript bundle for an
// OpenAPI/Swagger document assigned to a top-level var/let/const. If the
// captured value is itself a wrapper object (swagger-ui-express / swagger-ui
// bundle config), it unwraps one level via the swaggerDoc/spec/openapi keys.
// Returns the extracted JSON bytes and true on success, or the original
// bytes and false on failure.
func ExtractJSONFromJSSpec(bodyBytes []byte) ([]byte, bool) {
re := regexp.MustCompile(`(?s)(?:let|const|var)\s+(\w+)\s*=\s*({.*?});`)
matches := re.FindAllStringSubmatch(string(bodyBytes), -1)
for _, m := range matches {
if len(m) < 3 {
continue
}
candidate := []byte(m[2])
if looksLikeAPISpec(candidate) {
return candidate, true
}
var wrapper map[string]json.RawMessage
if err := json.Unmarshal(candidate, &wrapper); err != nil {
continue
}
for _, key := range []string{"swaggerDoc", "spec", "openapi"} {
if inner, ok := wrapper[key]; ok && looksLikeAPISpec(inner) {
return inner, true
}
}
}
return bodyBytes, false
}
func looksLikeAPISpec(b []byte) bool {
var probe struct {
OpenAPI string `json:"openapi"`
Swagger string `json:"swagger"`
}
if err := json.Unmarshal(b, &probe); err != nil {
return false
}
return strings.HasPrefix(probe.OpenAPI, "3") ||
strings.HasPrefix(probe.OpenAPI, "2") ||
strings.HasPrefix(probe.Swagger, "2")
}
func ExtractSpecFromJS(bodyBytes []byte) []byte {
var openApiIndex int
var specClose int
@@ -212,7 +349,7 @@ func ExtractSpecFromJS(bodyBytes []byte) []byte {
bodyBytes = []byte(spec[openApiIndex:specClose])
_ = json.Unmarshal(bodyBytes, &doc2)
if !strings.Contains(doc2.Swagger, "2") {
log.Error("Error parsing JavaScript file for spec. Try saving the object as a JSON file and reference it locally.")
printErr("Error parsing JavaScript file for spec. Try saving the object as a JSON file and reference it locally.")
}
}
} else if strings.Contains(strings.ReplaceAll(bodyString, " ", ""), `"openapi":"3`) {
@@ -228,17 +365,17 @@ func ExtractSpecFromJS(bodyBytes []byte) []byte {
bodyBytes = []byte(spec[openApiIndex:specClose])
_ = json.Unmarshal(bodyBytes, &doc3)
if !strings.Contains(doc3.OpenAPI, "3") {
log.Error("Error parsing JavaScript file for spec. Try saving the object as a JSON file and reference it locally.")
printErr("Error parsing JavaScript file for spec. Try saving the object as a JSON file and reference it locally.")
}
}
} else {
log.Error("Error parsing JavaScript file for spec. Try saving the object as a JSON file and reference it locally.")
printErr("Error parsing JavaScript file for spec. Try saving the object as a JSON file and reference it locally.")
}
return bodyBytes
}
func UnmarshalSpec(bodyBytes []byte) (newDoc *openapi3.T) {
func UnmarshalSpec(bodyBytes []byte) (*openapi3.T, error) {
var doc openapi2.T
var doc3 openapi3.T
@@ -254,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 {
fmt.Printf("Error converting v2 document to v3: %s\n", err)
printErr("Error converting v2 document to v3: %s", err)
}
return newDoc
} else if os.Args[1] == "brute" {
return newDoc, nil
} else if Mode == "brute" {
var noDoc openapi3.T
return &noDoc
return &noDoc, nil
} else {
log.Fatal("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")
}
}
+23 -17
View File
@@ -11,7 +11,6 @@ import (
"github.com/getkin/kin-openapi/openapi2"
"github.com/getkin/kin-openapi/openapi2conv"
"github.com/getkin/kin-openapi/openapi3"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
@@ -20,29 +19,35 @@ 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
if randomUserAgent {
if UserAgent != "Swagger Jacker (github.com/BishopFox/sj)" {
log.Warnf("A supplied User Agent was detected (%s) while supplying the 'random-user-agent' flag.", UserAgent)
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
}
if strings.ToLower(outputFormat) != "json" {
fmt.Printf("\n")
log.Infof("Gathering API details.\n\n")
printInfo("\n")
printInfo("Gathering API details.\n\n")
}
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 {
log.Fatal("Error opening definition file:", err)
return fmt.Errorf("error opening definition file: %v", err)
}
bodyBytes, _ = io.ReadAll(specFile)
@@ -61,22 +66,22 @@ var convertCmd = &cobra.Command{
}
if strings.HasPrefix(doc3.OpenAPI, "3") {
log.Warnln("Definition file is already version 3.")
printWarn("Definition file is already version 3.")
WriteConvertedDefinitionFile(bodyBytes)
} else if strings.HasPrefix(doc.Swagger, "2") {
newDoc, err := openapi2conv.ToV3(&doc)
if err != nil {
fmt.Printf("Error converting v2 document to v3: %s\n", err)
printErr("Error converting v2 document to v3: %s", err)
}
switch format {
case "json":
if strings.HasSuffix(outfile, "yaml") || strings.HasSuffix(outfile, "yml") {
log.Warn("It looks like you're trying to save the file in YAML format. Supply the '-f yaml' option to do so (default: json).")
printWarn("It looks like you're trying to save the file in YAML format. Supply the '-f yaml' option to do so (default: json).")
}
converted, err := json.Marshal(newDoc)
if err != nil {
log.Fatal("Error converting definition file to v3:", err)
return fmt.Errorf("error converting definition file to v3: %v", err)
}
if !strings.HasSuffix(string(converted), "}") {
if outfile == "" {
@@ -95,7 +100,7 @@ var convertCmd = &cobra.Command{
case "yaml", "yml":
converted, err := yaml.Marshal(newDoc)
if err != nil {
log.Fatal("Error converting definition file to v3:", err)
return fmt.Errorf("error converting definition file to v3: %v", err)
}
if outfile == "" {
fmt.Println(string(converted))
@@ -105,24 +110,25 @@ var convertCmd = &cobra.Command{
}
} else {
log.Fatal("Error parsing definition file.")
return fmt.Errorf("error parsing definition file")
}
return nil
},
}
func WriteConvertedDefinitionFile(data []byte) {
file, err := os.OpenFile(outfile, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Errorf("Error opening file: %s\n", err)
printErr("Error opening file: %s", err)
}
defer file.Close()
_, err = file.Write(data)
if err != nil {
log.Errorf("Error writing file: %s\n", err)
printErr("Error writing file: %s", err)
} else {
f, _ := filepath.Abs(outfile)
log.Infof("Wrote file to %s\n", f)
printInfo("Wrote file to %s\n", f)
}
}
+240
View File
@@ -0,0 +1,240 @@
package cmd
import (
"net/url"
"regexp"
"strings"
)
// Helpers for following spec references out of Swagger UI HTML pages and their
// initializer scripts. Extraction is permissive: candidates are captured and
// validated by the caller (fetch + parse), so custom document names still work.
// url / "url" / configUrl / specUrl assignment values (also each entry of a urls: [...] array).
var htmlSpecKeyRE = regexp.MustCompile(`(?i)"?(?:config|spec)?url"?\s*:\s*["']([^"']+)["']`)
// spec-url / href / src attribute values.
var htmlSpecAttrRE = regexp.MustCompile(`(?i)(?:spec-url|href|src)\s*=\s*["']([^"']+)["']`)
// Swashbuckle discoveryPaths: arrayFrom('a|b') first argument.
var swashbuckleDiscoveryRE = regexp.MustCompile(`(?i)discoveryPaths\s*:\s*arrayFrom\(\s*['"]([^'"]+)['"]`)
// url: assignments inside an initializer script; a { or , before url avoids matching unrelated keys.
var jsSpecURLAssignRE = []*regexp.Regexp{
regexp.MustCompile(`(?i)(?:{\s*|,\s*)url\s*:\s*"([^"]+)"`),
regexp.MustCompile(`(?i)(?:{\s*|,\s*)url\s*:\s*'([^']+)'`),
regexp.MustCompile(`(?i)(?:{\s*|,\s*)"url"\s*:\s*"([^"]+)"`),
}
// ExtractSpecURLsFromHTML returns the spec/initializer URLs referenced by a
// Swagger UI page (or swagger-config JSON), resolved against pageURL and deduped.
func ExtractSpecURLsFromHTML(htmlContent, pageURL string) []string {
var out []string
add := func(ref string) {
if abs, ok := resolveReferenceURL(pageURL, ref); ok {
out = append(out, abs)
}
}
for _, m := range htmlSpecKeyRE.FindAllStringSubmatch(htmlContent, -1) {
if len(m) > 1 && looksLikeSpecReference(m[1]) {
add(m[1])
}
}
// Keep spec-like href/src refs, plus initializer scripts (followed for the url they configure).
for _, m := range htmlSpecAttrRE.FindAllStringSubmatch(htmlContent, -1) {
if len(m) > 1 && (looksLikeSpecReference(m[1]) || looksLikeSwaggerInitCandidate(m[1])) {
add(m[1])
}
}
// Swashbuckle discoveryPaths are relative to the site root.
if dm := swashbuckleDiscoveryRE.FindStringSubmatch(htmlContent); len(dm) > 1 {
if paths := strings.Split(dm[1], "|"); len(paths) > 0 {
first := strings.TrimSpace(paths[0])
if first != "" {
if !strings.HasPrefix(first, "/") && !strings.Contains(first, "://") {
first = "/" + first
}
add(first)
}
}
}
return dedupeURLs(out)
}
// extractJSSpecURLs pulls url: assignments from an initializer script body.
func extractJSSpecURLs(js string) []string {
var out []string
for _, re := range jsSpecURLAssignRE {
for _, m := range re.FindAllStringSubmatch(js, -1) {
if len(m) > 1 {
out = append(out, m[1])
}
}
}
return out
}
// looksLikeSpecReference accepts references that plausibly point at an
// OpenAPI/Swagger document (including custom .json/.yaml names) and rejects UI
// assets and bundles. Matches are still validated by fetching and parsing.
func looksLikeSpecReference(ref string) bool {
s := strings.TrimSpace(ref)
if s == "" {
return false
}
lo := strings.ToLower(s)
if strings.HasPrefix(lo, "javascript:") || strings.HasPrefix(lo, "data:") || strings.HasPrefix(lo, "#") {
return false
}
for _, ext := range []string{".js", ".css", ".map", ".html", ".htm", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf"} {
if strings.HasSuffix(lo, ext) {
return false
}
}
if strings.Contains(lo, "swagger-ui-bundle") || strings.Contains(lo, "swagger-ui-standalone") {
return false
}
if strings.Contains(lo, "api-docs") || strings.Contains(lo, "api_docs") || strings.Contains(lo, "apidocs") {
return true
}
if strings.Contains(lo, "openapi") {
return true
}
if strings.Contains(lo, "swagger.json") || strings.Contains(lo, "swagger.yaml") || strings.Contains(lo, "swagger.yml") {
return true
}
if strings.Contains(lo, "api-json") || strings.Contains(lo, "api_json") {
return true
}
if strings.HasSuffix(lo, ".json") || strings.HasSuffix(lo, ".yaml") || strings.HasSuffix(lo, ".yml") {
return true
}
if strings.HasSuffix(lo, "-json") || strings.HasSuffix(lo, "_json") {
return true
}
if strings.Contains(lo, "swagger-resources") {
return true
}
return false
}
// Library bundles are never followed as a config source: they are large and full of internal url: strings.
var swaggerUILibraryScripts = []string{
"swagger-ui-bundle", "swagger-ui-standalone", "swagger-ui-es-bundle",
"swagger-ui-layout", "swagger-ui-plugins", "swagger-ui.min", "swagger-ui.js",
"redoc.standalone", "redoc.min", "redoc.js",
}
// looksLikeSwaggerInitCandidate reports whether a .js reference is a plausible
// initializer (swagger-initializer.js, swagger-ui-init.js, index.js, ...) rather
// than a library bundle.
func looksLikeSwaggerInitCandidate(rawURL string) bool {
p := strings.ToLower(urlPath(rawURL))
if !strings.HasSuffix(p, ".js") {
return false
}
for _, lib := range swaggerUILibraryScripts {
if strings.Contains(p, lib) {
return false
}
}
return true
}
// urlPath returns the path component of a URL, or the input unchanged if it does
// not parse (so relative references still work).
func urlPath(raw string) string {
if u, err := url.Parse(raw); err == nil && u.Path != "" {
return u.Path
}
return raw
}
// bodyHasEmbeddedSpec is a cheap sniff for a spec inlined in a JavaScript body.
func bodyHasEmbeddedSpec(body string) bool {
lo := strings.ToLower(body)
return strings.Contains(lo, `"paths"`) && (strings.Contains(lo, `"openapi"`) || strings.Contains(lo, `"swagger"`))
}
// looksLikeHTMLDocument reports whether a response body is an HTML document.
func looksLikeHTMLDocument(body string) bool {
t := strings.TrimSpace(strings.ToLower(body))
return strings.HasPrefix(t, "<!doctype") || strings.HasPrefix(t, "<html")
}
// Substrings found in Cloudflare/WAF browser-challenge interstitial pages.
var challengeMarkers = []string{
"just a moment...",
"_cf_chl_opt",
"challenge-platform",
"cf-browser-verification",
"cf_chl_",
"enable javascript and cookies to continue",
"checking your browser before accessing",
"attention required! | cloudflare",
"ddos protection by cloudflare",
"/cdn-cgi/challenge-platform",
}
// looksLikeChallengeResponse reports whether a response body is a WAF/anti-bot challenge page.
func looksLikeChallengeResponse(body string) bool {
lo := strings.ToLower(body)
for _, m := range challengeMarkers {
if strings.Contains(lo, m) {
return true
}
}
return false
}
// isExternalDemoSpecURL guards against following the SwaggerUIBundle default demo specs.
func isExternalDemoSpecURL(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
switch strings.ToLower(u.Hostname()) {
case "petstore.swagger.io", "generator.swagger.io", "swagger.io", "www.swagger.io":
return true
}
return false
}
// resolveReferenceURL resolves a (possibly relative) reference against pageURL.
func resolveReferenceURL(pageURL, ref string) (string, bool) {
ref = strings.TrimSpace(ref)
if ref == "" {
return "", false
}
base, err := url.Parse(pageURL)
if err != nil {
return "", false
}
r, err := url.Parse(ref)
if err != nil {
return "", false
}
resolved := base.ResolveReference(r)
if resolved.Scheme == "" || resolved.Host == "" {
return "", false
}
return resolved.String(), true
}
// dedupeURLs removes duplicate and empty entries, preserving order.
func dedupeURLs(in []string) []string {
seen := make(map[string]bool, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
+26 -13
View File
@@ -4,8 +4,8 @@ import (
"fmt"
"io"
"os"
"path/filepath"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
@@ -14,30 +14,43 @@ 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)" {
log.Warnf("A supplied User Agent was detected (%s) while supplying the 'random-user-agent' flag.", UserAgent)
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
fmt.Printf("\n")
log.Infof("Gathering endpoints.\n\n")
printInfo("\n")
printInfo("Gathering endpoints.\n\n")
if swaggerURL != "" {
bodyBytes, _, _ := MakeRequest(client, "GET", swaggerURL, timeout, nil)
GenerateRequests(bodyBytes, client)
} else {
specFile, err := os.Open(localFile)
bodyBytes, _, _, err = MakeRequest(client, "GET", swaggerURL, timeout, nil)
if err != nil {
log.Fatal("Error opening file:", err)
return err
}
bodyBytes, _ = io.ReadAll(specFile)
GenerateRequests(bodyBytes, client)
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)
},
}
+167 -184
View File
@@ -1,32 +1,88 @@
package cmd
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
log "github.com/sirupsen/logrus"
"github.com/fatih/color"
)
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"`
}
var tempLogger *log.Logger
// Diagnostic helpers — all write to stderr so stdout stays clean for piping.
func writeLog(sc int, target, method, errorMsg, response string) {
var file *os.File
tempLogger = log.New()
var green = color.New(color.FgGreen, color.Bold).SprintFunc()
var yellow = color.New(color.FgYellow, color.Bold).SprintFunc()
var red = color.New(color.FgRed, color.Bold).SprintFunc()
var faint = color.New(color.Faint).SprintFunc()
// progressPending is set while an unterminated progress line is on screen, so
// the diagnostic helpers can clear it before printing.
var progressPending bool
// writeProgress renders an overwriting progress line on stderr (the stream the
// diagnostic helpers use, so clearing is reliable).
func writeProgress(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "\r\033[2K"+format, args...)
progressPending = true
}
// clearProgressLine erases a pending progress line so a diagnostic prints cleanly.
func clearProgressLine() {
if progressPending {
fmt.Fprint(os.Stderr, "\r\033[2K")
progressPending = false
}
}
func printInfo(format string, args ...interface{}) {
clearProgressLine()
fmt.Fprintf(os.Stderr, format, args...)
}
func printWarn(format string, args ...interface{}) {
clearProgressLine()
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, "%s %s\n", yellow("[!]"), msg)
}
func printErr(format string, args ...interface{}) {
clearProgressLine()
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, "%s %s\n", red("[✗]"), msg)
}
// writeLog is the main dispatch function for endpoint results.
func writeLog(sc int, target, method, errorMsg, response, security string, missingAuth bool) {
var out io.Writer = os.Stdout
tempResponsePreviewLength := responsePreviewLength
if len(response) < responsePreviewLength {
@@ -36,200 +92,127 @@ 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.Println("Output file does not exist or cannot be created")
os.Exit(1)
}
tempLogger.SetOutput(file)
}
if outputFormat == "console" {
if strings.Contains(response, "\"") {
tempLogger.SetFormatter(&log.TextFormatter{DisableQuote: true, DisableTimestamp: true})
// 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 {
tempLogger.SetFormatter(&log.TextFormatter{DisableTimestamp: true})
defer file.Close()
out = file
// Disable color when writing to a file.
color.NoColor = true
defer func() { color.NoColor = false }()
}
} else {
tempLogger.SetFormatter(&log.JSONFormatter{DisableHTMLEscape: true, DisableTimestamp: true, PrettyPrint: true})
}
preview := ""
if verbose {
if sc != 200 {
if sc == 401 || sc == 403 {
logVerboseUnauth(sc, target, method, errorMsg, response, tempLogger)
} else if sc == 301 || sc == 302 {
logVerboseRedirect(sc, target, method, response, tempLogger)
} else if sc == 0 {
logVerboseBad(sc, target, method, response, tempLogger)
} else if sc == 404 {
logNotFound(sc, target, method, errorMsg, tempLogger)
} else if sc == 1 {
logDangerous(target, method, tempLogger)
} else if sc == 8899 {
logVerboseJSON(specTitle, specDescription)
} else {
logVerboseManual(sc, target, method, errorMsg, response, tempLogger)
}
} else {
logVerboseAccessible(sc, target, method, response, tempLogger)
}
} else {
if sc != 200 {
if sc == 401 || sc == 403 {
logUnauth(sc, target, method, errorMsg, tempLogger)
} else if sc == 301 || sc == 302 {
logRedirect(sc, target, method, tempLogger)
} else if sc == 0 {
logBad(sc, target, method, tempLogger)
} else if sc == 404 {
logNotFound(sc, target, method, errorMsg, tempLogger)
} else if sc == 1 {
logDangerous(target, method, tempLogger)
} else if sc == 8899 {
logJSON(specTitle, specDescription)
} else {
logManual(sc, target, method, errorMsg, tempLogger)
}
} else {
if sc == 8899 {
logJSON(specTitle, specDescription)
} else {
logAccessible(sc, target, method, tempLogger)
}
}
preview = response[:responsePreviewLength]
}
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(title, description, out)
} else {
logJSON(title, description, out)
}
default:
logResult(sc, target, method, errorMsg, preview, security, missingAuth, out)
}
responsePreviewLength = tempResponsePreviewLength
file.Close()
}
func logAccessible(status int, target, method string, logger *log.Logger) {
logger.WithFields(log.Fields{
"Status": status,
"Target": target,
"Method": method,
}).Print("Endpoint accessible!")
}
// logResult renders a single endpoint result line.
func logResult(sc int, target, method, errorMsg, preview, security string, missingAuth bool, out io.Writer) {
var sym string
var painter func(a ...interface{}) string
func logVerboseAccessible(status int, target, method, response string, logger *log.Logger) {
logger.WithFields(log.Fields{
"Status": status,
"Target": target,
"Method": method,
"Preview": response[:responsePreviewLength],
}).Print("Endpoint accessible!")
}
func logDangerous(target, method string, logger *log.Logger) {
logger.WithFields(log.Fields{
"Status": "skipped",
"Target": target,
"Method": method,
}).Warn("Endpoint skipped due to dangerous keyword (or request cancelled due to timeout).")
}
func logManual(status int, target, method, errorMsg string, logger *log.Logger) {
if errorMsg == "" {
errorMsg = "Manual testing may be required."
switch sc {
case 200:
sym = "✓"
painter = green
case 301, 302, 0, 1:
sym = "⚠"
painter = yellow
case 401, 403, 404:
sym = "✗"
painter = red
default:
sym = "⚠"
painter = yellow
}
logger.WithFields(log.Fields{
"Status": status,
"Target": target,
"Method": method,
}).Warn(errorMsg)
}
func logVerboseManual(status int, target, method, errorMsg, response string, logger *log.Logger) {
if errorMsg == "" {
errorMsg = "Manual testing may be required."
statusStr := fmt.Sprintf("%d", sc)
switch sc {
case 0:
statusStr = "N/A"
case 1:
statusStr = "---"
}
logger.WithFields(log.Fields{
"Status": status,
"Target": target,
"Method": method,
"Preview": response[:responsePreviewLength],
}).Warn(errorMsg)
}
func logNotFound(status int, target, method, errorMsg string, logger *log.Logger) {
if errorMsg == "" {
errorMsg = "Endpoint not found."
// 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)
}
logger.WithFields(log.Fields{
"Status": status,
"Target": target,
"Method": method,
}).Error(errorMsg)
}
func logRedirect(status int, target, method string, logger *log.Logger) {
logger.WithFields(log.Fields{
"Status": status,
"Target": target,
"Method": method,
}).Error("Redirect detected. This likely requires authentication.")
}
func logVerboseRedirect(status int, target, method, response string, logger *log.Logger) {
logger.WithFields(log.Fields{
"Status": status,
"Target": target,
"Method": method,
"Preview": response[0:responsePreviewLength],
}).Error("Redirect detected. This likely requires authentication.")
}
func logBad(status int, target, method string, logger *log.Logger) {
logger.WithFields(log.Fields{
"Status": "N/A",
"Target": target,
"Method": method,
}).Warn("Bad request (could not reach the target).")
}
func logVerboseBad(status int, target, method, response string, logger *log.Logger) {
logger.WithFields(log.Fields{
"Status": "N/A",
"Target": target,
"Method": method,
"Preview": response[:responsePreviewLength],
}).Warn("Bad request (could not reach the target).")
}
func logUnauth(status int, target, method, errorMsg string, logger *log.Logger) {
if errorMsg == "" {
errorMsg = "Unauthorized."
// 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+"]")
}
logger.WithFields(log.Fields{
"Status": status,
"Target": target,
"Method": method,
}).Error(errorMsg)
}
// 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))
}
func logVerboseUnauth(status int, target, method, errorMsg, response string, logger *log.Logger) {
if errorMsg == "" {
errorMsg = "Unauthorized."
line := fmt.Sprintf("%s %-7s %-3s %s%s\n",
painter(sym),
painter(method),
painter(statusStr),
target,
annotation,
)
fmt.Fprint(out, line)
if preview != "" {
fmt.Fprintf(out, " %s\n", faint(preview))
}
logger.WithFields(log.Fields{
"Status": status,
"Target": target,
"Method": method,
"Preview": response[:responsePreviewLength],
}).Error(errorMsg)
}
func logJSON(title, description string) {
tempLogger.WithFields(log.Fields{
"apiTitle": title,
"description": description,
"results": jsonResultArray,
}).Println("Done")
func logJSON(title, description string, out io.Writer) {
output := struct {
APITitle string `json:"apiTitle,omitempty"`
Description string `json:"description,omitempty"`
Results []Result `json:"results"`
}{
APITitle: title,
Description: description,
Results: jsonResultArray,
}
data, _ := json.MarshalIndent(output, "", " ")
fmt.Fprintln(out, string(data))
}
func logVerboseJSON(title, description string) {
tempLogger.WithFields(log.Fields{
"apiTitle": title,
"description": description,
"results": jsonVerboseResultArray,
}).Println("Done")
func logVerboseJSON(title, description string, out io.Writer) {
output := struct {
APITitle string `json:"apiTitle,omitempty"`
Description string `json:"description,omitempty"`
Results []VerboseResult `json:"results"`
}{
APITitle: title,
Description: description,
Results: jsonVerboseResultArray,
}
data, _ := json.MarshalIndent(output, "", " ")
fmt.Fprintln(out, string(data))
}
+131
View File
@@ -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)
}
}
+33 -14
View File
@@ -4,8 +4,9 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"time"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
@@ -14,30 +15,48 @@ 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)" {
log.Warnf("A supplied User Agent was detected (%s) while supplying the 'random-user-agent' flag.", UserAgent)
printWarn("A supplied User Agent was detected (%s) while supplying the 'random-user-agent' flag.", UserAgent)
}
}
client := CheckAndConfigureProxy()
_, err := time.Parse("2006-01-02", customDate)
if err != nil {
return fmt.Errorf("an invalid date was supplied. Please supply a date in '2006-01-02' format")
}
fmt.Printf("\n")
log.Infof("Gathering API details.\n\n")
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)
} else {
specFile, err := os.Open(localFile)
bodyBytes, _, _, err := MakeRequest(client, "GET", swaggerURL, timeout, nil)
if err != nil {
log.Fatal("Error opening file:", err)
return err
}
specBytes, _ := io.ReadAll(specFile)
GenerateRequests(specBytes, client)
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
+250 -113
View File
@@ -1,6 +1,7 @@
package cmd
import (
"bytes"
"context"
"crypto/tls"
"fmt"
@@ -9,10 +10,10 @@ import (
"net/http"
"net/url"
"os"
"slices"
"strings"
"time"
log "github.com/sirupsen/logrus"
"unicode"
)
var (
@@ -20,6 +21,7 @@ var (
avoidDangerousRequests string
contentType string
dangerousStrings []string = []string{"add", "block", "build", "buy", "change", "clear", "create", "delete", "deploy", "destroy", "drop", "edit", "emergency", "erase", "execute", "insert", "modify", "order", "overwrite", "pause", "purchase", "rebuild", "remove", "replace", "reset", "restart", "revoke", "run", "sell", "send", "set", "start", "stop", "update", "upload", "write"}
depth int = 0
Headers []string
requestStatus int
riskSurveyed bool = false
@@ -53,158 +55,250 @@ var (
userChoice string
)
func MakeRequest(client http.Client, method, target string, timeout int64, reqData io.Reader) ([]byte, string, int) {
if quiet {
avoidDangerousRequests = "y"
}
// Handling of dangerous keywords
u, _ := url.Parse(target)
endpoint := u.RawPath + "?" + u.RawQuery
for _, v := range dangerousStrings {
if os.Args[1] == "automate" && strings.Contains(endpoint, v) && !strings.Contains(strings.Join(safeWords, ","), v) {
userChoice = ""
if avoidDangerousRequests == "y" {
return nil, "", 0
} else {
log.Warnf("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"
log.Warnf("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 && err != context.Canceled && err != io.EOF {
log.Fatal("Error: could not create HTTP request - ", err)
// 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 {
delimIndex := strings.Index(Headers[i], ":")
if delimIndex == -1 {
log.Warnf("Header provided (%s) cannot be used. Headers must be in 'Key: Value' format (this may be caused by a header declared within the definition file).\n", Headers[i])
printWarn("Header provided (%s) cannot be used. Headers must be in 'Key: Value' format (this may be caused by a header declared within the definition file).", Headers[i])
continue
}
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 accept == "" {
req.Header.Set("Accept", "application/json, text/html, */*")
} else {
req.Header.Set("Accept", accept)
if acceptHdr == "" {
acceptHdr = "application/json, text/html, */*"
}
req.Header.Set("Accept", acceptHdr)
if method == "POST" {
if contentType == "" {
req.Header.Set("Content-Type", "application/json")
} else {
req.Header.Set("Content-Type", contentType)
// 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)
}
resp, err := client.Do(req.WithContext(ctx))
if err == context.DeadlineExceeded {
log.Printf("Error: %s - skipping request.", err)
return nil, "", 0
} else if err != nil && err != context.Canceled && err != io.EOF {
if (strings.Contains(fmt.Sprint(err), "tls") || strings.Contains(fmt.Sprint(err), "x509")) && !strings.Contains(fmt.Sprint(err), "user canceled") {
log.Fatal("Try supplying the --insecure flag.")
} else if strings.Contains(fmt.Sprint(err), "tcp") && strings.Contains(fmt.Sprint(err), "no such host") {
log.Fatalf("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
} else {
log.Error("Error: response not received.\n", err)
}
return nil, "", 0
}
bodyBytes, _ := io.ReadAll(resp.Body)
bodyString := string(bodyBytes)
if (resp.StatusCode == 301 || resp.StatusCode == 302) && strings.Contains(bodyString, "<html>") {
redirect, _ := resp.Location()
bodyBytes, bodyString, requestStatus = MakeRequest(client, method, redirect.Scheme+"://"+redirect.Host+redirect.Path, timeout, reqData)
return bodyBytes, bodyString, requestStatus
}
requestStatus = resp.StatusCode
return bodyBytes, bodyString, requestStatus
return userAgent
}
func CheckContentType(client http.Client, target string) string {
u, _ := url.Parse(target)
// 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
}
// 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()
req, err := http.NewRequest("GET", target, nil)
if err != nil && err != context.Canceled && err != io.EOF {
log.Fatal("Error: could not create HTTP request - ", err)
// 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)
}
// User-Agent handling
if randomUserAgent {
rand.New(rand.NewSource(time.Now().UnixNano()))
UserAgent = userAgents[rand.Intn(len(userAgents))]
req.Header.Set("User-Agent", UserAgent)
} else if UserAgent != "Swagger Jacker (github.com/BishopFox/sj)" {
req.Header.Set("User-Agent", UserAgent)
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 {
log.Printf("Error: %s - skipping request.", err)
return ""
printWarn("Error: %s - skipping request.", err)
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") {
log.Fatal("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") {
log.Fatalf("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, "", "", nil
} else {
log.Error("Error: response not received.\n", err)
printErr("Error: response not received.\n%v", err)
}
return ""
return nil, "", 0, "", "", nil
}
return resp.Header.Get("Content-Type")
bodyBytes, _ := io.ReadAll(resp.Body)
bodyString := string(bodyBytes)
contentTypeHeader := resp.Header.Get("Content-Type")
if (resp.StatusCode == 301 || resp.StatusCode == 302) && strings.Contains(bodyString, "<html>") && depth < 10 {
// 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
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, sentUserAgent, nil
}
func CheckAndConfigureProxy() (client http.Client) {
func CheckAndConfigureProxy() (client http.Client, replayClient *http.Client, err error) {
var proxyUrl *url.URL
transport := &http.Transport{}
@@ -224,5 +318,48 @@ func CheckAndConfigureProxy() (client http.Client) {
return http.ErrUseLastResponse
},
}
return client
if replayProxy != "" {
replayTransport := &http.Transport{}
if insecure {
replayTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
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{
Transport: replayTransport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
return client, replayClient, nil
}
// 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()
req, err := http.NewRequest(method, target, reqData)
if err != nil {
printWarn("Replay: error creating request for %s - %v", target, err)
return
}
applyHeaders(req, userAgent)
resp, err := replayClient.Do(req.WithContext(ctx))
if err != nil {
printWarn("Replay: error sending request to %s - %v", target, err)
return
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
fmt.Fprintf(os.Stderr, "[Replay] %s %s -> %d\n", method, target, resp.StatusCode)
}
+129
View File
@@ -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")
}
}
+42 -7
View File
@@ -1,20 +1,30 @@
package cmd
import (
log "github.com/sirupsen/logrus"
"fmt"
"os"
"github.com/spf13/cobra"
)
var allContentTypes bool
var apiTarget string
var basePath string
var customDate string
var customEmail string
var customURL string
var force bool
var format string
var insecure bool
var localFile string
var 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
@@ -44,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 {
log.Error("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.1.0",
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() {
@@ -62,16 +90,23 @@ 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.")
rootCmd.PersistentFlags().StringVarP(&customDate, "custom-date", "d", "1990-01-01", "A custom date to test discovered date parameters.")
rootCmd.PersistentFlags().StringVar(&customEmail, "custom-email", "[email protected]", "A custom email address to test discovered email parameters.")
rootCmd.PersistentFlags().BoolVar(&force, "force", false, "Send requests without prompting, even if dangerous keywords are detected.")
rootCmd.PersistentFlags().StringVarP(&format, "format", "f", "json", "Declare the format of the definition file (json/yaml/yml/js).")
rootCmd.PersistentFlags().StringArrayVarP(&Headers, "headers", "H", nil, "Add custom headers, separated by a colon (\"Name: Value\"). Multiple flags are accepted.")
rootCmd.PersistentFlags().BoolVarP(&insecure, "insecure", "i", false, "Ignores server certificate validation.")
rootCmd.PersistentFlags().StringVarP(&localFile, "local-file", "l", "", "Loads the documentation from a local file.")
rootCmd.PersistentFlags().StringVarP(&outfile, "outfile", "o", "", "Output the results to a file. Only supported for the 'automate' and 'brute' commands at this time.")
rootCmd.PersistentFlags().StringVarP(&proxy, "proxy", "p", "NOPROXY", "Proxy host and port. Example: http://127.0.0.1:8080")
rootCmd.PersistentFlags().StringVar(&replayProxy, "replay-proxy", "", "Replay matched requests using this proxy.")
rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "Do not prompt for user input - uses default values for all requests.")
rootCmd.PersistentFlags().BoolVar(&randomUserAgent, "randomize-user-agent", false, "Randomizes the user agent string. Default is 'false'.")
rootCmd.PersistentFlags().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.")
+1006 -409
View File
File diff suppressed because it is too large. Load diff
+1793
View File
File diff suppressed because it is too large. Load diff
+13 -14
View File
@@ -1,26 +1,25 @@
module github.com/BishopFox/sj
go 1.22.5
toolchain go1.23.4
go 1.25
require (
github.com/getkin/kin-openapi v0.132.0
github.com/sirupsen/logrus v1.9.0
github.com/fatih/color v1.18.0
github.com/getkin/kin-openapi v0.149.0
github.com/spf13/cobra v1.6.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/oasdiff/yaml v0.1.1 // indirect
github.com/oasdiff/yaml3 v0.0.14 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
+33 -32
View File
@@ -1,55 +1,56 @@
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/getkin/kin-openapi v0.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=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
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=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+46
View File
@@ -0,0 +1,46 @@
openapi: 3.0.0
info:
title: Shared Schemas
version: 1.0.0
components:
schemas:
Customer:
type: object
required:
- customerId
- companyName
properties:
customerId:
type: string
example: "CUST-12345"
companyName:
type: string
example: "Acme Corporation"
contactInfo:
$ref: '#/components/schemas/ContactInfo'
billingAddress:
$ref: '#/components/schemas/Address'
ContactInfo:
type: object
properties:
phone:
type: string
example: "+1-555-0123"
email:
type: string
example: "[email protected]"
Address:
type: object
properties:
street:
type: string
example: "123 Main St"
city:
type: string
example: "Springfield"
state:
type: string
example: "IL"
postalCode:
type: string
example: "62701"
+13
View File
@@ -0,0 +1,13 @@
openapi: 3.0.0
info:
title: Schema A
version: 1.0.0
components:
schemas:
SchemaA:
type: object
properties:
id:
type: string
nested:
$ref: 'schema_b.yaml#/components/schemas/SchemaB'
+12
View File
@@ -0,0 +1,12 @@
openapi: 3.0.0
info:
title: Schema B
version: 1.0.0
components:
schemas:
SchemaB:
type: object
properties:
value:
type: string
example: "nested-value"
+78
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
openapi: 3.0.0
info:
title: Main spec for nested external refs test
version: 1.0.0
servers:
- url: https://api.example.com
paths:
/test:
post:
requestBody:
content:
application/json:
schema:
$ref: 'subdir/schema_a.yaml#/components/schemas/SchemaA'
responses:
'200':
description: OK
+23
View File
@@ -0,0 +1,23 @@
openapi: 3.0.0
info:
title: Query Object Test
version: 1.0.0
servers:
- url: https://api.example.com
paths:
/search:
get:
summary: Search with object filter
parameters:
- name: filter
in: query
schema:
type: object
properties:
name:
type: string
age:
type: integer
responses:
'200':
description: Success
+12
View File
@@ -0,0 +1,12 @@
openapi: 3.0.0
info:
title: Relative Server URL Test
version: 1.0.0
servers:
- url: /api/v1
paths:
/test:
get:
responses:
'200':
description: OK
+30
View File
@@ -0,0 +1,30 @@
openapi: 3.0.0
info:
title: Main API Spec
version: 1.0.0
description: Test spec with external file references
servers:
- url: https://api.example.com/v2
paths:
/customers:
get:
summary: Get customers
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
$ref: './external_schemas.yaml#/components/schemas/Customer'
post:
summary: Create customer
requestBody:
content:
application/json:
schema:
$ref: './external_schemas.yaml#/components/schemas/Customer'
responses:
'201':
description: Created
+64
View File
@@ -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
+124
View File
@@ -0,0 +1,124 @@
swagger: "2.0"
info:
title: Swagger v2 Test API
version: 1.0.0
description: Test spec for validating Swagger 2.0 reference resolution
host: api.example.com
basePath: /v1
schemes:
- https
paths:
/users:
get:
summary: Get all users
parameters:
- $ref: '#/parameters/LimitParam'
- name: offset
in: query
type: integer
responses:
'200':
description: Success
schema:
type: array
items:
$ref: '#/definitions/User'
post:
summary: Create user
parameters:
- name: user
in: body
schema:
$ref: '#/definitions/User'
responses:
'201':
description: Created
/products/{id}:
get:
summary: Get product
parameters:
- name: id
in: path
type: string
required: true
responses:
'200':
description: Success
schema:
$ref: '#/definitions/Product'
/orders:
post:
summary: Create order
parameters:
- name: order
in: body
schema:
type: object
properties:
items:
type: array
items:
$ref: '#/definitions/OrderItem'
total:
type: number
responses:
'201':
description: Created
parameters:
LimitParam:
name: limit
in: query
type: integer
default: 10
definitions:
User:
type: object
required:
- name
- email
properties:
id:
type: integer
example: 42
name:
type: string
example: "Jane Smith"
email:
type: string
example: "[email protected]"
address:
$ref: '#/definitions/Address'
Address:
type: object
properties:
street:
type: string
city:
type: string
zipCode:
type: string
example: "12345"
Product:
type: object
properties:
id:
type: string
name:
type: string
price:
type: number
example: 29.99
category:
type: string
enum:
- electronics
- clothing
- books
OrderItem:
type: object
properties:
productId:
type: string
quantity:
type: integer
example: 2
+159
View File
@@ -0,0 +1,159 @@
openapi: 3.0.0
info:
title: Test API with References
version: 1.0.0
description: Test spec for validating reference resolution improvements
servers:
- url: https://api.example.com/v1
paths:
/users:
get:
summary: Get all users
parameters:
- $ref: '#/components/parameters/LimitParam'
- name: offset
in: query
schema:
type: integer
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
post:
summary: Create user
requestBody:
$ref: '#/components/requestBodies/UserBody'
responses:
'201':
description: Created
/products/{id}:
get:
summary: Get product by ID
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Product'
/pets:
post:
summary: Create a pet
requestBody:
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/Cat'
- $ref: '#/components/schemas/Dog'
responses:
'201':
description: Created
/config:
post:
summary: Update config
requestBody:
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/Config'
responses:
'200':
description: Updated
components:
parameters:
LimitParam:
name: limit
in: query
schema:
type: integer
example: 10
requestBodies:
UserBody:
content:
application/json:
schema:
$ref: '#/components/schemas/User'
schemas:
User:
type: object
required:
- name
- email
properties:
id:
type: integer
example: 42
name:
type: string
example: "John Doe"
email:
type: string
example: "[email protected]"
address:
$ref: '#/components/schemas/Address'
preferences:
type: object
additionalProperties:
type: string
Address:
type: object
properties:
street:
type: string
city:
type: string
country:
type: string
enum:
- USA
- Canada
- UK
Product:
type: object
properties:
id:
type: string
name:
type: string
price:
type: number
tags:
type: array
items:
type: string
Cat:
type: object
properties:
type:
type: string
enum: [cat]
meow:
type: boolean
Dog:
type: object
properties:
type:
type: string
enum: [dog]
bark:
type: boolean
Config:
type: object
properties:
theme:
type: string
example: "dark"
notifications:
type: boolean