├── .tool-versions ├── cmd ├── services │ ├── discord.go │ ├── mailgun.go │ ├── twitter.go │ ├── twitter_bearer.go │ ├── github.go │ └── slack.go ├── cli_test.go └── cli.go ├── go.mod ├── main.go ├── pkg ├── registry │ ├── registry.go │ └── registry_test.go └── keyhack │ ├── keyhack.go │ └── keyhack_test.go ├── keyhacks.yml ├── go.sum └── readme.md /.tool-versions: -------------------------------------------------------------------------------- 1 | golang 1.23.6 2 | nodejs 23.10.0 3 | -------------------------------------------------------------------------------- /cmd/services/discord.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | cli "github.com/audibleblink/kh/cmd" 5 | ) 6 | 7 | // Service configuration 8 | const ( 9 | DiscordSubCmd = "discord" 10 | DiscordToken = "" 11 | ) 12 | 13 | // init registers the Discord service 14 | func init() { 15 | // Create the command 16 | _ = cli.NewServiceCommand(DiscordSubCmd, DiscordToken) 17 | 18 | // No custom validator needed - uses default 200 OK check 19 | } -------------------------------------------------------------------------------- /cmd/services/mailgun.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | cli "github.com/audibleblink/kh/cmd" 5 | ) 6 | 7 | // Service configuration 8 | const ( 9 | MailgunSubCmd = "mailgun" 10 | MailgunToken = "" 11 | ) 12 | 13 | // init registers the Mailgun service 14 | func init() { 15 | // Create the command 16 | _ = cli.NewServiceCommand(MailgunSubCmd, MailgunToken) 17 | 18 | // No custom validator needed - uses default 200 OK check 19 | } -------------------------------------------------------------------------------- /cmd/services/twitter.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | cli "github.com/audibleblink/kh/cmd" 5 | ) 6 | 7 | // Service configuration 8 | const ( 9 | TwitterSubCmd = "twitter" 10 | TwitterToken = "" 11 | ) 12 | 13 | // init registers the Twitter service 14 | func init() { 15 | // Create the command 16 | _ = cli.NewServiceCommand(TwitterSubCmd, TwitterToken) 17 | 18 | // No custom validator needed - uses default 200 OK check 19 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/audibleblink/kh 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.6 6 | 7 | require ( 8 | github.com/spf13/cobra v1.9.1 9 | gopkg.in/yaml.v3 v3.0.1 10 | ) 11 | 12 | require ( 13 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 14 | github.com/kr/pretty v0.3.1 // indirect 15 | github.com/rogpeppe/go-internal v1.12.0 // indirect 16 | github.com/spf13/pflag v1.0.6 // indirect 17 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /cmd/services/twitter_bearer.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | cli "github.com/audibleblink/kh/cmd" 5 | ) 6 | 7 | // Service configuration 8 | const ( 9 | TwitterBearerSubCmd = "twitter-bearer" 10 | TwitterBearerToken = "" 11 | ) 12 | 13 | // init registers the Twitter Bearer service 14 | func init() { 15 | // Create the command 16 | _ = cli.NewServiceCommand(TwitterBearerSubCmd, TwitterBearerToken) 17 | 18 | // No custom validator needed - uses default 200 OK check 19 | } -------------------------------------------------------------------------------- /cmd/services/github.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | cli "github.com/audibleblink/kh/cmd" 5 | ) 6 | 7 | // Service configuration 8 | const ( 9 | GithubTokenSubCmd = "github-token" 10 | GithubTokenToken = "" 11 | 12 | GithubOauthSubCmd = "github-oauth" 13 | GithubOauthToken = "" 14 | ) 15 | 16 | // init registers the GitHub services 17 | func init() { 18 | // Create commands for both GitHub services 19 | _ = cli.NewServiceCommand(GithubTokenSubCmd, GithubTokenToken) 20 | _ = cli.NewServiceCommand(GithubOauthSubCmd, GithubOauthToken) 21 | 22 | // No custom validators needed - both use default 200 OK check 23 | } 24 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | 8 | cli "github.com/audibleblink/kh/cmd" 9 | _ "github.com/audibleblink/kh/cmd/services" 10 | "github.com/audibleblink/kh/pkg/keyhack" 11 | "github.com/audibleblink/kh/pkg/registry" 12 | ) 13 | 14 | //go:embed keyhacks.yml 15 | var configData []byte 16 | 17 | func init() { 18 | // Give pkg/keyhack access to pkg/registry 19 | keyhack.Registry.GetService = registry.GetService 20 | } 21 | 22 | func main() { 23 | if err := registry.LoadFromBytes(configData); err != nil { 24 | fmt.Fprintf(os.Stderr, "Error loading configuration: %s\n", err) 25 | os.Exit(1) 26 | } 27 | 28 | // Execute the CLI 29 | cli.Execute() 30 | } 31 | -------------------------------------------------------------------------------- /cmd/services/slack.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "net/http" 5 | 6 | cli "github.com/audibleblink/kh/cmd" 7 | "github.com/audibleblink/kh/pkg/registry" 8 | ) 9 | 10 | // Service configuration 11 | const ( 12 | SlackSubCmd = "slack-token" 13 | SlackToken = "" 14 | ) 15 | 16 | // init registers the Slack service 17 | func init() { 18 | // Create the command 19 | _ = cli.NewServiceCommand(SlackSubCmd, SlackToken) 20 | 21 | // Register the validator directly 22 | _ = registry.RegisterValidator(SlackSubCmd, validateSlack) 23 | } 24 | 25 | // validateSlack defines what a successful authentication looks like 26 | // based on the HTTP response of the API call 27 | func validateSlack(resp *http.Response) (ok bool, err error) { 28 | ok = resp.Header["X-Oauth-Scopes"] != nil 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /cmd/cli_test.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "slices" 5 | "testing" 6 | ) 7 | 8 | func TestNewServiceCommand(t *testing.T) { 9 | // Save the size of serviceCommands before the test 10 | initialSize := len(serviceCommands) 11 | 12 | // Call NewServiceCommand to create a new command 13 | cmd := NewServiceCommand("test-service", "") 14 | 15 | // Verify command properties 16 | if cmd.Use != "test-service " { 17 | t.Errorf("Command Use = %q, want %q", cmd.Use, "test-service ") 18 | } 19 | 20 | if cmd.Short != "" { 21 | t.Errorf("Command Short = %q, want %q", cmd.Short, "") 22 | } 23 | 24 | // Verify the command was registered 25 | if len(serviceCommands) != initialSize+1 { 26 | t.Errorf("serviceCommands size = %d, want %d", len(serviceCommands), initialSize+1) 27 | } 28 | 29 | // Verify the command is in the serviceCommands slice 30 | if !slices.Contains(serviceCommands, cmd) { 31 | t.Errorf("The created command was not found in serviceCommands") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/registry/registry.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "fmt" 5 | 6 | "gopkg.in/yaml.v3" 7 | 8 | kh "github.com/audibleblink/kh/pkg/keyhack" 9 | ) 10 | 11 | // ServiceRegistry holds configurations for service validation 12 | type ServiceRegistry map[string]*kh.KeyHack 13 | 14 | // registry is the global registry instance, accessed through accessor functions 15 | var registry = make(ServiceRegistry) 16 | 17 | // LoadFromBytes loads configuration from the provided bytes 18 | func LoadFromBytes(configData []byte) error { 19 | if err := yaml.Unmarshal(configData, ®istry); err != nil { 20 | return fmt.Errorf("failed to parse config: %w", err) 21 | } 22 | 23 | return nil 24 | } 25 | 26 | // GetService returns a service by name, or nil if not found 27 | func GetService(name string) (*kh.KeyHack, bool) { 28 | service, exists := registry[name] 29 | return service, exists 30 | } 31 | 32 | // RegisterValidator registers a custom validator for a service 33 | func RegisterValidator(serviceName string, validator kh.ValidatorFunc) error { 34 | service, exists := registry[serviceName] 35 | if !exists { 36 | return fmt.Errorf("service %q not configured", serviceName) 37 | } 38 | 39 | service.Validator.Fn = validator 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /keyhacks.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # Demo Service With All Params 4 | # sass-api: 5 | # name: sass-api 6 | # request: 7 | # method: POST [REQUIRED] 8 | # url: 'https://sass-api.io/api/auth' [REQUIRED] 9 | # headers: 10 | # Authorization: Bearer %s 11 | # validator: [REQUIRED if 200/40x http status is not indicative of success/failure] 12 | # custom: true 13 | 14 | github-oauth: 15 | name: github-oauth 16 | request: 17 | method: GET 18 | url: 'https://%s@api.github.com/feeds' 19 | github-token: 20 | name: github-token 21 | request: 22 | method: GET 23 | url: 'https://api.github.com/users' 24 | headers: 25 | Authorization: "token %s" 26 | slack-token: 27 | name: slack-token 28 | request: 29 | method: POST 30 | url: 'https://slack.com/api/auth.test?token=%s&pretty=1' 31 | validator: 32 | custom: true 33 | mailgun: 34 | name: mailgun 35 | request: 36 | method: GET 37 | url: 'https://api:%s@api.mailgun.net/v3/domains' 38 | twitter: 39 | name: twitter 40 | request: 41 | method: POST 42 | url: 'https://%s@api.twitter.com/oauth2/token?grant_type=client_credentials' 43 | twitter-bearer: 44 | name: twitter-bearer 45 | request: 46 | method: GET 47 | url: 'https://api.twitter.com/1.1/trends/available.json' 48 | headers: 49 | Authorization: Bearer %s 50 | discord: 51 | name: discord 52 | request: 53 | method: GET 54 | url: 'https://discordapp.com/api/users/@me' 55 | headers: 56 | Authorization: "Bot %s" 57 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 3 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 4 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 5 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 6 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 7 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 8 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 9 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 10 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 11 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 12 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 13 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 14 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 15 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 16 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 17 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 18 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 19 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 20 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 21 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 22 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 23 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 24 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 25 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 26 | -------------------------------------------------------------------------------- /cmd/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/audibleblink/kh/pkg/keyhack" 11 | ) 12 | 13 | var rootCmd = &cobra.Command{ 14 | Use: "kh", 15 | Short: "Validate API tokens/webhooks for various services", 16 | } 17 | 18 | // serviceCommands tracks all registered service commands 19 | var serviceCommands []*cobra.Command 20 | 21 | // Execute runs the CLI application 22 | func Execute() { 23 | // Add all registered service commands to the root command 24 | for _, cmd := range serviceCommands { 25 | rootCmd.AddCommand(cmd) 26 | } 27 | 28 | // Let Cobra handle command execution and errors 29 | if err := rootCmd.Execute(); err != nil { 30 | os.Exit(1) 31 | } 32 | } 33 | 34 | // NewServiceCommand creates a new cobra command for a service 35 | func NewServiceCommand(name, desc string) *cobra.Command { 36 | usage := strings.Join([]string{name, desc}, " ") 37 | cmd := &cobra.Command{ 38 | DisableFlagsInUseLine: true, 39 | DisableFlagParsing: true, 40 | Use: usage, 41 | Short: desc, 42 | Args: cobra.MinimumNArgs(1), 43 | Run: func(cmd *cobra.Command, args []string) { 44 | var ( 45 | ok bool 46 | err error 47 | ) 48 | 49 | // accept multiple lines through stdin 50 | if args[0] == "-" { 51 | scanner := bufio.NewScanner(os.Stdin) 52 | for scanner.Scan() { 53 | token := scanner.Text() 54 | ok, err = keyhack.Check(name, token) 55 | if err != nil { 56 | cmd.PrintErr(err) 57 | continue 58 | } 59 | if ok { 60 | cmd.Println(token) 61 | } 62 | } 63 | return 64 | } 65 | 66 | // allow multiple args so commands like xargs also work 67 | if len(args) > 1 { 68 | for _, token := range args { 69 | ok, err = keyhack.Check(name, token) 70 | if err != nil { 71 | cmd.PrintErr(err) 72 | continue 73 | } 74 | if ok { 75 | cmd.Println(token) 76 | } 77 | } 78 | return 79 | } 80 | 81 | token := args[0] 82 | ok, err = keyhack.Check(name, token) 83 | if err != nil { 84 | cmd.PrintErr(err) 85 | os.Exit(1) 86 | } 87 | if ok { 88 | cmd.Println(token) 89 | return 90 | } 91 | os.Exit(1) 92 | }, 93 | } 94 | 95 | // Register the command in the registry 96 | serviceCommands = append(serviceCommands, cmd) 97 | 98 | return cmd 99 | } 100 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | `kh` is a programmatic way to check for the validity of API tokens or webhooks. The services against 2 | which it is able to check originally came from the popular [keyhack](https://github.com/streaak/keyhacks#Slack-API-token) 3 | repo by [@streaak](https://github.com/streaak/). 4 | 5 | ## Usage 6 | 7 | ```bash 8 | $ kh github-token XXXXXXXXXXXXXXXXXXXXXXXXX 9 | 10 | $ ./my-custom-token-scanner | kh slack-token - | tee -a valid_slack_tokens.txt 11 | 12 | $ xargs kh slack-token < maybe_tokens.txt| tee -a valid_slack_tokens.txt 13 | ``` 14 | 15 | If the token is valid, `kh` will print the token and return a 0 status to bash. If the token is 16 | invalid, nothing will be printed and the status returned will be 1. The output is minimal so that 17 | the tool can be used in existing workflows, bash pipelines and scripts. 18 | 19 | ## Expandability 20 | 21 | It's possible to add services to the tool by modifying the configuration YAML file. 22 | 23 | ```yaml 24 | # Demo Service With All Params 25 | sass-api: 26 | name: sass-api 27 | request: 28 | method: POST # [REQUIRED] 29 | url: 'https://sass-api.io/api/auth' # [REQUIRED] 30 | headers: 31 | Authorization: Bearer %s 32 | validator: # [REQUIRED if 200/40x http status is not indicative of success/failure] 33 | custom: true 34 | ``` 35 | 36 | In the parameters where a token is to be interpolated, place a template symbol, `%s`, in place of 37 | the token value. 38 | 39 | By default, `kh` will declare a token as valid if the API returns a 200 HTTP status. Not all APIs are 40 | create equal nor do they use semantic HTTP status codes when replying. If you're attempting to add a 41 | new service to `kh` and both valid and invalid tokens return a `200`, then a custom validator must be written. 42 | 43 | In addition to editing the configuration YAML, users must add the subcommand to the `/cmd` 44 | folder in this repository's root. When declaring a custom validator in the YAML file, users must also 45 | define what a valid response looks like 46 | 47 | ```go 48 | // each subcommand's init function must add the subcommand to the root cli command 49 | // and then add the validator function to the keyhack registry so that it knows 50 | // what a good http response looks like 51 | func init() { 52 | rootCmd.AddCommand(slackTokenCmd) 53 | keyhack.Registry["slack-token"].Validator.Fn = validateSlack 54 | } 55 | 56 | // ensure the command name matches the entry in the YAML file 57 | var slackTokenCmd = newCommand("slack-token", "Checks a token against the Slack API") 58 | 59 | // validator functions define what a successful authentication means 60 | // based on the http response of the API call issued by keyhacks 61 | func validateSlack(resp *http.Response) (ok bool, err error) { 62 | ok = resp.Header["X-Oauth-Scopes"] != nil 63 | return 64 | } 65 | ``` 66 | 67 | If you don't need a custom validator, that is, if the API returns anything but a 200 with invalid creds, then the following is all that's needed in the new service: 68 | 69 | ```go 70 | // cmd/github.go 71 | package cli 72 | 73 | func init() { 74 | githubTokenCmd := newCommand("github-token", "Checks a token against the GitHub API") 75 | rootCmd.AddCommand(githubTokenCmd) 76 | } 77 | ``` 78 | 79 | 80 | ## Structure 81 | 82 | ``` 83 | ├── cmd # this is where new plugins go 84 | │   ├── cli.go # main entry point logic for the CLI utility 85 | │   └── 86 | ├── go.mod 87 | ├── go.sum 88 | ├── keyhacks.yml # tool configuration; add new service definitions here 89 | ├── main.go 90 | ├── pkg 91 | │   └── keyhack 92 | │   └── keyhack.go # core keyhack framework logic 93 | 94 | ``` 95 | -------------------------------------------------------------------------------- /pkg/keyhack/keyhack.go: -------------------------------------------------------------------------------- 1 | package keyhack 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "maps" 7 | "net/http" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // Registry provides access to the service registry 13 | var Registry struct { 14 | GetService func(name string) (*KeyHack, bool) 15 | } 16 | 17 | // Check validates a token against the specified service 18 | func Check(serviceName, token string) (bool, error) { 19 | service, exists := Registry.GetService(serviceName) 20 | if !exists { 21 | return false, fmt.Errorf("service %q not configured", serviceName) 22 | } 23 | 24 | ok, err := service.Validate(token) 25 | if err != nil { 26 | return false, fmt.Errorf("validation for service %q failed: %w", serviceName, err) 27 | } 28 | 29 | return ok, nil 30 | } 31 | 32 | // ValidatorFunc is a function that users define which establishes what a valid 33 | // authenticated HTTP response looks like from a given service 34 | type ValidatorFunc func(*http.Response) (bool, error) 35 | 36 | // Validator holds validation configuration and logic for a service 37 | type Validator struct { 38 | Custom bool 39 | Status int 40 | Fn ValidatorFunc 41 | } 42 | 43 | // Request contains the necessary parameters for validating a token 44 | type Request struct { 45 | Method string 46 | URL string 47 | Headers map[string]string 48 | } 49 | 50 | // KeyHack represents an API service definition from the config YAML 51 | type KeyHack struct { 52 | Name string 53 | Request 54 | Validator 55 | Custom bool 56 | } 57 | 58 | // Validate sends an HTTP request with the given token and validates the response 59 | func (kh *KeyHack) Validate(token string) (bool, error) { 60 | // Fill in the token template 61 | req := kh.prepareRequest(token) 62 | 63 | // Send the request 64 | res, err := kh.sendRequest(req) 65 | if err != nil { 66 | return false, fmt.Errorf("validation request failed: %w", err) 67 | } 68 | defer res.Body.Close() 69 | 70 | // Use default validator if none provided 71 | if !kh.Custom { 72 | kh.Fn = defaultValidator 73 | } 74 | 75 | // Run the validator 76 | ok, err := kh.Fn(res) 77 | if err != nil { 78 | return false, fmt.Errorf("validator function failed: %w", err) 79 | } 80 | 81 | return ok, nil 82 | } 83 | 84 | // defaultValidator checks for HTTP 200 OK status code 85 | func defaultValidator(resp *http.Response) (bool, error) { 86 | return resp.StatusCode == 200, nil 87 | } 88 | 89 | // prepareRequest creates a Request with the token inserted in templates 90 | func (kh *KeyHack) prepareRequest(token string) *Request { 91 | newReq := &Request{ 92 | Method: kh.Method, 93 | URL: kh.URL, 94 | Headers: make(map[string]string, len(kh.Headers)), 95 | } 96 | 97 | // Copy headers to avoid modifying original 98 | maps.Copy(newReq.Headers, kh.Headers) 99 | 100 | // Fill URL template 101 | if strings.Contains(kh.URL, "%s") { 102 | newReq.URL = fmt.Sprintf(kh.URL, token) 103 | } 104 | 105 | // Fill header templates 106 | for k, v := range kh.Headers { 107 | if strings.Contains(v, "%s") { 108 | newReq.Headers[k] = fmt.Sprintf(v, token) 109 | } 110 | } 111 | 112 | return newReq 113 | } 114 | 115 | // sendRequest performs the HTTP request and returns the response 116 | func (kh *KeyHack) sendRequest(req *Request) (*http.Response, error) { 117 | // Create a context with timeout 118 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 119 | defer cancel() 120 | 121 | // Build the HTTP request 122 | httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, nil) 123 | if err != nil { 124 | return nil, fmt.Errorf("failed to create request: %w", err) 125 | } 126 | 127 | // Add headers 128 | for k, v := range req.Headers { 129 | httpReq.Header.Add(k, v) 130 | } 131 | 132 | // Send the request 133 | client := &http.Client{ 134 | Timeout: 10 * time.Second, 135 | } 136 | 137 | res, err := client.Do(httpReq) 138 | if err != nil { 139 | return nil, fmt.Errorf("failed to execute request: %w", err) 140 | } 141 | 142 | return res, nil 143 | } 144 | -------------------------------------------------------------------------------- /pkg/registry/registry_test.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/audibleblink/kh/pkg/keyhack" 9 | ) 10 | 11 | // getRegistrySize returns the number of services in the registry 12 | func getRegistrySize() int { 13 | return len(registry) 14 | } 15 | 16 | // clearRegistry resets the registry to empty state 17 | func clearRegistry() { 18 | registry = make(ServiceRegistry) 19 | } 20 | 21 | func TestLoadFromBytes(t *testing.T) { 22 | // Clear the registry before test 23 | clearRegistry() 24 | 25 | testCases := []struct { 26 | name string 27 | yamlData []byte 28 | wantSize int 29 | wantError bool 30 | }{ 31 | { 32 | name: "Valid YAML", 33 | yamlData: []byte(` 34 | github-token: 35 | name: github-token 36 | request: 37 | method: GET 38 | url: 'https://api.github.com/users' 39 | headers: 40 | Authorization: "token %s" 41 | slack-token: 42 | name: slack-token 43 | request: 44 | method: POST 45 | url: 'https://slack.com/api/auth.test?token=%s&pretty=1' 46 | validator: 47 | custom: true 48 | `), 49 | wantSize: 2, 50 | wantError: false, 51 | }, 52 | { 53 | name: "Empty YAML", 54 | yamlData: []byte(""), 55 | wantSize: 0, 56 | wantError: false, 57 | }, 58 | { 59 | name: "Invalid YAML", 60 | yamlData: []byte(` 61 | invalid: 62 | - this 63 | is not: valid 64 | yaml: [ 65 | `), 66 | wantSize: 0, 67 | wantError: true, 68 | }, 69 | } 70 | 71 | for _, tc := range testCases { 72 | t.Run(tc.name, func(t *testing.T) { 73 | // Clear registry before each case 74 | clearRegistry() 75 | 76 | // Test LoadFromBytes 77 | err := LoadFromBytes(tc.yamlData) 78 | 79 | // Check error 80 | if (err != nil) != tc.wantError { 81 | t.Errorf("LoadFromBytes() error = %v, wantError %v", err, tc.wantError) 82 | return 83 | } 84 | 85 | // Check registry size 86 | gotSize := getRegistrySize() 87 | if gotSize != tc.wantSize { 88 | t.Errorf("LoadFromBytes() registry size = %d, want %d", gotSize, tc.wantSize) 89 | } 90 | }) 91 | } 92 | } 93 | 94 | func TestGetService(t *testing.T) { 95 | // Clear registry and set up test data 96 | clearRegistry() 97 | 98 | // Add a test service to the registry 99 | registry["test-service"] = &keyhack.KeyHack{ 100 | Name: "test-service", 101 | Request: keyhack.Request{ 102 | Method: "GET", 103 | URL: "https://example.com/api", 104 | }, 105 | } 106 | 107 | testCases := []struct { 108 | name string 109 | serviceName string 110 | wantExists bool 111 | }{ 112 | { 113 | name: "Service Exists", 114 | serviceName: "test-service", 115 | wantExists: true, 116 | }, 117 | { 118 | name: "Service Doesn't Exist", 119 | serviceName: "nonexistent", 120 | wantExists: false, 121 | }, 122 | } 123 | 124 | for _, tc := range testCases { 125 | t.Run(tc.name, func(t *testing.T) { 126 | service, exists := GetService(tc.serviceName) 127 | 128 | if exists != tc.wantExists { 129 | t.Errorf("GetService() exists = %v, want %v", exists, tc.wantExists) 130 | } 131 | 132 | if tc.wantExists && service == nil { 133 | t.Errorf("GetService() returned nil service but exists = true") 134 | } 135 | 136 | if tc.wantExists && service.Name != tc.serviceName { 137 | t.Errorf("GetService() service.Name = %s, want %s", service.Name, tc.serviceName) 138 | } 139 | }) 140 | } 141 | } 142 | 143 | func TestRegisterValidator(t *testing.T) { 144 | // Clear registry and set up test data 145 | clearRegistry() 146 | 147 | // Add a test service to the registry 148 | registry["test-service"] = &keyhack.KeyHack{ 149 | Name: "test-service", 150 | Request: keyhack.Request{ 151 | Method: "GET", 152 | URL: "https://example.com/api", 153 | }, 154 | } 155 | 156 | // Create a validator function 157 | validatorFunc := func(resp *http.Response) (bool, error) { 158 | return resp.StatusCode == 200, nil 159 | } 160 | 161 | testCases := []struct { 162 | name string 163 | serviceName string 164 | validator keyhack.ValidatorFunc 165 | wantError bool 166 | }{ 167 | { 168 | name: "Valid Service", 169 | serviceName: "test-service", 170 | validator: validatorFunc, 171 | wantError: false, 172 | }, 173 | { 174 | name: "Invalid Service", 175 | serviceName: "nonexistent", 176 | validator: validatorFunc, 177 | wantError: true, 178 | }, 179 | } 180 | 181 | for _, tc := range testCases { 182 | t.Run(tc.name, func(t *testing.T) { 183 | err := RegisterValidator(tc.serviceName, tc.validator) 184 | 185 | if (err != nil) != tc.wantError { 186 | t.Errorf("RegisterValidator() error = %v, wantError %v", err, tc.wantError) 187 | return 188 | } 189 | 190 | if !tc.wantError { 191 | // Verify validator was set 192 | service, exists := GetService(tc.serviceName) 193 | if !exists { 194 | t.Errorf("Service %s not found after registering validator", tc.serviceName) 195 | return 196 | } 197 | 198 | // Compare function pointers using reflection 199 | if reflect.ValueOf(service.Validator.Fn).Pointer() != reflect.ValueOf(tc.validator).Pointer() { 200 | t.Errorf("RegisterValidator() did not properly set the validator function") 201 | } 202 | } 203 | }) 204 | } 205 | } 206 | 207 | func TestCompleteYAMLParsing(t *testing.T) { 208 | // This test ensures that all fields in the YAML are properly parsed 209 | clearRegistry() 210 | 211 | yamlData := []byte(` 212 | github-token: 213 | name: github-token 214 | request: 215 | method: GET 216 | url: 'https://api.github.com/users' 217 | headers: 218 | Authorization: "token %s" 219 | Accept: "application/json" 220 | validator: 221 | custom: true 222 | status: 200 223 | `) 224 | 225 | err := LoadFromBytes(yamlData) 226 | if err != nil { 227 | t.Fatalf("LoadFromBytes() failed with error: %v", err) 228 | } 229 | 230 | service, exists := GetService("github-token") 231 | if !exists { 232 | t.Fatalf("Service 'github-token' not found after loading YAML") 233 | } 234 | 235 | // Check service name 236 | if service.Name != "github-token" { 237 | t.Errorf("Service name = %s, want 'github-token'", service.Name) 238 | } 239 | 240 | // Check request method 241 | if service.Method != "GET" { 242 | t.Errorf("Request method = %s, want 'GET'", service.Method) 243 | } 244 | 245 | // Check request URL 246 | if service.URL != "https://api.github.com/users" { 247 | t.Errorf("Request URL = %s, want 'https://api.github.com/users'", service.URL) 248 | } 249 | 250 | // Check headers 251 | if len(service.Headers) != 2 { 252 | t.Errorf("Request headers count = %d, want 2", len(service.Headers)) 253 | } 254 | 255 | if service.Headers["Authorization"] != "token %s" { 256 | t.Errorf("Authorization header = %s, want 'token %%s'", service.Headers["Authorization"]) 257 | } 258 | 259 | if service.Headers["Accept"] != "application/json" { 260 | t.Errorf("Accept header = %s, want 'application/json'", service.Headers["Accept"]) 261 | } 262 | 263 | // Check validator 264 | if !service.Validator.Custom { 265 | t.Errorf("Validator Custom = %v, want true", service.Validator.Custom) 266 | } 267 | 268 | if service.Status != 200 { 269 | t.Errorf("Validator Status = %d, want 200", service.Status) 270 | } 271 | } -------------------------------------------------------------------------------- /pkg/keyhack/keyhack_test.go: -------------------------------------------------------------------------------- 1 | package keyhack 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | // mockHTTP is a helper to create mock HTTP responses 12 | type mockHTTP struct { 13 | server *httptest.Server 14 | } 15 | 16 | // setupMockHTTP creates a test HTTP server with the given response 17 | func setupMockHTTP(statusCode int, body string, headers map[string]string) *mockHTTP { 18 | mock := &mockHTTP{} 19 | 20 | // Create a test server with a handler that returns the specified status and body 21 | mock.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | // Set response headers 23 | for k, v := range headers { 24 | w.Header().Set(k, v) 25 | } 26 | // Set status code 27 | w.WriteHeader(statusCode) 28 | // Write body 29 | w.Write([]byte(body)) 30 | })) 31 | 32 | return mock 33 | } 34 | 35 | // Close shuts down the mock HTTP server 36 | func (m *mockHTTP) Close() { 37 | m.server.Close() 38 | } 39 | 40 | // URL returns the mock server's URL 41 | func (m *mockHTTP) URL() string { 42 | return m.server.URL 43 | } 44 | 45 | func TestPrepareRequest(t *testing.T) { 46 | testCases := []struct { 47 | name string 48 | keyHack KeyHack 49 | token string 50 | wantURL string 51 | wantHeaders map[string]string 52 | wantMethod string 53 | }{ 54 | { 55 | name: "URL Template", 56 | keyHack: KeyHack{ 57 | Name: "test", 58 | Request: Request{ 59 | Method: "GET", 60 | URL: "https://api.example.com/%s/info", 61 | Headers: map[string]string{ 62 | "Accept": "application/json", 63 | }, 64 | }, 65 | }, 66 | token: "abc123", 67 | wantURL: "https://api.example.com/abc123/info", 68 | wantHeaders: map[string]string{"Accept": "application/json"}, 69 | wantMethod: "GET", 70 | }, 71 | { 72 | name: "Header Template", 73 | keyHack: KeyHack{ 74 | Name: "test", 75 | Request: Request{ 76 | Method: "POST", 77 | URL: "https://api.example.com/info", 78 | Headers: map[string]string{ 79 | "Authorization": "Bearer %s", 80 | "Accept": "application/json", 81 | }, 82 | }, 83 | }, 84 | token: "abc123", 85 | wantURL: "https://api.example.com/info", 86 | wantHeaders: map[string]string{ 87 | "Authorization": "Bearer abc123", 88 | "Accept": "application/json", 89 | }, 90 | wantMethod: "POST", 91 | }, 92 | { 93 | name: "Both Templates", 94 | keyHack: KeyHack{ 95 | Name: "test", 96 | Request: Request{ 97 | Method: "PUT", 98 | URL: "https://api.example.com/%s/info", 99 | Headers: map[string]string{ 100 | "Authorization": "Bearer %s", 101 | "Accept": "application/json", 102 | }, 103 | }, 104 | }, 105 | token: "abc123", 106 | wantURL: "https://api.example.com/abc123/info", 107 | wantHeaders: map[string]string{ 108 | "Authorization": "Bearer abc123", 109 | "Accept": "application/json", 110 | }, 111 | wantMethod: "PUT", 112 | }, 113 | } 114 | 115 | for _, tc := range testCases { 116 | t.Run(tc.name, func(t *testing.T) { 117 | req := tc.keyHack.prepareRequest(tc.token) 118 | 119 | // Check URL 120 | if req.URL != tc.wantURL { 121 | t.Errorf("URL = %q, want %q", req.URL, tc.wantURL) 122 | } 123 | 124 | // Check method 125 | if req.Method != tc.wantMethod { 126 | t.Errorf("Method = %q, want %q", req.Method, tc.wantMethod) 127 | } 128 | 129 | // Check headers 130 | if len(req.Headers) != len(tc.wantHeaders) { 131 | t.Errorf("Headers count = %d, want %d", len(req.Headers), len(tc.wantHeaders)) 132 | } 133 | 134 | for k, v := range tc.wantHeaders { 135 | if req.Headers[k] != v { 136 | t.Errorf("Header[%q] = %q, want %q", k, req.Headers[k], v) 137 | } 138 | } 139 | }) 140 | } 141 | } 142 | 143 | func TestDefaultValidator(t *testing.T) { 144 | testCases := []struct { 145 | name string 146 | statusCode int 147 | want bool 148 | }{ 149 | { 150 | name: "Success", 151 | statusCode: 200, 152 | want: true, 153 | }, 154 | { 155 | name: "Failure", 156 | statusCode: 401, 157 | want: false, 158 | }, 159 | } 160 | 161 | for _, tc := range testCases { 162 | t.Run(tc.name, func(t *testing.T) { 163 | resp := &http.Response{ 164 | StatusCode: tc.statusCode, 165 | Body: io.NopCloser(strings.NewReader("")), 166 | } 167 | 168 | got, err := defaultValidator(resp) 169 | if err != nil { 170 | t.Errorf("defaultValidator() error = %v", err) 171 | } 172 | 173 | if got != tc.want { 174 | t.Errorf("defaultValidator() = %v, want %v", got, tc.want) 175 | } 176 | }) 177 | } 178 | } 179 | 180 | func TestCheck(t *testing.T) { 181 | // Set up a service for testing 182 | service := &KeyHack{ 183 | Name: "test", 184 | Request: Request{ 185 | Method: "GET", 186 | URL: "https://api.example.com/test", 187 | }, 188 | Validator: Validator{ 189 | Custom: true, 190 | Fn: func(resp *http.Response) (bool, error) { 191 | return resp.StatusCode == 200, nil 192 | }, 193 | }, 194 | } 195 | 196 | // Save the original Registry.GetService 197 | originalGetService := Registry.GetService 198 | 199 | // Set up a temporary mock registry 200 | mockRegistry := func(name string) (*KeyHack, bool) { 201 | if name == "test" { 202 | return service, true 203 | } 204 | return nil, false 205 | } 206 | 207 | // Install the mock 208 | Registry.GetService = mockRegistry 209 | 210 | // Restore the original registry when we're done 211 | defer func() { 212 | Registry.GetService = originalGetService 213 | }() 214 | 215 | // Set up a mock HTTP server 216 | mock := setupMockHTTP(200, `{"success": true}`, nil) 217 | defer mock.Close() 218 | 219 | // Update the service URL to point to our mock server 220 | service.URL = mock.URL() 221 | 222 | // Test successful Check 223 | got, err := Check("test", "dummy-token") 224 | if err != nil { 225 | t.Errorf("Check() error = %v", err) 226 | } 227 | if !got { 228 | t.Errorf("Check() = %v, want true", got) 229 | } 230 | 231 | // Test non-existent service 232 | _, err = Check("nonexistent", "dummy-token") 233 | if err == nil { 234 | t.Error("Check() with nonexistent service should error") 235 | } 236 | } 237 | 238 | func TestValidate(t *testing.T) { 239 | // Set up a mock HTTP server 240 | mockSuccess := setupMockHTTP(200, `{"success": true}`, nil) 241 | defer mockSuccess.Close() 242 | 243 | mockFailure := setupMockHTTP(401, `{"error": "unauthorized"}`, nil) 244 | defer mockFailure.Close() 245 | 246 | testCases := []struct { 247 | name string 248 | keyHack KeyHack 249 | token string 250 | want bool 251 | wantErr bool 252 | }{ 253 | { 254 | name: "Success - Default Validator", 255 | keyHack: KeyHack{ 256 | Name: "test", 257 | Request: Request{ 258 | Method: "GET", 259 | URL: mockSuccess.URL(), 260 | }, 261 | Validator: Validator{ 262 | Custom: false, 263 | }, 264 | }, 265 | token: "dummy-token", 266 | want: true, 267 | wantErr: false, 268 | }, 269 | { 270 | name: "Failure - Default Validator", 271 | keyHack: KeyHack{ 272 | Name: "test", 273 | Request: Request{ 274 | Method: "GET", 275 | URL: mockFailure.URL(), 276 | }, 277 | Validator: Validator{ 278 | Custom: false, 279 | }, 280 | }, 281 | token: "dummy-token", 282 | want: false, 283 | wantErr: false, 284 | }, 285 | { 286 | name: "Success - Custom Validator", 287 | keyHack: KeyHack{ 288 | Name: "test", 289 | Request: Request{ 290 | Method: "GET", 291 | URL: mockSuccess.URL(), 292 | }, 293 | Validator: Validator{ 294 | Custom: true, 295 | Fn: func(resp *http.Response) (bool, error) { 296 | return true, nil 297 | }, 298 | }, 299 | }, 300 | token: "dummy-token", 301 | want: true, 302 | wantErr: false, 303 | }, 304 | } 305 | 306 | for _, tc := range testCases { 307 | t.Run(tc.name, func(t *testing.T) { 308 | got, err := tc.keyHack.Validate(tc.token) 309 | 310 | if (err != nil) != tc.wantErr { 311 | t.Errorf("Validate() error = %v, wantErr %v", err, tc.wantErr) 312 | return 313 | } 314 | 315 | if got != tc.want { 316 | t.Errorf("Validate() = %v, want %v", got, tc.want) 317 | } 318 | }) 319 | } 320 | } 321 | 322 | // TestInvalidURL ensures sendRequest handles invalid URLs properly 323 | func TestInvalidURL(t *testing.T) { 324 | kh := &KeyHack{ 325 | Name: "test", 326 | Request: Request{ 327 | Method: "GET", 328 | URL: "://invalid-url", // Invalid URL format 329 | }, 330 | } 331 | 332 | req := &Request{ 333 | Method: "GET", 334 | URL: "://invalid-url", 335 | } 336 | 337 | _, err := kh.sendRequest(req) 338 | if err == nil { 339 | t.Error("sendRequest() with invalid URL should return error") 340 | } 341 | } --------------------------------------------------------------------------------