[Feat] implementation Notion analyzer (#3869)
Lint / golangci-lint (push) Waiting to run
Lint / semgrep (push) Waiting to run
Release / Release (push) Waiting to run
Scan for secrets / test (push) Waiting to run
Test / test (push) Waiting to run
Test / test-community (push) Waiting to run

### Description:
Since `Notion.co` allows to enable [capabilities](https://developers.notion.com/reference/capabilities) against integration token, It is a good candidate to build an analyzer for it.
![Screenshot 2025-02-04 at 6 46 37 PM](https://github.com/user-attachments/assets/2fb711fe-466f-4b78-9b25-56852f08cd3b)

This PR implements notion analyzer along with its integration test.


### Checklist:
* [ ] Tests passing (`make test-community`)?
* [x] Lint passing (`make lint` this requires [golangci-lint](https://golangci-lint.run/welcome/install/#local-installation))?
This commit is contained in:
Abdul Basit
2025-02-11 10:35:31 -08:00
committed by GitHub
parent 0761334eec
commit f2dc96e854
9 changed files with 643 additions and 0 deletions
+2
View File
@@ -82,6 +82,7 @@ const (
AnalyzerTypeStripe
AnalyzerTypeTwilio
AnalyzerTypePrivateKey
AnalyzerTypeNotion
// Add new items here with AnalyzerType prefix
)
@@ -110,6 +111,7 @@ var analyzerTypeStrings = map[AnalyzerType]string{
AnalyzerTypeStripe: "Stripe",
AnalyzerTypeTwilio: "Twilio",
AnalyzerTypePrivateKey: "PrivateKey",
AnalyzerTypeNotion: "Notion",
// Add new mappings here
}
@@ -0,0 +1 @@
{"AnalyzerType":22,"Bindings":[{"Resource":{"Name":"hooman","FullyQualifiedName":"notion.so/bot/62faeec3-a948-4dd4-90ae-426e0b192902","Type":"bot","Metadata":{"workspace":"hoomanit"},"Parent":null},"Permission":{"Value":"insert_content","Parent":null}},{"Resource":{"Name":"hooman","FullyQualifiedName":"notion.so/bot/62faeec3-a948-4dd4-90ae-426e0b192902","Type":"bot","Metadata":{"workspace":"hoomanit"},"Parent":null},"Permission":{"Value":"read_content","Parent":null}},{"Resource":{"Name":"hooman","FullyQualifiedName":"notion.so/bot/62faeec3-a948-4dd4-90ae-426e0b192902","Type":"bot","Metadata":{"workspace":"hoomanit"},"Parent":null},"Permission":{"Value":"read_users_with_email","Parent":null}},{"Resource":{"Name":"hooman","FullyQualifiedName":"notion.so/bot/62faeec3-a948-4dd4-90ae-426e0b192902","Type":"bot","Metadata":{"workspace":"hoomanit"},"Parent":null},"Permission":{"Value":"update_content","Parent":null}}],"UnboundedResources":[{"Name":"hooman","FullyQualifiedName":"notion.so/person/3d0600fa-fa18-427d-8abc-58b662f0d209","Type":"person","Metadata":{"email":"[email protected]"},"Parent":null}],"Metadata":null}
+389
View File
@@ -0,0 +1,389 @@
//go:generate generate_permissions permissions.yaml permissions.go notion
package notion
import (
"bytes"
_ "embed"
"encoding/json"
"errors"
"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"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
)
var _ analyzers.Analyzer = (*Analyzer)(nil)
type Analyzer struct {
Cfg *config.Config
}
func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypeNotion }
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
key, ok := credInfo["key"]
if !ok {
return nil, errors.New("missing key in credInfo")
}
info, err := AnalyzePermissions(a.Cfg, key)
if err != nil {
return nil, err
}
return secretInfoToAnalyzerResult(info), nil
}
func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult {
if info == nil {
return nil
}
result := analyzers.AnalyzerResult{
AnalyzerType: analyzers.AnalyzerTypeNotion,
Metadata: nil,
Bindings: make([]analyzers.Binding, len(info.Permissions)),
UnboundedResources: make([]analyzers.Resource, 0, len(info.WorkspaceUsers)),
}
resource := analyzers.Resource{
Name: info.Bot.Name,
FullyQualifiedName: "notion.so/bot/" + info.Bot.Id,
Type: info.Bot.Type,
Metadata: map[string]interface{}{
"workspace": info.Bot.GetWorkspaceName(),
},
}
for idx, permission := range info.Permissions {
result.Bindings[idx] = analyzers.Binding{
Resource: resource,
Permission: analyzers.Permission{
Value: permission,
},
}
}
// We can find list of users in the current workspace
// if the API key has read_user permission, so these can be
// unbounded resources
for _, user := range info.WorkspaceUsers {
if info.Bot.Id == user.Id {
// Skip the bot itself
continue
}
unboundresource := analyzers.Resource{
Name: user.Name,
FullyQualifiedName: fmt.Sprintf("notion.so/%s/%s", user.Type, user.Id),
Type: user.Type, // person or bot
}
if user.Person.Email != "" {
unboundresource.Metadata = map[string]interface{}{
"email": user.Person.Email,
}
}
result.UnboundedResources = append(result.UnboundedResources, unboundresource)
}
return &result
}
//go:embed scopes.json
var scopesConfig []byte
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.NewAnalyzeClientUnrestricted(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 getPermissions(cfg *config.Config, key string) ([]string, error) {
scopes, err := readInScopes()
if err != nil {
return nil, fmt.Errorf("reading in scopes: %w", err)
}
permissions := make([]string, 0, len(scopes))
for _, scope := range scopes {
status, err := scope.HttpTest.RunTest(cfg, map[string]string{"Authorization": "Bearer " + key, "Notion-Version": "2022-06-28"})
if err != nil {
return nil, fmt.Errorf("running test: %w", err)
}
if status {
permissions = append(permissions, scope.Name)
}
}
return permissions, nil
}
type SecretInfo struct {
Bot *bot
WorkspaceUsers []user
Permissions []string
}
type user struct {
Id string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Person struct {
Email string `json:"email"`
}
}
type bot struct {
Id string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Bot struct {
Owner *struct {
Type string `json:"type"`
}
WorkspaceName string `json:"workspace_name"`
} `json:"bot"`
}
func (b *bot) GetWorkspaceName() string {
return b.Bot.WorkspaceName
}
func (b *bot) OwnedBy() string {
if b.Bot.Owner != nil {
return b.Bot.Owner.Type
}
return "N/A"
}
func AnalyzeAndPrintPermissions(cfg *config.Config, key string) {
info, err := AnalyzePermissions(cfg, key)
if err != nil {
color.Red("[x] Error : %s", err.Error())
return
}
color.Green("[!] Valid Notion API key\n\n")
color.Green("[i] Bot: %s (%s)\n", info.Bot.Name, info.Bot.Id)
color.Green("[i] Bot Owned By: %s\n", info.Bot.OwnedBy())
if info.Bot.GetWorkspaceName() != "" {
color.Green("[i] Workspace: %s\n\n", info.Bot.GetWorkspaceName())
}
printPermissions(info.Permissions)
if len(info.WorkspaceUsers) > 0 {
printUsers(info.WorkspaceUsers)
}
color.Yellow("\n[i] Expires: Never")
}
func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) {
permissions := make([]string, 0)
client := analyzers.NewAnalyzeClient(cfg)
bot, err := getBotInfo(client, key)
if err != nil {
return nil, err
}
credPermissions, err := getPermissions(cfg, key)
if err != nil {
return nil, err
}
permissions = append(permissions, credPermissions...)
users, err := getWorkspaceUsers(client, key)
if err != nil {
return nil, fmt.Errorf("error getting user permission: %s", err.Error())
}
// check if email is returned in users to determine permission
for _, user := range users {
if user.Type == "person" {
if user.Person.Email == "" {
permissions = append(permissions, PermissionStrings[ReadUsersWithoutEmail])
} else {
permissions = append(permissions, PermissionStrings[ReadUsersWithEmail])
}
break
}
}
return &SecretInfo{
Bot: bot,
Permissions: permissions,
WorkspaceUsers: users,
}, nil
}
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.Yellow("\n[i] Workspace Users:")
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.AppendHeader(table.Row{"ID", "Name", "Type", "Email"})
for _, user := range users {
t.AppendRow(table.Row{color.GreenString(user.Id), color.GreenString(user.Name), color.GreenString(user.Type), color.GreenString(user.Person.Email)})
}
t.Render()
}
func getBotInfo(client *http.Client, key string) (*bot, error) {
// Create new HTTP request
req, err := http.NewRequest(http.MethodGet, "https://api.notion.com/v1/users/me", http.NoBody)
if err != nil {
return nil, err
}
// Add custom headers if provided
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Notion-Version", "2022-06-28")
// Execute HTTP Request
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
me := &bot{}
err = json.NewDecoder(resp.Body).Decode(me)
if err != nil {
return nil, err
}
return me, nil
case http.StatusUnauthorized:
return nil, errors.New("invalid API key")
default:
return nil, errors.New("error getting bot info")
}
}
// Decode response body
type usersResponse struct {
Results []user `json:"results"`
}
func getWorkspaceUsers(client *http.Client, key string) ([]user, error) {
// Create new HTTP request
req, err := http.NewRequest(http.MethodGet, "https://api.notion.com/v1/users", http.NoBody)
if err != nil {
return nil, err
}
// Add custom headers if provided
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Notion-Version", "2022-06-28")
// Execute HTTP Request
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
response := &usersResponse{}
err = json.NewDecoder(resp.Body).Decode(response)
if err != nil {
return nil, err
}
return response.Results, nil
case http.StatusUnauthorized:
return nil, errors.New("invalid API key")
case http.StatusForbidden:
return nil, nil // no permission
case http.StatusNotFound:
return nil, errors.New("workspace not found")
default:
return nil, errors.New("error checking user permissions")
}
}
@@ -0,0 +1,100 @@
package notion
import (
_ "embed"
"encoding/json"
"sort"
"testing"
"time"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
)
//go:embed expected_output.json
var expectedOutput []byte
func TestAnalyzer_Analyze(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*15)
defer cancel()
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors3")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}
tests := []struct {
name string
key string
want string // JSON string
wantErr bool
}{
{
name: "valid notion key",
key: testSecrets.MustGetField("NOTION_TOKEN"),
want: string(expectedOutput),
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := Analyzer{Cfg: &config.Config{}}
got, err := a.Analyze(ctx, map[string]string{"key": tt.key})
if (err != nil) != tt.wantErr {
t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr)
return
}
// bindings need to be in the same order to be comparable
sortBindings(got.Bindings)
// Marshal the actual result to JSON
gotJSON, err := json.Marshal(got)
if err != nil {
t.Fatalf("could not marshal got to JSON: %s", err)
}
// Parse the expected JSON string
var wantObj analyzers.AnalyzerResult
if err := json.Unmarshal([]byte(tt.want), &wantObj); err != nil {
t.Fatalf("could not unmarshal want JSON string: %s", err)
}
// bindings need to be in the same order to be comparable
sortBindings(wantObj.Bindings)
// Marshal the expected result to JSON (to normalize)
wantJSON, err := json.Marshal(wantObj)
if err != nil {
t.Fatalf("could not marshal want to JSON: %s", err)
}
// Compare the JSON strings
if string(gotJSON) != string(wantJSON) {
// Pretty-print both JSON strings for easier comparison
var gotIndented, wantIndented []byte
gotIndented, err = json.MarshalIndent(got, "", " ")
if err != nil {
t.Fatalf("could not marshal got to indented JSON: %s", err)
}
wantIndented, err = json.MarshalIndent(wantObj, "", " ")
if err != nil {
t.Fatalf("could not marshal want to indented JSON: %s", err)
}
t.Errorf("Analyzer.Analyze() = %s, want %s", gotIndented, wantIndented)
}
})
}
}
// Helper function to sort bindings
func sortBindings(bindings []analyzers.Binding) {
sort.SliceStable(bindings, func(i, j int) bool {
if bindings[i].Resource.Name == bindings[j].Resource.Name {
return bindings[i].Permission.Value < bindings[j].Permission.Value
}
return bindings[i].Resource.Name < bindings[j].Resource.Name
})
}
@@ -0,0 +1,91 @@
// Code generated by go generate; DO NOT EDIT.
package notion
import "errors"
type Permission int
const (
Invalid Permission = iota
ReadContent Permission = iota
UpdateContent Permission = iota
InsertContent Permission = iota
ReadComments Permission = iota
InsertComments Permission = iota
ReadUsersWithEmail Permission = iota
ReadUsersWithoutEmail Permission = iota
)
var (
PermissionStrings = map[Permission]string{
ReadContent: "read_content",
UpdateContent: "update_content",
InsertContent: "insert_content",
ReadComments: "read_comments",
InsertComments: "insert_comments",
ReadUsersWithEmail: "read_users_with_email",
ReadUsersWithoutEmail: "read_users_without_email",
}
StringToPermission = map[string]Permission{
"read_content": ReadContent,
"update_content": UpdateContent,
"insert_content": InsertContent,
"read_comments": ReadComments,
"insert_comments": InsertComments,
"read_users_with_email": ReadUsersWithEmail,
"read_users_without_email": ReadUsersWithoutEmail,
}
PermissionIDs = map[Permission]int{
ReadContent: 1,
UpdateContent: 2,
InsertContent: 3,
ReadComments: 4,
InsertComments: 5,
ReadUsersWithEmail: 6,
ReadUsersWithoutEmail: 7,
}
IdToPermission = map[int]Permission{
1: ReadContent,
2: UpdateContent,
3: InsertContent,
4: ReadComments,
5: InsertComments,
6: ReadUsersWithEmail,
7: ReadUsersWithoutEmail,
}
)
// ToString converts a Permission enum to its string representation
func (p Permission) ToString() (string, error) {
if str, ok := PermissionStrings[p]; ok {
return str, nil
}
return "", errors.New("invalid permission")
}
// ToID converts a Permission enum to its ID
func (p Permission) ToID() (int, error) {
if id, ok := PermissionIDs[p]; ok {
return id, nil
}
return 0, errors.New("invalid permission")
}
// PermissionFromString converts a string representation to its Permission enum
func PermissionFromString(s string) (Permission, error) {
if p, ok := StringToPermission[s]; ok {
return p, nil
}
return 0, errors.New("invalid permission string")
}
// PermissionFromID converts an ID to its Permission enum
func PermissionFromID(id int) (Permission, error) {
if p, ok := IdToPermission[id]; ok {
return p, nil
}
return 0, errors.New("invalid permission ID")
}
@@ -0,0 +1,8 @@
permissions:
- read_content
- update_content
- insert_content
- read_comments
- insert_comments
- read_users_with_email
- read_users_without_email
+47
View File
@@ -0,0 +1,47 @@
[
{
"name": "read_content",
"test": {
"endpoint": "https://api.notion.com/v1/pages/`nowaythiscanexist",
"method": "GET",
"valid_status_code": [400],
"invalid_status_code": [403]
}
},
{
"name": "update_content",
"test": {
"endpoint": "https://api.notion.com/v1/pages/`nowaythiscanexist",
"method": "PATCH",
"valid_status_code": [400],
"invalid_status_code": [403]
}
},
{
"name": "insert_content",
"test": {
"endpoint": "https://api.notion.com/v1/pages",
"method": "POST",
"valid_status_code": [400],
"invalid_status_code": [403]
}
},
{
"name": "read_comments",
"test": {
"endpoint": "https://api.notion.com/v1/comments",
"method": "GET",
"valid_status_code": [400],
"invalid_status_code": [403]
}
},
{
"name": "insert_comments",
"test": {
"endpoint": "https://api.notion.com/v1/comments",
"method": "POST",
"valid_status_code": [400],
"invalid_status_code": [403]
}
}
]
+4
View File
@@ -4,6 +4,7 @@ import (
"strings"
"github.com/alecthomas/kingpin/v2"
"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"
@@ -14,6 +15,7 @@ import (
"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/notion"
"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"
@@ -85,6 +87,8 @@ func Run(keyType string, secretInfo SecretInfo) {
opsgenie.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"])
case "privatekey":
privatekey.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"])
case "notion":
notion.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"])
case "dockerhub":
dockerhub.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["username"], secretInfo.Parts["pat"])
}
+1
View File
@@ -60,6 +60,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
// Notion returns 401 for all non-valid keys, thus 403 indicates it has fine-tuned permissions,
// /v1/search, /v1/databases/*, etc. may work.
s1.Verified = true
s1.AnalysisInfo = map[string]string{"key": resMatch}
}
} else {