From 24246839237aedef2552e87de8005cdbe5b022b8 Mon Sep 17 00:00:00 2001 From: Miccah Date: Thu, 25 Jul 2024 12:06:05 -0700 Subject: [PATCH] Analyze (#3099) * Add POC analyze sub-command * Address lint errors * [chore] Embed scopes at compile time * [chore] Move subcommand check up to prevent printing metrics * added http logging to most analyzers * Use custom RoundTripper with default http.Client * Create framework of interfaces, structs, and protos * Merge main * Add AnalysisInfo to detectors.Result * Hide analyze subcommand * Update gen_proto.sh * Update protos * Make protos * Update analyzer data types * Rename argument to credentialInfo --------- Co-authored-by: Joe Leon --- go.mod | 13 + go.sum | 28 +- main.go | 93 +- pkg/analyzer/analyzers/airbrake/airbrake.go | 127 ++ pkg/analyzer/analyzers/airbrake/scopes.go | 27 + pkg/analyzer/analyzers/analyzers.go | 217 +++ pkg/analyzer/analyzers/asana/asana.go | 85 + pkg/analyzer/analyzers/bitbucket/bitbucket.go | 196 +++ pkg/analyzer/analyzers/bitbucket/scopes.go | 112 ++ pkg/analyzer/analyzers/github/classictoken.go | 202 +++ pkg/analyzer/analyzers/github/finegrained.go | 1411 ++++++++++++++++ pkg/analyzer/analyzers/github/github.go | 204 +++ pkg/analyzer/analyzers/gitlab/gitlab.go | 278 ++++ pkg/analyzer/analyzers/gitlab/scopes.go | 28 + .../analyzers/huggingface/huggingface.go | 409 +++++ pkg/analyzer/analyzers/huggingface/scopes.go | 74 + pkg/analyzer/analyzers/mailchimp/mailchimp.go | 191 +++ pkg/analyzer/analyzers/mailgun/mailgun.go | 93 ++ pkg/analyzer/analyzers/mysql/mysql.go | 775 +++++++++ pkg/analyzer/analyzers/mysql/scopes.go | 99 ++ pkg/analyzer/analyzers/openai/openai.go | 204 +++ pkg/analyzer/analyzers/openai/scopes.go | 72 + pkg/analyzer/analyzers/opsgenie/opsgenie.go | 205 +++ pkg/analyzer/analyzers/opsgenie/scopes.json | 38 + pkg/analyzer/analyzers/postgres/postgres.go | 463 ++++++ pkg/analyzer/analyzers/postman/postman.go | 152 ++ pkg/analyzer/analyzers/postman/scopes.go | 13 + pkg/analyzer/analyzers/sendgrid/scopes.go | 105 ++ pkg/analyzer/analyzers/sendgrid/sendgrid.go | 133 ++ pkg/analyzer/analyzers/shopify/scopes.json | 470 ++++++ pkg/analyzer/analyzers/shopify/shopify.go | 213 +++ pkg/analyzer/analyzers/slack/scopes.go | 90 ++ pkg/analyzer/analyzers/slack/slack.go | 141 ++ .../analyzers/sourcegraph/sourcegraph.go | 139 ++ pkg/analyzer/analyzers/square/scopes.go | 408 +++++ pkg/analyzer/analyzers/square/square.go | 191 +++ pkg/analyzer/analyzers/stripe/restricted.yaml | 1416 +++++++++++++++++ pkg/analyzer/analyzers/stripe/stripe.go | 300 ++++ pkg/analyzer/analyzers/twilio/twilio.go | 160 ++ pkg/analyzer/cli.go | 250 +++ pkg/analyzer/config/config.go | 8 + pkg/analyzer/pb/analyzerpb/analyzer.pb.go | 203 +++ .../pb/analyzerpb/analyzer.pb.validate.go | 36 + pkg/analyzer/proto/analyzer.proto | 29 + pkg/detectors/detectors.go | 5 + pkg/detectors/openai/openai.go | 1 + scripts/gen_proto.sh | 57 +- 47 files changed, 10107 insertions(+), 57 deletions(-) create mode 100644 pkg/analyzer/analyzers/airbrake/airbrake.go create mode 100644 pkg/analyzer/analyzers/airbrake/scopes.go create mode 100644 pkg/analyzer/analyzers/analyzers.go create mode 100644 pkg/analyzer/analyzers/asana/asana.go create mode 100644 pkg/analyzer/analyzers/bitbucket/bitbucket.go create mode 100644 pkg/analyzer/analyzers/bitbucket/scopes.go create mode 100644 pkg/analyzer/analyzers/github/classictoken.go create mode 100644 pkg/analyzer/analyzers/github/finegrained.go create mode 100644 pkg/analyzer/analyzers/github/github.go create mode 100644 pkg/analyzer/analyzers/gitlab/gitlab.go create mode 100644 pkg/analyzer/analyzers/gitlab/scopes.go create mode 100644 pkg/analyzer/analyzers/huggingface/huggingface.go create mode 100644 pkg/analyzer/analyzers/huggingface/scopes.go create mode 100644 pkg/analyzer/analyzers/mailchimp/mailchimp.go create mode 100644 pkg/analyzer/analyzers/mailgun/mailgun.go create mode 100644 pkg/analyzer/analyzers/mysql/mysql.go create mode 100644 pkg/analyzer/analyzers/mysql/scopes.go create mode 100644 pkg/analyzer/analyzers/openai/openai.go create mode 100644 pkg/analyzer/analyzers/openai/scopes.go create mode 100644 pkg/analyzer/analyzers/opsgenie/opsgenie.go create mode 100644 pkg/analyzer/analyzers/opsgenie/scopes.json create mode 100644 pkg/analyzer/analyzers/postgres/postgres.go create mode 100644 pkg/analyzer/analyzers/postman/postman.go create mode 100644 pkg/analyzer/analyzers/postman/scopes.go create mode 100644 pkg/analyzer/analyzers/sendgrid/scopes.go create mode 100644 pkg/analyzer/analyzers/sendgrid/sendgrid.go create mode 100644 pkg/analyzer/analyzers/shopify/scopes.json create mode 100644 pkg/analyzer/analyzers/shopify/shopify.go create mode 100644 pkg/analyzer/analyzers/slack/scopes.go create mode 100644 pkg/analyzer/analyzers/slack/slack.go create mode 100644 pkg/analyzer/analyzers/sourcegraph/sourcegraph.go create mode 100644 pkg/analyzer/analyzers/square/scopes.go create mode 100644 pkg/analyzer/analyzers/square/square.go create mode 100644 pkg/analyzer/analyzers/stripe/restricted.yaml create mode 100644 pkg/analyzer/analyzers/stripe/stripe.go create mode 100644 pkg/analyzer/analyzers/twilio/twilio.go create mode 100644 pkg/analyzer/cli.go create mode 100644 pkg/analyzer/config/config.go create mode 100644 pkg/analyzer/pb/analyzerpb/analyzer.pb.go create mode 100644 pkg/analyzer/pb/analyzerpb/analyzer.pb.validate.go create mode 100644 pkg/analyzer/proto/analyzer.proto diff --git a/go.mod b/go.mod index 1e161a2d8..3b2a5897c 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/coinbase/waas-client-library-go v1.0.8 github.com/couchbase/gocb/v2 v2.9.1 github.com/crewjam/rfc5424 v0.1.0 + github.com/dustin/go-humanize v1.0.1 github.com/elastic/go-elasticsearch/v8 v8.14.0 github.com/envoyproxy/protoc-gen-validate v1.0.4 github.com/fatih/color v1.17.0 @@ -53,11 +54,14 @@ require ( github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/go-cmp v0.6.0 github.com/google/go-containerregistry v0.20.1 + github.com/google/go-github/v59 v59.0.0 github.com/google/go-github/v62 v62.0.0 github.com/google/uuid v1.6.0 github.com/googleapis/gax-go/v2 v2.13.0 github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/jedib0t/go-pretty v4.3.0+incompatible + github.com/jedib0t/go-pretty/v6 v6.5.9 github.com/jlaffaye/ftp v0.2.0 github.com/joho/godotenv v1.5.1 github.com/jpillora/overseer v1.1.6 @@ -76,6 +80,7 @@ require ( github.com/prometheus/client_golang v1.19.1 github.com/rabbitmq/amqp091-go v1.10.0 github.com/sassoftware/go-rpmutils v0.4.0 + github.com/sendgrid/sendgrid-go v3.14.0+incompatible github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 github.com/shuheiktgw/go-travis v0.3.1 github.com/snowflakedb/gosnowflake v1.10.1 @@ -90,6 +95,7 @@ require ( github.com/trufflesecurity/disk-buffer-reader v0.2.1 github.com/wasilibs/go-re2 v1.6.0 github.com/xanzy/go-gitlab v0.107.0 + github.com/xo/dburl v0.23.2 go.mongodb.org/mongo-driver v1.16.0 go.uber.org/mock v0.4.0 go.uber.org/zap v1.27.0 @@ -102,6 +108,7 @@ require ( google.golang.org/api v0.189.0 google.golang.org/protobuf v1.34.2 gopkg.in/h2non/gock.v1 v1.1.2 + gopkg.in/yaml.v2 v2.4.0 pault.ag/go/debian v0.16.0 pgregory.net/rapid v1.1.0 sigs.k8s.io/yaml v1.4.0 @@ -137,6 +144,7 @@ require ( github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/apache/arrow/go/v14 v14.0.2 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/smithy-go v1.20.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -186,6 +194,8 @@ require ( github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-openapi/errors v0.22.0 // indirect + github.com/go-openapi/strfmt v0.23.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -224,6 +234,7 @@ require ( github.com/mattn/go-runewidth v0.0.15 // indirect github.com/microcosm-cc/bluemonday v1.0.25 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.5.0 // indirect @@ -236,6 +247,7 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/nwaples/rardecode/v2 v2.0.0-beta.2 // indirect + github.com/oklog/ulid v1.3.1 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/onsi/ginkgo v1.16.5 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -250,6 +262,7 @@ require ( github.com/prometheus/procfs v0.12.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect + github.com/sendgrid/rest v2.6.9+incompatible // indirect github.com/shirou/gopsutil/v3 v3.23.12 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect diff --git a/go.sum b/go.sum index 28623ee17..f0435a17e 100644 --- a/go.sum +++ b/go.sum @@ -119,12 +119,10 @@ github.com/apache/arrow/go/v14 v14.0.2 h1:N8OkaJEOfI3mEZt07BIkvo4sC6XDbL+48MBPWO github.com/apache/arrow/go/v14 v14.0.2/go.mod h1:u3fgh3EdgN/YQ8cVQRguVW3R+seMybFg8QBQ5LU+eBY= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go v1.54.20 h1:FZ2UcXya7bUkvkpf7TaPmiL7EubK0go1nlXGLRwEsoo= -github.com/aws/aws-sdk-go v1.54.20/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go v1.55.1 h1:ZTNPmbRMxaK5RlTJrBullX9r/rF1MPf3yAJOLlwDiT8= -github.com/aws/aws-sdk-go v1.55.1/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go v1.55.2 h1:/2OFM8uFfK9e+cqHTw9YPrvTzIXT2XkFGXRM7WbJb7E= github.com/aws/aws-sdk-go v1.55.2/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/smithy-go v1.20.1 h1:4SZlSlMr36UEqC7XOyRVb27XMeZubNcBNN+9IgEPIQw= @@ -243,6 +241,8 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dvsekhvalnov/jose2go v1.6.0 h1:Y9gnSnP4qEI0+/uQkHvFXeD2PLPJeXEL+ySMEA2EjTY= github.com/dvsekhvalnov/jose2go v1.6.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/elastic/elastic-transport-go/v8 v8.6.0 h1:Y2S/FBjx1LlCv5m6pWAF2kDJAHoSjSRSJCApolgfthA= @@ -308,6 +308,10 @@ github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= +github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= @@ -384,6 +388,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-containerregistry v0.20.1 h1:eTgx9QNYugV4DN5mz4U8hiAGTi1ybXn0TPi4Smd8du0= github.com/google/go-containerregistry v0.20.1/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI= +github.com/google/go-github/v59 v59.0.0 h1:7h6bgpF5as0YQLLkEiVqpgtJqjimMYhBkD4jT5aN3VA= +github.com/google/go-github/v59 v59.0.0/go.mod h1:rJU4R0rQHFVFDOkqGWxfLNo6vEk4dv40oDjhV/gH6wM= github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= @@ -468,6 +474,10 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= +github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag= +github.com/jedib0t/go-pretty/v6 v6.5.9 h1:ACteMBRrrmm1gMsXe9PSTOClQ63IXDUt03H5U+UV8OU= +github.com/jedib0t/go-pretty/v6 v6.5.9/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E= github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg= github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -542,6 +552,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= @@ -575,6 +587,8 @@ github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -637,6 +651,10 @@ github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wr github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sassoftware/go-rpmutils v0.4.0 h1:ojND82NYBxgwrV+mX1CWsd5QJvvEZTKddtCdFLPWhpg= github.com/sassoftware/go-rpmutils v0.4.0/go.mod h1:3goNWi7PGAT3/dlql2lv3+MSN5jNYPjT5mVcQcIsYzI= +github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= +github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= +github.com/sendgrid/sendgrid-go v3.14.0+incompatible h1:KDSasSTktAqMJCYClHVE94Fcif2i7P7wzISv1sU6DUA= +github.com/sendgrid/sendgrid-go v3.14.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= @@ -734,6 +752,8 @@ github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8 github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= +github.com/xo/dburl v0.23.2 h1:Fl88cvayrgE56JA/sqhNMLljCW/b7RmG1mMkKMZUFgA= +github.com/xo/dburl v0.23.2/go.mod h1:uazlaAQxj4gkshhfuuYyvwCBouOmNnG2aDxTCFZpmL4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= diff --git a/main.go b/main.go index c4911c391..d5b2c30ec 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( "github.com/jpillora/overseer" "github.com/mattn/go-isatty" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer" "github.com/trufflesecurity/trufflehog/v3/pkg/cleantemp" "github.com/trufflesecurity/trufflehog/v3/pkg/common" "github.com/trufflesecurity/trufflehog/v3/pkg/config" @@ -35,8 +36,57 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/version" ) +// usageTemplate is a copy of kingpin.DefaultUsageTemplate with a minor change +// to not list all flattened commands. This is required to hide all of the +// analyze sub-commands from the main help. +const usageTemplate = `{{define "FormatCommand" -}} +{{if .FlagSummary}} {{.FlagSummary}}{{end -}} +{{range .Args}}{{if not .Hidden}} {{if not .Required}}[{{end}}{{if .PlaceHolder}}{{.PlaceHolder}}{{else}}<{{.Name}}>{{end}}{{if .Value|IsCumulative}}...{{end}}{{if not .Required}}]{{end}}{{end}}{{end -}} +{{end -}} + +{{define "FormatCommands" -}} +{{range .Commands -}} +{{if not .Hidden -}} + {{.FullCommand}}{{if .Default}}*{{end}}{{template "FormatCommand" .}} +{{.Help|Wrap 4}} +{{end -}} +{{end -}} +{{end -}} + +{{define "FormatUsage" -}} +{{template "FormatCommand" .}}{{if .Commands}} [ ...]{{end}} +{{if .Help}} +{{.Help|Wrap 0 -}} +{{end -}} + +{{end -}} + +{{if .Context.SelectedCommand -}} +usage: {{.App.Name}} {{.Context.SelectedCommand}}{{template "FormatUsage" .Context.SelectedCommand}} +{{ else -}} +usage: {{.App.Name}}{{template "FormatUsage" .App}} +{{end}} +{{if .Context.Flags -}} +Flags: +{{.Context.Flags|FlagsToTwoColumns|FormatTwoColumns}} +{{end -}} +{{if .Context.Args -}} +Args: +{{.Context.Args|ArgsToTwoColumns|FormatTwoColumns}} +{{end -}} +{{if .Context.SelectedCommand -}} +{{if len .Context.SelectedCommand.Commands -}} +Subcommands: +{{template "FormatCommands" .Context.SelectedCommand}} +{{end -}} +{{else if .App.Commands -}} +Commands: +{{template "FormatCommands" .App}} +{{end -}} +` + var ( - cli = kingpin.New("TruffleHog", "TruffleHog is a tool for finding credentials.") + cli = kingpin.New("TruffleHog", "TruffleHog is a tool for finding credentials.").UsageTemplate(usageTemplate) cmd string debug = cli.Flag("debug", "Run in debug mode.").Bool() trace = cli.Flag("trace", "Run in trace mode.").Bool() @@ -219,7 +269,8 @@ var ( huggingfaceIncludeDiscussions = huggingfaceScan.Flag("include-discussions", "Include discussions in scan.").Bool() huggingfaceIncludePrs = huggingfaceScan.Flag("include-prs", "Include pull requests in scan.").Bool() - usingTUI = false + analyzeCmd = analyzer.Command(cli) + usingTUI = false ) func init() { @@ -423,24 +474,30 @@ func run(state overseer.State) { return } - metrics, err := runSingleScan(ctx, cmd, engConf) - if err != nil { - logFatal(err, "error running scan") - } + topLevelSubCommand, _, _ := strings.Cut(cmd, " ") + switch topLevelSubCommand { + case analyzeCmd.FullCommand(): + analyzer.Run(cmd) + default: + metrics, err := runSingleScan(ctx, cmd, engConf) + if err != nil { + logFatal(err, "error running scan") + } - // Print results. - logger.Info("finished scanning", - "chunks", metrics.ChunksScanned, - "bytes", metrics.BytesScanned, - "verified_secrets", metrics.VerifiedSecretsFound, - "unverified_secrets", metrics.UnverifiedSecretsFound, - "scan_duration", metrics.ScanDuration.String(), - "trufflehog_version", version.BuildVersion, - ) + // Print results. + logger.Info("finished scanning", + "chunks", metrics.ChunksScanned, + "bytes", metrics.BytesScanned, + "verified_secrets", metrics.VerifiedSecretsFound, + "unverified_secrets", metrics.UnverifiedSecretsFound, + "scan_duration", metrics.ScanDuration.String(), + "trufflehog_version", version.BuildVersion, + ) - if metrics.hasFoundResults && *fail { - logger.V(2).Info("exiting with code 183 because results were found") - os.Exit(183) + if metrics.hasFoundResults && *fail { + logger.V(2).Info("exiting with code 183 because results were found") + os.Exit(183) + } } } diff --git a/pkg/analyzer/analyzers/airbrake/airbrake.go b/pkg/analyzer/analyzers/airbrake/airbrake.go new file mode 100644 index 000000000..563e4e2fa --- /dev/null +++ b/pkg/analyzer/analyzers/airbrake/airbrake.go @@ -0,0 +1,127 @@ +package airbrake + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +type ProjectsJSON struct { + Projects []struct { + Name string `json:"name"` + ID int `json:"id"` + } `json:"projects"` +} + +// validateKey checks if the key is valid and returns the projects associated with the key +func validateKey(cfg *config.Config, key string) (bool, ProjectsJSON, error) { + // create struct to hold response + var projects ProjectsJSON + + // create http client + client := analyzers.NewAnalyzeClient(cfg) + + // create request + req, err := http.NewRequest("GET", "https://api.airbrake.io/api/v4/projects", nil) + if err != nil { + return false, projects, err + } + + // add key as url param + q := req.URL.Query() + q.Add("key", key) + req.URL.RawQuery = q.Encode() + + // send request + resp, err := client.Do(req) + if err != nil { + return false, projects, err + } + + // read response + defer resp.Body.Close() + + // if status code is 200, decode response + if resp.StatusCode == 200 { + err := json.NewDecoder(resp.Body).Decode(&projects) + return true, projects, err + } + + // if status code is not 200, return false + return false, projects, nil +} + +func AnalyzePermissions(cfg *config.Config, key string) { + // validate key + valid, projects, err := validateKey(cfg, key) + if err != nil { + color.Red("[x]" + err.Error()) + return + } + + if !valid { + color.Red("[x] Invalid Airbrake User API Key") + return + } + + color.Green("[!] Valid Airbrake User API Key\n\n") + + if len(key) == 40 { + color.Green("[i] Key Type: User Key") + color.Green("[i] Expiration: Never") + } else { + color.Yellow("[i] Key Type: User Token") + color.Yellow("[i] Duration: Short-Lived") + // ToDo: determine how long these are valid for + } + + // if key is valid, print projects + if valid { + color.Green("\n[i] Projects:") + printProjects(projects) + } + + color.Green("\n[i] Permissions:") + printPermissions() +} + +func printProjects(projects ProjectsJSON) { + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Project ID", "Project Name"}) + for _, project := range projects.Projects { + t.AppendRow([]interface{}{color.GreenString(strconv.Itoa(project.ID)), color.GreenString(project.Name)}) + } + t.Render() +} + +func printPermissions() { + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Scope", "Permissions"}) + for s := range scope_order { + scope := scope_order[s][0] + permissions := scope_mapping[scope] + if scope == "Authentication" { + t.AppendRow([]interface{}{scope, permissions[0]}) + continue + } + for i, permission := range permissions { + if i == 0 { + t.AppendRow([]interface{}{color.GreenString(scope), color.GreenString(permission)}) + } else { + t.AppendRow([]interface{}{"", color.GreenString(permission)}) + } + } + } + t.Render() + fmt.Println("| Ref: https://docs.airbrake.io/docs/devops-tools/api/ |") + fmt.Println("+------------------------+---------------------------------+") +} diff --git a/pkg/analyzer/analyzers/airbrake/scopes.go b/pkg/analyzer/analyzers/airbrake/scopes.go new file mode 100644 index 000000000..3d28af067 --- /dev/null +++ b/pkg/analyzer/analyzers/airbrake/scopes.go @@ -0,0 +1,27 @@ +package airbrake + +var scope_order = [][]string{ + {"Authentication"}, + {"Performance Monitoring"}, + {"Error Notification"}, + {"Projects"}, + {"Deploys"}, + {"Groups"}, + {"Notices"}, + {"Project Activities"}, + {"Source Maps"}, + {"iOS Crash Reports"}, +} + +var scope_mapping = map[string][]string{ + "Authentication": {"Create user token"}, + "Performance Monitoring": {"Route performance endpoint", "Routes breakdown endpoint", "Database query stats", "Queue stats"}, + "Error Notification": {"Create notice"}, + "Projects": {"List projects", "Show projects"}, + "Deploys": {"Create deploy", "List deploys", "Show deploy"}, + "Groups": {"List groups", "Show group", "Mute group", "Unmute group", "Delete group", "List groups across all projects", "Show group statistics"}, + "Notices": {"List notices", "Show notice status"}, + "Project Activities": {"List project activities", "Show project statistics"}, + "Source Maps": {"Create source map", "List source maps", "Show source map", "Delete source map"}, + "iOS Crash Reports": {"Create iOS crash report"}, +} diff --git a/pkg/analyzer/analyzers/analyzers.go b/pkg/analyzer/analyzers/analyzers.go new file mode 100644 index 000000000..23ff0a34c --- /dev/null +++ b/pkg/analyzer/analyzers/analyzers.go @@ -0,0 +1,217 @@ +package analyzers + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/fatih/color" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/pb/analyzerpb" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" +) + +type ( + Analyzer interface { + Type() analyzerpb.AnalyzerType + Analyze(ctx context.Context, credentialInfo map[string]string) (*AnalyzerResult, error) + } + + // AnalyzerResult is the output of analysis. + AnalyzerResult struct { + AnalyzerType analyzerpb.AnalyzerType + Bindings []Binding + UnboundedResources []Resource + Metadata map[string]any + } + + Resource struct { + Name string + FullyQualifiedName string + Type string + Metadata map[string]any + Parent *Resource + } + + Permission struct { + Value string + AccessLevel string + Parent *Permission + } + + Binding struct { + Resource Resource + Permission Permission + } +) + +type PermissionType string + +const ( + READ PermissionType = "Read" + WRITE PermissionType = "Write" + READ_WRITE PermissionType = "Read & Write" + NONE PermissionType = "None" + ERROR PermissionType = "Error" +) + +type PermissionStatus struct { + Value bool + IsError bool +} + +type HttpStatusTest struct { + URL string + Method string + Payload map[string]interface{} + Params map[string]string + Valid []int + Invalid []int + Type PermissionType + Status PermissionStatus + Risk string +} + +func (h *HttpStatusTest) RunTest(headers map[string]string) error { + // If body data, marshal to JSON + var data io.Reader + if h.Payload != nil { + jsonData, err := json.Marshal(h.Payload) + if err != nil { + return err + } + data = bytes.NewBuffer(jsonData) + } + + // Create new HTTP request + client := &http.Client{} + req, err := http.NewRequest(h.Method, h.URL, data) + if err != nil { + return err + } + + // Add custom headers if provided + for key, value := range headers { + req.Header.Set(key, value) + } + + // Execute HTTP Request + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // Check response status code + switch { + case StatusContains(resp.StatusCode, h.Valid): + h.Status.Value = true + case StatusContains(resp.StatusCode, h.Invalid): + h.Status.Value = false + default: + h.Status.IsError = true + } + return nil +} + +type Scope struct { + Name string + Tests []interface{} +} + +func StatusContains(status int, vals []int) bool { + for _, v := range vals { + if status == v { + return true + } + } + return false +} + +func GetWriterFromStatus(status PermissionType) func(a ...interface{}) string { + switch status { + case READ: + return color.New(color.FgYellow).SprintFunc() + case WRITE: + return color.New(color.FgGreen).SprintFunc() + case READ_WRITE: + return color.New(color.FgGreen).SprintFunc() + case NONE: + return color.New().SprintFunc() + case ERROR: + return color.New(color.FgRed).SprintFunc() + default: + return color.New().SprintFunc() + } +} + +var GreenWriter = color.New(color.FgGreen).SprintFunc() +var YellowWriter = color.New(color.FgYellow).SprintFunc() +var RedWriter = color.New(color.FgRed).SprintFunc() +var DefaultWriter = color.New().SprintFunc() + +type AnalyzeClient struct { + http.Client + LoggingEnabled bool + LogFile string +} + +func CreateLogFileName(baseName string) string { + // Get the current time + currentTime := time.Now() + + // Format the time as "2024_06_30_07_15_30" + timeString := currentTime.Format("2006_01_02_15_04_05") + + // Create the log file name + logFileName := fmt.Sprintf("%s_%s.log", timeString, baseName) + return logFileName +} + +func NewAnalyzeClient(cfg *config.Config) *http.Client { + if cfg == nil || !cfg.LoggingEnabled { + return &http.Client{} + } + return &http.Client{ + Transport: LoggingRoundTripper{ + parent: http.DefaultTransport, + logFile: cfg.LogFile, + }, + } +} + +type LoggingRoundTripper struct { + parent http.RoundTripper + // TODO: io.Writer + logFile string +} + +func (r LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + startTime := time.Now() + + resp, err := r.parent.RoundTrip(req) + if err != nil { + return resp, err + } + + // TODO: JSON + logEntry := fmt.Sprintf("Date: %s, Method: %s, Path: %s, Status: %d\n", startTime.Format(time.RFC3339), req.Method, req.URL.Path, resp.StatusCode) + + // Open log file in append mode. + file, err := os.OpenFile(r.logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return resp, fmt.Errorf("failed to open log file: %w", err) + } + defer file.Close() + + // Write log entry to file. + if _, err := file.WriteString(logEntry); err != nil { + return resp, fmt.Errorf("failed to write log entry to file: %w", err) + } + + return resp, nil +} diff --git a/pkg/analyzer/analyzers/asana/asana.go b/pkg/analyzer/analyzers/asana/asana.go new file mode 100644 index 000000000..77a73655c --- /dev/null +++ b/pkg/analyzer/analyzers/asana/asana.go @@ -0,0 +1,85 @@ +package asana + +// ToDo: Add OAuth token support. + +import ( + "encoding/json" + "net/http" + "os" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +type MeJSON struct { + Data struct { + Email string `json:"email"` + Name string `json:"name"` + Type string `json:"resource_type"` + Workspaces []struct { + Name string `json:"name"` + } `json:"workspaces"` + } `json:"data"` +} + +func getMetadata(cfg *config.Config, key string) (MeJSON, error) { + var me MeJSON + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", "https://app.asana.com/api/1.0/users/me", nil) + if err != nil { + return me, err + } + + req.Header.Set("Authorization", "Bearer "+key) + resp, err := client.Do(req) + if err != nil { + return me, err + } + + if resp.StatusCode != 200 { + return me, nil + } + + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&me) + if err != nil { + return me, err + } + return me, nil +} + +func AnalyzePermissions(cfg *config.Config, key string) { + me, err := getMetadata(cfg, key) + if err != nil { + color.Red("[x] ", err.Error()) + return + } + printMetadata(me) +} + +func printMetadata(me MeJSON) { + if me.Data.Email == "" { + color.Red("[x] Invalid Asana API Key\n") + return + } + color.Green("[!] Valid Asana API Key\n\n") + color.Yellow("[i] User Information") + color.Yellow(" Name: %s", me.Data.Name) + color.Yellow(" Email: %s", me.Data.Email) + color.Yellow(" Type: %s\n\n", me.Data.Type) + + color.Green("[i] Permissions: Full Access\n\n") + + color.Yellow("[i] Accessible Workspaces") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Workspace Name"}) + for _, workspace := range me.Data.Workspaces { + t.AppendRow(table.Row{color.GreenString(workspace.Name)}) + } + t.Render() +} diff --git a/pkg/analyzer/analyzers/bitbucket/bitbucket.go b/pkg/analyzer/analyzers/bitbucket/bitbucket.go new file mode 100644 index 000000000..2ad667cdb --- /dev/null +++ b/pkg/analyzer/analyzers/bitbucket/bitbucket.go @@ -0,0 +1,196 @@ +package bitbucket + +import ( + "encoding/json" + "net/http" + "os" + "sort" + "strings" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +type Repo struct { + FullName string `json:"full_name"` + RepoName string `json:"name"` + Project struct { + Name string `json:"name"` + } `json:"project"` + Workspace struct { + Name string `json:"name"` + } `json:"workspace"` + IsPrivate bool `json:"is_private"` + Owner struct { + Username string `json:"username"` + } `json:"owner"` + Role string +} + +type RepoJSON struct { + Values []Repo `json:"values"` +} + +func getScopesAndType(cfg *config.Config, key string) (string, string, error) { + + // client + client := analyzers.NewAnalyzeClient(cfg) + + // request + req, err := http.NewRequest("GET", "https://api.bitbucket.org/2.0/repositories", nil) + if err != nil { + return "", "", err + } + + // headers + req.Header.Set("Authorization", "Bearer "+key) + + // response + resp, err := client.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + // parse response headers + credentialType := resp.Header.Get("x-credential-type") + oauthScopes := resp.Header.Get("x-oauth-scopes") + + return credentialType, oauthScopes, nil +} + +func getRepositories(cfg *config.Config, key string, role string) (RepoJSON, error) { + var repos RepoJSON + + // client + client := analyzers.NewAnalyzeClient(cfg) + + // request + req, err := http.NewRequest("GET", "https://api.bitbucket.org/2.0/repositories", nil) + if err != nil { + return repos, err + } + + // headers + req.Header.Set("Authorization", "Bearer "+key) + + // add query params + q := req.URL.Query() + q.Add("role", role) + q.Add("pagelen", "100") + req.URL.RawQuery = q.Encode() + + // response + resp, err := client.Do(req) + if err != nil { + return repos, err + } + defer resp.Body.Close() + + // parse response body + err = json.NewDecoder(resp.Body).Decode(&repos) + if err != nil { + return repos, err + } + + return repos, nil +} + +func getAllRepos(cfg *config.Config, key string) (map[string]Repo, error) { + roles := []string{"member", "contributor", "admin", "owner"} + + var allRepos = make(map[string]Repo, 0) + for _, role := range roles { + repos, err := getRepositories(cfg, key, role) + if err != nil { + return allRepos, err + } + // purposefully overwriting, so that get the most permissive role + for _, repo := range repos.Values { + repo.Role = role + allRepos[repo.FullName] = repo + } + } + return allRepos, nil +} + +func AnalyzePermissions(cfg *config.Config, key string) { + + credentialType, oauthScopes, err := getScopesAndType(cfg, key) + if err != nil { + color.Red("Error: %s", err) + return + } + printScopes(credentialType, oauthScopes) + + // get all repos available to user + // ToDo: pagination + repos, err := getAllRepos(cfg, key) + if err != nil { + color.Red("Error: %s", err) + return + } + + printAccessibleRepositories(repos) + +} + +func printScopes(credentialType string, oauthScopes string) { + if credentialType == "" { + color.Red("[x] Invalid Bitbucket access token.") + return + } + color.Green("[!] Valid Bitbucket access token.\n\n") + color.Green("[i] Credential Type: %s\n\n", credential_type_map[credentialType]) + + scopes := strings.Split(oauthScopes, ", ") + scopesSlice := []BitbucketScope{} + for _, scope := range scopes { + mapping := oauth_scope_map[scope] + for _, impliedScope := range mapping.ImpliedScopes { + scopesSlice = append(scopesSlice, oauth_scope_map[impliedScope]) + } + scopesSlice = append(scopesSlice, oauth_scope_map[scope]) + } + + // sort scopes by category + sort.Sort(ByCategoryAndName(scopesSlice)) + + color.Yellow("[i] Access Token Scopes:") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Category", "Permission"}) + + currentCategory := "" + for _, scope := range scopesSlice { + if currentCategory != scope.Category { + currentCategory = scope.Category + t.AppendRow([]interface{}{scope.Category, ""}) + } + t.AppendRow([]interface{}{"", color.GreenString(scope.Name)}) + } + + t.Render() + +} + +func printAccessibleRepositories(repos map[string]Repo) { + color.Yellow("\n[i] Accessible Repositories:") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Repository", "Project", "Workspace", "Owner", "Is Private", "This User's Role"}) + + for _, repo := range repos { + private := "" + if repo.IsPrivate { + private = color.GreenString("Yes") + } else { + private = color.RedString("No") + } + t.AppendRow([]interface{}{color.GreenString(repo.RepoName), color.GreenString(repo.Project.Name), color.GreenString(repo.Workspace.Name), color.GreenString(repo.Owner.Username), private, color.GreenString(repo.Role)}) + } + + t.Render() +} diff --git a/pkg/analyzer/analyzers/bitbucket/scopes.go b/pkg/analyzer/analyzers/bitbucket/scopes.go new file mode 100644 index 000000000..2214a0ccf --- /dev/null +++ b/pkg/analyzer/analyzers/bitbucket/scopes.go @@ -0,0 +1,112 @@ +package bitbucket + +var credential_type_map = map[string]string{ + "repo_access_token": "Repository Access Token (Can access 1 repository)", + "project_access_token": "Project Access Token (Can access all repos in 1 project)", + "workspace_access_token": "Workspace Access Token (Can access all projects and repos in 1 workspace)", +} + +type BitbucketScope struct { + Name string `json:"name"` + Category string `json:"category"` + ImpliedScopes []string `json:"implied_scopes"` +} + +type ByCategoryAndName []BitbucketScope + +func (a ByCategoryAndName) Len() int { return len(a) } +func (a ByCategoryAndName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a ByCategoryAndName) Less(i, j int) bool { + categoryOrder := map[string]int{ + "Account": 0, + "Projects": 1, + "Repositories": 2, + "Pull Requests": 3, + "Webhooks": 4, + "Pipelines": 5, + "Runners": 6, + } + nameOrder := map[string]int{ + "Read": 0, + "Write": 1, + "Admin": 2, + "Delete": 3, + "Edit variables": 4, + "Read and write": 5, + } + + if categoryOrder[a[i].Category] != categoryOrder[a[j].Category] { + return categoryOrder[a[i].Category] < categoryOrder[a[j].Category] + } + return nameOrder[a[i].Name] < nameOrder[a[j].Name] +} + +var oauth_scope_map = map[string]BitbucketScope{ + "repository": { + Name: "Read", + Category: "Repositories", + }, + "repository:write": { + Name: "Write", + Category: "Repositories", + ImpliedScopes: []string{"repository"}, + }, + "repository:admin": { + Name: "Admin", + Category: "Repositories", + }, + "repository:delete": { + Name: "Delete", + Category: "Repositories", + }, + "pullrequest": { + Name: "Read", + Category: "Pull Requests", + ImpliedScopes: []string{"repository"}, + }, + "pullrequest:write": { + Name: "Write", + Category: "Pull Requests", + ImpliedScopes: []string{"pullrequest", "repository", "repository:write"}, + }, + "webhook": { + Name: "Read and write", + Category: "Webhooks", + }, + "pipeline": { + Name: "Read", + Category: "Pipelines", + }, + "pipeline:write": { + Name: "Write", + Category: "Pipelines", + ImpliedScopes: []string{"pipeline"}, + }, + "pipeline:variable": { + Name: "Edit variables", + Category: "Pipelines", + ImpliedScopes: []string{"pipeline", "pipeline:write"}, + }, + "runner": { + Name: "Read", + Category: "Runners", + }, + "runner:write": { + Name: "Write", + Category: "Runners", + ImpliedScopes: []string{"runner"}, + }, + "project": { + Name: "Read", + Category: "Projects", + ImpliedScopes: []string{"repository"}, + }, + "project:admin": { + Name: "Admin", + Category: "Projects", + }, + "account": { + Name: "Read", + Category: "Account", + }, +} diff --git a/pkg/analyzer/analyzers/github/classictoken.go b/pkg/analyzer/analyzers/github/classictoken.go new file mode 100644 index 000000000..87dae0e6a --- /dev/null +++ b/pkg/analyzer/analyzers/github/classictoken.go @@ -0,0 +1,202 @@ +package github + +import ( + "context" + "fmt" + "os" + "slices" + "strings" + + "github.com/fatih/color" + gh "github.com/google/go-github/v59/github" + "github.com/jedib0t/go-pretty/v6/table" +) + +// var SCOPE_ORDER = []string{"repo", "repo:status", "repo_deployment", "public_repo", "repo:invite", "security_events", "--", "workflow", "--", "write:packages", "read:packages", "--", "delete:packages", "--", "admin:org", "write:org", "read:org", "manage_runners:org", "--", "admin:public_key", "write:public_key", "read:public_key", "--", "admin:repo_hook", "write:repo_hook", "read:repo_hook", "--", "admin:org_hook", "--", "gist", "--", "notifications", "--", "user", "read:user", "user:email", "user:follow", "--", "delete_repo", "--", "write:discussion", "read:discussion", "--", "admin:enterprise", "manage_runners:enterprise", "manage_billing:enterprise", "read:enterprise", "--", "audit_log", "read:audit_log", "--", "codespace", "codespace:secrets", "--", "copilot", "manage_billing:copilot", "--", "project", "read:project", "--", "admin:gpg_key", "write:gpg_key", "read:gpg_key", "--", "admin:ssh_signing_key", "write:ssh_signing_key", "read:ssh_signing_key"} + +var SCOPE_ORDER = [][]string{{"repo", "repo:status", "repo_deployment", "public_repo", "repo:invite", "security_events"}, {"workflow"}, {"write:packages", "read:packages"}, {"delete:packages"}, {"admin:org", "write:org", "read:org", "manage_runners:org"}, {"admin:public_key", "write:public_key", "read:public_key"}, {"admin:repo_hook", "write:repo_hook", "read:repo_hook"}, {"admin:org_hook"}, {"gist"}, {"notifications"}, {"user", "read:user", "user:email", "user:follow"}, {"delete_repo"}, {"write:discussion", "read:discussion"}, {"admin:enterprise", "manage_runners:enterprise", "manage_billing:enterprise", "read:enterprise"}, {"audit_log", "read:audit_log"}, {"codespace", "codespace:secrets"}, {"copilot", "manage_billing:copilot"}, {"project", "read:project"}, {"admin:gpg_key", "write:gpg_key", "read:gpg_key"}, {"admin:ssh_signing_key", "write:ssh_signing_key", "read:ssh_signing_key"}} + +var SCOPE_TO_SUB_SCOPE = map[string][]string{ + "repo": {"repo:status", "repo_deployment", "public_repo", "repo:invite", "security_events"}, + "write:pakages": {"read:packages"}, + "admin:org": {"write:org", "read:org", "manage_runners:org"}, + "write:org": {"read:org"}, + "admin:public_key": {"write:public_key", "read:public_key"}, + "write:public_key": {"read:public_key"}, + "admin:repo_hook": {"write:repo_hook", "read:repo_hook"}, + "write:repo_hook": {"read:repo_hook"}, + "user": {"read:user", "user:email", "user:follow"}, + "write:discussion": {"read:discussion"}, + "admin:enterprise": {"manage_runners:enterprise", "manage_billing:enterprise", "read:enterprise"}, + "manage_billing:enterprise": {"read:enterprise"}, + "audit_log": {"read:audit_log"}, + "codespace": {"codespace:secrets"}, + "copilot": {"manage_billing:copilot"}, + "project": {"read:project"}, + "admin:gpg_key": {"write:gpg_key", "read:gpg_key"}, + "write:gpg_key": {"read:gpg_key"}, + "admin:ssh_signing_key": {"write:ssh_signing_key", "read:ssh_signing_key"}, + "write:ssh_signing_key": {"read:ssh_signing_key"}, +} + +func checkPrivateRepoAccess(scopes map[string]bool) []string { + var currPrivateScopes []string + privateScopes := []string{"repo", "repo:status", "repo_deployment", "repo:invite", "security_events", "admin:repo_hook", "write:repo_hook", "read:repo_hook"} + for _, scope := range privateScopes { + if scopes[scope] { + currPrivateScopes = append(currPrivateScopes, scope) + } + } + return currPrivateScopes +} + +func processScopes(headerScopesSlice []string) map[string]bool { + allScopes := make(map[string]bool) + for _, scope := range headerScopesSlice { + allScopes[scope] = true + } + for scope := range allScopes { + if subScopes, ok := SCOPE_TO_SUB_SCOPE[scope]; ok { + for _, subScope := range subScopes { + allScopes[subScope] = true + } + } + } + return allScopes +} + +// The `gists` scope is required to update private gists. Anyone can access a private gist with the link. +// These tokens can seem to list out the private repos, but access will depend on scopes. + +func analyzeClassicToken(client *gh.Client, _ string, show_all bool) { + + // Issue GET request to /user + user, resp, err := client.Users.Get(context.Background(), "") + if err != nil { + color.Red("[x] Invalid GitHub Token.") + return + } + + // If resp.Header "X-OAuth-Scopes", parse the scopes into a map[string]bool + headerScopes := resp.Header.Get("X-OAuth-Scopes") + + var scopes = make(map[string]bool) + if headerScopes == "" { + color.Red("[x] Classic Token has no scopes.") + } else { + // Split string into slice of strings + headerScopesSlice := strings.Split(headerScopes, ", ") + scopes = processScopes(headerScopesSlice) + } + + printClassicGHPermissions(scopes, show_all) + + // Check if private repo access + privateScopes := checkPrivateRepoAccess(scopes) + + if len(privateScopes) > 0 && slices.Contains(privateScopes, "repo") { + color.Green("[!] Token has scope(s) for both public and private repositories. Here's a list of all accessible repositories:") + repos, _ := getAllReposForUser(client) + printGitHubRepos(repos) + } else if len(privateScopes) > 0 { + color.Yellow("[!] Token has scope(s) useful for accessing both public and private repositories.\n However, without the `repo` scope, we cannot enumerate or access code from private repos.\n Review the permissions associated with the following scopes for more details: %v", strings.Join(privateScopes, ", ")) + } else if scopes["public_repo"] { + color.Yellow("[i] Token is scoped to only public repositories. See https://github.com/%v?tab=repositories", *user.Login) + } else { + color.Red("[x] Token does not appear scoped to any specific repositories.") + } + + // Get all private gists + gists, _ := getAllGistsForUser(client) + printGists(gists, show_all) + +} + +// Question: can you access private repo with those other permissions? or can we just not list them? + +func scopeFormatter(scope string, checked bool, indentation int) (string, string) { + if indentation != 0 { + scope = strings.Repeat(" ", indentation) + scope + } + if checked { + return color.GreenString(scope), color.GreenString("true") + } else { + return scope, "false" + } +} + +func printClassicGHPermissions(scopes map[string]bool, show_all bool) { + scopeCount := 0 + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Scope", "In-Scope" /* Add more column headers if needed */}) + + filteredScopes := make([][]string, 0) + for _, scopeSlice := range SCOPE_ORDER { + for _, scope := range scopeSlice { + if scopes[scope] { + filteredScopes = append(filteredScopes, scopeSlice) + break + } + } + } + + // For ease of reading, divide the scopes into sections, just like the GH UI + var formattedScope, status string + var indentation int + + if !show_all { + for _, scopeSlice := range filteredScopes { + for ind, scope := range scopeSlice { + if ind == 0 { + indentation = 0 + if scopes[scope] { + scopeCount++ + formattedScope, status = scopeFormatter(scope, true, indentation) + t.AppendRow([]interface{}{formattedScope, status}) + } else { + t.AppendRow([]interface{}{scope, "----"}) + } + } else { + indentation = 2 + if scopes[scope] { + scopeCount++ + formattedScope, status = scopeFormatter(scope, true, indentation) + t.AppendRow([]interface{}{formattedScope, status}) + } + } + } + t.AppendSeparator() + } + } else { + for _, scopeSlice := range SCOPE_ORDER { + for ind, scope := range scopeSlice { + if ind == 0 { + indentation = 0 + } else { + indentation = 2 + } + if scopes[scope] { + scopeCount++ + formattedScope, status = scopeFormatter(scope, true, indentation) + t.AppendRow([]interface{}{formattedScope, status}) + } else { + formattedScope, status = scopeFormatter(scope, false, indentation) + t.AppendRow([]interface{}{formattedScope, status}) + } + } + t.AppendSeparator() + } + } + + if scopeCount == 0 && !show_all { + color.Red("No Scopes Found for the GitHub Token above\n\n") + return + } else if scopeCount == 0 { + color.Red("Found No Scopes for the GitHub Token above\n") + } else { + color.Green(fmt.Sprintf("[!] Found %v Scope(s) for the GitHub Token above\n", scopeCount)) + } + t.Render() + fmt.Print("\n\n") +} diff --git a/pkg/analyzer/analyzers/github/finegrained.go b/pkg/analyzer/analyzers/github/finegrained.go new file mode 100644 index 000000000..4d21d20d9 --- /dev/null +++ b/pkg/analyzer/analyzers/github/finegrained.go @@ -0,0 +1,1411 @@ +package github + +import ( + "context" + "fmt" + "io" + "log" + "os" + "sort" + "strings" + + "github.com/fatih/color" + gh "github.com/google/go-github/v59/github" + "github.com/jedib0t/go-pretty/v6/table" +) + +const ( + // Random values for testing + RANDOM_STRING = "FQ2pR.4voZg-gJfsqYKx_eLDNF_6BYhw8RL__" + RANDOM_USERNAME = "d" + "ummy" + "acco" + "untgh" + "2024" + RANDOM_REPO = "te" + "st" + RANDOM_INTEGER = 4294967289 + + // Permissions + NO_ACCESS = "No access" + READ_ONLY = "Read-only" + READ_WRITE = "Read and write" + ERROR = "Error" + UNKNOWN = "Unknown" + NOT_IMPLEMENTED = "Not implemented" + + // Repo Permission Types + ACTIONS = "Actions" + ADMINISTRATION = "Administration" + CODE_SCANNING_ALERTS = "Code scanning alerts" + CODESPACES = "Codespaces" + CODESPACES_LIFECYCLE = "Codespaces lifecycle admin" + CODESPACES_METADATA = "Codespaces metadata" + CODESPACES_SECRETS = "Codespaces secrets" + COMMIT_STATUSES = "Commit statuses" + CONTENTS = "Contents" + CUSTOM_PROPERTIES = "Custom properties" + DEPENDABOT_ALERTS = "Dependabot alerts" + DEPENDABOT_SECRETS = "Dependabot secrets" + DEPLOYMENTS = "Deployments" + ENVIRONMENTS = "Environments" // Note: Addt'l permissions are not required (despite documentation). + ISSUES = "Issues" + MERGE_QUEUES = "Merge queues" + METADATA = "Metadata" + PAGES = "Pages" + PULL_REQUESTS = "Pull requests" + REPO_SECURITY = "Repository security advisories" + SECRET_SCANNING = "Secret scanning alerts" + SECRETS = "Secrets" + VARIABLES = "Variables" + WEBHOOKS = "Webhooks" + WORKFLOWS = "Workflows" + + // Account Permission Types + BLOCK_USER = "Block another user" + CODESPACE_USER_SECRETS = "Codespace user secrets" + EMAIL = "Email Addresses" + FOLLOWERS = "Followers" + GPG_KEYS = "GPG Keys" + GISTS = "Gists" + GIT_KEYS = "Git SSH keys" + LIMITS = "Interaction limits" + PLAN = "Plan" + PRIVATE_INVITES = "Private invitations" + PROFILE = "Profile" + SIGNING_KEYS = "SSH signing keys" + STARRING = "Starring" + WATCHING = "Watching" +) + +var repoPermFuncMap = map[string]func(client *gh.Client, repo *gh.Repository, acess string) (string, error){ + ACTIONS: getActionsPermission, + ADMINISTRATION: getAdministrationPermission, + CODE_SCANNING_ALERTS: getCodeScanningAlertsPermission, + CODESPACES: getCodespacesPermission, + CODESPACES_LIFECYCLE: notImplemented, // ToDo: Implement. Docs make this look org-wide...not repo-based? + CODESPACES_METADATA: getCodespacesMetadataPermission, + CODESPACES_SECRETS: getCodespacesSecretsPermission, + COMMIT_STATUSES: getCommitStatusesPermission, + CONTENTS: getContentsPermission, + CUSTOM_PROPERTIES: notImplemented, // ToDo: Only supports orgs. Implement once have an org token. + DEPENDABOT_ALERTS: getDependabotAlertsPermission, + DEPENDABOT_SECRETS: getDependabotSecretsPermission, + DEPLOYMENTS: getDeploymentsPermission, + ENVIRONMENTS: getEnvironmentsPermission, + ISSUES: getIssuesPermission, + MERGE_QUEUES: notImplemented, // Skipped until API better documented + METADATA: getMetadataPermission, + PAGES: getPagesPermission, + PULL_REQUESTS: getPullRequestsPermission, + REPO_SECURITY: getRepoSecurityPermission, + SECRET_SCANNING: getSecretScanningPermission, + SECRETS: getSecretsPermission, + VARIABLES: getVariablesPermission, + WEBHOOKS: getWebhooksPermission, + WORKFLOWS: notImplemented, // ToDo: Skipped b/c would require us to create a release (High Risk function) +} + +var acctPermFuncMap = map[string]func(client *gh.Client, user *gh.User) (string, error){ + BLOCK_USER: getBlockUserPermission, + CODESPACE_USER_SECRETS: getCodespacesUserPermission, + EMAIL: getEmailPermission, + FOLLOWERS: getFollowersPermission, + GPG_KEYS: getGPGKeysPermission, + GISTS: getGistsPermission, + GIT_KEYS: getGitKeysPermission, + LIMITS: getLimitsPermission, + PLAN: getPlanPermission, + //PRIVATE_INVITES: getPrivateInvitesPermission, // Skipped until API better documented + PROFILE: getProfilePermission, + SIGNING_KEYS: getSigningKeysPermission, + STARRING: getStarringPermission, + WATCHING: getWatchingPermission, +} + +// Define your custom formatter function +func permissionFormatter(key interface{}, val interface{}) (string, string) { + if strVal, ok := val.(string); ok { + switch strVal { + case NO_ACCESS: + red := color.New(color.FgRed).SprintFunc() + return red(key), red(NO_ACCESS) + case READ_ONLY: + yellow := color.New(color.FgYellow).SprintFunc() + return yellow(key), yellow(READ_ONLY) + case READ_WRITE: + green := color.New(color.FgGreen).SprintFunc() + return green(key), green(READ_WRITE) + case UNKNOWN: + blue := color.New(color.FgBlue).SprintFunc() + return blue(key), blue(UNKNOWN) + case NOT_IMPLEMENTED: + blue := color.New(color.FgBlue).SprintFunc() + return blue(key), blue(NOT_IMPLEMENTED) + default: + red := color.New(color.FgRed).SprintFunc() + return red(key), red(ERROR) + } + } + return fmt.Sprintf("%v", key), fmt.Sprintf("%v", val) +} + +func notImplemented(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + return NOT_IMPLEMENTED, nil +} + +func getMetadataPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // -> GET request to /repos/{owner}/{repo}/collaborators + _, resp, err := client.Repositories.ListCollaborators(context.Background(), *repo.Owner.Login, *repo.Name, nil) + if err != nil { + if resp.StatusCode == 403 { + return NO_ACCESS, nil + } + return ERROR, err + } + // If no error, then we have read access + return READ_ONLY, nil +} + +func getActionsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + if *repo.Private { + // Risk: Extremely Low + // -> GET request to /repos/{owner}/{repo}/actions/artifacts + _, resp, err := client.Actions.ListArtifacts(context.Background(), *repo.Owner.Login, *repo.Name, nil) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Very, very low. + // -> Unless the user has a workflow file named (see RANDOM_STRING above), this will always return 404 for users with READ_WRITE permissions. + // -> POST request to /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches + resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, gh.CreateWorkflowDispatchEventRequest{}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 404: + return READ_WRITE, nil + case 200: + log.Fatal("This shouldn't print. We are enabling a workflow based on a random string " + RANDOM_STRING + ", which most likely doesn't exist.") + return READ_WRITE, nil + default: + return ERROR, err + } + } else { + // Will only land here if already tested one public repo and got a 403. + if currentAccess == UNKNOWN { + return UNKNOWN, nil + } + // Risk: Very, very low. + // -> Unless the user has a workflow file named (see RANDOM_STRING above), this will always return 404 for users with READ_WRITE permissions. + // -> POST request to /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches + resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, gh.CreateWorkflowDispatchEventRequest{}) + switch resp.StatusCode { + case 403: + return UNKNOWN, nil + case 404: + return READ_WRITE, nil + case 200: + log.Fatal("This shouldn't print. We are enabling a workflow based on a random string " + RANDOM_STRING + ", which most likely doesn't exist.") + return READ_WRITE, nil + default: + return ERROR, err + } + } +} + +func getAdministrationPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // -> GET request to /repos/{owner}/{repo}/actions/permissions + _, resp, err := client.Repositories.GetActionsPermissions(context.Background(), *repo.Owner.Login, *repo.Name) + switch resp.StatusCode { + case 403, 404: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Extremely Low + // -> GET request to /repos/{owner}/{repo}/rulesets/rule-suites + req, err := client.NewRequest("GET", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/rulesets/rule-suites", nil) + if err != nil { + return ERROR, err + } + resp, err = client.Do(context.Background(), req, nil) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 200: + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getCodeScanningAlertsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // -> GET request to /repos/{owner}/{repo}/code-scanning/alerts + _, resp, err := client.CodeScanning.ListAlertsForRepo(context.Background(), *repo.Owner.Login, *repo.Name, nil) + if err != nil { + return "", err + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + body := string(bodyBytes) + + if strings.Contains(body, "Code scanning is not enabled for this repository") { + return UNKNOWN, nil + } + + switch { + case resp.StatusCode == 403: + return NO_ACCESS, nil + case resp.StatusCode == 404: + break + case resp.StatusCode >= 200 && resp.StatusCode <= 299: + break + default: + return ERROR, err + } + + // Risk: Very Low + // -> Even if user had an alert with the number (see RANDOM_INTEGER above), this should error 422 due to the nil value passed in. + // -> PATCH request to /repos/{owner}/{repo}/code-scanning/alerts/{alert_number} + _, resp, err = client.CodeScanning.UpdateAlert(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_INTEGER, nil) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 200: + log.Fatal("This should never happen. We are updating an alert with nil which should be an invalid request.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getCodespacesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // GET request to /repos/{owner}/{repo}/codespaces + _, resp, err := client.Codespaces.ListInRepo(context.Background(), *repo.Owner.Login, *repo.Name, nil) + switch resp.StatusCode { + case 403, 404: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Extremely Low + // GET request to /repos/{owner}/{repo}/codespaces/permissions_check + req, err := client.NewRequest("GET", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/codespaces/permissions_check", nil) + if err != nil { + return ERROR, err + } + resp, err = client.Do(context.Background(), req, nil) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 200: + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getCodespacesMetadataPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // GET request to /repos/{owner}/{repo}/codespaces/machines + req, err := client.NewRequest("GET", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/codespaces/machines", nil) + if err != nil { + return ERROR, err + } + resp, err := client.Do(context.Background(), req, nil) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200: + return READ_ONLY, nil + default: + return ERROR, err + } +} + +func getCodespacesSecretsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // GET request to /repos/{owner}/{repo}/codespaces/secrets for non-existent secret + _, resp, err := client.Codespaces.GetRepoSecret(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 404: + return READ_WRITE, nil + case 200: + return READ_WRITE, nil + default: + return ERROR, err + } +} + +// getCommitStatusesPermission will check if we have access to commit statuses for a given repo. +// By default, we have read-only access to commit statuses for all public repos. If only public repos exist under +// this key's permissions, then they best we can hope for us a READ_WRITE status or an UNKNOWN status. +// If a private repo exists, then we can check for READ_ONLY, READ_WRITE and NO_ACCESS. +func getCommitStatusesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + if *repo.Private { + // Risk: Extremely Low + // GET request to /repos/{owner}/{repo}/commits/{commit_sha}/statuses + _, resp, err := client.Repositories.ListStatuses(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, nil) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 404: + break + default: + return ERROR, err + } + // At this point we have read access + + // Risk: Extremely Low + // -> We're POSTing a commit status to a commit that cannot exist. This should always return 422 if valid access. + // POST request to /repos/{owner}/{repo}/statuses/{commit_sha} + _, resp, err = client.Repositories.CreateStatus(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.RepoStatus{}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + default: + return ERROR, err + } + } else { + // Will only land here if already tested one public repo and got a 403. + if currentAccess == UNKNOWN { + return UNKNOWN, nil + } + // Risk: Extremely Low + // -> We're POSTing a commit status to a commit that cannot exist. This should always return 422 if valid access. + // POST request to /repos/{owner}/{repo}/statuses/{commit_sha} + _, resp, err := client.Repositories.CreateStatus(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.RepoStatus{}) + switch resp.StatusCode { + case 403: + // All we know is we don't have READ_WRITE + return UNKNOWN, nil + case 422: + return READ_WRITE, nil + default: + return ERROR, err + } + } +} + +// getContentsPermission will check if we have access to the contents of a given repo. +// By default, we have read-only access to the contents of all public repos. If only public repos exist under +// this key's permissions, then they best we can hope for us a READ_WRITE status or an UNKNOWN status. +// If a private repo exists, then we can check for READ_ONLY, READ_WRITE and NO_ACCESS. +func getContentsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + if *repo.Private { + // Risk: Extremely Low + // GET request to /repos/{owner}/{repo}/commits + _, resp, err := client.Repositories.ListCommits(context.Background(), *repo.Owner.Login, *repo.Name, &gh.CommitsListOptions{}) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200: + break + case 409: + break + default: + return ERROR, err + } + // At this point we have read access + + // Risk: Low-Medium + // -> We're creating a file with an invalid payload. Worst case is a file with a random string and no content is created. But this should never happen. + // PUT /repos/{owner}/{repo}/contents/{path} + _, resp, err = client.Repositories.CreateFile(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.RepositoryContentFileOptions{}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 200: + log.Fatal("This should never happen. We are creating a file with an invalid payload.") + return READ_WRITE, nil + case 400, 422: + return READ_WRITE, nil + default: + return ERROR, err + } + } else { + // Will only land here if already tested one public repo and got a 403. + if currentAccess == UNKNOWN { + return UNKNOWN, nil + } + // Risk: Low-Medium + // -> We're creating a file with an invalid payload. Worst case is a file with a random string and no content is created. But this should never happen. + // PUT /repos/{owner}/{repo}/contents/{path} + _, resp, err := client.Repositories.CreateFile(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.RepositoryContentFileOptions{}) + switch resp.StatusCode { + case 403: + return UNKNOWN, nil + case 200: + log.Fatal("This should never happen. We are creating a file with an invalid payload.") + return READ_WRITE, nil + case 400, 422: + return READ_WRITE, nil + default: + return ERROR, err + } + } +} + +// func getCustomPropertiesPermission(client *gh.Client, owner, repo string, private bool) (string, error) { +// // Look for the phrase "Custom properties only supported for organizations" in the response body +// // If find, then we want to skip this repo. +// // If all repos have that phrase, then we have to put "Unknown". +// // If we find a repo without that (an organization-owned repo), then we just check for NO_ACCESS and READ_WRITE? +// // I'd add in READ_ONLY, but the docs only show one `write` endpoint for this block. +// } + +func getDependabotAlertsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // GET /repos/{owner}/{repo}/dependabot/alerts + _, resp, err := client.Dependabot.ListRepoAlerts(context.Background(), *repo.Owner.Login, *repo.Name, &gh.ListAlertsOptions{}) + switch resp.StatusCode { + case 403: + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatal(err) + } + body := string(bodyBytes) + + if strings.Contains(body, "Dependabot alerts are disabled for this repository.") { + return UNKNOWN, nil + } + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // PATCH /repos/{owner}/{repo}/dependabot/alerts/{alert_number} + _, resp, err = client.Dependabot.UpdateAlert(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_INTEGER, nil) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422, 404: + return READ_WRITE, nil + case 200: + log.Fatal("This should never happen. We are updating an alert with nil which should be an invalid request.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getDependabotSecretsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // GET /repos/{owner}/{repo}/dependabot/secrets + _, resp, err := client.Dependabot.ListRepoSecrets(context.Background(), *repo.Owner.Login, *repo.Name, &gh.ListOptions{}) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Very Low + // -> We're "creating" a secret with an invalid payload. Even if we did, the name would be (see RANDOM_STRING above) and the value would be nil. + // PUT /repos/{owner}/{repo}/dependabot/secrets/{secret_name} + resp, err = client.Dependabot.CreateOrUpdateRepoSecret(context.Background(), *repo.Owner.Login, *repo.Name, &gh.DependabotEncryptedSecret{Name: RANDOM_STRING}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 201, 204: + log.Fatal("This should never happen. We are creating a secret with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getDeploymentsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // GET /repos/{owner}/{repo}/deployments + _, resp, err := client.Repositories.ListDeployments(context.Background(), *repo.Owner.Login, *repo.Name, &gh.DeploymentsListOptions{}) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Very Low + // -> We're creating a deployment with an invalid payload. Even if we did, the name would be (see RANDOM_STRING above) and the value would be nil. + // POST /repos/{owner}/{repo}/deployments/{deployment_id}/statuses + _, resp, err = client.Repositories.CreateDeployment(context.Background(), *repo.Owner.Login, *repo.Name, &gh.DeploymentRequest{}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 409, 422: + return READ_WRITE, nil + case 201, 202: + log.Fatal("This should never happen. We are creating a deployment with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getEnvironmentsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // GET /repos/{owner}/{repo}/environments + envResp, resp, _ := client.Repositories.ListEnvironments(context.Background(), *repo.Owner.Login, *repo.Name, &gh.EnvironmentListOptions{}) + if resp.StatusCode != 200 { + return UNKNOWN, nil + } + // If no environments exist, then we return UNKNOWN + if len(envResp.Environments) == 0 { + return UNKNOWN, nil + } + + // Risk: Extremely Low + // GET /repositories/{repository_id}/environments/{environment_name}/variables + _, resp, err := client.Actions.ListEnvVariables(context.Background(), int(*repo.ID), *envResp.Environments[0].Name, &gh.ListOptions{}) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Very Low + // -> We're updating an environment variable with an invalid payload. Even if we did, the name would be (see RANDOM_STRING above) and the value would be nil. + // PATCH /repositories/{repository_id}/environments/{environment_name}/variables/{variable_name} + resp, err = client.Actions.UpdateEnvVariable(context.Background(), int(*repo.ID), *envResp.Environments[0].Name, &gh.ActionsVariable{Name: RANDOM_STRING}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 200: + log.Fatal("This should never happen. We are updating an environment variable with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getIssuesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + + if *repo.Private { + + // Risk: Extremely Low + // GET /repos/{owner}/{repo}/issues + _, resp, err := client.Issues.ListByRepo(context.Background(), *repo.Owner.Login, *repo.Name, &gh.IssueListByRepoOptions{}) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200, 301: + break + default: + return ERROR, err + } + + // Risk: Very Low + // -> We're editing an issue label that does not exist. Even if we did, the name would be (see RANDOM_STRING above). + // PATCH /repos/{owner}/{repo}/labels/{name} + _, resp, err = client.Issues.EditLabel(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.Label{}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 404: + return READ_WRITE, nil + case 200: + log.Fatal("This should never happen. We are editing a label with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } + } else { + // Will only land here if already tested one public repo and got a 403. + if currentAccess == UNKNOWN { + return UNKNOWN, nil + } + // Risk: Very Low + // -> We're editing an issue label that does not exist. Even if we did, the name would be (see RANDOM_STRING above). + // PATCH /repos/{owner}/{repo}/labels/{name} + _, resp, err := client.Issues.EditLabel(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.Label{}) + switch resp.StatusCode { + case 403: + return UNKNOWN, nil + case 404: + return READ_WRITE, nil + case 200: + log.Fatal("This should never happen. We are editing a label with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } + } +} + +func getPagesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + if *repo.Private { + // Risk: Extremely Low + // GET /repos/{owner}/{repo}/pages + _, resp, err := client.Repositories.GetPagesInfo(context.Background(), *repo.Owner.Login, *repo.Name) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200, 404: + break + default: + return ERROR, err + } + + // Risk: Very Low + // -> We're cancelling a GitHub Pages deployment that does not exist (see RANDOM_STRING above). + // POST /repos/{owner}/{repo}/pages/deployments/{deployment_id}/cancel + req, err := client.NewRequest("POST", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/pages/deployments/"+RANDOM_STRING+"/cancel", nil) + if err != nil { + return ERROR, err + } + resp, err = client.Do(context.Background(), req, nil) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 404: + return READ_WRITE, nil + case 200: + log.Fatal("This should never happen. We are cancelling a deployment with an invalid ID.") + return READ_WRITE, nil + default: + return ERROR, err + } + } else { + // Will only land here if already tested one public repo and got a 403. + if currentAccess == UNKNOWN { + return UNKNOWN, nil + } + // Risk: Very Low + // -> We're cancelling a GitHub Pages deployment that does not exist (see RANDOM_STRING above). + // POST /repos/{owner}/{repo}/pages/deployments/{deployment_id}/cancel + req, err := client.NewRequest("POST", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/pages/deployments/"+RANDOM_STRING+"/cancel", nil) + if err != nil { + return ERROR, err + } + resp, err := client.Do(context.Background(), req, nil) + switch resp.StatusCode { + case 403: + return UNKNOWN, nil + case 404: + return READ_WRITE, nil + case 200: + log.Fatal("This should never happen. We are cancelling a deployment with an invalid ID.") + return READ_WRITE, nil + default: + return ERROR, err + } + } +} + +func getPullRequestsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + if *repo.Private { + // Risk: Extremely Low + // GET /repos/{owner}/{repo}/pulls + _, resp, err := client.PullRequests.List(context.Background(), *repo.Owner.Login, *repo.Name, &gh.PullRequestListOptions{}) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Very Low + // -> We're creating a pull request with an invalid payload. + // POST /repos/{owner}/{repo}/pulls + _, resp, err = client.PullRequests.Create(context.Background(), *repo.Owner.Login, *repo.Name, &gh.NewPullRequest{}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 200: + log.Fatal("This should never happen. We are creating a pull request with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } + } else { + // Will only land here if already tested one public repo and got a 403. + if currentAccess == UNKNOWN { + return UNKNOWN, nil + } + // Risk: Very Low + // -> We're creating a pull request with an invalid payload. + // POST /repos/{owner}/{repo}/pulls + _, resp, err := client.PullRequests.Create(context.Background(), *repo.Owner.Login, *repo.Name, &gh.NewPullRequest{}) + switch resp.StatusCode { + case 403: + return UNKNOWN, nil + case 422: + return READ_WRITE, nil + case 200: + log.Fatal("This should never happen. We are creating a pull request with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } + } +} + +func getRepoSecurityPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + + if *repo.Private { + // Risk: Extremely Low + // GET /repos/{owner}/{repo}/security-advisories + _, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(context.Background(), *repo.Owner.Login, *repo.Name, nil) + switch resp.StatusCode { + case 403, 404: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Very Low + // -> We're creating a security advisory with an invalid payload. + // POST /repos/{owner}/{repo}/security-advisories + req, err := client.NewRequest("POST", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/security-advisories", nil) + if err != nil { + return ERROR, err + } + resp, err = client.Do(context.Background(), req, nil) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 200: + log.Fatal("This should never happen. We are creating a security advisory with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } + } else { + // Will only land here if already tested one public repo and got a 403. + if currentAccess == UNKNOWN { + return UNKNOWN, nil + } + // Risk: Very Low + // -> We're creating a security advisory with an invalid payload. + // POST /repos/{owner}/{repo}/security-advisories + req, err := client.NewRequest("POST", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/security-advisories", nil) + if err != nil { + return ERROR, err + } + resp, err := client.Do(context.Background(), req, nil) + switch resp.StatusCode { + case 403: + return UNKNOWN, nil + case 422: + return READ_WRITE, nil + case 200: + log.Fatal("This should never happen. We are creating a security advisory with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } + } +} + +func getSecretScanningPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // GET /repos/{owner}/{repo}/secret-scanning/alerts + _, resp, err := client.SecretScanning.ListAlertsForRepo(context.Background(), *repo.Owner.Login, *repo.Name, nil) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200, 404: + break + default: + return ERROR, err + } + + // Risk: Very Low + // -> We're updating a secret scanning alert for an alert that doesn't exist. + // POST /repos/{owner}/{repo}/secret-scanning/alerts + _, resp, err = client.SecretScanning.UpdateAlert(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_INTEGER, &gh.SecretScanningAlertUpdateOptions{}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 404, 422: + return READ_WRITE, nil + case 200: + log.Fatal("This should never happen. We are updating a secret scanning alert that doesn't exist.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getSecretsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // GET /repos/{owner}/{repo}/actions/secrets + _, resp, err := client.Actions.ListRepoSecrets(context.Background(), *repo.Owner.Login, *repo.Name, &gh.ListOptions{}) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Very Low + // -> We're creating a secret with an invalid payload. + // PUT /repos/{owner}/{repo}/actions/secrets/{secret_name} + resp, err = client.Actions.CreateOrUpdateRepoSecret(context.Background(), *repo.Owner.Login, *repo.Name, &gh.EncryptedSecret{Name: RANDOM_STRING}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 201, 204: + log.Fatal("This should never happen. We are creating a secret with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getVariablesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // GET /repos/{owner}/{repo}/actions/variables + _, resp, err := client.Actions.ListRepoVariables(context.Background(), *repo.Owner.Login, *repo.Name, &gh.ListOptions{}) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Very Low + // -> We're updating a variable that doesn't exist with an invalid payload. + // PATCH /repos/{owner}/{repo}/actions/variables/{name} + resp, err = client.Actions.UpdateRepoVariable(context.Background(), *repo.Owner.Login, *repo.Name, &gh.ActionsVariable{Name: RANDOM_STRING}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 201, 204: + log.Fatal("This should never happen. We are patching a variable with an invalid payload and no name.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getWebhooksPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { + // Risk: Extremely Low + // GET /repos/{owner}/{repo}/hooks + _, resp, err := client.Repositories.ListHooks(context.Background(), *repo.Owner.Login, *repo.Name, &gh.ListOptions{}) + switch resp.StatusCode { + case 403, 404: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Very Low + // -> We're updating a webhook that doesn't exist with an invalid payload. + // PATCH /repos/{owner}/{repo}/hooks/{hook_id} + _, resp, err = client.Repositories.EditHook(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_INTEGER, &gh.Hook{}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 404: + return READ_WRITE, nil + case 200: + log.Fatal("This should never happen. We are updating a webhook with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +// analyzeRepositoryPermissions will analyze the fine-grained permissions of a given permission type and return the access level. +// This function is needed b/c in some cases a token could have permissions that are only enabled on specific repos. +// If we only checked one repo, we wouldn't be able to tell if the token has access to a specific permission type. +// Ex: "Code scanning alerts" must be enabled to tell if we have that permission. +func analyzeRepositoryPermissions(client *gh.Client, repos []*gh.Repository, permissionType string) string { + access := "" + var err error + for _, repo := range repos { + access, err = repoPermFuncMap[permissionType](client, repo, access) + if err != nil { + log.Fatal(err) + } + if access != UNKNOWN && access != ERROR { + return access + } + } + return access +} + +func getBlockUserPermission(client *gh.Client, user *gh.User) (string, error) { + // Risk: Extremely Low + // -> GET request to /user/blocks + _, resp, err := client.Users.ListBlockedUsers(context.Background(), nil) + switch resp.StatusCode { + case 403, 404: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Extremely Low + // -> PUT request to /user/blocks/{username} + // -> We're blocking a user that doesn't exist. See RANDOM_STRING above. + resp, err = client.Users.BlockUser(context.Background(), RANDOM_STRING) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 404: + return READ_WRITE, nil + case 204: + log.Fatal("This should never happen. We are blocking a user that doesn't exist.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getCodespacesUserPermission(client *gh.Client, user *gh.User) (string, error) { + // Risk: Extremely Low + // GET request to /user/codespaces/secrets + _, resp, err := client.Codespaces.ListUserSecrets(context.Background(), nil) + switch resp.StatusCode { + case 403, 404: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Low + // PUT request to /user/codespaces/secrets/{secret_name} + // Payload is invalid, so it shouldn't actually post. + resp, err = client.Codespaces.CreateOrUpdateUserSecret(context.Background(), &gh.EncryptedSecret{Name: RANDOM_STRING}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 201, 204: + log.Fatal("This should never happen. We are creating a user secret with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getEmailPermission(client *gh.Client, user *gh.User) (string, error) { + // Risk: Extremely Low + // GET request to /user/emails + _, resp, err := client.Users.ListEmails(context.Background(), nil) + switch resp.StatusCode { + case 403, 404: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Low + // POST request to /user/emails/visibility + _, resp, err = client.Users.SetEmailVisibility(context.Background(), RANDOM_STRING) + switch resp.StatusCode { + case 403, 404: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 201: + log.Fatal("This should never happen. We are setting email visibility with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getFollowersPermission(client *gh.Client, user *gh.User) (string, error) { + // Risk: Extremely Low + // GET request to /user/followers + _, resp, err := client.Users.ListFollowers(context.Background(), "", nil) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Low - Medium + // DELETE request to /user/followers/{username} + // For the username value, we need to use a real username. So there is a super small chance that someone following + // an account for RANDOM_USERNAME value will then no longer follow that account. + // But we're using an account created specifically for this purpose with no activity. + resp, err = client.Users.Unfollow(context.Background(), RANDOM_USERNAME) + switch resp.StatusCode { + case 403, 404: + return READ_ONLY, nil + case 204: + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getGPGKeysPermission(client *gh.Client, user *gh.User) (string, error) { + // Risk: Extremely Low + // GET request to /user/gpg_keys + _, resp, err := client.Users.ListGPGKeys(context.Background(), "", nil) + switch resp.StatusCode { + case 403, 404: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Low - Medium + // POST request to /user/gpg_keys + // Payload is invalid, so it shouldn't actually post. + _, resp, err = client.Users.CreateGPGKey(context.Background(), RANDOM_STRING) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 200, 201, 204: + log.Fatal("This should never happen. We are creating a GPG key with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getGistsPermission(client *gh.Client, user *gh.User) (string, error) { + // Risk: Low - Medium + // POST request to /gists + // Payload is invalid, so it shouldn't actually post. + _, resp, err := client.Gists.Create(context.Background(), &gh.Gist{}) + switch resp.StatusCode { + case 403, 404: + return NO_ACCESS, nil + case 422: + return READ_WRITE, nil + case 200, 201, 204: + log.Fatal("This should never happen. We are creating a Gist with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getGitKeysPermission(client *gh.Client, user *gh.User) (string, error) { + // Risk: Extremely Low + // GET request to /user/keys + _, resp, err := client.Users.ListKeys(context.Background(), "", nil) + switch resp.StatusCode { + case 403, 404: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Low - Medium + // POST request to /user/keys + // Payload is invalid, so it shouldn't actually post. + _, resp, err = client.Users.CreateKey(context.Background(), &gh.Key{}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 200, 201, 204: + log.Fatal("This should never happen. We are creating a key with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getLimitsPermission(client *gh.Client, user *gh.User) (string, error) { + // Risk: Extremely Low + // GET request to /user/interaction-limits + req, err := client.NewRequest("GET", "https://api.github.com/user/interaction-limits", nil) + if err != nil { + return ERROR, err + } + resp, err := client.Do(context.Background(), req, nil) + switch resp.StatusCode { + case 403: + return NO_ACCESS, nil + case 200, 204: + break + default: + return ERROR, err + } + + // Risk: Low + // PUT request to /user/interaction-limits + // Payload is invalid, so it shouldn't actually post. + req, err = client.NewRequest("PUT", "https://api.github.com/user/interaction-limits", nil) + if err != nil { + return ERROR, err + } + resp, err = client.Do(context.Background(), req, nil) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 200, 204: + log.Fatal("This should never happen. We are setting interaction limits with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getPlanPermission(client *gh.Client, user *gh.User) (string, error) { + // Risk: Extremely Low + // GET request to /user/{username}/settings/billing/actions + _, resp, err := client.Billing.GetActionsBillingUser(context.Background(), *user.Login) + switch resp.StatusCode { + case 403, 404: + return NO_ACCESS, nil + case 200: + return READ_ONLY, nil + default: + return ERROR, err + } +} + +func getProfilePermission(client *gh.Client, user *gh.User) (string, error) { + // Risk: Low + // POST request to /user/social_accounts + // Payload is invalid, so it shouldn't actually patch. + req, err := client.NewRequest("POST", "https://api.github.com/user/social_accounts", nil) + if err != nil { + return ERROR, err + } + resp, err := client.Do(context.Background(), req, nil) + switch resp.StatusCode { + case 403, 404: + return NO_ACCESS, nil + case 422: + return READ_WRITE, nil + case 200, 201, 204: + log.Fatal("This should never happen. We are creating a social account with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getSigningKeysPermission(client *gh.Client, user *gh.User) (string, error) { + // Risk: Extremely Low + // GET request to /user/ssh_signing_keys + _, resp, err := client.Users.ListSSHSigningKeys(context.Background(), "", nil) + switch resp.StatusCode { + case 403, 404: + return NO_ACCESS, nil + case 200: + break + default: + return ERROR, err + } + + // Risk: Low - Medium + // POST request to /user/ssh_signing_keys + // Payload is invalid, so it shouldn't actually post. + _, resp, err = client.Users.CreateSSHSigningKey(context.Background(), &gh.Key{}) + switch resp.StatusCode { + case 403: + return READ_ONLY, nil + case 422: + return READ_WRITE, nil + case 200, 201, 204: + log.Fatal("This should never happen. We are creating a SSH key with an invalid payload.") + return READ_WRITE, nil + default: + return ERROR, err + } +} + +func getStarringPermission(client *gh.Client, user *gh.User) (string, error) { + // Note: We can't test READ_WRITE b/c Unstar() isn't working even with READ_WRITE permissions. + // Note: GET /user/starred returns the same results regardless of permissions + // but since all have the same access, we'll call it READ_ONLY for now. + return READ_ONLY, nil + +} + +func getWatchingPermission(client *gh.Client, user *gh.User) (string, error) { + // Note: GET /user/subscriptions returns the same results regardless of permissions + // but since all have the same access, we'll call it READ_ONLY for now. + return READ_ONLY, nil +} + +func analyzeUserPermissions(client *gh.Client, user *gh.User, permissionType string) string { + access := "" + var err error + access, err = acctPermFuncMap[permissionType](client, user) + if err != nil { + log.Fatal(err) + } + if access != UNKNOWN && access != ERROR { + return access + } + return access +} + +func analyzeFineGrainedToken(client *gh.Client, _ string, show_all bool) { + // Get all private repos + allRepos, err := getAllReposForUser(client) + if err != nil { + color.Red("Error getting repos.") + return + } + + filteredRepos := make([]*gh.Repository, 0) + for _, repo := range allRepos { + if analyzeRepositoryPermissions(client, []*gh.Repository{repo}, METADATA) != NO_ACCESS { + filteredRepos = append(filteredRepos, repo) + } + } + + if len(filteredRepos) == 0 { + // If no repos are accessible, then we only have read access to public repos + color.Red("[!] Repository Access: Public Repositories (read-only)\n") + } else { + // Print out the repos the token can access + color.Green(fmt.Sprintf("Found %v", len(filteredRepos)) + " Accessible Repositor(ies) \n") + printGitHubRepos(filteredRepos) + + // Check our access + repoAccessMap := make(map[string]string) + for key := range repoPermFuncMap { + repoAccessMap[key] = analyzeRepositoryPermissions(client, filteredRepos, key) + } + + // Print out the access map + printFineGrainedPermissions(repoAccessMap, show_all, true) + } + + // Get this token's user + user, _, err := client.Users.Get(context.Background(), "") + if err != nil { + color.Red("Error getting user.") + return + } + + // Analyze Account's Permissions + userAccessMap := make(map[string]string) + for key := range acctPermFuncMap { + userAccessMap[key] = analyzeUserPermissions(client, user, key) + } + + printFineGrainedPermissions(userAccessMap, show_all, false) + + // Get all private gists + gists, _ := getAllGistsForUser(client) + printGists(gists, show_all) + +} + +func printFineGrainedPermissions(accessMap map[string]string, show_all bool, repo_permissions bool) { + permissionCount := 0 + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Permission Type", "Permission" /* Add more column headers if needed */}) + + // Extract keys from accessMap into slice + keys := make([]string, 0, len(accessMap)) + for k := range accessMap { + keys = append(keys, k) + } + // Sort the slice + sort.Strings(keys) + + for _, key := range keys { + value := accessMap[key] + if value == NO_ACCESS || value == UNKNOWN || value == ERROR || value == NOT_IMPLEMENTED { + // don't change permissionCount + } else { + permissionCount++ + } + if !show_all && (value == NO_ACCESS || value == UNKNOWN || value == NOT_IMPLEMENTED) { + continue + } else { + k, v := permissionFormatter(key, value) + t.AppendRow([]interface{}{k, v}) + } + } + var permissionType string + if repo_permissions { + permissionType = "Repositor(ies)" + } else { + permissionType = "User Account" + } + if permissionCount == 0 && !show_all { + color.Red("No Permissions Found for the %v above\n\n", permissionType) + return + } else if permissionCount == 0 { + color.Red("Found No Permissions for the %v above\n", permissionType) + } else { + color.Green(fmt.Sprintf("Found %v Permission(s) for the %v above\n", permissionCount, permissionType)) + } + t.Render() + fmt.Print("\n\n") +} diff --git a/pkg/analyzer/analyzers/github/github.go b/pkg/analyzer/analyzers/github/github.go new file mode 100644 index 000000000..3c8dbc1a3 --- /dev/null +++ b/pkg/analyzer/analyzers/github/github.go @@ -0,0 +1,204 @@ +package github + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/fatih/color" + gh "github.com/google/go-github/v59/github" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +func getAllGistsForUser(client *gh.Client) ([]*gh.Gist, error) { + opt := &gh.GistListOptions{ListOptions: gh.ListOptions{PerPage: 100}} + var allGists []*gh.Gist + page := 1 + for { + opt.Page = page + gists, resp, err := client.Gists.List(context.Background(), "", opt) + if err != nil { + color.Red("Error getting gists.") + return nil, err + } + allGists = append(allGists, gists...) + + linkHeader := resp.Header.Get("link") + if linkHeader == "" || !strings.Contains(linkHeader, `rel="next"`) { + break + } + page++ + + } + + return allGists, nil +} + +func getAllReposForUser(client *gh.Client) ([]*gh.Repository, error) { + opt := &gh.RepositoryListByAuthenticatedUserOptions{ListOptions: gh.ListOptions{PerPage: 100}} + var allRepos []*gh.Repository + page := 1 + for { + opt.Page = page + repos, resp, err := client.Repositories.ListByAuthenticatedUser(context.Background(), opt) + if err != nil { + color.Red("Error getting repos.") + return nil, err + } + allRepos = append(allRepos, repos...) + + linkHeader := resp.Header.Get("link") + if linkHeader == "" || !strings.Contains(linkHeader, `rel="next"`) { + break + } + page++ + + } + return allRepos, nil +} + +func printGitHubRepos(repos []*gh.Repository) { + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Repo Name", "Owner", "Repo Link", "Private"}) + for _, repo := range repos { + if *repo.Private { + green := color.New(color.FgGreen).SprintFunc() + t.AppendRow([]interface{}{green(*repo.Name), green(*repo.Owner.Login), green(*repo.HTMLURL), green("true")}) + } else { + t.AppendRow([]interface{}{*repo.Name, *repo.Owner.Login, *repo.HTMLURL, *repo.Private}) + } + } + t.Render() + fmt.Print("\n\n") +} + +func printGists(gists []*gh.Gist, show_all bool) { + privateCount := 0 + + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Gist ID", "Gist Link", "Description", "Private"}) + for _, gist := range gists { + if show_all && *gist.Public { + t.AppendRow([]interface{}{*gist.ID, *gist.HTMLURL, *gist.Description, "false"}) + } else if !*gist.Public { + privateCount++ + green := color.New(color.FgGreen).SprintFunc() + t.AppendRow([]interface{}{green(*gist.ID), green(*gist.HTMLURL), green(*gist.Description), green("true")}) + } + } + if show_all && len(gists) == 0 { + color.Red("[i] No Gist(s) Found\n") + } else if show_all { + color.Yellow("[i] Found %v Total Gist(s) (%v private)\n", len(gists), privateCount) + t.Render() + } else if privateCount == 0 { + color.Red("[i] No Private Gist(s) Found\n") + } else { + color.Green(fmt.Sprintf("[!] Found %v Private Gist(s)\n", privateCount)) + t.Render() + } + fmt.Print("\n\n") +} + +func getRemainingTime(t string) string { + targetTime, err := time.Parse("2006-01-02 15:04:05 MST", t) + if err != nil { + return "" + } + + // Get the current time + currentTime := time.Now() + + // Calculate the duration until the target time + durationUntilTarget := targetTime.Sub(currentTime) + durationUntilTarget = durationUntilTarget.Truncate(time.Minute) + + // Print the duration + return fmt.Sprintf("%v", durationUntilTarget) +} + +// getTokenMetadata gets the username, expiration date, and x-oauth-scopes headers for a given token +// by sending a GET request to the /user endpoint +// Returns a response object for usage in the checkFineGrained function +func getTokenMetadata(token string, client *gh.Client) (resp *gh.Response, err error) { + user, resp, err := client.Users.Get(context.Background(), "") + if err != nil { + return nil, err + } + + color.Yellow("[i] Token User: %v", *user.Login) + + expiry := resp.Header.Get("github-authentication-token-expiration") + timeRemaining := getRemainingTime(expiry) + if timeRemaining == "" { + color.Red("[i] Token Expiration: does not expire") + } else { + color.Yellow("[i] Token Expiration: %v (%v remaining)", expiry, timeRemaining) + } + return resp, nil +} + +func checkFineGrained(resp *gh.Response, token string) (bool, error) { + // For details on token prefixes, see: + // https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/ + + // Special case for ghu_ prefix tokens (ex: in a codespace) that don't have the X-OAuth-Scopes header + if strings.HasPrefix(token, "ghu_") { + color.Yellow("[i] Token Type: GitHub User-to-Server Token") + return true, nil + } + + // Handle github_pat_ tokens + if strings.HasPrefix(token, "github_pat") { + color.Yellow("[i] Token Type: Fine-Grained GitHub Personal Access Token") + return true, nil + } + + // Handle classic PATs + if strings.HasPrefix(token, "ghp_") { + color.Yellow("[i] Token Type: Classic GitHub Personal Access Token") + return false, nil + } + + // Catch-all for any other types + // If resp.Header "X-OAuth-Scopes" doesn't exist, then we have fine-grained permissions + color.Yellow("[i] Token Type: GitHub Token") + if len(resp.Header.Values("X-Oauth-Scopes")) > 0 { + return false, nil + } + return true, nil +} + +func AnalyzePermissions(cfg *config.Config, key string) { + + // ToDo: Add logging for GitHub when rewrite to not use GH client. + if cfg.LoggingEnabled { + color.Red("[x] Logging not supported for GitHub Token Analysis.") + return + } + + client := gh.NewClient(nil).WithAuthToken(key) + + resp, err := getTokenMetadata(key, client) + if err != nil { + color.Red("[x] Invalid GitHub Token.") + return + } + + // Check if the token is fine-grained or classic + if fineGrained, err := checkFineGrained(resp, key); err != nil { + color.Red("[x] Invalid GitHub Token.") + return + } else if !fineGrained { + fmt.Print("\n\n") + analyzeClassicToken(client, key, cfg.ShowAll) + } else { + fmt.Print("\n\n") + analyzeFineGrainedToken(client, key, cfg.ShowAll) + } +} diff --git a/pkg/analyzer/analyzers/gitlab/gitlab.go b/pkg/analyzer/analyzers/gitlab/gitlab.go new file mode 100644 index 000000000..db9186c34 --- /dev/null +++ b/pkg/analyzer/analyzers/gitlab/gitlab.go @@ -0,0 +1,278 @@ +package gitlab + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +// consider calling /api/v4/metadata to learn about gitlab instance version and whether neterrprises is enabled + +// we'll call /api/v4/personal_access_tokens and /api/v4/user and then filter down to scopes. + +type AcessTokenJSON struct { + Name string `json:"name"` + Revoked bool `json:"revoked"` + CreatedAt string `json:"created_at"` + Scopes []string `json:"scopes"` + LastUsedAt string `json:"last_used_at"` + ExpiresAt string `json:"expires_at"` +} + +type ProjectsJSON struct { + NameWithNamespace string `json:"name_with_namespace"` + Permissions struct { + ProjectAccess struct { + AccessLevel int `json:"access_level"` + } `json:"project_access"` + } `json:"permissions"` +} + +type ErrorJSON struct { + Error string `json:"error"` + Scope string `json:"scope"` +} + +type MetadataJSON struct { + Version string `json:"version"` + Enterprise bool `json:"enterprise"` +} + +func getPersonalAccessToken(cfg *config.Config, key string) (AcessTokenJSON, int, error) { + var tokens AcessTokenJSON + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", "https://gitlab.com/api/v4/personal_access_tokens/self", nil) + if err != nil { + color.Red("[x] Error: %s", err) + return tokens, -1, err + } + + req.Header.Set("PRIVATE-TOKEN", key) + resp, err := client.Do(req) + if err != nil { + color.Red("[x] Error: %s", err) + return tokens, resp.StatusCode, err + } + + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil { + color.Red("[x] Error: %s", err) + return tokens, resp.StatusCode, err + } + return tokens, resp.StatusCode, nil +} + +func getAccessibleProjects(cfg *config.Config, key string) ([]ProjectsJSON, error) { + var projects []ProjectsJSON + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", "https://gitlab.com/api/v4/projects", nil) + if err != nil { + color.Red("[x] Error: %s", err) + return projects, err + } + + req.Header.Set("PRIVATE-TOKEN", key) + + // Add query parameters + q := req.URL.Query() + q.Add("min_access_level", "10") + req.URL.RawQuery = q.Encode() + + resp, err := client.Do(req) + if err != nil { + color.Red("[x] Error: %s", err) + return projects, err + } + + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Println("Error reading the response body:", err) + return projects, err + } + + newBody := func() io.ReadCloser { + return io.NopCloser(bytes.NewReader(bodyBytes)) + } + + if err := json.NewDecoder(newBody()).Decode(&projects); err != nil { + var e ErrorJSON + if err := json.NewDecoder(newBody()).Decode(&e); err == nil { + color.Red("[x] Insufficient Scope to query for projects. We need api or read_api permissions.\n") + return projects, nil + } + color.Red("[x] Error: %s", err) + return projects, err + } + return projects, nil +} + +func getMetadata(cfg *config.Config, key string) (MetadataJSON, error) { + var metadata MetadataJSON + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", "https://gitlab.com/api/v4/metadata", nil) + if err != nil { + color.Red("[x] Error: %s", err) + return metadata, err + } + + req.Header.Set("PRIVATE-TOKEN", key) + resp, err := client.Do(req) + if err != nil { + color.Red("[x] Error: %s", err) + return metadata, err + } + + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Println("Error reading the response body:", err) + return metadata, err + } + + newBody := func() io.ReadCloser { + return io.NopCloser(bytes.NewReader(bodyBytes)) + } + + if err := json.NewDecoder(newBody()).Decode(&metadata); err != nil { + return metadata, err + } + + if metadata.Version == "" { + var e ErrorJSON + if err := json.NewDecoder(newBody()).Decode(&e); err == nil { + color.Red("[x] Insufficient Scope to query for metadata. We need read_user, ai_features, api or read_api permissions.\n") + return metadata, nil + } else { + return metadata, err + } + } + + return metadata, nil +} + +func AnalyzePermissions(cfg *config.Config, key string) { + + // get personal_access_tokens accessible + token, statusCode, err := getPersonalAccessToken(cfg, key) + if err != nil { + color.Red("[x] Error: %s", err) + return + } + + if statusCode != 200 { + color.Red("[x] Invalid GitLab Access Token") + return + } + + // print token info + printTokenInfo(token) + + // get metadata + metadata, err := getMetadata(cfg, key) + if err != nil { + color.Red("[x] Error: %s", err) + return + } + + // print gitlab instance metadata + if metadata.Version != "" { + printMetadata(metadata) + } + + // print token permissions + printTokenPermissions(token) + + // get accessible projects + projects, err := getAccessibleProjects(cfg, key) + if err != nil { + color.Red("[x] Error: %s", err) + return + } + + // print repos accessible + if len(projects) > 0 { + printProjects(projects) + } +} + +func getRemainingTime(t string) string { + targetTime, err := time.Parse("2006-01-02", t) + if err != nil { + return "" + } + + // Get the current time + currentTime := time.Now() + + // Calculate the duration until the target time + durationUntilTarget := targetTime.Sub(currentTime) + durationUntilTarget = durationUntilTarget.Truncate(time.Minute) + + // Print the duration + return fmt.Sprintf("%v", durationUntilTarget) +} + +func printTokenInfo(token AcessTokenJSON) { + color.Green("[!] Valid GitLab Access Token\n\n") + color.Green("Token Name: %s\n", token.Name) + color.Green("Created At: %s\n", token.CreatedAt) + color.Green("Last Used At: %s\n", token.LastUsedAt) + color.Green("Expires At: %s (%v remaining)\n\n", token.ExpiresAt, getRemainingTime(token.ExpiresAt)) + if token.Revoked { + color.Red("Token Revoked: %v\n", token.Revoked) + } +} + +func printMetadata(metadata MetadataJSON) { + color.Green("[i] GitLab Instance Metadata\n") + color.Green("Version: %s\n", metadata.Version) + color.Green("Enterprise: %v\n\n", metadata.Enterprise) +} + +func printTokenPermissions(token AcessTokenJSON) { + color.Green("[i] Token Permissions\n") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Scope", "Access" /* Add more column headers if needed */}) + for _, scope := range token.Scopes { + t.AppendRow([]interface{}{color.GreenString(scope), color.GreenString(gitlab_scopes[scope])}) + } + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 2, WidthMax: 100}, // Limit the width of the third column (Description) to 20 characters + }) + t.Render() +} + +func printProjects(projects []ProjectsJSON) { + color.Green("\n[i] Accessible Projects\n") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Project", "Access Level" /* Add more column headers if needed */}) + for _, project := range projects { + access := access_level_map[project.Permissions.ProjectAccess.AccessLevel] + if project.Permissions.ProjectAccess.AccessLevel == 50 { + access = color.GreenString(access) + } else if project.Permissions.ProjectAccess.AccessLevel >= 30 { + access = color.YellowString(access) + } else { + access = color.RedString(access) + } + t.AppendRow([]interface{}{color.GreenString(project.NameWithNamespace), access}) + } + t.Render() +} diff --git a/pkg/analyzer/analyzers/gitlab/scopes.go b/pkg/analyzer/analyzers/gitlab/scopes.go new file mode 100644 index 000000000..ad8a36ce5 --- /dev/null +++ b/pkg/analyzer/analyzers/gitlab/scopes.go @@ -0,0 +1,28 @@ +package gitlab + +var gitlab_scopes = map[string]string{ + "api": "Grants complete read/write access to the API, including all groups and projects, the container registry, the dependency proxy, and the package registry. Also grants complete read/write access to the registry and repository using Git over HTTP.", + "read_user": "Grants read-only access to the authenticated user’s profile through the /user API endpoint, which includes username, public email, and full name. Also grants access to read-only API endpoints under /users.", + "read_api": "Grants read access to the API, including all groups and projects, the container registry, and the package registry.", + "read_repository": "Grants read-only access to repositories on private projects using Git-over-HTTP or the Repository Files API.", + "write_repository": "Grants read-write access to repositories on private projects using Git-over-HTTP (not using the API).", + "read_registry": "Grants read-only (pull) access to container registry images if a project is private and authorization is required. Available only when the container registry is enabled.", + "write_registry": "Grants read-write (push) access to container registry images if a project is private and authorization is required. Available only when the container registry is enabled.", + "sudo": "Grants permission to perform API actions as any user in the system, when authenticated as an administrator.", + "admin_mode": "Grants permission to perform API actions as an administrator, when Admin Mode is enabled. (Introduced in GitLab 15.8.)", + "create_runner": "Grants permission to create runners.", + "manage_runner": "Grants permission to manage runners.", + "ai_features": "Grants permission to perform API actions for GitLab Duo. This scope is designed to work with the GitLab Duo Plugin for JetBrains. For all other extensions, see scope requirements.", + "k8s_proxy": "Grants permission to perform Kubernetes API calls using the agent for Kubernetes.", + "read_service_ping": "Grant access to download Service Ping payload through the API when authenticated as an admin use. (Introduced in GitLab 16.8.", +} + +var access_level_map = map[int]string{ + 0: "No access", + 5: "Minimal access", + 10: "Guest", + 20: "Reporter", + 30: "Developer", + 40: "Maintainer", + 50: "Owner", +} diff --git a/pkg/analyzer/analyzers/huggingface/huggingface.go b/pkg/analyzer/analyzers/huggingface/huggingface.go new file mode 100644 index 000000000..99066946a --- /dev/null +++ b/pkg/analyzer/analyzers/huggingface/huggingface.go @@ -0,0 +1,409 @@ +package huggingface + +import ( + "encoding/json" + "net/http" + "os" + "strings" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +const ( + FINEGRAINED = "fineGrained" + WRITE = "write" + READ = "read" +) + +// HFTokenJSON is the struct for the HF /whoami-v2 API JSON response +type HFTokenJSON struct { + Username string `json:"name"` + Name string `json:"fullname"` + Orgs []struct { + Name string `json:"name"` + Role string `json:"roleInOrg"` + IsEnterprise bool `json:"isEnterprise"` + } `json:"orgs"` + Auth struct { + AccessToken struct { + Name string `json:"displayName"` + Type string `json:"role"` + CreatedAt string `json:"createdAt"` + FineGrained struct { + Global []string `json:"global"` + Scoped []struct { + Entity struct { + Type string `json:"type"` + Name string `json:"name"` + ID string `json:"_id"` + } `json:"entity"` + Permissions []string `json:"permissions"` + } `json:"scoped"` + } `json:"fineGrained"` + } + } `json:"auth"` +} + +type Permissions struct { + Read bool + Write bool +} + +type Model struct { + Name string `json:"id"` + ID string `json:"_id"` + Private bool `json:"private"` + Permissions Permissions +} + +// getModelsByAuthor calls the HF API /models endpoint with the author query param +// returns a list of models and an error +func getModelsByAuthor(cfg *config.Config, key string, author string) ([]Model, error) { + var modelsJSON []Model + + // create a new request + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", "https://huggingface.co/api/models", nil) + if err != nil { + return modelsJSON, err + } + + // Add bearer token + req.Header.Add("Authorization", "Bearer "+key) + + // Add author param + q := req.URL.Query() + q.Add("author", author) + req.URL.RawQuery = q.Encode() + + // send the request + resp, err := client.Do(req) + if err != nil { + return modelsJSON, err + } + + // defer the response body closing + defer resp.Body.Close() + + // read response + if err := json.NewDecoder(resp.Body).Decode(&modelsJSON); err != nil { + return modelsJSON, err + } + return modelsJSON, nil +} + +// getTokenInfo calls the HF API /whoami-v2 endpoint to get the token info +// returns the token info, a boolean indicating token validity, and an error +func getTokenInfo(cfg *config.Config, key string) (HFTokenJSON, bool, error) { + var tokenJSON HFTokenJSON + + // create a new request + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", "https://huggingface.co/api/whoami-v2", nil) + if err != nil { + return tokenJSON, false, err + } + + // Add bearer token + req.Header.Add("Authorization", "Bearer "+key) + + // send the request + resp, err := client.Do(req) + if err != nil { + return tokenJSON, false, err + } + + // check if the response is 200 + if resp.StatusCode != 200 { + return tokenJSON, false, nil + } + + // defer the response body closing + defer resp.Body.Close() + + // read response + if err := json.NewDecoder(resp.Body).Decode(&tokenJSON); err != nil { + return tokenJSON, true, err + } + return tokenJSON, true, nil +} + +// AnalyzePermissions prints the permissions of a HuggingFace API key +func AnalyzePermissions(cfg *config.Config, key string) { + + // get token info + tokenJSON, success, err := getTokenInfo(cfg, key) + if err != nil { + color.Red("[x] Error: " + err.Error()) + return + } + + // check if the token is valid + if !success { + color.Red("[x] Invalid HuggingFace Access Token") + return + } + color.Green("[!] Valid HuggingFace Access Token\n\n") + + // print user info + color.Yellow("[i] Username: " + tokenJSON.Username) + color.Yellow("[i] Name: " + tokenJSON.Name) + color.Yellow("[i] Token Name: " + tokenJSON.Auth.AccessToken.Name) + color.Yellow("[i] Token Type: " + tokenJSON.Auth.AccessToken.Type) + + // print org info + printOrgs(tokenJSON) + + // get all models by username + var allModels []Model + if userModels, err := getModelsByAuthor(cfg, key, tokenJSON.Username); err == nil { + allModels = append(allModels, userModels...) + } else { + color.Red("[x] Error: " + err.Error()) + return + } + + // get all models from all orgs + for _, org := range tokenJSON.Orgs { + if orgModels, err := getModelsByAuthor(cfg, key, org.Name); err == nil { + allModels = append(allModels, orgModels...) + } else { + color.Red("[x] Error: " + err.Error()) + return + } + } + + // print accessible models + printAccessibleModels(allModels, tokenJSON) + + if tokenJSON.Auth.AccessToken.Type == FINEGRAINED { + // print org permissions + printOrgPermissions(tokenJSON) + + // print user permissions + printUserPermissions(tokenJSON) + } + +} + +// printUserPermissions prints the user permissions +// only applies to fine-grained tokens +func printUserPermissions(tokenJSON HFTokenJSON) { + color.Green("\n[i] User Permissions:") + + // build a map of all user permissions + userPermissions := map[string]struct{}{} + for _, permission := range tokenJSON.Auth.AccessToken.FineGrained.Scoped { + if permission.Entity.Type == "user" { + for _, perm := range permission.Permissions { + userPermissions[perm] = struct{}{} + } + } + } + + // global permissions only apply to user tokens as of 6/6/24 + // but there would be a naming collision in the scopes document + // so we prepend "global." to the key and then add to the map + for _, permission := range tokenJSON.Auth.AccessToken.FineGrained.Global { + userPermissions["global."+permission] = struct{}{} + } + + // check if there are any user permissions + if len(userPermissions) == 0 { + color.Red("\tNo user permissions scoped.") + return + } + + // print the user permissions + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Category", "Permission", "In-Scope"}) + + for _, permission := range user_scopes_order { + t.AppendRow([]interface{}{permission, "---", "---"}) + for key, value := range user_scopes[permission] { + if _, ok := userPermissions[key]; ok { + t.AppendRow([]interface{}{"", color.GreenString(value), color.GreenString("True")}) + } else { + t.AppendRow([]interface{}{"", value, "False"}) + } + } + } + t.Render() +} + +// printOrgPermissions prints the organization permissions +// only applies to fine-grained tokens +func printOrgPermissions(tokenJSON HFTokenJSON) { + color.Green("\n[i] Organization Permissions:") + + // check if there are any org permissions + // if so, save them as a map. Only need to do this once + // even if multiple orgs b/c as of 6/6/24, users can only define one set of scopes + // for all orgs referenced on an access token + orgScoped := false + orgPermissions := map[string]struct{}{} + for _, permission := range tokenJSON.Auth.AccessToken.FineGrained.Scoped { + if permission.Entity.Type == "org" { + orgScoped = true + for _, perm := range permission.Permissions { + orgPermissions[perm] = struct{}{} + } + break + } + } + + // check if there are any org permissions + if !orgScoped { + color.Red("\tNo organization permissions scoped.") + return + } + + // print the org permissions + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Category", "Permission", "In-Scope"}) + + for _, permission := range org_scopes_order { + t.AppendRow([]interface{}{permission, "---", "---"}) + for key, value := range org_scopes[permission] { + if _, ok := orgPermissions[key]; ok { + t.AppendRow([]interface{}{"", color.GreenString(value), color.GreenString("True")}) + } else { + t.AppendRow([]interface{}{"", value, "False"}) + } + } + } + t.Render() +} + +// printOrgs prints the organizations the user is a member of +func printOrgs(tokenJSON HFTokenJSON) { + color.Green("\n[i] Organizations:") + + if len(tokenJSON.Orgs) == 0 { + color.Yellow("\tNo organizations found.") + return + } + + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Name", "Role", "Is Enterprise"}) + for _, org := range tokenJSON.Orgs { + enterprise := "" + role := "" + if org.IsEnterprise { + enterprise = color.New(color.FgGreen).Sprintf("True") + } else { + enterprise = "False" + } + if org.Role == "admin" { + role = color.New(color.FgGreen).Sprintf("Admin") + } else { + role = org.Role + } + t.AppendRow([]interface{}{color.GreenString(org.Name), role, enterprise}) + } + t.Render() +} + +// modelNameLookup is a helper function to lookup model name by _id +func modelNameLookup(models []Model, id string) string { + for _, model := range models { + if model.ID == id { + return model.Name + } + } + return "" +} + +// printAccessibleModels adds permissions as needed to each model +// +// and then calls the printModelsTable function +func printAccessibleModels(allModels []Model, tokenJSON HFTokenJSON) { + color.Green("\n[i] Accessible Models:") + + if tokenJSON.Auth.AccessToken.Type != FINEGRAINED { + // Add Read Privs to All Models + for idx := range allModels { + allModels[idx].Permissions.Read = true + } + // Add Write Privs to All Models if Write Access + if tokenJSON.Auth.AccessToken.Type == WRITE { + for idx := range allModels { + allModels[idx].Permissions.Write = true + } + } + // Print Models Table + printModelsTable(allModels) + return + } + + // finegrained scopes are grouped by org, user or model. + // this section will extract the relevant permissions for each entity and store them in a map + var nameToPermissions = make(map[string]Permissions) + for _, permission := range tokenJSON.Auth.AccessToken.FineGrained.Scoped { + read := false + write := false + for _, perm := range permission.Permissions { + if perm == "repo.content.read" { + read = true + } else if perm == "repo.write" { + write = true + } + } + if permission.Entity.Type == "user" || permission.Entity.Type == "org" { + nameToPermissions[permission.Entity.Name] = Permissions{Read: read, Write: write} + } else if permission.Entity.Type == "model" { + nameToPermissions[modelNameLookup(allModels, permission.Entity.ID)] = Permissions{Read: read, Write: write} + } + } + + // apply permissions to all models + for idx := range allModels { + // get username/orgname for each model and apply those permissions + modelUsername := strings.Split(allModels[idx].Name, "/")[0] + if permissions, ok := nameToPermissions[modelUsername]; ok { + allModels[idx].Permissions = permissions + } + // override model permissions with repo-specific permissions + if permissions, ok := nameToPermissions[allModels[idx].Name]; ok { + allModels[idx].Permissions = permissions + } + } + + // Print Models Table + printModelsTable(allModels) +} + +// printModelsTable prints the models table +func printModelsTable(models []Model) { + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Model", "Private", "Read", "Write"}) + for _, model := range models { + var name, read, write, private string + if model.Permissions.Read { + read = color.New(color.FgGreen).Sprintf("True") + } else { + read = "False" + } + if model.Permissions.Write { + write = color.New(color.FgGreen).Sprintf("True") + } else { + write = "False" + } + if model.Private { + private = color.New(color.FgGreen).Sprintf("True") + name = color.New(color.FgGreen).Sprintf(model.Name) + } else { + private = "False" + name = model.Name + } + t.AppendRow([]interface{}{name, private, read, write}) + } + t.Render() +} diff --git a/pkg/analyzer/analyzers/huggingface/scopes.go b/pkg/analyzer/analyzers/huggingface/scopes.go new file mode 100644 index 000000000..afa36466a --- /dev/null +++ b/pkg/analyzer/analyzers/huggingface/scopes.go @@ -0,0 +1,74 @@ +package huggingface + +//nolint:unused +var repo_scopes = map[string]string{ + "repo.content.read": "Read access to contents", + "discussion.write": "Interact with discussions / Open pull requests", + "repo.write": "Write access to contents/settings", +} + +var org_scopes_order = []string{ + "Repos", + "Collections", + "Inference endpoints", + "Org settings", +} + +var org_scopes = map[string]map[string]string{ + "Repos": { + "repo.content.read": "Read access to contents of all repos", + "discussion.write": "Interact with discussions / Open pull requests on all repos", + "repo.write": "Write access to contents/settings of all repos", + }, + "Collections": { + "collection.read": "Read access to all collections", + "collection.write": "Write access to all collections", + }, + "Inference endpoints": { + "inference.endpoints.infer.write": "Make calls to inference endpoints", + "inference.endpoints.write": "Manage inference endpoints", + }, + "Org settings": { + "org.read": "Read access to organization's settings", + "org.write": "Write access to organization's settings / member management", + }, +} + +var user_scopes_order = []string{ + "Billing", + "Collections", + "Discussions & Posts", + "Inference", + "Repos", + "Webhooks", +} + +var user_scopes = map[string]map[string]string{ + "Billing": { + "user.billing.read": "Read access to user's billing usage", + }, + "Collections": { + "collection.read": "Read access to all ollections under user's namespace", + "collection.write": "Write access to all collections under user's namespace", + }, + "Discussions & Posts": { + // Note: prepending global. to scopes that are nested under "global" in fine-grained permissions JSON + // otherwise they would overlap with user scopes under the "scoped" JSON + "discussion.write": "Interact with discussions / Open pull requests on repos under user's namespace", + "global.discussion.write": "Interact with discussions / Open pull requests on external repos", + "global.post.write": "Interact with posts", + }, + "Inference": { + "global.inference.serverless.write": "Make calls to the serverless Inference API", + "inference.endpoints.infer.write": "Make calls to inference endpoints", + "inference.endpoints.write": "Manage inference endpoints", + }, + "Repos": { + "repo.content.read": "Read access to contents of all repos under user's namespace", + "repo.write": "Write access to contents/settings of all repos under user's namespace", + }, + "Webhooks": { + "user.webhooks.read": "Access webhooks data", + "user.webhooks.write": "Create and manage webhooks", + }, +} diff --git a/pkg/analyzer/analyzers/mailchimp/mailchimp.go b/pkg/analyzer/analyzers/mailchimp/mailchimp.go new file mode 100644 index 000000000..709177b49 --- /dev/null +++ b/pkg/analyzer/analyzers/mailchimp/mailchimp.go @@ -0,0 +1,191 @@ +package mailchimp + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +var BASE_URL = "https://%s.api.mailchimp.com/3.0" + +type MetadataJSON struct { + AccountID string `json:"account_id"` + AccountName string `json:"account_name"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Role string `json:"role"` + MemberSince string `json:"member_since"` + PricingPlan string `json:"pricing_plan_type"` + AccountTimezone string `json:"account_timezone"` + Contact struct { + Company string `json:"company"` + Address1 string `json:"addr1"` + Address2 string `json:"addr2"` + City string `json:"city"` + State string `json:"state"` + Zip string `json:"zip"` + Country string `json:"country"` + } `json:"contact"` + LastLogin string `json:"last_login"` + TotalSubscribers int `json:"total_subscribers"` +} + +type DomainsJSON struct { + Domains []struct { + Domain string `json:"domain"` + Authenticated bool `json:"authenticated"` + Verified bool `json:"verified"` + } `json:"domains"` +} + +func getMetadata(cfg *config.Config, key string) (MetadataJSON, error) { + var metadata MetadataJSON + + // extract datacenter + keySplit := strings.Split(key, "-") + if len(keySplit) != 2 { + return metadata, nil + } + datacenter := keySplit[1] + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", fmt.Sprintf(BASE_URL, datacenter), nil) + if err != nil { + color.Red("[x] Error: %s", err) + return metadata, err + } + + req.SetBasicAuth("anystring", key) + resp, err := client.Do(req) + if err != nil { + color.Red("[x] Error: %s", err) + return metadata, err + } + + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { + color.Red("[x] Error: %s", err) + return metadata, err + } + + return metadata, nil +} + +func getDomains(cfg *config.Config, key string) (DomainsJSON, error) { + var domains DomainsJSON + + // extract datacenter + keySplit := strings.Split(key, "-") + if len(keySplit) != 2 { + return domains, nil + } + datacenter := keySplit[1] + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", fmt.Sprintf(BASE_URL, datacenter)+"/verified-domains", nil) + if err != nil { + color.Red("[x] Error: %s", err) + return domains, err + } + + req.SetBasicAuth("anystring", key) + resp, err := client.Do(req) + if err != nil { + color.Red("[x] Error: %s", err) + return domains, err + } + + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&domains); err != nil { + color.Red("[x] Error: %s", err) + return domains, err + } + + return domains, nil +} + +func AnalyzePermissions(cfg *config.Config, key string) { + // get metadata + metadata, err := getMetadata(cfg, key) + if err != nil { + color.Red("[x] Error: %s", err) + return + } + + // print mailchimp instance metadata + if metadata.AccountID == "" { + color.Red("[x] Invalid Mailchimp API key") + return + } + printMetadata(metadata) + + // print full api key permissions + color.Green("\n[i] Permissions: Full Access\n\n") + + // get sending domains + domains, err := getDomains(cfg, key) + if err != nil { + color.Red("[x] Error: %s", err) + return + } + + // print sending domains + if len(domains.Domains) > 0 { + printDomains(domains) + } else { + color.Yellow("[i] No sending domains found\n") + } + +} + +func printMetadata(metadata MetadataJSON) { + color.Green("[!] Valid Mailchimp API key\n\n") + + // print table with account info + color.Yellow("[i] Mailchimp Account Info:\n") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendRow([]interface{}{("Account Name"), color.GreenString("%s", metadata.AccountName)}) + t.AppendRow([]interface{}{("Company Name"), color.GreenString("%s", metadata.Contact.Company)}) + t.AppendRow([]interface{}{("Address"), color.GreenString("%s %s\n%s, %s %s\n%s", metadata.Contact.Address1, metadata.Contact.Address2, metadata.Contact.City, metadata.Contact.State, metadata.Contact.Zip, metadata.Contact.Country)}) + t.AppendRow([]interface{}{("Total Subscribers"), color.GreenString("%d", metadata.TotalSubscribers)}) + t.Render() + + // print user info + color.Yellow("\n[i] Mailchimp User Info:\n") + t = table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendRow([]interface{}{("User Name"), color.GreenString("%s %s", metadata.FirstName, metadata.LastName)}) + t.AppendRow([]interface{}{("User Email"), color.GreenString("%s", metadata.Email)}) + t.AppendRow([]interface{}{("User Role"), color.GreenString("%s", metadata.Role)}) + t.AppendRow([]interface{}{("Last Login"), color.GreenString("%s", metadata.LastLogin)}) + t.AppendRow([]interface{}{("Member Since"), color.GreenString("%s", metadata.MemberSince)}) + t.Render() +} + +func printDomains(domains DomainsJSON) { + color.Yellow("\n[i] Sending Domains:\n") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Domain", "Enabled and Verified"}) + for _, domain := range domains.Domains { + authenticated := "" + if domain.Authenticated && domain.Verified { + authenticated = color.GreenString("Yes") + } else { + authenticated = color.RedString("No") + } + t.AppendRow([]interface{}{color.GreenString(domain.Domain), authenticated}) + } + t.Render() +} diff --git a/pkg/analyzer/analyzers/mailgun/mailgun.go b/pkg/analyzer/analyzers/mailgun/mailgun.go new file mode 100644 index 000000000..3884c4b76 --- /dev/null +++ b/pkg/analyzer/analyzers/mailgun/mailgun.go @@ -0,0 +1,93 @@ +package mailgun + +import ( + "encoding/json" + "net/http" + "os" + "strconv" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +type Domain struct { + URL string `json:"name"` + IsDisabled bool `json:"is_disabled"` + Type string `json:"type"` + State string `json:"state"` + CreatedAt string `json:"created_at"` +} + +type DomainsJSON struct { + Items []Domain `json:"items"` + TotalCount int `json:"total_count"` +} + +func getDomains(cfg *config.Config, apiKey string) (DomainsJSON, int, error) { + var domainsJSON DomainsJSON + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", "https://api.mailgun.net/v4/domains", nil) + if err != nil { + return domainsJSON, -1, err + } + + req.SetBasicAuth("api", apiKey) + resp, err := client.Do(req) + if err != nil { + return domainsJSON, -1, err + } + + if resp.StatusCode != 200 { + return domainsJSON, resp.StatusCode, nil + } + + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&domainsJSON) + if err != nil { + return domainsJSON, resp.StatusCode, err + } + return domainsJSON, resp.StatusCode, nil +} + +func AnalyzePermissions(cfg *config.Config, apiKey string) { + // Get the domains associated with the API key + domains, statusCode, err := getDomains(cfg, apiKey) + if err != nil { + color.Red("[x] Error getting domains: %s", err) + return + } + + if statusCode != 200 { + color.Red("[x] Invalid Mailgun API key.") + return + } + color.Green("[i] Valid Mailgun API key\n\n") + color.Green("[i] Permissions: Full Access\n\n") + // Print the domains + printDomains(domains) +} + +func printDomains(domains DomainsJSON) { + if domains.TotalCount == 0 { + color.Red("[i] No domains found") + return + } + color.Yellow("[i] Found %d domain(s)", domains.TotalCount) + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Domain", "Type", "State", "Created At", "Disabled"}) + for _, domain := range domains.Items { + if domain.IsDisabled { + t.AppendRow([]interface{}{color.RedString(domain.URL), color.RedString(domain.Type), color.RedString(domain.State), color.RedString(domain.CreatedAt), color.RedString(strconv.FormatBool(domain.IsDisabled))}) + } else if domain.Type == "sandbox" || domain.State == "unverified" { + t.AppendRow([]interface{}{color.YellowString(domain.URL), color.YellowString(domain.Type), color.YellowString(domain.State), color.YellowString(domain.CreatedAt), color.YellowString(strconv.FormatBool(domain.IsDisabled))}) + } else { + t.AppendRow([]interface{}{color.GreenString(domain.URL), color.GreenString(domain.Type), color.GreenString(domain.State), color.GreenString(domain.CreatedAt), color.GreenString(strconv.FormatBool(domain.IsDisabled))}) + } + } + t.Render() +} diff --git a/pkg/analyzer/analyzers/mysql/mysql.go b/pkg/analyzer/analyzers/mysql/mysql.go new file mode 100644 index 000000000..5aa7a888d --- /dev/null +++ b/pkg/analyzer/analyzers/mysql/mysql.go @@ -0,0 +1,775 @@ +package mysql + +import ( + "database/sql" + "fmt" + "os" + "strings" + "time" + + "github.com/dustin/go-humanize" + _ "github.com/go-sql-driver/mysql" + "github.com/jedib0t/go-pretty/text" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/xo/dburl" + + "github.com/fatih/color" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +const ( + // MySQL SSL Modes + mysql_sslmode = "ssl-mode" + mysql_sslmode_disabled = "DISABLED" + mysql_sslmode_preferred = "PREFERRED" + mysql_sslmode_required = "REQUIRED" + mysql_sslmode_verify_ca = "VERIFY_CA" + mysql_sslmode_verify_identity = "VERIFY_IDENTITY" + //https://github.com/go-sql-driver/mysql/issues/899#issuecomment-443493840 + + // MySQL Built-in Databases + mysql_db_sys = "sys" + mysql_db_perf_sch = "performance_schema" + mysql_db_info_sch = "information_schema" + mysql_db_mysql = "mysql" + + mysql_all = "*" +) + +type GlobalPrivs struct { + Privs []string +} + +type Database struct { + Name string + Default bool + Tables *[]Table + Privs []string + Routines *[]Routine + Nonexistent bool +} + +type Table struct { + Name string + Columns []Column + Privs []string + Nonexistent bool + Bytes uint64 +} + +type Column struct { + Name string + Privs []string +} + +type Routine struct { + Name string + Privs []string + Nonexistent bool +} + +// so CURRENT_USER returns `doadmin@%` and not `doadmin@localhost +// USER() returns `doadmin@localhost` + +func AnalyzePermissions(cfg *config.Config, connectionStr string) { + + // ToDo: Add in logging + if cfg.LoggingEnabled { + color.Red("[x] Logging is not supported for this analyzer.") + return + } + + db, err := createConnection(connectionStr) + if err != nil { + color.Red("[!] Error connecting to the MySQL database: %s", err) + return + } + + defer db.Close() + + // Get the current user + user, err := getUser(db) + if err != nil { + color.Red("[!] Error getting the current user: %s", err) + return + } + color.Green("[+] Successfully connected as user: %s", user) + + // Get all accessible databases + var databases = make(map[string]*Database, 0) + err = getDatabases(db, databases) + if err != nil { + color.Red("[!] Error getting databases: %s", err) + return + } + + //Get all accessible tables + err = getTables(db, databases) + if err != nil { + color.Red("[!] Error getting tables: %s", err) + return + } + + // Get all accessible routines + err = getRoutines(db, databases) + if err != nil { + color.Red("[!] Error getting routines: %s", err) + return + } + + // Get user grants + grants, err := getGrants(db) + if err != nil { + color.Red("[!] Error getting user grants: %s", err) + return + } + + var globalPrivs GlobalPrivs + // Process user grants + processGrants(grants, databases, &globalPrivs) + + // Print the results + printResults(databases, globalPrivs, cfg.ShowAll) + + // Build print function, check data, and then review all of the logic. + // Then make sure we have an instance of lal of that logic to actually test. + +} + +func createConnection(connection string) (*sql.DB, error) { + // Check if the connection string starts with 'mysql://' + if !strings.HasPrefix(connection, "mysql://") { + color.Yellow("[i] The connection string should start with 'mysql://'. Adding it for you.") + connection = "mysql://" + connection + } + + // Adapt ssl-mode params to Go MySQL driver + connection, err := fixTLSQueryParam(connection) + if err != nil { + return nil, err + } + + // Parse the connection string + u, err := dburl.Parse(connection) + if err != nil { + return nil, err + } + + // Connect to the MySQL database + db, err := sql.Open("mysql", u.DSN) + if err != nil { + return nil, err + } + + db.SetConnMaxLifetime(time.Minute * 5) + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(10) + + // Check the connection + err = db.Ping() + if err != nil { + if strings.Contains(err.Error(), "certificate signed by unknown authority") { + return nil, fmt.Errorf("%s. try adding 'ssl-mode=PREFERRED' to your connection string", err.Error()) + } + return nil, err + } + + return db, nil +} + +func fixTLSQueryParam(connection string) (string, error) { + // Parse connection string on "?" + parsed := strings.Split(connection, "?") + + // Check if has query parms + if len(parsed) < 2 { + // Add 10s timeout + connection += "?timeout=10s" + return connection, nil + } + + var error error + + // Split parms + querySlice := strings.Split(parsed[1], "&") + + // Check if ssl-mode is present + for i, part := range querySlice { + if strings.HasPrefix(part, "ssl-mode") { + mode := strings.Split(part, "=")[1] + switch mode { + case mysql_sslmode_disabled: + querySlice[i] = "tls=false" + case mysql_sslmode_preferred: + querySlice[i] = "tls=preferred" + case mysql_sslmode_required: + querySlice[i] = "tls=true" + case mysql_sslmode_verify_ca: + error = fmt.Errorf("this implementation does not support VERIFY_CA. try removing it or using ssl-mode=REQUIRED") + // Need to implement --ssl-ca or --ssl-capath + case mysql_sslmode_verify_identity: + error = fmt.Errorf("this implementation does not support VERIFY_IDENTITY. try removing it or using ssl-mode=REQUIRED") + // Need to implement --ssl-ca or --ssl-capath + } + } + } + + // Join the parts back together + newQuerySlice := strings.Join(querySlice, "&") + return (parsed[0] + "?" + newQuerySlice + "&timeout=10s"), error +} + +func getUser(db *sql.DB) (string, error) { + var user string + err := db.QueryRow("SELECT CURRENT_USER()").Scan(&user) + if err != nil { + return "", err + } + return user, nil +} + +func getDatabases(db *sql.DB, databases map[string]*Database) error { + rows, err := db.Query("SHOW DATABASES") + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var dbName string + err = rows.Scan(&dbName) + if err != nil { + return err + } + // check if the database is a built-in database + built_in_db := false + switch dbName { + case mysql_db_sys, mysql_db_perf_sch, mysql_db_info_sch, mysql_db_mysql: + built_in_db = true + } + // add the database to the databases map + newTables := make([]Table, 0) + newRoutines := make([]Routine, 0) + databases[dbName] = &Database{Name: dbName, Default: built_in_db, Tables: &newTables, Routines: &newRoutines} + } + + return nil +} + +func getTables(db *sql.DB, databases map[string]*Database) error { + rows, err := db.Query("SELECT table_schema, table_name, IFNULL(DATA_LENGTH,0) FROM information_schema.tables") + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var dbName string + var tableName string + var tableSize uint64 + err = rows.Scan(&dbName, &tableName, &tableSize) + if err != nil { + return err + } + + // find the database in the databases slice + d := databases[dbName] + *d.Tables = append(*d.Tables, Table{Name: tableName, Bytes: tableSize}) + } + + return nil +} + +func getRoutines(db *sql.DB, databases map[string]*Database) error { + rows, err := db.Query("SELECT routine_schema, routine_name FROM information_schema.routines") + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var dbName string + var routineName string + err = rows.Scan(&dbName, &routineName) + if err != nil { + return err + } + // find the database in the databases slice + d, ok := databases[dbName] + if !ok { + databases[dbName] = &Database{Name: dbName, Default: false, Tables: &[]Table{}, Routines: &[]Routine{}, Nonexistent: true} + d = databases[dbName] + } + + *d.Routines = append(*d.Routines, Routine{Name: routineName}) + } + + return nil +} + +func getGrants(db *sql.DB) ([]string, error) { + rows, err := db.Query("SHOW GRANTS") + if err != nil { + return nil, err + } + defer rows.Close() + + var grants []string + for rows.Next() { + var grant string + err = rows.Scan(&grant) + if err != nil { + return nil, err + } + grants = append(grants, grant) + } + + return grants, nil +} + +// ToDo: Deal with these GRANT/REVOKE statements +// GRANT SELECT (col1), INSERT (col1, col2) ON mydb.mytbl TO 'someuser'@'somehost'; +// GRANT PROXY ON 'localuser'@'localhost' TO 'externaluser'@'somehost'; +// GRANT 'role1', 'role2' TO 'user1'@'localhost', 'user2'@'localhost'; + +// What are the default privs on information_schema and performance_Schema? +// Seems table by table...maybe just put "Not Implemented" and leave this to be a show_all option. + +// Note: Can't GRANT on a table that doesn't exist, but DB is fine. + +// processGrants processes the grants and adds them to the databases structs and globalPrivs +func processGrants(grants []string, databases map[string]*Database, globalPrivs *GlobalPrivs) { + for _, grant := range grants { + // GRANTs on non-existent databases are valid, but we need that object to exist in "databases" for processGrant(). + db := parseDBFromGrant(grant) + if db == mysql_all { + continue + } + _, ok := databases[db] + if !ok { + databases[db] = &Database{Name: db, Default: false, Tables: &[]Table{}, Routines: &[]Routine{}, Nonexistent: true} + } + } + for _, grant := range grants { + processGrant(grant, databases, globalPrivs) + } +} + +func processGrant(grant string, databases map[string]*Database, globalPrivs *GlobalPrivs) { + isGrant := strings.HasPrefix(grant, "GRANT") + //hasGrantOption := strings.HasSuffix(grant, "WITH GRANT OPTION") + + // remove GRANT or REVOKE + grant = strings.TrimPrefix(grant, "GRANT") + grant = strings.TrimPrefix(grant, "REVOKE") + + // Split on " ON " + parts := strings.Split(grant, " ON ") + if len(parts) < 2 { + color.Red("[!] Error processing grant: %s", grant) + return + } + + // Put privs in a slice + privs := strings.Split(parts[0], ",") + for i, priv := range privs { + privs[i] = strings.Trim(priv, " ") + } + + // Get DB and Table + dbName := strings.Trim(strings.Split(parts[1], " TO ")[0], " ") + if dbName == parts[1] { + dbName = strings.Trim(strings.Split(parts[1], " FROM ")[0], " ") + } + + // Find the database in the databases slice + // Note: table may not exist yet OR may be a routine + dbTableParts := strings.Split(dbName, ".") + db := strings.Trim(dbTableParts[0], "\"`") + table := strings.Trim(dbTableParts[1], "\"`") + + // dont' forget to deal with revoking db-level privs + + if db == mysql_all { + // Deal with "ALL" and "ALL PRIVILEGES" + switch privs[0] { + case "ALL", "ALL PRIVILEGES": + addRemoveAllPrivs(databases, globalPrivs, isGrant) + default: + for _, priv := range privs { + addRemoveOnePrivOnAll(databases, globalPrivs, priv, isGrant) + } + } + } else { + + // Check if the privs are for a routine + isRoutine := checkIsRoutine(privs) + if isRoutine { + db = strings.TrimPrefix(db, "PROCEDURE `") + db = strings.TrimSuffix(db, "`") + } + d := databases[db] + + switch { + case table == mysql_all: + filteredDBPrivs := filterDBPrivs(privs) + filteredTablePrivs := filterTablePrivs(privs) + d.Privs = addRemovePrivs(d.Privs, filteredDBPrivs, isGrant) + for i, t := range *d.Tables { + (*d.Tables)[i].Privs = addRemovePrivs(t.Privs, filteredTablePrivs, isGrant) + } + case isRoutine: + var idx = getRoutineIndex(d, table) + if idx == -1 { + *d.Routines = append(*d.Routines, Routine{Name: table, Nonexistent: true}) + idx = len(*d.Routines) - 1 + } + (*d.Routines)[idx].Privs = addRemovePrivs((*d.Routines)[idx].Privs, privs, isGrant) + default: + var idx = getTableIndex(d, table) + if idx == -1 { + *d.Tables = append(*d.Tables, Table{Name: table, Nonexistent: true, Bytes: 0}) + idx = len(*d.Tables) - 1 + } + (*d.Tables)[idx].Privs = addRemovePrivs((*d.Tables)[idx].Privs, privs, isGrant) + } + } +} + +func parseDBFromGrant(grant string) string { + // Split on " ON " + parts := strings.Split(grant, " ON ") + if len(parts) < 2 { + color.Red("[!] Error processing grant: %s", grant) + return "" + } + + // Get DB and Table + dbName := strings.Trim(strings.Split(parts[1], " TO ")[0], " ") + if dbName == parts[1] { + dbName = strings.Trim(strings.Split(parts[1], " FROM ")[0], " ") + } + dbTableParts := strings.Split(dbName, ".") + db := strings.Trim(dbTableParts[0], "\"`") + db = strings.TrimPrefix(db, "PROCEDURE `") + db = strings.TrimSuffix(db, "`") + return db +} + +func filterDBPrivs(privs []string) []string { + filtered := make([]string, 0) + for _, priv := range privs { + if SCOPES[priv].Database { + filtered = append(filtered, priv) + } + } + return filtered +} + +func filterTablePrivs(privs []string) []string { + filtered := make([]string, 0) + for _, priv := range privs { + if SCOPES[priv].Table { + filtered = append(filtered, priv) + } + } + return filtered +} + +func addRemoveOnePrivOnAll(databases map[string]*Database, globalPrivs *GlobalPrivs, priv string, isGrant bool) { + scope, ok := SCOPES[priv] + if !ok { + color.Red("[!] Error processing grant: privilege doesn't exist in our MySQL (%s)", priv) + return + } + + slicedPriv := []string{priv} + + // Add priv to globalPrivs + if scope.Global { + globalPrivs.Privs = addRemovePrivs(globalPrivs.Privs, slicedPriv, isGrant) + } + + // Add/Remove priv to all databases + if scope.Database { + for _, d := range databases { + if d.Name == "information_schema" || d.Name == "performance_schema" { + continue + } + d.Privs = addRemovePrivs(d.Privs, slicedPriv, isGrant) + } + } + + // Add/Remove priv to all tables + if scope.Table { + for _, d := range databases { + for i, t := range *d.Tables { + (*d.Tables)[i].Privs = addRemovePrivs(t.Privs, slicedPriv, isGrant) + } + } + } + + // Add/Remove priv to all routines + if scope.Routine { + for _, d := range databases { + for i, r := range *d.Routines { + (*d.Routines)[i].Privs = addRemovePrivs(r.Privs, slicedPriv, isGrant) + } + } + } +} + +func addRemoveAllPrivs(databases map[string]*Database, globalPrivs *GlobalPrivs, isGrant bool) { + // Add all privs to globalPrivs + globalAllPrivs := getGlobalAllPrivileges() + globalPrivs.Privs = addRemovePrivs(globalPrivs.Privs, globalAllPrivs, isGrant) + + // Get DB, Table and Routine Privs + dbAllPrivs := getDBAllPrivs() + tableAllPrivs := getTableAllPrivs() + routineAllPrivs := getRoutineAllPrivs() + + // Add all privs to all databases and tables and routines + for _, d := range databases { + if d.Name == "information_schema" || d.Name == "performance_schema" { + continue + } + // Add DB-level privs + d.Privs = addRemovePrivs(d.Privs, dbAllPrivs, isGrant) + + // Add Table-level privs + for i, t := range *d.Tables { + (*d.Tables)[i].Privs = addRemovePrivs(t.Privs, tableAllPrivs, isGrant) + } + + // Add Routine-level privs + for i, r := range *d.Routines { + (*d.Routines)[i].Privs = addRemovePrivs(r.Privs, routineAllPrivs, isGrant) + } + } +} + +func getGlobalAllPrivileges() []string { + privs := make([]string, 0) + for priv, scope := range SCOPES { + if scope.Global && !scope.Dynamic && priv != "USAGE" && priv != "GRANT OPTION" { + privs = append(privs, priv) + } + } + return privs +} + +func getDBAllPrivs() []string { + privs := make([]string, 0) + for priv, scope := range SCOPES { + if scope.Database && !scope.Dynamic && priv != "USAGE" && priv != "GRANT OPTION" { + privs = append(privs, priv) + } + } + return privs +} + +func getTableAllPrivs() []string { + privs := make([]string, 0) + for priv, scope := range SCOPES { + if scope.Table && !scope.Dynamic && priv != "USAGE" && priv != "GRANT OPTION" { + privs = append(privs, priv) + } + } + return privs +} + +func getRoutineAllPrivs() []string { + privs := make([]string, 0) + for priv, scope := range SCOPES { + if scope.Routine && !scope.Dynamic && priv != "USAGE" && priv != "GRANT OPTION" { + privs = append(privs, priv) + } + } + return privs +} + +func checkIsRoutine(privs []string) bool { + if len(privs) > 0 { + return SCOPES[privs[0]].Routine + } + return false +} + +func getTableIndex(d *Database, tableName string) int { + for i, t := range *d.Tables { + if t.Name == tableName { + return i + } + } + return -1 +} + +func getRoutineIndex(d *Database, routineName string) int { + for i, r := range *d.Routines { + if r.Name == routineName { + return i + } + } + return -1 +} + +func addRemovePrivs(currentPrivs []string, privsToAddRemove []string, add bool) []string { + newPrivs := make([]string, 0) + if add { + newPrivs = append(currentPrivs, privsToAddRemove...) + return newPrivs + } + for _, p := range currentPrivs { + found := false + for _, p2 := range privsToAddRemove { + if p == p2 { + found = true + break + } + } + if !found { + newPrivs = append(newPrivs, p) + } + } + return newPrivs +} + +func printResults(databases map[string]*Database, globalPrivs GlobalPrivs, showAll bool) { + // Print Global Privileges + printGlobalPrivs(globalPrivs) + // Print Database and Table Privileges + printDBTablePrivs(databases, showAll) + // Print Routine Privileges + printRoutinePrivs(databases, showAll) +} + +func printGlobalPrivs(globalPrivs GlobalPrivs) { + // Prep table writer + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Global Privileges"}) + + // Print global privs + globalPrivsStr := "" + for _, priv := range globalPrivs.Privs { + globalPrivsStr += priv + ", " + } + // Clean up privs string + globalPrivsStr = cleanPrivStr(globalPrivsStr) + + // Add rows of priv string data + t.AppendRow([]interface{}{analyzers.GreenWriter(text.WrapSoft(globalPrivsStr, 100))}) + t.Render() +} + +func printDBTablePrivs(databases map[string]*Database, showAll bool) { + // Prep table writer + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Database", "Table", "Privileges", "Est. Size"}) + + // Print database privs + for _, d := range databases { + if isBuiltIn(d.Name) && !showAll { + continue + } + + // Add privileges to db or table privs strings + dbPrivsStr := "" + dbTablesStr := "" + for _, priv := range d.Privs { + scope := SCOPES[priv] + if scope.Database && scope.Table { + dbTablesStr += priv + ", " + } else { + dbPrivsStr += priv + ", " + } + } + + // Clean up privs strings + dbPrivsStr = cleanPrivStr(dbPrivsStr) + dbTablesStr = cleanPrivStr(dbTablesStr) + + // Prep String colors + var dbName string + var writer func(a ...interface{}) string + if d.Default { + dbName = d.Name + " (built-in)" + writer = analyzers.YellowWriter + } else if d.Nonexistent { + dbName = d.Name + " (nonexistent)" + writer = analyzers.RedWriter + } else { + dbName = d.Name + writer = analyzers.GreenWriter + } + + // Prep Priv Strings + + // Add rows of priv string data + t.AppendRow([]interface{}{writer(dbName), writer(""), writer(text.WrapSoft(dbPrivsStr, 80)), writer("-")}) + t.AppendRow([]interface{}{"", writer(""), writer(text.WrapSoft(dbTablesStr, 80)), writer("-")}) + + // Print table privs + for _, t2 := range *d.Tables { + tablePrivsStr := "" + for _, priv := range t2.Privs { + tablePrivsStr += priv + ", " + } + tablePrivsStr = cleanPrivStr(tablePrivsStr) + t.AppendRow([]interface{}{"", writer(t2.Name), writer(text.WrapSoft(tablePrivsStr, 80)), writer(humanize.Bytes(t2.Bytes))}) + } + // Add a separator between databases + t.AppendSeparator() + } + t.Render() +} + +func printRoutinePrivs(databases map[string]*Database, showAll bool) { + // Print routine privs + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Database", "Routine", "Privileges"}) + + // Add rows of priv string data + for _, d := range databases { + if isBuiltIn(d.Name) && !showAll { + continue + } + for _, r := range *d.Routines { + routinePrivsStr := "" + for _, priv := range r.Privs { + routinePrivsStr += priv + ", " + } + routinePrivsStr = cleanPrivStr(routinePrivsStr) + var writer func(a ...interface{}) string + switch d.Name { + case mysql_db_info_sch, mysql_db_perf_sch, mysql_db_sys, mysql_db_mysql: + writer = analyzers.YellowWriter + default: + writer = analyzers.GreenWriter + } + t.AppendRow([]interface{}{writer(d.Name), writer(r.Name), writer(text.WrapSoft(routinePrivsStr, 80))}) + } + } + t.Render() +} + +func cleanPrivStr(priv string) string { + priv = strings.TrimSuffix(priv, ", ") + if priv == "" { + priv = "-" + } + return priv +} + +func isBuiltIn(dbName string) bool { + switch dbName { + case mysql_db_sys, mysql_db_perf_sch, mysql_db_info_sch, mysql_db_mysql: + return true + } + return false +} diff --git a/pkg/analyzer/analyzers/mysql/scopes.go b/pkg/analyzer/analyzers/mysql/scopes.go new file mode 100644 index 000000000..012e147be --- /dev/null +++ b/pkg/analyzer/analyzers/mysql/scopes.go @@ -0,0 +1,99 @@ +package mysql + +type PrivTypes struct { + Global bool + Database bool + Table bool + Column bool + Routine bool + Proxy bool + Dynamic bool +} + +// https://dev.mysql.com/doc/refman/8.0/en/grant.html#grant-global-privileges:~:text=%27localhost%27%3B-,Privileges%20Supported%20by%20MySQL,-The%20following%20tables +var SCOPES = map[string]PrivTypes{ + // Static privs + "ALTER": {Global: true, Database: true, Table: true}, + "ALTER ROUTINE": {Global: true, Database: true, Routine: true}, + "CREATE": {Global: true, Database: true, Table: true}, + "CREATE ROLE": {Global: true}, + "CREATE ROUTINE": {Global: true, Database: true}, + "CREATE TABLESPACE": {Global: true}, + "CREATE TEMPORARY TABLES": {Global: true, Database: true}, + "CREATE USER": {Global: true}, + "CREATE VIEW": {Global: true, Database: true, Table: true}, + "DELETE": {Global: true, Database: true, Table: true}, + "DROP": {Global: true, Database: true, Table: true}, + "DROP ROLE": {Global: true}, + "EVENT": {Global: true, Database: true}, + "EXECUTE": {Global: true, Database: true, Routine: true}, + "FILE": {Global: true}, + "GRANT OPTION": {Global: true, Database: true, Table: true, Routine: true, Proxy: true}, // Not granted on ALL PRIVILEGES + "INDEX": {Global: true, Database: true, Table: true}, + "INSERT": {Global: true, Database: true, Table: true, Column: true}, + "LOCK TABLES": {Global: true, Database: true}, + "PROCESS": {Global: true}, + "PROXY": {Proxy: true}, // Not granted on ALL PRIVILEGES + "REFERENCES": {Global: true, Database: true, Table: true, Column: true}, + "RELOAD": {Global: true}, + "REPLICATION CLIENT": {Global: true}, + "REPLICATION SLAVE": {Global: true}, + "SELECT": {Global: true, Database: true, Table: true, Column: true}, + "SHOW DATABASES": {Global: true}, + "SHOW VIEW": {Global: true, Database: true, Table: true}, + "SHUTDOWN": {Global: true}, + "SUPER": {Global: true}, + "TRIGGER": {Global: true, Database: true, Table: true}, + "UPDATE": {Global: true, Database: true, Table: true, Column: true}, + + // This is a special case, it's not a real privilege + "USAGE": {Global: true, Database: true, Table: true, Column: true, Routine: true}, + + // Dynamic privs + "ALLOW_NONEXISTENT_DEFINER": {Global: true, Dynamic: true}, + "APPLICATION_PASSWORD_ADMIN": {Global: true, Dynamic: true}, + "AUDIT_ABORT_EXEMPT": {Global: true, Dynamic: true}, + "AUDIT_ADMIN": {Global: true, Dynamic: true}, + "AUTHENTICATION_POLICY_ADMIN": {Global: true, Dynamic: true}, + "BACKUP_ADMIN": {Global: true, Dynamic: true}, + "BINLOG_ADMIN": {Global: true, Dynamic: true}, + "BINLOG_ENCRYPTION_ADMIN": {Global: true, Dynamic: true}, + "CLONE_ADMIN": {Global: true, Dynamic: true}, + "CONNECTION_ADMIN": {Global: true, Dynamic: true}, + "ENCRYPTION_KEY_ADMIN": {Global: true, Dynamic: true}, + "FIREWALL_ADMIN": {Global: true, Dynamic: true}, + "FIREWALL_EXEMPT": {Global: true, Dynamic: true}, + "FIREWALL_USER": {Global: true, Dynamic: true}, + "FLUSH_OPTIMIZER_COSTS": {Global: true, Dynamic: true}, + "FLUSH_STATUS": {Global: true, Dynamic: true}, + "FLUSH_TABLES": {Global: true, Dynamic: true}, + "FLUSH_USER_RESOURCES": {Global: true, Dynamic: true}, + "GROUP_REPLICATION_ADMIN": {Global: true, Dynamic: true}, + "GROUP_REPLICATION_STREAM": {Global: true, Dynamic: true}, + "INNODB_REDO_LOG_ARCHIVE": {Global: true, Dynamic: true}, + "INNODB_REDO_LOG_ENABLE": {Global: true, Dynamic: true}, + "MASKING_DICTIONARIES_ADMIN": {Global: true, Dynamic: true}, + "NDB_STORED_USER": {Global: true, Dynamic: true}, + "PASSWORDLESS_USER_ADMIN": {Global: true, Dynamic: true}, + "PERSIST_RO_VARIABLES_ADMIN": {Global: true, Dynamic: true}, + "REPLICATION_APPLIER": {Global: true, Dynamic: true}, + "REPLICATION_SLAVE_ADMIN": {Global: true, Dynamic: true}, + "RESOURCE_GROUP_ADMIN": {Global: true, Dynamic: true}, + "RESOURCE_GROUP_USER": {Global: true, Dynamic: true}, + "ROLE_ADMIN": {Global: true, Dynamic: true}, + "SENSITIVE_VARIABLES_OBSERVER": {Global: true, Dynamic: true}, + "SERVICE_CONNECTION_ADMIN": {Global: true, Dynamic: true}, + "SESSION_VARIABLES_ADMIN": {Global: true, Dynamic: true}, + "SET_ANY_DEFINER": {Global: true, Dynamic: true}, + "SET_USER_ID": {Global: true, Dynamic: true}, + "SHOW_ROUTINE": {Global: true, Dynamic: true}, + "SKIP_QUERY_REWRITE": {Global: true, Dynamic: true}, + "SYSTEM_USER": {Global: true, Dynamic: true}, + "SYSTEM_VARIABLES_ADMIN": {Global: true, Dynamic: true}, + "TABLE_ENCRYPTION_ADMIN": {Global: true, Dynamic: true}, + "TELEMETRY_LOG_ADMIN": {Global: true, Dynamic: true}, + "TP_CONNECTION_ADMIN": {Global: true, Dynamic: true}, + "TRANSACTION_GTID_TAG": {Global: true, Dynamic: true}, + "VERSION_TOKEN_ADMIN": {Global: true, Dynamic: true}, + "XA_RECOVER_ADMIN": {Global: true, Dynamic: true}, +} diff --git a/pkg/analyzer/analyzers/openai/openai.go b/pkg/analyzer/analyzers/openai/openai.go new file mode 100644 index 000000000..a73e831b1 --- /dev/null +++ b/pkg/analyzer/analyzers/openai/openai.go @@ -0,0 +1,204 @@ +package openai + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +const ( + BASE_URL = "https://api.openai.com" + ORGS_ENDPOINT = "/v1/organizations" + ME_ENDPOINT = "/v1/me" +) + +type MeJSON struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone_number"` + MfaEnabled bool `json:"mfa_flag_enabled"` + Orgs struct { + Data []struct { + Title string `json:"title"` + } `json:"data"` + } `json:"orgs"` +} + +var POST_PAYLOAD = map[string]interface{}{"speed": 1} + +// AnalyzePermissions will analyze the permissions of an OpenAI API key +func AnalyzePermissions(cfg *config.Config, key string) { + if meJSON, err := getUserData(cfg, key); err != nil { + color.Red("[x]" + err.Error()) + return + } else { + printUserData(meJSON) + } + + if isAdmin, err := checkAdminKey(cfg, key); isAdmin { + color.Green("[!] Admin API Key. All permissions available.") + return + } else if err != nil { + color.Red("[x]" + err.Error()) + return + } else { + color.Yellow("[!] Restricted API Key. Limited permissions available.") + if err := analyzeScopes(key); err != nil { + color.Red("[x]" + err.Error()) + return + } + printPermissions(cfg.ShowAll) + } + +} + +func analyzeScopes(key string) error { + for _, scope := range SCOPES { + if err := scope.RunTests(key); err != nil { + return err + } + } + return nil +} + +func openAIRequest(cfg *config.Config, method string, url string, key string, data map[string]interface{}) ([]byte, *http.Response, error) { + var inBody io.Reader + if data != nil { + jsonData, err := json.Marshal(data) + if err != nil { + return nil, nil, err + } + inBody = bytes.NewBuffer(jsonData) + } + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest(method, url, inBody) + if err != nil { + return nil, nil, err + } + req.Header.Add("Authorization", "Bearer "+key) + req.Header.Add("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, nil, err + } + + defer resp.Body.Close() + + outBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + + return outBody, resp, nil +} + +func checkAdminKey(cfg *config.Config, key string) (bool, error) { + // Check for all permissions + //nolint:bodyclose + _, resp, err := openAIRequest(cfg, "GET", BASE_URL+ORGS_ENDPOINT, key, nil) + if err != nil { + return false, err + } + switch resp.StatusCode { + case 200: + return true, nil + case 403: + return false, nil + default: + return false, err + } +} + +func getUserData(cfg *config.Config, key string) (MeJSON, error) { + var meJSON MeJSON + //nolint:bodyclose + me, resp, err := openAIRequest(cfg, "GET", BASE_URL+ME_ENDPOINT, key, nil) + if err != nil { + return meJSON, err + } + + if resp.StatusCode != 200 { + return meJSON, fmt.Errorf("Invalid OpenAI Token") + } + color.Green("[!] Valid OpenAI Token\n\n") + + // Marshall me into meJSON struct + if err := json.Unmarshal(me, &meJSON); err != nil { + return meJSON, err + } + return meJSON, nil +} + +func printUserData(meJSON MeJSON) { + color.Green("[i] User: %v", meJSON.Name) + color.Green("[i] Email: %v", meJSON.Email) + color.Green("[i] Phone: %v", meJSON.Phone) + color.Green("[i] MFA Enabled: %v", meJSON.MfaEnabled) + + if len(meJSON.Orgs.Data) > 0 { + color.Green("[i] Organizations:") + for _, org := range meJSON.Orgs.Data { + color.Green(" - %v", org.Title) + } + } + fmt.Print("\n\n") +} + +func stringifyPermissionStatus(tests []analyzers.HttpStatusTest) analyzers.PermissionType { + readStatus := false + writeStatus := false + errors := false + for _, test := range tests { + if test.Type == analyzers.READ { + readStatus = test.Status.Value + } else if test.Type == analyzers.WRITE { + writeStatus = test.Status.Value + } + if test.Status.IsError { + errors = true + } + } + if errors { + return analyzers.ERROR + } + if readStatus && writeStatus { + return analyzers.READ_WRITE + } else if readStatus { + return analyzers.READ + } else if writeStatus { + return analyzers.WRITE + } else { + return analyzers.NONE + } +} + +func printPermissions(show_all bool) { + fmt.Print("\n\n") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Scope", "Endpoints", "Permission"}) + + for _, scope := range SCOPES { + status := stringifyPermissionStatus(scope.Tests) + writer := analyzers.GetWriterFromStatus(status) + if show_all || status != analyzers.NONE { + t.AppendRow([]interface{}{writer(scope.Name), writer(scope.Endpoints[0]), writer(status)}) + for i := 1; i < len(scope.Endpoints); i++ { + t.AppendRow([]interface{}{"", writer(scope.Endpoints[i]), writer(status)}) + } + } + } + t.Render() + fmt.Print("\n\n") +} diff --git a/pkg/analyzer/analyzers/openai/scopes.go b/pkg/analyzer/analyzers/openai/scopes.go new file mode 100644 index 000000000..b76eaabc3 --- /dev/null +++ b/pkg/analyzer/analyzers/openai/scopes.go @@ -0,0 +1,72 @@ +package openai + +import "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + +type OpenAIScope struct { + Name string + Tests []analyzers.HttpStatusTest + Endpoints []string +} + +func (s *OpenAIScope) RunTests(key string) error { + headers := map[string]string{ + "Authorization": "Bearer " + key, + "Content-Type": "application/json", + } + for i := range s.Tests { + test := &s.Tests[i] + if err := test.RunTest(headers); err != nil { + return err + } + } + return nil +} + +var SCOPES = []OpenAIScope{ + { + Name: "Models", + Tests: []analyzers.HttpStatusTest{ + {URL: BASE_URL + "/v1/models", Method: "GET", Valid: []int{200}, Invalid: []int{403}, Type: analyzers.READ, Status: analyzers.PermissionStatus{}}, + }, + Endpoints: []string{"/v1/models"}, + }, + { + Name: "Model capabilities", + Tests: []analyzers.HttpStatusTest{ + {URL: BASE_URL + "/v1/images/generations", Method: "POST", Payload: POST_PAYLOAD, Valid: []int{400}, Invalid: []int{401}, Type: analyzers.WRITE, Status: analyzers.PermissionStatus{}}, + }, + Endpoints: []string{"/v1/audio", "/v1/chat/completions", "/v1/embeddings", "/v1/images", "/v1/moderations"}, + }, + { + Name: "Assistants", + Tests: []analyzers.HttpStatusTest{ + {URL: BASE_URL + "/v1/assistants", Method: "GET", Valid: []int{400}, Invalid: []int{401}, Type: analyzers.READ, Status: analyzers.PermissionStatus{}}, + {URL: BASE_URL + "/v1/assistants", Method: "POST", Payload: POST_PAYLOAD, Valid: []int{400}, Invalid: []int{401}, Type: analyzers.WRITE, Status: analyzers.PermissionStatus{}}, + }, + Endpoints: []string{"/v1/assistants"}, + }, + { + Name: "Threads", + Tests: []analyzers.HttpStatusTest{ + {URL: BASE_URL + "/v1/threads/1", Method: "GET", Valid: []int{400}, Invalid: []int{401}, Type: analyzers.READ, Status: analyzers.PermissionStatus{}}, + {URL: BASE_URL + "/v1/threads", Method: "POST", Payload: POST_PAYLOAD, Valid: []int{400}, Invalid: []int{401}, Type: analyzers.WRITE, Status: analyzers.PermissionStatus{}}, + }, + Endpoints: []string{"/v1/threads"}, + }, + { + Name: "Fine-tuning", + Tests: []analyzers.HttpStatusTest{ + {URL: BASE_URL + "/v1/fine_tuning/jobs", Method: "GET", Valid: []int{200}, Invalid: []int{401}, Type: analyzers.READ, Status: analyzers.PermissionStatus{}}, + {URL: BASE_URL + "/v1/fine_tuning/jobs", Method: "POST", Payload: POST_PAYLOAD, Valid: []int{400}, Invalid: []int{401}, Type: analyzers.WRITE, Status: analyzers.PermissionStatus{}}, + }, + Endpoints: []string{"/v1/fine_tuning"}, + }, + { + Name: "Files", + Tests: []analyzers.HttpStatusTest{ + {URL: BASE_URL + "/v1/files", Method: "GET", Valid: []int{200}, Invalid: []int{401}, Type: analyzers.READ, Status: analyzers.PermissionStatus{}}, + {URL: BASE_URL + "/v1/files", Method: "POST", Payload: POST_PAYLOAD, Valid: []int{415}, Invalid: []int{401}, Type: analyzers.WRITE, Status: analyzers.PermissionStatus{}}, + }, + Endpoints: []string{"/v1/files"}, + }, +} diff --git a/pkg/analyzer/analyzers/opsgenie/opsgenie.go b/pkg/analyzer/analyzers/opsgenie/opsgenie.go new file mode 100644 index 000000000..d6020e628 --- /dev/null +++ b/pkg/analyzer/analyzers/opsgenie/opsgenie.go @@ -0,0 +1,205 @@ +package opsgenie + +import ( + "bytes" + _ "embed" + "encoding/json" + "errors" + "io" + "net/http" + "os" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +//go:embed scopes.json +var scopesConfig []byte + +type User struct { + FullName string `json:"fullName"` + Username string `json:"username"` + Role struct { + Name string `json:"name"` + } `json:"role"` +} + +type UsersJSON struct { + Users []User `json:"data"` +} + +type HttpStatusTest struct { + Endpoint string `json:"endpoint"` + Method string `json:"method"` + Payload interface{} `json:"payload"` + ValidStatuses []int `json:"valid_status_code"` + InvalidStatuses []int `json:"invalid_status_code"` +} + +func StatusContains(status int, vals []int) bool { + for _, v := range vals { + if status == v { + return true + } + } + return false +} + +func (h *HttpStatusTest) RunTest(cfg *config.Config, headers map[string]string) (bool, error) { + // If body data, marshal to JSON + var data io.Reader + if h.Payload != nil { + jsonData, err := json.Marshal(h.Payload) + if err != nil { + return false, err + } + data = bytes.NewBuffer(jsonData) + } + + // Create new HTTP request + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest(h.Method, h.Endpoint, data) + if err != nil { + return false, err + } + + // Add custom headers if provided + for key, value := range headers { + req.Header.Set(key, value) + } + + // Execute HTTP Request + resp, err := client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + // Check response status code + switch { + case StatusContains(resp.StatusCode, h.ValidStatuses): + return true, nil + case StatusContains(resp.StatusCode, h.InvalidStatuses): + return false, nil + default: + return false, errors.New("error checking response status code") + } +} + +type Scope struct { + Name string `json:"name"` + HttpTest HttpStatusTest `json:"test"` +} + +func readInScopes() ([]Scope, error) { + var scopes []Scope + if err := json.Unmarshal(scopesConfig, &scopes); err != nil { + return nil, err + } + + return scopes, nil +} + +func checkPermissions(cfg *config.Config, key string) []string { + scopes, err := readInScopes() + if err != nil { + color.Red("[x] Error reading in scopes: %s", err.Error()) + return nil + } + + permissions := make([]string, 0) + for _, scope := range scopes { + status, err := scope.HttpTest.RunTest(cfg, map[string]string{"Authorization": "GenieKey " + key}) + if err != nil { + color.Red("[x] Error running test: %s", err.Error()) + return nil + } + if status { + permissions = append(permissions, scope.Name) + } + } + + return permissions +} + +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +func getUserList(cfg *config.Config, key string) ([]User, error) { + // Create new HTTP request + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", "https://api.opsgenie.com/v2/users", nil) + if err != nil { + return nil, err + } + + // Add custom headers if provided + req.Header.Set("Authorization", "GenieKey "+key) + + // Execute HTTP Request + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Decode response body + var userList UsersJSON + err = json.NewDecoder(resp.Body).Decode(&userList) + if err != nil { + return nil, err + } + + return userList.Users, nil +} + +func AnalyzePermissions(cfg *config.Config, key string) { + permissions := checkPermissions(cfg, key) + if len(permissions) == 0 { + color.Red("[x] Invalid OpsGenie API key") + return + } + color.Green("[!] Valid OpsGenie API key\n\n") + printPermissions(permissions) + + if contains(permissions, "Configuration Access") { + users, err := getUserList(cfg, key) + if err != nil { + color.Red("[x] Error getting user list: %s", err.Error()) + return + } + printUsers(users) + } + + color.Yellow("\n[i] Expires: Never") +} + +func printPermissions(permissions []string) { + color.Yellow("[i] Permissions:") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Permission"}) + for _, permission := range permissions { + t.AppendRow(table.Row{color.GreenString(permission)}) + } + t.Render() +} + +func printUsers(users []User) { + color.Green("\n[i] Users:") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Name", "Username", "Role"}) + for _, user := range users { + t.AppendRow(table.Row{color.GreenString(user.FullName), color.GreenString(user.Username), color.GreenString(user.Role.Name)}) + } + t.Render() +} diff --git a/pkg/analyzer/analyzers/opsgenie/scopes.json b/pkg/analyzer/analyzers/opsgenie/scopes.json new file mode 100644 index 000000000..65a7aad5c --- /dev/null +++ b/pkg/analyzer/analyzers/opsgenie/scopes.json @@ -0,0 +1,38 @@ +[ + { + "name": "Configuration Access", + "test": { + "endpoint": "https://api.opsgenie.com/v2/account", + "method": "GET", + "valid_status_code": [200], + "invalid_status_code": [403] + } + }, + { + "name": "Read", + "test": { + "endpoint": "https://api.opsgenie.com/v2/alerts", + "method": "GET", + "valid_status_code": [200], + "invalid_status_code": [403] + } + }, + { + "name": "Delete", + "test": { + "endpoint": "https://api.opsgenie.com/v2/alerts/`nowaythiscanexist", + "method": "DELETE", + "valid_status_code": [202], + "invalid_status_code": [403] + } + }, + { + "name": "Create and Update", + "test": { + "endpoint": "https://api.opsgenie.com/v2/alerts/`nowaycanthisexist/message", + "method": "PUT", + "valid_status_code": [400], + "invalid_status_code": [403] + } + } +] \ No newline at end of file diff --git a/pkg/analyzer/analyzers/postgres/postgres.go b/pkg/analyzer/analyzers/postgres/postgres.go new file mode 100644 index 000000000..3c6466c5e --- /dev/null +++ b/pkg/analyzer/analyzers/postgres/postgres.go @@ -0,0 +1,463 @@ +package postgres + +import ( + "database/sql" + "errors" + "fmt" + "os" + "regexp" + "strings" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/lib/pq" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +type DBPrivs struct { + Connect bool + Create bool + CreateTemp bool +} + +type DB struct { + DatabaseName string + Owner string + DBPrivs +} + +type TablePrivs struct { + Select bool + Insert bool + Update bool + Delete bool + Truncate bool + References bool + Trigger bool +} + +type TableData struct { + Size string + Rows string + Privs TablePrivs +} + +const ( + pg_connect_timeout = "connect_timeout" + pg_dbname = "dbname" + pg_host = "host" + pg_password = "password" + pg_port = "port" + pg_requiressl = "requiressl" + pg_sslmode = "sslmode" + pg_sslmode_allow = "allow" + pg_sslmode_disable = "disable" + pg_sslmode_prefer = "prefer" + pg_sslmode_require = "require" + pg_user = "user" +) + +var connStrPartPattern = regexp.MustCompile(`([[:alpha:]]+)='(.+?)' ?`) + +func AnalyzePermissions(cfg *config.Config, connectionStr string) { + + // ToDo: Add in logging + if cfg.LoggingEnabled { + color.Red("[x] Logging is not supported for this analyzer.") + return + } + + connStr, err := pq.ParseURL(string(connectionStr)) + if err != nil { + color.Red("[x] Failed to parse Postgres connection string.\n Error: " + err.Error()) + return + } + parts := connStrPartPattern.FindAllStringSubmatch(connStr, -1) + params := make(map[string]string, len(parts)) + for _, part := range parts { + params[part[1]] = part[2] + } + db, err := createConnection(params, "") + if err != nil { + color.Red("[x] Failed to connect to Postgres database.\n Error: " + err.Error()) + return + } + defer db.Close() + color.Yellow("[!] Successfully connected to Postgres database.") + err = getUserPrivs(db) + if err != nil { + color.Red("[x] Failed to retrieve user privileges.\n Error: " + err.Error()) + return + } + dbs, err := getDBPrivs(db) + if err != nil { + color.Red("[x] Failed to retrieve database privileges.\n Error: " + err.Error()) + return + } + err = getTablePrivs(params, dbs) + if err != nil { + color.Red("[x] Failed to retrieve table privileges.\n Error: " + err.Error()) + return + } +} + +func isErrorDatabaseNotFound(err error, dbName string, user string) bool { + options := []string{dbName, user, "postgres"} + for _, option := range options { + if strings.Contains(err.Error(), fmt.Sprintf("database \"%s\" does not exist", option)) { + return true + } + } + return false +} + +func createConnection(params map[string]string, database string) (*sql.DB, error) { + if sslmode := params[pg_sslmode]; sslmode == pg_sslmode_allow || sslmode == pg_sslmode_prefer { + // pq doesn't support 'allow' or 'prefer'. If we find either of them, we'll just ignore it. This will trigger + // the same logic that is run if no sslmode is set at all (which mimics 'prefer', which is the default). + delete(params, pg_sslmode) + } + + var connStr string + for key, value := range params { + if database != "" && key == "dbname" { + connStr += fmt.Sprintf("%s='%s'", key, database) + } else { + connStr += fmt.Sprintf("%s='%s'", key, value) + } + } + + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, err + } + + err = db.Ping() + switch { + case err == nil: + return db, nil + case strings.Contains(err.Error(), "password authentication failed"): + return nil, errors.New("password authentication failed") + case errors.Is(err, pq.ErrSSLNotSupported) && params[pg_sslmode] == "": + // If the sslmode is unset, then either it was unset in the candidate secret, or we've intentionally unset it + // because it was specified as 'allow' or 'prefer', neither of which pq supports. In all of these cases, non-SSL + // connections are acceptable, so now we try a connection without SSL. + params[pg_sslmode] = pg_sslmode_disable + defer delete(params, pg_sslmode) // We want to return with the original params map intact (for ExtraData) + return createConnection(params, database) + case isErrorDatabaseNotFound(err, params[pg_dbname], params[pg_user]): + color.Green("[!] Successfully connected to Postgres database.") + return nil, err + default: + return nil, err + } +} + +func getUserPrivs(db *sql.DB) error { + // Prepare the SQL statement + query := `SELECT rolname AS role_name, + rolsuper AS is_superuser, + rolinherit AS can_inherit, + rolcreaterole AS can_create_role, + rolcreatedb AS can_create_db, + rolcanlogin AS can_login, + rolreplication AS is_replication_role, + rolbypassrls AS bypasses_rls + FROM pg_roles WHERE rolname = current_user;` + + // Execute the SQL query + rows, err := db.Query(query) + if err != nil { + return err + } + defer rows.Close() + + var roleName string + var isSuperuser, canInherit, canCreateRole, canCreateDB, canLogin, isReplicationRole, bypassesRLS bool + // Iterate over the rows + for rows.Next() { + if err := rows.Scan(&roleName, &isSuperuser, &canInherit, &canCreateRole, &canCreateDB, &canLogin, &isReplicationRole, &bypassesRLS); err != nil { + return err + } + } + + // Check for errors during iteration + if err := rows.Err(); err != nil { + return err + } + + // Map roles to privileges + var mapRoles map[string]bool = map[string]bool{ + "Superuser": isSuperuser, + "Inheritance of Privs": canInherit, + "Create Role": canCreateRole, + "Create DB": canCreateDB, + "Login": canLogin, + "Replication": isReplicationRole, + "Bypass RLS": bypassesRLS, + } + + // Print User roles + privs + color.Yellow("[i] User: %s", roleName) + color.Yellow("[i] Privileges: ") + for role, priv := range mapRoles { + if role == "Superuser" && priv { + color.Green(" - %s", role) + } else if priv { + color.Yellow(" - %s", role) + } + } + return nil +} + +func getDBPrivs(db *sql.DB) ([]string, error) { + query := ` + SELECT + d.datname AS database_name, + u.usename AS owner, + current_user AS current_user, + has_database_privilege(current_user, d.datname, 'CONNECT') AS can_connect, + has_database_privilege(current_user, d.datname, 'CREATE') AS can_create, + has_database_privilege(current_user, d.datname, 'TEMP') AS can_create_temporary_tables + FROM + pg_database d + JOIN + pg_user u ON d.datdba = u.usesysid + WHERE + NOT d.datistemplate + ORDER BY + d.datname; + ` + // Originally had WHERE NOT d.datistemplate AND d.datallowconn + + // Execute the query + rows, err := db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + dbs := make([]DB, 0) + + var currentUser string + // Iterate through the result set + for rows.Next() { + var dbName, owner string + var canConnect, canCreate, canCreateTemp bool + err := rows.Scan(&dbName, &owner, ¤tUser, &canConnect, &canCreate, &canCreateTemp) + if err != nil { + return nil, err + } + + db := DB{ + DatabaseName: dbName, + Owner: owner, + DBPrivs: DBPrivs{ + Connect: canConnect, + Create: canCreate, + CreateTemp: canCreateTemp, + }, + } + dbs = append(dbs, db) + } + if err = rows.Err(); err != nil { + return nil, err + } + + // Print db privs + if len(dbs) > 0 { + fmt.Print("\n\n") + color.Green("[i] User has the following database privileges:") + printDBPrivs(dbs, currentUser) + return buildSliceDBNames(dbs), nil + } + return nil, nil +} + +func printDBPrivs(dbs []DB, current_user string) { + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Database", "Owner", "Access Privileges"}) + for _, db := range dbs { + privs := buildDBPrivsStr(db) + writer := getDBWriter(db, current_user) + t.AppendRow([]interface{}{writer(db.DatabaseName), writer(db.Owner), writer(privs)}) + } + t.Render() +} + +func buildDBPrivsStr(db DB) string { + privs := "" + if db.Connect { + privs += "CONNECT" + } + if db.Create { + privs += ", CREATE" + } + if db.CreateTemp { + privs += ", TEMP" + } + privs = strings.TrimPrefix(privs, ", ") + return privs +} + +func getDBWriter(db DB, current_user string) func(a ...interface{}) string { + if db.Owner == current_user { + return analyzers.GreenWriter + } else if db.Connect && db.Create && db.CreateTemp { + return analyzers.GreenWriter + } else if db.Connect || db.Create || db.CreateTemp { + return analyzers.YellowWriter + } else { + return analyzers.DefaultWriter + } +} + +func buildSliceDBNames(dbs []DB) []string { + var dbNames []string + for _, db := range dbs { + if db.DBPrivs.Connect { + dbNames = append(dbNames, db.DatabaseName) + } + } + return dbNames +} + +func getTablePrivs(params map[string]string, databases []string) error { + + tablePrivileges := make(map[string]map[string]*TableData, 0) + + for _, dbase := range databases { + + // Connect to db + db, err := createConnection(params, dbase) + if err != nil { + color.Red("[x] Failed to connect to Postgres database: %s", dbase) + continue + } + defer db.Close() + + // Get table privs + query := ` + SELECT + rtg.table_catalog, + rtg.table_name, + rtg.privilege_type, + pg_size_pretty(pg_total_relation_size(pc.oid)) AS table_size, + pc.reltuples AS estimate + FROM + information_schema.role_table_grants rtg + JOIN + pg_catalog.pg_class pc ON rtg.table_name = pc.relname + WHERE + rtg.grantee = current_user; + + ` + + // Execute the query + rows, err := db.Query(query) + if err != nil { + return err + } + defer rows.Close() + + // Iterate through the result set + for rows.Next() { + var database, table, priv, size, row_count string + err := rows.Scan(&database, &table, &priv, &size, &row_count) + if err != nil { + return err + } + + if _, ok := tablePrivileges[database]; !ok { + tablePrivileges[database] = map[string]*TableData{ + table: {}, + } + } + + switch priv { + case "SELECT": + tablePrivileges[database][table].Privs.Select = true + case "INSERT": + tablePrivileges[database][table].Privs.Insert = true + case "UPDATE": + tablePrivileges[database][table].Privs.Update = true + case "DELETE": + tablePrivileges[database][table].Privs.Delete = true + case "TRUNCATE": + tablePrivileges[database][table].Privs.Truncate = true + case "REFERENCES": + tablePrivileges[database][table].Privs.References = true + case "TRIGGER": + tablePrivileges[database][table].Privs.Trigger = true + } + tablePrivileges[database][table].Size = size + if row_count != "-1" { + tablePrivileges[database][table].Rows = row_count + } else { + tablePrivileges[database][table].Rows = "Unknown" + } + } + if err = rows.Err(); err != nil { + return err + } + db.Close() + } + + // Print table privs + if len(tablePrivileges) > 0 { + fmt.Print("\n\n") + color.Green("[i] User has the following table privileges:") + printTablePrivs(tablePrivileges) + } + return nil +} + +func printTablePrivs(tables map[string]map[string]*TableData) { + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Database", "Table", "Access Privileges", "Est. Size", "Est. Rows"}) + var writer func(a ...interface{}) string + for db, table := range tables { + for table_name, tableData := range table { + privs := tableData.Privs + privsStr := buildTablePrivsStr(privs) + if privsStr == "" { + writer = color.New().SprintFunc() + } else { + writer = color.New(color.FgGreen).SprintFunc() + } + t.AppendRow([]interface{}{writer(db), writer(table_name), writer(privsStr), writer("< " + tableData.Size), writer(tableData.Rows)}) + } + } + t.Render() +} + +func buildTablePrivsStr(privs TablePrivs) string { + var privsStr string + if privs.Select { + privsStr += "SELECT" + } + if privs.Insert { + privsStr += ", INSERT" + } + if privs.Update { + privsStr += ", UPDATE" + } + if privs.Delete { + privsStr += ", DELETE" + } + if privs.Truncate { + privsStr += ", TRUNCATE" + } + if privs.References { + privsStr += ", REFERENCES" + } + if privs.Trigger { + privsStr += ", TRIGGER" + } + privsStr = strings.TrimPrefix(privsStr, ", ") + return privsStr +} diff --git a/pkg/analyzer/analyzers/postman/postman.go b/pkg/analyzer/analyzers/postman/postman.go new file mode 100644 index 000000000..7c5daccc8 --- /dev/null +++ b/pkg/analyzer/analyzers/postman/postman.go @@ -0,0 +1,152 @@ +package postman + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +type UserInfoJSON struct { + User struct { + Username string `json:"username"` + Email string `json:"email"` + FullName string `json:"fullName"` + Roles []string `json:"roles"` + TeamName string `json:"teamName"` + TeamDomain string `json:"teamDomain"` + } `json:"user"` +} + +type WorkspaceJSON struct { + Workspaces []struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Visibility string `json:"visibility"` + } `json:"workspaces"` +} + +func getUserInfo(cfg *config.Config, key string) (UserInfoJSON, error) { + var me UserInfoJSON + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", "https://api.getpostman.com/me", nil) + if err != nil { + return me, err + } + + req.Header.Add("X-API-Key", key) + + // send request + resp, err := client.Do(req) + if err != nil { + return me, err + } + + // read response + defer resp.Body.Close() + + // if status code is 200, decode response + if resp.StatusCode == 200 { + err = json.NewDecoder(resp.Body).Decode(&me) + } + return me, err +} + +func getWorkspaces(cfg *config.Config, key string) (WorkspaceJSON, error) { + var workspaces WorkspaceJSON + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", "https://api.getpostman.com/workspaces", nil) + if err != nil { + return workspaces, err + } + + req.Header.Add("X-API-Key", key) + + // send request + resp, err := client.Do(req) + if err != nil { + return workspaces, err + } + + // read response + defer resp.Body.Close() + + // if status code is 200, decode response + if resp.StatusCode == 200 { + err = json.NewDecoder(resp.Body).Decode(&workspaces) + } + return workspaces, err +} + +func AnalyzePermissions(cfg *config.Config, key string) { + // validate key & get user info + + me, err := getUserInfo(cfg, key) + if err != nil { + color.Red("[x]" + err.Error()) + } + + if me.User.Username == "" { + color.Red("[x] Invalid Postman API Key") + return + } + + // print user info + printUserInfo(me) + + // get workspaces + workspaces, err := getWorkspaces(cfg, key) + if err != nil { + color.Red("[x]" + err.Error()) + } + + if len(workspaces.Workspaces) == 0 { + color.Red("[x] No Workspaces Found") + return + } + + // print workspaces + printWorkspaces(workspaces) +} + +func printUserInfo(me UserInfoJSON) { + color.Green("[!] Valid Postman API Key") + + color.Yellow("\n[i] User Information") + color.Green("Username: " + me.User.Username) + color.Green("Email: " + me.User.Email) + color.Green("Full Name: " + me.User.FullName) + + color.Yellow("\n[i] Team Information") + color.Green("Name: " + me.User.TeamName) + color.Green("Domain: https://" + me.User.TeamDomain + ".postman.co") + + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Scope", "Permissions"}) + + for _, role := range me.User.Roles { + t.AppendRow([]interface{}{color.GreenString(role), color.GreenString(roleDescriptions[role])}) + } + t.Render() + fmt.Println("Reference: https://learning.postman.com/docs/collaborating-in-postman/roles-and-permissions/#team-roles") +} + +func printWorkspaces(workspaces WorkspaceJSON) { + color.Yellow("[i] Accessible Workspaces") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Workspace Name", "Type", "Visibility", "Link"}) + for _, workspace := range workspaces.Workspaces { + t.AppendRow([]interface{}{color.GreenString(workspace.Name), color.GreenString(workspace.Type), color.GreenString(workspace.Visibility), color.GreenString("https://go.postman.co/workspaces/" + workspace.ID)}) + } + t.Render() +} diff --git a/pkg/analyzer/analyzers/postman/scopes.go b/pkg/analyzer/analyzers/postman/scopes.go new file mode 100644 index 000000000..496ba8360 --- /dev/null +++ b/pkg/analyzer/analyzers/postman/scopes.go @@ -0,0 +1,13 @@ +package postman + +var roleDescriptions = map[string]string{ + "super-admin": "(Enterprise Only) Manages everything within a team, including team settings, members, roles, and resources. This role can view and manage all elements in public, team, private, and personal workspaces. Super Admins can perform all actions that other roles can perform.", + "admin": "Manages team members and team settings. Can also view monitor metadata and run, pause, and resume monitors.", + "billing": "Manages team plan and payments. Billing roles can be granted by a Super Admin, Team Admin, or by a fellow team member with a Billing role.", + "user": "Has access to all team resources and workspaces.", + "community-manager": "(Pro & Enterprise Only) Manages the public visibility of workspaces and team profile.", + "partner-manager": "(Internal, Enterprise plans only) - Manages all Partner Workspaces within an organization. Controls Partner Workspace settings and visibility, and can send invites to partners.", + "partner": "(External, Professional and Enterprise plans only) - All partners are automatically granted the Partner role at the team level. Partners can only access the Partner Workspaces they've been invited to.", + "guest": "Views collections and sends requests in collections that have been shared with them. This role can't be directly assigned to a user.", + "flow-editor": "(Basic and Professional plans only) - Can create, edit, run, and publish Postman Flows.", +} diff --git a/pkg/analyzer/analyzers/sendgrid/scopes.go b/pkg/analyzer/analyzers/sendgrid/scopes.go new file mode 100644 index 000000000..9933cabc7 --- /dev/null +++ b/pkg/analyzer/analyzers/sendgrid/scopes.go @@ -0,0 +1,105 @@ +package sendgrid + +import ( + "strings" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" +) + +type SendgridScope struct { + Category string + SubCategory string + Prefixes []string // Prefixes for the scope + Permissions []string + PermissionType analyzers.PermissionType +} + +func (s *SendgridScope) AddPermission(permission string) { + s.Permissions = append(s.Permissions, permission) +} + +func (s *SendgridScope) RunTests() { + if len(s.Permissions) == 0 { + s.PermissionType = analyzers.NONE + return + } + for _, permission := range s.Permissions { + if strings.Contains(permission, ".read") { + s.PermissionType = analyzers.READ + } else { + s.PermissionType = analyzers.READ_WRITE + return + } + } +} + +var SCOPES = []SendgridScope{ + // Billing + {Category: "Billing", Prefixes: []string{"billing"}}, + // Restricted Access + {Category: "API Keys", Prefixes: []string{"api_keys"}}, + {Category: "Alerts", Prefixes: []string{"alerts"}}, + {Category: "Category Management", Prefixes: []string{"categories"}}, + {Category: "Design Library", Prefixes: []string{"design_library"}}, + {Category: "Email Activity", Prefixes: []string{"messages"}}, + {Category: "Email Testing", Prefixes: []string{"email_testing"}}, + {Category: "IP Management", Prefixes: []string{"ips"}}, + {Category: "Inbound Parse", Prefixes: []string{"user.webhooks.parse.settings"}}, + {Category: "Mail Send", SubCategory: "Mail Send", Prefixes: []string{"mail.send"}}, + {Category: "Mail Send", SubCategory: "Scheduled Sends", Prefixes: []string{"user.scheduled_sends, mail.batch"}}, + {Category: "Mail Settings", SubCategory: "Address Allow List", Prefixes: []string{"mail_settings.address_whitelist"}}, + {Category: "Mail Settings", SubCategory: "BCC", Prefixes: []string{"mail_settings.bcc"}}, + {Category: "Mail Settings", SubCategory: "Bounce Purge", Prefixes: []string{"mail_settings.bounce_purge"}}, + {Category: "Mail Settings", SubCategory: "Event Notification", Prefixes: []string{"user.webhooks.event"}}, + {Category: "Mail Settings", SubCategory: "Footer", Prefixes: []string{"mail_settings.footer"}}, + {Category: "Mail Settings", SubCategory: "Forward Bounce", Prefixes: []string{"mail_settings.forward_bounce"}}, + {Category: "Mail Settings", SubCategory: "Forward Spam", Prefixes: []string{"mail_settings.forward_spam"}}, + {Category: "Mail Settings", SubCategory: "Legacy Email Template", Prefixes: []string{"mail_settings.template"}}, + {Category: "Mail Settings", SubCategory: "Plain Content", Prefixes: []string{"mail_settings.plain_content"}}, + {Category: "Mail Settings", SubCategory: "Spam Checker", Prefixes: []string{"mail_settings.spam_check"}}, + {Category: "Marketing", SubCategory: "Automation", Prefixes: []string{"marketing.automation"}}, + {Category: "Marketing", SubCategory: "Marketing", Prefixes: []string{"marketing.read"}}, + {Category: "Partners", Prefixes: []string{"partner_settings"}}, + {Category: "Recipients Data Erasure", Prefixes: []string{"recipients"}}, + {Category: "Security", Prefixes: []string{"access_settings"}}, + {Category: "Sender Authentication", Prefixes: []string{"whitelabel"}}, + {Category: "Stats", SubCategory: "Browser Stats", Prefixes: []string{"browsers"}}, + {Category: "Stats", SubCategory: "Category Stats", Prefixes: []string{"categories.stats"}}, + {Category: "Stats", SubCategory: "Email Clients and Devices", Prefixes: []string{"clients", "devices"}}, + {Category: "Stats", SubCategory: "Geographical", Prefixes: []string{"geo"}}, + {Category: "Stats", SubCategory: "Global Stats", Prefixes: []string{"stats.global"}}, + {Category: "Stats", SubCategory: "Mailbox Provider Stats", Prefixes: []string{"mailbox_providers"}}, + {Category: "Stats", SubCategory: "Parse Webhook", Prefixes: []string{"user.webhooks.parse.stats"}}, + {Category: "Stats", SubCategory: "Stats Overview", Prefixes: []string{"stats.read"}}, + {Category: "Stats", SubCategory: "Subuser Stats", Prefixes: []string{"subusers"}}, + {Category: "Suppressions", SubCategory: "Supressions", Prefixes: []string{"suppression"}}, + {Category: "Suppressions", SubCategory: "Unsubscribe Groups", Prefixes: []string{"asm.groups"}}, + {Category: "Template Engine", Prefixes: []string{"templates"}}, + {Category: "Tracking", SubCategory: "Click Tracking", Prefixes: []string{"tracking_settings.click"}}, + {Category: "Tracking", SubCategory: "Google Analytics", Prefixes: []string{"tracking_settings.google_analytics"}}, + {Category: "Tracking", SubCategory: "Open Tracking", Prefixes: []string{"tracking_settings.open"}}, + {Category: "Tracking", SubCategory: "Subscription Tracking", Prefixes: []string{"tracking_settings.subscription"}}, + {Category: "User Account", SubCategory: "Enforced TLS", Prefixes: []string{"user.settings.enforced_tls"}}, + {Category: "User Account", SubCategory: "Timezone", Prefixes: []string{"user.timezone"}}, + // Full Access Additional Categories + {Category: "Suppressions", SubCategory: "Unsubscribe Group Suppressions", Prefixes: []string{"asm.groups.suppressions"}}, + {Category: "Suppressions", SubCategory: "Global Suppressions", Prefixes: []string{"asm.suppressions.global"}}, + {Category: "Credentials", Prefixes: []string{"credentials"}}, + {Category: "Mail Settings", Prefixes: []string{"mail_settings"}}, + {Category: "Signup", Prefixes: []string{"signup"}}, + {Category: "Suppressions", SubCategory: "Blocks", Prefixes: []string{"suppression.blocks"}}, + {Category: "Suppressions", SubCategory: "Bounces", Prefixes: []string{"suppression.bounces"}}, + {Category: "Suppressions", SubCategory: "Invalid Emails", Prefixes: []string{"suppression.invalid_emails"}}, + {Category: "Suppressions", SubCategory: "Spam Reports", Prefixes: []string{"suppression.spam_reports"}}, + {Category: "Suppressions", SubCategory: "Unsubscribes", Prefixes: []string{"suppression.unsubscribes"}}, + {Category: "Teammates", Prefixes: []string{"teammates"}}, + {Category: "Tracking", Prefixes: []string{"tracking_settings"}}, + {Category: "UI", Prefixes: []string{"ui"}}, + {Category: "User Account", SubCategory: "Account", Prefixes: []string{"user.account"}}, + {Category: "User Account", SubCategory: "Credits", Prefixes: []string{"user.credits"}}, + {Category: "User Account", SubCategory: "Email", Prefixes: []string{"user.email"}}, + {Category: "User Account", SubCategory: "Multifactor Authentication", Prefixes: []string{"user.multifactor_authentication"}}, + {Category: "User Account", SubCategory: "Password", Prefixes: []string{"user.password"}}, + {Category: "User Account", SubCategory: "Profile", Prefixes: []string{"user.profile"}}, + {Category: "User Account", SubCategory: "Username", Prefixes: []string{"user.username"}}, +} diff --git a/pkg/analyzer/analyzers/sendgrid/sendgrid.go b/pkg/analyzer/analyzers/sendgrid/sendgrid.go new file mode 100644 index 000000000..660e25b94 --- /dev/null +++ b/pkg/analyzer/analyzers/sendgrid/sendgrid.go @@ -0,0 +1,133 @@ +package sendgrid + +import ( + "encoding/json" + "fmt" + "os" + "slices" + "strings" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/v6/table" + sg "github.com/sendgrid/sendgrid-go" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +type ScopesJSON struct { + Scopes []string `json:"scopes"` +} + +func printPermissions(show_all bool) { + fmt.Print("\n\n") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + if show_all { + t.AppendHeader(table.Row{"Scope", "Sub-Scope", "Access", "Permissions"}) + } else { + t.AppendHeader(table.Row{"Scope", "Sub-Scope", "Access"}) + } + // Print the scopes + for _, s := range SCOPES { + writer := analyzers.GetWriterFromStatus(s.PermissionType) + if show_all { + t.AppendRow([]interface{}{writer(s.Category), writer(s.SubCategory), writer(s.PermissionType), writer(strings.Join(s.Permissions, "\n"))}) + } else if s.PermissionType != analyzers.NONE { + t.AppendRow([]interface{}{writer(s.Category), writer(s.SubCategory), writer(s.PermissionType)}) + } + } + t.Render() + fmt.Print("\n\n") +} + +// getCategoryFromScope returns the category for a given scope. +// It will return the most specific category possible. +// For example, if the scope is "mail.send.read", it will return "Mail Send", not just "Mail" +// since it's searching "mail.send.read" -> "mail.send" -> "mail" +func getScopeIndex(scope string) int { + splitScope := strings.Split(scope, ".") + for i := len(splitScope); i > 0; i-- { + searchScope := strings.Join(splitScope[:i], ".") + for i, s := range SCOPES { + for _, prefix := range s.Prefixes { + if strings.HasPrefix(searchScope, prefix) { + return i + } + } + } + } + return -1 +} + +func processPermissions(rawScopes []string) { + for _, scope := range rawScopes { + // Skip these scopes since they are not useful for this analysis + if scope == "2fa_required" || scope == "sender_verification_eligible" { + continue + } + ind := getScopeIndex(scope) + if ind == -1 { + //color.Red("[!] Scope not found: %v", scope) + continue + } + s := &SCOPES[ind] + s.AddPermission(scope) + } + // Run tests to determine the permission type + for i := range SCOPES { + SCOPES[i].RunTests() + } +} + +func AnalyzePermissions(cfg *config.Config, key string) { + + // ToDo: Add logging when rewrite to not use SG client. + if cfg.LoggingEnabled { + color.Red("[x] Logging not supported for GitHub Token Analysis.") + return + } + + req := sg.GetRequest(key, "/v3/scopes", "https://api.sendgrid.com") + req.Method = "GET" + resp, err := sg.API(req) + if resp.StatusCode == 401 || resp.StatusCode == 403 { + color.Red("[!] Invalid API Key") + return + } else if resp.StatusCode != 200 { + color.Red("[!] Error: %v", resp.StatusCode) + return + } + if err != nil { + color.Red("[!] Error: %v", err) + return + } + + color.Green("[!] Valid Sendgrid API Key\n\n") + + // Unmarshal the JSON response into a struct + var jsonScopes ScopesJSON + if err := json.Unmarshal([]byte(resp.Body), &jsonScopes); err != nil { + color.Red("Error:", err) + return + } + + // Now you can access the scopes + rawScopes := jsonScopes.Scopes + + if slices.Contains(rawScopes, "user.email.read") { + color.Green("[*] Sendgrid Key Type: Full Access Key") + } else if slices.Contains(rawScopes, "billing.read") { + color.Yellow("[*] Sendgrid Key Type: Billing Access Key") + } else { + color.Yellow("[*] Sendgrid Key Type: Restricted Access Key") + } + + if slices.Contains(rawScopes, "2fa_required") { + color.Yellow("[i] 2FA Required for this account") + } + + processPermissions(rawScopes) + printPermissions(cfg.ShowAll) + +} diff --git a/pkg/analyzer/analyzers/shopify/scopes.json b/pkg/analyzer/analyzers/shopify/scopes.json new file mode 100644 index 000000000..8e82193fa --- /dev/null +++ b/pkg/analyzer/analyzers/shopify/scopes.json @@ -0,0 +1,470 @@ +{ + "categories": { + "Analytics": { + "description": "View store metrics", + "scopes": { + "read_analytics": "Read" + } + }, + "Applications": { + "description": "View or manage apps", + "scopes": { + "read_apps": "Read" + } + }, + "Assigned fulfillment orders": { + "description": "View or manage fulfillment orders", + "scopes": { + "write_assigned_fulfillment_orders": "Write", + "read_assigned_fulfillment_orders": "Read" + } + }, + "Browsing behavior": { + "description": "View or manage online-store browsing behavior including page views, cart updates, product views and searches", + "scopes": { + "read_customer_events": "Read" + } + }, + "Custom pixels": { + "description": "View or manage custom pixels", + "scopes": { + "write_custom_pixels": "Write", + "read_custom_pixels": "Read" + } + }, + "Customers": { + "description": "View or manage customers, customer addresses, order history, and customer groups", + "scopes": { + "write_customers": "Write", + "read_customers": "Read" + } + }, + "Discounts": { + "description": "View or manage automatic discounts and discount codes", + "scopes": { + "write_discounts": "Write", + "read_discounts": "Read" + } + }, + "Discovery": { + "description": "View or manage Discovery API", + "scopes": { + "write_discovery": "Write", + "read_discovery": "Read" + } + }, + "Draft orders": { + "description": "View or manage orders created by merchants on behalf of customers", + "scopes": { + "write_draft_orders": "Write", + "read_draft_orders": "Read" + } + }, + "Files": { + "description": "View or manage files", + "scopes": { + "write_files": "Write", + "read_files": "Read" + } + }, + "Fulfillment services": { + "description": "View or manage fulfillment services", + "scopes": { + "write_fulfillments": "Write", + "read_fulfillments": "Read" + } + }, + "Gift cards": { + "description": "View or manage gift cards", + "scopes": { + "write_gift_cards": "Write", + "read_gift_cards": "Read" + } + }, + "Inventory": { + "description": "View or manage inventory across multiple locations", + "scopes": { + "write_inventory": "Write", + "read_inventory": "Read" + } + }, + "Legal policies": { + "description": "View or manage a shop's legal policies", + "scopes": { + "write_legal_policies": "Write", + "read_legal_policies": "Read" + } + }, + "Locations": { + "description": "View the geographic location of stores, headquarters, and warehouses", + "scopes": { + "write_locations": "Write", + "read_locations": "Read" + } + }, + "Marketing events": { + "description": "View or manage marketing events and engagement data", + "scopes": { + "write_marketing_events": "Write", + "read_marketing_events": "Read" + } + }, + "Merchant-managed fulfillment orders": { + "description": "View or manage fulfillment orders assigned to merchant-managed locations", + "scopes": { + "write_merchant_managed_fulfillment_orders": "Write", + "read_merchant_managed_fulfillment_orders": "Read" + } + }, + "Metaobject definitions": { + "description": "View or manage definitions", + "scopes": { + "write_metaobject_definitions": "Write", + "read_metaobject_definitions": "Read" + } + }, + "Metaobject entries": { + "description": "View or manage entries", + "scopes": { + "write_metaobjects": "Write", + "read_metaobjects": "Read" + } + }, + "Online Store navigation": { + "description": "View menus for display on the storefront", + "scopes": { + "write_online_store_navigation": "Write", + "read_online_store_navigation": "Read" + } + }, + "Online Store pages": { + "description": "View or manage Online Store pages", + "scopes": { + "write_online_store_pages": "Write", + "read_online_store_pages": "Read" + } + }, + "Order editing": { + "description": "View or manage edits to orders", + "scopes": { + "write_order_edits": "Write", + "read_order_edits": "Read" + } + }, + "Orders": { + "description": "View or manage orders, transactions, fulfillments, and abandoned checkouts", + "scopes": { + "write_orders": "Write", + "read_orders": "Read" + } + }, + "Packing slip management": { + "description": "Edit and preview packing slip template", + "scopes": { + "write_packing_slip_templates": "Write", + "read_packing_slip_templates": "Read" + } + }, + "Payment customizations": { + "description": "View or manage payment customizations", + "scopes": { + "write_payment_customizations": "Write", + "read_payment_customizations": "Read" + } + }, + "Payment terms": { + "description": "View or manage payment terms", + "scopes": { + "write_payment_terms": "Write", + "read_payment_terms": "Read" + } + }, + "Pixels": { + "description": "View or manage pixels", + "scopes": { + "write_pixels": "Write", + "read_pixels": "Read" + } + }, + "Price rules": { + "description": "View or manage conditional discounts", + "scopes": { + "write_price_rules": "Write", + "read_price_rules": "Read" + } + }, + "Product feeds": { + "description": "View or manage product feeds", + "scopes": { + "write_product_feeds": "Write", + "read_product_feeds": "Read" + } + }, + "Product listings": { + "description": "View or manage product or collection listings", + "scopes": { + "write_product_listings": "Write", + "read_product_listings": "Read" + } + }, + "Products": { + "description": "View or manage products, variants, and collections", + "scopes": { + "write_products": "Write", + "read_products": "Read" + } + }, + "Publications": { + "description": "View or manage groups of products that have been published to an app", + "scopes": { + "write_publications": "Write", + "read_publications": "Read" + } + }, + "Purchase options": { + "description": "View or manage purchase options owned by this app", + "scopes": { + "write_purchase_options": "Write", + "read_purchase_options": "Read" + } + }, + "Reports": { + "description": "View or manage reports on the Reports page in the Shopify admin", + "scopes": { + "write_reports": "Write", + "read_reports": "Read" + } + }, + "Resource feedback": { + "description": "View or manage the status of shops and resources", + "scopes": { + "write_resource_feedbacks": "Write", + "read_resource_feedbacks": "Read" + } + }, + "Returns": { + "description": "View or manage returns", + "scopes": { + "write_returns": "Write", + "read_returns": "Read" + } + }, + "Sales channels": { + "description": "View or manage sales channels", + "scopes": { + "write_channels": "Write", + "read_channels": "Read" + } + }, + "Script tags": { + "description": "View or manage the JavaScript code in storefront or orders status pages", + "scopes": { + "write_script_tags": "Write", + "read_script_tags": "Read" + } + }, + "Shipping": { + "description": "View or manage shipping carriers, countries, and provinces", + "scopes": { + "write_shipping": "Write", + "read_shipping": "Read" + } + }, + "Shop locales": { + "description": "View or manage available locales for a shop", + "scopes": { + "write_locales": "Write", + "read_locales": "Read" + } + }, + "Shopify Markets": { + "description": "View or manage Shopify Markets configuration", + "scopes": { + "write_markets": "Write", + "read_markets": "Read" + } + }, + "Shopify Payments accounts": { + "description": "View Shopify Payments accounts", + "scopes": { + "read_shopify_payments_accounts": "Read" + } + }, + "Shopify Payments bank accounts": { + "description": "View bank accounts that can receive Shopify Payment payouts", + "scopes": { + "read_shopify_payments_bank_accounts": "Read" + } + }, + "Shopify Payments disputes": { + "description": "View Shopify Payment disputes raised by buyers", + "scopes": { + "write_shopify_payments_disputes": "Write", + "read_shopify_payments_disputes": "Read" + } + }, + "Shopify Payments payouts": { + "description": "View Shopify Payments payouts and the account's current balance", + "scopes": { + "read_shopify_payments_payouts": "Read" + } + }, + "Store content": { + "description": "View or manage articles, blogs, comments, pages, and redirects", + "scopes": { + "write_content": "Write", + "read_content": "Read" + } + }, + "Store credit account transactions": { + "description": "View or create store credit transactions", + "scopes": { + "write_store_credit_account_transactions": "Write", + "read_store_credit_account_transactions": "Read" + } + }, + "Store credit accounts": { + "description": "View a customer's store credit balance and currency", + "scopes": { + "read_store_credit_accounts": "Read" + } + }, + "Themes": { + "description": "View or manage theme templates and assets", + "scopes": { + "write_themes": "Write", + "read_themes": "Read" + } + }, + "Third-party fulfillment orders": { + "description": "View or manage fulfillment orders assigned to a location managed by any fulfillment service", + "scopes": { + "write_third_party_fulfillment_orders": "Write", + "read_third_party_fulfillment_orders": "Read" + } + }, + "Translations": { + "description": "View or manage content that can be translated", + "scopes": { + "write_translations": "Write", + "read_translations": "Read" + } + }, + "all_cart_transforms": { + "description": "", + "scopes": { + "read_all_cart_transforms": "Read" + } + }, + "all_checkout_completion_target_customizations": { + "description": "", + "scopes": { + "write_all_checkout_completion_target_customizations": "Write", + "read_all_checkout_completion_target_customizations": "Read" + } + }, + "cart_transforms": { + "description": "", + "scopes": { + "write_cart_transforms": "Write", + "read_cart_transforms": "Read" + } + }, + "cash_tracking": { + "description": "", + "scopes": { + "read_cash_tracking": "Read" + } + }, + "companies": { + "description": "", + "scopes": { + "write_companies": "Write", + "read_companies": "Read" + } + }, + "custom_fulfillment_services": { + "description": "", + "scopes": { + "write_custom_fulfillment_services": "Write", + "read_custom_fulfillment_services": "Read" + } + }, + "customer_data_erasure": { + "description": "", + "scopes": { + "write_customer_data_erasure": "Write", + "read_customer_data_erasure": "Read" + } + }, + "customer_merge": { + "description": "", + "scopes": { + "write_customer_merge": "Write", + "read_customer_merge": "Read" + } + }, + "delivery_customizations": { + "description": "", + "scopes": { + "write_delivery_customizations": "Write", + "read_delivery_customizations": "Read" + } + }, + "delivery_option_generators": { + "description": "", + "scopes": { + "write_delivery_option_generators": "Write", + "read_delivery_option_generators": "Read" + } + }, + "discounts_allocator_functions": { + "description": "", + "scopes": { + "write_discounts_allocator_functions": "Write", + "read_discounts_allocator_functions": "Read" + } + }, + "fulfillment_constraint_rules": { + "description": "", + "scopes": { + "write_fulfillment_constraint_rules": "Write", + "read_fulfillment_constraint_rules": "Read" + } + }, + "gates": { + "description": "", + "scopes": { + "write_gates": "Write", + "read_gates": "Read" + } + }, + "order_submission_rules": { + "description": "", + "scopes": { + "write_order_submission_rules": "Write", + "read_order_submission_rules": "Read" + } + }, + "privacy_settings": { + "description": "", + "scopes": { + "write_privacy_settings": "Write", + "read_privacy_settings": "Read" + } + }, + "shopify_payments_provider_accounts_sensitive": { + "description": "", + "scopes": { + "read_shopify_payments_provider_accounts_sensitive": "Read" + } + }, + "validations": { + "description": "", + "scopes": { + "write_validations": "Write", + "read_validations": "Read" + } + } + } +} \ No newline at end of file diff --git a/pkg/analyzer/analyzers/shopify/shopify.go b/pkg/analyzer/analyzers/shopify/shopify.go new file mode 100644 index 000000000..b5ecbec7f --- /dev/null +++ b/pkg/analyzer/analyzers/shopify/shopify.go @@ -0,0 +1,213 @@ +package shopify + +import ( + _ "embed" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +//go:embed scopes.json +var scopesConfig []byte + +func sliceContains(slice []string, value string) bool { + for _, v := range slice { + if v == value { + return true + } + } + return false +} + +type OutputScopes struct { + Description string + Scopes []string +} + +func (o OutputScopes) PrintScopes() string { + // Custom rules unique to this analyzer + var scopes []string + if sliceContains(o.Scopes, "Read") && sliceContains(o.Scopes, "Write") { + scopes = append(scopes, "Read & Write") + for _, scope := range o.Scopes { + if scope != "Read" && scope != "Write" { + scopes = append(scopes, scope) + } + } + } else { + scopes = append(scopes, o.Scopes...) + } + return strings.Join(scopes, ", ") +} + +// Category represents the structure of each category in the JSON +type CategoryJSON struct { + Description string `json:"description"` + Scopes map[string]string `json:"scopes"` +} + +// Data represents the overall JSON structure +type ScopeDataJSON struct { + Categories map[string]CategoryJSON `json:"categories"` +} + +// Function to determine the appropriate scope +func determineScopes(data ScopeDataJSON, input string) map[string]OutputScopes { + // Split the input string into individual scopes + inputScopes := strings.Split(input, ", ") + + // Map to store scopes found for each category + scopeResults := make(map[string]OutputScopes) + + // Populate categoryScopes map with individual scopes found + for _, scope := range inputScopes { + for category, catData := range data.Categories { + if scopeType, exists := catData.Scopes[scope]; exists { + if _, ok := scopeResults[category]; !ok { + scopeResults[category] = OutputScopes{Description: catData.Description} + } + // Extract the struct from the map + outputData := scopeResults[category] + + // Modify the struct (ex: append "Read" or "Write" to the Scopes slice) + outputData.Scopes = append(outputData.Scopes, scopeType) + + // Reassign the modified struct back to the map + scopeResults[category] = outputData + } + } + } + + return scopeResults +} + +type ShopInfoJSON struct { + Shop struct { + Name string `json:"name"` + Email string `json:"email"` + CreatedAt string `json:"created_at"` + } `json:"shop"` +} + +func getShopInfo(cfg *config.Config, key string, store string) (ShopInfoJSON, error) { + var shopInfo ShopInfoJSON + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", fmt.Sprintf("https://%s/admin/api/2024-04/shop.json", store), nil) + if err != nil { + return shopInfo, err + } + + req.Header.Set("X-Shopify-Access-Token", key) + + resp, err := client.Do(req) + if err != nil { + return shopInfo, err + } + + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&shopInfo) + if err != nil { + return shopInfo, err + } + return shopInfo, nil +} + +type AccessScopesJSON struct { + AccessScopes []struct { + Handle string `json:"handle"` + } `json:"access_scopes"` +} + +func (a AccessScopesJSON) String() string { + var scopes []string + for _, scope := range a.AccessScopes { + scopes = append(scopes, scope.Handle) + } + return strings.Join(scopes, ", ") +} + +func getAccessScopes(cfg *config.Config, key string, store string) (AccessScopesJSON, int, error) { + var accessScopes AccessScopesJSON + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", fmt.Sprintf("https://%s/admin/oauth/access_scopes.json", store), nil) + if err != nil { + return accessScopes, -1, err + } + + req.Header.Set("X-Shopify-Access-Token", key) + + resp, err := client.Do(req) + if err != nil { + return accessScopes, resp.StatusCode, err + } + + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&accessScopes) + if err != nil { + return accessScopes, resp.StatusCode, err + } + return accessScopes, resp.StatusCode, nil +} + +func AnalyzePermissions(cfg *config.Config, key string, storeURL string) { + + accessScopes, statusCode, err := getAccessScopes(cfg, key, storeURL) + if err != nil { + color.Red("Error: %s", err) + return + } + + if statusCode != 200 { + color.Red("[x] Invalid Shopfiy API Key and Store URL combination") + return + } + color.Green("[i] Valid Shopify API Key\n\n") + + shopInfo, err := getShopInfo(cfg, key, storeURL) + if err != nil { + color.Red("Error: %s", err) + return + } + + color.Yellow("[i] Shop Information\n") + color.Yellow("Name: %s", shopInfo.Shop.Name) + color.Yellow("Email: %s", shopInfo.Shop.Email) + color.Yellow("Created At: %s\n\n", shopInfo.Shop.CreatedAt) + + var data ScopeDataJSON + if err := json.Unmarshal(scopesConfig, &data); err != nil { + color.Red("Error: %s", err) + return + } + scopes := determineScopes(data, accessScopes.String()) + printAccessScopes(scopes) +} + +func printAccessScopes(accessScopes map[string]OutputScopes) { + color.Yellow("[i] Access Scopes\n") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Scope", "Description", "Access"}) + + // order the categories + categoryOrder := []string{"Analytics", "Applications", "Assigned fulfillment orders", "Browsing behavior", "Custom pixels", "Customers", "Discounts", "Discovery", "Draft orders", "Files", "Fulfillment services", "Gift cards", "Inventory", "Legal policies", "Locations", "Marketing events", "Merchant-managed fulfillment orders", "Metaobject definitions", "Metaobject entries", "Online Store navigation", "Online Store pages", "Order editing", "Orders", "Packing slip management", "Payment customizations", "Payment terms", "Pixels", "Price rules", "Product feeds", "Product listings", "Products", "Publications", "Purchase options", "Reports", "Resource feedback", "Returns", "Sales channels", "Script tags", "Shipping", "Shop locales", "Shopify Markets", "Shopify Payments accounts", "Shopify Payments bank accounts", "Shopify Payments disputes", "Shopify Payments payouts", "Store content", "Store credit account transactions", "Store credit accounts", "Themes", "Third-party fulfillment orders", "Translations", "all_cart_transforms", "all_checkout_completion_target_customizations", "cart_transforms", "cash_tracking", "companies", "custom_fulfillment_services", "customer_data_erasure", "customer_merge", "delivery_customizations", "delivery_option_generators", "discounts_allocator_functions", "fulfillment_constraint_rules", "gates", "order_submission_rules", "privacy_settings", "shopify_payments_provider_accounts_sensitive", "validations"} + + for _, category := range categoryOrder { + if val, ok := accessScopes[category]; ok { + t.AppendRow([]interface{}{color.GreenString(category), color.GreenString(val.Description), color.GreenString(val.PrintScopes())}) + } + } + t.Render() + +} diff --git a/pkg/analyzer/analyzers/slack/scopes.go b/pkg/analyzer/analyzers/slack/scopes.go new file mode 100644 index 000000000..c5dba89a1 --- /dev/null +++ b/pkg/analyzer/analyzers/slack/scopes.go @@ -0,0 +1,90 @@ +package slack + +// SCOPES := []string{string} { + +// "admin.analytics:read" : { +// "admin.analytics.getFile", +// "admin.analytics.getUsage", +// "admin.analytics.listFiles", +// } +// } + +var scope_mapping = map[string][]string{ + "admin.analytics:read": {"admin.analytics.getFile"}, + "admin.app_activities:read": {"admin.apps.activities.list"}, + "admin.apps:write": {"admin.apps.approve", "admin.apps.clearResolution", "admin.apps.config.set", "admin.apps.requests.cancel", "admin.apps.restrict", "admin.apps.uninstall"}, + "admin.apps:read": {"admin.apps.approved.list", "admin.apps.config.lookup", "admin.apps.requests.list", "admin.apps.restricted.list"}, + "admin.users:write": {"admin.auth.policy.assignEntities", "admin.auth.policy.removeEntities", "admin.users.assign", "admin.users.invite", "admin.users.remove", "admin.users.session.clearSettings", "admin.users.session.invalidate", "admin.users.session.reset", "admin.users.session.resetBulk", "admin.users.session.setSettings", "admin.users.setAdmin", "admin.users.setExpiration", "admin.users.setOwner", "admin.users.setRegular"}, + "admin.users:read": {"admin.auth.policy.getEntities", "admin.users.list", "admin.users.session.getSettings", "admin.users.session.list", "admin.users.unsupportedVersions.export"}, + "admin.barriers:write": {"admin.barriers.create", "admin.barriers.delete", "admin.barriers.update"}, + "admin.barriers:read": {"admin.barriers.list"}, + "admin.conversations:write": {"admin.conversations.archive", "admin.conversations.bulkArchive", "admin.conversations.bulkDelete", "admin.conversations.bulkMove", "admin.conversations.convertToPrivate", "admin.conversations.convertToPublic", "admin.conversations.create", "admin.conversations.delete", "admin.conversations.disconnectShared", "admin.conversations.invite", "admin.conversations.removeCustomRetention", "admin.conversations.rename", "admin.conversations.restrictAccess.addGroup", "admin.conversations.restrictAccess.removeGroup", "admin.conversations.setConversationPrefs", "admin.conversations.setCustomRetention", "admin.conversations.setTeams", "admin.conversations.unarchive"}, + "admin.conversations:read": {"admin.conversations.ekm.listOriginalConnectedChannelInfo", "admin.conversations.getConversationPrefs", "admin.conversations.getCustomRetention", "admin.conversations.getTeams", "admin.conversations.lookup", "admin.conversations.restrictAccess.listGroups", "admin.conversations.search"}, + "admin.teams:write": {"admin.emoji.add", "admin.emoji.addAlias", "admin.emoji.remove", "admin.emoji.rename", "admin.teams.create", "admin.teams.settings.setDefaultChannels", "admin.teams.settings.setDescription", "admin.teams.settings.setDiscoverability", "admin.teams.settings.setIcon", "admin.teams.settings.setName", "admin.usergroups.addTeams"}, + "admin.teams:read": {"admin.emoji.list", "admin.teams.admins.list", "admin.teams.list", "admin.teams.owners.list", "admin.teams.settings.info"}, + "admin.workflows:read": {"admin.functions.list", "admin.functions.permissions.lookup", "admin.workflows.permissions.lookup", "admin.workflows.search"}, + "admin.workflows:write": {"admin.functions.permissions.set", "admin.workflows.collaborators.add", "admin.workflows.collaborators.remove", "admin.workflows.unpublish"}, + "admin.invites:write": {"admin.inviteRequests.approve", "admin.inviteRequests.deny"}, + "admin.invites:read": {"admin.inviteRequests.approved.list", "admin.inviteRequests.denied.list", "admin.inviteRequests.list"}, + "admin.roles:write": {"admin.roles.addAssignments", "admin.roles.removeAssignments"}, + "admin.roles:read": {"admin.roles.listAssignments"}, + "admin.usergroups:write": {"admin.usergroups.addChannels", "admin.usergroups.removeChannels"}, + "admin.usergroups:read": {"admin.usergroups.listChannels"}, + "hosting:read": {"apps.activities.list"}, + "connections:write": {"apps.connections.open"}, + "token": {"apps.datastore.bulkDelete", "apps.datastore.bulkGet", "apps.datastore.bulkPut", "apps.datastore.delete", "apps.datastore.get", "apps.datastore.put", "apps.datastore.query", "apps.datastore.update"}, + "datastore:read": {"apps.datastore.count"}, + "authorizations:read": {"apps.event.authorizations.list"}, + "bot": {"auth.revoke", "auth.test", "chat.getPermalink", "chat.scheduledMessages.list", "dialog.open", "functions.completeError", "functions.completeSuccess", "rtm.connect", "rtm.start", "views.open", "views.publish", "views.push", "views.update"}, + "bookmarks:write": {"bookmarks.add", "bookmarks.edit", "bookmarks.remove"}, + "bookmarks:read": {"bookmarks.list"}, + "users:read": {"bots.info", "users.getPresence", "users.info", "users.list"}, + "calls:write": {"calls.add", "calls.end", "calls.participants.add", "calls.participants.remove", "calls.update"}, + "calls:read": {"calls.info"}, + "channels:manage": {"channels.create", "channels.mark", "conversations.archive", "conversations.close", "conversations.create", "conversations.kick", "conversations.leave", "conversations.mark", "conversations.open", "conversations.rename", "conversations.unarchive", "groups.create", "groups.mark", "im.mark", "im.open", "mpim.mark", "mpim.open"}, + "channels:read": {"channels.info", "conversations.info", "conversations.list", "conversations.members", "groups.info", "im.list", "mpim.list", "users.conversations"}, + "channels:write.invites": {"channels.invite", "conversations.invite", "groups.invite"}, + "chat:write": {"chat.delete", "chat.deleteScheduledMessage", "chat.meMessage", "chat.postEphemeral", "chat.postMessage", "chat.scheduleMessage", "chat.update"}, + "links:write": {"chat.unfurl"}, + "conversations.connect:write": {"conversations.acceptSharedInvite", "conversations.inviteShared"}, + "conversations.connect:manage": {"conversations.approveSharedInvite", "conversations.declineSharedInvite", "conversations.listConnectInvites"}, + "channels:history": {"conversations.history", "conversations.replies"}, + "channels:join": {"conversations.join"}, + "channels:write.topic": {"conversations.setPurpose", "conversations.setTopic"}, + "dnd:write": {"dnd.endDnd", "dnd.endSnooze", "dnd.setSnooze"}, + "dnd:read": {"dnd.info", "dnd.teamInfo"}, + "emoji:read": {"emoji.list"}, + "files:write": {"files.comments.delete", "files.completeUploadExternal", "files.delete", "files.getUploadURLExternal", "files.revokePublicURL", "files.sharedPublicURL", "files.upload"}, + "files:read": {"files.info", "files.list"}, + "remote_files:write": {"files.remote.add", "files.remote.remove", "files.remote.update"}, + "remote_files:read": {"files.remote.info", "files.remote.list"}, + "remote_files:share": {"files.remote.share"}, + "app_configurations:write": {"functions.distributions.permissions.add", "functions.distributions.permissions.remove", "functions.distributions.permissions.set"}, + "app_configurations:read": {"functions.distributions.permissions.list"}, + "conversations": {"groups.open"}, + "tokens.basic": {"migration.exchange"}, + "email": {"openid.connect.userInfo"}, + "pins:write": {"pins.add", "pins.remove"}, + "pins:read": {"pins.list"}, + "reactions:write": {"reactions.add", "reactions.remove"}, + "reactions:read": {"reactions.get", "reactions.list"}, + "reminders:write": {"reminders.add", "reminders.complete", "reminders.delete"}, + "reminders:read": {"reminders.info", "reminders.list"}, + "search:read": {"search.all", "search.files", "search.messages"}, + "stars:write": {"stars.add", "stars.remove"}, + "stars:read": {"stars.list"}, + "admin": {"team.accessLogs", "team.billableInfo", "team.integrationLogs"}, + "team.billing:read": {"team.billing.info"}, + "team:read": {"team.info"}, + "team.preferences:read": {"team.preferences.list"}, + "users.profile:read": {"team.profile.get", "users.profile.get"}, + "usergroups:write": {"usergroups.create", "usergroups.disable", "usergroups.enable", "usergroups.update", "usergroups.users.update"}, + "usergroups:read": {"usergroups.list", "usergroups.users.list"}, + "users.profile:write": {"users.deletePhoto", "users.profile.set", "users.setPhoto"}, + "identity.basic": {"users.identity"}, + "users:read.email": {"users.lookupByEmail"}, + "users:write": {"users.setActive", "users.setPresence"}, + "workflow.steps:execute": {"workflows.stepCompleted", "workflows.stepFailed", "workflows.updateStep"}, + "triggers:write": {"workflows.triggers.permissions.add", "workflows.triggers.permissions.remove", "workflows.triggers.permissions.set"}, + "triggers:read": {"workflows.triggers.permissions.list"}, +} diff --git a/pkg/analyzer/analyzers/slack/slack.go b/pkg/analyzer/analyzers/slack/slack.go new file mode 100644 index 000000000..de46900dd --- /dev/null +++ b/pkg/analyzer/analyzers/slack/slack.go @@ -0,0 +1,141 @@ +package slack + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +// Add in showAll to printScopes + deal with testing enterprise + add scope details + +type SlackUserData struct { + Ok bool `json:"ok"` + Url string `json:"url"` + Team string `json:"team"` + User string `json:"user"` + TeamId string `json:"team_id"` + UserId string `json:"user_id"` + BotId string `json:"bot_id"` + IsEnterprise bool `json:"is_enterprise"` +} + +func getSlackOAuthScopes(cfg *config.Config, key string) (scopes string, userData SlackUserData, err error) { + userData = SlackUserData{} + scopes = "" + + // URL to which the request will be sent + url := "https://slack.com/api/auth.test" + + // Create a client to send the request + client := analyzers.NewAnalyzeClient(cfg) + + // Create the request + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return scopes, userData, err + } + + // Add the Authorization header to the request + req.Header.Add("Authorization", "Bearer "+key) + + // Send the request + resp, err := client.Do(req) + if err != nil { + return scopes, userData, err + } + defer resp.Body.Close() // Close the response body when the function returns + + // print body + body, err := io.ReadAll(resp.Body) + if err != nil { + return scopes, userData, err + } + + // Unmarshal the response body into the SlackUserData struct + if err := json.Unmarshal(body, &userData); err != nil { + return scopes, userData, err + } + + // Print all headers received from the server + scopes = resp.Header.Get("X-Oauth-Scopes") + return scopes, userData, err +} + +func AnalyzePermissions(cfg *config.Config, key string) { + scopes, userData, err := getSlackOAuthScopes(cfg, key) + if err != nil { + color.Red("[!] Error getting Slack OAuth scopes:", err) + return + } + + if !userData.Ok { + color.Red("[!] Invalid Slack Token") + return + } + + color.Green("[!] Valid Slack API Key\n\n") + printIdentityInfo(userData) + printScopes(strings.Split(scopes, ",")) +} + +func printIdentityInfo(userData SlackUserData) { + if userData.Url != "" { + color.Green("URL: %v", userData.Url) + } + if userData.Team != "" { + color.Green("Team: %v", userData.Team) + } + if userData.User != "" { + color.Green("User: %v", userData.User) + } + if userData.TeamId != "" { + color.Green("Team ID: %v", userData.TeamId) + } + if userData.UserId != "" { + color.Green("User ID: %v", userData.UserId) + } + if userData.BotId != "" { + color.Green("Bot ID: %v", userData.BotId) + } + fmt.Println("") + if userData.IsEnterprise { + color.Green("[!] Slack is Enterprise") + } else { + color.Yellow("[-] Slack is not Enterprise") + } + fmt.Println("") +} + +func printScopes(scopes []string) { + t := table.NewWriter() + // if !showAll { + // t.SetOutputMirror(os.Stdout) + // t.AppendHeader(table.Row{"Scopes"}) + // for _, scope := range scopes { + // t.AppendRow([]interface{}{color.GreenString(scope)}) + // } + // } else { + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Scope", "Permissions"}) + for _, scope := range scopes { + perms := scope_mapping[scope] + if perms == nil { + t.AppendRow([]interface{}{color.GreenString(scope), color.GreenString("")}) + } else { + t.AppendRow([]interface{}{color.GreenString(scope), color.GreenString(strings.Join(perms, ", "))}) + } + + } + //} + + t.Render() + +} diff --git a/pkg/analyzer/analyzers/sourcegraph/sourcegraph.go b/pkg/analyzer/analyzers/sourcegraph/sourcegraph.go new file mode 100644 index 000000000..a4ef34f6d --- /dev/null +++ b/pkg/analyzer/analyzers/sourcegraph/sourcegraph.go @@ -0,0 +1,139 @@ +package sourcegraph + +// ToDo: Add suport for custom domain + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/fatih/color" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +type GraphQLError struct { + Message string `json:"message"` + Path []string `json:"path"` +} + +type GraphQLResponse struct { + Errors []GraphQLError `json:"errors"` + Data interface{} `json:"data"` +} + +type UserInfoJSON struct { + Data struct { + CurrentUser struct { + Username string `json:"username"` + Email string `json:"email"` + SiteAdmin bool `json:"siteAdmin"` + CreatedAt string `json:"createdAt"` + } `json:"currentUser"` + } `json:"data"` +} + +func getUserInfo(cfg *config.Config, key string) (UserInfoJSON, error) { + var userInfo UserInfoJSON + + client := analyzers.NewAnalyzeClient(cfg) + payload := "{\"query\":\"query { currentUser { username, email, siteAdmin, createdAt } }\"}" + req, err := http.NewRequest("POST", "https://sourcegraph.com/.api/graphql", strings.NewReader(payload)) + if err != nil { + return userInfo, err + } + + req.Header.Set("Authorization", "token "+key) + + resp, err := client.Do(req) + if err != nil { + return userInfo, err + } + + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&userInfo) + if err != nil { + return userInfo, err + } + return userInfo, nil +} + +func checkSiteAdmin(cfg *config.Config, key string) (bool, error) { + query := ` + { + "query": "query webhooks($first: Int, $after: String, $kind: ExternalServiceKind) { webhooks(first: $first, after: $after, kind: $kind) { totalCount } }", + "variables": { + "first": 10, + "after": "", + "kind": "GITHUB" + } + }` + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("POST", "https://sourcegraph.com/.api/graphql", strings.NewReader(query)) + if err != nil { + return false, err + } + + req.Header.Set("Authorization", "token "+key) + + resp, err := client.Do(req) + if err != nil { + return false, err + } + + defer resp.Body.Close() + + var response GraphQLResponse + + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return false, err + } + + if len(response.Errors) > 0 { + return false, nil + } + return true, nil +} + +func AnalyzePermissions(cfg *config.Config, key string) { + + userInfo, err := getUserInfo(cfg, key) + if err != nil { + color.Red("Error: %s", err) + return + } + + // second call + userInfo, err = getUserInfo(cfg, key) + if err != nil { + color.Red("Error: %s", err) + return + } + + if userInfo.Data.CurrentUser.Username == "" { + color.Red("[x] Invalid Sourcegraph Access Token") + return + } + color.Green("[!] Valid Sourcegraph Access Token\n\n") + color.Yellow("[i] Sourcegraph User Information\n") + color.Green("Username: %s\n", userInfo.Data.CurrentUser.Username) + color.Green("Email: %s\n", userInfo.Data.CurrentUser.Email) + color.Green("Created At: %s\n\n", userInfo.Data.CurrentUser.CreatedAt) + + isSiteAdmin, err := checkSiteAdmin(cfg, key) + if err != nil { + color.Red("Error: %s", err) + return + } + + if isSiteAdmin { + color.Green("[!] Token Permissions: Site Admin") + } else { + // This is the default for all access tokens as of 6/11/24 + color.Yellow("[i] Token Permissions: user:full (default)") + } + +} diff --git a/pkg/analyzer/analyzers/square/scopes.go b/pkg/analyzer/analyzers/square/scopes.go new file mode 100644 index 000000000..5473b6b79 --- /dev/null +++ b/pkg/analyzer/analyzers/square/scopes.go @@ -0,0 +1,408 @@ +package square + +var permissions_slice = []map[string]map[string][]string{ + { + "Bank Accounts": { + "GetBankAccount": []string{"BANK_ACCOUNTS_READ"}, + "ListBankAccounts": []string{"BANK_ACCOUNTS_READ"}, + "GetBankAccountByV1Id": []string{"BANK_ACCOUNTS_READ"}, + }, + }, + { + "Bookings": { + "CreateBooking (buyer-level)": []string{"APPOINTMENTS_WRITE"}, + "CreateBooking (seller-level)": []string{"APPOINTMENTS_WRITE", "APPOINTMENTS_ALL_WRITE"}, + "SearchAvailability (buyer-level)": []string{"APPOINTMENTS_READ"}, + "SearchAvailability (seller-level)": []string{"APPOINTMENTS_READ", "APPOINTMENTS_ALL_READ"}, + "RetrieveBusinessBookingProfile": []string{"APPOINTMENTS_BUSINESS_SETTINGS_READ"}, + "ListTeamMemberBookingProfiles": []string{"APPOINTMENTS_BUSINESS_SETTINGS_READ"}, + "RetrieveTeamMemberBookingProfile": []string{"APPOINTMENTS_BUSINESS_SETTINGS_READ"}, + "ListBookings (buyer-level)": []string{"APPOINTMENTS_READ"}, + "ListBookings (seller-level)": []string{"APPOINTMENTS_READ", "APPOINTMENTS_ALL_READ"}, + "RetrieveBooking (buyer-level)": []string{"APPOINTMENTS_READ"}, + "RetrieveBooking (seller-level)": []string{"APPOINTMENTS_READ", "APPOINTMENTS_ALL_READ"}, + "UpdateBooking (buyer-level)": []string{"APPOINTMENTS_WRITE"}, + "UpdateBooking (seller-level)": []string{"APPOINTMENTS_WRITE", "APPOINTMENTS_ALL_WRITE"}, + "CancelBooking (buyer-level)": []string{"APPOINTMENTS_WRITE"}, + "CancelBooking (seller-level)": []string{"APPOINTMENTS_WRITE", "APPOINTMENTS_ALL_WRITE"}, + }, + }, + { + "Booking Custom Attributes": { + "CreateBookingCustomAttributeDefinition (buyer-level)": []string{"APPOINTMENTS_WRITE"}, + "CreateBookingCustomAttributeDefinition (seller-level)": []string{"APPOINTMENTS_WRITE", "APPOINTMENTS_ALL_WRITE"}, + "UpdateBookingCustomAttributeDefinition (buyer-level)": []string{"APPOINTMENTS_WRITE"}, + "UpdateBookingCustomAttributeDefinition (seller-level)": []string{"APPOINTMENTS_WRITE", "APPOINTMENTS_ALL_WRITE"}, + "ListBookingCustomAttributeDefinitions (buyer-level)": []string{"APPOINTMENTS_READ"}, + "ListBookingCustomAttributeDefinitions (seller-level)": []string{"APPOINTMENTS_READ", "APPOINTMENTS_ALL_READ"}, + "RetrieveBookingCustomAttributeDefinition (buyer-level)": []string{"APPOINTMENTS_READ"}, + "RetrieveBookingCustomAttributeDefinition (seller-level)": []string{"APPOINTMENTS_READ", "APPOINTMENTS_ALL_READ"}, + "DeleteBookingCustomAttributeDefinition (buyer-level)": []string{"APPOINTMENTS_WRITE"}, + "DeleteBookingCustomAttributeDefinition (seller-level)": []string{"APPOINTMENTS_WRITE", "APPOINTMENTS_ALL_WRITE"}, + "UpsertBookingCustomAttribute (buyer-level)": []string{"APPOINTMENTS_WRITE"}, + "UpsertBookingCustomAttribute (seller-level)": []string{"APPOINTMENTS_WRITE", "APPOINTMENTS_ALL_WRITE"}, + "BulkUpsertBookingCustomAttributes (buyer-level)": []string{"APPOINTMENTS_WRITE"}, + "BulkUpsertBookingCustomAttributes (seller-level)": []string{"APPOINTMENTS_WRITE", "APPOINTMENTS_ALL_WRITE"}, + "ListBookingCustomAttributes (buyer-level)": []string{"APPOINTMENTS_READ"}, + "ListBookingCustomAttributes (seller-level)": []string{"APPOINTMENTS_READ", "APPOINTMENTS_ALL_READ"}, + "RetrieveBookingCustomAttribute (buyer-level)": []string{"APPOINTMENTS_READ"}, + "RetrieveBookingCustomAttribute (seller-level)": []string{"APPOINTMENTS_READ", "APPOINTMENTS_ALL_READ"}, + "DeleteBookingCustomAttribute (buyer-level)": []string{"APPOINTMENTS_WRITE"}, + "DeleteBookingCustomAttribute (seller-level)": []string{"APPOINTMENTS_WRITE", "APPOINTMENTS_ALL_WRITE"}, + }, + }, + { + "Cards": { + "ListCards": []string{"PAYMENTS_READ"}, + "CreateCard": []string{"PAYMENTS_WRITE"}, + "RetrieveCard": []string{"PAYMENTS_READ"}, + "DisableCard": []string{"PAYMENTS_WRITE"}, + }, + }, + { + "Cash Drawer Shifts": { + "ListCashDrawerShifts": []string{"CASH_DRAWER_READ"}, + "ListCashDrawerShiftEvents": []string{"CASH_DRAWER_READ"}, + "RetrieveCashDrawerShift": []string{"CASH_DRAWER_READ"}, + }, + }, + { + "Catalog": { + "BatchDeleteCatalogObjects": []string{"ITEMS_WRITE"}, + "BatchUpsertCatalogObjects": []string{"ITEMS_WRITE"}, + "BatchRetrieveCatalogObjects": []string{"ITEMS_READ"}, + "CatalogInfo": []string{"ITEMS_READ"}, + "CreateCatalogImage": []string{"ITEMS_WRITE"}, + "DeleteCatalogObject": []string{"ITEMS_WRITE"}, + "ListCatalog": []string{"ITEMS_READ"}, + "RetrieveCatalogObject": []string{"ITEMS_READ"}, + "SearchCatalogItems": []string{"ITEMS_READ"}, + "SearchCatalogObjects": []string{"ITEMS_READ"}, + "UpdateItemTaxes": []string{"ITEMS_WRITE"}, + "UpdateItemModifierLists": []string{"ITEMS_WRITE"}, + "UpsertCatalogObject": []string{"ITEMS_WRITE"}, + }, + }, + { + "Checkout": { + "CreatePaymentLink": []string{"ORDERS_WRITE", "ORDERS_READ", "PAYMENTS_WRITE"}, + }, + }, + { + "Customers": { + "AddGroupToCustomer": []string{"CUSTOMERS_WRITE"}, + "BulkCreateCustomers": []string{"CUSTOMERS_WRITE"}, + "BulkDeleteCustomers": []string{"CUSTOMERS_WRITE"}, + "BulkRetrieveCustomers": []string{"CUSTOMERS_READ"}, + "BulkUpdateCustomers": []string{"CUSTOMERS_WRITE"}, + "CreateCustomer": []string{"CUSTOMERS_WRITE"}, + "CreateCustomerCard (deprecated)": []string{"CUSTOMERS_WRITE"}, + "DeleteCustomer": []string{"CUSTOMERS_WRITE"}, + "DeleteCustomerCard (deprecated)": []string{"CUSTOMERS_WRITE"}, + "ListCustomers": []string{"CUSTOMERS_READ"}, + "RemoveGroupFromCustomer": []string{"CUSTOMERS_WRITE"}, + "RetrieveCustomer": []string{"CUSTOMERS_READ"}, + "SearchCustomers": []string{"CUSTOMERS_READ"}, + "UpdateCustomer": []string{"CUSTOMERS_WRITE"}, + }, + }, + { + "Customer Custom Attributes": { + "CreateCustomerCustomAttributeDefinition": []string{"CUSTOMERS_WRITE"}, + "UpdateCustomerCustomAttributeDefinition": []string{"CUSTOMERS_WRITE"}, + "ListCustomerCustomAttributeDefinitions": []string{"CUSTOMERS_READ"}, + "RetrieveCustomerCustomAttributeDefinition": []string{"CUSTOMERS_READ"}, + "DeleteCustomerCustomAttributeDefinition": []string{"CUSTOMERS_WRITE"}, + "UpsertCustomerCustomAttribute": []string{"CUSTOMERS_WRITE"}, + "BulkUpsertCustomerCustomAttributes": []string{"CUSTOMERS_WRITE"}, + "ListCustomerCustomAttributes": []string{"CUSTOMERS_READ"}, + "RetrieveCustomerCustomAttribute": []string{"CUSTOMERS_READ"}, + "DeleteCustomerCustomAttribute": []string{"CUSTOMERS_WRITE"}, + }, + }, + { + "Customer Groups": { + "CreateCustomerGroup": []string{"CUSTOMERS_WRITE"}, + "DeleteCustomerGroup": []string{"CUSTOMERS_WRITE"}, + "ListCustomerGroups": []string{"CUSTOMERS_READ"}, + "RetrieveCustomerGroup": []string{"CUSTOMERS_READ"}, + "UpdateCustomerGroup": []string{"CUSTOMERS_WRITE"}, + }, + }, + { + "Customer Segments": { + "ListCustomerSegments": []string{"CUSTOMERS_READ"}, + "RetrieveCustomerSegment": []string{"CUSTOMERS_READ"}, + }, + }, + { + "Devices": { + "CreateDeviceCode": []string{"DEVICE_CREDENTIAL_MANAGEMENT"}, + "GetDeviceCode": []string{"DEVICE_CREDENTIAL_MANAGEMENT"}, + "ListDeviceCodes": []string{"DEVICE_CREDENTIAL_MANAGEMENT"}, + "ListDevices": []string{"DEVICES_READ"}, + "GetDevice": []string{"DEVICES_READ"}, + }, + }, + { + "Disputes": { + "AcceptDispute": []string{"DISPUTES_WRITE"}, + "CreateDisputeEvidenceFile": []string{"DISPUTES_WRITE"}, + "CreateDisputeEvidenceText": []string{"DISPUTES_WRITE"}, + "ListDisputeEvidence": []string{"DISPUTES_READ"}, + "ListDisputes": []string{"DISPUTES_READ"}, + "DeleteDisputeEvidence": []string{"DISPUTES_WRITE"}, + "RetrieveDispute": []string{"DISPUTES_READ"}, + "RetrieveDisputeEvidence": []string{"DISPUTES_READ"}, + "SubmitEvidence": []string{"DISPUTES_WRITE"}, + }, + }, + { + "Employees": { + "ListEmployees (deprecated)": []string{"EMPLOYEES_READ"}, + "RetrieveEmployee (deprecated)": []string{"EMPLOYEES_READ"}, + }, + }, + { + "Gift Cards": { + "ListGiftCards": []string{"GIFTCARDS_READ"}, + "CreateGiftCard": []string{"GIFTCARDS_WRITE"}, + "RetrieveGiftCard": []string{"GIFTCARDS_READ"}, + "RetrieveGiftCardFromGAN": []string{"GIFTCARDS_READ"}, + "RetrieveGiftCardFromNonce": []string{"GIFTCARDS_READ"}, + "LinkCustomerToGiftCard": []string{"GIFTCARDS_WRITE"}, + "UnlinkCustomerFromGiftCard": []string{"GIFTCARDS_WRITE"}, + }, + }, + { + "Gift Card Activities": { + "ListGiftCardActivities": []string{"GIFTCARDS_READ"}, + "CreateGiftCardActivity": []string{"GIFTCARDS_WRITE"}, + }, + }, + { + "Inventory": { + "BatchChangeInventory": []string{"INVENTORY_WRITE"}, + "BatchRetrieveInventoryCounts": []string{"INVENTORY_READ"}, + "BatchRetrieveInventoryChanges": []string{"INVENTORY_READ"}, + "RetrieveInventoryAdjustment": []string{"INVENTORY_READ"}, + "RetrieveInventoryChanges": []string{"INVENTORY_READ"}, + "RetrieveInventoryCount": []string{"INVENTORY_READ"}, + "RetrieveInventoryPhysicalCount": []string{"INVENTORY_READ"}, + }, + }, + { + "Invoices": { + "CreateInvoice": []string{"INVOICES_WRITE", "ORDERS_WRITE"}, + "PublishInvoice": []string{"CUSTOMERS_READ", "PAYMENTS_WRITE", "INVOICES_WRITE", "ORDERS_WRITE"}, + "GetInvoice": []string{"INVOICES_READ"}, + "ListInvoices": []string{"INVOICES_READ"}, + "SearchInvoices": []string{"INVOICES_READ"}, + "CreateInvoiceAttachment": []string{"INVOICES_WRITE", "ORDERS_WRITE"}, + "DeleteInvoiceAttachment": []string{"INVOICES_WRITE", "ORDERS_WRITE"}, + "UpdateInvoice": []string{"INVOICES_WRITE", "ORDERS_WRITE"}, + "DeleteInvoice": []string{"INVOICES_WRITE", "ORDERS_WRITE"}, + "CancelInvoice": []string{"INVOICES_WRITE", "ORDERS_WRITE"}, + }, + }, + { + "Labor": { + "CreateBreakType": []string{"TIMECARDS_SETTINGS_WRITE"}, + "CreateShift": []string{"TIMECARDS_WRITE"}, + "DeleteBreakType": []string{"TIMECARDS_SETTINGS_WRITE"}, + "DeleteShift": []string{"TIMECARDS_WRITE"}, + "GetBreakType": []string{"TIMECARDS_SETTINGS_READ"}, + "GetTeamMemberWage": []string{"EMPLOYEES_READ"}, + "GetShift": []string{"TIMECARDS_READ"}, + "ListBreakTypes": []string{"TIMECARDS_SETTINGS_READ"}, + "ListTeamMemberWages": []string{"EMPLOYEES_READ"}, + "ListWorkweekConfigs": []string{"TIMECARDS_SETTINGS_READ"}, + "SearchShifts": []string{"TIMECARDS_READ"}, + "UpdateShift": []string{"TIMECARDS_WRITE", "TIMECARDS_READ"}, + "UpdateWorkweekConfig": []string{"TIMECARDS_SETTINGS_WRITE", "TIMECARDS_SETTINGS_READ"}, + "UpdateBreakType": []string{"TIMECARDS_SETTINGS_WRITE", "TIMECARDS_SETTINGS_READ"}, + }, + }, + { + "Locations": { + "CreateLocation": []string{"MERCHANT_PROFILE_WRITE"}, + "ListLocations": []string{"MERCHANT_PROFILE_READ"}, + "RetrieveLocation": []string{"MERCHANT_PROFILE_READ"}, + "UpdateLocation": []string{"MERCHANT_PROFILE_WRITE"}, + }, + }, + { + "Location Custom Attributes": { + "CreateLocationCustomAttributeDefinition": []string{"MERCHANT_PROFILE_WRITE"}, + "UpdateLocationCustomAttributeDefinition": []string{"MERCHANT_PROFILE_WRITE"}, + "ListLocationCustomAttributeDefinitions": []string{"MERCHANT_PROFILE_READ"}, + "RetrieveLocationCustomAttributeDefinition": []string{"MERCHANT_PROFILE_READ"}, + "DeleteLocationCustomAttributeDefinition": []string{"MERCHANT_PROFILE_WRITE"}, + "UpsertLocationCustomAttribute": []string{"MERCHANT_PROFILE_WRITE"}, + "BulkUpsertLocationCustomAttributes": []string{"MERCHANT_PROFILE_WRITE"}, + "ListLocationCustomAttributes": []string{"MERCHANT_PROFILE_READ"}, + "RetrieveLocationCustomAttribute": []string{"MERCHANT_PROFILE_READ"}, + "DeleteLocationCustomAttribute": []string{"MERCHANT_PROFILE_WRITE"}, + "BulkDeleteLocationCustomAttributes": []string{"MERCHANT_PROFILE_WRITE"}, + }, + }, + { + "Loyalty": { + "RetrieveLoyaltyProgram": []string{"LOYALTY_READ"}, + "ListLoyaltyPrograms (deprecated)": []string{"LOYALTY_READ"}, + "CreateLoyaltyPromotion": []string{"LOYALTY_WRITE"}, + "ListLoyaltyPromotions": []string{"LOYALTY_READ"}, + "RetrieveLoyaltyPromotion": []string{"LOYALTY_READ"}, + "CancelLoyaltyPromotion": []string{"LOYALTY_WRITE"}, + "CreateLoyaltyAccount": []string{"LOYALTY_WRITE"}, + "RetrieveLoyaltyAccount": []string{"LOYALTY_READ"}, + "SearchLoyaltyAccounts": []string{"LOYALTY_READ"}, + "AccumulateLoyaltyPoints": []string{"LOYALTY_WRITE"}, + "AdjustLoyaltyPoints": []string{"LOYALTY_WRITE"}, + "CalculateLoyaltyPoints": []string{"LOYALTY_READ"}, + "CreateLoyaltyReward": []string{"LOYALTY_WRITE"}, + "RedeemLoyaltyReward": []string{"LOYALTY_WRITE"}, + "RetrieveLoyaltyReward": []string{"LOYALTY_READ"}, + "SearchLoyaltyRewards": []string{"LOYALTY_READ"}, + "DeleteLoyaltyReward": []string{"LOYALTY_WRITE"}, + "SearchLoyaltyEvents": []string{"LOYALTY_READ"}, + }, + }, + { + "Merchants": { + "ListMerchants": []string{"MERCHANT_PROFILE_READ"}, + "RetrieveMerchant": []string{"MERCHANT_PROFILE_READ"}, + }, + }, + { + "Merchant Custom Attributes": { + "CreateMerchantCustomAttributeDefinition": []string{"MERCHANT_PROFILE_WRITE"}, + "UpdateMerchantCustomAttributeDefinition": []string{"MERCHANT_PROFILE_WRITE"}, + "ListMerchantCustomAttributeDefinitions": []string{"MERCHANT_PROFILE_READ"}, + "RetrieveMerchantCustomAttributeDefinition": []string{"MERCHANT_PROFILE_READ"}, + "DeleteMerchantCustomAttributeDefinition": []string{"MERCHANT_PROFILE_WRITE"}, + "UpsertMerchantCustomAttribute": []string{"MERCHANT_PROFILE_WRITE"}, + "BulkUpsertMerchantCustomAttributes": []string{"MERCHANT_PROFILE_WRITE"}, + "ListMerchantCustomAttributes": []string{"MERCHANT_PROFILE_READ"}, + "RetrieveMerchantCustomAttribute": []string{"MERCHANT_PROFILE_READ"}, + "DeleteMerchantCustomAttribute": []string{"MERCHANT_PROFILE_WRITE"}, + "BulkDeleteMerchantCustomAttributes": []string{"MERCHANT_PROFILE_WRITE"}, + }, + }, + { + "Mobile Authorization": { + "CreateMobileAuthorizationCode": []string{"PAYMENTS_WRITE_IN_PERSON"}, + }, + }, + { + "Orders": { + "CloneOrder": []string{"ORDERS_WRITE"}, + "CreateOrder": []string{"ORDERS_WRITE"}, + "BatchRetrieveOrders": []string{"ORDERS_READ"}, + "PayOrder": []string{"ORDERS_WRITE", "PAYMENTS_WRITE"}, + "RetrieveOrder": []string{"ORDERS_WRITE", "ORDERS_READ"}, + "SearchOrders": []string{"ORDERS_READ"}, + "UpdateOrder": []string{"ORDERS_WRITE"}, + }, + }, + { + "Order Custom Attributes": { + "CreateOrderCustomAttributeDefinition": []string{"ORDERS_WRITE"}, + "UpdateOrderCustomAttributeDefinition": []string{"ORDERS_WRITE"}, + "ListOrderCustomAttributeDefinitions": []string{"ORDERS_READ"}, + "RetrieveOrderCustomAttributeDefinition": []string{"ORDERS_READ"}, + "DeleteOrderCustomAttributeDefinition": []string{"ORDERS_WRITE"}, + "UpsertOrderCustomAttribute": []string{"ORDERS_WRITE"}, + "BulkUpsertOrderCustomAttributes": []string{"ORDERS_WRITE"}, + "ListOrderCustomAttributes": []string{"ORDERS_READ"}, + "RetrieveOrderCustomAttribute": []string{"ORDERS_READ"}, + "DeleteOrderCustomAttribute": []string{"ORDERS_WRITE"}, + "BulkDeleteOrderCustomAttributes": []string{"ORDERS_WRITE"}, + }, + }, + { + "Payments and Refunds": { + "CancelPayment": []string{"PAYMENTS_WRITE"}, + "CancelPaymentByIdempotencyKey": []string{"PAYMENTS_WRITE"}, + "CompletePayment": []string{"PAYMENTS_WRITE"}, + "CreatePayment": []string{"PAYMENTS_WRITE", "PAYMENTS_WRITE_SHARED_ONFILE", "PAYMENTS_WRITE_ADDITIONAL_RECIPIENTS"}, + "GetPayment": []string{"PAYMENTS_READ"}, + "GetPaymentRefund": []string{"PAYMENTS_READ"}, + "ListPayments": []string{"PAYMENTS_READ"}, + "ListPaymentRefunds": []string{"PAYMENTS_READ"}, + "RefundPayment": []string{"PAYMENTS_WRITE", "PAYMENTS_WRITE_ADDITIONAL_RECIPIENTS"}, + }, + }, + { + "Payouts": { + "ListPayouts": []string{"PAYOUTS_READ"}, + "GetPayout": []string{"PAYOUTS_READ"}, + "ListPayoutEntries": []string{"PAYOUTS_READ"}, + }, + }, + { + "Sites": { + "ListSites": []string{"ONLINE_STORE_SITE_READ"}, + }, + }, + { + "Snippets": { + "UpsertSnippet": []string{"ONLINE_STORE_SNIPPETS_WRITE"}, + "RetrieveSnippet": []string{"ONLINE_STORE_SNIPPETS_READ"}, + "DeleteSnippet": []string{"ONLINE_STORE_SNIPPETS_WRITE"}, + }, + }, + { + "Subscriptions": { + "CreateSubscription": []string{"CUSTOMERS_READ", "PAYMENTS_WRITE", "SUBSCRIPTIONS_WRITE", "ITEMS_READ", "ORDERS_WRITE", "INVOICES_WRITE"}, + "SearchSubscriptions": []string{"SUBSCRIPTIONS_READ"}, + "RetrieveSubscription": []string{"SUBSCRIPTIONS_READ"}, + "UpdateSubscription": []string{"CUSTOMERS_READ", "PAYMENTS_WRITE", "SUBSCRIPTIONS_WRITE", "ITEMS_READ", "ORDERS_WRITE", "INVOICES_WRITE"}, + "CancelSubscription": []string{"SUBSCRIPTIONS_WRITE"}, + "ListSubscriptionEvents": []string{"SUBSCRIPTIONS_READ"}, + "ResumeSubscription": []string{"CUSTOMERS_READ", "PAYMENTS_WRITE", "SUBSCRIPTIONS_WRITE", "ITEMS_READ", "ORDERS_WRITE", "INVOICES_WRITE"}, + "PauseSubscription": []string{"CUSTOMERS_READ", "PAYMENTS_WRITE", "SUBSCRIPTIONS_WRITE", "ITEMS_READ", "ORDERS_WRITE", "INVOICES_WRITE"}, + "SwapPlan": []string{"CUSTOMERS_READ", "PAYMENTS_WRITE", "SUBSCRIPTIONS_WRITE", "ITEMS_READ", "ORDERS_WRITE", "INVOICES_WRITE"}, + "DeleteSubscriptionAction": []string{"SUBSCRIPTIONS_WRITE"}, + }, + }, + { + "Team": { + "BulkCreateTeamMembers": []string{"EMPLOYEES_WRITE"}, + "BulkUpdateTeamMembers": []string{"EMPLOYEES_WRITE"}, + "CreateTeamMember": []string{"EMPLOYEES_WRITE"}, + "UpdateTeamMember": []string{"EMPLOYEES_WRITE"}, + "RetrieveTeamMember": []string{"EMPLOYEES_READ"}, + "RetrieveWageSetting": []string{"EMPLOYEES_READ"}, + "SearchTeamMembers": []string{"EMPLOYEES_READ"}, + "UpdateWageSetting": []string{"EMPLOYEES_WRITE"}, + }, + }, + { + "Terminal": { + "CreateTerminalCheckout": []string{"PAYMENTS_WRITE"}, + "CancelTerminalCheckout": []string{"PAYMENTS_WRITE"}, + "GetTerminalCheckout": []string{"PAYMENTS_READ"}, + "SearchTerminalCheckouts": []string{"PAYMENTS_READ"}, + "CreateTerminalRefund": []string{"PAYMENTS_WRITE"}, + "CancelTerminalRefund": []string{"PAYMENTS_WRITE"}, + "GetTerminalRefund": []string{"PAYMENTS_READ"}, + "SearchTerminalRefunds": []string{"PAYMENTS_READ"}, + "CreateTerminalAction": []string{"PAYMENTS_WRITE"}, + "CancelTerminalAction": []string{"PAYMENTS_WRITE"}, + "GetTerminalAction": []string{"PAYMENTS_READ", "CUSTOMERS_READ"}, + "SearchTerminalAction": []string{"PAYMENTS_READ"}, + }, + }, + { + "Vendors": { + "BulkCreateVendors": []string{"VENDOR_WRITE"}, + "BulkRetrieveVendors": []string{"VENDOR_READ"}, + "BulkUpdateVendors": []string{"VENDOR_WRITE"}, + "CreateVendor": []string{"VENDOR_WRITE"}, + "SearchVendors": []string{"VENDOR_READ"}, + "RetrieveVendor": []string{"VENDOR_READ"}, + "UpdateVendors": []string{"VENDOR_WRITE"}, + }, + }, +} diff --git a/pkg/analyzer/analyzers/square/square.go b/pkg/analyzer/analyzers/square/square.go new file mode 100644 index 000000000..94ece11b7 --- /dev/null +++ b/pkg/analyzer/analyzers/square/square.go @@ -0,0 +1,191 @@ +package square + +import ( + "encoding/json" + "net/http" + "os" + "strconv" + "strings" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +type TeamJSON struct { + TeamMembers []struct { + IsOwner bool `json:"is_owner"` + FirstName string `json:"given_name"` + LastName string `json:"family_name"` + Email string `json:"email_address"` + CreatedAt string `json:"created_at"` + } `json:"team_members"` +} + +type PermissionsJSON struct { + Scopes []string `json:"scopes"` + ExpiresAt string `json:"expires_at"` + ClientID string `json:"client_id"` + MerchantID string `json:"merchant_id"` +} + +func getPermissions(cfg *config.Config, key string) (PermissionsJSON, error) { + var permissions PermissionsJSON + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("POST", "https://connect.squareup.com/oauth2/token/status", nil) + if err != nil { + return permissions, err + } + + req.Header.Add("Authorization", "Bearer "+key) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Square-Version", "2024-06-04") + + resp, err := client.Do(req) + if err != nil { + return permissions, err + } + + if resp.StatusCode != 200 { + return permissions, nil + } + + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&permissions) + if err != nil { + return permissions, err + } + return permissions, nil +} + +func getUsers(cfg *config.Config, key string) (TeamJSON, error) { + var team TeamJSON + + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("POST", "https://connect.squareup.com/v2/team-members/search", nil) + if err != nil { + return team, err + } + + req.Header.Add("Authorization", "Bearer "+key) + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Square-Version", "2024-06-04") + + q := req.URL.Query() + q.Add("limit", "200") + req.URL.RawQuery = q.Encode() + + resp, err := client.Do(req) + if err != nil { + return team, err + } + + if resp.StatusCode != 200 { + return team, nil + } + + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(&team) + if err != nil { + return team, err + } + return team, nil +} + +func AnalyzePermissions(cfg *config.Config, key string) { + permissions, err := getPermissions(cfg, key) + if err != nil { + color.Red("Error: %s", err) + return + } + + if permissions.MerchantID == "" { + color.Red("[x] Invalid Square API Key") + return + } + color.Green("[!] Valid Square API Key\n\n") + color.Yellow("Merchant ID: %s", permissions.MerchantID) + color.Yellow("Client ID: %s", permissions.ClientID) + if permissions.ExpiresAt == "" { + color.Green("Expires: Never\n\n") + } else { + color.Yellow("Expires: %s\n\n", permissions.ExpiresAt) + } + printPermissions(permissions.Scopes, cfg.ShowAll) + + team, err := getUsers(cfg, key) + if err != nil { + color.Red("Error: %s", err) + return + } + printTeamMembers(team) +} + +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +func printPermissions(scopes []string, showAll bool) { + isAccessToken := true + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"API Category", "Accessible Endpoints"}) + for _, permissions_slice := range permissions_slice { + for category, permissions := range permissions_slice { + accessibleEndpoints := []string{} + for endpoint, requiredPermissions := range permissions { + hasAllPermissions := true + for _, permission := range requiredPermissions { + if !contains(scopes, permission) { + hasAllPermissions = false + isAccessToken = false + break + } + } + if hasAllPermissions { + accessibleEndpoints = append(accessibleEndpoints, endpoint) + } + } + if len(accessibleEndpoints) == 0 { + t.AppendRow([]interface{}{category, ""}) + } else { + t.AppendRow([]interface{}{color.GreenString(category), color.GreenString(strings.Join(accessibleEndpoints, ", "))}) + } + } + } + if isAccessToken { + color.Green("[i] Permissions: Full Access") + } else { + color.Yellow("[i] Permissions:") + } + if !isAccessToken || showAll { + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 2, WidthMax: 100}, + }) + t.Render() + } +} + +func printTeamMembers(team TeamJSON) { + if len(team.TeamMembers) == 0 { + color.Red("\n[x] No team members found") + return + } + color.Yellow("\n[i] Team Members (don't imply any permissions)") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"First Name", "Last Name", "Email", "Owner", "Created At"}) + for _, member := range team.TeamMembers { + t.AppendRow([]interface{}{color.GreenString(member.FirstName), color.GreenString(member.LastName), color.GreenString(member.Email), color.GreenString(strconv.FormatBool(member.IsOwner)), color.GreenString(member.CreatedAt)}) + } + t.Render() +} diff --git a/pkg/analyzer/analyzers/stripe/restricted.yaml b/pkg/analyzer/analyzers/stripe/restricted.yaml new file mode 100644 index 000000000..9608d1dcb --- /dev/null +++ b/pkg/analyzer/analyzers/stripe/restricted.yaml @@ -0,0 +1,1416 @@ +categories: + Core: + Apple Pay Domains: + Read: + Scope: rak_apple_pay_domain_read + Endpoint: https://api.stripe.com/v1/apple_pay/domains + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://any-api.com/stripe_com/stripe_com/docs/_v1_apple_pay_domains/GetApplePayDomains + Note: '' + Write: + Scope: rak_apple_pay_domain_write + Endpoint: https://api.stripe.com/v1/apple_pay/domains + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: '' + Note: '' + Balance: + Read: + Scope: rak_balance_read + Endpoint: https://api.stripe.com/v1/balance + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/balance + Note: '' + Balance transaction sources: + Read: + Scope: '' + Endpoint: '' + Method: '' + Payload: '' + Valid: [] + Invalid: [] + Docs: Can't find a relevant endpoint + Note: 'I think we just build this one based off of all the others? Note that + this permission also implies the following permissions: Application Fees + (Read), Balance (Read), Financing Transactions (Read), Payouts (Read), Transfers + (Read), and Balance Transfers (Read)' + Balance Transfer: + Read: + Scope: '' + Endpoint: '' + Method: '' + Payload: '' + Valid: [] + Invalid: [] + Docs: Can't find a relevant endpoint + Note: Not sure this exists anymore + Write: + Scope: '' + Endpoint: '' + Method: '' + Payload: '' + Valid: [] + Invalid: [] + Docs: Can't find a relevant endpoint + Note: Not sure this exists anymore + Test clocks: + Read: + Scope: rak_billing_clock_read + Endpoint: https://api.stripe.com/v1/test_helpers/test_clocks + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/test_clocks/list + Note: '' + Write: + Scope: rak_billing_clock_write + Endpoint: https://api.stripe.com/v1/test_helpers/test_clocks + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/test_clocks/create + Note: '' + Charges: + Read: + Scope: rak_charge_read + Endpoint: https://api.stripe.com/v1/charges + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/charges/list + Note: '' + Write: + Scope: rak_charge_write + Endpoint: https://api.stripe.com/v1/charges + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/charges/update + Note: '' + Confirmation token: + Read: + Scope: rak_confirmation_token_read + Endpoint: https://api.stripe.com/v1/confirmation_tokens/nowaythiscanexist + Method: GET + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/confirmation_tokens/retrieve + Note: '' + Confirmation token (client): + Read: + Scope: '' + Endpoint: '' + Method: '' + Payload: '' + Valid: [] + Invalid: [] + Docs: Can't find a relevant endpoint + Note: Not sure this exists anymore + Write: + Scope: rak_confirmation_token_client_write + Endpoint: https://api.stripe.com/v1/test_helpers/confirmation_tokens + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/confirmation_tokens/test_create + Note: '' + Customers: + Read: + Scope: rak_customer_read + Endpoint: https://api.stripe.com/v1/customers + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/customers/list + Note: '' + Write: + Scope: rak_customer_write + Endpoint: https://api.stripe.com/v1/customers/nowaythiscanexist + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/customers/update + Note: Couldn't use "Create Customer", b/c default with no payload creates + a customer. + Customer session: + Read: + Scope: '' + Endpoint: '' + Method: '' + Payload: '' + Valid: [] + Invalid: [] + Docs: Can't find a relevant endpoint + Note: Not sure this exists anymore + Write: + Scope: rak_customer_session_write + Endpoint: https://api.stripe.com/v1/customer_sessions + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/customer_sessions/create + Note: '' + Disputes: + Read: + Scope: rak_dispute_read + Endpoint: https://api.stripe.com/v1/disputes + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/disputes/list + Note: '' + Write: + Scope: rak_dispute_write + Endpoint: https://api.stripe.com/v1/disputes/nowaycanthisexist + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/disputes/update + Note: '' + Events: + Read: + Scope: rak_event_read + Endpoint: https://api.stripe.com/v1/events + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/events/list + Note: '' + Ephemeral keys: + Read: + Scope: '' + Endpoint: '' + Method: '' + Payload: '' + Valid: [] + Invalid: [] + Docs: Can't find a relevant endpoint + Note: '' + Write: + Scope: rak_ephemeral_key_write + Endpoint: https://api.stripe.com/v1/ephemeral_keys + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://any-api.com/stripe_com/stripe_com/docs/_v1_ephemeral_keys_key_ + Note: '' + Files: + Read: + Scope: rak_file_read + Endpoint: https://api.stripe.com/v1/files + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: '' + Note: '' + Write: + Scope: '' + Endpoint: https://files.stripe.com/v1/files + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: '' + Note: On 403, it mistakenly says "rak_dispute_write" missing + Funding Instructions: + Read: + Scope: '' + Endpoint: https://api.stripe.com/v1/issuing/funding_instructions + Method: GET + Payload: '' + Valid: + - 200 + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/funding_instructions/list + Note: On 403, it mistakently says "rak_topup_read" + Write: + Scope: '' + Endpoint: https://api.stripe.com/v1/issuing/funding_instructions + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/funding_instructions/create + Note: Same as read but says "write" + PaymentIntents: + Read: + Scope: rak_payment_intent_read + Endpoint: https://api.stripe.com/v1/payment_intents + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/payment_intents/list + Note: '' + Write: + Scope: rak_payment_intent_write + Endpoint: https://api.stripe.com/v1/payment_intents + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/payment_intents/create + Note: '' + PaymentMethods: + Read: + Scope: rak_payment_method_read + Endpoint: https://api.stripe.com/v1/payment_methods + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://any-api.com/stripe_com/stripe_com/docs/_v1_payment_methods/GetPaymentMethods + Note: '' + Write: + Scope: rak_payment_method_write + Endpoint: https://api.stripe.com/v1/payment_methods/nowaycanthisexist + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://any-api.com/stripe_com/stripe_com/docs/_v1_payment_methods_payment_method_/PostPaymentMethodsPaymentMethod + Note: '' + Payment Method Domains: + Read: + Scope: '' + Endpoint: https://api.stripe.com/v1/payment_method_domains + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/payment_method_domains/list + Note: '' + Write: + Scope: '' + Endpoint: https://api.stripe.com/v1/payment_method_domains + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/payment_method_domains/create + Note: '' + Payouts: + Read: + Scope: rak_payout_read + Endpoint: https://api.stripe.com/v1/payouts + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/payouts/list + Note: '' + Write: + Scope: rak_payout_write + Endpoint: https://api.stripe.com/v1/payouts + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/payouts/create + Note: '' + Products: + Read: + Scope: rak_product_read + Endpoint: https://api.stripe.com/v1/products + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/products/list + Note: '' + Write: + Scope: rak_product_write + Endpoint: https://api.stripe.com/v1/products + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/products/create + Note: '' + Shipping Rates: + Read: + Scope: rak_shipping_rate_read + Endpoint: https://api.stripe.com/v1/shipping_rates + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/shipping_rates/list + Note: '' + Write: + Scope: rak_shipping_rate_write + Endpoint: https://api.stripe.com/v1/shipping_rates + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/shipping_rates/create + Note: '' + SetupIntents: + Read: + Scope: rak_setup_intent_read + Endpoint: https://api.stripe.com/v1/setup_intents + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/setup_intents/list + Note: '' + Write: + Scope: rak_setup_intent_write + Endpoint: https://api.stripe.com/v1/setup_intents/nowaycanthisexist + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/setup_intents/create + Note: '' + Sources: + Read: + Scope: rak_source_read + Endpoint: https://api.stripe.com/v1/sources/nowaycanthisexist + Method: GET + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/sources/retrieve + Note: '' + Write: + Scope: rak_source_write + Endpoint: https://api.stripe.com/v1/sources/nowaycanthisexist + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/sources/update + Note: '' + Tokens: + Read: + Scope: rak_token_read + Endpoint: https://api.stripe.com/v1/tokens/nowaycanthisexist + Method: GET + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/tokens/retrieve + Note: '' + Write: + Scope: rak_token_write + Endpoint: https://api.stripe.com/v1/tokens + Method: POST + Payload: '"card[number]"=4242424242424242' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/tokens/create_card + Note: '' + Checkout: + Checkout Sessions: + Read: + Scope: rak_checkout_session_read + Endpoint: https://api.stripe.com/v1/checkout/sessions + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/checkout/sessions/list + Note: '' + Write: + Scope: rak_checkout_session_write + Endpoint: https://api.stripe.com/v1/checkout/sessions + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/checkout/sessions/create + Note: '' + Billing: + Coupons: + Read: + Scope: rak_coupon_read + Endpoint: https://api.stripe.com/v1/coupons + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/coupons/list + Note: '' + Write: + Scope: rak_coupon_write + Endpoint: https://api.stripe.com/v1/coupons + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/coupons/create + Note: '' + Promotion Codes: + Read: + Scope: rak_promotion_code_read + Endpoint: https://api.stripe.com/v1/promotion_codes + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/promotion_codes/list + Note: '' + Write: + Scope: rak_promotion_code_write + Endpoint: https://api.stripe.com/v1/promotion_codes + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/promotion_codes/create + Note: '' + Credit notes: + Read: + Scope: rak_credit_note_read + Endpoint: https://api.stripe.com/v1/credit_notes + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/credit_notes/list + Note: '' + Write: + Scope: rak_credit_note_write + Endpoint: https://api.stripe.com/v1/credit_notes/nowaythiscanexsit + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/credit_notes/update + Note: '' + Customer portal: + Read: + Scope: '' + Endpoint: '' + Method: '' + Payload: '' + Valid: [] + Invalid: [] + Docs: Can't find a relevant endpoint + Note: '' + Write: + Scope: rak_customer_portal_write + Endpoint: https://api.stripe.com/v1/billing_portal/sessions + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/customer_portal/sessions/create + Note: '' + Invoices: + Read: + Scope: '' + Endpoint: https://api.stripe.com/v1/invoices + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/invoices/list + Note: Wrong scope in error message. + Write: + Scope: rak_invoice_write + Endpoint: https://api.stripe.com/v1/invoices + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/invoices/create + Note: '' + Prices: + Read: + Scope: rak_plan_read + Endpoint: https://api.stripe.com/v1/prices + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/prices/list + Note: '' + Write: + Scope: rak_plan_write + Endpoint: https://api.stripe.com/v1/prices + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/prices/create + Note: '' + Subscriptions: + Read: + Scope: rak_subscription_read + Endpoint: https://api.stripe.com/v1/subscriptions + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/subscriptions/list + Note: '' + Write: + Scope: rak_subscription_write + Endpoint: https://api.stripe.com/v1/subscriptions + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/subscriptions/create + Note: '' + Quote: + Read: + Scope: rak_quote_read + Endpoint: https://api.stripe.com/v1/quotes + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/quotes/list + Note: '' + Write: + Scope: rak_quote_write + Endpoint: https://api.stripe.com/v1/quotes/nowaythiscanexist + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/quotes/update + Note: '' + Tax IDs: + Read: + Scope: rak_tax_id_read + Endpoint: https://api.stripe.com/v1/tax_ids + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/tax_ids/list + Note: '' + Write: + Scope: rak_tax_id_write + Endpoint: https://api.stripe.com/v1/tax_ids + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/tax_ids/create + Note: '' + Tax Rates: + Read: + Scope: rak_tax_rate_read + Endpoint: https://api.stripe.com/v1/tax_rates + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/tax_rates/list + Note: '' + Write: + Scope: rak_tax_rate_write + Endpoint: https://api.stripe.com/v1/tax_rates + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/tax_rates/create + Note: '' + Usage Records: + Read: + Scope: rak_usage_record_read + Endpoint: https://api.stripe.com/v1/subscription_items/nowaythiscanexist/usage_record_summaries + Method: GET + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/usage_records/subscription_item_summary_list + Note: '' + Write: + Scope: rak_usage_record_write + Endpoint: https://api.stripe.com/v1/subscription_items/nowaythiscanexist/usage_records + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/usage_records/create + Note: '' + Meters: + Read: + Scope: rak_billing_meter_read + Endpoint: https://api.stripe.com/v1/billing/meters + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/billing/meter/list + Note: '' + Write: + Scope: rak_billing_meter_write + Endpoint: https://api.stripe.com/v1/billing/meters + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/billing/meter/create + Note: '' + Meter Events: + Read: + Scope: rak_billing_meter_event_read + Endpoint: https://api.stripe.com/v1/billing/meters/nowaythiscanexist/event_summaries + Method: GET + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/billing/meter-event_summary/list + Note: '' + Write: + Scope: rak_billing_meter_event_write + Endpoint: https://api.stripe.com/v1/billing/meter_events + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/billing/meter-event/create + Note: '' + Meter Event Adjustments: + Write: + Scope: rak_billing_meter_event_adjustment_write + Endpoint: https://api.stripe.com/v1/billing/meter_event_adjustments + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/billing/meter-event_adjustment/create + Note: '' + Connect: + Application Fees: + Read: + Scope: rak_application_fee_read + Endpoint: https://api.stripe.com/v1/application_fees + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/application_fees/list + Note: '' + Write: + Scope: rak_application_fee_write + Endpoint: https://api.stripe.com/v1/application_fees/nowaythiscanexist/refunds + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/fee_refunds/create + Note: '' + Login Links: + Write: + Scope: rak_edit_link_write + Endpoint: https://api.stripe.com/v1/account/login_links + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://any-api.com/stripe_com/stripe_com/docs/_v1_account_login_links/PostAccountLoginLinks + Note: '' + Account Links: + Write: + Scope: rak_account_link_write + Endpoint: https://api.stripe.com/v1/account_links + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/account_links + Note: '' + Top-ups: + Read: + Scope: rak_topup_read + Endpoint: https://api.stripe.com/v1/topups + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/topups/list + Note: '' + Write: + Scope: rak_topup_write + Endpoint: https://api.stripe.com/v1/topups + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/topups/create + Note: '' + Transfers: + Read: + Scope: rak_transfer_read + Endpoint: https://api.stripe.com/v1/transfers + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/transfers/list + Note: '' + Write: + Scope: rak_transfer_write + Endpoint: https://api.stripe.com/v1/transfers + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/transfers/create + Note: '' + Orders: + Orders: + Read: + Scope: rak_order_read + Endpoint: https://api.stripe.com/v1/orders + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://any-api.com/stripe_com/stripe_com/docs/_v1_orders/GetOrders + Note: '' + Write: + Scope: rak_order_write + Endpoint: https://api.stripe.com/v1/orders + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://any-api.com/stripe_com/stripe_com/docs/_v1_orders/PostOrders + Note: '' + SKUs: + Read: + Scope: '' + Endpoint: '' + Method: '' + Payload: '' + Valid: [] + Invalid: [] + Docs: Can't find a relevant endpoint + Note: Seems like any key has 200 over these. + Write: + Scope: rak_sku_write + Endpoint: https://api.stripe.com/v1/skus + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://any-api.com/stripe_com/stripe_com/docs/_v1_skus/PostSkus + Note: '' + Issuing: + Authorizations: + Read: + Scope: rak_issuing_authorization_read + Endpoint: https://api.stripe.com/v1/issuing/authorizations/nowaythiscanexist + Method: GET + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/authorizations/retrieve + Note: '' + Write: + Scope: rak_issuing_authorization_write + Endpoint: https://api.stripe.com/v1/issuing/authorizations/nowaythiscanexist + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/authorizations/update + Note: '' + Cardholders: + Read: + Scope: rak_issuing_cardholder_read + Endpoint: https://api.stripe.com/v1/issuing/cardholders/nowaythiscanexist + Method: GET + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/cardholders/retrieve + Note: '' + Write: + Scope: rak_issuing_cardholder_write + Endpoint: https://api.stripe.com/v1/issuing/cardholders + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/cardholders/create + Note: '' + Cards: + Read: + Scope: rak_issuing_card_read + Endpoint: https://api.stripe.com/v1/issuing/cards/nowaythiscanexist + Method: GET + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/cards/retrieve + Note: '' + Write: + Scope: rak_issuing_card_write + Endpoint: https://api.stripe.com/v1/issuing/cards + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/cards/create + Note: '' + Disputes: + Read: + Scope: rak_issuing_dispute_read + Endpoint: https://api.stripe.com/v1/issuing/disputes/nowaythiscanexist + Method: GET + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/disputes/retrieve + Note: '' + Write: + Scope: rak_issuing_dispute_write + Endpoint: https://api.stripe.com/v1/issuing/disputes/nowaythiscanexist + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/disputes/update + Note: '' + Tokens: + Read: + Scope: rak_issuing_network_token_read + Endpoint: https://api.stripe.com/v1/issuing/tokens/nowaythiscanexist + Method: GET + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/tokens/retrieve + Note: '' + Write: + Scope: rak_issuing_network_token_write + Endpoint: https://api.stripe.com/v1/issuing/tokens/nowaythiscanexist + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/tokens/update + Note: '' + Token Network Data: + Read: + Scope: '' + Endpoint: '' + Method: '' + Payload: '' + Valid: [] + Invalid: [] + Docs: Can't find a relevant endpoint + Note: '' + Transactions: + Read: + Scope: rak_issuing_transaction_read + Endpoint: https://api.stripe.com/v1/issuing/transactions/nowaythiscanexist + Method: GET + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/transactions/retrieve + Note: '' + Write: + Scope: rak_issuing_transaction_write + Endpoint: https://api.stripe.com/v1/issuing/transactions/nowaythiscanexist + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/issuing/transactions/update + Note: '' + Reporting: + Report Runs and Report Types: + Read: + Scope: rak_financial_statement_read + Endpoint: https://api.stripe.com/v1/reporting/report_runs + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/reporting/report_run/list + Note: '' + Identity: + Verification Sessions and Reports: + Read: + Scope: rak_identity_product_read + Endpoint: https://api.stripe.com/v1/identity/verification_sessions + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/identity/verification_sessions/list + Note: '' + Write: + Scope: rak_identity_product_write + Endpoint: https://api.stripe.com/v1/identity/verification_sessions + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/identity/verification_sessions/create + Note: '' + Access recent detailed verification results: + Read: + Scope: '' + Endpoint: '' + Method: '' + Payload: '' + Valid: [] + Invalid: [] + Docs: Can't find a relevant endpoint + Note: Skip for now b/c requires account with data + Access all detailed verification results: + Read: + Scope: '' + Endpoint: '' + Method: '' + Payload: '' + Valid: [] + Invalid: [] + Docs: Can't find a relevant endpoint + Note: Skip for now b/c requires account with data + this one requires IP allowlisting + Webhook: + Webhook Endpoints: + Read: + Scope: rak_webhook_read + Endpoint: https://api.stripe.com/v1/webhook_endpoints + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/webhook_endpoints/list + Note: '' + Write: + Scope: rak_webhook_write + Endpoint: https://api.stripe.com/v1/webhook_endpoints + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/webhook_endpoints/create + Note: '' + Stripe CLI: + Debugging tools: + Write: + Scope: '' + Endpoint: '' + Method: '' + Payload: '' + Valid: [] + Invalid: [] + Docs: Can't find a relevant endpoint + Note: '' + Payment Links: + Payment Links: + Read: + Scope: rak_payment_links_read + Endpoint: https://api.stripe.com/v1/payment_links + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/payment_links/payment_links/list + Note: '' + Write: + Scope: rak_payment_links_write + Endpoint: https://api.stripe.com/v1/payment_links + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/payment_links/payment_links/create + Note: '' + Terminal: + Configurations: + Read: + Scope: rak_terminal_configuration_read + Endpoint: https://api.stripe.com/v1/terminal/configurations + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/terminal/configuration/list + Note: '' + Write: + Scope: rak_terminal_configuration_write + Endpoint: https://api.stripe.com/v1/terminal/configurations/nowaythiscanexist + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/terminal/configuration/update + Note: '' + Locations: + Read: + Scope: rak_terminal_location_read + Endpoint: https://api.stripe.com/v1/terminal/locations + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/terminal/locations/list + Note: '' + Write: + Scope: rak_terminal_location_write + Endpoint: https://api.stripe.com/v1/terminal/locations + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/terminal/locations/create + Note: '' + Readers: + Read: + Scope: rak_terminal_reader_read + Endpoint: https://api.stripe.com/v1/terminal/readers + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/terminal/readers/list + Note: '' + Write: + Scope: rak_terminal_reader_write + Endpoint: https://api.stripe.com/v1/terminal/readers + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/terminal/readers/create + Note: '' + Connection Tokens: + Write: + Scope: rak_terminal_connection_token_write + Endpoint: '' + Method: POST + Payload: '' + Valid: [] + Invalid: [] + Docs: '' + Note: Skip b/c requires a state change. + Tax: + Tax Calculations and Transactions: + Read: + Scope: rak_tax_transaction_read + Endpoint: https://api.stripe.com/v1/tax/calculations/nowaycanthisexist/line_items + Method: GET + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/tax/calculations/line_items + Note: '' + Write: + Scope: rak_tax_transaction_write + Endpoint: https://api.stripe.com/v1/tax/calculations + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/tax/calculations/create + Note: '' + Tax Settings and Registrations: + Read: + Scope: rak_tax_settings_read + Endpoint: https://api.stripe.com/v1/tax/settings + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/tax/settings/retrieve + Note: '' + Write: + Scope: rak_tax_settings_write + Endpoint: https://api.stripe.com/v1/tax/registrations/nowaycanthisexist + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/tax/registrations/update + Note: '' + Radar: + Reviews: + Read: + Scope: rak_review_read + Endpoint: https://api.stripe.com/v1/reviews + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/radar/reviews/list + Note: '' + Write: + Scope: rak_review_write + Endpoint: https://api.stripe.com/v1/reviews/nowaycanthisexist/approve + Method: POST + Payload: '' + Valid: + - 404 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/radar/reviews/approve + Note: '' + Climate: + Climate Orders: + Read: + Scope: rak_climate_order_read + Endpoint: https://api.stripe.com/v1/climate/orders + Method: GET + Payload: '' + Valid: + - 200 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/climate/order/list + Note: '' + Write: + Scope: rak_climate_order_write + Endpoint: https://api.stripe.com/v1/climate/orders + Method: POST + Payload: '' + Valid: + - 400 + Invalid: + - 403 + Docs: https://docs.stripe.com/api/climate/order/create + Note: '' diff --git a/pkg/analyzer/analyzers/stripe/stripe.go b/pkg/analyzer/analyzers/stripe/stripe.go new file mode 100644 index 000000000..b96db2c6d --- /dev/null +++ b/pkg/analyzer/analyzers/stripe/stripe.go @@ -0,0 +1,300 @@ +package stripe + +import ( + "bytes" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "gopkg.in/yaml.v2" +) + +const ( + SECRET_PREFIX = "sk_" + PUBLISHABLE_PREFIX = "pk_" + RESTRICTED_PREFIX = "rk_" + LIVE_PREFIX = "live_" + TEST_PREFIX = "test_" + SECRET = "Secret" + PUBLISHABLE = "Publishable" + RESTRICTED = "Restricted" + LIVE = "Live" + TEST = "Test" +) + +//go:embed restricted.yaml +var restrictedConfig []byte + +type Permission struct { + Name string + Value *string +} + +type PermissionsCategory struct { + Name string + Permissions []Permission +} + +type HttpStatusTest struct { + Endpoint string `yaml:"Endpoint"` + Method string `yaml:"Method"` + Payload interface{} `yaml:"Payload"` + ValidStatuses []int `yaml:"Valid"` + InvalidStatuses []int `yaml:"Invalid"` +} + +type Category map[string]map[string]HttpStatusTest + +type Config struct { + Categories map[string]Category `yaml:"categories"` +} + +func (h *HttpStatusTest) RunTest(cfg *config.Config, headers map[string]string) (bool, error) { + // If body data, marshal to JSON + var data io.Reader + if h.Payload != nil { + jsonData, err := json.Marshal(h.Payload) + if err != nil { + return false, err + } + data = bytes.NewBuffer(jsonData) + } + + // Create new HTTP request + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest(h.Method, h.Endpoint, data) + if err != nil { + return false, err + } + + // Add custom headers if provided + for key, value := range headers { + req.Header.Set(key, value) + } + + // Execute HTTP Request + resp, err := client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + // Check response status code + switch { + case StatusContains(resp.StatusCode, h.ValidStatuses): + return true, nil + case StatusContains(resp.StatusCode, h.InvalidStatuses): + return false, nil + default: + fmt.Println(h) + fmt.Println(resp.Body) + fmt.Println(resp.StatusCode) + return false, errors.New("error checking response status code") + } +} + +func StatusContains(status int, vals []int) bool { + for _, v := range vals { + if status == v { + return true + } + } + return false +} + +func checkKeyType(key string) (string, error) { + if strings.HasPrefix(key, SECRET_PREFIX) { + return SECRET, nil + } else if strings.HasPrefix(key, PUBLISHABLE_PREFIX) { + return PUBLISHABLE, nil + } else if strings.HasPrefix(key, RESTRICTED_PREFIX) { + return RESTRICTED, nil + } + return "", errors.New("Invalid Stripe key format") +} + +func checkKeyEnv(key string) (string, error) { + //remove first 3 characters + key = key[3:] + if strings.HasPrefix(key, LIVE_PREFIX) { + return LIVE, nil + } + if strings.HasPrefix(key, TEST_PREFIX) { + return TEST, nil + } + return "", errors.New("invalid Stripe key format") +} + +func checkValidity(cfg *config.Config, key string) (bool, error) { + // Create a new request + client := analyzers.NewAnalyzeClient(cfg) + req, err := http.NewRequest("GET", "https://api.stripe.com/v1/charges", nil) + if err != nil { + color.Red("[x] Error creating request: %s", err.Error()) + return false, err + } + + // Add Authorization header + req.Header.Add("Authorization", "Bearer "+key) + + // Send the request + resp, err := client.Do(req) + if err != nil { + color.Red("[x] Error sending request: %s", err.Error()) + return false, err + } + defer resp.Body.Close() + + // Check the response. Valid is 200 (secret/restricted) or 403 (restricted) + if resp.StatusCode == 200 || resp.StatusCode == 403 { + return true, nil + } + return false, nil +} + +func AnalyzePermissions(cfg *config.Config, key string) { + + // Check if secret, publishable, or restricted key + var keyType, keyEnv string + keyType, err := checkKeyType(key) + if err != nil { + color.Red("[x] ", err.Error()) + return + } + + if keyType == PUBLISHABLE { + color.Red("[x] This is a publishable Stripe key. It is not considered secret.") + return + } + + // Check if live or test key + keyEnv, err = checkKeyEnv(key) + if err != nil { + color.Red("[x] ", err.Error()) + return + } + + // Check if key is valid + valid, err := checkValidity(cfg, key) + if err != nil { + color.Red("[x] ", err.Error()) + return + } + + if !valid { + color.Red("[x] Invalid Stripe API Key\n") + return + } + + color.Green("[!] Valid Stripe API Key\n\n") + + if keyType == SECRET { + color.Green("[i] Key Type: %s", keyType) + } else if keyType == RESTRICTED { + color.Yellow("[i] Key Type: %s", keyType) + } + + if keyEnv == LIVE { + color.Green("[i] Key Environment: %s", keyEnv) + } else if keyEnv == TEST { + color.Red("[i] Key Environment: %s", keyEnv) + } + + fmt.Println("") + + if keyType == SECRET { + color.Green("[i] Permissions: Full Access") + return + } + + permissions, err := getRestrictedPermissions(cfg, key) + if err != nil { + color.Red("[x] Error getting permissions: %s", err.Error()) + return + } + printRestrictedPermissions(permissions, cfg.ShowAll) + // Additional details + // get total customers + // get total charges +} + +func getRestrictedPermissions(cfg *config.Config, key string) ([]PermissionsCategory, error) { + var config Config + if err := yaml.Unmarshal(restrictedConfig, &config); err != nil { + fmt.Println("Error unmarshalling YAML:", err) + return nil, err + } + + output := make([]PermissionsCategory, 0) + + for category, scopes := range config.Categories { + permissions := make([]Permission, 0) + for name, scope := range scopes { + value := "" + testCount := 0 + for typ, test := range scope { + if test.Endpoint == "" { + continue + } + testCount++ + status, err := test.RunTest(cfg, map[string]string{"Authorization": "Bearer " + key}) + if err != nil { + color.Red("[x] Error running test: %s", err.Error()) + return nil, err + } + if status { + value = typ + } + if value == "Write" { + break + } + } + if testCount > 0 { + permissions = append(permissions, Permission{Name: name, Value: &value}) + } + } + output = append(output, PermissionsCategory{Name: category, Permissions: permissions}) + } + + // sort the output + order := []string{"Core", "Checkout", "Billing", "Connect", "Orders", "Issuing", "Reporting", "Identity", "Webhook", "Stripe CLI", "Payment Links", "Terminal", "Tax", "Radar", "Climate"} + // ToDo: order the permissions within each category + + // Create a map for quick lookup of the order + orderMap := make(map[string]int) + for i, name := range order { + orderMap[name] = i + } + + // Sort the categories according to the desired order + sort.Slice(output, func(i, j int) bool { + return orderMap[output[i].Name] < orderMap[output[j].Name] + }) + + return output, nil + +} + +func printRestrictedPermissions(permissions []PermissionsCategory, show_all bool) { + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Category", "Permission", "Access"}) + for _, category := range permissions { + for _, permission := range category.Permissions { + if *permission.Value != "" || show_all { + t.AppendRow([]interface{}{category.Name, permission.Name, *permission.Value}) + } + } + } + t.Render() +} diff --git a/pkg/analyzer/analyzers/twilio/twilio.go b/pkg/analyzer/analyzers/twilio/twilio.go new file mode 100644 index 000000000..493c6dacd --- /dev/null +++ b/pkg/analyzer/analyzers/twilio/twilio.go @@ -0,0 +1,160 @@ +package twilio + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/fatih/color" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +type VerifyJSON struct { + Code int `json:"code"` +} + +const ( + AUTHENTICATED_NO_PERMISSION = 70051 + INVALID_CREDENTIALS = 20003 +) + +// splitKey splits the key into SID and Secret +func splitKey(key string) (string, string, error) { + split := strings.Split(key, ":") + if len(split) != 2 { + return "", "", errors.New("key must be in the format SID:Secret") + } + return split[0], split[1], nil +} + +// getAccountsStatusCode returns the status code from the Accounts endpoint +// this is used to determine whether the key is scoped as main or standard, since standard has no access here. +func getAccountsStatusCode(cfg *config.Config, sid string, secret string) (int, error) { + // create http client + client := analyzers.NewAnalyzeClient(cfg) + + // create request + req, err := http.NewRequest("GET", "https://api.twilio.com/2010-04-01/Accounts", nil) + if err != nil { + return 0, err + } + + // add query params + q := req.URL.Query() + q.Add("FriendlyName", "zpoOnD08HdLLZGFnGUMTxbX3qQ1kS") + req.URL.RawQuery = q.Encode() + + // add basicAuth + req.SetBasicAuth(sid, secret) + + // send request + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + return resp.StatusCode, nil +} + +// getVerifyServicesStatusCode returns the status code and the JSON response from the Verify Services endpoint +// only the code value is captured in the JSON response and this is only shown when the key is invalid or has no permissions +func getVerifyServicesStatusCode(cfg *config.Config, sid string, secret string) (VerifyJSON, error) { + var verifyJSON VerifyJSON + + // create http client + client := analyzers.NewAnalyzeClient(cfg) + + // create request + req, err := http.NewRequest("GET", "https://verify.twilio.com/v2/Services", nil) + if err != nil { + return verifyJSON, err + } + + // add query params + q := req.URL.Query() + q.Add("FriendlyName", "zpoOnD08HdLLZGFnGUMTxbX3qQ1kS") + req.URL.RawQuery = q.Encode() + + // add basicAuth + req.SetBasicAuth(sid, secret) + + // send request + resp, err := client.Do(req) + if err != nil { + return verifyJSON, err + } + defer resp.Body.Close() + + // read response + if err := json.NewDecoder(resp.Body).Decode(&verifyJSON); err != nil { + return verifyJSON, err + } + + return verifyJSON, nil +} + +func AnalyzePermissions(cfg *config.Config, key string) { + sid, secret, err := splitKey(key) + if err != nil { + color.Red("[x]" + err.Error()) + return + } + + verifyJSON, err := getVerifyServicesStatusCode(cfg, sid, secret) + if err != nil { + color.Red("[x]" + err.Error()) + return + } + + if verifyJSON.Code == INVALID_CREDENTIALS { + color.Red("[x] Invalid Twilio API Key") + return + } + + if verifyJSON.Code == AUTHENTICATED_NO_PERMISSION { + printRestrictedKeyMsg() + return + } + + statusCode, err := getAccountsStatusCode(cfg, sid, secret) + if err != nil { + color.Red("[x]" + err.Error()) + return + } + printPermissions(statusCode) +} + +// printPermissions prints the permissions based on the status code +// 200 means the key is main, 401 means the key is standard +func printPermissions(statusCode int) { + + if statusCode != 200 && statusCode != 401 { + color.Red("[x] Invalid Twilio API Key") + return + } + + color.Green("[!] Valid Twilio API Key\n") + color.Green("[i] Expires: Never") + + if statusCode == 401 { + color.Yellow("[i] Key type: Standard") + color.Yellow("[i] Permissions: All EXCEPT key management and account/subaccount configuration.") + + } else if statusCode == 200 { + color.Green("[i] Key type: Main (aka Admin)") + color.Green("[i] Permissions: All") + } +} + +// printRestrictedKeyMsg prints the message for a restricted key +// this is a temporary measure since the restricted key type is still in beta +func printRestrictedKeyMsg() { + color.Green("[!] Valid Twilio API Key\n") + color.Green("[i] Expires: Never") + color.Yellow("[i] Key type: Restricted") + color.Yellow("[i] Permissions: Limited") + fmt.Println("[*] Note: Twilio is rolling out a Restricted API Key type, which provides fine-grained control over API endpoints. Since it's still in a Public Beta, this has not been incorporated into this tool.") +} diff --git a/pkg/analyzer/cli.go b/pkg/analyzer/cli.go new file mode 100644 index 000000000..b80ba09c4 --- /dev/null +++ b/pkg/analyzer/cli.go @@ -0,0 +1,250 @@ +package analyzer + +import ( + "github.com/alecthomas/kingpin/v2" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/airbrake" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/asana" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/bitbucket" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/github" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/gitlab" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/huggingface" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/mailchimp" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/mailgun" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/mysql" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/openai" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/opsgenie" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/postgres" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/postman" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/sendgrid" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/shopify" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/slack" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/sourcegraph" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/square" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/stripe" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/twilio" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +var ( + // TODO: Add list of supported key types. + list *kingpin.CmdClause + showAll *bool + log *bool + + githubScan *kingpin.CmdClause + githubKey *string + + sendgridScan *kingpin.CmdClause + sendgridKey *string + + openAIScan *kingpin.CmdClause + openaiKey *string + + postgresScan *kingpin.CmdClause + postgresConnectionStr *string + + mysqlScan *kingpin.CmdClause + mysqlConnectionStr *string + + // mongodbScan *kingpin.CmdClause + // mongodbConnectionStr *string + + slackScan *kingpin.CmdClause + slackKey *string + + twilioScan *kingpin.CmdClause + twilioKey *string + + airbrakeScan *kingpin.CmdClause + airbrakeKey *string + + huggingfaceScan *kingpin.CmdClause + huggingfaceKey *string + + stripeScan *kingpin.CmdClause + stripeKey *string + + gitlabScan *kingpin.CmdClause + gitlabKey *string + + mailchimpScan *kingpin.CmdClause + mailchimpKey *string + + // mandrillScan *kingpin.CmdClause + // mandrillKey *string + + postmanScan *kingpin.CmdClause + postmanKey *string + + bitbucketScan *kingpin.CmdClause + bitbucketKey *string + + asanaScan *kingpin.CmdClause + asanaKey *string + + mailgunScan *kingpin.CmdClause + mailgunKey *string + + squareScan *kingpin.CmdClause + squareKey *string + + sourcegraphScan *kingpin.CmdClause + sourcegraphKey *string + + shopifyScan *kingpin.CmdClause + shopifyKey *string + shopifyStoreURL *string + + opsgenieScan *kingpin.CmdClause + opsgenieKey *string +) + +func Command(app *kingpin.Application) *kingpin.CmdClause { + // TODO: Add list of supported key types. + cli := app.Command("analyze", "Analyze API keys for fine-grained permissions information").Hidden() + list = cli.Command("list", "List supported API providers") + showAll = cli.Flag("show-all", "Show all data, including permissions not available to this account + publicly-available data related to this account.").Default("false").Bool() + log = cli.Flag("log", "Log all HTTP requests sent during analysis to a file").Default("false").Bool() + + githubScan = cli.Command("github", "Scan a GitHub API key") + githubKey = githubScan.Arg("key", "GitHub Key.").Required().String() + + sendgridScan = cli.Command("sendgrid", "Scan a Sendgrid API key") + sendgridKey = sendgridScan.Arg("key", "Sendgrid Key.").Required().String() + + openAIScan = cli.Command("openai", "Scan an OpenAI API key") + openaiKey = openAIScan.Arg("key", "OpenAI Key.").Required().String() + + postgresScan = cli.Command("postgres", "Scan a Postgres connection string") + postgresConnectionStr = postgresScan.Arg("connection-string", "Postgres Connection String. As a reference, here's an example: postgresql://[user[:password]@][netloc][:port][/dbname][?param1=value1&...]").Required().String() + + mysqlScan = cli.Command("mysql", "Scan a MySQL connection string") + mysqlConnectionStr = mysqlScan.Arg("connection-string", "MySQL Connection String. As a reference, here's an example: mysql://[user[:password]@][netloc][:port][/dbname][?param1=value1&...]").Required().String() + + // mongodbScan = cli.Command("mongodb", "Scan a MongoDB connection string") + // mongodbConnectionStr = mongodbScan.Arg("connection-string", "MongoDB Connection String. As a reference, here's an example: mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[defaultauthdb][?options]]").Required().String() + + slackScan = cli.Command("slack", "Scan a Slack API key") + slackKey = slackScan.Arg("key", "Slack Key.").Required().String() + + twilioScan = cli.Command("twilio", "Scan a Twilio API key") + twilioKey = twilioScan.Arg("key", "Twilio API Key SID & Secret (ex: keySID:keySecret).").Required().String() + + airbrakeScan = cli.Command("airbrake", "Scan an Airbrake User Key or Token") + airbrakeKey = airbrakeScan.Arg("key", "Airbrake User Key or Token.").Required().String() + + huggingfaceScan = cli.Command("huggingface", "Scan a Huggingface API key") + huggingfaceKey = huggingfaceScan.Arg("key", "Huggingface Key.").Required().String() + + stripeScan = cli.Command("stripe", "Scan a Stripe API key") + stripeKey = stripeScan.Arg("key", "Stripe Key.").Required().String() + + gitlabScan = cli.Command("gitlab", "Scan a GitLab API key") + gitlabKey = gitlabScan.Arg("key", "GitLab Key.").Required().String() + + mailchimpScan = cli.Command("mailchimp", "Scan a Mailchimp API key") + mailchimpKey = mailchimpScan.Arg("key", "Mailchimp Key.").Required().String() + + // mandrillScan = cli.Command("mandrill", "Scan a Mandrill API key") + // mandrillKey = mandrillScan.Arg("key", "Mandril Key.").Required().String() + + postmanScan = cli.Command("postman", "Scan a Postman API key") + postmanKey = postmanScan.Arg("key", "Postman Key.").Required().String() + + bitbucketScan = cli.Command("bitbucket", "Scan a Bitbucket Access Token") + bitbucketKey = bitbucketScan.Arg("key", "Bitbucket Access Token.").Required().String() + + asanaScan = cli.Command("asana", "Scan an Asana API key") + asanaKey = asanaScan.Arg("key", "Asana Key.").Required().String() + + mailgunScan = cli.Command("mailgun", "Scan a Mailgun API key") + mailgunKey = mailgunScan.Arg("key", "Mailgun Key.").Required().String() + + squareScan = cli.Command("square", "Scan a Square API key") + squareKey = squareScan.Arg("key", "Square Key.").Required().String() + + sourcegraphScan = cli.Command("sourcegraph", "Scan a Sourcegraph Access Token") + sourcegraphKey = sourcegraphScan.Arg("key", "Sourcegraph Access Token.").Required().String() + + shopifyScan = cli.Command("shopify", "Scan a Shopify API key") + shopifyKey = shopifyScan.Arg("key", "Shopify Key.").Required().String() + shopifyStoreURL = shopifyScan.Arg("store-url", "Shopify Store Domain (ex: 22297c-c6.myshopify.com).").Required().String() + + opsgenieScan = cli.Command("opsgenie", "Scan an Opsgenie API key") + opsgenieKey = opsgenieScan.Arg("key", "Opsgenie Key.").Required().String() + + return cli +} + +func Run(cmd string) { + // Initialize configuration + cfg := &config.Config{ + LoggingEnabled: *log, + ShowAll: *showAll, + } + switch cmd { + case list.FullCommand(): + panic("todo") + case githubScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("github") + github.AnalyzePermissions(cfg, *githubKey) + case sendgridScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("sendgrid") + sendgrid.AnalyzePermissions(cfg, *sendgridKey) + case openAIScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("openai") + openai.AnalyzePermissions(cfg, *openaiKey) + case postgresScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("postgres") + postgres.AnalyzePermissions(cfg, *postgresConnectionStr) + case mysqlScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("mysql") + mysql.AnalyzePermissions(cfg, *mysqlConnectionStr) + case slackScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("slack") + slack.AnalyzePermissions(cfg, *slackKey) + case twilioScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("twilio") + twilio.AnalyzePermissions(cfg, *twilioKey) + case airbrakeScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("airbrake") + airbrake.AnalyzePermissions(cfg, *airbrakeKey) + case huggingfaceScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("huggingface") + huggingface.AnalyzePermissions(cfg, *huggingfaceKey) + case stripeScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("stripe") + stripe.AnalyzePermissions(cfg, *stripeKey) + case gitlabScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("gitlab") + gitlab.AnalyzePermissions(cfg, *gitlabKey) + case mailchimpScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("mailchimp") + mailchimp.AnalyzePermissions(cfg, *mailchimpKey) + case postmanScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("postman") + postman.AnalyzePermissions(cfg, *postmanKey) + case bitbucketScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("bitbucket") + bitbucket.AnalyzePermissions(cfg, *bitbucketKey) + case asanaScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("asana") + asana.AnalyzePermissions(cfg, *asanaKey) + case mailgunScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("mailgun") + mailgun.AnalyzePermissions(cfg, *mailgunKey) + case squareScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("square") + square.AnalyzePermissions(cfg, *squareKey) + case sourcegraphScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("sourcegraph") + sourcegraph.AnalyzePermissions(cfg, *sourcegraphKey) + case shopifyScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("shopify") + shopify.AnalyzePermissions(cfg, *shopifyKey, *shopifyStoreURL) + case opsgenieScan.FullCommand(): + cfg.LogFile = analyzers.CreateLogFileName("opsgenie") + opsgenie.AnalyzePermissions(cfg, *opsgenieKey) + } +} diff --git a/pkg/analyzer/config/config.go b/pkg/analyzer/config/config.go new file mode 100644 index 000000000..3189eabec --- /dev/null +++ b/pkg/analyzer/config/config.go @@ -0,0 +1,8 @@ +package config + +// TODO: separate CLI configuration from analysis configuration. +type Config struct { + LoggingEnabled bool + LogFile string + ShowAll bool +} diff --git a/pkg/analyzer/pb/analyzerpb/analyzer.pb.go b/pkg/analyzer/pb/analyzerpb/analyzer.pb.go new file mode 100644 index 000000000..3e73341be --- /dev/null +++ b/pkg/analyzer/pb/analyzerpb/analyzer.pb.go @@ -0,0 +1,203 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.33.0 +// protoc v4.25.3 +// source: analyzer.proto + +package analyzerpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AnalyzerType int32 + +const ( + AnalyzerType_Invalid AnalyzerType = 0 + AnalyzerType_Airbrake AnalyzerType = 1 + AnalyzerType_Asana AnalyzerType = 2 + AnalyzerType_Bitbucket AnalyzerType = 3 + AnalyzerType_GitHub AnalyzerType = 4 + AnalyzerType_GitLab AnalyzerType = 5 + AnalyzerType_HuggingFace AnalyzerType = 6 + AnalyzerType_Mailchimp AnalyzerType = 7 + AnalyzerType_Mailgun AnalyzerType = 8 + AnalyzerType_MySQL AnalyzerType = 9 + AnalyzerType_OpenAI AnalyzerType = 10 + AnalyzerType_Opsgenie AnalyzerType = 11 + AnalyzerType_Postgres AnalyzerType = 12 + AnalyzerType_Postman AnalyzerType = 13 + AnalyzerType_Sendgrid AnalyzerType = 14 + AnalyzerType_Shopify AnalyzerType = 15 + AnalyzerType_Slack AnalyzerType = 16 + AnalyzerType_Sourcegraph AnalyzerType = 17 + AnalyzerType_Square AnalyzerType = 18 + AnalyzerType_Stripe AnalyzerType = 19 + AnalyzerType_Twilio AnalyzerType = 20 +) + +// Enum value maps for AnalyzerType. +var ( + AnalyzerType_name = map[int32]string{ + 0: "Invalid", + 1: "Airbrake", + 2: "Asana", + 3: "Bitbucket", + 4: "GitHub", + 5: "GitLab", + 6: "HuggingFace", + 7: "Mailchimp", + 8: "Mailgun", + 9: "MySQL", + 10: "OpenAI", + 11: "Opsgenie", + 12: "Postgres", + 13: "Postman", + 14: "Sendgrid", + 15: "Shopify", + 16: "Slack", + 17: "Sourcegraph", + 18: "Square", + 19: "Stripe", + 20: "Twilio", + } + AnalyzerType_value = map[string]int32{ + "Invalid": 0, + "Airbrake": 1, + "Asana": 2, + "Bitbucket": 3, + "GitHub": 4, + "GitLab": 5, + "HuggingFace": 6, + "Mailchimp": 7, + "Mailgun": 8, + "MySQL": 9, + "OpenAI": 10, + "Opsgenie": 11, + "Postgres": 12, + "Postman": 13, + "Sendgrid": 14, + "Shopify": 15, + "Slack": 16, + "Sourcegraph": 17, + "Square": 18, + "Stripe": 19, + "Twilio": 20, + } +) + +func (x AnalyzerType) Enum() *AnalyzerType { + p := new(AnalyzerType) + *p = x + return p +} + +func (x AnalyzerType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AnalyzerType) Descriptor() protoreflect.EnumDescriptor { + return file_analyzer_proto_enumTypes[0].Descriptor() +} + +func (AnalyzerType) Type() protoreflect.EnumType { + return &file_analyzer_proto_enumTypes[0] +} + +func (x AnalyzerType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AnalyzerType.Descriptor instead. +func (AnalyzerType) EnumDescriptor() ([]byte, []int) { + return file_analyzer_proto_rawDescGZIP(), []int{0} +} + +var File_analyzer_proto protoreflect.FileDescriptor + +var file_analyzer_proto_rawDesc = []byte{ + 0x0a, 0x0e, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x12, 0x08, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x72, 0x2a, 0xa3, 0x02, 0x0a, 0x0c, 0x41, + 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x49, + 0x6e, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x41, 0x69, 0x72, 0x62, + 0x72, 0x61, 0x6b, 0x65, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x73, 0x61, 0x6e, 0x61, 0x10, + 0x02, 0x12, 0x0d, 0x0a, 0x09, 0x42, 0x69, 0x74, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x10, 0x03, + 0x12, 0x0a, 0x0a, 0x06, 0x47, 0x69, 0x74, 0x48, 0x75, 0x62, 0x10, 0x04, 0x12, 0x0a, 0x0a, 0x06, + 0x47, 0x69, 0x74, 0x4c, 0x61, 0x62, 0x10, 0x05, 0x12, 0x0f, 0x0a, 0x0b, 0x48, 0x75, 0x67, 0x67, + 0x69, 0x6e, 0x67, 0x46, 0x61, 0x63, 0x65, 0x10, 0x06, 0x12, 0x0d, 0x0a, 0x09, 0x4d, 0x61, 0x69, + 0x6c, 0x63, 0x68, 0x69, 0x6d, 0x70, 0x10, 0x07, 0x12, 0x0b, 0x0a, 0x07, 0x4d, 0x61, 0x69, 0x6c, + 0x67, 0x75, 0x6e, 0x10, 0x08, 0x12, 0x09, 0x0a, 0x05, 0x4d, 0x79, 0x53, 0x51, 0x4c, 0x10, 0x09, + 0x12, 0x0a, 0x0a, 0x06, 0x4f, 0x70, 0x65, 0x6e, 0x41, 0x49, 0x10, 0x0a, 0x12, 0x0c, 0x0a, 0x08, + 0x4f, 0x70, 0x73, 0x67, 0x65, 0x6e, 0x69, 0x65, 0x10, 0x0b, 0x12, 0x0c, 0x0a, 0x08, 0x50, 0x6f, + 0x73, 0x74, 0x67, 0x72, 0x65, 0x73, 0x10, 0x0c, 0x12, 0x0b, 0x0a, 0x07, 0x50, 0x6f, 0x73, 0x74, + 0x6d, 0x61, 0x6e, 0x10, 0x0d, 0x12, 0x0c, 0x0a, 0x08, 0x53, 0x65, 0x6e, 0x64, 0x67, 0x72, 0x69, + 0x64, 0x10, 0x0e, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x68, 0x6f, 0x70, 0x69, 0x66, 0x79, 0x10, 0x0f, + 0x12, 0x09, 0x0a, 0x05, 0x53, 0x6c, 0x61, 0x63, 0x6b, 0x10, 0x10, 0x12, 0x0f, 0x0a, 0x0b, 0x53, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x67, 0x72, 0x61, 0x70, 0x68, 0x10, 0x11, 0x12, 0x0a, 0x0a, 0x06, + 0x53, 0x71, 0x75, 0x61, 0x72, 0x65, 0x10, 0x12, 0x12, 0x0a, 0x0a, 0x06, 0x53, 0x74, 0x72, 0x69, + 0x70, 0x65, 0x10, 0x13, 0x12, 0x0a, 0x0a, 0x06, 0x54, 0x77, 0x69, 0x6c, 0x69, 0x6f, 0x10, 0x14, + 0x42, 0x45, 0x5a, 0x43, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, + 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f, 0x74, + 0x72, 0x75, 0x66, 0x66, 0x6c, 0x65, 0x68, 0x6f, 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, + 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x72, 0x2f, 0x70, 0x62, 0x2f, 0x61, 0x6e, 0x61, + 0x6c, 0x79, 0x7a, 0x65, 0x72, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_analyzer_proto_rawDescOnce sync.Once + file_analyzer_proto_rawDescData = file_analyzer_proto_rawDesc +) + +func file_analyzer_proto_rawDescGZIP() []byte { + file_analyzer_proto_rawDescOnce.Do(func() { + file_analyzer_proto_rawDescData = protoimpl.X.CompressGZIP(file_analyzer_proto_rawDescData) + }) + return file_analyzer_proto_rawDescData +} + +var file_analyzer_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_analyzer_proto_goTypes = []interface{}{ + (AnalyzerType)(0), // 0: analyzer.AnalyzerType +} +var file_analyzer_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_analyzer_proto_init() } +func file_analyzer_proto_init() { + if File_analyzer_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_analyzer_proto_rawDesc, + NumEnums: 1, + NumMessages: 0, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_analyzer_proto_goTypes, + DependencyIndexes: file_analyzer_proto_depIdxs, + EnumInfos: file_analyzer_proto_enumTypes, + }.Build() + File_analyzer_proto = out.File + file_analyzer_proto_rawDesc = nil + file_analyzer_proto_goTypes = nil + file_analyzer_proto_depIdxs = nil +} diff --git a/pkg/analyzer/pb/analyzerpb/analyzer.pb.validate.go b/pkg/analyzer/pb/analyzerpb/analyzer.pb.validate.go new file mode 100644 index 000000000..c0327c9fa --- /dev/null +++ b/pkg/analyzer/pb/analyzerpb/analyzer.pb.validate.go @@ -0,0 +1,36 @@ +// Code generated by protoc-gen-validate. DO NOT EDIT. +// source: analyzer.proto + +package analyzerpb + +import ( + "bytes" + "errors" + "fmt" + "net" + "net/mail" + "net/url" + "regexp" + "sort" + "strings" + "time" + "unicode/utf8" + + "google.golang.org/protobuf/types/known/anypb" +) + +// ensure the imports are used +var ( + _ = bytes.MinRead + _ = errors.New("") + _ = fmt.Print + _ = utf8.UTFMax + _ = (*regexp.Regexp)(nil) + _ = (*strings.Reader)(nil) + _ = net.IPv4len + _ = time.Duration(0) + _ = (*url.URL)(nil) + _ = (*mail.Address)(nil) + _ = anypb.Any{} + _ = sort.Sort +) diff --git a/pkg/analyzer/proto/analyzer.proto b/pkg/analyzer/proto/analyzer.proto new file mode 100644 index 000000000..31e32c489 --- /dev/null +++ b/pkg/analyzer/proto/analyzer.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; + +package analyzer; + +option go_package = "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/pb/analyzerpb"; + +enum AnalyzerType { + Invalid = 0; + Airbrake = 1; + Asana = 2; + Bitbucket = 3; + GitHub = 4; + GitLab = 5; + HuggingFace = 6; + Mailchimp = 7; + Mailgun = 8; + MySQL = 9; + OpenAI = 10; + Opsgenie = 11; + Postgres = 12; + Postman = 13; + Sendgrid = 14; + Shopify = 15; + Slack = 16; + Sourcegraph = 17; + Square = 18; + Stripe = 19; + Twilio = 20; +} diff --git a/pkg/detectors/detectors.go b/pkg/detectors/detectors.go index ff7a5d182..ee4f1d86b 100644 --- a/pkg/detectors/detectors.go +++ b/pkg/detectors/detectors.go @@ -82,6 +82,11 @@ type Result struct { // This field should only be populated if the verification process itself failed in a way that provides no // information about the verification status of the candidate secret, such as if the verification request timed out. verificationError error + + // AnalysisInfo should be set with information required for credential + // analysis to run. The keys of the map are analyzer specific and + // should match what is expected in the corresponding analyzer. + AnalysisInfo map[string]string } // SetVerificationError is the only way to set a verification error. Any sensitive values should be passed-in as secrets to be redacted. diff --git a/pkg/detectors/openai/openai.go b/pkg/detectors/openai/openai.go index 1b0e53ca3..0b2b15912 100644 --- a/pkg/detectors/openai/openai.go +++ b/pkg/detectors/openai/openai.go @@ -62,6 +62,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result s1.Verified = verified s1.ExtraData = extraData s1.SetVerificationError(verificationErr) + s1.AnalysisInfo = map[string]string{"key": token} } results = append(results, s1) diff --git a/scripts/gen_proto.sh b/scripts/gen_proto.sh index d3df1c128..56d69a05d 100755 --- a/scripts/gen_proto.sh +++ b/scripts/gen_proto.sh @@ -2,38 +2,25 @@ set -eux -protoc -I proto/ \ - -I ${GOPATH}/src \ - -I /usr/local/include \ - -I ${GOPATH}/src/github.com/envoyproxy/protoc-gen-validate \ - --go_out=plugins=grpc:./pkg/pb/credentialspb --go_opt=paths=source_relative \ - --validate_out="lang=go,paths=source_relative:./pkg/pb/credentialspb" \ - proto/credentials.proto -protoc -I proto/ \ - -I ${GOPATH}/src \ - -I /usr/local/include \ - -I ${GOPATH}/src/github.com/envoyproxy/protoc-gen-validate \ - --go_out=plugins=grpc:./pkg/pb/sourcespb --go_opt=paths=source_relative \ - --validate_out="lang=go,paths=source_relative:./pkg/pb/sourcespb" \ - proto/sources.proto -protoc -I proto/ \ - -I ${GOPATH}/src \ - -I /usr/local/include \ - -I ${GOPATH}/src/github.com/envoyproxy/protoc-gen-validate \ - --go_out=plugins=grpc:./pkg/pb/detectorspb --go_opt=paths=source_relative \ - --validate_out="lang=go,paths=source_relative:./pkg/pb/detectorspb" \ - proto/detectors.proto -protoc -I proto/ \ - -I ${GOPATH}/src \ - -I /usr/local/include \ - -I ${GOPATH}/src/github.com/envoyproxy/protoc-gen-validate \ - --go_out=plugins=grpc:./pkg/pb/source_metadatapb --go_opt=paths=source_relative \ - --validate_out="lang=go,paths=source_relative:./pkg/pb/source_metadatapb" \ - proto/source_metadata.proto -protoc -I proto/ \ - -I ${GOPATH}/src \ - -I /usr/local/include \ - -I ${GOPATH}/src/github.com/envoyproxy/protoc-gen-validate \ - --go_out=plugins=grpc:./pkg/pb/custom_detectorspb --go_opt=paths=source_relative \ - --validate_out="lang=go,paths=source_relative:./pkg/pb/custom_detectorspb" \ - proto/custom_detectors.proto +for pbfile in $(ls proto/); do + mod=${pbfile%%.proto} + protoc -I proto/ \ + -I ${GOPATH}/src \ + -I /usr/local/include \ + -I ${GOPATH}/src/github.com/envoyproxy/protoc-gen-validate \ + --go_out=plugins=grpc:./pkg/pb/${mod}pb --go_opt=paths=source_relative \ + --validate_out="lang=go,paths=source_relative:./pkg/pb/${mod}pb" \ + proto/${mod}.proto +done + +for pbfile in $(ls pkg/analyzer/proto/); do + mod=${pbfile%%.proto} + mkdir -p "./pkg/analyzer/pb/${mod}pb" + protoc -I pkg/analyzer/proto/ \ + -I ${GOPATH}/src \ + -I /usr/local/include \ + -I ${GOPATH}/src/github.com/envoyproxy/protoc-gen-validate \ + --go_out=plugins=grpc:./pkg/analyzer/pb/${mod}pb --go_opt=paths=source_relative \ + --validate_out="lang=go,paths=source_relative:./pkg/analyzer/pb/${mod}pb" \ + pkg/analyzer/proto/${mod}.proto +done