package auth import ( "strings" "github.com/sven-seyfert/apiprobe/internal/loader" "github.com/sven-seyfert/apiprobe/internal/logger" "github.com/sven-seyfert/apiprobe/internal/util" ) // RepaceAuthTokenPlaceholderInRequestHeader replaces the <auth-token> placeholder // in request headers with the corresponding token from the token store, if available. // Returns nothing. func RepaceAuthTokenPlaceholderInRequestHeader(req *loader.APIRequest, tokenStore *TokenStore) { const headerReplacementIndicator = "<auth-token>" lookupID := req.PreRequestID for idx, header := range req.Request.Headers { if !strings.Contains(header, headerReplacementIndicator) { continue } if token, found := tokenStore.Get(lookupID); found { lastTokenChars := token[util.Max(0, len(token)-12):] //nolint:mnd logger.Debugf(`Token "...%s" found for auth request "%s".`, lastTokenChars, lookupID) req.Request.Headers[idx] = strings.ReplaceAll(header, headerReplacementIndicator, token) break } logger.Warnf(`No token found for auth request "%s".`, lookupID) } } // AddAuthTokenToTokenStore attempts to add the token to the provided token store // using the request ID as the key. Returns nothing. func AddAuthTokenToTokenStore(result []byte, tokenStore *TokenStore, req *loader.APIRequest) { token := util.TrimQuotes(string(result)) lastTokenChars := token[util.Max(0, len(token)-12):] //nolint:mnd if added := tokenStore.Add(req.ID, token); added { logger.Debugf(`Token "...%s" for auth request "%s" added to token store.`, lastTokenChars, req.ID) } else { logger.Warnf(`Token "...%s" for auth request "%s" already exists in token store.`, lastTokenChars, req.ID) } }
package auth // TokenStore maintains a map of request IDs to API tokens. // Each key is a 10 character hex hash, and each value // is the corresponding token. type TokenStore struct { data map[string]string } // NewTokenStore initializes and returns a new TokenStore. func NewTokenStore() *TokenStore { return &TokenStore{ data: make(map[string]string), } } // Add inserts a token for the given id if it does not already exist. // Returns true if the token was added, false if the id already exists. func (t *TokenStore) Add(id, token string) bool { if _, exists := t.data[id]; exists { return false } t.data[id] = token return true } // Get retrieves the token for the given id. Returns the token and // true if found, or "" and false otherwise. func (t *TokenStore) Get(id string) (string, bool) { if t, found := t.data[id]; found { return t, true } return "", false }
package config import ( "encoding/json" "os" "github.com/sven-seyfert/apiprobe/internal/logger" ) const Version = "APIProbe 📡 v0.17.0 - 2025-10-06" type Heartbeat struct { IntervalInHours int `json:"intervalInHours"` LastHeartbeatTime string `json:"lastHeartbeatTime"` } type Notification struct { WebEx *struct { Active bool `json:"active"` WebhookURL string `json:"webhookUrl"` Space string `json:"space"` } `json:"webEx"` } type Config struct { DebugMode bool `json:"debugMode"` Heartbeat Heartbeat `json:"heartbeat"` Notification Notification `json:"notification"` } // Load opens the JSON configuration file, decodes its contents into // a Config struct and returns the loaded configuration or an error. func Load(filePath string) (*Config, error) { file, err := os.Open(filePath) if err != nil { logger.Errorf(`Failure opening config file "%s". Error: %v`, filePath, err) return nil, err } defer file.Close() decoder := json.NewDecoder(file) var cfg Config if err = decoder.Decode(&cfg); err != nil { logger.Errorf(`Failure parsing config file "%s". Error: %v`, filePath, err) return nil, err } return &cfg, nil }
package crypto import ( "crypto/rand" "encoding/base64" "encoding/hex" "fmt" "math/big" "strings" "github.com/sven-seyfert/apiprobe/internal/logger" ) // Obfuscate encodes a string with Base64 and random characters // to produce a token. func Obfuscate(data string) string { core := base64.StdEncoding.EncodeToString([]byte(data)) core = strings.ReplaceAll(core, "=", "-") return fmt.Sprintf("ey%s.%s%s%s.%s", chars(9), chars(2), core, chars(6), chars(24)) //nolint:mnd } // Deobfuscate decodes a token back into its original plaintext. func Deobfuscate(data string) string { if data == "" { return "" } core := data[14 : len(data)-31] core = strings.ReplaceAll(core, "-", "=") byteString, err := base64.StdEncoding.DecodeString(core) if err != nil { logger.Warnf("Decryption failed: %s", err) return "" } return string(byteString) } // chars returns a random alphanumeric string of the specified length. func chars(length int) string { const alphaNum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" byteString := make([]byte, length) for idx := range byteString { nBig, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphaNum)))) byteString[idx] = alphaNum[nBig.Int64()] } return string(byteString) } // HexHash returns a cryptographically secure random hex string of length 10. // It reads the needed random bytes, encodes them, and truncates to 10 chars. func HexHash() (string, error) { const charCount = 10 byteLen := (charCount + 1) / 2 //nolint:mnd buf := make([]byte, byteLen) if _, err := rand.Read(buf); err != nil { return "", err } hexStr := hex.EncodeToString(buf) return hexStr[:charCount], nil }
package crypto import ( "fmt" "regexp" "strings" "zombiezen.com/go/sqlite" "github.com/sven-seyfert/apiprobe/internal/db" "github.com/sven-seyfert/apiprobe/internal/loader" "github.com/sven-seyfert/apiprobe/internal/logger" ) // HandleSecrets iterates over each APIRequest in filteredRequests, finds all // placeholders '<secret-<hash>>' in PostBody, BasicAuth, Params, Headers and TestCases, // retrieves the real secret from the database, deobfuscates it, and replaces the // placeholder. Returns an error immediately if any DB lookup fails. func HandleSecrets(filteredRequests []*loader.APIRequest, conn *sqlite.Conn) ([]*loader.APIRequest, error) { for _, req := range filteredRequests { newBody, err := replaceSecretInString(req.Request.PostBody, conn) if err != nil { return nil, err } req.Request.PostBody = newBody newAuth, err := replaceSecretInString(req.Request.BasicAuth, conn) if err != nil { return nil, err } req.Request.BasicAuth = newAuth if err = replaceSecretInSlice(req.Request.Params, conn); err != nil { return nil, err } if err = replaceSecretInSlice(req.Request.Headers, conn); err != nil { return nil, err } if err = replaceSecretInTestCases(req.TestCases, conn); err != nil { return nil, err } } return filteredRequests, nil } // replaceSecretInString searches a single string for '<secret-<hash>>' // patterns. For each found hash, it retrieves the secret from the database, // deobfuscates it, and replaces the placeholder in the string. // Returns an error if DB lookup fails. func replaceSecretInString(str string, conn *sqlite.Conn) (string, error) { const secretPrefix = "<secret-" if !strings.Contains(str, secretPrefix) { return str, nil } secretHash := ExtractSecretHash(str) if secretHash == "" { logger.Warnf("No valid secret hash found in string: %s", str) return str, nil } secret, err := db.SelectHash(conn, secretHash) if err != nil { logger.Debugf(`Failed to retrieve secret for hash "%s": %v`, secretHash, err) return "", err } if secret != "" { from := fmt.Sprintf("%s%s>", secretPrefix, secretHash) to := Deobfuscate(secret) return strings.ReplaceAll(str, from, to), nil } logger.Warnf(`Secret value "%s" not found`, secretHash) return str, nil } // replaceSecretInSlice iterates over a slice of strings, calls replaceSecretInString // on each element, and updates the slice in-place. // Returns the first error encountered, if any. func replaceSecretInSlice(reqSlice []string, conn *sqlite.Conn) error { for idx, val := range reqSlice { newVal, err := replaceSecretInString(val, conn) if err != nil { return err } reqSlice[idx] = newVal } return nil } // replaceSecretInTestCases iterates over all test cases and replaces secrets // in the ParamsData and PostBodyData fields in-place. // Returns the first error encountered, if any. func replaceSecretInTestCases(testCases []loader.TestCases, conn *sqlite.Conn) error { for idx := range testCases { testCase := &testCases[idx] var err error if testCase.ParamsData != "" { testCase.ParamsData, err = replaceSecretInString(testCase.ParamsData, conn) if err != nil { logger.Errorf(`Error replacing secret in ParamsData of test "%q".`, testCase.Name) return err } } if testCase.PostBodyData != "" { testCase.PostBodyData, err = replaceSecretInString(testCase.PostBodyData, conn) if err != nil { logger.Errorf(`Error replacing secret in PostBodyData of test "%q".`, testCase.Name) return err } } } return nil } // ExtractSecretHash uses a precompiled regex to extract the hash from // a '<secret-<hash>>' placeholder. Returns the hash without angle brackets // or prefix or an empty string if no match is found. func ExtractSecretHash(input string) string { pattern := regexp.MustCompile(`<secret-([^>]+)>`) matches := pattern.FindStringSubmatch(input) if len(matches) < 2 { //nolint:mnd logger.Warnf(`"No secret hash found: "%s"`, input) return "" } return matches[1] }
package db import ( "encoding/csv" "fmt" "io" "os" "strings" "zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite/sqlitex" "github.com/sven-seyfert/apiprobe/internal/logger" ) // Init opens or creates the SQLite database file at './db/store.db', // ensures that the 'secrets' table exists and returns the active // connection to the caller. func Init() (*sqlite.Conn, error) { // Create database. conn, err := sqlite.OpenConn("./db/store.db", sqlite.OpenReadWrite, sqlite.OpenCreate) if err != nil { logger.Errorf("Failed to open database. Error: %v", err) return nil, err } // Create table if it does not exist. createTableSQL := ` CREATE TABLE IF NOT EXISTS secrets ( hash TEXT PRIMARY KEY, secret TEXT NOT NULL );` err = sqlitex.ExecuteTransient(conn, createTableSQL, nil) if err != nil { logger.Errorf("Failed to create database. Error: %v", err) return nil, err } return conn, nil } // InsertSeedData checks if the 'secrets' table is empty; // if so, reads './db/seed.csv', constructs a bulk-insert SQL statement // and populates the table. Returns an error if any operation fails. func InsertSeedData(conn *sqlite.Conn) error { // Check if the table is empty. count, err := GetTableEntryCount(conn) if err != nil { return err } if count > 0 { return nil } // Insert data (bulk insert). SQLValues, err := readSeedData() if err != nil { return err } bulkInsertSQL := "INSERT INTO secrets(hash, secret) VALUES" + SQLValues err = sqlitex.ExecuteTransient(conn, bulkInsertSQL, nil) if err != nil { logger.Errorf("Failed to insert data. Error: %v", err) return err } return nil } // GetTableEntryCount returns the total number of rows in the 'secrets' // table by executing 'SELECT COUNT(*)'. func GetTableEntryCount(conn *sqlite.Conn) (int, error) { var count int countSQL := "SELECT COUNT(*) FROM secrets" err := sqlitex.ExecuteTransient(conn, countSQL, &sqlitex.ExecOptions{ Args: nil, Named: nil, ResultFunc: func(stmt *sqlite.Stmt) error { count = stmt.ColumnInt(0) return nil }, }) if err != nil { logger.Errorf("Failed to query table count. Error: %v", err) return 0, err } return count, nil } // readSeedData reads './db/seed.csv', each line containing 'hash,secret' // and returns a string suitable for a SQL VALUES clause for a bulk insert. func readSeedData() (string, error) { file, err := os.Open("./db/seed.csv") if err != nil { logger.Errorf("Failure opening file. Error: %v", err) return "", err } defer file.Close() reader := csv.NewReader(file) var values []string for { record, readErr := reader.Read() if readErr == io.EOF { break } if readErr != nil { logger.Errorf("Failure reading file. Error: %v", readErr) return "", readErr } hash := record[0] secret := record[1] values = append(values, fmt.Sprintf("('%s', '%s')", hash, secret)) } joinedValues := strings.Join(values, ",\n ") bulkInsertSQL := fmt.Sprintf("\n %s;", joinedValues) return bulkInsertSQL, nil } // InsertSecret stores a new (hash, secret) pair into the 'secrets' table // using parameterized SQL to avoid injection. Returns an error if insertion fails. func InsertSecret(conn *sqlite.Conn, hash string, secret string) error { stmt, _, err := conn.PrepareTransient("INSERT INTO secrets(hash, secret) VALUES (?, ?)") if err != nil { logger.Errorf("Failed to prepare insert statement. Error: %v", err) return err } defer func() { if err = stmt.Finalize(); err != nil { logger.Errorf("Failed to finalize statement. Error: %v", err) } }() stmt.BindText(1, hash) stmt.BindText(2, secret) //nolint:mnd if _, err = stmt.Step(); err != nil { logger.Errorf("Failed to execute insert statement. Error: %v", err) return err } return nil } // SelectHash queries the 'secrets' table for the given hash // and returns its stored secret. Returns an empty string if no row is found // or an error on failure. func SelectHash(conn *sqlite.Conn, hash string) (string, error) { stmt, _, err := conn.PrepareTransient("SELECT secret FROM secrets WHERE hash = ?") if err != nil { logger.Errorf("Failed to prepare select statement. Error: %v", err) return "", err } defer func() { if err = stmt.Finalize(); err != nil { logger.Errorf("Failed to finalize statement. Error: %v", err) } }() stmt.BindText(1, hash) hasRow, err := stmt.Step() if err != nil { logger.Errorf("Failed to execute select statement. Error: %v", err) return "", err } if !hasRow { return "", nil } secret := stmt.ColumnText(0) return secret, nil }
package diff import ( "crypto/sha256" "os" "github.com/sven-seyfert/apiprobe/internal/fileutil" "github.com/sven-seyfert/apiprobe/internal/logger" ) // HasFileContentChanged compares the SHA256 checksum of the given output // bytes against the current contents of outputPath. If they differ, // writes the new content to file and returns true; // otherwise logs 'No change' and returns false. func HasFileContentChanged(output []byte, outputPath string) (bool, error) { err := fileutil.EnsureFileExists(outputPath) if err != nil { return false, err } newHash := sha256.Sum256(output) var prevHash [32]byte existing, err := os.ReadFile(outputPath) if err != nil { logger.Errorf(`Failed to read file "%s"`, outputPath) return false, err } prevHash = sha256.Sum256(existing) if newHash == prevHash { logger.Infof(`No change for "%s"`, outputPath) return false, nil } logger.Infof(`Detected change (diff) in "%s"`, outputPath) if err = fileutil.WriteOutputFile(outputPath, output); err != nil { return true, err } return true, nil }
package exec import "strings" type token struct { option string value string hasValue bool } // buildCurlFormat formats a given input string into a multi-line, // indented curl command. Returns the formatted curl command as a // string or an empty string if input is invalid. func buildCurlFormat(input string) string { parts := strings.Fields(input) if len(parts) == 0 { return "" } const ( executable = "curl" indent = " " backslash = " \\" ) tokens := parseTokens(parts[1:]) isFirstOption := true var lines []string for _, tok := range tokens { if !tok.hasValue { if isFirstOption { lines = append(lines, executable+" "+tok.option+backslash) isFirstOption = false } else { lines = append(lines, indent+tok.option+backslash) } continue } formattedValue := quoteValue(tok.option, tok.value) if isFirstOption { lines = append(lines, executable+" "+tok.option+" "+formattedValue+backslash) isFirstOption = false } else { lines = append(lines, indent+tok.option+" "+formattedValue+backslash) } } if len(lines) == 0 { return executable } result := strings.Join(lines, "\n") result = strings.TrimRight(result, backslash) return result } // parseTokens parses command line parts into tokens with options and values. // Returns a slice of token structs. func parseTokens(parts []string) []token { var tokens []token valueFlags := map[string]struct{}{ "--request": {}, "--connect-timeout": {}, "--max-time": {}, "--url": {}, "--write-out": {}, "--data": {}, "--user": {}, "--header": {}, } for idx := 0; idx < len(parts); idx++ { part := parts[idx] if !strings.HasPrefix(part, "-") { continue } if _, ok := valueFlags[part]; !ok { tokens = append(tokens, token{option: part, value: "", hasValue: false}) continue } value := "" nextIndex := idx + 1 for nextIndex < len(parts) && !strings.HasPrefix(parts[nextIndex], "-") { if value != "" { value += " " } value += parts[nextIndex] nextIndex++ } tokens = append(tokens, token{option: part, value: value, hasValue: true}) idx = nextIndex - 1 } return tokens } // quoteValue adds appropriate quoting to a flag value based on the flag type. // Returns the quoted value as a string. func quoteValue(flag, value string) string { switch flag { case "--request", "--connect-timeout", "--max-time": return value default: return "'" + escapeSingleQuotes(value) + "'" } } // escapeSingleQuotes escapes single quotes in a string for shell safety. // Returns the escaped string. func escapeSingleQuotes(s string) string { return strings.ReplaceAll(s, "'", "'\\''") }
package exec import ( "bytes" "context" "fmt" "os/exec" "strings" "time" "github.com/sven-seyfert/apiprobe/internal/loader" "github.com/sven-seyfert/apiprobe/internal/logger" ) // runCurl executes an external 'curl' command with specified timeouts // and write-out flags, captures its stdout, splits the HTTP status code // and returns the response body if the status code is 2xx; // otherwise, returns an error. func runCurl(ctx context.Context, req *loader.APIRequest, debugMode bool) ([]byte, string, error) { cmdArgs := req.CurlCmdArguments() var stdout bytes.Buffer cmd := exec.CommandContext(ctx, "./lib/curl.exe", cmdArgs...) if debugMode { fmt.Printf("\n%s\n\n", buildCurlFormat(cmd.String())) //nolint:forbidigo } cmd.Stdout = &stdout logger.Debugf(`Executing endpoint request "%s"`, req.Request.Endpoint) logger.Infof(`Description: "%s"`, req.Request.Description) start := time.Now() if err := cmd.Run(); err != nil { logger.Errorf("Curl execution failed. Error: %v", err) return nil, "", fmt.Errorf("curl error: %w", err) } duration := time.Since(start) rawOutput := stdout.Bytes() body, statusCode, err := extractStatusCode(rawOutput) if err != nil { return nil, "", err } logger.Debugf("Status: %s, Duration: %dms", statusCode, duration.Milliseconds()) if !strings.HasPrefix(statusCode, "2") { logger.Warnf("Non-2xx status code received: status %s", statusCode) return nil, statusCode, fmt.Errorf("status %s", statusCode) } return body, statusCode, nil } // extractStatusCode splits the raw output from curl (where the last // three bytes encode the HTTP status code) into the response body // and the status code string. func extractStatusCode(output []byte) ([]byte, string, error) { const HTTPCodeLength = 3 if len(output) < HTTPCodeLength { logger.Warnf("Output too short to contain status code: only %d bytes", len(output)) return nil, "", fmt.Errorf("only %d bytes", len(output)) } body := output[:len(output)-HTTPCodeLength] statusCode := string(output[len(output)-HTTPCodeLength:]) return body, statusCode, nil }
package exec import ( "context" "strings" "github.com/sven-seyfert/apiprobe/internal/auth" "github.com/sven-seyfert/apiprobe/internal/diff" "github.com/sven-seyfert/apiprobe/internal/fileutil" "github.com/sven-seyfert/apiprobe/internal/loader" "github.com/sven-seyfert/apiprobe/internal/logger" "github.com/sven-seyfert/apiprobe/internal/report" "github.com/sven-seyfert/apiprobe/internal/util" ) // ProcessFirstRequest executes the APIRequest (including optional test cases), // compares the response against existing output, and triggers the webhook // if differences are detected. func ProcessFirstRequest( ctx context.Context, idx int, req *loader.APIRequest, testCaseIndex *int, res *report.Result, rep *report.Report, tokenStore *auth.TokenStore, debugMode bool, ) { if testCaseIndex != nil { logger.NewLine() logger.Debugf("Run: %d, Test case: %d", idx, *testCaseIndex+1) } const noTestCaseIndicator = -1 outputFile := fileutil.BuildOutputFilePath(req, testCaseIndex) response, statusCode, err := executeRequest(ctx, req, debugMode) if err != nil { logger.Errorf(`Failed endpoint request "%s": %v`, req.Request.Endpoint, err) res.IncreaseRequestErrorCount() if testCaseIndex != nil { rep.AddReportData(req, statusCode, outputFile, *testCaseIndex) } else { rep.AddReportData(req, statusCode, outputFile, noTestCaseIndicator) } return } result, err := formatResponse(ctx, req, response) if err != nil { logger.Errorf("Failed processing JSON query by JQ. Error: %v", err) res.IncreaseFormatErrorCount() if testCaseIndex != nil { rep.AddReportData(req, statusCode, outputFile, *testCaseIndex) } else { rep.AddReportData(req, statusCode, outputFile, noTestCaseIndicator) } return } if req.IsAuthRequest { auth.AddAuthTokenToTokenStore(result, tokenStore, req) logger.Debugf("No output file will be written (unnecessary), because generic token result.") return } hasChanged, err := diff.HasFileContentChanged(result, outputFile) if err != nil { logger.Errorf("%v", err) return } if !hasChanged { return } res.IncreaseChangedFilesCount() if testCaseIndex != nil { rep.AddReportData(req, statusCode, outputFile, *testCaseIndex) } else { rep.AddReportData(req, statusCode, outputFile, noTestCaseIndicator) } } // ProcessTestCasesRequests executes all test case variations for a given // API request. Returns nothing. func ProcessTestCasesRequests( ctx context.Context, req *loader.APIRequest, idx int, res *report.Result, rep *report.Report, tokenStore *auth.TokenStore, debugMode bool, ) { for testCaseIndex, testCase := range req.TestCases { if testCase.ParamsData == "" && testCase.PostBodyData == "" { continue } modifiedReq := *req if testCase.ParamsData != "" { modifiedReq.Request.Params = util.ReplaceQueryParam(req.Request.Params, testCase.ParamsData) } if testCase.PostBodyData != "" { modifiedReq.Request.PostBody = testCase.PostBodyData } ProcessFirstRequest(ctx, idx+1, &modifiedReq, &testCaseIndex, res, rep, tokenStore, debugMode) logger.Infof("Test case: %s", testCase.Name) } } // executeRequest wraps runCurl to perform the HTTP request defined by APIRequest // and returns the raw response body and status code. func executeRequest(ctx context.Context, req *loader.APIRequest, debugMode bool) ([]byte, string, error) { curlOutput, statusCode, err := runCurl(ctx, req, debugMode) if err != nil { return nil, statusCode, err } return curlOutput, statusCode, nil } // formatResponse formats the curl output using jq // and returns the filtered result. func formatResponse(ctx context.Context, req *loader.APIRequest, response []byte) ([]byte, error) { // If response is not JSON ("content-type: application/json"), // it's plain text and therefore there is no need for jq formatting. if !strings.HasPrefix(string(response), "{") && !strings.HasPrefix(string(response), "[") { return response, nil } jqOutput, err := GoJQ(ctx, req.JqCommand, response) if err != nil { return nil, err } return jqOutput, nil }
package exec import ( "context" "encoding/json" "fmt" "github.com/itchyny/gojq" "github.com/sven-seyfert/apiprobe/internal/logger" ) // GoJQ executes the jq query given by jqCommand against inputJSON. // Returns the encoded JSON ([]byte) of the query result or an error. func GoJQ(ctx context.Context, jqCommand string, inputJSON []byte) ([]byte, error) { const defaultJQPrettifyFilter = "." if jqCommand == "" { jqCommand = defaultJQPrettifyFilter } var input any if err := json.Unmarshal(inputJSON, &input); err != nil { logger.Errorf("Failed to unmarshal input (invalid input json). Error: %v", err) return nil, err } code, err := compileQuery(jqCommand) if err != nil { return nil, err } results, err := runQuery(ctx, code, input) if err != nil { return nil, err } return encodeResults(results) } // compileQuery parses and compiles the provided jqCommand into *gojq.Code. // Returns the compiled code or an error if parsing/compilation fails. func compileQuery(jqCommand string) (*gojq.Code, error) { query, err := gojq.Parse(jqCommand) if err != nil { logger.Errorf("Failed to parse jqCommand. Error: %v", err) return nil, err } code, err := gojq.Compile(query) if err != nil { logger.Errorf("Failed to compile jq. Error: %v", err) return nil, err } return code, nil } // runQuery executes the compiled gojq code with the provided context and input, // collects all produced values and handles gojq.HaltError specially. // Returns a slice of results ([]any) or an error. func runQuery(ctx context.Context, code *gojq.Code, input any) ([]any, error) { iter := code.RunWithContext(ctx, input) results := []any{} for { nextVal, isOk := iter.Next() if !isOk { break } // If the iterator produced a non-error value, // append and continue early. errVal, isErr := nextVal.(error) if !isErr { results = append(results, nextVal) continue } // Handle error values if halt, ok := errVal.(*gojq.HaltError); ok { //nolint:errorlint if halt.Value() == nil { break } // If HaltError carries a value, // append it and continue. if ve, okay := errVal.(interface{ Value() any }); okay { results = append(results, ve.Value()) continue } logger.Errorf("Failed to run query. JQ halt error with non-nil value (type=%T).", errVal) return nil, errVal } logger.Errorf("Failed to run query. JQ runtime error: %s (type=%T)", safeErrorString(errVal), errVal) return nil, errVal } return results, nil } // encodeResults marshals results to indented JSON. If results contains exactly // one element, that element is marshaled directly; otherwise the whole slice // is marshaled. Returns the JSON bytes ([]byte) or an error. func encodeResults(results []any) ([]byte, error) { var out any if len(results) == 1 { out = results[0] } else { out = results } enc, err := json.MarshalIndent(out, "", " ") if err != nil { logger.Errorf("Failed to marshal indent. Marshal output: %v", err) return nil, err } return enc, nil } // safeErrorString returns err.Error(), but recovers and returns a fallback // string if calling Error() panics. func safeErrorString(err error) string { var errMsg string func() { defer func() { if rec := recover(); rec != nil { errMsg = fmt.Sprintf("error.Error() panicked: %v (error type=%T)", rec, err) } }() errMsg = err.Error() }() return errMsg }
package fileutil import ( "os" "github.com/sven-seyfert/apiprobe/internal/logger" ) // EnsureFileExists ensures that the target file and its directory exist, // creating them if necessary. func EnsureFileExists(outputPath string) error { if _, err := os.Stat(outputPath); os.IsNotExist(err) { if err = createOutputDir(outputPath); err != nil { return err } if err = WriteOutputFile(outputPath, nil); err != nil { return err } } return nil } // WriteOutputFile writes byte content to the specified file // with defined permissions. func WriteOutputFile(outputPath string, output []byte) error { const permissions = 0o644 if err := os.WriteFile(outputPath, output, permissions); err != nil { logger.Errorf(`Failed to write file "%s". Error: %v`, outputPath, err) return err } return nil }
package fileutil import ( "fmt" "os" "path/filepath" "strings" "github.com/sven-seyfert/apiprobe/internal/loader" "github.com/sven-seyfert/apiprobe/internal/logger" ) // BuildOutputFilePath computes the output file path for a // given APIRequest and optional test case index, by inserting // '-test-case-XX' into the JSON file name and nesting under // './data/output'. func BuildOutputFilePath(req *loader.APIRequest, testCaseIndex *int) string { outputDir := "./data/output" fileExt := filepath.Ext(req.JSONFilePath) file := req.JSONFilePath if testCaseIndex != nil { file = strings.Replace(file, fileExt, fmt.Sprintf("-test-case-%02d%s", *testCaseIndex+1, fileExt), 1) } else { file = strings.Replace(file, fileExt, fmt.Sprintf("-test-case-%02d%s", 0, fileExt), 1) } return filepath.Join(outputDir, file) } // createOutputDir ensures that the parent directory for the given // output path exists. If necessary, it creates all missing directories. func createOutputDir(outputPath string) error { const permissions = 0o755 if err := os.MkdirAll(filepath.Dir(outputPath), permissions); err != nil { logger.Errorf(`Failed to create output directory "%s". Error: %v"`, outputPath, err) return err } return nil }
package flags import ( "flag" "fmt" "os" "path/filepath" "strings" "zombiezen.com/go/sqlite" "github.com/sven-seyfert/apiprobe/internal/config" "github.com/sven-seyfert/apiprobe/internal/crypto" "github.com/sven-seyfert/apiprobe/internal/db" "github.com/sven-seyfert/apiprobe/internal/logger" ) type CLIFlags struct { Name *string ID *string Tags *string Exclude *string NewID *bool NewFile *bool AddSecret *string } // Init defines and parses the CLI flags and returning their values. func Init() *CLIFlags { flag.Usage = func() { //nolint:reassign fmt.Fprintf(os.Stderr, config.Version+"\n\n") fmt.Fprintf(os.Stderr, "Usage:\n") flag.PrintDefaults() } nameUsage := "Custom name for this test run (for this execution). Shown in the final notification to help identify the run.\n" + "Example: --name \"Environment: PROD\"\n" idUsage := "Specify the ten-character hex hash (id) of the request to run.\n" + "The hash must match the JSON \"id\" value, in the JSON definition (input) files.\n" + "In combination with the --exclude flag, exclude will be prioritized.\n" + "Example: --id \"ff00fceb61\"\n" tagUsage := "Specify a comma-separated list of tags to select which requests to run.\n" + "Tags must match the JSON \"tags\" value, in the JSON definition (input) files.\n" + "In combination with the --exclude flag, exclude will be prioritized.\n" + "Example: --tags \"reqres, booker\"\n" excludeUsage := "Specify a comma-separated list of IDs (hashes) to exclude from the execution.\n" + "The IDs must match the JSON \"id\" value, in the JSON definition (input) files.\n" + "Example: --exclude \"bb5599abcd, ff00fceb61\"\n" newIDUsage := "Generate a new ten-character hex hash (id) for the \n" + "JSON \"id\" value, in the JSON definition (input) file.\n" + "Example: --new-id\n" newFileUsage := "Generate a new JSON definition template file.\n" + "Then enter the request values/data and done.\n" + "Example: --new-file\n" addSecretUsage := "Stores a secret (e.g., API request token, api-key, a bearer token or\n" + "other request secrets) in the database and return a placeholder such as \"<secret-b29ff12b50>\".\n" + "Use this placeholder in your JSON definition (input) file instead of the actual secret value.\n" + "Example: --add-secret \"ThisIsMySecretText\"\n" cliFlags := &CLIFlags{ Name: flag.String("name", "", nameUsage), ID: flag.String("id", "", idUsage), Tags: flag.String("tags", "", tagUsage), Exclude: flag.String("exclude", "", excludeUsage), NewID: flag.Bool("new-id", false, newIDUsage), NewFile: flag.Bool("new-file", false, newFileUsage), AddSecret: flag.String("add-secret", "", addSecretUsage), } flag.Parse() return cliFlags } // IsNewID checks whether a new ID should be generated, and if so, // produces a cryptographically secure hex hash and prints it and // returns an instruction to exit the program or not. func IsNewID(isNewID bool) (bool, error) { complete := false if !isNewID { return complete, nil } hash, err := crypto.HexHash() if err != nil { logger.Errorf("Failed to generate new ID. Error: %v", err) return complete, err } fmt.Printf(`Use this ID "%s" in your JSON file, key "id".`, hash) //nolint:forbidigo complete = true return complete, nil } // IsNewFile checks if a new file should be created. If true, it generates an ID, // writes a new template JSON file, and returns true on success. Returns false // and an error if any step fails. func IsNewFile(isNewFile bool) (bool, error) { complete := false if !isNewFile { return complete, nil } hash, err := crypto.HexHash() if err != nil { logger.Errorf("Failed to generate new ID. Error: %v", err) return complete, err } if err = writeNewTemplateJSONFile(hash); err != nil { return complete, err } complete = true return complete, nil } // writeNewTemplateJSONFile creates a new JSON definition file (a template) // with a given ID as content. Returns an error if directory creation // or file writing fails. func writeNewTemplateJSONFile(hash string) error { content := `[ { "id": "${ID}", "isAuthRequest": false, "preRequestId": "", "request": { "description": "...", "method": "GET", "url": "https://...", "endpoint": "/...", "basicAuth": "", "headers": [], "params": [], "postBody": {} }, "testCases": [ { "name": "", "paramsData": "", "postBodyData": {} } ], "tags": [ "env-prod" ], "jq": "" } ]` const ( path = "./data/input/" file = "new-template.json" createPermissions = 0o755 writePermissions = 0o644 ) err := os.MkdirAll(filepath.Dir(path), createPermissions) if err != nil { logger.Errorf(`Failed to create data/input directory "%s". Error: %v`, file, err) return err } filePath := filepath.Join(path, file) content = strings.Replace(content, "${ID}", hash, 1) err = os.WriteFile(filePath, []byte(content), writePermissions) if err != nil { logger.Errorf(`Failed to write file "%s". Error: %v`, filePath, err) return err } return nil } // IsAddSecret validates the provided secret string and, if non-empty, // generates a cryptographically secure hex hash to serve as a placeholder // and prints it and returns an instruction to exit the program or not. func IsAddSecret(givenSecret string, conn *sqlite.Conn) (bool, error) { complete := false if givenSecret == "" { return complete, nil } hash, err := crypto.HexHash() if err != nil { logger.Errorf("Failed to generate new ID. Error: %v", err) return complete, err } DBValidSecret := crypto.Obfuscate(givenSecret) countBefore, err := db.GetTableEntryCount(conn) if err != nil { return complete, err } if err = db.InsertSecret(conn, hash, DBValidSecret); err != nil { return complete, err } countAfter, err := db.GetTableEntryCount(conn) if err != nil { return complete, err } fmt.Printf("%d ==> %d\n"+ //nolint:forbidigo "Use this placeholder \"<secret-%s>\" in your JSON file "+ "instead of the actual secret value.", countBefore, countAfter, hash) complete = true return complete, nil }
package loader import ( "errors" "regexp" "strings" "github.com/sven-seyfert/apiprobe/internal/logger" ) // ExcludeRequestsByID returns a filtered slice of APIRequest, excluding any // requests whose IDs are listed in the comma-separated excludeIDs string. func ExcludeRequestsByID(requests []*APIRequest, excludeIDs string) []*APIRequest { if excludeIDs == "" { return requests } idList := strings.Split(excludeIDs, ",") excludeSet := make(map[string]struct{}) for _, id := range idList { id = strings.TrimSpace(id) if id != "" { excludeSet[id] = struct{}{} } } var filteredRequests []*APIRequest for _, req := range requests { if _, found := excludeSet[req.ID]; !found { filteredRequests = append(filteredRequests, req) } } return filteredRequests } // FilterRequests filters the given slice of APIRequest by the '--id' // and '--tags' flags. It returns a slice of matching requests and a // boolean flag that is true if no requests matched the filters. func FilterRequests(requests []*APIRequest, id string, tags string) ([]*APIRequest, bool) { //nolint:varnamelen if len(requests) == 0 { logger.Warnf(`No requests found.`) return requests, true } // Filter requests by ID. if id != "" { if req := filterByID(requests, id); req != nil { return []*APIRequest{req}, false } logger.Warnf(`No request with id (hex hash) "%s" found.`, id) return requests, true } // Or filter requests by tags. if tags != "" { tagsList := strings.Split(tags, ",") wantedTags := make([]string, 0, len(tagsList)) for _, tag := range tagsList { tag = strings.TrimSpace(tag) if tag != "" { wantedTags = append(wantedTags, tag) } } filteredRequests := filterByTags(requests, wantedTags) if len(filteredRequests) > 0 { return filteredRequests, false } logger.Warnf(`No requests found for tags "%s".`, tags) return requests, true } // Or use the fallback (return all requests). return requests, false } // filterByID searches a slice of APIRequest for the given ID // and returns the first matching object. func filterByID(requests []*APIRequest, id string) *APIRequest { for _, req := range requests { if req.ID == id { return req } } return nil } // filterByTags returns all APIRequest objects whose tags intersect // with the desired tag list. func filterByTags(requests []*APIRequest, wantedTags []string) []*APIRequest { // Build a set (map) for O|1 lookup of desired tags. wantedSet := make(map[string]struct{}, len(wantedTags)) for _, w := range wantedTags { wantedSet[w] = struct{}{} } var filteredRequests []*APIRequest // Check each request only once. for _, req := range requests { for _, tag := range req.Tags { if _, ok := wantedSet[tag]; ok { filteredRequests = append(filteredRequests, req) break } } } return filteredRequests } // MergePreRequests constructs a merged requests list in which, for each // filtered request having a PreRequestID, the corresponding loaded request // is prepended before the filtered requests. It returns the gathered/merged // APIRequest list without duplicates. func MergePreRequests(loadedRequests []*APIRequest, filteredRequests []*APIRequest) ([]*APIRequest, error) { lookupMap := make(map[string]*APIRequest, len(loadedRequests)) for _, loadedReq := range loadedRequests { lookupMap[loadedReq.ID] = loadedReq } const tenCharHexHashPattern = `^[a-fA-F0-9]{10}$` hexPattern := regexp.MustCompile(tenCharHexHashPattern) requestsList := make([]*APIRequest, 0, len(loadedRequests)+len(filteredRequests)) // Handle possible pre-requests by PreRequestID. for _, filteredReq := range filteredRequests { preID := filteredReq.PreRequestID if preID == "" { continue } if !hexPattern.MatchString(preID) { logger.Errorf(`PreRequestID "%s" has invalid format (not the expected ten character hex hash format).`, preID) return nil, errors.New("invalid format error") } prev, found := lookupMap[preID] if !found { logger.Errorf(`PreRequestID "%s" not found in loadedRequests.`, preID) return nil, errors.New("not found error") } requestsList = append(requestsList, prev) } // Append all filtered requests (to be behind the pre-requests). requestsList = append(requestsList, filteredRequests...) return removeDuplicates(requestsList), nil } // removeDuplicates returns a new slice of APIRequest pointers with // duplicates removed, keeping only the first occurrence of each // request based on its ID. func removeDuplicates(requestsList []*APIRequest) []*APIRequest { seen := make(map[string]bool, len(requestsList)) unique := make([]*APIRequest, 0, len(requestsList)) for _, req := range requestsList { if !seen[req.ID] { seen[req.ID] = true unique = append(unique, req) } } return unique }
package loader import ( "bytes" "encoding/json" "net/http" "net/url" "os" "path/filepath" "strings" "github.com/sven-seyfert/apiprobe/internal/logger" "github.com/sven-seyfert/apiprobe/internal/util" ) // APIRequest represents the structure of each API request definition // as specified in the input JSON configuration. type APIRequest struct { ID string `json:"id"` IsActive bool `json:"isActive"` IsAuthRequest bool `json:"isAuthRequest"` PreRequestID string `json:"preRequestId"` Request Request `json:"request"` TestCases []TestCases `json:"testCases"` Tags []string `json:"tags"` JqCommand string `json:"jq"` // Relative JSON file path. JSONFilePath string `json:"-"` } // Request holds the HTTP-specific details for an API request. type Request struct { Description string `json:"description"` Method string `json:"method"` BaseURL string `json:"url"` Endpoint string `json:"endpoint"` BasicAuth string `json:"basicAuth"` Headers []string `json:"headers"` Params []string `json:"params"` PostBodyRaw json.RawMessage `json:"postBody"` // Target data type for the POST body format is string. PostBody string `json:"-"` } // TestCases defines the input variations for the requests. type TestCases struct { Name string `json:"name"` ParamsData string `json:"paramsData"` PostBodyDataRaw json.RawMessage `json:"postBodyData"` // Target data type for the POST body format is string. PostBodyData string `json:"-"` } // PreparePostBody prepares the request body (empty, x-www-form-urlencoded // or compacted JSON). Returns nil on success or an error if JSON compaction fails. func (req *APIRequest) PreparePostBody() error { const emptyPostBodyLength = 2 if len(string(req.Request.PostBodyRaw)) == emptyPostBodyLength { req.Request.PostBody = "" return nil } var buf bytes.Buffer if err := json.Compact(&buf, req.Request.PostBodyRaw); err != nil { logger.Errorf("Failed by attempting JSON compact. Error: %v", err) return err } req.Request.PostBody = buf.String() // Case POST body is JSON. if !util.ContainsSubstring(req.Request.Headers, "x-www-form-urlencoded") { return nil } // Case POST body form is "x-www-form-urlencoded" which is no JSON. formURL, err := transformToFormURL(req.Request.PostBody) if err != nil { return err } req.Request.PostBody = formURL return nil } // PreparePostBodyData processes the raw POST body data of all test cases. // It normalizes the content based on header type, compacts JSON when needed, // and sets the processed result into PostBodyData. // Returns an error if JSON compaction fails, otherwise nil. func (req *APIRequest) PreparePostBodyData() error { for idx := range req.TestCases { testCase := &req.TestCases[idx] const emptyPostBodyDataLength = 2 if len(string(testCase.PostBodyDataRaw)) == emptyPostBodyDataLength { testCase.PostBodyData = "" continue } var buf bytes.Buffer if err := json.Compact(&buf, testCase.PostBodyDataRaw); err != nil { logger.Errorf("Failed by attempting JSON compact for test case %d. Error: %v", idx, err) return err } testCase.PostBodyData = buf.String() // Case POST body is JSON. if !util.ContainsSubstring(req.Request.Headers, "x-www-form-urlencoded") { continue } // Case POST body form is "x-www-form-urlencoded" which is no JSON. formURL, err := transformToFormURL(testCase.PostBodyData) if err != nil { return err } testCase.PostBodyData = formURL } return nil } // transformToFormURL converts a JSON string representing a flat map[string]string // into a URL-encoded form string and returns the decoded form. An error is // returned if JSON unmarshalling or URL query unescape fails. func transformToFormURL(jsonStr string) (string, error) { var params map[string]string if err := json.Unmarshal([]byte(jsonStr), ¶ms); err != nil { logger.Errorf(`Failed to unmarshal JSON "%v".`, err) return "", err } formValues := url.Values{} for key, value := range params { formValues.Set(key, value) } encodedForm := formValues.Encode() rawForm, err := url.QueryUnescape(encodedForm) if err != nil { logger.Errorf(`Failed to query unescape "%v".`, err) return "", err } return rawForm, nil } // BuildRequestURL constructs the full request URL by concatenating the BaseURL, // Endpoint, and optional query parameters defined in the APIRequest. func (req *APIRequest) BuildRequestURL() string { var requestURL strings.Builder requestURL.WriteString(req.Request.BaseURL) requestURL.WriteString(req.Request.Endpoint) if len(req.Request.Params) > 0 { requestURL.WriteString("?") requestURL.WriteString(url.PathEscape(strings.Join(req.Request.Params, "&"))) } return requestURL.String() } // CurlCmdArguments builds the command-line arguments for a curl invocation // based on the HTTP method, URL, headers, authentication and payload // specified in the APIRequest. func (req *APIRequest) CurlCmdArguments() []string { cmdArgs := []string{ "--request", req.Request.Method, "--silent", "--location", "--insecure", "--connect-timeout", "8", "--max-time", "24", "--url", req.BuildRequestURL(), "--write-out", "%{http_code}", } if req.Request.Method == http.MethodGet { cmdArgs = append(cmdArgs, "--get") } if req.Request.Method == http.MethodPost || req.Request.Method == http.MethodPut { if req.Request.PostBody != "" { postBody := req.Request.PostBody // Encoding for POST/PUT body form "x-www-form-urlencoded". if util.ContainsSubstring(req.Request.Headers, "x-www-form-urlencoded") { postBody = url.PathEscape(req.Request.PostBody) } cmdArgs = append(cmdArgs, "--data", postBody) } } if req.Request.BasicAuth != "" { cmdArgs = append(cmdArgs, "--user", req.Request.BasicAuth) } for _, header := range req.Request.Headers { cmdArgs = append(cmdArgs, "--header", header) } return cmdArgs } // LoadAllRequests recursively walks the input directory, parses all JSON files // and returns APIRequest pointers. func LoadAllRequests() ([]*APIRequest, error) { const inputDir = "./data/input" var requests []*APIRequest err := filepath.Walk(inputDir, func(path string, _ os.FileInfo, err error) error { if err != nil { logger.Errorf("Failed to walk path. Error: %v", err) return err } if filepath.Ext(path) == ".json" { fileRequest, loadErr := loadRequestFromFile(path, inputDir) if loadErr != nil { return loadErr } requests = append(requests, fileRequest...) } return nil }) return requests, err } // loadRequestFromFile reads a JSON file, unmarshals it into APIRequest structs // and assigns the file path. func loadRequestFromFile(path string, inputDir string) ([]*APIRequest, error) { bytes, err := os.ReadFile(path) if err != nil { logger.Errorf(`Failed to read file "%s". Error: %v`, path, err) return nil, err } var requestData []APIRequest if err = json.Unmarshal(bytes, &requestData); err != nil { logger.Errorf(`Failed to unmarshal JSON "%s". Error: %v`, path, err) return nil, err } // Store JSON file path in each request (relative to ./data/input). relPath, err := filepath.Rel(inputDir, path) if err != nil { logger.Errorf(`Failed to get relative path "%s". Error: %v`, path, err) return nil, err } request := make([]*APIRequest, len(requestData)) for idx := range requestData { requestData[idx].JSONFilePath = relPath request[idx] = &requestData[idx] } return request, nil }
package logger import ( "fmt" "io" "log" //nolint:depguard "os" "path/filepath" "runtime" "time" ) // Init sets up the logger, creates a new log file, // directs output to both console and file, and // returns an error if initialization fails. func Init() error { now := time.Now() yearMonth := now.Format("2006-01") day := now.Format("02") logsDir := filepath.Join(".", "logs", yearMonth, day) if err := os.MkdirAll(logsDir, os.ModePerm); err != nil { //nolint:gosec Errorf(`Failed to create logs directory "%s". Error: %v`, logsDir, err) return err } // Generate unique log file (./logs/2025-06/18/2025-06-18-12-58-54.938.log). filename := now.Format("2006-01-02-15-04-05.000") + ".log" logFilePath := filepath.Join(logsDir, filename) // Open log file. const permissions = 0o644 logFile, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, permissions) if err != nil { Errorf(`Failed to open log file "%s". Error: %v`, logFilePath, err) return err } // Set log output to console and to file. log.SetOutput(io.MultiWriter(os.Stdout, logFile)) return nil } // NewLine logs a blank line without timestamp or prefix. func NewLine() { oldFlags := log.Flags() log.SetFlags(0) log.Println() log.SetFlags(oldFlags) } // Fatalf logs a formatted fatal message with context. func Fatalf(format string, args ...interface{}) { log.Printf("[FATAL] %s %s", fmt.Sprintf(format, args...), occurrence()) } // Errorf logs a formatted error message with context. func Errorf(format string, args ...interface{}) { log.Printf("[ERROR] %s %s", fmt.Sprintf(format, args...), occurrence()) } // Warnf logs a formatted warning message with context. func Warnf(format string, args ...interface{}) { log.Printf("[WARN] %s %s", fmt.Sprintf(format, args...), occurrence()) } // Infof logs a formatted informational message with context. func Infof(format string, args ...interface{}) { log.Printf("[INFO] %s %s", fmt.Sprintf(format, args...), occurrence()) } // Debugf logs a formatted debug message with context. func Debugf(format string, args ...interface{}) { log.Printf("[DEBUG] %s %s", fmt.Sprintf(format, args...), occurrence()) } // occurrence retrieves the caller’s file and line number // for log entries. func occurrence() string { const skip = 2 _, file, line, ok := runtime.Caller(skip) if !ok { return "" } return fmt.Sprintf("(%s:%d)\n", filepath.Base(file), line) }
package report import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os" "strings" "zombiezen.com/go/sqlite" "github.com/sven-seyfert/apiprobe/internal/config" "github.com/sven-seyfert/apiprobe/internal/crypto" "github.com/sven-seyfert/apiprobe/internal/db" "github.com/sven-seyfert/apiprobe/internal/logger" ) // Notification sends a summary notification via WebEx webhook. func Notification( ctx context.Context, cfg *config.Config, conn *sqlite.Conn, res *Result, rep *Report, name string, ) { if cfg.Notification.WebEx == nil || !cfg.Notification.WebEx.Active { return } const reportFile = "./logs/report.json" hostname, _ := os.Hostname() hostnameMessage := fmt.Sprintf("Message from: __%s__ (hostname)", hostname) if res.RequestErrorCount == 0 && res.FormatResponseErrorCount == 0 && res.ChangedFilesCount == 0 { _ = os.Remove(reportFile) isHeartbeatTime, err := IsHeartbeatTime(cfg) if err != nil { return } if !isHeartbeatTime { return } if err = UpdateHeartbeatTime(cfg); err != nil { return } mdMessage := fmt.Sprintf( `{"markdown":"#### 💙 %s\nHeartbeat: __still alive__\n\n%s"}`, config.Version, hostnameMessage, ) webhookPayload := []byte(mdMessage) webExWebhookNotification(ctx, conn, cfg.Notification.WebEx.WebhookURL, cfg.Notification.WebEx.Space, webhookPayload) return } if err := rep.SaveToFile(reportFile); err != nil { logger.Errorf("Error on save file. Error: %v", err) return } data, err := os.ReadFile(reportFile) if err != nil { logger.Errorf("Error on read file. Error: %v", err) return } mdCodeBlock := fmt.Sprintf("```json\n%s\n```", data) testRunName := "" if name != "" { testRunName = fmt.Sprintf("`%s`\n\n", name) } mdResult := fmt.Sprintf( "%sChanged files: __%d__\nRequest errors: __%d__\nFormat response errors: __%d__\n\n📄 _report.json_", testRunName, res.ChangedFilesCount, res.RequestErrorCount, res.FormatResponseErrorCount, ) trafficLight := "🔴" if res.RequestErrorCount == 0 && res.FormatResponseErrorCount == 0 && res.ChangedFilesCount > 0 { trafficLight = "🟡" } mdMessage := fmt.Sprintf( "#### %s %s\n%s\n%s\n\n%s", trafficLight, config.Version, mdResult, mdCodeBlock, hostnameMessage, ) payload := map[string]string{ "markdown": mdMessage, } webhookPayload, _ := json.Marshal(payload) webExWebhookNotification(ctx, conn, cfg.Notification.WebEx.WebhookURL, cfg.Notification.WebEx.Space, webhookPayload) } // webExWebhookNotification sends the given JSON payload to the configured // WebEx incoming webhook URL. func webExWebhookNotification( ctx context.Context, conn *sqlite.Conn, webhookURL string, spaceSecret string, webhookPayload []byte, ) { url := webhookURL + spaceSecret const secretPrefix = "<secret-" if strings.Contains(spaceSecret, secretPrefix) { spaceSecret = crypto.ExtractSecretHash(spaceSecret) spaceIdentifier, _ := db.SelectHash(conn, spaceSecret) url = webhookURL + crypto.Deobfuscate(spaceIdentifier) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(webhookPayload)) if err != nil { logger.Errorf("Error on new request. Error: %v", err) return } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { logger.Errorf("Error on send request. Error: %v", err) return } defer resp.Body.Close() }
package report import ( "encoding/json" "os" "time" "github.com/sven-seyfert/apiprobe/internal/config" "github.com/sven-seyfert/apiprobe/internal/loader" "github.com/sven-seyfert/apiprobe/internal/logger" ) type Result struct { RequestErrorCount int FormatResponseErrorCount int ChangedFilesCount int } // IncreaseRequestErrorCount increments the Result counter for failed HTTP requests. func (res *Result) IncreaseRequestErrorCount() { res.RequestErrorCount++ } // IncreaseFormatErrorCount increments the Result counter for JSON formatting or jq errors. func (res *Result) IncreaseFormatErrorCount() { res.FormatResponseErrorCount++ } // IncreaseChangedFilesCount increments the Result counter for the number of output files that have changed. func (res *Result) IncreaseChangedFilesCount() { res.ChangedFilesCount++ } type Request struct { ID string `json:"id"` Description string `json:"description"` Endpoint string `json:"endpoint"` StatusCode string `json:"statusCode"` OutputFilePath string `json:"outputFilePath"` TestCase string `json:"testCase"` } type Report struct { Requests []Request `json:"issues"` } // AddReportData records a single API request’s result, its ID, description, // endpoint, status code, output file path and test case into the Report. func (r *Report) AddReportData(req *loader.APIRequest, statusCode string, outputFilePath string, testCaseIndex int) { const noTestCaseIndicator = -1 testCase := "" if testCaseIndex != noTestCaseIndicator { testCase = req.TestCases[testCaseIndex].Name } request := Request{ ID: req.ID, Description: req.Request.Description, Endpoint: req.Request.Endpoint, StatusCode: statusCode, OutputFilePath: outputFilePath, TestCase: testCase, } r.Requests = append(r.Requests, request) } // SaveToFile creates a file with the given name and writes the report as // pretty-printed JSON. Returns an error if file creation or writing fails. func (r *Report) SaveToFile(filename string) error { file, err := os.Create(filename) if err != nil { logger.Errorf("Failure on create file. Error: %v", err) return err } defer file.Close() encoder := json.NewEncoder(file) encoder.SetIndent("", " ") if err = encoder.Encode(r); err != nil { logger.Errorf("Failure on write file. Error: %v", err) return err } return nil } // IsHeartbeatTime checks whether enough time has passed // since the last heartbeat and returns true if a new // heartbeat should be sent, or false otherwise, // along with any error encountered. func IsHeartbeatTime(cfg *config.Config) (bool, error) { lastHeartbeatTime := cfg.Heartbeat.LastHeartbeatTime if lastHeartbeatTime == "" { return true, nil } threshold := time.Hour * time.Duration(cfg.Heartbeat.IntervalInHours) lastTime, err := time.Parse(time.RFC3339, lastHeartbeatTime) if err != nil { logger.Errorf(`Invalid datetime "%s". Error: %v\n`, lastHeartbeatTime, err) return false, err } diff := time.Since(lastTime) return diff >= threshold, nil } // UpdateHeartbeatTime writes the current UTC time (RFC3339) into // cfg.Heartbeat.LastHeartbeatTime and persists the entire config // back to the JSON file, returning an error if persistence fails. func UpdateHeartbeatTime(cfg *config.Config) error { cfg.Heartbeat.LastHeartbeatTime = time.Now().UTC().Format(time.RFC3339) file, err := os.Create("./config/apiprobe.json") if err != nil { logger.Errorf("Failure on create file. Error: %v", err) return err } defer file.Close() encoder := json.NewEncoder(file) encoder.SetIndent("", " ") encoder.SetEscapeHTML(false) if err = encoder.Encode(cfg); err != nil { logger.Errorf("Failure on write file. Error: %v", err) return err } return nil }
package util // Max returns the larger of two integer values. func Max(a, b int) int { if a > b { return a } return b }
package util import ( "strings" ) // ReplaceQueryParam returns a copy of the given params slice, with the // key from testCaseValue replaced if present, or appended otherwise. func ReplaceQueryParam(params []string, testCaseValue string) []string { const subStringCount = 2 keyToReplace := strings.SplitN(testCaseValue, "=", subStringCount)[0] replaced := false newParams := make([]string, len(params)) copy(newParams, params) for idx, param := range newParams { parts := strings.SplitN(param, "=", subStringCount) if len(parts) == subStringCount && parts[0] == keyToReplace { newParams[idx] = testCaseValue replaced = true break } } if !replaced { newParams = append(newParams, testCaseValue) //nolint:makezero } return newParams }
package util //nolint:revive import "strings" // TrimQuotes removes leading and trailing double quotes and trailing // CRLF from the given string. Returns the cleaned string. func TrimQuotes(value string) string { value = strings.TrimPrefix(value, `"`) value = strings.TrimSuffix(value, "\r\n") value = strings.TrimSuffix(value, `"`) return value } // ContainsSubstring checks if any string in the slice contains the given substring. // The comparison is case-insensitive and returns true if found, otherwise false. func ContainsSubstring(slice []string, substr string) bool { for _, value := range slice { if strings.Contains(strings.ToLower(value), strings.ToLower(substr)) { return true } } return false }
package main import ( "context" "errors" "os" "os/signal" "syscall" "github.com/sven-seyfert/apiprobe/internal/auth" "github.com/sven-seyfert/apiprobe/internal/config" "github.com/sven-seyfert/apiprobe/internal/crypto" "github.com/sven-seyfert/apiprobe/internal/db" "github.com/sven-seyfert/apiprobe/internal/exec" "github.com/sven-seyfert/apiprobe/internal/flags" "github.com/sven-seyfert/apiprobe/internal/loader" "github.com/sven-seyfert/apiprobe/internal/logger" "github.com/sven-seyfert/apiprobe/internal/report" "zombiezen.com/go/sqlite" ) // main initializes the logger and database, parses command-line flags loads // configuration and seeds the database. It then loads and filters API request // definitions, injects secrets, establishes a cancellation-aware context, // processes each request and finally sends notifications based on errors // or detected changes. func main() { dbConn, cliFlags, err := initializeServices() if err != nil { logger.Fatalf("Program exits: %v", err) return } defer dbConn.Close() cfg, err := config.Load("./config/apiprobe.json") if err != nil { logger.Fatalf("Program exits: Failed to load config file.") return } // Handle command-line flags. complete, err := flags.IsNewID(*cliFlags.NewID) if complete || err != nil { return } complete, err = flags.IsNewFile(*cliFlags.NewFile) if complete || err != nil { return } complete, err = flags.IsAddSecret(*cliFlags.AddSecret, dbConn) if complete || err != nil { return } // Fill database with default seed data. err = db.InsertSeedData(dbConn) if err != nil { logger.Fatalf("Program exits: Failed to fill database with seed default data.") return } // Load requests from JSON files in the input directory. requests, err := loader.LoadAllRequests() if err != nil { logger.Fatalf("Program exits: Failed to load API request definitions.") return } // Exclude requests based on IDs. filteredRequests := loader.ExcludeRequestsByID(requests, *cliFlags.Exclude) // Filter requests based on single id (ten character long hex hash) or by flags. filteredRequests, notFound := loader.FilterRequests(filteredRequests, *cliFlags.ID, *cliFlags.Tags) if notFound { return } // Merge possible pre-requests (prepend) with the filtered requests. preparedRequests, err := loader.MergePreRequests(requests, filteredRequests) if err != nil { logger.Fatalf("Program exits: Failed to gather pre-requests.") return } // Prepare the requests by compacting the JSON POST body, // handling "x-www-form-urlencoded" and POST body test cases. for _, req := range preparedRequests { if err = req.PreparePostBody(); err != nil { logger.Fatalf("Program exits: Failed to prepare the POST body.") } if err = req.PreparePostBodyData(); err != nil { logger.Fatalf("Program exits: Failed to prepare the POST body test cases.") } } // Replace secrets placeholders in the requests with actual values. finalRequests, err := crypto.HandleSecrets(preparedRequests, dbConn) if err != nil { logger.Fatalf("Program exits: Failed to handle secrets in requests.") return } // Only once requests are loaded successfully, set up signal-cancellation context. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() // Initializes token store. tokenStore := auth.NewTokenStore() // Process each API request, optionally with test case variations. res, rep := processRequests(ctx, finalRequests, tokenStore, cfg.DebugMode) // Send notification on error case or on changes. report.Notification(ctx, cfg, dbConn, res, rep, *cliFlags.Name) } // initializeServices initializes logger, database and CLI flags. // Returns database connection, CLI flags and error if initialization fails. func initializeServices() (*sqlite.Conn, *flags.CLIFlags, error) { if err := logger.Init(); err != nil { return nil, nil, errors.Join(errors.New("failed to initialize logger: "), err) } conn, err := db.Init() if err != nil { return nil, nil, errors.Join(errors.New("failed to initialize database: "), err) } cliFlags := flags.Init() return conn, cliFlags, nil } // processRequests iterates over the APIRequests, executes // each (including test cases), and writes the results. It returns // the aggregated Result and Report. func processRequests( ctx context.Context, requests []*loader.APIRequest, tokenStore *auth.TokenStore, debugMode bool, ) (*report.Result, *report.Report) { res := &report.Result{} rep := &report.Report{} for idx, req := range requests { if ctx.Err() != nil { logger.Debugf("Received cancellation signal. Stopping request processing.") return res, rep } if !req.IsActive { continue } if idx > 0 { logger.NewLine() } logger.Infof(`Run: %d, Test case: %d, File: "%s"`, idx+1, 0, req.JSONFilePath) if req.PreRequestID != "" { auth.RepaceAuthTokenPlaceholderInRequestHeader(req, tokenStore) } // Execute first (main) request, regardless of whether additional test cases exist. exec.ProcessFirstRequest(ctx, idx+1, req, nil, res, rep, tokenStore, debugMode) // Execute additional requests of the same JSON definition file, // depending on the number of defined test cases. exec.ProcessTestCasesRequests(ctx, req, idx, res, rep, tokenStore, debugMode) } return res, rep }