Monday App Analyzer (#4120)

* initial commit

* updated monday detector

* close resp body
This commit is contained in:
Kashif Khan
2025-05-08 08:37:56 -05:00
committed by GitHub
parent 18c009f4f2
commit f573da368a
11 changed files with 950 additions and 12 deletions
+2
View File
@@ -96,6 +96,7 @@ const (
AnalyzerTypePlaid
AnalyzerTypeNetlify
AnalyzerTypeFastly
AnalyzerTypeMonday
// Add new items here with AnalyzerType prefix
)
@@ -137,6 +138,7 @@ var analyzerTypeStrings = map[AnalyzerType]string{
AnalyzerTypePlaid: "Plaid",
AnalyzerTypeNetlify: "Netlify",
AnalyzerTypeFastly: "Fastly",
AnalyzerTypeMonday: "Monday",
// Add new mappings here
}
+149
View File
@@ -0,0 +1,149 @@
//go:generate generate_permissions permissions.yaml permissions.go monday
package monday
import (
"errors"
"fmt"
"os"
"github.com/fatih/color"
"github.com/jedib0t/go-pretty/v6/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
}
type SecretInfo struct {
User Me
Account Account
Resources []MondayResource
}
func (s *SecretInfo) appendResource(resource MondayResource) {
s.Resources = append(s.Resources, resource)
}
type MondayResource struct {
ID string
Name string
Type string
MetaData map[string]string
Parent *MondayResource
}
func (a Analyzer) Type() analyzers.AnalyzerType {
return analyzers.AnalyzerTypeMonday
}
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
key, exist := credInfo["key"]
if !exist {
return nil, errors.New("key not found in credentials info")
}
info, err := AnalyzePermissions(a.Cfg, key)
if err != nil {
return nil, err
}
return secretInfoToAnalyzerResult(info), nil
}
func AnalyzeAndPrintPermissions(cfg *config.Config, key string) {
info, err := AnalyzePermissions(cfg, key)
if err != nil {
// just print the error in cli and continue as a partial success
color.Red("[x] Error : %s", err.Error())
}
if info == nil {
color.Red("[x] Error : %s", "No information found")
return
}
color.Green("[!] Valid Monday Personal Access Token\n\n")
// print user information
printUser(info.User)
printResources(info.Resources)
color.Yellow("\n[i] Expires: Never")
}
func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) {
// create http client
client := analyzers.NewAnalyzeClientUnrestricted(cfg)
var secretInfo = &SecretInfo{}
// captureMondayData make a query to graphql API of monday to fetch all data and store it in secret info
if err := captureMondayData(client, key, secretInfo); err != nil {
return nil, err
}
return secretInfo, nil
}
// secretInfoToAnalyzerResult translate secret info to Analyzer Result
func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult {
if info == nil {
return nil
}
result := analyzers.AnalyzerResult{
AnalyzerType: analyzers.AnalyzerTypeMonday,
Metadata: map[string]any{},
Bindings: make([]analyzers.Binding, 0),
}
// extract information from resource to create bindings and append to result bindings
for _, resource := range info.Resources {
binding := analyzers.Binding{
Resource: analyzers.Resource{
Name: resource.Name,
FullyQualifiedName: fmt.Sprintf("%s/%s", resource.Type, resource.ID), // e.g: Board/123
Type: resource.Type,
Metadata: map[string]any{}, // to avoid panic
},
Permission: analyzers.Permission{
Value: PermissionStrings[FullAccess], // token always has full access
},
}
for key, value := range resource.MetaData {
binding.Resource.Metadata[key] = value
}
result.Bindings = append(result.Bindings, binding)
}
return &result
}
// cli print functions
func printUser(user Me) {
color.Green("\n[i] User Information:")
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.AppendHeader(table.Row{"ID", "Name", "Email", "Title", "Is Admin", "Is Guest"})
t.AppendRow(table.Row{color.GreenString(user.ID), color.GreenString(user.Name), color.GreenString(user.Email),
color.GreenString(user.Title), color.GreenString(fmt.Sprintf("%t", user.IsAdmin)), color.GreenString(fmt.Sprintf("%t", user.IsGuest))})
t.Render()
}
func printResources(resources []MondayResource) {
color.Green("\n[i] Resources:")
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.AppendHeader(table.Row{"Name", "Type"})
for _, resource := range resources {
t.AppendRow(table.Row{color.GreenString(resource.Name), color.GreenString(resource.Type)})
}
t.Render()
}
@@ -0,0 +1,102 @@
package monday
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 result_output.json
var expectedOutput []byte
func TestAnalyzer_Analyze(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
defer cancel()
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "analyzers1")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}
key := testSecrets.MustGetField("MONDAY_PAT")
tests := []struct {
name string
key string
want []byte // JSON string
wantErr bool
}{
{
name: "valid monday personal access token",
key: key,
want: 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,61 @@
// Code generated by go generate; DO NOT EDIT.
package monday
import "errors"
type Permission int
const (
Invalid Permission = iota
FullAccess Permission = iota
)
var (
PermissionStrings = map[Permission]string{
FullAccess: "full_access",
}
StringToPermission = map[string]Permission{
"full_access": FullAccess,
}
PermissionIDs = map[Permission]int{
FullAccess: 1,
}
IdToPermission = map[int]Permission{
1: FullAccess,
}
)
// 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,2 @@
permissions:
- full_access
+240
View File
@@ -0,0 +1,240 @@
package monday
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"io"
"net/http"
)
//go:embed query.graphql
var requestQuery string
const (
// resource types
TypeBoard = "Board"
TypeBoardGroup = "Board Group"
TypeBoardColumn = "Board Column"
TypeDoc = "Document"
TypeFolder = "Folder"
TypeTag = "Tag"
TypeTeam = "Team"
TypeWorkspace = "Workspace"
)
type Request struct {
Query string `json:"query"`
}
// Response is the Monday Graphql API response in case of success
type Response struct {
Data Data `json:"data"`
}
type Data struct {
Me Me `json:"me"`
Account Account `json:"account"`
Users []User `json:"users"`
Boards []Board `json:"boards"`
Docs []Doc `json:"docs"`
Folders []EntityRef `json:"folders"`
Tags []EntityRef `json:"tags"`
Teams []EntityRef `json:"teams"`
Workspaces []Workspace `json:"workspaces"`
}
type EntityRef struct {
ID string `json:"id"`
Name string `json:"name"`
}
type Me struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Title string `json:"title"`
IsAdmin bool `json:"is_admin"`
IsGuest bool `json:"is_guest"`
IsViewOnly bool `json:"is_view_only"`
IsPending bool `json:"is_pending"`
IsVerified bool `json:"is_verified"`
Teams []EntityRef `json:"teams"`
}
type Account struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Tier string `json:"tier"`
}
type User struct {
Email string `json:"email"`
Account Account `json:"account"`
}
type Board struct {
ID string `json:"id"`
Name string `json:"name"`
State string `json:"state"`
Permissions string `json:"permissions"`
Groups []Group `json:"groups"`
Columns []Column `json:"column"`
Owners []EntityRef `json:"owner"`
}
type Group struct {
Title string `json:"title"`
ID string `json:"id"`
}
type Column struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
}
type Doc struct {
ID string `json:"id"`
ObjectID string `json:"object_id"`
Name string `json:"name"`
CreatedBy EntityRef `json:"created_by"`
}
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
}
// captureMondayData send a request to Monday graphql API to get all data and capture it in secret info
func captureMondayData(client *http.Client, key string, secretInfo *SecretInfo) error {
jsonData, err := json.Marshal(Request{Query: requestQuery})
if err != nil {
panic(err)
}
req, err := http.NewRequest(http.MethodPost, "https://api.monday.com/v2", bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", key)
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
switch resp.StatusCode {
case http.StatusOK:
var apiResponse Response
if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil {
return err
}
// capture details in secret info
responseToSecretInfo(apiResponse, secretInfo)
return nil
case http.StatusUnauthorized:
return fmt.Errorf("expired/invalid access token")
default:
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
}
// responseToSecretInfo translate api response to secret info
func responseToSecretInfo(apiResponse Response, secretInfo *SecretInfo) {
secretInfo.User = apiResponse.Data.Me
secretInfo.Account = apiResponse.Data.Account
processBoards(apiResponse.Data.Boards, secretInfo)
processDocs(apiResponse.Data.Docs, secretInfo)
processSimpleEntities(apiResponse.Data.Folders, TypeFolder, secretInfo)
processSimpleEntities(apiResponse.Data.Tags, TypeTag, secretInfo)
processSimpleEntities(apiResponse.Data.Teams, TypeTeam, secretInfo)
processWorkspaces(apiResponse.Data.Workspaces, secretInfo)
}
func processBoards(boards []Board, secretInfo *SecretInfo) {
for _, board := range boards {
boardResource := MondayResource{
ID: board.ID,
Name: board.Name,
Type: TypeBoard,
MetaData: map[string]string{
"state": board.State,
"permissions": board.Permissions,
},
}
secretInfo.appendResource(boardResource)
// sub resources of board
for _, group := range board.Groups {
secretInfo.appendResource(MondayResource{
ID: group.ID,
Name: group.Title,
Type: TypeBoardGroup,
Parent: &boardResource,
})
}
for _, column := range board.Columns {
secretInfo.appendResource(MondayResource{
ID: column.ID,
Name: column.Title,
Type: TypeBoardColumn,
MetaData: map[string]string{
"Column Type": column.Type,
},
Parent: &boardResource,
})
}
}
}
func processDocs(docs []Doc, secretInfo *SecretInfo) {
for _, doc := range docs {
secretInfo.appendResource(MondayResource{
ID: doc.ID,
Name: doc.Name,
Type: TypeDoc,
MetaData: map[string]string{
"created_by": doc.CreatedBy.Name,
},
})
}
}
func processSimpleEntities(entities []EntityRef, entityType string, secretInfo *SecretInfo) {
for _, entity := range entities {
secretInfo.appendResource(MondayResource{
ID: entity.ID,
Name: entity.Name,
Type: entityType,
})
}
}
func processWorkspaces(workspaces []Workspace, secretInfo *SecretInfo) {
for _, workspace := range workspaces {
secretInfo.appendResource(MondayResource{
ID: workspace.ID,
Name: workspace.Name,
Type: TypeWorkspace,
MetaData: map[string]string{
"workspace_kind": workspace.Kind,
},
})
}
}
@@ -0,0 +1,75 @@
{
me {
id
name
email
title
is_admin
is_guest
is_view_only
is_pending
is_verified
teams {
id
name
}
}
account {
id
name
slug
tier
}
users {
email
account {
name
id
}
}
boards {
id
name
state
permissions
groups {
title
id
}
columns {
id
title
type
}
owners {
id
name
}
}
docs {
id
object_id
name
created_by {
id
name
}
}
folders {
name
id
}
tags {
id
name
}
teams {
id
name
}
workspaces {
id
name
kind
}
}
@@ -0,0 +1,276 @@
{
"AnalyzerType": 35,
"Bindings": [
{
"Resource": {
"Name": "All Tasks",
"FullyQualifiedName": "Board Group/topics",
"Type": "Board Group",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Bugs Queue",
"FullyQualifiedName": "Board/2007387485",
"Type": "Board",
"Metadata": {
"permissions": "everyone",
"state": "active"
},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Development Work",
"FullyQualifiedName": "Board Group/new_group24572",
"Type": "Board Group",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Epics",
"FullyQualifiedName": "Board/2007387484",
"Type": "Board",
"Metadata": {
"permissions": "everyone",
"state": "active"
},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Epics Backlog",
"FullyQualifiedName": "Board Group/new_group",
"Type": "Board Group",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Getting Started",
"FullyQualifiedName": "Board/2007387480",
"Type": "Board",
"Metadata": {
"permissions": "everyone",
"state": "active"
},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Getting Started",
"FullyQualifiedName": "Document/1126907",
"Type": "Document",
"Metadata": {
"created_by": "Truffle Security Detectors"
},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Group Title",
"FullyQualifiedName": "Board Group/topics",
"Type": "Board Group",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Incoming Bugs",
"FullyQualifiedName": "Board Group/topics",
"Type": "Board Group",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Managed in sprints",
"FullyQualifiedName": "Board Group/new_group",
"Type": "Board Group",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "My Team",
"FullyQualifiedName": "Workspace/1857558",
"Type": "Workspace",
"Metadata": {
"workspace_kind": "open"
},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Q1 2025",
"FullyQualifiedName": "Board Group/new_group313",
"Type": "Board Group",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Resolved",
"FullyQualifiedName": "Board Group/group_title",
"Type": "Board Group",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Retrospectives",
"FullyQualifiedName": "Board/2007387481",
"Type": "Board",
"Metadata": {
"permissions": "everyone",
"state": "active"
},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Sprint 1",
"FullyQualifiedName": "Board Group/group_title",
"Type": "Board Group",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Sprints",
"FullyQualifiedName": "Board Group/topics",
"Type": "Board Group",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Sprints",
"FullyQualifiedName": "Board/2007387482",
"Type": "Board",
"Metadata": {
"permissions": "everyone",
"state": "active"
},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Sprints order",
"FullyQualifiedName": "Board/2007387483",
"Type": "Board",
"Metadata": {
"permissions": "everyone",
"state": "active"
},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
},
{
"Resource": {
"Name": "Team OSS team",
"FullyQualifiedName": "Folder/6205823",
"Type": "Folder",
"Metadata": {},
"Parent": null
},
"Permission": {
"Value": "full_access",
"Parent": null
}
}
],
"UnboundedResources": null,
"Metadata": {}
}
+3
View File
@@ -23,6 +23,7 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/launchdarkly"
"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/monday"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/mysql"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/netlify"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/notion"
@@ -127,5 +128,7 @@ func Run(keyType string, secretInfo SecretInfo) {
netlify.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"])
case "fastly":
fastly.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"])
case "monday":
monday.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"])
}
}
+39 -12
View File
@@ -2,6 +2,8 @@ package monday
import (
"context"
"fmt"
"io"
"net/http"
"strings"
@@ -45,18 +47,13 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result
}
if verify {
payload := strings.NewReader(`{"query":"{boards(limit:1){id name}}"}`)
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.monday.com/v2", payload)
if err != nil {
continue
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", resMatch)
res, err := client.Do(req)
if err == nil {
defer res.Body.Close()
if res.StatusCode >= 200 && res.StatusCode < 300 {
s1.Verified = true
isVerified, verificationErr := verifyMondayPAT(ctx, client, resMatch)
s1.Verified = isVerified
s1.SetVerificationError(verificationErr)
if s1.Verified {
s1.AnalysisInfo = map[string]string{
"key": resMatch,
}
}
}
@@ -74,3 +71,33 @@ func (s Scanner) Type() detectorspb.DetectorType {
func (s Scanner) Description() string {
return "Monday.com is a work operating system that powers teams to run projects and workflows with confidence. Monday API keys can be used to access and modify data and workflows on the platform."
}
func verifyMondayPAT(ctx context.Context, client *http.Client, token string) (bool, error) {
payload := strings.NewReader(`{"query":"{boards(limit:1){id name}}"}`)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.monday.com/v2", payload)
if err != nil {
return false, err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", token)
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusUnauthorized:
return false, nil
default:
return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
}
@@ -95,6 +95,7 @@ func TestMonday_FromChunk(t *testing.T) {
t.Fatalf("no raw secret present: \n %+v", got[i])
}
got[i].Raw = nil
got[i].AnalysisInfo = nil
}
if diff := pretty.Compare(got, tt.want); diff != "" {
t.Errorf("Monday.FromData() %s diff: (-got +want)\n%s", tt.name, diff)