mirror of
https://github.com/BishopFox/sj.git
synced 2026-11-07 10:26:01 -10:00
1113 lines
37 KiB
Go
1113 lines
37 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
var accessibleEndpoints []string
|
|
var jsonResultsStringArray []string
|
|
var jsonResultArray []Result
|
|
var jsonVerboseResultArray []VerboseResult
|
|
var specTitle string
|
|
var specDescription string
|
|
var externalRefCache = make(map[string]map[string]interface{})
|
|
var specToFilePath = make(map[*interface{}]string) // Maps external spec pointers to their file paths
|
|
var specBaseDir string // Directory of the loaded spec file for resolving external refs
|
|
|
|
type SchemaNode struct {
|
|
Type string
|
|
Properties map[string]*SchemaNode
|
|
Items *SchemaNode
|
|
Required map[string]bool
|
|
Enum []interface{}
|
|
Example interface{}
|
|
Ref string
|
|
OneOf []*SchemaNode
|
|
AnyOf []*SchemaNode
|
|
AdditionalProperties *SchemaNode
|
|
}
|
|
|
|
func BuildRequestsFromPaths(spec map[string]interface{}, client http.Client, replayClient *http.Client) error {
|
|
paths, ok := spec["paths"].(map[string]interface{})
|
|
if !ok || paths == nil {
|
|
return fmt.Errorf("could not find any defined operations. Review the file manually")
|
|
}
|
|
|
|
pathKeys := make([]string, 0, len(paths))
|
|
for k := range paths {
|
|
pathKeys = append(pathKeys, k)
|
|
}
|
|
slices.Sort(pathKeys)
|
|
|
|
// Snapshot CLI-supplied headers so per-endpoint Content-Type
|
|
// mutations from EnforceSingleContentType don't leak across endpoints.
|
|
userHeaders := append([]string(nil), Headers...)
|
|
var userContentType string
|
|
for _, h := range userHeaders {
|
|
if strings.HasPrefix(strings.ToLower(h), "content-type:") {
|
|
if idx := strings.Index(h, ":"); idx >= 0 {
|
|
userContentType = strings.TrimSpace(h[idx+1:])
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
for _, pathName := range pathKeys {
|
|
pathItem := paths[pathName]
|
|
if ops, ok := pathItem.(map[string]interface{}); ok {
|
|
methodKeys := make([]string, 0, len(ops))
|
|
for k := range ops {
|
|
methodKeys = append(methodKeys, k)
|
|
}
|
|
slices.Sort(methodKeys)
|
|
for _, method := range methodKeys {
|
|
op := ops[method]
|
|
switch strings.ToLower(method) {
|
|
// SKIPS THE "DELETE" AND "PATCH" METHODS FOR SAFETY
|
|
case "delete":
|
|
continue
|
|
case "patch":
|
|
continue
|
|
default:
|
|
if opMap, ok := op.(map[string]interface{}); ok {
|
|
// Reset header state so the previous endpoint's
|
|
// Content-Type doesn't leak into this one.
|
|
Headers = append([]string(nil), userHeaders...)
|
|
contentType = userContentType
|
|
|
|
// Scoped to this operation: a description belongs to the
|
|
// response it was declared under, so one endpoint's "Not
|
|
// found" must never be printed against another's.
|
|
errorDescriptions := make(map[string]string)
|
|
if responses, ok := opMap["responses"].(map[string]interface{}); ok {
|
|
for status, respItem := range responses {
|
|
if respMap, ok := respItem.(map[string]interface{}); ok {
|
|
if desc, ok := respMap["description"].(string); ok {
|
|
errorDescriptions[status] = desc
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The effective security the spec declares for this operation,
|
|
// and whether an auth-required operation is being tested without a
|
|
// matching credential (so a 2xx becomes a missing-auth finding).
|
|
// userHeaders is the pre-mutation snapshot, so it carries any
|
|
// auth-supplied header credential but not this op's header params.
|
|
opSec := resolveOperationSecurity(spec, opMap)
|
|
secLabel := opSec.label()
|
|
authRequiredNoCred := opSec.state == secRequired &&
|
|
!requirementSatisfiedByCredentials(opSec, spec, userHeaders)
|
|
|
|
targetURL := fmt.Sprintf("%s%s%s", apiTarget, basePath, pathName)
|
|
// Apply any query-string API keys from a security scheme
|
|
// before spec params and before curl is composed, so the
|
|
// sent request and the printed command both carry the key.
|
|
for _, qp := range authQueryParams {
|
|
targetURL = appendQueryParam(targetURL, qp.name, qp.value)
|
|
}
|
|
curl := fmt.Sprintf("curl -X %s \"%s\"", strings.ToUpper(method), targetURL)
|
|
// variants collects every body this operation declares, from
|
|
// all three sources below. None is discarded; selectVariants
|
|
// decides which ones are sent.
|
|
var variants []bodyVariant
|
|
// cookiePairs accumulates "in: cookie" parameters so they can be sent
|
|
// as a single Cookie header. formBody accumulates "in: formData"
|
|
// (Swagger v2) fields, which become one urlencoded variant.
|
|
var cookiePairs []string
|
|
// Seed with any cookie-based API keys from a security scheme
|
|
// (apiKey with "in: cookie") so they fold into the same Cookie
|
|
// header as the operation's own cookie params, mirroring how
|
|
// authQueryParams seeds the query string above.
|
|
for _, cp := range authCookieParams {
|
|
cookiePairs = append(cookiePairs, encodePair(cp.name, cp.value))
|
|
}
|
|
var formBody string
|
|
var hasFormData bool
|
|
|
|
// Extracts the expected parameters from the parameters object
|
|
if params, ok := opMap["parameters"].([]interface{}); ok {
|
|
for _, p := range params {
|
|
var pValue string
|
|
|
|
// Handle parameter-level $ref (e.g., #/parameters/... or #/components/parameters/...)
|
|
if pMap, ok := p.(map[string]interface{}); ok {
|
|
// Check if the parameter itself is a reference
|
|
paramContextSpec := spec
|
|
if ref, hasRef := pMap["$ref"].(string); hasRef {
|
|
resolved, contextSpec := ResolveRefWithContext(spec, ref)
|
|
if resolved != nil {
|
|
pMap = resolved
|
|
paramContextSpec = contextSpec
|
|
}
|
|
}
|
|
|
|
if name, ok := pMap["name"].(string); ok {
|
|
in, hasIn := pMap["in"].(string)
|
|
if !hasIn {
|
|
continue
|
|
}
|
|
|
|
// A Swagger v2 "in: body" parameter's schema is the entire request
|
|
// body, so it is serialized as JSON rather than folded into form
|
|
// fields. This covers non-object schemas (an array body) too.
|
|
if in == "body" {
|
|
bodySchema, _ := pMap["schema"].(map[string]interface{})
|
|
expandedBody := ExpandSchema(spec, bodySchema, map[string]bool{}, paramContextSpec)
|
|
if variant, ok := buildVariant(ctJSON, GenerateExample(expandedBody)); ok {
|
|
variants = append(variants, variant)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Handle schema-based parameters (OpenAPI v3 and some v2)
|
|
var handledAsObject bool // Track if we already handled this as an object
|
|
if schema, ok := pMap["schema"].(map[string]interface{}); ok {
|
|
expanded := ExpandSchema(spec, schema, map[string]bool{}, paramContextSpec)
|
|
if expanded.Type == "object" || len(expanded.Properties) > 0 {
|
|
// For object schemas, generate full example and serialize
|
|
example := GenerateExample(expanded)
|
|
if exampleMap, ok := example.(map[string]interface{}); ok {
|
|
// Handle based on parameter location
|
|
if in == "query" {
|
|
// Query params with object schema: add each property to query string
|
|
for _, propertyItem := range slices.Sorted(maps.Keys(exampleMap)) {
|
|
targetURL = appendQueryParam(targetURL, propertyItem, exampleMap[propertyItem])
|
|
}
|
|
handledAsObject = true
|
|
}
|
|
}
|
|
} else {
|
|
// For primitive types, generate simple value
|
|
exampleValue := GenerateExample(expanded)
|
|
if expanded.Type == "string" && name != "version" {
|
|
pValue = testString
|
|
} else if exampleValue != nil {
|
|
pValue = fmt.Sprintf("%v", exampleValue)
|
|
} else {
|
|
pValue = "1"
|
|
}
|
|
}
|
|
} else if pType, ok := pMap["type"].(string); ok {
|
|
// Direct type without schema (Swagger v2 style)
|
|
// Check for default value first
|
|
if defaultVal := pMap["default"]; defaultVal != nil {
|
|
pValue = fmt.Sprintf("%v", defaultVal)
|
|
} else if pType == "string" && name != "version" {
|
|
pValue = testString
|
|
} else {
|
|
pValue = "1"
|
|
}
|
|
} else if defaultVal := pMap["default"]; defaultVal != nil {
|
|
// Use default value if no type or schema
|
|
pValue = fmt.Sprintf("%v", defaultVal)
|
|
} else {
|
|
// Fallback to generic value
|
|
pValue = "1"
|
|
}
|
|
|
|
// An unconsumed object schema leaves pValue empty: "/pet/{id}" -> "/pet/".
|
|
if !handledAsObject && pValue == "" {
|
|
pValue = testString
|
|
}
|
|
|
|
// Only process parameters that weren't already handled as objects
|
|
if !handledAsObject {
|
|
switch in {
|
|
case "query":
|
|
targetURL = appendQueryParam(targetURL, name, pValue)
|
|
case "path":
|
|
targetURL = replacePathParam(targetURL, name, pValue)
|
|
case "header":
|
|
// Append to Headers so the value is actually sent (applyHeaders
|
|
// applies every Headers entry to the request), and mirror it in
|
|
// the printed command. A header value is not a URL component, so
|
|
// it is shell-quoted for display but never percent-encoded.
|
|
Headers = append(Headers, name+": "+pValue)
|
|
curl += " -H " + shellSingleQuote(name+": "+pValue)
|
|
case "cookie":
|
|
// Accumulate cookie params; they are emitted as one Cookie
|
|
// header after every parameter is known.
|
|
cookiePairs = append(cookiePairs, encodePair(name, pValue))
|
|
case "formData":
|
|
// Swagger v2 form field: contributes to the urlencoded body.
|
|
formBody = appendFormField(formBody, name, pValue)
|
|
hasFormData = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Emit accumulated cookie parameters as a single Cookie header,
|
|
// both on the request (via Headers) and in the printed command.
|
|
if len(cookiePairs) > 0 {
|
|
cookieValue := strings.Join(cookiePairs, "; ")
|
|
Headers = append(Headers, "Cookie: "+cookieValue)
|
|
curl += " -b " + shellSingleQuote(cookieValue)
|
|
}
|
|
|
|
// Swagger v2 formData fields have no requestBody to declare a
|
|
// content type, so they become an explicit urlencoded variant.
|
|
if hasFormData {
|
|
variants = append(variants, newTextBodyVariant(ctForm, ctForm, formBody))
|
|
}
|
|
|
|
// Extracts the expected parameters from the requestBody object
|
|
if reqBody, ok := opMap["requestBody"].(map[string]interface{}); ok {
|
|
// Handle requestBody-level $ref (e.g., #/components/requestBodies/...)
|
|
reqBodyContextSpec := spec
|
|
if ref, hasRef := reqBody["$ref"].(string); hasRef {
|
|
resolved, contextSpec := ResolveRefWithContext(spec, ref)
|
|
if resolved != nil {
|
|
reqBody = resolved
|
|
reqBodyContextSpec = contextSpec
|
|
}
|
|
}
|
|
variants = append(variants, buildBodyVariants(spec, reqBody, reqBodyContextSpec)...)
|
|
}
|
|
|
|
logURL, parseErr := url.Parse(targetURL)
|
|
if parseErr != nil || logURL == nil {
|
|
printWarn("Error parsing URL '%s': %v - skipping endpoint.", targetURL, parseErr)
|
|
continue
|
|
}
|
|
|
|
if Mode == "endpoints" {
|
|
// endpoints only lists paths, so the body never matters and
|
|
// one line per path+method is printed regardless of how many
|
|
// content types the operation declares.
|
|
fmt.Println(basePath + pathName)
|
|
continue
|
|
}
|
|
|
|
variants = orderVariants(variants)
|
|
if Mode == "prepare" && strings.EqualFold(prepareFor, "sqlmap") {
|
|
// sqlmap has no -F equivalent, so a multipart variant would
|
|
// print a command that cannot be run.
|
|
variants = slices.DeleteFunc(variants, func(v bodyVariant) bool {
|
|
return v.declared == ctMultipart
|
|
})
|
|
}
|
|
|
|
selected := selectVariants(variants, contentType, allContentTypes)
|
|
if len(selected) == 0 {
|
|
// No declared body: a single bodyless request, as before.
|
|
selected = []bodyVariant{{}}
|
|
}
|
|
|
|
if Mode == "automate" {
|
|
// Ask once per operation. sj may send this same URL once per
|
|
// declared content type, and re-prompting for each one would
|
|
// be noise; approvedTarget stops MakeRequestFull asking again.
|
|
if dangerousRequestDeclined(targetURL) {
|
|
continue
|
|
}
|
|
approvedTarget = targetURL
|
|
}
|
|
|
|
// Snapshot the state every variant starts from. The loop below
|
|
// rebuilds curl and Headers per variant so that what is printed
|
|
// and what goes on the wire describe the same request.
|
|
baseCurl := curl
|
|
baseHeaders := append([]string(nil), Headers...)
|
|
|
|
// The body encoding is only worth reporting when an operation
|
|
// was tested under more than one. Otherwise it would add a field
|
|
// to every result row that says nothing the request did not
|
|
// already imply, and change output every consumer already parses.
|
|
reportContentType := len(selected) > 1
|
|
|
|
for _, v := range selected {
|
|
// Re-copy rather than assign: EnforceSingleContentType
|
|
// compacts Headers in place, so sharing a backing array would
|
|
// let one variant rewrite the snapshot the next one starts from.
|
|
Headers = append([]string(nil), baseHeaders...)
|
|
curl = baseCurl
|
|
|
|
// A bodyless request declares no media type. Enforcing an
|
|
// empty one would append a bare "Content-Type:" that
|
|
// applyHeaders only replaces when the request carries bytes.
|
|
if v.contentType != "" {
|
|
EnforceSingleContentType(v.contentType)
|
|
if !v.omitCurlCT {
|
|
curl += " -H " + shellSingleQuote("Content-Type: "+v.contentType)
|
|
}
|
|
}
|
|
curl += v.curlArgs
|
|
|
|
// Update the curl command with the final targetURL (which may have been modified with query params)
|
|
// Extract and replace the URL in quotes
|
|
curlParts := strings.SplitN(curl, "\"", 3)
|
|
if len(curlParts) >= 3 {
|
|
curl = curlParts[0] + "\"" + targetURL + "\"" + curlParts[2]
|
|
}
|
|
|
|
switch Mode {
|
|
case "automate":
|
|
// These are the same bytes the printed curl command was
|
|
// composed from above, so what is shown and what goes on
|
|
// the wire cannot disagree.
|
|
sentBody := []byte(v.body)
|
|
|
|
_, resp, sc, _, sentUA, reqErr := MakeRequestFull(client, strings.ToUpper(method), targetURL, timeout, bytes.NewReader(sentBody))
|
|
if reqErr != nil {
|
|
return reqErr
|
|
}
|
|
|
|
tempResponsePreviewLength := responsePreviewLength
|
|
if len(resp) <= responsePreviewLength {
|
|
tempResponsePreviewLength = len(resp)
|
|
}
|
|
|
|
// Only annotate responses that went wrong: a 2xx
|
|
// description ("successful operation") only repeats what
|
|
// the status already says. Descriptions are keyed by the
|
|
// status as written in the document, with "default" as the
|
|
// spec's catch-all.
|
|
description := ""
|
|
if sc < 200 || sc > 299 {
|
|
desc, ok := errorDescriptions[strconv.Itoa(sc)]
|
|
if !ok {
|
|
desc = errorDescriptions["default"]
|
|
}
|
|
description = desc
|
|
}
|
|
|
|
reportedCT := ""
|
|
if reportContentType {
|
|
reportedCT = v.declared
|
|
}
|
|
// A spec-declared-auth operation answering 2xx with no
|
|
// credential supplied is a possible broken access control.
|
|
missingAuth := authRequiredNoCred && sc >= 200 && sc <= 299
|
|
var result []byte
|
|
|
|
if verbose {
|
|
result, _ = json.Marshal(VerboseResult{Method: method, Preview: resp[:tempResponsePreviewLength], Status: sc, Target: logURL.Path, Curl: curl, ContentType: reportedCT, Security: secLabel, MissingAuth: missingAuth})
|
|
} else {
|
|
result, _ = json.Marshal(Result{Method: method, Status: sc, Target: logURL.Path, ContentType: reportedCT, Security: secLabel, MissingAuth: missingAuth})
|
|
}
|
|
|
|
if getAccessibleEndpoints {
|
|
if sc == 200 {
|
|
accessibleEndpoints = append(accessibleEndpoints, logURL.Path)
|
|
if jsonResultsStringArray == nil {
|
|
jsonResultsStringArray = append(jsonResultsStringArray, string(result))
|
|
} else {
|
|
jsonResultsStringArray = append(jsonResultsStringArray, ","+string(result))
|
|
}
|
|
if outputFormat == "console" {
|
|
writeLog(sc, logURL.Path, strings.ToUpper(method), description, resp[:tempResponsePreviewLength], secLabel, missingAuth)
|
|
}
|
|
if replayClient != nil {
|
|
ReplayRequest(replayClient, strings.ToUpper(method), targetURL, timeout, bytes.NewReader(sentBody), sentUA)
|
|
}
|
|
}
|
|
} else {
|
|
if jsonResultsStringArray == nil {
|
|
jsonResultsStringArray = append(jsonResultsStringArray, string(result))
|
|
} else {
|
|
jsonResultsStringArray = append(jsonResultsStringArray, ","+string(result))
|
|
}
|
|
if outputFormat == "console" {
|
|
writeLog(sc, logURL.Path, strings.ToUpper(method), description, resp[:tempResponsePreviewLength], secLabel, missingAuth)
|
|
}
|
|
if replayClient != nil {
|
|
ReplayRequest(replayClient, strings.ToUpper(method), targetURL, timeout, bytes.NewReader(sentBody), sentUA)
|
|
}
|
|
}
|
|
|
|
case "prepare":
|
|
var preparedCommand string = curl
|
|
if strings.EqualFold(prepareFor, "sqlmap") {
|
|
preparedCommand = strings.Replace(preparedCommand, "curl", "sqlmap", 1)
|
|
preparedCommand = strings.Replace(preparedCommand, "-X "+strings.ToUpper(method), "--method="+strings.ToUpper(method)+" -u", 1)
|
|
if v.curlBodyArg != "" {
|
|
preparedCommand = strings.Replace(preparedCommand, "-d "+v.curlBodyArg, "--data="+v.curlBodyArg, 1)
|
|
}
|
|
preparedCommand = "$ " + preparedCommand
|
|
} else if strings.EqualFold(prepareFor, "curl") {
|
|
preparedCommand = "$ " + curl
|
|
}
|
|
fmt.Println(preparedCommand)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if Mode == "automate" && outputFormat == "json" {
|
|
for r := range jsonResultsStringArray {
|
|
var result Result
|
|
var verboseResult VerboseResult
|
|
if verbose {
|
|
err := json.Unmarshal([]byte(strings.TrimPrefix(jsonResultsStringArray[r], ",")), &verboseResult)
|
|
if err != nil {
|
|
return fmt.Errorf("error unmarshalling JSON: %v", err)
|
|
}
|
|
jsonVerboseResultArray = append(jsonVerboseResultArray, verboseResult)
|
|
} else {
|
|
err := json.Unmarshal([]byte(strings.TrimPrefix(jsonResultsStringArray[r], ",")), &result)
|
|
if err != nil {
|
|
return fmt.Errorf("error unmarshalling JSON: %v", err)
|
|
}
|
|
jsonResultArray = append(jsonResultArray, result)
|
|
}
|
|
}
|
|
writeLog(8899, "", "", "", "", "", false)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func EnforceSingleContentType(newContentType string) {
|
|
newContentType = strings.TrimSpace(newContentType)
|
|
|
|
// Remove old 'Content-Type' header
|
|
Headers = slices.DeleteFunc(Headers, func(h string) bool {
|
|
return strings.HasPrefix(strings.ToLower(h), "content-type:")
|
|
})
|
|
|
|
Headers = append(Headers, "Content-Type: "+newContentType)
|
|
|
|
// Remove empty elements to avoid repetitions of "-H ''"
|
|
Headers = slices.DeleteFunc(Headers, func(h string) bool {
|
|
return strings.TrimSpace(h) == ""
|
|
})
|
|
}
|
|
|
|
func ExpandSchema(
|
|
spec map[string]interface{},
|
|
schema map[string]interface{},
|
|
visited map[string]bool,
|
|
contextSpec map[string]interface{}, // The spec document this schema belongs to (for resolving nested refs)
|
|
) *SchemaNode {
|
|
if schema == nil {
|
|
return &SchemaNode{Type: "object"}
|
|
}
|
|
|
|
if ref, ok := schema["$ref"].(string); ok {
|
|
if visited[ref] {
|
|
return &SchemaNode{Type: "object"} // break cycle: ref is already on the current path
|
|
}
|
|
|
|
// Resolve ref in the context spec (could be external)
|
|
resolved, resolvedSpec := ResolveRefWithContext(contextSpec, ref)
|
|
if resolved == nil {
|
|
return &SchemaNode{Type: "object"}
|
|
}
|
|
|
|
// Mark ref as on the current expansion path, expand, then pop it so sibling
|
|
// branches can reference the same schema without a false cycle-break.
|
|
visited[ref] = true
|
|
node := ExpandSchema(spec, resolved, visited, resolvedSpec)
|
|
delete(visited, ref)
|
|
return node
|
|
}
|
|
|
|
node := &SchemaNode{
|
|
Properties: map[string]*SchemaNode{},
|
|
Required: map[string]bool{},
|
|
}
|
|
|
|
if t, ok := schema["type"].(string); ok {
|
|
node.Type = t
|
|
}
|
|
|
|
// Handle enum values
|
|
if enum, ok := schema["enum"].([]interface{}); ok {
|
|
node.Enum = enum
|
|
}
|
|
|
|
// Handle example values
|
|
if example := schema["example"]; example != nil {
|
|
node.Example = example
|
|
}
|
|
|
|
// Populate required fields
|
|
if required, ok := schema["required"].([]interface{}); ok {
|
|
for _, r := range required {
|
|
if fieldName, ok := r.(string); ok {
|
|
node.Required[fieldName] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if props, ok := schema["properties"].(map[string]interface{}); ok {
|
|
for name, raw := range props {
|
|
if m, ok := raw.(map[string]interface{}); ok {
|
|
node.Properties[name] = ExpandSchema(spec, m, visited, contextSpec)
|
|
}
|
|
}
|
|
}
|
|
|
|
if items, ok := schema["items"].(map[string]interface{}); ok {
|
|
node.Items = ExpandSchema(spec, items, visited, contextSpec)
|
|
}
|
|
|
|
// Handle additionalProperties
|
|
if addProps, ok := schema["additionalProperties"]; ok {
|
|
if addPropsMap, ok := addProps.(map[string]interface{}); ok {
|
|
node.AdditionalProperties = ExpandSchema(spec, addPropsMap, visited, contextSpec)
|
|
}
|
|
}
|
|
|
|
// Handle allOf (merge every subschema into this node; the node's own values win)
|
|
if allOf, ok := schema["allOf"].([]interface{}); ok {
|
|
for _, entry := range allOf {
|
|
m, ok := entry.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
sub := ExpandSchema(spec, m, visited, contextSpec)
|
|
for k, v := range sub.Properties {
|
|
if _, exists := node.Properties[k]; !exists {
|
|
node.Properties[k] = v
|
|
}
|
|
}
|
|
for k, v := range sub.Required {
|
|
node.Required[k] = v
|
|
}
|
|
if node.Type == "" {
|
|
node.Type = sub.Type
|
|
}
|
|
if node.Enum == nil {
|
|
node.Enum = sub.Enum
|
|
}
|
|
if node.Example == nil {
|
|
node.Example = sub.Example
|
|
}
|
|
if node.Items == nil {
|
|
node.Items = sub.Items
|
|
}
|
|
if node.AdditionalProperties == nil {
|
|
node.AdditionalProperties = sub.AdditionalProperties
|
|
}
|
|
node.OneOf = append(node.OneOf, sub.OneOf...)
|
|
node.AnyOf = append(node.AnyOf, sub.AnyOf...)
|
|
}
|
|
if node.Type == "" {
|
|
node.Type = "object"
|
|
}
|
|
}
|
|
|
|
// Handle oneOf (expand all options)
|
|
if oneOf, ok := schema["oneOf"].([]interface{}); ok {
|
|
for _, entry := range oneOf {
|
|
if m, ok := entry.(map[string]interface{}); ok {
|
|
node.OneOf = append(node.OneOf, ExpandSchema(spec, m, visited, contextSpec))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle anyOf (expand all options)
|
|
if anyOf, ok := schema["anyOf"].([]interface{}); ok {
|
|
for _, entry := range anyOf {
|
|
if m, ok := entry.(map[string]interface{}); ok {
|
|
node.AnyOf = append(node.AnyOf, ExpandSchema(spec, m, visited, contextSpec))
|
|
}
|
|
}
|
|
}
|
|
|
|
return node
|
|
}
|
|
|
|
func GenerateExample(node *SchemaNode) interface{} {
|
|
// Use example value if available
|
|
if node.Example != nil {
|
|
return node.Example
|
|
}
|
|
|
|
// Use first enum value if available
|
|
if len(node.Enum) > 0 {
|
|
return node.Enum[0]
|
|
}
|
|
|
|
// Handle oneOf - use first option
|
|
if len(node.OneOf) > 0 {
|
|
return GenerateExample(node.OneOf[0])
|
|
}
|
|
|
|
// Handle anyOf - use first option
|
|
if len(node.AnyOf) > 0 {
|
|
return GenerateExample(node.AnyOf[0])
|
|
}
|
|
|
|
switch node.Type {
|
|
case "object", "":
|
|
obj := map[string]interface{}{}
|
|
for k, v := range node.Properties {
|
|
if strings.Contains(strings.ToLower(k), "date") {
|
|
obj[k] = customDate
|
|
} else if strings.Contains(strings.ToLower(k), "url") {
|
|
obj[k] = customURL
|
|
} else if strings.Contains(strings.ToLower(k), "email") {
|
|
obj[k] = customEmail
|
|
} else {
|
|
obj[k] = GenerateExample(v)
|
|
}
|
|
}
|
|
// Handle additionalProperties if present and no regular properties
|
|
if len(obj) == 0 && node.AdditionalProperties != nil {
|
|
obj["additionalProp1"] = GenerateExample(node.AdditionalProperties)
|
|
}
|
|
return obj
|
|
case "array":
|
|
if node.Items != nil {
|
|
return []interface{}{GenerateExample(node.Items)}
|
|
}
|
|
return []interface{}{}
|
|
case "string":
|
|
return testString
|
|
case "integer", "number":
|
|
return 1
|
|
case "boolean":
|
|
return true
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func GenerateRequests(bodyBytes []byte, client http.Client, replayClient *http.Client) error {
|
|
// Swagger UI bundles serve the spec wrapped in JavaScript (e.g.
|
|
// swagger-ui-init.js). Detect that and unwrap before parsing so the
|
|
// downstream YAML/JSON unmarshal succeeds.
|
|
if looksLikeJSSpec(bodyBytes) {
|
|
if extracted, ok := ExtractJSONFromJSSpec(bodyBytes); ok {
|
|
bodyBytes = extracted
|
|
}
|
|
}
|
|
|
|
// Ingests the specification file
|
|
spec, err := SafelyUnmarshalSpec(bodyBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Checks defined security schemes and prompts for authentication
|
|
CheckSecuritySchemes(spec)
|
|
|
|
// Reports the effective security requirement of every declared operation so
|
|
// the operator can see which are meant to be authenticated, which are public,
|
|
// and which reference an undefined scheme.
|
|
SummarizeOperationSecurity(spec)
|
|
|
|
u, parseErr := url.Parse(swaggerURL)
|
|
if parseErr != nil {
|
|
u = &url.URL{}
|
|
}
|
|
|
|
// Parse basePath and server info from spec
|
|
// Always extract basePath, even if -T was used (so -T sets host but spec sets path)
|
|
if v, ok := spec["swagger"].(string); ok && strings.HasPrefix(v, "2") {
|
|
// Swagger (v2)
|
|
host, _ := spec["host"].(string)
|
|
bp, _ := spec["basePath"].(string)
|
|
if bp != "" {
|
|
basePath = normalizeBasePath(bp)
|
|
}
|
|
|
|
// Only set apiTarget from spec if -T flag wasn't used
|
|
if apiTarget == "" {
|
|
if host != "" && strings.Contains(host, "://") {
|
|
apiTarget = host
|
|
} else {
|
|
if host != "" {
|
|
scheme := u.Scheme
|
|
// If scheme is empty (e.g., local file), try to get from spec's schemes array
|
|
if scheme == "" {
|
|
if schemes, ok := spec["schemes"].([]interface{}); ok && len(schemes) > 0 {
|
|
if s, ok := schemes[0].(string); ok {
|
|
scheme = s
|
|
}
|
|
}
|
|
}
|
|
// Default to https if still no scheme
|
|
if scheme == "" {
|
|
scheme = "https"
|
|
}
|
|
apiTarget = scheme + "://" + host
|
|
}
|
|
}
|
|
}
|
|
} else if v, ok := spec["openapi"].(string); ok && strings.HasPrefix(v, "3") {
|
|
// OpenAPI (v3)
|
|
if servers, ok := spec["servers"].([]interface{}); ok && len(servers) > 0 {
|
|
// When several servers are declared and the user hasn't pinned one
|
|
// with -T, warn and fall through to the first declared server rather
|
|
// than aborting the run.
|
|
if len(servers) > 1 && !quiet && Mode != "endpoints" && apiTarget == "" {
|
|
printWarn("Multiple servers detected in documentation. Defaulting to the first; set a specific server with the -T flag.\nThe detected servers are as follows:")
|
|
for i := range servers {
|
|
if srv, ok := servers[i].(map[string]interface{}); ok {
|
|
if serverURL, ok := srv["url"].(string); ok {
|
|
printInfo("%s\n", serverURL)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if srv, ok := servers[0].(map[string]interface{}); ok {
|
|
if serverURL, ok := srv["url"].(string); ok {
|
|
if strings.Contains(serverURL, "://") {
|
|
// Full URL in server
|
|
if parsedServerURL, err := url.Parse(serverURL); err == nil {
|
|
basePath = normalizeBasePath(parsedServerURL.Path)
|
|
if apiTarget == "" {
|
|
apiTarget = parsedServerURL.Scheme + "://" + parsedServerURL.Host
|
|
}
|
|
}
|
|
} else if serverURL == "/" {
|
|
basePath = ""
|
|
} else {
|
|
// Relative URL - this becomes the basePath
|
|
basePath = normalizeBasePath(serverURL)
|
|
// Only try to construct apiTarget if -T wasn't used
|
|
if apiTarget == "" {
|
|
if u.Scheme != "" && u.Host != "" {
|
|
apiTarget = u.Scheme + "://" + u.Host
|
|
} else if Mode != "endpoints" {
|
|
// Local file with relative server URL and no -T flag:
|
|
// there is no host to resolve, so this can't proceed.
|
|
return fmt.Errorf("spec has relative server URL '%s' but no base URL available. Use -T to specify target server", serverURL)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use the original host at the target if no server found from specification.
|
|
if apiTarget == "" {
|
|
if u.Scheme != "" && u.Host != "" {
|
|
apiTarget = u.Scheme + "://" + u.Host
|
|
} else if Mode != "endpoints" {
|
|
// No server info and no URL to parse - require user to specify target.
|
|
// Only fail for commands that need full URLs.
|
|
return fmt.Errorf("no server information found in spec and no URL provided. Use -T to specify target server")
|
|
}
|
|
}
|
|
|
|
if Mode != "endpoints" {
|
|
// Prints Title/Description/Version values if they exist
|
|
PrintSpecInfo(spec)
|
|
}
|
|
|
|
// Reviews all defined API routes and builds requests as defined
|
|
return BuildRequestsFromPaths(spec, client, replayClient)
|
|
}
|
|
|
|
func ResolveRef(spec map[string]interface{}, ref string) map[string]interface{} {
|
|
resolved, _ := ResolveRefWithContext(spec, ref)
|
|
return resolved
|
|
}
|
|
|
|
func normalizeBasePath(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" || path == "/" {
|
|
return ""
|
|
}
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
path = strings.TrimRight(path, "/")
|
|
if path == "/" {
|
|
return ""
|
|
}
|
|
return path
|
|
}
|
|
|
|
// encodePair returns an encoded "name=value" pair for a query string or an
|
|
// x-www-form-urlencoded body. This and replacePathParam are the only places
|
|
// the --raw-values escape hatch is honored, so the printed curl command and
|
|
// the request that is actually sent can never disagree.
|
|
func encodePair(name string, value interface{}) string {
|
|
v := fmt.Sprintf("%v", value)
|
|
if rawValues {
|
|
return name + "=" + v
|
|
}
|
|
return url.QueryEscape(name) + "=" + url.QueryEscape(v)
|
|
}
|
|
|
|
// appendQueryParam returns rawURL with an encoded pair appended, using "?" or
|
|
// "&" depending on whether rawURL already carries a query string.
|
|
func appendQueryParam(rawURL, name string, value interface{}) string {
|
|
sep := "?"
|
|
if strings.Contains(rawURL, "?") {
|
|
sep = "&"
|
|
}
|
|
return rawURL + sep + encodePair(name, value)
|
|
}
|
|
|
|
// replacePathParam substitutes the first {name} placeholder in rawURL with a
|
|
// path-escaped value so a "/", "?" or "#" cannot re-segment the URL.
|
|
func replacePathParam(rawURL, name, value string) string {
|
|
if !rawValues {
|
|
value = url.PathEscape(value)
|
|
}
|
|
return strings.Replace(rawURL, "{"+name+"}", value, 1)
|
|
}
|
|
|
|
// appendFormField returns an x-www-form-urlencoded body with an encoded pair
|
|
// appended, separated by "&" when body is not empty.
|
|
func appendFormField(body, name string, value interface{}) string {
|
|
pair := encodePair(name, value)
|
|
if body == "" {
|
|
return pair
|
|
}
|
|
return body + "&" + pair
|
|
}
|
|
|
|
// encodeFormBody serializes obj as an x-www-form-urlencoded body with keys in
|
|
// sorted order so repeated runs produce identical output.
|
|
func encodeFormBody(obj map[string]interface{}) string {
|
|
var body string
|
|
for _, k := range slices.Sorted(maps.Keys(obj)) {
|
|
body = appendFormField(body, k, obj[k])
|
|
}
|
|
return body
|
|
}
|
|
|
|
// shellSingleQuote wraps s in single quotes for a printed shell command,
|
|
// escaping any embedded single quote the POSIX way (close, escape, reopen) so
|
|
// the command stays runnable. This is presentation only and is not affected
|
|
// by --raw-values.
|
|
func shellSingleQuote(s string) string {
|
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
}
|
|
|
|
// escapeXmlText returns s with XML metacharacters escaped so a value
|
|
// containing '<', '>' or '&' cannot break out of its element.
|
|
func escapeXmlText(s string) string {
|
|
var b bytes.Buffer
|
|
if err := xml.EscapeText(&b, []byte(s)); err != nil {
|
|
return ""
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// ResolveRefWithContext resolves a reference and returns both the resolved schema and the spec it came from
|
|
func ResolveRefWithContext(spec map[string]interface{}, ref string) (map[string]interface{}, map[string]interface{}) {
|
|
// Handle external references (e.g., "./schemas/user.yaml#/User")
|
|
if !strings.HasPrefix(ref, "#") {
|
|
// Determine the base directory for resolving this external ref
|
|
baseDir := specBaseDir
|
|
// Check if the current spec is an external file
|
|
for cachedPath, cachedSpec := range externalRefCache {
|
|
if fmt.Sprintf("%p", cachedSpec) == fmt.Sprintf("%p", spec) {
|
|
// This spec is an external file, use its directory as base
|
|
baseDir = filepath.Dir(cachedPath)
|
|
break
|
|
}
|
|
}
|
|
|
|
resolved := ResolveExternalRef(ref, baseDir)
|
|
// For external refs, the resolved schema's context is itself
|
|
// (nested refs within it should be resolved in the external doc)
|
|
if resolved != nil {
|
|
// Try to get the cached external spec as context
|
|
filePath := strings.SplitN(ref, "#", 2)[0]
|
|
fullPath := filepath.Clean(filepath.Join(baseDir, filePath))
|
|
if externalSpec, exists := externalRefCache[fullPath]; exists {
|
|
return resolved, externalSpec
|
|
}
|
|
}
|
|
return resolved, spec
|
|
}
|
|
|
|
parts := strings.Split(ref[2:], "/")
|
|
var cur interface{} = spec
|
|
|
|
for _, p := range parts {
|
|
m, ok := cur.(map[string]interface{})
|
|
if !ok {
|
|
return nil, spec
|
|
}
|
|
cur = m[p]
|
|
}
|
|
|
|
resolved, _ := cur.(map[string]interface{})
|
|
// Internal refs stay in the same spec context
|
|
return resolved, spec
|
|
}
|
|
|
|
func ResolveExternalRef(ref string, baseDir string) map[string]interface{} {
|
|
// Parse external reference format: "<file_path>#<json_pointer>"
|
|
parts := strings.SplitN(ref, "#", 2)
|
|
if len(parts) < 1 {
|
|
return nil
|
|
}
|
|
|
|
relativePath := parts[0]
|
|
var jsonPointer string
|
|
if len(parts) == 2 {
|
|
jsonPointer = parts[1]
|
|
}
|
|
|
|
// Resolve path relative to the provided base directory
|
|
filePath := filepath.Join(baseDir, relativePath)
|
|
filePath = filepath.Clean(filePath)
|
|
|
|
// Check cache first
|
|
if cached, exists := externalRefCache[filePath]; exists {
|
|
if jsonPointer == "" {
|
|
return cached
|
|
}
|
|
// Resolve pointer within cached file
|
|
return ResolveRef(cached, "#"+jsonPointer)
|
|
}
|
|
|
|
// Load external file
|
|
fileData, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
externalSpec, err := SafelyUnmarshalSpec(fileData)
|
|
if err != nil || externalSpec == nil {
|
|
return nil
|
|
}
|
|
|
|
// Cache the loaded file
|
|
externalRefCache[filePath] = externalSpec
|
|
|
|
// Resolve pointer if present
|
|
if jsonPointer == "" {
|
|
return externalSpec
|
|
}
|
|
return ResolveRef(externalSpec, "#"+jsonPointer)
|
|
}
|
|
|
|
func PrintSpecInfo(spec map[string]interface{}) {
|
|
info, ok := spec["info"].(map[string]interface{})
|
|
if !ok || info == nil {
|
|
if !quiet {
|
|
printInfo("No information defined in the documentation.\n")
|
|
}
|
|
} else {
|
|
title, ok := info["title"].(string)
|
|
if ok && title != "" {
|
|
if outputFormat == "json" {
|
|
specTitle = title
|
|
} else if !quiet {
|
|
printInfo("Title: %s\n", title)
|
|
}
|
|
}
|
|
|
|
description, ok := info["description"].(string)
|
|
if ok && description != "" {
|
|
if outputFormat == "json" {
|
|
specDescription = description
|
|
} else if !quiet {
|
|
printInfo("Description: %s\n", description)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func SetScheme(swaggerURL string) (scheme string) {
|
|
if strings.HasPrefix(swaggerURL, "http://") {
|
|
scheme = "http"
|
|
} else if strings.HasPrefix(swaggerURL, "https://") {
|
|
scheme = "https"
|
|
} else {
|
|
scheme = "https"
|
|
}
|
|
return scheme
|
|
}
|
|
|
|
// looksLikeJSSpec is a cheap content sniff so we only run the JS-extraction
|
|
// regex against bodies that plausibly contain JavaScript. Normal JSON
|
|
// (starts with `{`/`[`) and YAML (starts with `openapi:`/`swagger:`/`---`/`#`)
|
|
// fall through unchanged.
|
|
func looksLikeJSSpec(b []byte) bool {
|
|
if strings.HasSuffix(strings.ToLower(swaggerURL), ".js") ||
|
|
strings.HasSuffix(strings.ToLower(localFile), ".js") ||
|
|
strings.ToLower(format) == "js" {
|
|
return true
|
|
}
|
|
trimmed := bytes.TrimLeft(b, " \t\r\n")
|
|
prefixes := [][]byte{
|
|
[]byte("var "), []byte("let "), []byte("const "),
|
|
[]byte("(function"), []byte("window."),
|
|
[]byte("//"), []byte("/*"),
|
|
}
|
|
for _, p := range prefixes {
|
|
if bytes.HasPrefix(trimmed, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func SafelyUnmarshalSpec(data []byte) (map[string]interface{}, error) {
|
|
|
|
var doc map[string]interface{}
|
|
if err := yaml.Unmarshal(data, &doc); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal API documentation: %v", err)
|
|
}
|
|
|
|
return doc, nil
|
|
}
|
|
|
|
/*
|
|
TrimHostScheme trims the scheme from the provided URL if the '-T' flag is supplied to sj.
|
|
*/
|
|
func TrimHostScheme(apiTarget, fullUrlHost string) (host string) {
|
|
if apiTarget != "" {
|
|
if strings.HasPrefix(apiTarget, "http://") {
|
|
host = strings.TrimPrefix(apiTarget, "http://")
|
|
} else if strings.HasPrefix(apiTarget, "https://") {
|
|
host = strings.TrimPrefix(apiTarget, "https://")
|
|
} else {
|
|
host = apiTarget
|
|
}
|
|
} else {
|
|
host = fullUrlHost
|
|
}
|
|
return host
|
|
}
|
|
|
|
func XmlFromObject(obj map[string]interface{}) string {
|
|
var b strings.Builder
|
|
|
|
// Sorted keys keep the generated body identical between runs.
|
|
for _, k := range slices.Sorted(maps.Keys(obj)) {
|
|
switch val := obj[k].(type) {
|
|
case map[string]interface{}:
|
|
b.WriteString("<" + k + ">")
|
|
b.WriteString(XmlFromObject(val))
|
|
b.WriteString("</" + k + ">")
|
|
case []interface{}:
|
|
for _, item := range val {
|
|
b.WriteString("<" + k + ">")
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
b.WriteString(XmlFromObject(m))
|
|
} else {
|
|
b.WriteString(escapeXmlText(fmt.Sprintf("%v", item)))
|
|
}
|
|
b.WriteString("</" + k + ">")
|
|
}
|
|
default:
|
|
b.WriteString("<" + k + ">" + escapeXmlText(fmt.Sprintf("%v", val)) + "</" + k + ">")
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|