├── .github ├── dependabot.yml ├── go │ └── Dockerfile ├── golangci.yml ├── goreleaser.yml ├── readme │ ├── overview.png │ └── user.png └── workflows │ ├── build.yml │ ├── lint.yml │ ├── release.yml │ └── todo.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── app.json ├── cmd └── main.go ├── config └── config.go ├── go.mod ├── go.sum └── pkg ├── falcon └── extractor.go ├── overview ├── security │ └── builder.go └── user │ └── builder.go ├── slack └── holiday.go └── ws1 ├── api.go └── extractor.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: docker 4 | directory: "/.github/go" 5 | schedule: 6 | interval: daily 7 | time: '04:00' 8 | open-pull-requests-limit: 10 9 | - package-ecosystem: docker 10 | directory: "/" 11 | schedule: 12 | interval: daily 13 | time: '04:00' 14 | open-pull-requests-limit: 10 15 | - package-ecosystem: github-actions 16 | directory: "/" 17 | schedule: 18 | interval: daily 19 | time: '04:00' 20 | open-pull-requests-limit: 10 21 | - package-ecosystem: gomod 22 | directory: "/" 23 | schedule: 24 | interval: daily 25 | time: '04:00' 26 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/go/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 -------------------------------------------------------------------------------- /.github/golangci.yml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hazcod/security-slacker/20fff034949445f3a6f93d27a378716ddfda6688/.github/golangci.yml -------------------------------------------------------------------------------- /.github/goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod download 6 | 7 | checksum: 8 | name_template: 'checksums.txt' 9 | 10 | snapshot: 11 | version_template: "securityslacker_{{ .Version }}" 12 | 13 | changelog: 14 | sort: asc 15 | filters: 16 | exclude: 17 | - '^docs:' 18 | - '^test:' 19 | - '^chore' 20 | 21 | release: 22 | disable: false 23 | 24 | dockers: 25 | - 26 | image_templates: 27 | - "ghcr.io/hazcod/security-slacker/securityslacker:latest" 28 | - "ghcr.io/hazcod/security-slacker/securityslacker:{{ .Tag }}" 29 | - "ghcr.io/hazcod/security-slacker/securityslacker:{{ .Major }}" 30 | 31 | sboms: 32 | - 33 | artifacts: archive 34 | 35 | builds: 36 | - 37 | id: cli 38 | dir: ./cmd/ 39 | env: [CGO_ENABLED=0] 40 | ldflags: [-w -s -extldflags "-static"] 41 | goos: [darwin, linux, windows] 42 | goarch: [amd64, arm64] 43 | binary: securityslacker -------------------------------------------------------------------------------- /.github/readme/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hazcod/security-slacker/20fff034949445f3a6f93d27a378716ddfda6688/.github/readme/overview.png -------------------------------------------------------------------------------- /.github/readme/user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hazcod/security-slacker/20fff034949445f3a6f93d27a378716ddfda6688/.github/readme/user.png -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | goreleaser: 7 | name: build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - 11 | uses: actions/checkout@v4 12 | - 13 | id: versions 14 | run: | 15 | echo ::set-output name=go::$(grep '^FROM go' .github/go/Dockerfile | cut -d ' ' -f 2 | cut -d ':' -f 2) 16 | echo "Using Go version ${{ steps.versions.outputs.go }}" 17 | - 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: ${{ steps.versions.outputs.go }} 21 | - 22 | uses: goreleaser/goreleaser-action@v6 23 | with: 24 | version: latest 25 | args: build --config=.github/goreleaser.yml --snapshot 26 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | golangci: 7 | name: lint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - 11 | uses: actions/checkout@v4 12 | - 13 | uses: reviewdog/action-golangci-lint@master 14 | with: 15 | github_token: ${{ secrets.github_token }} 16 | golangci_lint_flags: "--config=.github/golangci.yml" 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: [main, master] 6 | 7 | jobs: 8 | goreleaser: 9 | runs-on: ubuntu-latest 10 | 11 | outputs: 12 | hashes: ${{ steps.hashes.outputs.hashes }} 13 | version: ${{ steps.semrel.outputs.version }} 14 | 15 | permissions: 16 | contents: write 17 | packages: write 18 | 19 | steps: 20 | - 21 | name: Checkout 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | - 26 | id: vars 27 | run: | 28 | goVersion=$(grep '^FROM go' .github/go/Dockerfile | cut -d ' ' -f 2 | cut -d ':' -f 2) 29 | echo "go_version=${goVersion}" >> $GITHUB_OUTPUT 30 | echo "Using Go version ${goVersion}" 31 | - 32 | name: Set up Go 33 | uses: actions/setup-go@v5 34 | with: 35 | go-version: ${{ steps.vars.outputs.go_version }} 36 | - 37 | name: Run Trivy in GitHub SBOM mode and submit results to Dependency Snapshots 38 | uses: aquasecurity/trivy-action@master 39 | with: 40 | scan-type: 'fs' 41 | format: 'github' 42 | output: 'dependency-results.sbom.json' 43 | image-ref: '.' 44 | github-pat: ${{ secrets.GH_PRIVATEREPO_TOKEN }} 45 | - 46 | name: Remove SBOM result 47 | run: | 48 | rm dependency-results.sbom.json 49 | - 50 | name: Install syft 51 | run: | 52 | curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin 53 | - 54 | name: Create release tag 55 | uses: go-semantic-release/action@v1 56 | id: semrel 57 | with: 58 | github-token: ${{ secrets.GITHUB_TOKEN }} 59 | - 60 | run: git fetch -a 61 | if: steps.semrel.outputs.version != '' 62 | - 63 | name: Login to GitHub Docker registry 64 | if: steps.semrel.outputs.version != '' 65 | uses: docker/login-action@v3 66 | with: 67 | registry: ghcr.io 68 | username: ${{ github.repository_owner }} 69 | password: ${{ secrets.GITHUB_TOKEN }} 70 | - 71 | name: Release 72 | uses: goreleaser/goreleaser-action@v6.1.0 73 | if: steps.semrel.outputs.version != '' 74 | with: 75 | version: latest 76 | args: release --config=.github/goreleaser.yml --clean 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | - 80 | name: Generate dist hashes 81 | id: hashes 82 | if: steps.semrel.outputs.version != '' 83 | env: 84 | ARTIFACTS: "${{ steps.goreleaser.outputs.artifacts }}" 85 | run: | 86 | checksum_file=$(echo "$ARTIFACTS" | jq -r '.[] | select (.type=="Checksum") | .path') 87 | echo "hashes=$(cat $checksum_file | base64 -w0)" >> $GITHUB_OUTPUT 88 | 89 | provenance: 90 | needs: [goreleaser] 91 | if: needs.goreleaser.outputs.hashes != '' 92 | permissions: 93 | actions: read # To read the workflow path. 94 | id-token: write # To sign the provenance. 95 | contents: write # To add assets to a release. 96 | uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 97 | with: 98 | base64-subjects: "${{ needs.goreleaser.outputs.hashes }}" 99 | -------------------------------------------------------------------------------- /.github/workflows/todo.yml: -------------------------------------------------------------------------------- 1 | name: Todo 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | todo: 9 | name: todo 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | uses: actions/checkout@v4 14 | - 15 | name: Check Todos 16 | uses: ribtoks/tdg-github-action@master 17 | with: 18 | TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | REPO: ${{ github.repository }} 20 | SHA: ${{ github.sha }} 21 | REF: ${{ github.ref }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | vendor/ 16 | 17 | # exclude config 18 | test.yaml 19 | test.yml 20 | 21 | # binaries 22 | slacker 23 | csn 24 | 25 | # jetbrains ide 26 | .idea/ 27 | 28 | .cache/ 29 | .generated-go-semantic-release-changelog.md 30 | .semrel/ 31 | 32 | .DS_Store 33 | dist/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest AS builder 2 | 3 | # add ca certificates and timezone data files 4 | # hadolint ignore=DL3018 5 | RUN apk add -U --no-cache ca-certificates tzdata 6 | 7 | # add unprivileged user 8 | RUN adduser -s /bin/true -u 1000 -D -h /app app \ 9 | && sed -i -r "/^(app|root)/!d" /etc/group /etc/passwd \ 10 | && sed -i -r 's#^(.*):[^:]*$#\1:/sbin/nologin#' /etc/passwd 11 | 12 | # 13 | # --- 14 | # 15 | 16 | # start with empty image 17 | FROM scratch 18 | 19 | # add-in our timezone data file 20 | COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo 21 | 22 | # add-in our unprivileged user 23 | COPY --from=builder /etc/passwd /etc/group /etc/shadow /etc/ 24 | 25 | # add-in our ca certificates 26 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 27 | 28 | COPY --chown=app securityslacker /app 29 | 30 | # from now on, run as the unprivileged user 31 | USER 1000 32 | 33 | # entrypoint 34 | ENTRYPOINT ["/app"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Niels Hofmans 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: run 2 | 3 | clean: 4 | rm -r dist/ || true 5 | 6 | build: 7 | goreleaser --config=.github/goreleaser.yml build --snapshot --clean 8 | 9 | run: 10 | go run ./cmd/ -dry -config=test.yml -log=trace 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🤖 security-slacker 2 | Pokes users on Slack about outstanding risks found by Crowdstrike Spotlight or vmware Workspace ONE so they can secure their own endpoint. 3 | 4 | Self-service security culture! :partying_face: 5 | 6 | Slack message for the user: 7 | 8 | ![slack example](.github/readme/user.png) 9 | 10 | Slack overview message for the security user: 11 | 12 | ![slack example](.github/readme/overview.png) 13 | 14 | ## Heroku 15 | 16 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 17 | 18 | ## Instructions 19 | 20 | 1. Tag your Falcon hosts with `email/user/company/com` if their email is `user@company.com`. 21 | 2. Assign compliance policies to your devices in Workspace ONE. 22 | 3. Fetch a binary release or Docker image from [Releases](https://github.com/hazcod/security-slacker/releases). 23 | 4. Create a Falcon API token to use in `API Clients and Keys` with `Read` permission to `Hosts` and `Spotlight`. 24 | 5. Create Workspace ONE oauth2 API credentials with a read-only role. 25 | 6. Create a configuration file: 26 | ```yaml 27 | slack: 28 | # slack bot token 29 | token: "XXX" 30 | # Slack user that receives messages if the user is not found 31 | security_user: ["security@mycompany.com"] 32 | # skip sending a security overview if there is nothing to mention 33 | skip_no_report: true 34 | # don't send a message to the user if 'Vacationing' status is set 35 | skip_on_holiday: true 36 | 37 | # falcon crowdstrike 38 | falcon: 39 | # falcon api credentials 40 | clientid: "XXX" 41 | secret: "XXX" 42 | # your falcon SaaS cloud region 43 | cloud_region: "eu-1" 44 | # skip vulnerabilities without available patches 45 | skip_no_mitigation: true 46 | # what severity classes you want to skip 47 | skip_severities: ["low"] 48 | # minimum CVE base score to report 49 | min_cve_base_score: 0 50 | # the CVEs you want to ignore 51 | skip_cves: ["CVE-2019-15315"] 52 | # the minimum exprtAI severity you want to filter for 53 | min_exprtai_severity: medium 54 | 55 | # vmware workspace one 56 | ws1: 57 | # the api endpoint of your Workspace ONE instance, eg. "https://asXXXX.awmdm.com/api/" 58 | api_url: "https://xxx.awmdm.com/api/" 59 | # your Workspace ONE oauth2 credentials 60 | # Groups & Settings > Configurations > Search for "oauth" > Click > Add with a Reader role 61 | client_id: "XXX" 62 | client_secret: "XXX" 63 | # the location of your Workspace ONE tenant, see 'Region-specific Token URLs' 64 | # https://docs.vmware.com/en/VMware-Workspace-ONE-UEM/services/UEM_ConsoleBasics/GUID-BF20C949-5065-4DCF-889D-1E0151016B5A.html 65 | auth_location: "emea" 66 | # what policies you want to skip 67 | # leave user or policy blank to ignore it 68 | skip: 69 | - policy: "Disk Encryption" 70 | user: "some_special_user@company.com" 71 | 72 | # email domains used in your Slack workspace for filtering 73 | # e.g. for a Slack account user@mycompany.com 74 | email: 75 | domains: ["mycompany.com"] 76 | # any users that shouldn't be in MDM or EDR 77 | whitelist: 78 | - foo@company.com 79 | 80 | # what is sent to the user in Go templating 81 | templates: 82 | user_message: | 83 | *:warning: We detected security issues on your device(s)* 84 | Hi {{ .Slack.Profile.FirstName }} {{ .Slack.Profile.LastName }}! 85 | 86 | {{ if not (eq (len .Falcon.Devices) 0) }} 87 | One or more of your devices seem to be vulnerable. 88 | Luckily we noticed there are patches available. Please install following patches: 89 | {{ range $device := .Falcon.Devices }} 90 | :computer: {{ $device.MachineName }} 91 | {{ range $vuln := $device.Findings }} 92 | `{{ $vuln.ProductName }}` 93 | {{ end }} 94 | {{ end }} 95 | {{ end }} 96 | 97 | {{ if not (eq (len .WS1.Devices) 0) }} 98 | One or more of your devices seem to be misconfigured in an insecure way. 99 | Please check the below policies which are violated: 100 | {{ range $device := .WS1.Devices }} 101 | :computer: {{ $device.MachineName }} 102 | {{ range $finding := $device.Findings }} 103 | - :warning: {{ $finding.ComplianceName }} 104 | {{ end }} 105 | {{ end }} 106 | {{ end }} 107 | 108 | Please resolve those issues as soon as possible. In case of any issues, hop into *#security*. 109 | Thank you! :wave: 110 | 111 | security_overview_message: | 112 | 113 | :information_source: *Device Posture overview* {{ .Date.Format "Jan 02, 2006 15:04:05 UTC" }} 114 | 115 | {{ if and (not .Falcon) (not .WS1) }}Nothing to report! :white_check_mark: {{ else }} 116 | 117 | {{ range $result := .Falcon }} 118 | :man-surfing: *{{ $result.Email }}* 119 | {{ range $device := $result.Devices }} 120 | :computer: {{ $device.MachineName}} 121 | {{ range $vuln := $device.Findings }}- {{ $vuln.ProductName }} ({{ $vuln.CveSeverity }}) (Open for {{ $vuln.DaysOpen }} days) ({{ $vuln.CveID }}){{ end }} 122 | {{ end }} 123 | {{ end }} 124 | 125 | {{ range $result := .WS1 }} 126 | :man-surfing: *{{ $result.Email }}* 127 | {{ range $device := $result.Devices }} 128 | :computer: {{ $device.MachineName }} 129 | Compromised: {{ $device.Compromised }} 130 | Last seen: {{ $device.LastSeen.Format "Jan 02, 2006 15:04:05 UTC" }} 131 | {{ range $finding := $device.Findings }}- :warning: {{ $finding.ComplianceName }}{{ end }} 132 | {{ end }} 133 | {{ end }} 134 | {{ end }} 135 | 136 | {{ if .Errors }} 137 | :warning: *Errors:* 138 | {{ range $err := .Errors }} 139 | - {{ $err }} 140 | {{ end }} 141 | {{ end }} 142 | ``` 143 | 7. Run `css -config=your-config.yml -log=debug -dry` to test. 144 | 8. See the security overview popup to you in Slack! 145 | 9. Now run it for real with `css -config=your-config.yml`. 146 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "security-slacker", 3 | "description": "Pokes users about outstanding security risks found by Crowdstrike Spotlight or vmware Workspace ONE so they secure their own endpoint.", 4 | "repository": "https://github.com/hazcod/security-slacker", 5 | "keywords": ["security", "slack", "slacker", "crowdstrike", "spotlight", "workspace", "one", "airwatch", "nag", "patch", "vulnerability"], 6 | "builpacks": [ 7 | { "url": "heroku/go" } 8 | ], 9 | "env": { 10 | "CSS_SLACK_TOKEN": { "required": true, "description": "" }, 11 | "CSS_SLACK_SECURITY_USER": { "required": true, "description": "" }, 12 | "CSS_SLACK_SKIP_NO_REPORT": { "required": false, "description": "" }, 13 | "CSS_FALCON_CLIENT_ID": { "required": true, "description": "" }, 14 | "CSS_FALCON_SECRET": { "required": true, "description": "" }, 15 | "CSS_FALCON_CLOUD_REGION": { "required": true, "description": "" }, 16 | "CSS_FALCON_SKIP_NO_MITIGATION": { "required": false, "description": "" }, 17 | "CSS_FALCON_SKIP_SEVERITIES": { "required": false, "description": "" }, 18 | "CSS_FALCON_MIN_CVE_BASE_SCORE": { "required": false, "description": "" }, 19 | "CSS_WS1_API_URL": { "required": true, "description": "" }, 20 | "CSS_WS1_USER": { "required": true, "description": "" }, 21 | "CSS_WS1_PASSWORD": { "required": true, "description": "" }, 22 | "CSS_DOMAINS": { "required": true, "description": "" }, 23 | "CSS_USER_MESSAGE": { "required": true, "description": "" }, 24 | "CSS_SECURITY_OVERVIEW_MESSAGE": { "required": true, "description": "" } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "github.com/hazcod/security-slacker/pkg/overview/security" 7 | "github.com/hazcod/security-slacker/pkg/overview/user" 8 | slackPkg "github.com/hazcod/security-slacker/pkg/slack" 9 | "github.com/hazcod/security-slacker/pkg/ws1" 10 | "gopkg.in/errgo.v2/fmt/errors" 11 | "os" 12 | "strings" 13 | 14 | config2 "github.com/hazcod/security-slacker/config" 15 | "github.com/hazcod/security-slacker/pkg/falcon" 16 | "github.com/sirupsen/logrus" 17 | "github.com/slack-go/slack" 18 | ) 19 | 20 | func main() { 21 | ctx := context.Background() 22 | 23 | configPath := flag.String("config", "", "Path to your config file.") 24 | logLevelStr := flag.String("log", "info", "Log level.") 25 | dryMode := flag.Bool("dry", false, "whether we run in dry-run mode and send nothing to the users.") 26 | noReport := flag.Bool("noreport", false, "disable sending an overview to the security user.") 27 | flag.Parse() 28 | 29 | logLevel, err := logrus.ParseLevel(*logLevelStr) 30 | if err != nil { 31 | logrus.WithError(err).Fatal("could not parse log level") 32 | } 33 | logrus.SetLevel(logLevel) 34 | 35 | if *dryMode { 36 | logrus.Warn("running in dry mode, nothing will be sent to the users") 37 | } 38 | 39 | config, err := config2.LoadConfig(logrus.StandardLogger(), *configPath) 40 | if err != nil { 41 | logrus.Fatalf("could not load configuration: %s", err) 42 | } 43 | 44 | if err := config.Validate(); err != nil { 45 | logrus.WithError(err).Fatal("invalid configuration") 46 | } 47 | 48 | // --- 49 | 50 | falconMessages, usersWithSensors, securityErrors, err := falcon.GetMessages(config, ctx) 51 | if err != nil { 52 | logrus.WithError(err).Fatal("could not get falcon messages") 53 | } 54 | 55 | ws1Messages, usersWithDevices, mdmSecurityErrors, err := ws1.GetMessages(config, ctx) 56 | if err != nil { 57 | logrus.WithError(err).Fatal("could not get WS1 messages") 58 | } 59 | 60 | securityErrors = append(securityErrors, mdmSecurityErrors...) 61 | if len(securityErrors) > 0 { 62 | for _, secError := range securityErrors { 63 | logrus.WithField("module", "falcon").Warn(secError.Error()) 64 | } 65 | } 66 | 67 | usersWithMDMOrEDR := append(usersWithDevices, usersWithSensors...) 68 | 69 | // --- 70 | 71 | slackClient := slack.New(config.Slack.Token) 72 | 73 | logrus.Debug("fetching slack users") 74 | slackUsers, err := slackClient.GetUsers() 75 | if err != nil { 76 | logrus.WithError(err).Fatal("could not fetch slack users") 77 | } 78 | 79 | securityUserIDs := map[string]string{} 80 | for _, slackUser := range slackUsers { 81 | for _, secUser := range config.Slack.SecurityUser { 82 | if strings.EqualFold(slackUser.Profile.Email, secUser) { 83 | securityUserIDs[secUser] = slackUser.ID 84 | break 85 | } 86 | } 87 | 88 | if len(securityUserIDs) == len(config.Slack.SecurityUser) { 89 | break 90 | } 91 | } 92 | 93 | if len(securityUserIDs) == 0 && !*noReport { 94 | logrus.WithField("fallback_users", config.Slack.SecurityUser). 95 | Fatal("could not find fallback user on Slack") 96 | } 97 | 98 | logrus.WithField("users", len(slackUsers)).Info("found Slack users") 99 | 100 | errorsToReport := securityErrors 101 | 102 | for _, slackUser := range slackUsers { 103 | userEmail := strings.ToLower(slackUser.Profile.Email) 104 | 105 | if slackUser.IsBot || slackUser.Deleted || "" == slackUser.Profile.Email { 106 | continue 107 | } 108 | 109 | userFalconMsg := falconMessages[userEmail] 110 | 111 | userWS1Msg := ws1Messages[userEmail] 112 | 113 | numFindings := 0 114 | for _, device := range userWS1Msg.Devices { 115 | numFindings += len(device.Findings) 116 | } 117 | 118 | // check if every slack user has a device in MDM 119 | hasDevice := false 120 | for _, userWDevice := range usersWithMDMOrEDR { 121 | if strings.EqualFold(userWDevice, userEmail) { 122 | hasDevice = true 123 | break 124 | } 125 | } 126 | 127 | if !hasDevice { 128 | isWhitelisted := false 129 | 130 | for _, whitelist := range config.Email.Whitelist { 131 | if strings.EqualFold(strings.TrimSpace(whitelist), strings.TrimSpace(userEmail)) { 132 | isWhitelisted = true 133 | break 134 | } 135 | } 136 | 137 | if !isWhitelisted { 138 | errorsToReport = append(errorsToReport, errors.Newf( 139 | "%s does not have a device in MDM nor a sensor", userEmail, 140 | )) 141 | } 142 | } 143 | 144 | if len(userFalconMsg.Devices) == 0 && numFindings == 0 { 145 | continue 146 | } 147 | 148 | logrus.WithField("email", slackUser.Profile.Email).Debug("looking at Slack user") 149 | 150 | if config.Slack.SkipOnHoliday && slackPkg.IsOnHoliday(slackUser) { 151 | logrus.WithField("slack_name", slackUser.Name).Warn("skipping user since he/she is on holiday") 152 | continue 153 | } 154 | 155 | logrus.WithField("falcon", len(userFalconMsg.Devices)).WithField("ws1", len(userWS1Msg.Devices)). 156 | WithField("email", userEmail).Debug("found messages") 157 | 158 | slackMessage, err := user.BuildUserOverviewMessage( 159 | logrus.StandardLogger(), config, slackUser, falconMessages[userEmail], ws1Messages[userEmail]) 160 | if err != nil { 161 | logrus.WithError(err).WithField("user", slackUser.Profile.Email).Error("could not generate user message") 162 | continue 163 | } 164 | 165 | if slackMessage == "" { 166 | continue 167 | } 168 | 169 | if !*dryMode { 170 | if _, _, _, err := slackClient.SendMessage( 171 | slackUser.ID, 172 | slack.MsgOptionText(slackMessage, false), 173 | slack.MsgOptionAsUser(true), 174 | ); err != nil { 175 | logrus.WithError(err). 176 | WithField("user", slackUser.Profile.Email). 177 | Error("could not send slack message") 178 | continue 179 | } 180 | } 181 | 182 | logrus.WithField("user", userEmail).Info("sent notice on Slack") 183 | } 184 | 185 | if *noReport { 186 | logrus.Info("exiting since security overview is disabled") 187 | os.Exit(0) 188 | } 189 | 190 | if config.Templates.SecurityOverviewMessage == "" { 191 | logrus.Warn("not sending a security overview since template is empty") 192 | os.Exit(0) 193 | } 194 | 195 | if config.Slack.SkipNoReport { 196 | if len(falconMessages) == 0 && len(ws1Messages) == 0 { 197 | logrus.Info("nothing to report, exiting") 198 | os.Exit(0) 199 | } 200 | } 201 | 202 | // --- find users without sensors 203 | 204 | for _, userWithSensor := range usersWithSensors { 205 | if strings.HasPrefix(userWithSensor, "_NOTAG/") { 206 | errorsToReport = append(errorsToReport, errors.Newf( 207 | "%s does not have a user email tag assigned", strings.Split(userWithSensor, "/")[1], 208 | )) 209 | } 210 | } 211 | 212 | for _, userWDevice := range usersWithDevices { 213 | if strings.TrimSpace(userWDevice) == "" { 214 | continue 215 | } 216 | 217 | found := false 218 | 219 | for _, userWSensor := range usersWithSensors { 220 | if strings.EqualFold(userWDevice, userWSensor) { 221 | found = true 222 | break 223 | } 224 | } 225 | 226 | if !found { 227 | errorsToReport = append(errorsToReport, errors.Newf( 228 | "%s does not have at least one sensor assigned", userWDevice), 229 | ) 230 | } 231 | } 232 | 233 | // --- 234 | 235 | overviewText, err := security.BuildSecurityOverviewMessage(logrus.StandardLogger(), 236 | *config, falconMessages, ws1Messages, errorsToReport) 237 | if err != nil { 238 | logrus.WithError(err).Fatal("could not generate security overview") 239 | } 240 | 241 | logrus.WithField("emails", config.Slack.SecurityUser). 242 | Debug("sending security report to security users") 243 | 244 | for _, secUser := range config.Slack.SecurityUser { 245 | if _, _, _, err := slackClient.SendMessage( 246 | securityUserIDs[secUser], slack.MsgOptionText(overviewText, false), slack.MsgOptionAsUser(true), 247 | ); err != nil { 248 | logrus.WithField("email", secUser).WithError(err). 249 | Fatal("could not send security overview to security user") 250 | } 251 | 252 | logrus.WithField("email", secUser).Info("sent security overview to security user") 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/kelseyhightower/envconfig" 5 | "github.com/pkg/errors" 6 | "github.com/sirupsen/logrus" 7 | "gopkg.in/yaml.v3" 8 | "os" 9 | ) 10 | 11 | const ( 12 | appEnvPrefix = "CSS" 13 | ) 14 | 15 | type Config struct { 16 | Slack struct { 17 | Token string `yaml:"token" env:"SLACK_TOKEN"` 18 | SecurityUser []string `yaml:"security_user" emv:"SLACK_SECURITY_USER"` 19 | 20 | SkipNoReport bool `yaml:"skip_no_report" env:"SLACK_SKIP_NO_REPORT"` 21 | SkipOnHoliday bool `yaml:"skip_on_holiday" env:"SLACK_SKIP_ON_HOLIDAY"` 22 | } `yaml:"slack"` 23 | 24 | Falcon struct { 25 | ClientID string `yaml:"clientid" env:"FALCON_CLIENT_ID"` 26 | Secret string `yaml:"secret" env:"FALCON_SECRET"` 27 | CloudRegion string `yaml:"cloud_region" env:"FALCON_CLOUD_REGION"` 28 | 29 | SkipNoMitigation bool `yaml:"skip_no_mitigation" env:"FALCON_SKIP_NO_MITIGATION"` 30 | SkipSeverities []string `yaml:"skip_severities" env:"FALCON_SKIP_SEVERITIES"` 31 | MinCVEBaseScore int `yaml:"min_cve_base_score" env:"FALCON_MIN_CVE_BASE_SCORE"` 32 | SkipCVEs []string `yaml:"skip_cves" env:"FALCON_SKIP_CVES"` 33 | MinExprtAISeverity string `yaml:"min_exprtai_severity" env:"FALCON_MIN_EXPRTAI_SEVERITYs"` 34 | } `yaml:"falcon"` 35 | 36 | WS1 struct { 37 | Endpoint string `yaml:"api_url" env:"WS1_API_URL"` 38 | // from https://docs.vmware.com/en/VMware-Workspace-ONE-UEM/services/UEM_ConsoleBasics/GUID-BF20C949-5065-4DCF-889D-1E0151016B5A.html 39 | // e.g. 'emea' 40 | AuthLocation string `yaml:"auth_location" env:"WS1_AUTH_LOCATION"` 41 | ClientID string `yaml:"client_id" env:"WS1_CLIENT_ID"` 42 | ClientSecret string `yaml:"client_secret" env:"WS1_CLIENT_SECRET"` 43 | 44 | SkipFilters []struct { 45 | Policy string `yaml:"policy"` 46 | User string `yaml:"user"` 47 | } `yaml:"skip"` 48 | } `yaml:"ws1"` 49 | 50 | Email struct { 51 | Domains []string `yaml:"domains" env:"DOMAINS"` 52 | Whitelist []string `yaml:"whitelist" env:"WHITELIST"` 53 | } `yaml:"email"` 54 | 55 | Templates struct { 56 | UserMessage string `yaml:"user_message" env:"USER_MESSAGE"` 57 | SecurityOverviewMessage string `yaml:"security_overview_message" env:"SECURITY_OVERVIEW_MESSAGE"` 58 | } `yaml:"templates"` 59 | } 60 | 61 | func LoadConfig(logger *logrus.Logger, path string) (*Config, error) { 62 | var config Config 63 | 64 | if path != "" { 65 | configBytes, err := os.ReadFile(path) 66 | if err != nil { 67 | return nil, errors.Wrap(err, "could not load configuration file") 68 | } 69 | 70 | if err := yaml.Unmarshal(configBytes, &config); err != nil { 71 | return nil, errors.Wrap(err, "could not parse configuration file") 72 | } 73 | 74 | logger.Info("loaded configuration from " + path) 75 | } 76 | 77 | if err := envconfig.Process(appEnvPrefix, &config); err != nil { 78 | return nil, errors.Wrap(err, "could not load environment variables") 79 | } 80 | 81 | return &config, nil 82 | } 83 | 84 | func (c *Config) Validate() error { 85 | if c.Slack.Token == "" { 86 | return errors.New("missing slack token") 87 | } 88 | 89 | if c.Falcon.ClientID == "" { 90 | return errors.New("missing falcon clientid") 91 | } 92 | 93 | if c.Falcon.Secret == "" { 94 | return errors.New("missing falcon secret") 95 | } 96 | 97 | if c.Falcon.CloudRegion == "" { 98 | return errors.New("missing falcon cloud region") 99 | } 100 | 101 | if len(c.Email.Domains) == 0 { 102 | return errors.New("missing email domain(s)") 103 | } 104 | 105 | if c.Templates.UserMessage == "" { 106 | return errors.New("missing message") 107 | } 108 | 109 | if c.WS1.ClientSecret == "" || c.WS1.ClientID == "" { 110 | return errors.New("missing WS1 client_id or client_secret") 111 | } 112 | 113 | if c.WS1.AuthLocation == "" { 114 | return errors.New("missing WS1 auth_location") 115 | } 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hazcod/security-slacker 2 | 3 | go 1.23.5 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/crowdstrike/gofalcon v0.14.2 9 | github.com/kelseyhightower/envconfig v1.4.0 10 | github.com/pkg/errors v0.9.1 11 | github.com/sirupsen/logrus v1.9.3 12 | github.com/slack-go/slack v0.17.1 13 | golang.org/x/oauth2 v0.29.0 14 | gopkg.in/errgo.v2 v2.1.0 15 | gopkg.in/yaml.v3 v3.0.1 16 | ) 17 | 18 | require ( 19 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 20 | github.com/blang/semver/v4 v4.0.0 // indirect 21 | github.com/go-logr/logr v1.4.2 // indirect 22 | github.com/go-logr/stdr v1.2.2 // indirect 23 | github.com/go-openapi/analysis v0.23.0 // indirect 24 | github.com/go-openapi/errors v0.22.0 // indirect 25 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 26 | github.com/go-openapi/jsonreference v0.21.0 // indirect 27 | github.com/go-openapi/loads v0.22.0 // indirect 28 | github.com/go-openapi/runtime v0.28.0 // indirect 29 | github.com/go-openapi/spec v0.21.0 // indirect 30 | github.com/go-openapi/strfmt v0.23.0 // indirect 31 | github.com/go-openapi/swag v0.23.0 // indirect 32 | github.com/go-openapi/validate v0.24.0 // indirect 33 | github.com/google/uuid v1.6.0 // indirect 34 | github.com/gorilla/websocket v1.5.3 // indirect 35 | github.com/josharian/intern v1.0.0 // indirect 36 | github.com/mailru/easyjson v0.9.0 // indirect 37 | github.com/mitchellh/mapstructure v1.5.0 // indirect 38 | github.com/oklog/ulid v1.3.1 // indirect 39 | github.com/opentracing/opentracing-go v1.2.0 // indirect 40 | go.mongodb.org/mongo-driver v1.17.2 // indirect 41 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 42 | go.opentelemetry.io/otel v1.34.0 // indirect 43 | go.opentelemetry.io/otel/metric v1.34.0 // indirect 44 | go.opentelemetry.io/otel/trace v1.34.0 // indirect 45 | golang.org/x/sync v0.10.0 // indirect 46 | golang.org/x/sys v0.29.0 // indirect 47 | ) 48 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= 2 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 3 | github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= 4 | github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= 5 | github.com/crowdstrike/gofalcon v0.14.2 h1:M5I6mPs7A87AUZN5zqeZM0TZr2NvGEoFM+tEysIIXOc= 6 | github.com/crowdstrike/gofalcon v0.14.2/go.mod h1:zKtGtkiNyxlY1mVdGY0/fRWb1AlCdGi8Z50AjfGAtvg= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 11 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 12 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 13 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 14 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 15 | github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= 16 | github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= 17 | github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= 18 | github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= 19 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 20 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 21 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 22 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 23 | github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= 24 | github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= 25 | github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= 26 | github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= 27 | github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= 28 | github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= 29 | github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= 30 | github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= 31 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 32 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 33 | github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= 34 | github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= 35 | github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 36 | github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 37 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 38 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 39 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 40 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 41 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 42 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 43 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 44 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 45 | github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= 46 | github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= 47 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 48 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 49 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 50 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 51 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 52 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 53 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 54 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 55 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 56 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 57 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 58 | github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= 59 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 60 | github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 61 | github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 62 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 63 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 64 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 65 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 66 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 67 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 68 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 69 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 70 | github.com/slack-go/slack v0.17.1 h1:x0Mnc6biHBea5vfxLR+x4JFl/Rm3eIo0iS3xDZenX+o= 71 | github.com/slack-go/slack v0.17.1/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= 72 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 73 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 74 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 75 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 76 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 77 | go.mongodb.org/mongo-driver v1.17.2 h1:gvZyk8352qSfzyZ2UMWcpDpMSGEr1eqE4T793SqyhzM= 78 | go.mongodb.org/mongo-driver v1.17.2/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= 79 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 80 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 81 | go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= 82 | go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= 83 | go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= 84 | go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= 85 | go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= 86 | go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= 87 | go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= 88 | go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= 89 | golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= 90 | golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 91 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 92 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 93 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 94 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 95 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 96 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 97 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 98 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 99 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 100 | gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= 101 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 102 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 103 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 104 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 105 | -------------------------------------------------------------------------------- /pkg/falcon/extractor.go: -------------------------------------------------------------------------------- 1 | package falcon 2 | 3 | import ( 4 | "context" 5 | "crypto/sha1" 6 | "encoding/hex" 7 | "encoding/json" 8 | "fmt" 9 | "github.com/crowdstrike/gofalcon/falcon/client/hosts" 10 | "github.com/crowdstrike/gofalcon/falcon/client/spotlight_vulnerabilities" 11 | "github.com/pkg/errors" 12 | "math" 13 | "strings" 14 | "time" 15 | 16 | "github.com/crowdstrike/gofalcon/falcon" 17 | "github.com/crowdstrike/gofalcon/falcon/models" 18 | "github.com/hazcod/security-slacker/config" 19 | "github.com/sirupsen/logrus" 20 | ) 21 | 22 | const ( 23 | tagEmailPrefix = "email/" 24 | tagFalconPrefix = "FalconGroupingTags/" 25 | tagSensorPrefix = "SensorGroupingTags/" 26 | ) 27 | 28 | type FalconResult struct { 29 | Email string 30 | Devices []UserDevice 31 | } 32 | 33 | type UserDevice struct { 34 | Hostname string 35 | MachineName string 36 | Tags []string 37 | Findings []UserDeviceFinding 38 | } 39 | 40 | type UserDeviceFinding struct { 41 | FixedVersion string 42 | ProductName string 43 | CveID string 44 | CveSeverity string 45 | TimestampFound string 46 | DaysOpen uint 47 | Mitigations []string 48 | } 49 | 50 | func getUniqueDeviceID(hostInfo models.DomainAPIVulnerabilityHostFacetV2) (string, error) { 51 | b, err := json.Marshal(&hostInfo) 52 | if err != nil { 53 | return "", err 54 | } 55 | hasher := sha1.New() 56 | if _, err := hasher.Write(b); err != nil { 57 | return "", err 58 | } 59 | return hex.EncodeToString(hasher.Sum(nil)), nil 60 | } 61 | 62 | func findEmailTag(tags []string, emailDomains []string) (email string, err error) { 63 | for _, tag := range tags { 64 | tag = strings.ToLower(tag) 65 | tag = strings.TrimPrefix(tag, strings.ToLower(tagFalconPrefix)) 66 | tag = strings.TrimPrefix(tag, strings.ToLower(tagSensorPrefix)) 67 | 68 | logrus.WithField("tag", tag).Trace("looking at falcon tag") 69 | 70 | if !strings.HasPrefix(tag, tagEmailPrefix) { 71 | continue 72 | } 73 | 74 | email = strings.TrimPrefix(tag, tagEmailPrefix) 75 | break 76 | } 77 | 78 | if email == "" { 79 | return "", errors.New("email tag not found") 80 | } 81 | 82 | domainFound := false 83 | for _, domain := range emailDomains { 84 | encodedDomain := strings.ToLower(strings.ReplaceAll(domain, ".", "/")) 85 | 86 | if !strings.HasSuffix(email, encodedDomain) { 87 | continue 88 | } 89 | 90 | email = strings.Replace(email, fmt.Sprintf("/%s", encodedDomain), fmt.Sprintf("@%s", domain), 1) 91 | email = strings.ReplaceAll(email, "/", ".") 92 | 93 | domainFound = true 94 | break 95 | } 96 | 97 | if !domainFound { 98 | return "", errors.New("domain not recognized") 99 | } 100 | 101 | if !strings.Contains(email, "@") || !strings.Contains(email, ".") { 102 | return "", errors.New("invalid email address: " + email) 103 | } 104 | 105 | logrus.WithField("email", email).Debug("converted tag to email") 106 | 107 | return email, nil 108 | } 109 | 110 | func appendUnique(main, adder []string) []string { 111 | for i := range adder { 112 | found := false 113 | 114 | for j := range main { 115 | if strings.EqualFold(adder[i], main[j]) { 116 | found = true 117 | break 118 | } 119 | } 120 | 121 | if found { 122 | continue 123 | } 124 | 125 | main = append(main, adder[i]) 126 | } 127 | 128 | return main 129 | } 130 | 131 | func getSeverityScore(severity string) (int, error) { 132 | switch strings.TrimSpace(strings.ToLower(severity)) { 133 | case "": 134 | return 0, nil 135 | case "low": 136 | return 0, nil 137 | case "medium": 138 | return 1, nil 139 | case "high": 140 | return 2, nil 141 | case "critical": 142 | return 3, nil 143 | } 144 | 145 | return -1, errors.New("unknown severity: " + severity) 146 | } 147 | 148 | func GetMessages(config *config.Config, ctx context.Context) (results map[string]FalconResult, usersWithSensors []string, securityErrors []error, err error) { 149 | falconAPIMaxRecords := int64(5000) 150 | 151 | results = map[string]FalconResult{} 152 | 153 | client, err := falcon.NewClient(&falcon.ApiConfig{ 154 | ClientId: config.Falcon.ClientID, 155 | ClientSecret: config.Falcon.Secret, 156 | Cloud: falcon.Cloud(config.Falcon.CloudRegion), 157 | Context: ctx, 158 | }) 159 | if err != nil { 160 | return nil, nil, nil, errors.Wrap(err, "could not initialize Falcon client") 161 | } 162 | 163 | // this filters our Cloud Hosts which are not relevant for user notifications 164 | hostFilter := "service_provider:null" 165 | 166 | hostResult, err := client.Hosts.QueryDevicesByFilter( 167 | &hosts.QueryDevicesByFilterParams{ 168 | Filter: &hostFilter, 169 | Limit: &falconAPIMaxRecords, 170 | Offset: nil, 171 | Sort: nil, 172 | Context: ctx, 173 | }, 174 | ) 175 | if err != nil || !hostResult.IsSuccess() { 176 | return nil, nil, nil, errors.Wrap(err, "could not query all hosts") 177 | } 178 | 179 | allHostDetails := make([]*models.DeviceapiDeviceSwagger, 0) 180 | 181 | step := 100 182 | 183 | for sliceStart := 0; sliceStart < len(hostResult.Payload.Resources); sliceStart += step { 184 | sliceEnd := sliceStart + step 185 | if sliceEnd > len(hostResult.Payload.Resources) { 186 | sliceEnd = len(hostResult.Payload.Resources) 187 | } 188 | 189 | if sliceEnd == len(hostResult.Payload.Resources)-1 { 190 | break 191 | } 192 | 193 | if sliceEnd >= len(hostResult.Payload.Resources) { 194 | sliceEnd = len(hostResult.Payload.Resources) - 1 195 | } 196 | 197 | if sliceEnd == sliceStart { 198 | break 199 | } 200 | 201 | logrus.WithField("slice_start", sliceStart).WithField("slice_end", sliceEnd). 202 | Debug("fetching host device details") 203 | 204 | slicePart := hostResult.Payload.Resources[sliceStart:sliceEnd] 205 | 206 | hostDetail, err := client.Hosts.GetDeviceDetailsV2(&hosts.GetDeviceDetailsV2Params{ 207 | Ids: slicePart, 208 | Context: ctx, 209 | HTTPClient: nil, 210 | }) 211 | if err != nil || !hostDetail.IsSuccess() { 212 | return nil, nil, nil, errors.Wrap(err, "could not query all host details") 213 | } 214 | 215 | allHostDetails = append(allHostDetails, hostDetail.Payload.Resources...) 216 | 217 | sliceStart = sliceEnd 218 | } 219 | 220 | securityErrorsMap := make(map[string]struct{}) 221 | now := time.Now() 222 | 223 | for _, detail := range allHostDetails { 224 | 225 | // skip all cloud sensors since those are auto-managed 226 | if detail.ServiceProvider != "" { 227 | continue 228 | } 229 | 230 | email, err := findEmailTag(detail.Tags, config.Email.Domains) 231 | if err != nil || email == "" { 232 | email = "_NOTAG/" + detail.Hostname 233 | securityErrorsMap[fmt.Sprintf( 234 | "Host %s is missing an email tag", 235 | detail.Hostname, 236 | )] = struct{}{} 237 | } 238 | 239 | hostLastSeen, err := time.Parse(time.RFC3339, detail.LastSeen) 240 | if err != nil { 241 | logrus.WithError(err).WithField("timestamp", detail.LastSeen). 242 | WithField("device", detail.LastSeen).Error("could not parse falcon host last seen") 243 | } 244 | 245 | if hostLastSeen.Before(now.Add(-2 * time.Hour * 24 * 31)) { 246 | securityErrorsMap[fmt.Sprintf("Falcon sensor for '%s' has not been since for over 2 months: %s", detail.Hostname, detail.LastSeen)] = struct{}{} 247 | } 248 | 249 | usersWithSensors = append(usersWithSensors, strings.ToLower(email)) 250 | } 251 | 252 | var hostTags []string 253 | devices := map[string]UserDevice{} 254 | 255 | paginationToken := "" 256 | for { 257 | queryResult, err := client.SpotlightVulnerabilities.CombinedQueryVulnerabilities( 258 | &spotlight_vulnerabilities.CombinedQueryVulnerabilitiesParams{ 259 | Context: ctx, 260 | Filter: "status:'open'+suppression_info.is_suppressed:'false'", 261 | Limit: &falconAPIMaxRecords, 262 | Facet: []string{"host_info", "cve", "remediation"}, 263 | After: &paginationToken, 264 | }, 265 | ) 266 | if err != nil { 267 | return nil, nil, nil, errors.Wrap(err, "could not query vulnerabilities") 268 | } 269 | 270 | if queryResult == nil { 271 | return nil, nil, nil, errors.New("QueryVulnerabilities result was nil") 272 | } 273 | 274 | minExpertAIScore := 0 275 | if newScore, err := getSeverityScore(config.Falcon.MinExprtAISeverity); err != nil { 276 | return nil, nil, nil, errors.Wrap(err, "unknown minimum exprtai severity specified") 277 | } else { 278 | minExpertAIScore = newScore 279 | } 280 | 281 | for _, vuln := range queryResult.GetPayload().Resources { 282 | 283 | if vuln.Apps == nil { 284 | continue 285 | } 286 | 287 | for _, vulnApp := range vuln.Apps { 288 | 289 | if (vulnApp.Remediation == nil || len(vulnApp.Remediation.Ids) == 0) && config.Falcon.SkipNoMitigation { 290 | logrus.WithField("rem", fmt.Sprintf("%+v", vulnApp.Remediation)).Debug("remediation") 291 | 292 | logrus.WithField("app", vulnApp.ProductNameVersion). 293 | Debug("skipping vulnerability without remediation") 294 | 295 | continue 296 | } 297 | 298 | if *vuln.Cve.ID != "" && len(config.Falcon.SkipCVEs) > 0 { 299 | vulnIgnore := false 300 | 301 | for _, cve := range config.Falcon.SkipCVEs { 302 | if strings.EqualFold(cve, *vuln.Cve.ID) { 303 | vulnIgnore = true 304 | break 305 | } 306 | } 307 | 308 | if vulnIgnore { 309 | logrus.WithField("cve", *vuln.Cve.ID). 310 | WithField("host", *vuln.HostInfo.Hostname). 311 | Warn("skipping CVE") 312 | continue 313 | } 314 | } 315 | 316 | uniqueDeviceID, err := getUniqueDeviceID(*vuln.HostInfo) 317 | if err != nil { 318 | logrus.WithError(err).Error("could not calculate unique device id") 319 | 320 | continue 321 | } 322 | 323 | if config.Falcon.MinCVEBaseScore > 0 { 324 | if int(vuln.Cve.BaseScore) < config.Falcon.MinCVEBaseScore { 325 | logrus.WithField("cve_score", vuln.Cve.BaseScore).Debug("skipping vulnerability") 326 | continue 327 | } 328 | } 329 | 330 | if config.Falcon.MinExprtAISeverity != "" { 331 | vulnExpertAISevScore, err := getSeverityScore(config.Falcon.MinExprtAISeverity) 332 | if err != nil { 333 | logrus.WithField("exprtai_score", vuln.Cve.ExprtRating).WithError(err). 334 | Error("unknown exprtai score") 335 | } else { 336 | if vulnExpertAISevScore < minExpertAIScore { 337 | logrus.WithField("min_exprtai_severity", config.Falcon.MinExprtAISeverity). 338 | WithField("exprtai_severity", vuln.Cve.ExprtRating).Debug("skipping vulnerability") 339 | continue 340 | } 341 | } 342 | } 343 | 344 | if len(config.Falcon.SkipSeverities) > 0 { 345 | vulnSev := strings.ToLower(vuln.Cve.Severity) 346 | skip := false 347 | 348 | for _, sev := range config.Falcon.SkipSeverities { 349 | if strings.EqualFold(sev, vulnSev) { 350 | logrus.WithField("host", *vuln.HostInfo.Hostname).WithField("cve_score", vuln.Cve.BaseScore). 351 | WithField("severity", vuln.Cve.Severity).WithField("cve", *vuln.Cve.ID). 352 | Debug("skipping vulnerability") 353 | skip = true 354 | break 355 | } 356 | } 357 | 358 | if skip { 359 | continue 360 | } 361 | } 362 | 363 | logrus.WithField("host", *vuln.HostInfo.Hostname).WithField("cve_score", vuln.Cve.BaseScore). 364 | WithField("severity", vuln.Cve.Severity).WithField("cve", *vuln.Cve.ID). 365 | Debug("adding vulnerability") 366 | 367 | createdTime, err := time.Parse(time.RFC3339, *vuln.CreatedTimestamp) 368 | if err != nil { 369 | logrus.WithField("created_timestamp", *vuln.CreatedTimestamp).WithError(err). 370 | Error("could not parse created timestamp as RFC3339") 371 | } 372 | 373 | deviceFinding := UserDeviceFinding{ 374 | ProductName: *vulnApp.ProductNameVersion, 375 | CveID: *vuln.Cve.ID, 376 | CveSeverity: vuln.Cve.Severity, 377 | TimestampFound: *vuln.CreatedTimestamp, 378 | DaysOpen: uint(math.Ceil(time.Since(createdTime).Hours() / 24)), 379 | } 380 | 381 | for _, mitigation := range vuln.Remediation.Entities { 382 | if strings.HasPrefix(strings.ToLower(*mitigation.Action), "no fix available for ") { 383 | continue 384 | } 385 | 386 | deviceFinding.Mitigations = appendUnique(deviceFinding.Mitigations, []string{*mitigation.Action}) 387 | } 388 | 389 | if _, ok := devices[uniqueDeviceID]; !ok { 390 | devices[uniqueDeviceID] = UserDevice{ 391 | Hostname: *vuln.HostInfo.Hostname, 392 | MachineName: fmt.Sprintf( 393 | "%s (%s)", 394 | *vuln.HostInfo.Hostname, 395 | *vuln.HostInfo.OsVersion, 396 | ), 397 | Tags: vuln.HostInfo.Tags, 398 | Findings: []UserDeviceFinding{}, 399 | } 400 | } 401 | 402 | device := devices[uniqueDeviceID] 403 | 404 | findingExists := false 405 | 406 | for _, finding := range device.Findings { 407 | if strings.EqualFold(finding.ProductName, deviceFinding.ProductName) { 408 | findingExists = true 409 | break 410 | } 411 | } 412 | 413 | if !findingExists { 414 | device.Findings = append(device.Findings, deviceFinding) 415 | } 416 | 417 | device.Tags = appendUnique(device.Tags, vuln.HostInfo.Tags) 418 | 419 | devices[uniqueDeviceID] = device 420 | 421 | hostTags = append(hostTags, device.Tags...) 422 | } 423 | } 424 | 425 | // stop pagination if we reached the end 426 | paginationToken = *queryResult.GetPayload().Meta.Pagination.After 427 | 428 | logrus.WithField("total", *queryResult.GetPayload().Meta.Pagination.Total). 429 | WithField("limit", *queryResult.GetPayload().Meta.Pagination.Limit). 430 | Debug("paginating") 431 | 432 | if paginationToken == "" { 433 | logrus.Debug("stopping pagination") 434 | break 435 | } 436 | } 437 | 438 | if len(devices) == 0 { 439 | return results, nil, securityErrors, nil 440 | } 441 | 442 | if len(hostTags) == 0 { 443 | return nil, nil, securityErrors, errors.New("no tags found on decices") 444 | } 445 | 446 | logrus.WithField("devices", len(devices)).Info("found vulnerable devices") 447 | 448 | for _, device := range devices { 449 | if len(device.Findings) == 0 { 450 | continue 451 | } 452 | 453 | hasMitigations := false 454 | for _, f := range device.Findings { 455 | if len(f.Mitigations) > 0 { 456 | hasMitigations = true 457 | break 458 | } 459 | } 460 | 461 | if !hasMitigations { 462 | logrus.WithField("device", device.MachineName). 463 | Debug("skipping device with vulnerabilities but no mitigations") 464 | continue 465 | } 466 | 467 | userEmail, err := findEmailTag(device.Tags, config.Email.Domains) 468 | if err != nil { 469 | logrus. 470 | WithError(err). 471 | WithField("tags", device.Tags). 472 | WithField("prefix", tagEmailPrefix). 473 | WithField("device", device.MachineName). 474 | Warn("could not extract Falcon email tag from host, using first fallback") 475 | 476 | userEmail = config.Slack.SecurityUser[0] 477 | } 478 | 479 | user, ok := results[userEmail] 480 | if !ok { 481 | results[userEmail] = FalconResult{ 482 | Email: userEmail, 483 | Devices: []UserDevice{}, 484 | } 485 | } 486 | 487 | user.Devices = append(user.Devices, device) 488 | user.Email = userEmail 489 | results[userEmail] = user 490 | } 491 | 492 | for key := range securityErrorsMap { 493 | securityErrors = append(securityErrors, errors.New(key)) 494 | } 495 | 496 | return results, usersWithSensors, securityErrors, nil 497 | } 498 | -------------------------------------------------------------------------------- /pkg/overview/security/builder.go: -------------------------------------------------------------------------------- 1 | package security 2 | 3 | import ( 4 | "bytes" 5 | "github.com/hazcod/security-slacker/config" 6 | "github.com/hazcod/security-slacker/pkg/falcon" 7 | "github.com/hazcod/security-slacker/pkg/ws1" 8 | "github.com/pkg/errors" 9 | "github.com/sirupsen/logrus" 10 | "html/template" 11 | "time" 12 | ) 13 | 14 | func BuildSecurityOverviewMessage(logger *logrus.Logger, config config.Config, falconResults map[string]falcon.FalconResult, ws1Results map[string]ws1.WS1Result, reportedErrors []error) (string, error) { 15 | messageTemplate, err := template.New("message").Parse(config.Templates.SecurityOverviewMessage) 16 | if err != nil { 17 | return "", errors.Wrap(err, "unable to parse message") 18 | } 19 | 20 | var allFalcon []falcon.FalconResult 21 | for _, f := range falconResults { 22 | allFalcon = append(allFalcon, f) 23 | } 24 | 25 | var allWS1 []ws1.WS1Result 26 | for _, w := range ws1Results { 27 | hasIssues := false 28 | for _, device := range w.Devices { 29 | if len(device.Findings) > 0 { 30 | hasIssues = true 31 | break 32 | } 33 | } 34 | 35 | if hasIssues { 36 | allWS1 = append(allWS1, w) 37 | } 38 | } 39 | 40 | logrus.Debugf("findings: falcon: %d ws1: %d", len(allFalcon), len(allWS1)) 41 | 42 | variables := struct { 43 | Falcon []falcon.FalconResult 44 | WS1 []ws1.WS1Result 45 | Date time.Time 46 | Errors []error 47 | MissingSensor []ws1.UserDevice 48 | }{ 49 | Date: time.Now(), 50 | Falcon: allFalcon, 51 | WS1: allWS1, 52 | Errors: reportedErrors, 53 | } 54 | 55 | var buffer bytes.Buffer 56 | if err := messageTemplate.Execute(&buffer, &variables); err != nil { 57 | return "", errors.Wrap(err, "could not parse security overview") 58 | } 59 | 60 | logrus.WithField("message", buffer.String()).Debug("built security overview message") 61 | 62 | return buffer.String(), nil 63 | } 64 | -------------------------------------------------------------------------------- /pkg/overview/user/builder.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "bytes" 5 | "github.com/hazcod/security-slacker/config" 6 | "github.com/hazcod/security-slacker/pkg/falcon" 7 | "github.com/hazcod/security-slacker/pkg/ws1" 8 | "github.com/pkg/errors" 9 | "github.com/sirupsen/logrus" 10 | "github.com/slack-go/slack" 11 | "html/template" 12 | ) 13 | 14 | func BuildUserOverviewMessage(logger *logrus.Logger, config *config.Config, slackUser slack.User, falconResult falcon.FalconResult, ws1Result ws1.WS1Result) (string, error) { 15 | if config.Templates.UserMessage == "" { 16 | return "", errors.New("no user message template defined") 17 | } 18 | 19 | if len(falconResult.Devices) == 0 && len(ws1Result.Devices) == 0 { 20 | return "", nil 21 | } 22 | 23 | messageTemplate, err := template.New("message").Parse(config.Templates.UserMessage) 24 | if err != nil { 25 | logrus.WithError(err).Fatal("unable to parse message") 26 | } 27 | 28 | variables := struct { 29 | Slack slack.User 30 | Falcon falcon.FalconResult 31 | WS1 ws1.WS1Result 32 | }{ 33 | Slack: slackUser, 34 | Falcon: falconResult, 35 | WS1: ws1Result, 36 | } 37 | 38 | var buffer bytes.Buffer 39 | if err := messageTemplate.Execute(&buffer, &variables); err != nil { 40 | logrus.WithError(err).Fatal("could not parse user message") 41 | } 42 | 43 | logrus.WithField("message", buffer.String()).Debug("built user overview message") 44 | 45 | return buffer.String(), nil 46 | } 47 | -------------------------------------------------------------------------------- /pkg/slack/holiday.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "github.com/slack-go/slack" 5 | "strings" 6 | ) 7 | 8 | var ( 9 | slackStatusHolidays = []string{ 10 | "vacationing", 11 | "absent", 12 | } 13 | ) 14 | 15 | func IsOnHoliday(user slack.User) bool { 16 | slackStatus := strings.ToLower(user.Profile.StatusText) 17 | 18 | for _, statusPrefix := range slackStatusHolidays { 19 | if strings.HasPrefix(slackStatus, statusPrefix) { 20 | return true 21 | } 22 | } 23 | 24 | return false 25 | } 26 | -------------------------------------------------------------------------------- /pkg/ws1/api.go: -------------------------------------------------------------------------------- 1 | package ws1 2 | 3 | type DevicesResponse struct { 4 | Devices []Devices `json:"Devices"` 5 | Page int `json:"Page"` 6 | PageSize int `json:"PageSize"` 7 | Total int `json:"Total"` 8 | } 9 | 10 | type ID struct { 11 | Value int `json:"Value"` 12 | } 13 | type LocationGroupID struct { 14 | ID ID `json:"Id"` 15 | Name string `json:"Name"` 16 | UUID string `json:"Uuid"` 17 | } 18 | type UserID struct { 19 | ID ID `json:"Id"` 20 | Name string `json:"Name"` 21 | UUID string `json:"Uuid"` 22 | } 23 | type PlatformID struct { 24 | ID ID `json:"Id"` 25 | Name string `json:"Name"` 26 | } 27 | type ModelID struct { 28 | ID ID `json:"Id"` 29 | Name string `json:"Name"` 30 | } 31 | type DeviceMCC struct { 32 | Simmcc string `json:"SIMMCC"` 33 | CurrentMCC string `json:"CurrentMCC"` 34 | } 35 | type DeviceCellularNetworkInfo struct { 36 | CarrierName string `json:"CarrierName"` 37 | CardID string `json:"CardId"` 38 | PhoneNumber string `json:"PhoneNumber"` 39 | DeviceMCC DeviceMCC `json:"DeviceMCC"` 40 | IsRoaming bool `json:"IsRoaming"` 41 | } 42 | type DeviceCompliance struct { 43 | CompliantStatus bool `json:"CompliantStatus"` 44 | PolicyName string `json:"PolicyName"` 45 | PolicyDetail string `json:"PolicyDetail"` 46 | LastComplianceCheck string `json:"LastComplianceCheck"` 47 | NextComplianceCheck string `json:"NextComplianceCheck"` 48 | ActionTaken []interface{} `json:"ActionTaken"` 49 | ID ID `json:"Id"` 50 | UUID string `json:"Uuid"` 51 | } 52 | type ComplianceSummary struct { 53 | DeviceCompliance []DeviceCompliance `json:"DeviceCompliance"` 54 | } 55 | type EasIds struct { 56 | EasID []string `json:"EasId"` 57 | } 58 | type Devices struct { 59 | TimeZone string `json:"TimeZone"` 60 | Udid string `json:"Udid"` 61 | SerialNumber string `json:"SerialNumber"` 62 | MacAddress string `json:"MacAddress"` 63 | Imei string `json:"Imei"` 64 | EasID string `json:"EasId"` 65 | AssetNumber string `json:"AssetNumber"` 66 | DeviceFriendlyName string `json:"DeviceFriendlyName"` 67 | DeviceReportedName string `json:"DeviceReportedName"` 68 | LocationGroupID LocationGroupID `json:"LocationGroupId"` 69 | LocationGroupName string `json:"LocationGroupName"` 70 | UserID UserID `json:"UserId"` 71 | UserName string `json:"UserName"` 72 | DataProtectionStatus int `json:"DataProtectionStatus"` 73 | UserEmailAddress string `json:"UserEmailAddress"` 74 | Ownership string `json:"Ownership"` 75 | PlatformID PlatformID `json:"PlatformId"` 76 | Platform string `json:"Platform"` 77 | ModelID ModelID `json:"ModelId"` 78 | Model string `json:"Model"` 79 | OperatingSystem string `json:"OperatingSystem"` 80 | PhoneNumber string `json:"PhoneNumber"` 81 | LastSeen string `json:"LastSeen"` 82 | EnrollmentStatus string `json:"EnrollmentStatus"` 83 | ComplianceStatus string `json:"ComplianceStatus"` 84 | CompromisedStatus bool `json:"CompromisedStatus"` 85 | LastEnrolledOn string `json:"LastEnrolledOn"` 86 | LastComplianceCheckOn string `json:"LastComplianceCheckOn"` 87 | LastCompromisedCheckOn string `json:"LastCompromisedCheckOn"` 88 | IsSupervised bool `json:"IsSupervised"` 89 | VirtualMemory int `json:"VirtualMemory"` 90 | OEMInfo string `json:"OEMInfo"` 91 | DeviceCapacity float64 `json:"DeviceCapacity,omitempty"` 92 | AvailableDeviceCapacity float64 `json:"AvailableDeviceCapacity,omitempty"` 93 | IsDeviceDNDEnabled bool `json:"IsDeviceDNDEnabled"` 94 | IsDeviceLocatorEnabled bool `json:"IsDeviceLocatorEnabled"` 95 | IsCloudBackupEnabled bool `json:"IsCloudBackupEnabled"` 96 | IsActivationLockEnabled bool `json:"IsActivationLockEnabled"` 97 | IsNetworkTethered bool `json:"IsNetworkTethered"` 98 | BatteryLevel string `json:"BatteryLevel"` 99 | IsRoaming bool `json:"IsRoaming"` 100 | SystemIntegrityProtectionEnabled bool `json:"SystemIntegrityProtectionEnabled"` 101 | ProcessorArchitecture int `json:"ProcessorArchitecture"` 102 | TotalPhysicalMemory int `json:"TotalPhysicalMemory"` 103 | AvailablePhysicalMemory int `json:"AvailablePhysicalMemory"` 104 | OSBuildVersion string `json:"OSBuildVersion"` 105 | DeviceCellularNetworkInfo []DeviceCellularNetworkInfo `json:"DeviceCellularNetworkInfo,omitempty"` 106 | EnrollmentUserUUID string `json:"EnrollmentUserUuid"` 107 | ManagedBy int `json:"ManagedBy"` 108 | WifiSsid string `json:"WifiSsid"` 109 | ID ID `json:"Id"` 110 | UUID string `json:"Uuid"` 111 | ComplianceSummary ComplianceSummary `json:"ComplianceSummary,omitempty"` 112 | } 113 | -------------------------------------------------------------------------------- /pkg/ws1/extractor.go: -------------------------------------------------------------------------------- 1 | package ws1 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "github.com/hazcod/security-slacker/config" 9 | "github.com/pkg/errors" 10 | "github.com/sirupsen/logrus" 11 | "golang.org/x/oauth2/clientcredentials" 12 | "io" 13 | "net/http" 14 | "regexp" 15 | "strconv" 16 | "strings" 17 | "time" 18 | ) 19 | 20 | var ( 21 | rgxCommaNumber = regexp.MustCompile(`(\.\d+)$`) 22 | ) 23 | 24 | type WS1Result struct { 25 | Email string 26 | Devices []UserDevice 27 | } 28 | 29 | type UserDevice struct { 30 | MachineName string 31 | Compromised bool 32 | LastSeen time.Time 33 | Findings []UserDeviceFinding 34 | } 35 | 36 | type UserDeviceFinding struct { 37 | ComplianceName string 38 | } 39 | 40 | func doAuthRequest(ctx context.Context, ws1AuthLocation, clientID, secret, url, method string, payload interface{}) (respBytes []byte, err error) { 41 | var reqPayload []byte 42 | if payload != nil { 43 | if reqPayload, err = json.Marshal(&payload); err != nil { 44 | return nil, errors.Wrap(err, "coult not encode request body") 45 | } 46 | } 47 | 48 | oauth2Config := clientcredentials.Config{ClientID: clientID, ClientSecret: secret, 49 | TokenURL: fmt.Sprintf("https://%s.uemauth.vmwservices.com/connect/token", ws1AuthLocation)} 50 | httpClient := oauth2Config.Client(ctx) 51 | httpClient.Timeout = time.Second * 30 52 | 53 | req, err := http.NewRequest(method, url, bytes.NewReader(reqPayload)) 54 | req = req.WithContext(ctx) 55 | if err != nil { 56 | return nil, errors.Wrap(err, "request failed") 57 | } 58 | 59 | req.Header.Set("Accept", "application/json") 60 | 61 | resp, err := httpClient.Do(req) 62 | if err != nil { 63 | return nil, errors.Wrap(err, "http request failed") 64 | } 65 | 66 | if resp.StatusCode > 399 { 67 | respB, _ := io.ReadAll(resp.Body) 68 | logrus.WithField("response", string(respB)).Warn("invalid response") 69 | return nil, errors.New("invalid response code: " + strconv.Itoa(resp.StatusCode)) 70 | } 71 | 72 | defer resp.Body.Close() 73 | 74 | if respBytes, err = io.ReadAll(resp.Body); err != nil { 75 | return nil, errors.New("could not read response body") 76 | } 77 | 78 | return respBytes, nil 79 | } 80 | 81 | func GetMessages(config *config.Config, ctx context.Context) (map[string]WS1Result, []string, []error, error) { 82 | deviceResponseB, err := doAuthRequest( 83 | ctx, 84 | config.WS1.AuthLocation, config.WS1.ClientID, config.WS1.ClientSecret, 85 | strings.TrimRight(config.WS1.Endpoint, "/")+"/mdm/devices/search?compliance_status=NonCompliant", 86 | http.MethodGet, 87 | nil, 88 | ) 89 | 90 | var securityErrors []error 91 | 92 | if err != nil { 93 | return nil, nil, nil, errors.Wrap(err, "could not fetch WS1 devices") 94 | } 95 | 96 | usersWithDevices := make([]string, 0) 97 | 98 | var devicesResponse DevicesResponse 99 | if err := json.Unmarshal(deviceResponseB, &devicesResponse); err != nil { 100 | return nil, nil, nil, errors.Wrap(err, "could not deserialize getDevices call") 101 | } 102 | 103 | securityErrorsMap := make(map[string]struct{}) 104 | 105 | now := time.Now() 106 | result := make(map[string]WS1Result) 107 | 108 | for _, device := range devicesResponse.Devices { 109 | 110 | // add an error if a device has not been seen for over a month 111 | lastSeen := rgxCommaNumber.ReplaceAllString(device.LastSeen, "") 112 | hostLastSeen, err := time.Parse("2006-01-02T15:04:05", lastSeen) 113 | if err != nil { 114 | logrus.WithError(err).WithField("timestamp", lastSeen). 115 | WithField("device", device.DeviceFriendlyName).Error("could not parse MDM host last seen") 116 | } 117 | 118 | if hostLastSeen.Before(now.Add(-2 * time.Hour * 24 * 31)) { 119 | securityErrorsMap[fmt.Sprintf("%s has not been seen for over 2 months in MDM: %s", device.DeviceFriendlyName, lastSeen)] = struct{}{} 120 | } 121 | 122 | usersWithDevices = append(usersWithDevices, strings.ToLower(device.UserEmailAddress)) 123 | 124 | if strings.EqualFold(device.ComplianceStatus, "Compliant") { 125 | continue 126 | } 127 | 128 | userEmail := strings.ToLower(device.UserEmailAddress) 129 | 130 | ws1Result, ok := result[userEmail] 131 | if !ok { 132 | ws1Result = WS1Result{Devices: []UserDevice{}, Email: strings.ToLower(userEmail)} 133 | } 134 | 135 | lastSeenDate, err := time.Parse("2006-01-02T15:04:05", device.LastSeen) 136 | if err != nil { 137 | logrus.WithError(err).WithField("last_seen", device.LastSeen). 138 | WithField("device", device.DeviceFriendlyName).Error("could not parse device last seen") 139 | lastSeenDate = time.Now() 140 | } 141 | 142 | userDevice := UserDevice{ 143 | MachineName: device.DeviceFriendlyName, 144 | Compromised: device.CompromisedStatus, 145 | LastSeen: lastSeenDate, 146 | } 147 | 148 | for _, policy := range device.ComplianceSummary.DeviceCompliance { 149 | if policy.CompliantStatus { 150 | continue 151 | } 152 | 153 | shouldSkip := false 154 | for _, filter := range config.WS1.SkipFilters { 155 | if filter.Policy != "" && !strings.EqualFold(policy.PolicyName, filter.Policy) { 156 | continue 157 | } 158 | if filter.User != "" && !strings.EqualFold(filter.User, userEmail) { 159 | continue 160 | } 161 | shouldSkip = true 162 | } 163 | if shouldSkip { 164 | continue 165 | } 166 | 167 | userDevice.Findings = append(userDevice.Findings, UserDeviceFinding{ 168 | ComplianceName: policy.PolicyName, 169 | }) 170 | } 171 | 172 | ws1Result.Devices = append(ws1Result.Devices, userDevice) 173 | 174 | result[userEmail] = ws1Result 175 | } 176 | 177 | for key, _ := range securityErrorsMap { 178 | securityErrors = append(securityErrors, errors.New(key)) 179 | } 180 | 181 | return result, usersWithDevices, securityErrors, nil 182 | } 183 | --------------------------------------------------------------------------------