├── .cloudignore ├── .dockerignore ├── .git-cc.yaml ├── .github └── workflows │ └── go.yml ├── .gitignore ├── .golangci.yml ├── .semgrepignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── buf.gen.yaml ├── buf.lock ├── buf.yaml ├── cloudbuild-ui.yaml ├── cloudbuild.yaml ├── docker-compose.dev.yaml ├── docker-compose.ui.yaml ├── docker-compose.yaml ├── docker ├── Dockerfile.buf ├── cypress │ ├── cypress.config.ts │ └── tsconfig.json └── envoyproxy │ └── envoy.yaml ├── docs ├── FINDINGS.md ├── RISK_SCORE.md ├── pics │ ├── architecture.svg │ ├── modron.svg │ ├── risk_score_matrix.png │ └── severities.png └── screenshots │ ├── home.jpg │ ├── nagatha-1.jpg │ ├── nagatha-2.jpg │ ├── observations.jpg │ ├── risk-score.jpg │ ├── single-observation.jpg │ └── stats.jpg ├── env.example ├── go.mod ├── go.sum ├── images └── scan_process.png ├── otel └── config │ └── config.yaml ├── src ├── .gcloudignore ├── .gitignore ├── Dockerfile ├── Dockerfile.e2e ├── README.md ├── acl │ ├── fakeacl │ │ └── checkerFake.go │ └── gcpacl │ │ ├── cache.go │ │ ├── cache_test.go │ │ ├── checker.go │ │ ├── checker_real_test.go │ │ └── checker_test.go ├── collector │ ├── collector.go │ ├── gcpcollector │ │ ├── api_fake.go │ │ ├── api_gcp.go │ │ ├── api_key.go │ │ ├── bucket.go │ │ ├── cloudsql.go │ │ ├── collector.go │ │ ├── collector_call.go │ │ ├── collector_generic.go │ │ ├── collector_integration_test.go │ │ ├── collector_rg_call.go │ │ ├── collector_test.go │ │ ├── kubernetes_cluster.go │ │ ├── kubernetes_cluster_test.go │ │ ├── load_balancer.go │ │ ├── metrics.go │ │ ├── network.go │ │ ├── network_test.go │ │ ├── ratelimiter.go │ │ ├── resource_group.go │ │ ├── resource_group_test.go │ │ ├── scc_findings.go │ │ ├── scc_findings_test.go │ │ ├── service_account.go │ │ ├── service_account_test.go │ │ ├── spanner.go │ │ └── vm_instance.go │ └── testcollector │ │ └── testcollector.go ├── common │ └── protoutils.go ├── constants │ ├── constants.go │ ├── context.go │ └── gcp_sa_projects.go ├── engine │ ├── framework.go │ ├── framework_test.go │ ├── rules │ │ ├── api_key_overbroad_scope.go │ │ ├── api_key_overbroad_scope_test.go │ │ ├── bucket_is_public.go │ │ ├── bucket_is_public_test.go │ │ ├── cluster_nodes_have_public_ips.go │ │ ├── cluster_nodes_have_public_ips_test.go │ │ ├── common.go │ │ ├── container_running.go │ │ ├── container_running_test.go │ │ ├── cross_environment_permission.go │ │ ├── cross_project_permissions.go │ │ ├── cross_project_permissions_test.go │ │ ├── database_allows_unencrypted_connections.go │ │ ├── database_allows_unencrypted_connections_test.go │ │ ├── database_authorized_network_not_set_test.go │ │ ├── database_authorized_networks_not_set.go │ │ ├── exported_key_expiry_too_long.go │ │ ├── exported_key_expiry_too_long_test.go │ │ ├── exported_key_with_admin_privileges.go │ │ ├── exported_key_with_admin_privileges_test.go │ │ ├── human_with_overprivileged_basic_role.go │ │ ├── human_with_overprivileged_basic_role_test.go │ │ ├── iap_disabled.go │ │ ├── iap_disabled_test.go │ │ ├── kubernetes_vulnerability_scanning.go │ │ ├── kubernetes_vulnerability_scanning_e2e_test.go │ │ ├── kubernetes_vulnerability_scanning_test.go │ │ ├── lb_tls_cert_expiring_soon.go │ │ ├── lb_tls_cert_expiring_soon_test.go │ │ ├── lb_tls_min_version_too_old.go │ │ ├── lb_tls_min_version_too_old_test.go │ │ ├── lb_user_managed_cert.go │ │ ├── lb_user_managed_cert_test.go │ │ ├── master_authorized_neworks_not_set.go │ │ ├── master_authorized_neworks_not_set_test.go │ │ ├── outdated_kubernetes_version.go │ │ ├── outdated_kubernetes_version_test.go │ │ ├── private_google_access_disabled.go │ │ ├── private_google_access_disabled_test.go │ │ ├── registry.go │ │ ├── registry_test.go │ │ ├── svc_account_too_high_privileges.go │ │ ├── svc_account_too_high_privileges_test.go │ │ ├── testing.go │ │ ├── testing_e2e.go │ │ ├── unused_exported_credentials.go │ │ ├── unused_exported_credentials_test.go │ │ ├── vm_has_public_ip.go │ │ └── vm_has_public_ip_test.go │ ├── runner.go │ ├── runner_integration_test.go │ └── runner_test.go ├── go.mod ├── go.sum ├── log.go ├── lognotifier │ └── lognotifier.go ├── main.go ├── metric │ ├── keys.go │ └── status.go ├── model │ ├── acl.go │ ├── collector.go │ ├── engine.go │ ├── exception.go │ ├── model.go │ ├── stateManager.go │ └── storage.go ├── nagatha │ ├── convert.go │ ├── nagatha.go │ ├── notification.go │ ├── notification_test.go │ ├── proto │ │ └── nagatha.proto │ ├── rest.go │ └── rest_test.go ├── proto │ ├── .gitignore │ ├── generated │ │ ├── go.mod │ │ └── go.sum │ ├── modron.proto │ └── notification.proto ├── risk │ └── risk.go ├── server.go ├── service │ ├── mock_notifier_test.go │ ├── service.go │ └── service_test.go ├── statemanager │ └── reqdepstatemanager │ │ ├── requestDependenciesStateManager.go │ │ └── requestDependenciesStateManager_test.go ├── storage │ ├── gormstorage │ │ ├── gorm.go │ │ ├── gorm_integration_test.go │ │ ├── gorm_test.go │ │ ├── impact.go │ │ ├── model.go │ │ ├── observation_category.go │ │ ├── observation_source.go │ │ ├── operation.go │ │ └── severity.go │ ├── memstorage │ │ └── memstorage.go │ ├── storage.go │ ├── test │ │ └── test.go │ └── utils │ │ └── utils.go ├── test │ ├── e2e_test.go │ ├── fake_notification_service.go │ ├── go.mod │ └── go.sum ├── ui │ ├── .dockerignore │ ├── .gcloudignore │ ├── .gitignore │ ├── .yarnrc.yml │ ├── Dockerfile │ ├── README.md │ ├── client │ │ ├── .browserslistrc │ │ ├── .dockerignore │ │ ├── .editorconfig │ │ ├── .eslintignore │ │ ├── .eslintrc.json │ │ ├── Dockerfile │ │ ├── Dockerfile.e2e │ │ ├── README.md │ │ ├── angular.json │ │ ├── cypress.config.ts │ │ ├── cypress │ │ │ ├── .gitignore │ │ │ ├── e2e │ │ │ │ └── spec.cy.ts │ │ │ ├── support │ │ │ │ └── component-index.html │ │ │ └── tsconfig.json │ │ ├── karma.conf.js │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── src │ │ │ ├── .gitignore │ │ │ ├── app │ │ │ │ ├── app-routing.module.ts │ │ │ │ ├── app.component.html │ │ │ │ ├── app.component.scss │ │ │ │ ├── app.component.spec.ts │ │ │ │ ├── app.component.ts │ │ │ │ ├── app.module.ts │ │ │ │ ├── authentication.service.spec.ts │ │ │ │ ├── authentication.service.ts │ │ │ │ ├── filter.pipe.spec.ts │ │ │ │ ├── filter.pipe.ts │ │ │ │ ├── grpc-token.interceptor.ts │ │ │ │ ├── impact-indicator │ │ │ │ │ ├── impact-indicator.component.html │ │ │ │ │ ├── impact-indicator.component.scss │ │ │ │ │ └── impact-indicator.component.ts │ │ │ │ ├── model │ │ │ │ │ ├── modron.model.ts │ │ │ │ │ └── notification.model.ts │ │ │ │ ├── modron-app │ │ │ │ │ ├── modron-app.component.html │ │ │ │ │ ├── modron-app.component.scss │ │ │ │ │ ├── modron-app.component.spec.ts │ │ │ │ │ └── modron-app.component.ts │ │ │ │ ├── modron.service.spec.ts │ │ │ │ ├── modron.service.ts │ │ │ │ ├── notif-bell-button │ │ │ │ │ ├── notif-bell-button.component.html │ │ │ │ │ ├── notif-bell-button.component.scss │ │ │ │ │ └── notif-bell-button.component.ts │ │ │ │ ├── notification-exception-form │ │ │ │ │ ├── notification-exception-form.component.html │ │ │ │ │ ├── notification-exception-form.component.scss │ │ │ │ │ ├── notification-exception-form.component.spec.ts │ │ │ │ │ └── notification-exception-form.component.ts │ │ │ │ ├── notification-exceptions │ │ │ │ │ ├── notification-exceptions.component.html │ │ │ │ │ ├── notification-exceptions.component.scss │ │ │ │ │ ├── notification-exceptions.component.spec.ts │ │ │ │ │ ├── notification-exceptions.component.ts │ │ │ │ │ └── notification-exceptions.pipe.ts │ │ │ │ ├── notification.service.spec.ts │ │ │ │ ├── notification.service.ts │ │ │ │ ├── observation-details-dialog-content │ │ │ │ │ ├── observation-details-dialog-content.component.html │ │ │ │ │ ├── observation-details-dialog-content.component.scss │ │ │ │ │ ├── observation-details-dialog-content.component.ts │ │ │ │ │ └── observation-details-dialog-content.filter.ts │ │ │ │ ├── observation-details-dialog │ │ │ │ │ ├── observation-details-dialog.component.html │ │ │ │ │ ├── observation-details-dialog.component.scss │ │ │ │ │ └── observation-details-dialog.component.ts │ │ │ │ ├── observation-details │ │ │ │ │ ├── observation-details.component.html │ │ │ │ │ ├── observation-details.component.scss │ │ │ │ │ ├── observation-details.component.spec.ts │ │ │ │ │ └── observation-details.component.ts │ │ │ │ ├── observations-stats │ │ │ │ │ ├── observations-stats.component.html │ │ │ │ │ ├── observations-stats.component.scss │ │ │ │ │ ├── observations-stats.component.spec.ts │ │ │ │ │ └── observations-stats.component.ts │ │ │ │ ├── observations-table │ │ │ │ │ ├── observations-table.component.html │ │ │ │ │ ├── observations-table.component.scss │ │ │ │ │ └── observations-table.component.ts │ │ │ │ ├── resource-group-details │ │ │ │ │ ├── resource-group-details.component.html │ │ │ │ │ ├── resource-group-details.component.scss │ │ │ │ │ ├── resource-group-details.component.spec.ts │ │ │ │ │ ├── resource-group-details.component.ts │ │ │ │ │ └── resource-group-details.pipe.ts │ │ │ │ ├── resource-group │ │ │ │ │ ├── resource-group.component.html │ │ │ │ │ ├── resource-group.component.scss │ │ │ │ │ ├── resource-group.component.spec.ts │ │ │ │ │ ├── resource-group.component.ts │ │ │ │ │ └── resource-group.pipe.ts │ │ │ │ ├── resource-groups │ │ │ │ │ ├── resource-groups.component.html │ │ │ │ │ ├── resource-groups.component.scss │ │ │ │ │ ├── resource-groups.component.spec.ts │ │ │ │ │ ├── resource-groups.component.ts │ │ │ │ │ └── resource-groups.pipe.ts │ │ │ │ ├── search-obs │ │ │ │ │ ├── search-obs.component.html │ │ │ │ │ ├── search-obs.component.scss │ │ │ │ │ ├── search-obs.component.spec.ts │ │ │ │ │ └── search-obs.component.ts │ │ │ │ ├── severity-indicator │ │ │ │ │ ├── severity-indicator.component.html │ │ │ │ │ ├── severity-indicator.component.scss │ │ │ │ │ ├── severity-indicator.component.ts │ │ │ │ │ └── severity-indicator.pipe.ts │ │ │ │ ├── sidenav │ │ │ │ │ ├── sidenav.component.html │ │ │ │ │ ├── sidenav.component.scss │ │ │ │ │ ├── sidenav.component.spec.ts │ │ │ │ │ └── sidenav.component.ts │ │ │ │ ├── state │ │ │ │ │ ├── authentication.store.ts │ │ │ │ │ ├── modron.store.ts │ │ │ │ │ └── notification.store.ts │ │ │ │ ├── stats.service.spec.ts │ │ │ │ ├── stats.service.ts │ │ │ │ ├── stats │ │ │ │ │ ├── stats.component.html │ │ │ │ │ ├── stats.component.scss │ │ │ │ │ ├── stats.component.spec.ts │ │ │ │ │ └── stats.component.ts │ │ │ │ ├── token.interceptor.ts │ │ │ │ └── ui-demo │ │ │ │ │ ├── ui-demo.component.html │ │ │ │ │ ├── ui-demo.component.scss │ │ │ │ │ └── ui-demo.component.ts │ │ │ ├── assets │ │ │ │ ├── .gitkeep │ │ │ │ ├── modron-white.svg │ │ │ │ └── modron.svg │ │ │ ├── colors.scss │ │ │ ├── environments │ │ │ │ ├── environment.base.ts │ │ │ │ ├── environment.local.ts │ │ │ │ ├── environment.prod.ts │ │ │ │ └── environment.ts │ │ │ ├── favicon.png │ │ │ ├── index.html │ │ │ ├── main.ts │ │ │ ├── polyfills.ts │ │ │ ├── styles.scss │ │ │ └── test.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ └── tsconfig.spec.json │ ├── docker-compose.yml │ ├── go.mod │ ├── go.sum │ ├── mock-grpc-server │ │ ├── .dockerignore │ │ ├── Dockerfile │ │ ├── copy-proto.sh │ │ ├── envoy.yaml │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── proto │ │ │ └── .gitkeep │ │ ├── server.ts │ │ └── tsconfig.json │ ├── package-lock.json │ ├── package.json │ ├── proxy.conf.json │ └── server.go ├── utils │ ├── gcp.go │ ├── gke.go │ ├── gke_test.go │ ├── groups.go │ ├── hierarchy.go │ ├── keys.go │ ├── keys_test.go │ ├── name.go │ ├── name_test.go │ ├── protobuf.go │ ├── protobuf_test.go │ ├── ref.go │ ├── resource_ref.go │ ├── rule.go │ ├── service_account.go │ └── service_account_test.go └── validation.go ├── terraform ├── README.md ├── dev │ └── main.tf.example ├── modron │ ├── artifact_registry.tf │ ├── cloud_run.tf │ ├── cloud_sql.tf │ ├── dns_logging.tf │ ├── gitlab.tf │ ├── iap.tf │ ├── load_balancer.tf │ ├── main.tf │ ├── network.tf │ ├── otel │ │ ├── README.md │ │ └── config.yaml │ ├── project.tf │ ├── secret.tf │ ├── service_account.tf │ ├── services.tf │ ├── terraform.tf │ ├── tracing.tf │ └── variables.tf └── prod │ └── main.tf.example └── utils └── gcp_service_agents ├── .gitignore ├── README.md ├── go.mod ├── go.sum └── main.go /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/.angular 3 | /.git 4 | /terraform -------------------------------------------------------------------------------- /.git-cc.yaml: -------------------------------------------------------------------------------- 1 | scopes: 2 | collector: Collector changes 3 | docker: Docker related changes 4 | nagatha: Nagatha related changes 5 | otel: OpenTelemetry related changes 6 | rules: Changes to the rules 7 | scc: Security Command Center related changes 8 | server: Changes to the Modron backend 9 | storage: Changes to the storage layer 10 | terraform: Terraform related changes 11 | ui: UI changes 12 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v3 17 | with: 18 | go-version: 1.21 19 | 20 | - name: Go Test 21 | working-directory: src 22 | run: go mod tidy && go test --short --timeout 5s ./... 23 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable-all: true 3 | enable: 4 | - contextcheck 5 | - errcheck 6 | - errorlint 7 | - gocritic 8 | - mnd 9 | - gosec 10 | - gosimple 11 | - govet 12 | - ineffassign 13 | - misspell 14 | - nestif 15 | - nilerr 16 | - nilnil 17 | - revive 18 | - staticcheck 19 | - typecheck 20 | - unused 21 | - wastedassign 22 | 23 | issues: 24 | exclude-dirs: 25 | - "terraform" 26 | run: 27 | timeout: 5m 28 | 29 | output: 30 | formats: 31 | - format: colored-line-number 32 | path: stdout 33 | - format: junit-xml 34 | path: modron-lint.xml 35 | -------------------------------------------------------------------------------- /.semgrepignore: -------------------------------------------------------------------------------- 1 | # Ignore generated code 2 | *_pb_service.d.ts 3 | *_pb_service.js 4 | *_pb.d.ts 5 | *_pb.js 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.0.0 4 | 5 | ### Structure 6 | 7 | - Support Resource Group Hierarchy 8 | 9 | ### Observations 10 | 11 | - Add [Risk Score](docs/RISK_SCORE.md) to observations, calculated from the severity of the observation (as defined in the rule) and the impact of the observation (detected from the environment) 12 | - Collectors can now collect observations 13 | 14 | ### Stats 15 | 16 | - Improved stats view 17 | - Improved export to CSV 18 | 19 | ### GCP 20 | 21 | - Add support for [Security Command Center (SCC)](https://cloud.google.com/security-command-center/docs/concepts-security-command-center-overview) 22 | - Start collecting Kubernetes resources 23 | 24 | ### Storage 25 | 26 | - Use [GORM](https://gorm.io/) for both the PSQL and SQLite storage backends 27 | - Use SQLite for the in-memory database for testing 28 | 29 | ### Performance 30 | 31 | - Increase performance overall by optimizing the DB queries, parallelizing the scans, and reducing the number of external calls 32 | - Introduce rate limiting for the collectors 33 | 34 | ### Observability 35 | 36 | - Use [logrus](https://github.com/sirupsen/logrus) with structured logging for GCP Logging (Stackdriver) 37 | - Add support for OpenTelemetry 38 | - Add an otel-collector to receive traces and metrics 39 | - Send traces to [Google Cloud Trace](https://cloud.google.com/trace) 40 | - Send metrics to [Google Cloud Monitoring](https://cloud.google.com/monitoring) 41 | 42 | ### UI 43 | 44 | - Completely rework the UI with an improved design 45 | - Show observations as a table, sorted by Risk Score by default 46 | - Add a detailed view dialog for the observations 47 | 48 | ### Misc 49 | 50 | - Use [`go-arg`](https://github.com/alexflint/go-arg) for the CLI arguments / environment variables 51 | - Switch to [buf](https://buf.build/) for the protobuf generation 52 | - Bug fixes 53 | - Upgrade to Go 1.23 54 | - Rules now support external configuration 55 | 56 | ## v0.2 57 | 58 | - Moved to go 1.19 59 | - Added automated runs for scans 60 | - Fixed issue where last reported observation would still appear even if newer scans reported no observations 61 | - Fixed group member ship resolution when checking for accesses to GCP projects 62 | 63 | ## v0.1 64 | 65 | - Initial public release 66 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022 Niantic Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | managed: 3 | enabled: true 4 | 5 | plugins: 6 | - local: protoc-gen-go 7 | out: src/proto/generated 8 | - local: protoc-gen-go-grpc 9 | out: src/proto/generated 10 | - local: protoc-gen-js 11 | out: src/ui/client/src/proto/ 12 | opt: import_style=commonjs,binary 13 | - local: protoc-gen-grpc-web 14 | out: src/ui/client/src/proto/ 15 | opt: 16 | - import_style=typescript 17 | - mode=grpcweb 18 | 19 | inputs: 20 | - directory: ./src/proto 21 | - directory: ./src/nagatha/proto 22 | - module: buf.build/googleapis/googleapis:8bc2c51e08c447cd8886cdea48a73e14 23 | paths: 24 | - google/api 25 | - google/rpc 26 | - google/longrunning 27 | - module: buf.build/k8s/api:8f68e41b943c4de8a5e9c9a921c889a7 28 | paths: 29 | - k8s.io/api/core 30 | - k8s.io/apimachinery/ -------------------------------------------------------------------------------- /buf.lock: -------------------------------------------------------------------------------- 1 | # Generated by buf. DO NOT EDIT. 2 | version: v2 3 | deps: 4 | - name: buf.build/googleapis/googleapis 5 | commit: 8bc2c51e08c447cd8886cdea48a73e14 6 | digest: b5:b7e0ac9d192bd0eae88160101269550281448c51f25121cd0d51957661a350aab07001bc145fe9029a8da10b99ff000ae5b284ecaca9c75f2a99604a04d9b4ab 7 | - name: buf.build/k8s/api 8 | commit: 8f68e41b943c4de8a5e9c9a921c889a7 9 | digest: b5:0c188e351df7b094d6a5412f4cd5f097fbf1a32d4a2d4c42b83774e168961447e6e706e4ebf241a13b94493aa6cbe08dc8abd03e2a1f8207ac7620bf186030c8 10 | -------------------------------------------------------------------------------- /buf.yaml: -------------------------------------------------------------------------------- 1 | version: v2 2 | modules: 3 | - path: src/proto 4 | - path: src/nagatha/proto 5 | deps: 6 | - buf.build/googleapis/googleapis 7 | - buf.build/k8s/api 8 | lint: 9 | use: 10 | - DEFAULT -------------------------------------------------------------------------------- /cloudbuild-ui.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: 'gcr.io/cloud-builders/docker' 3 | args: 4 | - build 5 | - --tag=us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron-ui:$_TAG_REF_1 6 | - --tag=us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron-ui:$_TAG_REF_2 7 | - -f 8 | - ./src/ui/Dockerfile 9 | - . 10 | images: 11 | - "us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron-ui:$_TAG_REF_1" 12 | - "us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron-ui:$_TAG_REF_2" 13 | -------------------------------------------------------------------------------- /cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: 'gcr.io/cloud-builders/docker' 3 | args: 4 | - build 5 | - --tag=us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron:$_TAG_REF_1 6 | - --tag=us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron:$_TAG_REF_2 7 | - -f 8 | - ./src/Dockerfile 9 | - . 10 | images: 11 | - "us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron:$_TAG_REF_1" 12 | - "us-central1-docker.pkg.dev/$PROJECT_ID/modron/modron:$_TAG_REF_2" -------------------------------------------------------------------------------- /docker-compose.dev.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | postgres_db: 5 | container_name: postgres_db 6 | image: postgres:16 7 | restart: always 8 | environment: 9 | POSTGRES_USER: "modron" 10 | POSTGRES_PASSWORD: "modron" 11 | POSTGRES_DB: "modron" 12 | PGDATA: "/tmp/" 13 | ports: 14 | - "5432:5432" 15 | healthcheck: 16 | test: ["CMD-SHELL", "pg_isready -U modron"] 17 | interval: 1s 18 | timeout: 2s 19 | retries: 5 20 | tmpfs: 21 | - /tmp 22 | 23 | jaeger: 24 | image: jaegertracing/all-in-one:1.59 25 | ports: 26 | - "16686:16686" 27 | environment: 28 | COLLECTOR_OTLP_ENABLED: true 29 | COLLECTOR_OTLP_GRPC_HOST_PORT: 0.0.0.0:4317 30 | networks: 31 | - otel 32 | 33 | prometheus: 34 | image: prom/prometheus:latest 35 | command: 36 | - --web.enable-remote-write-receiver 37 | ports: 38 | - "9090:9090" 39 | networks: 40 | - otel 41 | 42 | otel-collector: 43 | image: otel/opentelemetry-collector:0.108.0 44 | command: 45 | - --config=/etc/otel/config.yaml 46 | ports: 47 | - "4317:4317" 48 | volumes: 49 | - ./otel/config:/etc/otel 50 | networks: 51 | - otel 52 | networks: 53 | otel: {} 54 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres_db: 3 | container_name: postgres_db 4 | image: postgres:14-bookworm 5 | restart: always 6 | ports: 7 | - "5432:5432" 8 | environment: 9 | POSTGRES_USER: "modron" 10 | POSTGRES_PASSWORD: "docker-test-password" 11 | POSTGRES_DB: "modron" 12 | PGDATA: "/tmp/" 13 | healthcheck: 14 | test: ["CMD-SHELL", "pg_isready -U modron"] 15 | interval: 1s 16 | timeout: 2s 17 | retries: 5 18 | tmpfs: 19 | - /tmp 20 | networks: 21 | - modron 22 | 23 | modron_fake: 24 | container_name: modron_fake 25 | build: 26 | context: . 27 | dockerfile: src/Dockerfile 28 | environment: 29 | COLLECTOR: "FAKE" 30 | DB_BATCH_SIZE: "1" 31 | DB_MAX_CONNECTIONS: "1" 32 | IS_E2E_GRPC_TEST: "true" 33 | LISTEN_ADDR: "0.0.0.0" 34 | NOTIFICATION_SERVICE: "modron_test:8082" 35 | ORG_ID: "111111111111" 36 | ORG_SUFFIX: "@example.com" 37 | PORT: 8081 38 | RUN_AUTOMATED_SCANS: "false" 39 | SQL_BACKEND_DRIVER: "postgres" 40 | SQL_CONNECT_STRING: "host=postgres_db port=5432 user=modron password=docker-test-password database=modron sslmode=disable" 41 | STORAGE: "SQL" 42 | TAG_CUSTOMER_DATA: 111111111111/customer_data 43 | TAG_EMPLOYEE_DATA: 111111111111/employee_data 44 | TAG_ENVIRONMENT: 111111111111/environment 45 | ports: 46 | - "8081:8081" 47 | networks: 48 | - modron 49 | depends_on: 50 | postgres_db: 51 | condition: service_healthy 52 | 53 | modron_test: 54 | container_name: e2e_test 55 | build: 56 | context: . 57 | dockerfile: src/Dockerfile.e2e 58 | environment: 59 | BACKEND_ADDRESS: "modron:8080" 60 | FAKE_BACKEND_ADDRESS: "modron_fake:8081" 61 | FAKE_NOTIFICATION_SERVICE_PORT: "8082" 62 | networks: 63 | - modron 64 | depends_on: 65 | - modron_fake 66 | 67 | networks: 68 | modron: 69 | driver: bridge 70 | -------------------------------------------------------------------------------- /docker/cypress/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress" 2 | 3 | export default defineConfig({ 4 | 5 | e2e: { 6 | baseUrl: "http://localhost:8080", 7 | supportFile: false 8 | }, 9 | video: false, 10 | screenshotOnRunFailure: false, 11 | component: { 12 | devServer: { 13 | framework: "angular", 14 | bundler: "webpack", 15 | }, 16 | specPattern: "**/*.cy.ts" 17 | }, 18 | 19 | reporter: "junit", 20 | reporterOptions: { 21 | mochaFile: "/app/results/modron-e2e-ui-junit.xml", 22 | toConsole: false, 23 | }, 24 | 25 | }) 26 | -------------------------------------------------------------------------------- /docker/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "downlevelIteration": true, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "lib": [ 22 | "es2020", 23 | "dom" 24 | ], 25 | }, 26 | "angularCompilerOptions": { 27 | "enableI18nLegacyMessageIdFormat": false, 28 | "strictInjectionParameters": true, 29 | "strictInputAccessModifiers": true, 30 | "strictTemplates": true 31 | }, 32 | "exclude": [ 33 | "cypress.config.ts" 34 | ], 35 | "files": [ 36 | "cypress.config.ts" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /docs/pics/risk_score_matrix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/pics/risk_score_matrix.png -------------------------------------------------------------------------------- /docs/pics/severities.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/pics/severities.png -------------------------------------------------------------------------------- /docs/screenshots/home.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/home.jpg -------------------------------------------------------------------------------- /docs/screenshots/nagatha-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/nagatha-1.jpg -------------------------------------------------------------------------------- /docs/screenshots/nagatha-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/nagatha-2.jpg -------------------------------------------------------------------------------- /docs/screenshots/observations.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/observations.jpg -------------------------------------------------------------------------------- /docs/screenshots/risk-score.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/risk-score.jpg -------------------------------------------------------------------------------- /docs/screenshots/single-observation.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/single-observation.jpg -------------------------------------------------------------------------------- /docs/screenshots/stats.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/docs/screenshots/stats.jpg -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | ADMIN_GROUPS=modron-admins@example.com 2 | COLLECTORS=gcp 3 | LABEL_TO_EMAIL_REGEXP=(.*)_(.*?)_(.*?) 4 | LABEL_TO_EMAIL_SUBSTITUTION=$1@$2.$3 5 | LOG_FORMAT=text 6 | LOG_LEVEL=info 7 | LOG_ALL_SQL_QUERIES=false 8 | ORG_ID=111111111111 9 | ORG_SUFFIX=example.com 10 | PERSISTENT_CACHE=true 11 | PERSISTENT_CACHE_TIMEOUT=48h 12 | PORT=4201 13 | PROJECT_ID=modron-project-id 14 | RUN_AUTOMATED_SCANS=false 15 | SKIP_IAP=true 16 | SQL_CONNECT_STRING=postgres://modron:modron@127.0.0.1:5432/modron?sslmode=disable 17 | 18 | OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317 19 | OTEL_EXPORTER_OTLP_INSECURE=true 20 | OTEL_LOG_LEVEL=debug 21 | OTEL_SERVICE_NAME=modron 22 | 23 | TAG_CUSTOMER_DATA=111111111111/customer_data 24 | TAG_EMPLOYEE_DATA=111111111111/employee_data 25 | TAG_ENVIRONMENT=111111111111/environment 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | // This file is required for gosec to work. 2 | module github.com/nianticlabs/modron 3 | 4 | go 1.23.2 5 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/go.sum -------------------------------------------------------------------------------- /images/scan_process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/images/scan_process.png -------------------------------------------------------------------------------- /otel/config/config.yaml: -------------------------------------------------------------------------------- 1 | receivers: 2 | otlp: 3 | protocols: 4 | grpc: 5 | endpoint: 0.0.0.0:4317 6 | http: 7 | endpoint: 0.0.0.0:4318 8 | processors: 9 | batch: 10 | 11 | exporters: 12 | otlp: 13 | endpoint: "jaeger:4317" 14 | tls: 15 | insecure: true 16 | prometheusremotewrite: 17 | endpoint: "http://prometheus:9090/api/v1/write" 18 | 19 | extensions: 20 | health_check: 21 | pprof: 22 | zpages: 23 | 24 | service: 25 | extensions: [health_check, pprof, zpages] 26 | pipelines: 27 | traces: 28 | receivers: [otlp] 29 | processors: [batch] 30 | exporters: [otlp] 31 | metrics: 32 | receivers: [otlp] 33 | processors: [] 34 | exporters: [prometheusremotewrite] -------------------------------------------------------------------------------- /src/.gcloudignore: -------------------------------------------------------------------------------- 1 | *~ 2 | **/node_modules 3 | **/.angular 4 | test/certs/** -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | /modron-lint.xml 2 | -------------------------------------------------------------------------------- /src/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GOVERSION=1.23 2 | 3 | FROM alpine:latest AS ca-certificates_builder 4 | RUN apk add --no-cache ca-certificates 5 | 6 | FROM golang:${GOVERSION} AS modron_builder 7 | ENV GOPATH=/go 8 | WORKDIR /app/src 9 | COPY src/go.* /app/src/ 10 | COPY src/proto/generated /app/src/proto/generated/ 11 | RUN go mod download 12 | COPY ./src/ /app/src/ 13 | RUN CGO_ENABLED=0 go build -v -o modron 14 | 15 | FROM scratch 16 | WORKDIR /app 17 | COPY --from=ca-certificates_builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 18 | COPY --from=modron_builder /app/src/modron /app/modron 19 | USER 101:101 20 | ENTRYPOINT ["/app/modron"] 21 | -------------------------------------------------------------------------------- /src/Dockerfile.e2e: -------------------------------------------------------------------------------- 1 | # We have to keep this file here otherwise we can't depend on the shared proto. 2 | # Docker prevents including files above the Dockerfile directory (.. forbidden). 3 | ARG GOVERSION=1.23 4 | 5 | FROM golang:${GOVERSION} AS builder 6 | ENV GOPATH=/go 7 | WORKDIR /app/src/test/ 8 | COPY ./src/test/go.* /app/src/test/ 9 | COPY ./src/proto/ /app/src/proto 10 | RUN go mod download 11 | COPY ./src/test/* /app/src/test/ 12 | RUN CGO_ENABLED=0 go test -c -v -o test 13 | 14 | FROM scratch 15 | WORKDIR /app/stats 16 | WORKDIR /app/secrets 17 | WORKDIR /app 18 | COPY --from=builder /app/src/test/test /app/test 19 | ENTRYPOINT ["/app/test", "--test.short"] 20 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Modron 2 | 3 | ## Build modron and push to Google Cloud Registry 4 | 5 | ```bash 6 | gcloud builds submit . --tag us-central1.docker.pkg.dev/$PROJECT_ID/modron/modron:dev --timeout=900 7 | ``` 8 | 9 | This applies the label `dev` on the image you're pushing. 10 | This image is expected to run on modron-dev environment. 11 | 12 | Deploy to cloud run dev: 13 | 14 | ```bash 15 | DEV_RUNNER_SA_NAME=$PROJECT_ID-runner@$PROJECT_ID.iam.gserviceaccount.com 16 | REGION=us-central1 17 | gcloud run deploy \ 18 | modron-grpc-web-dev \ 19 | --platform=managed \ 20 | --image="$REGION.docker.pkg.dev/$PROJECT_ID/modron/modron:dev" \ 21 | --region="$REGION" \ 22 | --service-account="$DEV_RUNNER_SA_NAME" 23 | gcloud run services update-traffic modron-ui --to-revisions LATEST=100 --region="$REGION" 24 | ``` 25 | 26 | ## Debug 27 | 28 | To debug RPC issues, set the two following environment variables: 29 | 30 | ```bash 31 | export GRPC_GO_LOG_VERBOSITY_LEVEL=99 32 | export GRPC_GO_LOG_SEVERITY_LEVEL=info 33 | ``` 34 | 35 | ## Update libraries 36 | 37 | ```bash 38 | export CYPRESS_CACHE_FOLDER=/tmp 39 | npm upgrade 40 | npm install 41 | ``` 42 | 43 | Note: Cypress tries to write to `/root/.cache` which doesn't work. This is why we need to set the environment variable. 44 | -------------------------------------------------------------------------------- /src/acl/fakeacl/checkerFake.go: -------------------------------------------------------------------------------- 1 | package fakeacl 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | "golang.org/x/net/context" 6 | 7 | "github.com/nianticlabs/modron/src/constants" 8 | "github.com/nianticlabs/modron/src/model" 9 | ) 10 | 11 | type GcpCheckerFake struct{} 12 | 13 | var log = logrus.StandardLogger().WithField(constants.LogKeyPkg, "fakeacl") 14 | var _ model.Checker = (*GcpCheckerFake)(nil) 15 | 16 | func New() model.Checker { 17 | log.Warnf("If you see this on production, contact security") 18 | return &GcpCheckerFake{} 19 | } 20 | 21 | func (checker *GcpCheckerFake) GetACL() model.ACLCache { 22 | return nil 23 | } 24 | 25 | func (checker *GcpCheckerFake) GetValidatedUser(_ context.Context) (string, error) { 26 | return "", nil 27 | } 28 | 29 | func (checker *GcpCheckerFake) ListResourceGroupNamesOwned(_ context.Context) (map[string]struct{}, error) { 30 | return map[string]struct{}{"projects/modron-test": {}}, nil 31 | } 32 | -------------------------------------------------------------------------------- /src/acl/gcpacl/cache.go: -------------------------------------------------------------------------------- 1 | package gcpacl 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | "time" 8 | 9 | "github.com/nianticlabs/modron/src/model" 10 | ) 11 | 12 | type FSACLCache struct { 13 | LastUpdate time.Time `json:"last_update"` 14 | Content model.ACLCache `json:"content"` 15 | } 16 | 17 | var localACLCacheFile = os.TempDir() + "/modron-acl-cache.json" 18 | 19 | const ownerRWPermissions = 0600 20 | 21 | func (checker *GcpChecker) getLocalACLCache() (*FSACLCache, error) { 22 | log.Tracef("getting ACL cache from %s", localACLCacheFile) 23 | f, err := os.Open(localACLCacheFile) 24 | if err != nil { 25 | if errors.Is(err, os.ErrNotExist) { 26 | return nil, nil //nolint:nilnil 27 | } 28 | return nil, err 29 | } 30 | defer f.Close() 31 | 32 | var fsACLCache FSACLCache 33 | if err := json.NewDecoder(f).Decode(&fsACLCache); err != nil { 34 | return nil, err 35 | } 36 | return &fsACLCache, nil 37 | } 38 | 39 | func (checker *GcpChecker) saveLocalACLCache(res model.ACLCache) error { 40 | log.Tracef("saving ACL cache to %s", localACLCacheFile) 41 | fsACLCache := FSACLCache{ 42 | LastUpdate: time.Now(), 43 | Content: res, 44 | } 45 | f, err := os.OpenFile(localACLCacheFile, os.O_CREATE|os.O_WRONLY, ownerRWPermissions) 46 | if err != nil { 47 | return err 48 | } 49 | defer f.Close() 50 | return json.NewEncoder(f).Encode(fsACLCache) 51 | } 52 | 53 | func (checker *GcpChecker) deleteLocalACLCache() error { 54 | return os.Remove(localACLCacheFile) 55 | } 56 | -------------------------------------------------------------------------------- /src/acl/gcpacl/checker_real_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package gcpacl 4 | 5 | import ( 6 | "context" 7 | "os" 8 | "testing" 9 | 10 | "github.com/nianticlabs/modron/src/collector/gcpcollector" 11 | "github.com/nianticlabs/modron/src/risk" 12 | "github.com/nianticlabs/modron/src/storage/memstorage" 13 | ) 14 | 15 | func TestCheckerReal(t *testing.T) { 16 | orgID := os.Getenv("ORG_ID") 17 | orgSuffix := os.Getenv("ORG_SUFFIX") 18 | if orgID == "" || orgSuffix == "" { 19 | t.Fatalf("ORG_ID and ORG_SUFFIX are required, orgID=%q, orgSuffix=%q", orgID, orgSuffix) 20 | } 21 | 22 | ctx := context.Background() 23 | storage := memstorage.New() 24 | gcpCollector, err := gcpcollector.New(ctx, storage, orgID, orgSuffix, []string{}, risk.TagConfig{}, []string{}) 25 | if err != nil { 26 | t.Error(err) 27 | } 28 | 29 | checker, err := New(ctx, gcpCollector, Config{}) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | if _, err := checker.ListResourceGroupNamesOwned(ctx); err != nil { 35 | t.Error(err) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/acl/gcpacl/checker_test.go: -------------------------------------------------------------------------------- 1 | package gcpacl 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | 8 | "github.com/nianticlabs/modron/src/collector/gcpcollector" 9 | "github.com/nianticlabs/modron/src/risk" 10 | "github.com/nianticlabs/modron/src/storage/memstorage" 11 | 12 | "google.golang.org/grpc/metadata" 13 | ) 14 | 15 | func TestMain(m *testing.M) { 16 | os.Exit(m.Run()) 17 | } 18 | 19 | func TestInvalidNoToken(t *testing.T) { 20 | if testing.Short() { 21 | t.Skip("skipping test in short mode: need GCP credentials") 22 | } 23 | 24 | ctx := context.Background() 25 | storage := memstorage.New() 26 | gcpCollector := gcpcollector.NewFake(ctx, storage, risk.TagConfig{}) 27 | 28 | checker, err := New(ctx, gcpCollector, Config{}) 29 | if err != nil { 30 | t.Error(err) 31 | } 32 | 33 | if _, err = checker.GetValidatedUser(ctx); err == nil { 34 | t.Error("expected error: the context does not have a tokenid but the checker authenticated a user") 35 | } 36 | } 37 | 38 | func TestInvalidParseToken(t *testing.T) { 39 | ctx := metadata.NewIncomingContext(context.Background(), metadata.New( 40 | map[string]string{"Authorization": "Bearer xyz.abc.123"})) 41 | storage := memstorage.New() 42 | gcpCollector := gcpcollector.NewFake(ctx, storage, risk.TagConfig{}) 43 | 44 | checker, err := New(ctx, gcpCollector, Config{}) 45 | if err != nil { 46 | t.Error(err) 47 | } 48 | 49 | if _, err = checker.GetValidatedUser(ctx); err == nil { 50 | t.Error("expected error: checker parsed a jwt tokenId that is invalid") 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/collector/collector.go: -------------------------------------------------------------------------------- 1 | package collector 2 | 3 | type Type string 4 | 5 | const ( 6 | Gcp Type = "gcp" 7 | Fake Type = "fake" 8 | ) 9 | 10 | var validCollectors = []Type{Gcp, Fake} 11 | 12 | func ValidCollectors() []string { 13 | var collectors []string 14 | for _, c := range validCollectors { 15 | collectors = append(collectors, string(c)) 16 | } 17 | return collectors 18 | } 19 | -------------------------------------------------------------------------------- /src/collector/gcpcollector/api_key.go: -------------------------------------------------------------------------------- 1 | package gcpcollector 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/nianticlabs/modron/src/common" 7 | "github.com/nianticlabs/modron/src/constants" 8 | pb "github.com/nianticlabs/modron/src/proto/generated" 9 | 10 | "golang.org/x/net/context" 11 | "google.golang.org/api/apikeys/v2" 12 | ) 13 | 14 | const ( 15 | globalProjectResourceID = "%s/locations/global" 16 | ) 17 | 18 | func (collector *GCPCollector) ListAPIKeys(ctx context.Context, rgName string) (apiKeys []*pb.Resource, err error) { 19 | name := fmt.Sprintf(globalProjectResourceID, constants.ResourceWithProjectsPrefix(rgName)) 20 | 21 | keys, err := collector.api.ListAPIKeys(ctx, name) 22 | if err != nil { 23 | return nil, err 24 | } 25 | for _, key := range keys { 26 | // TODO : handle other types of GCP API keys restrictions 27 | // example : BrowserKeyRestrictions , AndroidKeyRestrictions , etc.. 28 | scopes := getAPIKeyScopes(key) 29 | apiKeys = append(apiKeys, &pb.Resource{ 30 | Uid: common.GetUUID(uuidGenRetries), 31 | ResourceGroupName: rgName, 32 | Name: key.Name, 33 | Parent: rgName, 34 | Type: &pb.Resource_ApiKey{ 35 | ApiKey: &pb.APIKey{ 36 | Scopes: scopes, 37 | }, 38 | }, 39 | }) 40 | } 41 | return apiKeys, err 42 | } 43 | 44 | func getAPIKeyScopes(key *apikeys.V2Key) (scopes []string) { 45 | if key.Restrictions == nil || key.Restrictions.ApiTargets == nil { 46 | return nil 47 | } 48 | for _, apiTarget := range key.Restrictions.ApiTargets { 49 | scopes = append(scopes, apiTarget.Service) 50 | } 51 | return scopes 52 | } 53 | -------------------------------------------------------------------------------- /src/collector/gcpcollector/collector_call.go: -------------------------------------------------------------------------------- 1 | package gcpcollector 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "google.golang.org/api/googleapi" 8 | ) 9 | 10 | const ( 11 | maxAttemptNumber = 100 12 | maxSecBtwAttempts = 30. 13 | maxSecAcrossAttempts = time.Duration(3600 * time.Second) 14 | ) 15 | 16 | var ( 17 | // Retry following errors: 18 | // * 408: Request timeout 19 | // * 429: Too many requests 20 | // * 5XX: Server errors 21 | retryableErrorCode = []int{408, 429, 500, 502, 503, 504} 22 | // We are not interested in the following codes: 23 | // * 403: Sometimes returned for non-existing resources. 24 | // * 404: A resource can be tracked by modron and then deleted. 25 | skippableErrorCodes = []int{403, 404} 26 | ) 27 | 28 | func getErrorCode(err error) int { 29 | var e *googleapi.Error 30 | if errors.As(err, &e) { 31 | return e.Code 32 | } 33 | return 0 34 | } 35 | 36 | func isErrorCodeRetryable(errorCode int) bool { 37 | for _, code := range retryableErrorCode { 38 | if code == errorCode { 39 | return true 40 | } 41 | } 42 | return false 43 | } 44 | 45 | func isErrorCodeSkippable(errorCode int) bool { 46 | for _, code := range skippableErrorCodes { 47 | if code == errorCode { 48 | return true 49 | } 50 | } 51 | return false 52 | } 53 | -------------------------------------------------------------------------------- /src/collector/gcpcollector/collector_generic.go: -------------------------------------------------------------------------------- 1 | package gcpcollector 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math" 7 | "math/rand" 8 | "time" 9 | ) 10 | 11 | type GenericCollector[T any] func(ctx context.Context, rgName string) ([]T, error) 12 | 13 | func (call GenericCollector[T]) ExponentialBackoffRun(ctx context.Context, rgName string) ([]T, error) { 14 | attemptNumber := 1 15 | var waitSec float64 16 | start := time.Now() 17 | for { 18 | collected, err := call.Run(ctx, rgName) 19 | if err == nil { 20 | return collected, nil 21 | } 22 | if !isErrorCodeRetryable(getErrorCode(err)) { 23 | return nil, fmt.Errorf("ExponentialBackoffRun.notRetryableCode: %w ", err) 24 | } 25 | if time.Since(start) > maxSecAcrossAttempts { 26 | return nil, fmt.Errorf("ExponentialBackoffRun.tooLong: after %v seconds %w ", time.Since(start), err) 27 | } 28 | if attemptNumber >= maxAttemptNumber { 29 | return nil, fmt.Errorf("ExponentialBackoffRun.maxAttempt: after %v attempts %w ", attemptNumber, err) 30 | } 31 | waitSec = math.Min(math.Pow(2, float64(attemptNumber))+rand.Float64(), maxSecBtwAttempts) //nolint:mnd,gosec 32 | time.Sleep(time.Duration(waitSec) * time.Second) 33 | attemptNumber++ 34 | } 35 | } 36 | 37 | func (call GenericCollector[T]) Run(ctx context.Context, rgName string) ([]T, error) { 38 | resources, err := call(ctx, rgName) 39 | if isErrorCodeSkippable(getErrorCode(err)) { 40 | return []T{}, nil 41 | } 42 | return resources, err 43 | } 44 | -------------------------------------------------------------------------------- /src/collector/gcpcollector/collector_rg_call.go: -------------------------------------------------------------------------------- 1 | package gcpcollector 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math" 7 | "math/rand" 8 | "time" 9 | 10 | pb "github.com/nianticlabs/modron/src/proto/generated" 11 | ) 12 | 13 | // TODO: find a way to avoid code duplication: 14 | // (the implementation of CollectorResourceGroupCall.ExponentialBackoffRun is very similar to GenericCollector.ExponentialBackoffRun) 15 | type CollectorResourceGroupCall func(ctx context.Context, collectID string, rgName string) (*pb.Resource, error) 16 | 17 | func (call CollectorResourceGroupCall) ExponentialBackoffRun(ctx context.Context, collectID string, rgName string) (*pb.Resource, error) { 18 | attemptNumber := 0 19 | var waitSec float64 20 | start := time.Now() 21 | for { 22 | resources, err := call.Run(ctx, collectID, rgName) 23 | if err == nil { 24 | return resources, nil 25 | } 26 | if !isErrorCodeRetryable(getErrorCode(err)) { 27 | return nil, fmt.Errorf("ExponentialBackoffRun.notRetryableCode: %w ", err) 28 | } 29 | if time.Since(start) > maxSecAcrossAttempts { 30 | return nil, fmt.Errorf("ExponentialBackoffRun.tooLong: after %v seconds %w", time.Since(start), err) 31 | } 32 | if attemptNumber >= maxAttemptNumber { 33 | return nil, fmt.Errorf("ExponentialBackoffRun.maxAttempt: after %v attempts %w", attemptNumber, err) 34 | } 35 | waitSec = math.Min(math.Pow(2, float64(attemptNumber))+rand.Float64(), maxSecBtwAttempts) //nolint:mnd,gosec 36 | time.Sleep(time.Duration(waitSec) * time.Second) 37 | attemptNumber++ 38 | } 39 | } 40 | 41 | func (call CollectorResourceGroupCall) Run(ctx context.Context, collectID string, rgName string) (*pb.Resource, error) { 42 | return call(ctx, collectID, rgName) 43 | } 44 | -------------------------------------------------------------------------------- /src/collector/gcpcollector/metrics.go: -------------------------------------------------------------------------------- 1 | package gcpcollector 2 | 3 | import ( 4 | "go.opentelemetry.io/otel" 5 | "go.opentelemetry.io/otel/metric" 6 | 7 | "github.com/nianticlabs/modron/src/constants" 8 | ) 9 | 10 | var meter = otel.Meter("github.com/nianticlabs/modron/src/collector/gcpcollector") 11 | 12 | type metrics struct { 13 | SccCollectedObservations metric.Int64Counter 14 | } 15 | 16 | func initMetrics() metrics { 17 | sccCollectedObsCounter, err := meter.Int64Counter(constants.MetricsPrefix+"scc_collected_observations", 18 | metric.WithDescription("Number of collected observations from SCC"), 19 | ) 20 | if err != nil { 21 | log.Errorf("failed to create scc_collected_observations counter: %v", err) 22 | } 23 | return metrics{ 24 | SccCollectedObservations: sccCollectedObsCounter, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/collector/gcpcollector/resource_group_test.go: -------------------------------------------------------------------------------- 1 | package gcpcollector 2 | 3 | import "testing" 4 | 5 | func TestGetResourceGroupLink(t *testing.T) { 6 | tc := []struct { 7 | name string 8 | expected string 9 | }{ 10 | { 11 | name: "projects/project-1", 12 | expected: "https://console.cloud.google.com/welcome?project=project-1", 13 | }, 14 | { 15 | name: "folders/1000000", 16 | expected: "https://console.cloud.google.com/welcome?folder=1000000", 17 | }, 18 | { 19 | name: "organizations/1000000", 20 | expected: "https://console.cloud.google.com/welcome?organizationId=1000000", 21 | }, 22 | } 23 | 24 | for _, tt := range tc { 25 | t.Run(tt.name, func(t *testing.T) { 26 | if got := getResourceGroupLink(tt.name); got != tt.expected { 27 | t.Errorf("GetResourceGroupLink() = %v, want %v", got, tt.expected) 28 | } 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/collector/gcpcollector/service_account_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package gcpcollector 4 | 5 | import ( 6 | "context" 7 | "os" 8 | "testing" 9 | 10 | "github.com/nianticlabs/modron/src/constants" 11 | ) 12 | 13 | func TestListServiceAccounts(t *testing.T) { 14 | ctx := context.Background() 15 | coll, _ := getCollector(ctx, t) 16 | project := constants.GCPProjectsNamePrefix + os.Getenv("PROJECT_ID") 17 | accounts, err := coll.(*GCPCollector).ListServiceAccounts(ctx, project) 18 | if err != nil { 19 | t.Fatalf("ListServiceAccounts failed: %v", err) 20 | } 21 | if len(accounts) == 0 { 22 | t.Fatalf("ListServiceAccounts returned 0 accounts") 23 | } 24 | for _, account := range accounts { 25 | t.Logf("ServiceAccount: %s", account.Name) 26 | if account.IamPolicy != nil && len(account.IamPolicy.Permissions) > 0 { 27 | t.Logf("\tIAM Policy: %v", account.IamPolicy) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/collector/gcpcollector/spanner.go: -------------------------------------------------------------------------------- 1 | package gcpcollector 2 | 3 | import ( 4 | "github.com/nianticlabs/modron/src/common" 5 | "github.com/nianticlabs/modron/src/constants" 6 | pb "github.com/nianticlabs/modron/src/proto/generated" 7 | 8 | "golang.org/x/net/context" 9 | ) 10 | 11 | func (collector *GCPCollector) ListSpannerDatabases(ctx context.Context, rgName string) (resources []*pb.Resource, err error) { 12 | name := constants.ResourceWithProjectsPrefix(rgName) 13 | dbs, err := collector.api.ListSpannerDatabases(ctx, name) 14 | if err != nil { 15 | return nil, err 16 | } 17 | for _, database := range dbs { 18 | dbResource := &pb.Resource{ 19 | // TODO: Collect IAM Policy 20 | Uid: common.GetUUID(uuidGenRetries), 21 | ResourceGroupName: rgName, 22 | Name: database.Name, 23 | Parent: rgName, 24 | Type: &pb.Resource_Database{ 25 | Database: &pb.Database{ 26 | Type: "spanner", 27 | Address: "spanner.googleapis.com", 28 | AutoResize: true, 29 | // Default on Spanner. 30 | TlsRequired: true, 31 | }, 32 | }, 33 | } 34 | if database.EncryptionConfig != nil { 35 | dbResource.GetDatabase().Encryption = pb.Database_ENCRYPTION_USER_MANAGED 36 | } else { 37 | dbResource.GetDatabase().Encryption = pb.Database_ENCRYPTION_MANAGED 38 | } 39 | 40 | resources = append(resources, dbResource) 41 | } 42 | 43 | return resources, nil 44 | } 45 | -------------------------------------------------------------------------------- /src/collector/gcpcollector/vm_instance.go: -------------------------------------------------------------------------------- 1 | package gcpcollector 2 | 3 | import ( 4 | "github.com/nianticlabs/modron/src/common" 5 | pb "github.com/nianticlabs/modron/src/proto/generated" 6 | 7 | "golang.org/x/net/context" 8 | ) 9 | 10 | func (collector *GCPCollector) ListVMInstances(ctx context.Context, rgName string) (vmInstances []*pb.Resource, err error) { 11 | instances, err := collector.api.ListInstances(ctx, rgName) 12 | if err != nil { 13 | return nil, err 14 | } 15 | for _, instance := range instances { 16 | name := instance.Name 17 | privateIP, publicIP := "", "" 18 | for _, networkInterface := range instance.NetworkInterfaces { 19 | privateIP = networkInterface.NetworkIP 20 | for _, accessConfig := range networkInterface.AccessConfigs { 21 | publicIP = accessConfig.NatIP 22 | } 23 | } 24 | serviceAccountName := "" 25 | for _, sa := range instance.ServiceAccounts { 26 | serviceAccountName = sa.Email 27 | } 28 | vmInstances = append(vmInstances, &pb.Resource{ 29 | Uid: common.GetUUID(uuidGenRetries), 30 | ResourceGroupName: rgName, 31 | Name: name, 32 | Parent: rgName, 33 | Type: &pb.Resource_VmInstance{ 34 | VmInstance: &pb.VmInstance{ 35 | PublicIp: publicIP, 36 | PrivateIp: privateIP, 37 | Identity: serviceAccountName, 38 | }, 39 | }, 40 | }) 41 | } 42 | return vmInstances, nil 43 | } 44 | -------------------------------------------------------------------------------- /src/collector/testcollector/testcollector.go: -------------------------------------------------------------------------------- 1 | package testcollector 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/nianticlabs/modron/src/model" 7 | pb "github.com/nianticlabs/modron/src/proto/generated" 8 | 9 | "golang.org/x/net/context" 10 | ) 11 | 12 | var _ model.Collector = (*TestCollector)(nil) 13 | 14 | var errNotImplemented = fmt.Errorf("not implemented") 15 | 16 | type TestCollector struct{} 17 | 18 | func (t TestCollector) CollectAndStoreAll(_ context.Context, _ string, _ []string, _ []*pb.Resource) error { 19 | return errNotImplemented 20 | } 21 | 22 | func (t TestCollector) ListResourceGroupObservations(_ context.Context, _ string, _ string) ([]*pb.Observation, []error) { 23 | return nil, []error{errNotImplemented} 24 | } 25 | 26 | func (t TestCollector) GetResourceGroupWithIamPolicy(_ context.Context, _ string, _ string) (*pb.Resource, error) { 27 | return nil, errNotImplemented 28 | } 29 | 30 | func (t TestCollector) ListResourceGroups(_ context.Context, _ []string) ([]*pb.Resource, error) { 31 | return nil, errNotImplemented 32 | } 33 | 34 | func (t TestCollector) ListResourceGroupsWithIamPolicies(_ context.Context, _ []string) ([]*pb.Resource, error) { 35 | return nil, errNotImplemented 36 | } 37 | 38 | func (t TestCollector) ListResourceGroupNames(_ context.Context) ([]string, error) { 39 | return nil, errNotImplemented 40 | } 41 | 42 | func (t TestCollector) ListResourceGroupAdmins(_ context.Context) (model.ACLCache, error) { 43 | return model.ACLCache{ 44 | "*": { 45 | "projects/modron-test": {}, 46 | "projects/super-secret": {}, 47 | }, 48 | "user@example.com": { 49 | "projects/modron-test": {}, 50 | }, 51 | }, nil 52 | } 53 | 54 | func (t TestCollector) ListResourceGroupResources(_ context.Context, _ string, _ string) ([]*pb.Resource, []error) { 55 | return nil, []error{errNotImplemented} 56 | } 57 | -------------------------------------------------------------------------------- /src/constants/context.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | type runnerCtxKey string 4 | 5 | const ( 6 | CollectIDKey runnerCtxKey = "collect_id" 7 | ScanIDKey runnerCtxKey = "scan_id" 8 | ) 9 | -------------------------------------------------------------------------------- /src/engine/rules/common.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/nianticlabs/modron/src/constants" 8 | pb "github.com/nianticlabs/modron/src/proto/generated" 9 | 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | var log = logrus.StandardLogger().WithField(constants.LogKeyPkg, "rules") 14 | 15 | func getAccountRoles(perm *pb.Permission, account string) (roles []string) { 16 | for _, principal := range perm.Principals { 17 | if strings.HasPrefix(principal, PrincipalDeleted) { 18 | continue 19 | } 20 | s := strings.Split(principal, ":") 21 | if len(s) != 2 { // nolint:mnd 22 | log.Warn("invalid principal in org policy: ", principal) 23 | continue 24 | } 25 | p := strings.Split(principal, ":")[1] 26 | if strings.EqualFold(p, account) { 27 | roles = append(roles, perm.Role) 28 | } 29 | } 30 | return roles 31 | } 32 | 33 | const ( 34 | PrincipalDeleted = "deleted:" 35 | ) 36 | 37 | // TODO: Add SelfLink and HumanReadableName field to Protobuf and move this logic to the collector. 38 | func getGcpReadableResourceName(resourceName string) string { 39 | if !(strings.Contains(resourceName, "[") && strings.Contains(resourceName, "]")) { 40 | return resourceName 41 | } 42 | m := regexp.MustCompile(`\[.*\]$`) 43 | return m.ReplaceAllLiteralString(resourceName, "") 44 | } 45 | -------------------------------------------------------------------------------- /src/engine/rules/database_allows_unencrypted_connections_test.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nianticlabs/modron/src/model" 7 | pb "github.com/nianticlabs/modron/src/proto/generated" 8 | "github.com/nianticlabs/modron/src/utils" 9 | 10 | "google.golang.org/protobuf/types/known/structpb" 11 | ) 12 | 13 | func TestCheckDetectsDatabaseAllowsUnencryptedConnections(t *testing.T) { 14 | databaseNoForceTLS := &pb.Resource{ 15 | Name: "database-no-force-tls", 16 | Parent: testProjectName, 17 | ResourceGroupName: testProjectName, 18 | IamPolicy: &pb.IamPolicy{}, 19 | Type: &pb.Resource_Database{ 20 | Database: &pb.Database{ 21 | Type: "cloudsql", 22 | Version: "123", 23 | TlsRequired: false, 24 | }, 25 | }, 26 | } 27 | resources := []*pb.Resource{ 28 | { 29 | Name: testProjectName, 30 | Parent: "", 31 | ResourceGroupName: testProjectName, 32 | IamPolicy: &pb.IamPolicy{}, 33 | Type: &pb.Resource_ResourceGroup{ 34 | ResourceGroup: &pb.ResourceGroup{}, 35 | }, 36 | }, 37 | databaseNoForceTLS, 38 | { 39 | Name: "database-force-tls", 40 | Parent: testProjectName, 41 | ResourceGroupName: testProjectName, 42 | IamPolicy: &pb.IamPolicy{}, 43 | Type: &pb.Resource_Database{ 44 | Database: &pb.Database{ 45 | Type: "cloudsql", 46 | Version: "123", 47 | TlsRequired: true, 48 | }, 49 | }, 50 | }, 51 | } 52 | 53 | want := []*pb.Observation{ 54 | { 55 | Name: DatabaseAllowsUnencryptedConnections, 56 | ResourceRef: utils.GetResourceRef(databaseNoForceTLS), 57 | ObservedValue: structpb.NewBoolValue(false), 58 | ExpectedValue: structpb.NewBoolValue(true), 59 | Remediation: &pb.Remediation{ 60 | Description: "Database database-no-force-tls allows for unencrypted connections.", 61 | Recommendation: "Enable the require SSL setting in the database settings to allow only encrypted connections to database-no-force-tls.", 62 | }, 63 | Severity: pb.Severity_SEVERITY_MEDIUM, 64 | }, 65 | } 66 | 67 | TestRuleRun(t, resources, []model.Rule{NewDatabaseAllowsUnencryptedConnectionsRule()}, want) 68 | } 69 | -------------------------------------------------------------------------------- /src/engine/rules/kubernetes_vulnerability_scanning_e2e_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package rules 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/nianticlabs/modron/src/model" 9 | ) 10 | 11 | func TestKubernetesVulnerabilityScanningRuleE2E(t *testing.T) { 12 | obs, err := TestE2ERuleRun(t, []model.Rule{NewKubernetesVulnerabilityScanningDisabledRule()}) 13 | if err != nil { 14 | t.Fatalf("TestE2ERuleRun unexpected error: %v", err) 15 | } 16 | for _, o := range obs { 17 | t.Logf("Observation: %v", o) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/engine/rules/registry.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/nianticlabs/modron/src/model" 8 | ) 9 | 10 | var rules sync.Map 11 | 12 | func AddRule(r model.Rule) { 13 | ruleName := r.Info().Name 14 | _, ok := rules.Load(ruleName) 15 | if ok { 16 | panic(fmt.Sprintf("rule %q already exists", ruleName)) 17 | } 18 | rules.Store(r.Info().Name, r) 19 | } 20 | 21 | func GetRule(name string) (model.Rule, error) { 22 | if rule, ok := rules.Load(name); ok { 23 | return rule.(model.Rule), nil 24 | } 25 | return nil, fmt.Errorf("could not find rule %q", name) 26 | } 27 | 28 | func GetRules() []model.Rule { 29 | var rulesSnapshot []model.Rule 30 | rules.Range(func(_, rule any) bool { 31 | rulesSnapshot = append(rulesSnapshot, rule.(model.Rule)) 32 | return true 33 | }) 34 | 35 | return rulesSnapshot 36 | } 37 | -------------------------------------------------------------------------------- /src/engine/rules/registry_test.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "google.golang.org/protobuf/proto" 8 | 9 | "github.com/nianticlabs/modron/src/model" 10 | pb "github.com/nianticlabs/modron/src/proto/generated" 11 | ) 12 | 13 | type TestRule struct { 14 | info model.RuleInfo 15 | } 16 | 17 | func NewTestRule(name string) *TestRule { 18 | return &TestRule{ 19 | info: model.RuleInfo{ 20 | Name: name, 21 | AcceptedResourceTypes: []proto.Message{}, 22 | }, 23 | } 24 | } 25 | 26 | func (r *TestRule) Check(context.Context, model.Engine, *pb.Resource) ([]*pb.Observation, []error) { 27 | return []*pb.Observation{{}}, nil 28 | } 29 | 30 | func (r *TestRule) Info() *model.RuleInfo { 31 | return &r.info 32 | } 33 | 34 | func TestAddAndGetRule(t *testing.T) { 35 | want := NewTestRule("TEST_RULE_3") 36 | AddRule(want) 37 | 38 | got, err := GetRule("TEST_RULE_3") 39 | if err != nil { 40 | t.Errorf(`GetRule unexpected error "%v"`, err) 41 | } 42 | if got != want { 43 | t.Errorf(`GetRule unexpected diff (-want, +got): "-%v", "+%v"`, want, got) 44 | } 45 | } 46 | 47 | // TODO: Make test state-independent (i.e., irrespective of the presence of rules in the registry). 48 | func TestGetRules(t *testing.T) { 49 | rulesToAdd := []model.Rule{NewTestRule("TEST_RULE1"), NewTestRule("TEST_RULE2")} 50 | for _, rule := range rulesToAdd { 51 | AddRule(rule) 52 | } 53 | 54 | rules := GetRules() 55 | 56 | for _, addedRule := range rulesToAdd { 57 | found := false 58 | 59 | for _, rule := range rules { 60 | if rule == addedRule { 61 | found = true 62 | break 63 | } 64 | } 65 | if !found { 66 | t.Errorf(`GetRules() does not contain rule "%v"`, addedRule.Info().Name) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/engine/rules/testing_e2e.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "os" 8 | "testing" 9 | 10 | "github.com/sirupsen/logrus" 11 | 12 | "github.com/nianticlabs/modron/src/collector/gcpcollector" 13 | "github.com/nianticlabs/modron/src/engine" 14 | "github.com/nianticlabs/modron/src/model" 15 | pb "github.com/nianticlabs/modron/src/proto/generated" 16 | "github.com/nianticlabs/modron/src/risk" 17 | "github.com/nianticlabs/modron/src/storage/memstorage" 18 | ) 19 | 20 | func TestE2ERuleRun(t *testing.T, rules []model.Rule) ([]*pb.Observation, error) { 21 | t.Helper() 22 | logrus.StandardLogger().SetLevel(logrus.DebugLevel) 23 | projectID := os.Getenv("PROJECT_ID") 24 | if projectID == "" { 25 | t.Skip("PROJECT_ID is not set") 26 | } 27 | orgID := os.Getenv("ORG_ID") 28 | if orgID == "" { 29 | t.Skip("ORG_ID is not set") 30 | } 31 | orgSuffix := os.Getenv("ORG_SUFFIX") 32 | if orgSuffix == "" { 33 | t.Skip("ORG_SUFFIX is not set") 34 | } 35 | tagConfig := risk.TagConfig{ 36 | Environment: "111111111111/environment", 37 | EmployeeData: "111111111111/employee_data", 38 | CustomerData: "111111111111/customer_data", 39 | } 40 | qualifiedProjectID := "projects/" + projectID 41 | ctx := context.Background() 42 | storage := memstorage.New() 43 | collector, err := gcpcollector.New(ctx, storage, orgID, orgSuffix, []string{}, tagConfig, []string{}) 44 | if err != nil { 45 | t.Fatalf("NewCollector unexpected error: %v", err) 46 | } 47 | 48 | if err := collector.CollectAndStoreAll(ctx, "test-collect", []string{qualifiedProjectID}, nil); err != nil { 49 | t.Fatalf("collectAndStoreResources unexpected error: %v", err) 50 | } 51 | 52 | e, err := engine.New(storage, rules, map[string]json.RawMessage{}, []string{}, tagConfig) 53 | if err != nil { 54 | t.Fatalf("NewEngine unexpected error: %v", err) 55 | } 56 | obs, errArr := e.CheckRules(ctx, "unit-test-scan", "", []string{qualifiedProjectID}, nil) 57 | if errArr != nil { 58 | return nil, errors.Join(errArr...) 59 | } 60 | return obs, nil 61 | } 62 | -------------------------------------------------------------------------------- /src/engine/runner_integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package engine_test 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | "errors" 9 | "os" 10 | "testing" 11 | 12 | "github.com/google/uuid" 13 | "github.com/sirupsen/logrus" 14 | 15 | "github.com/nianticlabs/modron/src/collector/gcpcollector" 16 | "github.com/nianticlabs/modron/src/engine" 17 | "github.com/nianticlabs/modron/src/engine/rules" 18 | "github.com/nianticlabs/modron/src/model" 19 | "github.com/nianticlabs/modron/src/risk" 20 | "github.com/nianticlabs/modron/src/storage/memstorage" 21 | ) 22 | 23 | func TestCrossEnvironmentRuleIntegration(t *testing.T) { 24 | rgNames := []string{ 25 | "projects/modron-dev", 26 | } 27 | ctx := context.Background() 28 | st := memstorage.New() 29 | collectID := uuid.NewString() 30 | scanID := uuid.NewString() 31 | 32 | logrus.StandardLogger().SetLevel(logrus.DebugLevel) 33 | logrus.StandardLogger().SetFormatter(&logrus.TextFormatter{ 34 | ForceColors: true, 35 | }) 36 | 37 | orgID := os.Getenv("ORG_ID") 38 | orgSuffix := os.Getenv("ORG_SUFFIX") 39 | if orgID == "" || orgSuffix == "" { 40 | t.Fatalf("ORG_ID and ORG_SUFFIX are required for this test") 41 | } 42 | tagConfig := risk.TagConfig{ 43 | Environment: "111111111111/environment", 44 | EmployeeData: "111111111111/employee_data", 45 | CustomerData: "111111111111/customer_data", 46 | } 47 | 48 | // Collect resources 49 | c, err := gcpcollector.New(ctx, st, orgID, orgSuffix, []string{}, tagConfig, []string{}) 50 | if err != nil { 51 | t.Fatalf("failed to create collector: %v", err) 52 | } 53 | if err := c.CollectAndStoreAll(ctx, collectID, rgNames, nil); err != nil { 54 | t.Fatalf("failed to collect resources: %v", err) 55 | } 56 | 57 | e, _ := engine.New(st, []model.Rule{ 58 | rules.NewCrossEnvironmentPermissionsRule(), 59 | }, map[string]json.RawMessage{}, []string{}, tagConfig) 60 | obs, errArr := e.CheckRules(ctx, scanID, collectID, rgNames, nil) 61 | err = errors.Join(errArr...) 62 | if err != nil { 63 | t.Fatalf("failed to check rules: %v", err) 64 | } 65 | t.Logf("Observations: %v", obs) 66 | } 67 | -------------------------------------------------------------------------------- /src/log.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "time" 7 | 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | type LogFormat string 12 | 13 | const ( 14 | LogFormatJSON LogFormat = "json" 15 | LogFormatText LogFormat = "text" 16 | ) 17 | 18 | // setLogLevel sets the logrus log level 19 | func setLogLevel() { 20 | switch strings.ToLower(args.LogLevel) { 21 | case "trace": 22 | log.SetLevel(logrus.TraceLevel) 23 | case "debug": 24 | log.SetLevel(logrus.DebugLevel) 25 | case "info": 26 | log.SetLevel(logrus.InfoLevel) 27 | case "warning": 28 | log.SetLevel(logrus.WarnLevel) 29 | case "error": 30 | log.SetLevel(logrus.ErrorLevel) 31 | } 32 | } 33 | 34 | type gcpFormatter struct{} 35 | 36 | func (g gcpFormatter) Format(entry *logrus.Entry) ([]byte, error) { 37 | output := map[string]any{} 38 | output["severity"] = toGcpSeverity(entry.Level) 39 | output["message"] = entry.Message 40 | output["timestamp"] = entry.Time.Format(time.RFC3339Nano) 41 | if len(entry.Data) > 0 { 42 | output["labels"] = entry.Data 43 | } 44 | 45 | b, err := json.Marshal(output) 46 | if err != nil { 47 | return nil, err 48 | } 49 | b = append(b, '\n') 50 | return b, nil 51 | } 52 | 53 | func toGcpSeverity(level logrus.Level) string { 54 | switch level { 55 | case logrus.TraceLevel, logrus.DebugLevel: 56 | return "DEBUG" 57 | case logrus.InfoLevel: 58 | return "INFO" 59 | case logrus.WarnLevel: 60 | return "WARNING" 61 | case logrus.ErrorLevel: 62 | return "ERROR" 63 | case logrus.FatalLevel, logrus.PanicLevel: 64 | return "CRITICAL" 65 | default: 66 | return "DEFAULT" 67 | } 68 | } 69 | 70 | var gcpFormat = &gcpFormatter{} 71 | var textFormatter = &logrus.TextFormatter{} 72 | 73 | // setLogFormat sets the format of the logs 74 | func setLogFormat() { 75 | var formatter logrus.Formatter 76 | invalidFormatter := false 77 | switch args.LogFormat { 78 | case LogFormatJSON: 79 | formatter = gcpFormat 80 | case LogFormatText: 81 | formatter = textFormatter 82 | default: 83 | formatter = textFormatter 84 | invalidFormatter = true 85 | } 86 | 87 | logrus.SetFormatter(formatter) 88 | if invalidFormatter { 89 | log.Errorf("invalid log format, using %s", LogFormatText) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/lognotifier/lognotifier.go: -------------------------------------------------------------------------------- 1 | package lognotifier 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/nianticlabs/modron/src/constants" 8 | "github.com/nianticlabs/modron/src/model" 9 | 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | func New() model.NotificationService { 14 | return &LogNotifier{} 15 | } 16 | 17 | var log = logrus.StandardLogger().WithField(constants.LogKeyPkg, "lognotifier") 18 | 19 | type LogNotifier struct { 20 | exceptions []model.Exception 21 | } 22 | 23 | func (ln *LogNotifier) BatchCreateNotifications(ctx context.Context, notifications []model.Notification) ([]model.Notification, error) { 24 | var resultNotifications []model.Notification 25 | var errArr []error 26 | for _, v := range notifications { 27 | notif, err := ln.CreateNotification(ctx, v) 28 | if err != nil { 29 | errArr = append(errArr, err) 30 | continue 31 | } 32 | resultNotifications = append(resultNotifications, notif) 33 | } 34 | return resultNotifications, errors.Join(errArr...) 35 | } 36 | 37 | func (ln *LogNotifier) CreateNotification(_ context.Context, notification model.Notification) (model.Notification, error) { 38 | log.Infof("create notification: %+v", notification) 39 | return notification, nil 40 | } 41 | 42 | func (ln *LogNotifier) GetException(_ context.Context, uuid string) (model.Exception, error) { 43 | log.Infof("get exception called with %q", uuid) 44 | return model.Exception{UUID: uuid}, nil 45 | } 46 | 47 | func (ln *LogNotifier) CreateException(_ context.Context, exception model.Exception) (model.Exception, error) { 48 | log.Infof("create exception %+v", exception) 49 | ln.exceptions = append(ln.exceptions, exception) 50 | return exception, nil 51 | } 52 | 53 | func (ln *LogNotifier) UpdateException(_ context.Context, exception model.Exception) (model.Exception, error) { 54 | log.Infof("update exception: %+v", exception) 55 | return exception, nil 56 | } 57 | 58 | func (ln *LogNotifier) DeleteException(_ context.Context, id string) error { 59 | log.Infof("delete exception %q", id) 60 | return nil 61 | } 62 | 63 | func (ln *LogNotifier) ListExceptions(_ context.Context, userEmail string, _ int32, _ string) ([]model.Exception, error) { 64 | log.Infof("list exceptions for user %q", userEmail) 65 | return ln.exceptions, nil 66 | } 67 | -------------------------------------------------------------------------------- /src/metric/keys.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | const ( 4 | KeyCategory = "category" 5 | KeyCount = "count" 6 | KeyMethod = "method" 7 | KeyOffendingPackage = "offending_package" 8 | KeyPath = "path" 9 | KeyRecipient = "recipient" 10 | KeyRule = "rule" 11 | KeySeverity = "severity" 12 | KeyStatus = "status" 13 | ) 14 | -------------------------------------------------------------------------------- /src/metric/status.go: -------------------------------------------------------------------------------- 1 | package metric 2 | 3 | type Status = string 4 | 5 | const ( 6 | StatusCancelled Status = "cancelled" 7 | StatusCompleted Status = "completed" 8 | StatusError Status = "error" 9 | StatusSuccess Status = "success" 10 | ) 11 | -------------------------------------------------------------------------------- /src/model/acl.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "golang.org/x/net/context" 4 | 5 | // ACLCache is a map of users to a map of resource names: {"user@example.com": {"projects/xyz": {}}} 6 | // the reason why the last part is a struct{} is that we don't care about the value, we only care about the key 7 | // and a struct{} is the smallest value we can use. 8 | type ACLCache map[string]map[string]struct{} 9 | 10 | type Checker interface { 11 | GetACL() ACLCache 12 | GetValidatedUser(ctx context.Context) (string, error) 13 | ListResourceGroupNamesOwned(ctx context.Context) (map[string]struct{}, error) 14 | } 15 | -------------------------------------------------------------------------------- /src/model/collector.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "golang.org/x/net/context" 5 | 6 | pb "github.com/nianticlabs/modron/src/proto/generated" 7 | ) 8 | 9 | type Collector interface { 10 | CollectAndStoreAll(ctx context.Context, collectID string, resourceGroupNames []string, preCollectedRgs []*pb.Resource) error 11 | 12 | GetResourceGroupWithIamPolicy(ctx context.Context, collectID string, rgName string) (*pb.Resource, error) 13 | ListResourceGroups(ctx context.Context, rgNames []string) ([]*pb.Resource, error) 14 | ListResourceGroupsWithIamPolicies(ctx context.Context, rgNames []string) ([]*pb.Resource, error) 15 | ListResourceGroupNames(ctx context.Context) ([]string, error) 16 | ListResourceGroupAdmins(ctx context.Context) (ACLCache, error) 17 | ListResourceGroupResources(ctx context.Context, collectID string, rgName string) ([]*pb.Resource, []error) 18 | ListResourceGroupObservations(ctx context.Context, collectID string, rgName string) ([]*pb.Observation, []error) 19 | } 20 | -------------------------------------------------------------------------------- /src/model/engine.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | pb "github.com/nianticlabs/modron/src/proto/generated" 8 | "github.com/nianticlabs/modron/src/risk" 9 | ) 10 | 11 | type Engine interface { 12 | GetChildren(ctx context.Context, parent string) ([]*pb.Resource, error) 13 | GetResource(ctx context.Context, resourceName string) (*pb.Resource, error) 14 | GetHierarchy(ctx context.Context, collectionID string) (map[string]*pb.RecursiveResource, error) 15 | GetTagConfig() risk.TagConfig 16 | 17 | CheckRules(ctx context.Context, scanID string, collectID string, groups []string, preCollectedRGs []*pb.Resource) ([]*pb.Observation, []error) 18 | GetRuleConfig(ctx context.Context, ruleName string) (json.RawMessage, error) 19 | GetRules() []Rule 20 | } 21 | -------------------------------------------------------------------------------- /src/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | 6 | "google.golang.org/protobuf/proto" 7 | 8 | pb "github.com/nianticlabs/modron/src/proto/generated" 9 | ) 10 | 11 | // Rule is the interface that is implemented by the rules. 12 | // A `Rule` takes a resource, checks its observed values against an expected reference value, 13 | // and creates an observation if it identifies a discrepancy, which may include a remediation for resolving it. 14 | type Rule interface { 15 | // Check Performs a rule-dependent check on a resource and, in case it detects an anomaly, 16 | // returns a list of observations. The method MUST return nil in case either it did 17 | // not create any observations or detect any errors. 18 | Check(ctx context.Context, engine Engine, rsrc *pb.Resource) (obs []*pb.Observation, errs []error) 19 | // Info returns the associated RuleInfo data. 20 | Info() *RuleInfo 21 | } 22 | 23 | type RuleInfo struct { 24 | // Human-readable name of the rule, e.g., "EXPOSED_INFRASTRUCTURE_WITH_ADMIN_PRIVILEGES". 25 | Name string 26 | // Types of resource this rule accepts as an input to `Check`. This helps the rule engine 27 | // fetch in advance all the resources the rule needs to perform check(s) against. 28 | AcceptedResourceTypes []proto.Message 29 | } 30 | 31 | type RuleEngine interface { 32 | CheckRules( 33 | ctx context.Context, 34 | scanID string, 35 | collectID string, 36 | resourceGroups []string, 37 | preCollectedRgs []*pb.Resource, 38 | ) (obs []*pb.Observation, errs []error) 39 | GetRules() []Rule 40 | } 41 | -------------------------------------------------------------------------------- /src/model/stateManager.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | pb "github.com/nianticlabs/modron/src/proto/generated" 5 | ) 6 | 7 | type StateManager interface { 8 | GetCollectState(collectID string) pb.RequestStatus 9 | GetScanState(scanID string) pb.RequestStatus 10 | 11 | AddScan(scanID string, resourceGroupNames []string) []string 12 | EndScan(scanID string, resourceGroupNames []string) 13 | 14 | AddCollect(collectID string, resourceGroupNames []string) []string 15 | EndCollect(collectID string, resourceGroupNames []string) 16 | } 17 | -------------------------------------------------------------------------------- /src/model/storage.go: -------------------------------------------------------------------------------- 1 | // Package model is a shared set of models needed 2 | package model 3 | 4 | import ( 5 | "context" 6 | "time" 7 | 8 | pb "github.com/nianticlabs/modron/src/proto/generated" 9 | ) 10 | 11 | type StorageFilter struct { 12 | Limit int 13 | ResourceNames []string 14 | ResourceTypes []string 15 | ResourceGroupNames []string 16 | ResourceIDs []string 17 | ParentNames []string 18 | OperationID string 19 | StartTime time.Time 20 | TimeOffset time.Duration 21 | } 22 | 23 | type Storage interface { 24 | BatchCreateResources(ctx context.Context, resource []*pb.Resource) ([]*pb.Resource, error) 25 | ListResources(ctx context.Context, filter StorageFilter) ([]*pb.Resource, error) 26 | BatchCreateObservations(ctx context.Context, observations []*pb.Observation) ([]*pb.Observation, error) 27 | ListObservations(ctx context.Context, filter StorageFilter) ([]*pb.Observation, error) 28 | GetChildrenOfResource(ctx context.Context, collectID string, parentResourceName string, resourceType *string) (map[string]*pb.RecursiveResource, error) 29 | 30 | AddOperationLog(ctx context.Context, ops []*pb.Operation) error 31 | FlushOpsLog(ctx context.Context) error 32 | PurgeIncompleteOperations(ctx context.Context) error 33 | } 34 | -------------------------------------------------------------------------------- /src/nagatha/convert.go: -------------------------------------------------------------------------------- 1 | package nagatha 2 | 3 | import ( 4 | "github.com/nianticlabs/modron/src/model" 5 | "github.com/nianticlabs/modron/src/proto/generated/nagatha" 6 | 7 | "google.golang.org/protobuf/types/known/timestamppb" 8 | ) 9 | 10 | func exceptionModelFromNagathaProto(ex *nagatha.Exception) model.Exception { 11 | return model.Exception{ 12 | UUID: ex.Uuid, 13 | SourceSystem: ex.SourceSystem, 14 | UserEmail: ex.UserEmail, 15 | NotificationName: ex.NotificationName, 16 | Justification: ex.Justification, 17 | CreatedOn: ex.CreatedOnTime.AsTime(), 18 | ValidUntil: ex.ValidUntilTime.AsTime(), 19 | } 20 | } 21 | 22 | func exceptionNagathaProtoFromModel(ex model.Exception) *nagatha.Exception { 23 | return &nagatha.Exception{ 24 | Uuid: ex.UUID, 25 | SourceSystem: ex.SourceSystem, 26 | UserEmail: ex.UserEmail, 27 | NotificationName: ex.NotificationName, 28 | Justification: ex.Justification, 29 | CreatedOnTime: timestamppb.New(ex.CreatedOn), 30 | ValidUntilTime: timestamppb.New(ex.ValidUntil), 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/nagatha/notification.go: -------------------------------------------------------------------------------- 1 | package nagatha 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/nianticlabs/modron/src/model" 7 | pb "github.com/nianticlabs/modron/src/proto/generated" 8 | "github.com/nianticlabs/modron/src/proto/generated/nagatha" 9 | ) 10 | 11 | func NotificationFromObservation(contact string, interval time.Duration, obs *pb.Observation) model.Notification { 12 | return model.Notification{ 13 | SourceSystem: "modron", 14 | Name: obs.Name, 15 | Recipient: contact, 16 | Content: formatNotificationContent(obs), 17 | Interval: interval, 18 | } 19 | } 20 | 21 | func notificationFromProto(p *nagatha.Notification) model.Notification { 22 | return model.Notification{ 23 | UUID: p.Uuid, 24 | SourceSystem: p.SourceSystem, 25 | Name: p.Name, 26 | Recipient: p.Recipient, 27 | Content: p.Content, 28 | CreatedOn: p.CreatedOn.AsTime(), 29 | SentOn: p.SentOn.AsTime(), 30 | Interval: p.Interval.AsDuration(), 31 | } 32 | } 33 | 34 | func formatNotificationContent(obs *pb.Observation) string { 35 | var out string 36 | out += obs.Remediation.Description + "\n\n" 37 | out += obs.Remediation.Recommendation + " \n \n" 38 | return out 39 | } 40 | -------------------------------------------------------------------------------- /src/nagatha/notification_test.go: -------------------------------------------------------------------------------- 1 | package nagatha_test 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/gogo/protobuf/proto" 8 | "github.com/google/go-cmp/cmp" 9 | "google.golang.org/protobuf/types/known/timestamppb" 10 | 11 | "github.com/nianticlabs/modron/src/model" 12 | "github.com/nianticlabs/modron/src/nagatha" 13 | pb "github.com/nianticlabs/modron/src/proto/generated" 14 | ) 15 | 16 | func TestNotificationFromObservation(t *testing.T) { 17 | pbObs := pb.Observation{ 18 | Uid: "47fc94f3-b6e9-4ae5-b719-c8dd2157744b", 19 | ScanUid: proto.String("2f86b47d-a386-4f4e-88a1-786f2a572ad8"), 20 | Timestamp: timestamppb.New(time.Now()), 21 | ResourceRef: &pb.ResourceRef{ 22 | Uid: proto.String("79bc4bd2-a454-4837-8a09-0d769cb36d0f"), 23 | GroupName: "projects/some-project", 24 | }, 25 | Name: "DATABASE_ALLOWS_UNENCRYPTED_CONNECTIONS", 26 | Remediation: &pb.Remediation{ 27 | Description: "Database example-psql allows for unencrypted connections.", 28 | Recommendation: "Enable the require SSL setting in the database settings to allow only encrypted connections to example-psql.", 29 | }, 30 | } 31 | 32 | want := model.Notification{ 33 | SourceSystem: "modron", 34 | Name: "DATABASE_ALLOWS_UNENCRYPTED_CONNECTIONS", 35 | Content: "Database example-psql allows for unencrypted connections.\n\nEnable the require SSL setting in the database settings to allow only encrypted connections to example-psql. \n \n", 36 | Recipient: "test@example.com", 37 | Interval: 24 * time.Hour, 38 | } 39 | got := nagatha.NotificationFromObservation("test@example.com", 24*time.Hour, &pbObs) 40 | if diff := cmp.Diff(&got, &want); diff != "" { 41 | t.Errorf("NotificationFromObservation() mismatch (-got +want):\n%s", diff) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/proto/.gitignore: -------------------------------------------------------------------------------- 1 | generated/* 2 | !generated/go.mod 3 | !generated/go.sum 4 | -------------------------------------------------------------------------------- /src/proto/generated/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nianticlabs/modron/src/proto/generated 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | cloud.google.com/go/longrunning v0.6.1 7 | google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 8 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 9 | google.golang.org/grpc v1.67.1 10 | google.golang.org/protobuf v1.35.1 11 | k8s.io/api v0.31.2 12 | k8s.io/apimachinery v0.31.2 13 | ) 14 | 15 | require ( 16 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 17 | github.com/go-logr/logr v1.4.2 // indirect 18 | github.com/gogo/protobuf v1.3.2 // indirect 19 | github.com/google/gofuzz v1.2.0 // indirect 20 | github.com/json-iterator/go v1.1.12 // indirect 21 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 22 | github.com/modern-go/reflect2 v1.0.2 // indirect 23 | github.com/x448/float16 v0.8.4 // indirect 24 | golang.org/x/net v0.30.0 // indirect 25 | golang.org/x/sys v0.26.0 // indirect 26 | golang.org/x/text v0.19.0 // indirect 27 | gopkg.in/inf.v0 v0.9.1 // indirect 28 | gopkg.in/yaml.v2 v2.4.0 // indirect 29 | k8s.io/klog/v2 v2.130.1 // indirect 30 | k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 // indirect 31 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 32 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 33 | ) 34 | -------------------------------------------------------------------------------- /src/proto/notification.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | import "google/protobuf/empty.proto"; 4 | import "google/protobuf/field_mask.proto"; 5 | import "google/protobuf/timestamp.proto"; 6 | 7 | option go_package = "./"; 8 | 9 | message NotificationException { 10 | string uuid = 1; 11 | string source_system = 2; 12 | string user_email = 3; 13 | string notification_name = 4; 14 | string justification = 5; 15 | google.protobuf.Timestamp created_on_time = 6; 16 | google.protobuf.Timestamp valid_until_time = 7; 17 | } 18 | 19 | service NotificationService { 20 | rpc GetNotificationException(GetNotificationExceptionRequest) returns (NotificationException); 21 | rpc CreateNotificationException(CreateNotificationExceptionRequest)returns (NotificationException); 22 | rpc UpdateNotificationException(UpdateNotificationExceptionRequest) returns (NotificationException); 23 | rpc DeleteNotificationException(DeleteNotificationExceptionRequest) returns (google.protobuf.Empty); 24 | rpc ListNotificationExceptions(ListNotificationExceptionsRequest) returns (ListNotificationExceptionsResponse); 25 | 26 | } 27 | message GetNotificationExceptionRequest { 28 | string uuid = 1; 29 | } 30 | 31 | message CreateNotificationExceptionRequest { 32 | NotificationException exception = 1; 33 | } 34 | 35 | message UpdateNotificationExceptionRequest { 36 | NotificationException exception = 1; 37 | 38 | google.protobuf.FieldMask update_mask = 2; 39 | } 40 | 41 | message DeleteNotificationExceptionRequest { 42 | string uuid = 1; 43 | } 44 | 45 | message ListNotificationExceptionsRequest { 46 | string user_email = 1; 47 | 48 | int32 page_size = 2; 49 | 50 | string page_token = 3; 51 | } 52 | 53 | message ListNotificationExceptionsResponse { 54 | repeated NotificationException exceptions = 1; 55 | 56 | string next_page_token = 2; 57 | } 58 | -------------------------------------------------------------------------------- /src/service/mock_notifier_test.go: -------------------------------------------------------------------------------- 1 | package service_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | 8 | "github.com/nianticlabs/modron/src/model" 9 | ) 10 | 11 | type mockNotifier struct { 12 | notificationLock sync.Mutex 13 | notifications []model.Notification 14 | } 15 | 16 | func (m *mockNotifier) BatchCreateNotifications(ctx context.Context, notifications []model.Notification) ([]model.Notification, error) { 17 | var createdNotifications []model.Notification 18 | var errArr []error 19 | for _, n := range notifications { 20 | created, err := m.CreateNotification(ctx, n) 21 | if err != nil { 22 | errArr = append(errArr, err) 23 | continue 24 | } 25 | createdNotifications = append(createdNotifications, created) 26 | } 27 | return createdNotifications, errors.Join(errArr...) 28 | } 29 | 30 | func (m *mockNotifier) CreateNotification(_ context.Context, notification model.Notification) (model.Notification, error) { 31 | m.notificationLock.Lock() 32 | defer m.notificationLock.Unlock() 33 | m.notifications = append(m.notifications, notification) 34 | return notification, nil 35 | } 36 | 37 | func (m *mockNotifier) GetException(context.Context, string) (model.Exception, error) { 38 | panic("implement me") 39 | } 40 | 41 | func (m *mockNotifier) CreateException(context.Context, model.Exception) (model.Exception, error) { 42 | panic("implement me") 43 | } 44 | 45 | func (m *mockNotifier) UpdateException(context.Context, model.Exception) (model.Exception, error) { 46 | panic("implement me") 47 | } 48 | 49 | func (m *mockNotifier) DeleteException(context.Context, string) error { 50 | panic("implement me") 51 | } 52 | 53 | func (m *mockNotifier) ListExceptions(context.Context, string, int32, string) ([]model.Exception, error) { 54 | panic("implement me") 55 | } 56 | 57 | var _ model.NotificationService = (*mockNotifier)(nil) 58 | 59 | func newMockNotifier() *mockNotifier { 60 | return &mockNotifier{} 61 | } 62 | 63 | func (m *mockNotifier) getNotifications() []model.Notification { 64 | // Clone the notifications 65 | m.notificationLock.Lock() 66 | defer m.notificationLock.Unlock() 67 | notifications := make([]model.Notification, len(m.notifications)) 68 | copy(notifications, m.notifications) 69 | return notifications 70 | } 71 | -------------------------------------------------------------------------------- /src/storage/gormstorage/gorm_integration_test.go: -------------------------------------------------------------------------------- 1 | //go:build integration 2 | 3 | package gormstorage 4 | 5 | import ( 6 | "context" 7 | "testing" 8 | "time" 9 | 10 | _ "github.com/lib/pq" 11 | "github.com/testcontainers/testcontainers-go" 12 | "github.com/testcontainers/testcontainers-go/modules/postgres" 13 | "github.com/testcontainers/testcontainers-go/wait" 14 | 15 | "github.com/nianticlabs/modron/src/model" 16 | "github.com/nianticlabs/modron/src/storage/test" 17 | ) 18 | 19 | func getPostgresDB(ctx context.Context, t *testing.T) (*postgres.PostgresContainer, error) { 20 | t.Helper() 21 | return postgres.Run(ctx, "postgres:16-alpine", 22 | postgres.WithDatabase("modron"), 23 | testcontainers.WithLogger(testcontainers.TestLogger(t)), 24 | testcontainers.WithWaitStrategy( 25 | wait.ForAll( 26 | wait.ForLog("database system is ready to accept connections"). 27 | WithOccurrence(2). 28 | WithStartupTimeout(6*time.Second), 29 | wait.ForListeningPort("5432"), 30 | ), 31 | ), 32 | testcontainers.WithHostPortAccess(5432), 33 | ) 34 | } 35 | 36 | func newPostgresTestDb(ctx context.Context, t *testing.T) model.Storage { 37 | t.Helper() 38 | pgDb, err := getPostgresDB(ctx, t) 39 | if err != nil { 40 | t.Fatalf("unable to create postgres container: %v", err) 41 | } 42 | connStr, err := pgDb.ConnectionString(ctx) 43 | if err != nil { 44 | t.Fatalf("unable to get connection string: %v", err) 45 | } 46 | st, err := NewPostgres(Config{ 47 | BatchSize: 10, 48 | }, connStr) 49 | if err != nil { 50 | t.Fatalf("failed to create storage: %v", err) 51 | } 52 | return st 53 | } 54 | 55 | func TestPostgresStorageResource(t *testing.T) { 56 | test.StorageResource(t, newPostgresTestDb(context.Background(), t)) 57 | } 58 | 59 | func TestPostgresStorageObservation(t *testing.T) { 60 | test.StorageObservation(t, newPostgresTestDb(context.Background(), t)) 61 | } 62 | 63 | func TestPostgresStorageListObservationsActive(t *testing.T) { 64 | test.StorageListObservations2(t, newPostgresTestDb(context.Background(), t)) 65 | } 66 | -------------------------------------------------------------------------------- /src/storage/gormstorage/gorm_test.go: -------------------------------------------------------------------------------- 1 | package gormstorage 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nianticlabs/modron/src/model" 7 | "github.com/nianticlabs/modron/src/storage/test" 8 | storageutils "github.com/nianticlabs/modron/src/storage/utils" 9 | ) 10 | 11 | func newTestDb(t *testing.T) model.Storage { 12 | st, err := NewSQLite(Config{ 13 | BatchSize: 100, 14 | LogAllQueries: true, 15 | }, storageutils.GetSqliteMemoryDbPath()) 16 | if err != nil { 17 | t.Fatalf("failed to create storage: %v", err) 18 | } 19 | return st 20 | } 21 | 22 | func TestStorageResource(t *testing.T) { 23 | test.StorageResource(t, newTestDb(t)) 24 | } 25 | 26 | func TestStorageObservation(t *testing.T) { 27 | test.StorageObservation(t, newTestDb(t)) 28 | } 29 | 30 | func TestStorageListObservationsActive(t *testing.T) { 31 | test.StorageListObservations2(t, newTestDb(t)) 32 | } 33 | -------------------------------------------------------------------------------- /src/storage/gormstorage/impact.go: -------------------------------------------------------------------------------- 1 | package gormstorage 2 | 3 | import ( 4 | "database/sql" 5 | "database/sql/driver" 6 | "fmt" 7 | 8 | pb "github.com/nianticlabs/modron/src/proto/generated" 9 | ) 10 | 11 | type Impact pb.Impact 12 | 13 | func (i *Impact) Scan(src any) error { 14 | str, ok := src.(string) 15 | if !ok { 16 | return fmt.Errorf("expected string, got %T", src) 17 | } 18 | v, ok := pb.Impact_value[str] 19 | if !ok { 20 | return fmt.Errorf("invalid Impact: %q", str) 21 | } 22 | *i = Impact(v) 23 | return nil 24 | } 25 | 26 | func (i Impact) Value() (driver.Value, error) { 27 | return pb.Impact_name[int32(i)], nil 28 | } 29 | 30 | var _ sql.Scanner = (*Impact)(nil) 31 | var _ driver.Valuer = (*Impact)(nil) 32 | -------------------------------------------------------------------------------- /src/storage/gormstorage/observation_category.go: -------------------------------------------------------------------------------- 1 | package gormstorage 2 | 3 | import ( 4 | "database/sql" 5 | "database/sql/driver" 6 | "fmt" 7 | 8 | pb "github.com/nianticlabs/modron/src/proto/generated" 9 | ) 10 | 11 | type ObservationCategory pb.Observation_Category 12 | 13 | func (o ObservationCategory) Value() (driver.Value, error) { 14 | return pb.Observation_Category_name[int32(o)], nil 15 | } 16 | 17 | func (o *ObservationCategory) Scan(src any) error { 18 | str, ok := src.(string) 19 | if !ok { 20 | return fmt.Errorf("expected string, got %T", src) 21 | } 22 | v, ok := pb.Observation_Category_value[str] 23 | if !ok { 24 | return fmt.Errorf("invalid ObservationCategory: %q", str) 25 | } 26 | *o = ObservationCategory(v) 27 | return nil 28 | } 29 | 30 | var _ sql.Scanner = (*ObservationCategory)(nil) 31 | var _ driver.Valuer = (*ObservationCategory)(nil) 32 | -------------------------------------------------------------------------------- /src/storage/gormstorage/observation_source.go: -------------------------------------------------------------------------------- 1 | package gormstorage 2 | 3 | import ( 4 | "database/sql" 5 | "database/sql/driver" 6 | "fmt" 7 | 8 | pb "github.com/nianticlabs/modron/src/proto/generated" 9 | ) 10 | 11 | type ObservationSource pb.Observation_Source 12 | 13 | func (o ObservationSource) Value() (driver.Value, error) { 14 | return pb.Observation_Source_name[int32(o)], nil 15 | } 16 | 17 | func (o *ObservationSource) Scan(src any) error { 18 | str, ok := src.(string) 19 | if !ok { 20 | return fmt.Errorf("expected string, got %T", src) 21 | } 22 | v, ok := pb.Observation_Source_value[str] 23 | if !ok { 24 | return fmt.Errorf("invalid ObservationSource: %q", str) 25 | } 26 | *o = ObservationSource(v) 27 | return nil 28 | } 29 | 30 | var _ sql.Scanner = (*ObservationSource)(nil) 31 | var _ driver.Valuer = (*ObservationSource)(nil) 32 | -------------------------------------------------------------------------------- /src/storage/gormstorage/operation.go: -------------------------------------------------------------------------------- 1 | package gormstorage 2 | 3 | import ( 4 | "database/sql/driver" 5 | "fmt" 6 | "time" 7 | 8 | pb "github.com/nianticlabs/modron/src/proto/generated" 9 | ) 10 | 11 | var _ driver.Valuer = (*OperationStatus)(nil) 12 | 13 | type OperationStatus pb.Operation_Status 14 | 15 | func (o OperationStatus) Value() (driver.Value, error) { 16 | return pb.Operation_Status(o).String(), nil 17 | } 18 | 19 | func (o *OperationStatus) Scan(src interface{}) error { 20 | str, ok := src.(string) 21 | if !ok { 22 | return fmt.Errorf("expected string, got %T", src) 23 | } 24 | v, ok := pb.Operation_Status_value[str] 25 | if !ok { 26 | return fmt.Errorf("invalid OperationStatus: %q", str) 27 | } 28 | *o = OperationStatus(v) 29 | return nil 30 | } 31 | 32 | type Operation struct { 33 | ID string `gorm:"column:operationid;primaryKey;index:operations_idx"` 34 | ResourceGroup string `gorm:"column:resourcegroupname;index;primaryKey;index:operations_idx;index:operations_rgname_endtime"` 35 | OpsType string `gorm:"column:opstype;index;primaryKey;index:operations_idx"` 36 | StartTime time.Time `gorm:"column:starttime;index"` 37 | EndTime *time.Time `gorm:"column:endtime;index;index:operations_idx;index:operations_rgname_endtime"` 38 | Status OperationStatus `gorm:"index;index:operations_idx"` 39 | Reason string 40 | } 41 | -------------------------------------------------------------------------------- /src/storage/gormstorage/severity.go: -------------------------------------------------------------------------------- 1 | package gormstorage 2 | 3 | import ( 4 | pb "github.com/nianticlabs/modron/src/proto/generated" 5 | ) 6 | 7 | // SeverityScore represents a score for the severity - this number is in the 0.0 - 10.0 range. 8 | type SeverityScore float32 9 | 10 | const ( 11 | SeverityLowMax = 3.9 12 | SeverityMediumMax = 6.9 13 | SeverityHighMax = 8.9 14 | 15 | SeverityMin = 0.0 16 | SeverityMax = 10.0 17 | ) 18 | 19 | func (s SeverityScore) ToSeverity() pb.Severity { 20 | if s < 0 { 21 | // This should never happen (SeverityScore should be nil if anything) 22 | return pb.Severity_SEVERITY_UNKNOWN 23 | } 24 | if s == SeverityMin { 25 | return pb.Severity_SEVERITY_INFO 26 | } 27 | if s <= SeverityLowMax { 28 | return pb.Severity_SEVERITY_LOW 29 | } 30 | if s <= SeverityMediumMax { 31 | return pb.Severity_SEVERITY_MEDIUM 32 | } 33 | if s <= SeverityHighMax { 34 | return pb.Severity_SEVERITY_HIGH 35 | } 36 | return pb.Severity_SEVERITY_CRITICAL 37 | } 38 | 39 | func FromSeverityPb(severity pb.Severity) *SeverityScore { 40 | switch severity { 41 | case pb.Severity_SEVERITY_INFO: 42 | info := SeverityScore(0.0) 43 | return &info 44 | case pb.Severity_SEVERITY_LOW: 45 | low := SeverityScore(SeverityLowMax) 46 | return &low 47 | case pb.Severity_SEVERITY_MEDIUM: 48 | medium := SeverityScore(SeverityMediumMax) 49 | return &medium 50 | case pb.Severity_SEVERITY_HIGH: 51 | high := SeverityScore(SeverityHighMax) 52 | return &high 53 | case pb.Severity_SEVERITY_CRITICAL: 54 | critical := SeverityScore(SeverityMax) 55 | return &critical 56 | } 57 | unknownScore := SeverityScore(-1) 58 | return &unknownScore 59 | } 60 | -------------------------------------------------------------------------------- /src/storage/memstorage/memstorage.go: -------------------------------------------------------------------------------- 1 | package memstorage 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/sirupsen/logrus" 7 | 8 | "github.com/nianticlabs/modron/src/model" 9 | "github.com/nianticlabs/modron/src/storage/gormstorage" 10 | storageutils "github.com/nianticlabs/modron/src/storage/utils" 11 | ) 12 | 13 | const DefaultBatchSize = 100 14 | 15 | var logger = logrus.StandardLogger() 16 | 17 | func New() model.Storage { 18 | dbPath := storageutils.GetSqliteMemoryDbPath() 19 | logger.Debugf("Using SQLite storage with path: %s", dbPath) 20 | st, err := gormstorage.NewSQLite(gormstorage.Config{ 21 | BatchSize: DefaultBatchSize, 22 | LogAllQueries: os.Getenv("LOG_ALL_SQL_QUERIES") == "true", 23 | }, dbPath) 24 | if err != nil { 25 | // It's fine to panic here, memstorage should only be used in tests 26 | panic(err) 27 | } 28 | return st 29 | } 30 | -------------------------------------------------------------------------------- /src/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | type Type string 4 | 5 | const ( 6 | SQL Type = "sql" 7 | Memory Type = "memory" 8 | ) 9 | -------------------------------------------------------------------------------- /src/storage/utils/utils.go: -------------------------------------------------------------------------------- 1 | package storageutils 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | func GetSqliteMemoryDbPath() string { 11 | debugDbPath := os.Getenv("DEBUG_DB_PATH") 12 | if debugDbPath != "" { 13 | return debugDbPath 14 | } 15 | // We use an uniqueID, so that two tests running in parallel do not conflict with each other 16 | uniqueID := uuid.NewString() 17 | // Do not use `:memory:` here! https://github.com/mattn/go-sqlite3/issues/204 18 | return fmt.Sprintf("file:%s?mode=memory&cache=shared", uniqueID) 19 | } 20 | -------------------------------------------------------------------------------- /src/test/fake_notification_service.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import pb "github.com/nianticlabs/modron/src/proto/generated" 4 | 5 | func New() pb.NotificationServiceServer { 6 | return newFakeServer() 7 | } 8 | 9 | type FakeNotificationService struct { 10 | // Required 11 | pb.UnimplementedNotificationServiceServer 12 | } 13 | 14 | func newFakeServer() pb.NotificationServiceServer { 15 | return FakeNotificationService{} 16 | } 17 | -------------------------------------------------------------------------------- /src/test/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nianticlabs/modron/src/e2e_test 2 | 3 | go 1.23.2 4 | 5 | replace github.com/nianticlabs/modron/src/proto/generated => ../proto/generated 6 | 7 | require ( 8 | github.com/google/go-cmp v0.6.0 9 | google.golang.org/grpc v1.67.1 10 | google.golang.org/protobuf v1.35.1 11 | github.com/nianticlabs/modron/src/proto/generated v0.0.0-00010101000000-000000000000 12 | ) 13 | 14 | require ( 15 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 16 | github.com/go-logr/logr v1.4.2 // indirect 17 | github.com/gogo/protobuf v1.3.2 // indirect 18 | github.com/google/gofuzz v1.2.0 // indirect 19 | github.com/json-iterator/go v1.1.12 // indirect 20 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 21 | github.com/modern-go/reflect2 v1.0.2 // indirect 22 | github.com/x448/float16 v0.8.4 // indirect 23 | golang.org/x/net v0.30.0 // indirect 24 | golang.org/x/sys v0.26.0 // indirect 25 | golang.org/x/text v0.19.0 // indirect 26 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect 27 | gopkg.in/inf.v0 v0.9.1 // indirect 28 | gopkg.in/yaml.v2 v2.4.0 // indirect 29 | k8s.io/api v0.31.2 // indirect 30 | k8s.io/apimachinery v0.31.2 // indirect 31 | k8s.io/klog/v2 v2.130.1 // indirect 32 | k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 // indirect 33 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 34 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /src/ui/.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/.angular -------------------------------------------------------------------------------- /src/ui/.gcloudignore: -------------------------------------------------------------------------------- 1 | *~ 2 | **/node_modules 3 | **/.angular -------------------------------------------------------------------------------- /src/ui/.gitignore: -------------------------------------------------------------------------------- 1 | .yarn 2 | -------------------------------------------------------------------------------- /src/ui/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /src/ui/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GOVERSION=1.23 2 | ARG NODE_VERSION=20 3 | 4 | FROM node:${NODE_VERSION}-alpine AS ui_builder 5 | WORKDIR /app 6 | COPY ./src/ui/client/ . 7 | RUN npm install 8 | RUN npm run build 9 | 10 | FROM golang:${GOVERSION} AS server_builder 11 | ENV GOPATH=/go 12 | WORKDIR /app 13 | COPY ./src/ui/go.* ./ 14 | RUN go mod download 15 | COPY ./src/ui/ ./ 16 | RUN CGO_ENABLED=0 go build -v -o modron-ui-server 17 | 18 | FROM alpine:latest AS ca-certificates_builder 19 | RUN apk add --no-cache ca-certificates 20 | 21 | # FROM scratch 22 | WORKDIR /app 23 | # COPY --from=ca-certificates_builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 24 | COPY --from=ui_builder /app/dist/ . 25 | COPY --from=server_builder /app/modron-ui-server . 26 | USER 101:101 27 | EXPOSE 8080 28 | ENTRYPOINT ["/app/modron-ui-server", "-logtostderr"] 29 | -------------------------------------------------------------------------------- /src/ui/README.md: -------------------------------------------------------------------------------- 1 | # Modron UI 2 | User interface for the Modron service. 3 | 4 | ## Dependencies 5 | - Docker 6 | - Node.js LTS 7 | - Angular CLI 8 | 9 | ## How to run 10 | 11 | ```bash 12 | npm run dev # Run UI with mock gRPC server and envoy proxy 13 | ``` 14 | 15 | Then navigate to `localhost:4200`. 16 | 17 | 18 | ## Developing on MacOS with [Lima](https://github.com/lima-vm/lima) 19 | 20 | ### Frontend with `mock-grcp-server` 21 | 22 | When you're developing against the `mock-grpc-server` (`npm run dev`), this will be the setup: 23 | 24 | ```mermaid 25 | flowchart LR 26 | subgraph Host 27 | frontend["frontend"] 28 | webpack_proxy["webpack-proxy"] 29 | end 30 | subgraph "Docker Host" 31 | subgraph docker 32 | envoy 33 | mgs["mock-grpc-server"] 34 | end 35 | end 36 | 37 | frontend -- 127.0.0.1:4000 --> webpack_proxy 38 | webpack_proxy -- 127.0.0.1:4201 --> envoy 39 | envoy --> mgs 40 | ``` 41 | 42 | ### Frontend with Modron as a backend 43 | 44 | 1. Start the backend (either with `docker-compose.yml` or via `go run ./src`) and make sure it's listening on `:4201` 45 | 1. In this directory (`src/ui`), run `npm run dev:client` 46 | -------------------------------------------------------------------------------- /src/ui/client/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | -------------------------------------------------------------------------------- /src/ui/client/.dockerignore: -------------------------------------------------------------------------------- 1 | /cypress 2 | !/cypress/e2e 3 | !/cypress/components 4 | !/cypress/tsconfig.json 5 | /dist 6 | /results 7 | -------------------------------------------------------------------------------- /src/ui/client/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = double 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = true 17 | -------------------------------------------------------------------------------- /src/ui/client/.eslintignore: -------------------------------------------------------------------------------- 1 | /src/proto/** 2 | -------------------------------------------------------------------------------- /src/ui/client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "projects/**/*", 5 | "dist/**/*" 6 | ], 7 | "overrides": [ 8 | { 9 | "files": [ 10 | "*.ts" 11 | ], 12 | "extends": [ 13 | "eslint:recommended", 14 | "plugin:@typescript-eslint/recommended", 15 | "plugin:@angular-eslint/recommended", 16 | "plugin:@angular-eslint/template/process-inline-templates" 17 | ], 18 | "rules": { 19 | "@angular-eslint/directive-selector": [ 20 | "error", 21 | { 22 | "type": "attribute", 23 | "prefix": "app", 24 | "style": "camelCase" 25 | } 26 | ], 27 | "@angular-eslint/component-selector": [ 28 | "error", 29 | { 30 | "type": "element", 31 | "prefix": "app", 32 | "style": "kebab-case" 33 | } 34 | ], 35 | "@typescript-eslint/quotes": [ 36 | "error", 37 | "double" 38 | ], 39 | "@typescript-eslint/no-explicit-any": [ 40 | "warn" 41 | ] 42 | } 43 | }, 44 | { 45 | "files": [ 46 | "*.html" 47 | ], 48 | "extends": [ 49 | "plugin:@angular-eslint/template/recommended" 50 | ], 51 | "rules": {} 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /src/ui/client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-bookworm-slim 2 | RUN apt-get update && apt-get install -y gnupg wget 3 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - 4 | RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' 5 | RUN apt-get update && apt-get install -y google-chrome-stable 6 | 7 | WORKDIR /usr/src/app 8 | COPY . . 9 | RUN chown -R node:node /usr/src/app 10 | ENV CHROME_BIN=/usr/bin/google-chrome 11 | ENV USER=node 12 | ENV PATH="/home/node/.npm-global/bin:${PATH}" 13 | ENV NPM_CONFIG_PREFIX="/home/node/.npm-global" 14 | USER "${USER}" 15 | 16 | RUN mkdir -p "${NPM_CONFIG_PREFIX}/lib" 17 | COPY package.json package-lock.json ./ 18 | RUN npm ci 19 | 20 | ENTRYPOINT npm run test:ci 21 | -------------------------------------------------------------------------------- /src/ui/client/Dockerfile.e2e: -------------------------------------------------------------------------------- 1 | FROM cypress/base:20.9.0 2 | WORKDIR /app 3 | COPY ./src/ui/client/package.json . 4 | COPY ./src/ui/client/package-lock.json . 5 | ENV CI=1 6 | RUN npm ci 7 | RUN npx cypress verify 8 | -------------------------------------------------------------------------------- /src/ui/client/README.md: -------------------------------------------------------------------------------- 1 | # Ui 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 14.0.5. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 28 | -------------------------------------------------------------------------------- /src/ui/client/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress" 2 | 3 | export default defineConfig({ 4 | 5 | e2e: { 6 | baseUrl: "http://localhost:8080", 7 | supportFile: false 8 | }, 9 | video: false, 10 | screenshotOnRunFailure: false, 11 | component: { 12 | devServer: { 13 | framework: "angular", 14 | bundler: "webpack", 15 | }, 16 | specPattern: "**/*.cy.ts" 17 | }, 18 | 19 | reporter: "junit", 20 | reporterOptions: { 21 | mochaFile: "/app/results/modron-e2e-ui-junit.xml", 22 | toConsole: false, 23 | }, 24 | 25 | }) 26 | -------------------------------------------------------------------------------- /src/ui/client/cypress/.gitignore: -------------------------------------------------------------------------------- 1 | /screenshots 2 | /videos 3 | -------------------------------------------------------------------------------- /src/ui/client/cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /src/ui/client/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["**/*.ts"], 4 | "compilerOptions": { 5 | "sourceMap": false, 6 | "types": ["cypress"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/ui/client/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage'), 13 | require('karma-junit-reporter'), 14 | require('@angular-devkit/build-angular/plugins/karma') 15 | ], 16 | client: { 17 | jasmine: { 18 | // you can add configuration options for Jasmine here 19 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 20 | // for example, you can disable the random execution with `random: false` 21 | // or set a specific seed with `seed: 4321` 22 | }, 23 | clearContext: false // leave Jasmine Spec Runner output visible in browser 24 | }, 25 | jasmineHtmlReporter: { 26 | suppressAll: true // removes the duplicated traces 27 | }, 28 | coverageReporter: { 29 | dir: require('path').join(__dirname, './coverage/ui'), 30 | subdir: '.', 31 | reporters: [ 32 | { type: 'html' }, 33 | { type: 'text-summary' }, 34 | { type: 'cobertura', file: 'reports/modron.cobertura.xml' } 35 | ] 36 | }, 37 | junitReporter: { 38 | outputDir: 'reports', 39 | outputFile: 'modron.junit.xml', 40 | useBrowserName: false, 41 | }, 42 | reporters: ['progress', 'kjhtml', 'junit'], 43 | port: 9876, 44 | colors: true, 45 | logLevel: config.LOG_INFO, 46 | autoWatch: true, 47 | browsers: ['Chrome', 'ChromeHeadless', 'ChromeHeadlessCI'], 48 | customLaunchers: { 49 | ChromeHeadlessCI: { 50 | base: 'ChromeHeadless', 51 | flags: ['--no-sandbox'] 52 | } 53 | }, 54 | singleRun: false, 55 | restartOnFileChange: true 56 | }); 57 | }; 58 | -------------------------------------------------------------------------------- /src/ui/client/src/.gitignore: -------------------------------------------------------------------------------- 1 | proto/ 2 | !proto/.gitkeep 3 | -------------------------------------------------------------------------------- /src/ui/client/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from "@angular/core" 2 | import { RouterModule, Routes } from "@angular/router" 3 | import { ModronAppComponent } from "./modron-app/modron-app.component" 4 | import { NotificationExceptionFormComponent } from "./notification-exception-form/notification-exception-form.component" 5 | import { NotificationExceptionsComponent } from "./notification-exceptions/notification-exceptions.component" 6 | import { ResourceGroupDetailsComponent } from "./resource-group-details/resource-group-details.component" 7 | import { ResourceGroupsComponent } from "./resource-groups/resource-groups.component" 8 | import { StatsComponent } from "./stats/stats.component" 9 | import {UIDemoComponent} from "./ui-demo/ui-demo.component"; 10 | 11 | const routes: Routes = [ 12 | { 13 | path: "modron", 14 | component: ModronAppComponent, 15 | children: [ 16 | { path: "resourcegroups", component: ResourceGroupsComponent }, 17 | { path: "resourcegroup/:id", component: ResourceGroupDetailsComponent }, 18 | { path: "stats", component: StatsComponent }, 19 | { path: "exceptions", component: NotificationExceptionsComponent }, 20 | { 21 | path: "exceptions/:notificationName", 22 | component: NotificationExceptionsComponent, 23 | }, 24 | { 25 | path: "exceptions/new/:notificationName", 26 | component: NotificationExceptionFormComponent, 27 | }, 28 | { 29 | path: "ui-demo", 30 | component: UIDemoComponent, 31 | } 32 | ], 33 | }, 34 | 35 | // otherwise redirect to home 36 | { path: "**", redirectTo: "modron/resourcegroups" }, 37 | ] 38 | 39 | @NgModule({ 40 | imports: [RouterModule.forRoot(routes, { 41 | anchorScrolling: "enabled" 42 | })], 43 | exports: [RouterModule], 44 | }) 45 | export class AppRoutingModule { } 46 | -------------------------------------------------------------------------------- /src/ui/client/src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | An unexpected error has occurred while authenticating. Please try to refresh 6 | the page in a couple of seconds. 7 | 8 | -------------------------------------------------------------------------------- /src/ui/client/src/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/client/src/app/app.component.scss -------------------------------------------------------------------------------- /src/ui/client/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from "@angular/core/testing"; 2 | import { RouterTestingModule } from "@angular/router/testing"; 3 | import { AppComponent } from "./app.component"; 4 | import { AuthenticationStore } from "./state/authentication.store"; 5 | import { ModronStore } from "./state/modron.store"; 6 | import { NotificationStore } from "./state/notification.store"; 7 | 8 | describe("AppComponent", () => { 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [RouterTestingModule], 12 | declarations: [AppComponent], 13 | providers: [AuthenticationStore, NotificationStore, ModronStore], 14 | }).compileComponents(); 15 | }); 16 | 17 | it("should create the app", () => { 18 | const fixture = TestBed.createComponent(AppComponent); 19 | const app = fixture.componentInstance; 20 | expect(app).toBeTruthy(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/ui/client/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "@angular/core" 2 | import { environment } from "src/environments/environment" 3 | import { AuthenticationStore } from "./state/authentication.store" 4 | import { ModronStore } from "./state/modron.store" 5 | import { NotificationStore } from "./state/notification.store" 6 | 7 | @Component({ 8 | selector: "app-root", 9 | templateUrl: "./app.component.html", 10 | styleUrls: ["./app.component.scss"], 11 | }) 12 | export class AppComponent { 13 | constructor( 14 | public auth: AuthenticationStore, 15 | public modron: ModronStore, 16 | public notification: NotificationStore 17 | ) { } 18 | 19 | get local(): boolean { 20 | return environment.local 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/client/src/app/authentication.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from "@angular/core/testing"; 2 | 3 | import { AuthenticationService } from "./authentication.service"; 4 | 5 | describe("AuthenticationServiceService", () => { 6 | let service: AuthenticationService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(AuthenticationService); 11 | }); 12 | 13 | it("should be created", () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/ui/client/src/app/authentication.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { CookieService } from "ngx-cookie-service"; 3 | 4 | export class User { 5 | constructor(private _signedIn: boolean, private _email = "") {} 6 | 7 | get email(): string { 8 | return this._email; 9 | } 10 | 11 | get signedIn(): boolean { 12 | return this._signedIn; 13 | } 14 | } 15 | 16 | @Injectable({ 17 | providedIn: "root", 18 | }) 19 | export class AuthenticationService { 20 | public static readonly USER_EMAIL_COOKIE_NAME = "modron-user-email"; 21 | 22 | constructor(private _service: CookieService) {} 23 | 24 | authenticate(): User { 25 | const email = this._service.get( 26 | AuthenticationService.USER_EMAIL_COOKIE_NAME 27 | ); 28 | if (email !== "") { 29 | return new User(true, email); 30 | } 31 | return new User(false); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/ui/client/src/app/filter.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { FilterKeyValuePipe, FilterNoObservationsPipe } from "./filter.pipe"; 2 | 3 | describe("FilterKeyValuePipe", () => { 4 | it("create an instance", () => { 5 | const pipe = new FilterKeyValuePipe(); 6 | expect(pipe).toBeTruthy(); 7 | }); 8 | }); 9 | 10 | describe("filterNoObservations", () => { 11 | it("create an instance", () => { 12 | const pipe = new FilterNoObservationsPipe(); 13 | expect(pipe).toBeTruthy(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/ui/client/src/app/grpc-token.interceptor.ts: -------------------------------------------------------------------------------- 1 | export class GrpcTokenInterceptor { 2 | constructor(private _token: string) {} 3 | 4 | // TODO: The @improbable-en/grpc-web impl does not support 5 | // client interceptors. So we mimick an interceptor by returning metadata 6 | // that contains the grpc auth token. 7 | get intercept() { 8 | return { Authorization: `Bearer ${this._token}` }; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/ui/client/src/app/impact-indicator/impact-indicator.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/ui/client/src/app/impact-indicator/impact-indicator.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/client/src/app/impact-indicator/impact-indicator.component.scss -------------------------------------------------------------------------------- /src/ui/client/src/app/impact-indicator/impact-indicator.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input} from "@angular/core"; 2 | import {Impact, Severity} from "../../proto/modron_pb"; 3 | 4 | @Component( 5 | { 6 | selector: "app-impact-indicator", 7 | templateUrl: "./impact-indicator.component.html", 8 | styleUrls: ["./impact-indicator.component.scss"], 9 | } 10 | ) 11 | export class ImpactIndicatorComponent { 12 | @Input() 13 | public impact: Impact = Impact.IMPACT_UNKNOWN; 14 | constructor() { 15 | } 16 | 17 | // We reuse the severity indicator component to display the impact 18 | public severity(): Severity { 19 | switch (this.impact) { 20 | case Impact.IMPACT_HIGH: 21 | return Severity.SEVERITY_HIGH; 22 | case Impact.IMPACT_MEDIUM: 23 | return Severity.SEVERITY_MEDIUM; 24 | case Impact.IMPACT_LOW: 25 | return Severity.SEVERITY_LOW; 26 | default: 27 | return Severity.SEVERITY_UNKNOWN; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ui/client/src/app/model/modron.model.ts: -------------------------------------------------------------------------------- 1 | import {RequestStatus, ScanType} from "../../proto/modron_pb"; 2 | 3 | export type StatusInfo = { 4 | state: RequestStatus 5 | resourceGroups: string[] 6 | scanType: ScanType 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/client/src/app/model/notification.model.ts: -------------------------------------------------------------------------------- 1 | import * as pb from "src/proto/notification_pb"; 2 | import * as timestampPb from "google-protobuf/google/protobuf/timestamp_pb" 3 | 4 | export class NotificationException { 5 | private _uuid?: string 6 | private _createdOnTime?: Date 7 | 8 | sourceSystem = "" 9 | userEmail = "" 10 | notificationName = "" 11 | justification = "" 12 | validUntilTime?: Date 13 | 14 | get uuid(): string { 15 | if (this._uuid === undefined) { 16 | throw new Error("exception has not been created") 17 | } 18 | return this._uuid 19 | } 20 | 21 | get createdOnTime(): Date { 22 | if (this._createdOnTime === undefined) { 23 | throw new Error("exception has not been created") 24 | } 25 | return this._createdOnTime 26 | } 27 | 28 | toProto(): pb.NotificationException { 29 | const proto = new pb.NotificationException() 30 | proto.setSourceSystem(this.sourceSystem) 31 | proto.setUserEmail(this.userEmail) 32 | proto.setNotificationName(this.notificationName) 33 | proto.setJustification(this.justification) 34 | if (this.validUntilTime !== undefined) { 35 | proto.setValidUntilTime(timestampPb.Timestamp.fromDate(this.validUntilTime)) 36 | } 37 | return proto 38 | } 39 | 40 | static fromProto(proto: pb.NotificationException): NotificationException { 41 | const model = new NotificationException() 42 | model._uuid = proto.getUuid() 43 | model.sourceSystem = proto.getSourceSystem() 44 | model.userEmail = proto.getUserEmail() 45 | model.notificationName = proto.getNotificationName() 46 | model.justification = proto.getJustification() 47 | model.validUntilTime = proto.getValidUntilTime()?.toDate() 48 | model._createdOnTime = proto.getCreatedOnTime()?.toDate() 49 | return model 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ui/client/src/app/modron-app/modron-app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | 12 | Modron 13 | 14 | 15 |
16 | 17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /src/ui/client/src/app/modron-app/modron-app.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../colors.scss' as colors; 2 | 3 | .modron-app { 4 | position: absolute; 5 | display: flex; 6 | top: 0; 7 | left: 0; 8 | right: 0; 9 | bottom: 0; 10 | 11 | .sidenav { 12 | top: 0; 13 | bottom: 0; 14 | position: fixed; 15 | display: flex; 16 | } 17 | 18 | .app-content { 19 | position: relative; 20 | margin-left: 68px; 21 | width: calc(100% - 68px); 22 | 23 | .toolbar { 24 | position: fixed; 25 | display: flex; 26 | height: 60px; 27 | z-index: 300; 28 | 29 | img.logo { 30 | height: 60%; 31 | margin-right: 4px; 32 | } 33 | 34 | .app-title { 35 | font-weight: 400; 36 | color: colors.$title; 37 | text-transform: uppercase; 38 | } 39 | } 40 | 41 | .router-container { 42 | margin: 16px; 43 | margin-top: 60px; 44 | overflow: hidden; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/ui/client/src/app/modron-app/modron-app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing" 2 | 3 | import { ModronAppComponent } from "./modron-app.component" 4 | 5 | describe("ModronAppComponent", () => { 6 | let component: ModronAppComponent 7 | let fixture: ComponentFixture 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ModronAppComponent], 12 | }).compileComponents() 13 | 14 | fixture = TestBed.createComponent(ModronAppComponent) 15 | component = fixture.componentInstance 16 | fixture.detectChanges() 17 | }) 18 | 19 | it("should create", () => { 20 | expect(component).toBeTruthy() 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/ui/client/src/app/modron-app/modron-app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from "@angular/core" 2 | import { environment } from "../../environments/environment" 3 | import { Router } from "@angular/router"; 4 | 5 | @Component({ 6 | selector: "app-modron-app", 7 | templateUrl: "./modron-app.component.html", 8 | styleUrls: ["./modron-app.component.scss"], 9 | }) 10 | export class ModronAppComponent { 11 | public organization: string 12 | public href= ""; 13 | 14 | @Input() isExpanded: boolean = false; 15 | @Output() toggleMenu = new EventEmitter(); 16 | 17 | constructor(private router: Router) { 18 | this.organization = environment.organization 19 | } 20 | 21 | get production(): boolean { 22 | return environment.production 23 | } 24 | 25 | get currentUrl(): string { 26 | return this.router.url; 27 | } 28 | 29 | public navItems = [ 30 | { link: "/modron/resourcegroups", name: "Resource Groups", icon: "folder" }, 31 | { link: "/modron/stats", name: "Stats", icon: "bar_chart" }, 32 | { link: "/modron/exceptions", name: "Exceptions", icon: "notifications_paused" }, 33 | ]; 34 | } 35 | -------------------------------------------------------------------------------- /src/ui/client/src/app/modron.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from "@angular/core/testing"; 2 | import { ModronService } from "./modron.service"; 3 | 4 | describe("ModronService", () => { 5 | let service: ModronService; 6 | 7 | beforeEach(() => { 8 | TestBed.configureTestingModule({}); 9 | service = TestBed.inject(ModronService); 10 | }); 11 | 12 | it("should be created", () => { 13 | expect(service).toBeTruthy(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/ui/client/src/app/notif-bell-button/notif-bell-button.component.html: -------------------------------------------------------------------------------- 1 |
2 |
12 | 13 | edit_notifications 26 | 27 | 28 | notifications_off 34 | 35 | 36 |
37 | -------------------------------------------------------------------------------- /src/ui/client/src/app/notif-bell-button/notif-bell-button.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/client/src/app/notif-bell-button/notif-bell-button.component.scss -------------------------------------------------------------------------------- /src/ui/client/src/app/notification-exception-form/notification-exception-form.component.scss: -------------------------------------------------------------------------------- 1 | .form { 2 | min-width: 150px; 3 | max-width: 500px; 4 | width: 100%; 5 | } 6 | 7 | .form-field-fill { 8 | width: 100%; 9 | } 10 | -------------------------------------------------------------------------------- /src/ui/client/src/app/notification-exceptions/notification-exceptions.component.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | gap: 10px; 6 | flex: 1; 7 | background-color: rgb(239, 239, 239); 8 | margin-bottom: 10px; 9 | 10 | .exceptions-filter { 11 | display: flex; 12 | flex-direction: row; 13 | gap: 10px; 14 | 15 | input { 16 | padding: 5px; 17 | margin: 5px; 18 | font-size: 20px; 19 | } 20 | 21 | input:focus { 22 | outline: none; 23 | } 24 | } 25 | 26 | h1 { 27 | margin: 5px 40px; 28 | } 29 | } 30 | 31 | table { 32 | width: 100%; 33 | font-family: "IBM Plex Sans", sans-serif; 34 | 35 | th, 36 | td { 37 | font-size: 18px; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ui/client/src/app/notification-exceptions/notification-exceptions.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 2 | import { ActivatedRoute } from "@angular/router"; 3 | import { AuthenticationStore } from "../state/authentication.store"; 4 | import { NotificationStore } from "../state/notification.store"; 5 | 6 | import { NotificationExceptionsComponent } from "./notification-exceptions.component"; 7 | import { NotificationExceptionsFilterPipe } from "./notification-exceptions.pipe"; 8 | 9 | describe("NotificationExceptionsComponent", () => { 10 | let component: NotificationExceptionsComponent; 11 | let fixture: ComponentFixture; 12 | 13 | beforeEach(async () => { 14 | await TestBed.configureTestingModule({ 15 | declarations: [ 16 | NotificationExceptionsComponent, 17 | NotificationExceptionsFilterPipe, 18 | ], 19 | providers: [ 20 | NotificationStore, 21 | AuthenticationStore, 22 | { 23 | provide: ActivatedRoute, 24 | useValue: { 25 | snapshot: { 26 | paramMap: { 27 | get(): string { 28 | return "mock-notification-name"; 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | ], 35 | }).compileComponents(); 36 | 37 | fixture = TestBed.createComponent(NotificationExceptionsComponent); 38 | component = fixture.componentInstance; 39 | fixture.detectChanges(); 40 | }); 41 | 42 | it("should create", () => { 43 | expect(component).toBeTruthy(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/ui/client/src/app/notification-exceptions/notification-exceptions.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "@angular/core" 2 | import { ActivatedRoute } from "@angular/router" 3 | import { NotificationStore } from "../state/notification.store" 4 | 5 | @Component({ 6 | selector: "app-notification-exceptions", 7 | templateUrl: "./notification-exceptions.component.html", 8 | styleUrls: ["./notification-exceptions.component.scss"], 9 | }) 10 | export class NotificationExceptionsComponent { 11 | displayedColumns = [ 12 | "userEmail", 13 | "notificationName", 14 | "justification", 15 | "sourceSystem", 16 | "validUntilTime", 17 | "$actions", 18 | ]; 19 | searchText: string 20 | 21 | constructor(route: ActivatedRoute, public store: NotificationStore) { 22 | this.searchText = route.snapshot.paramMap.get("notificationName") ?? "" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/ui/client/src/app/notification-exceptions/notification-exceptions.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from "@angular/core" 2 | import { NotificationException } from "../model/notification.model" 3 | 4 | @Pipe({ 5 | name: "filterExceptions", 6 | }) 7 | export class NotificationExceptionsFilterPipe implements PipeTransform { 8 | transform(exps: NotificationException[], searchText: string): NotificationException[] { 9 | searchText = searchText.toLocaleLowerCase() 10 | 11 | return exps.filter((exp) => { 12 | return exp.notificationName.toLocaleLowerCase().includes(searchText) 13 | }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/ui/client/src/app/notification.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from "@angular/core/testing"; 2 | import { NotificationService } from "./notification.service"; 3 | 4 | describe("ModronService", () => { 5 | let service: NotificationService; 6 | 7 | beforeEach(() => { 8 | TestBed.configureTestingModule({}); 9 | service = TestBed.inject(NotificationService); 10 | }); 11 | 12 | it("should be created", () => { 13 | expect(service).toBeTruthy(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/ui/client/src/app/observation-details-dialog-content/observation-details-dialog-content.component.scss: -------------------------------------------------------------------------------- 1 | .dialog-content { 2 | display: flex; 3 | flex-direction: column; 4 | width: 100%; 5 | 6 | h1, h2, h3, h4, h5, h6 { 7 | margin-bottom: 0.5em; 8 | } 9 | 10 | p { 11 | margin-top: 0; 12 | } 13 | } 14 | 15 | .observation-details { 16 | display: grid; 17 | grid-template-columns: 1fr 2fr; 18 | margin: 16px; 19 | row-gap: 4px; 20 | 21 | p { 22 | margin: 0; 23 | } 24 | 25 | .hdr { 26 | font-weight: bold; 27 | } 28 | } 29 | 30 | .risk-score { 31 | display: grid; 32 | grid-template-columns: 80px 1fr; 33 | column-gap: 8px; 34 | align-items: center; 35 | width: 100%; 36 | 37 | .main-indicator { 38 | justify-self: center; 39 | } 40 | 41 | .severity-impact-container { 42 | display: grid; 43 | align-items: start; 44 | row-gap: 18px; 45 | grid-template-columns: 80px 1fr; 46 | margin-top: 1em; 47 | 48 | .indicator { 49 | justify-self: center; 50 | } 51 | 52 | div.expl { 53 | h1,h2,h3,h4,h5,h6 { 54 | margin: 0 0 0.5em; 55 | } 56 | 57 | code { 58 | background-color: #eeeeee; 59 | padding: 3px; 60 | } 61 | 62 | } 63 | } 64 | } 65 | 66 | .short-risk-score-description { 67 | margin-top: 1em; 68 | margin-bottom: 1em; 69 | } 70 | -------------------------------------------------------------------------------- /src/ui/client/src/app/observation-details-dialog-content/observation-details-dialog-content.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input} from "@angular/core"; 2 | import {Impact, Observation} from "../../proto/modron_pb"; 3 | 4 | @Component({ 5 | selector: "app-observation-details-dialog-content", 6 | templateUrl: "./observation-details-dialog-content.component.html", 7 | styleUrls: ["./observation-details-dialog-content.component.scss"], 8 | }) 9 | export class ObservationDetailsDialogContentComponent { 10 | @Input() 11 | observation!: Observation; 12 | constructor() {} 13 | 14 | protected readonly Impact = Impact; 15 | } 16 | -------------------------------------------------------------------------------- /src/ui/client/src/app/observation-details-dialog-content/observation-details-dialog-content.filter.ts: -------------------------------------------------------------------------------- 1 | import {Pipe, PipeTransform} from "@angular/core"; 2 | import {Observation} from "../../proto/modron_pb"; 3 | import Category = Observation.Category; 4 | 5 | @Pipe({ 6 | name: "categoryName" 7 | }) 8 | export class CategoryNamePipe implements PipeTransform { 9 | transform(cat: Category): string { 10 | switch(cat) { 11 | case Category.CATEGORY_VULNERABILITY: 12 | return "Vulnerability"; 13 | case Category.CATEGORY_MISCONFIGURATION: 14 | return "Misconfiguration"; 15 | case Category.CATEGORY_TOXIC_COMBINATION: 16 | return "Toxic Combination"; 17 | default: 18 | return "Unknown"; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/ui/client/src/app/observation-details-dialog/observation-details-dialog.component.html: -------------------------------------------------------------------------------- 1 |
2 | 5 |
6 | -------------------------------------------------------------------------------- /src/ui/client/src/app/observation-details-dialog/observation-details-dialog.component.scss: -------------------------------------------------------------------------------- 1 | .observation-details-dialog { 2 | padding: 20px; 3 | } 4 | -------------------------------------------------------------------------------- /src/ui/client/src/app/observation-details-dialog/observation-details-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Inject} from "@angular/core"; 2 | import {MAT_DIALOG_DATA} from "@angular/material/dialog"; 3 | import {Observation} from "../../proto/modron_pb"; 4 | 5 | @Component( 6 | { 7 | selector: "app-observation-details-dialog", 8 | templateUrl: "./observation-details-dialog.component.html", 9 | styleUrls: ["./observation-details-dialog.component.scss"], 10 | } 11 | ) 12 | export class ObservationDetailsDialogComponent { 13 | constructor( 14 | @Inject(MAT_DIALOG_DATA) public observation: Observation 15 | ) { 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/client/src/app/observation-details/observation-details.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 2 | import { MatDialogModule } from "@angular/material/dialog"; 3 | import { MatSnackBarModule } from "@angular/material/snack-bar"; 4 | import { NotificationExceptionsFilterPipe } from "../notification-exceptions/notification-exceptions.pipe"; 5 | import { AuthenticationStore } from "../state/authentication.store"; 6 | import { NotificationStore } from "../state/notification.store"; 7 | 8 | import { ObservationDetailsComponent } from "./observation-details.component"; 9 | import { ParseExternalIdPipe, ShortenDescriptionPipe } from "../filter.pipe"; 10 | import {ImpactNamePipe, SeverityNamePipe} from "../severity-indicator/severity-indicator.pipe"; 11 | 12 | describe("ObservationDetailsComponent", () => { 13 | let component: ObservationDetailsComponent; 14 | let fixture: ComponentFixture; 15 | 16 | beforeEach(async () => { 17 | await TestBed.configureTestingModule({ 18 | imports: [MatDialogModule, MatSnackBarModule], 19 | declarations: [ 20 | ObservationDetailsComponent, 21 | NotificationExceptionsFilterPipe, 22 | ImpactNamePipe, 23 | ParseExternalIdPipe, 24 | SeverityNamePipe, 25 | ShortenDescriptionPipe, 26 | ], 27 | providers: [AuthenticationStore, NotificationStore], 28 | }).compileComponents(); 29 | 30 | fixture = TestBed.createComponent(ObservationDetailsComponent); 31 | component = fixture.componentInstance; 32 | fixture.detectChanges(); 33 | }); 34 | 35 | it("should create", () => { 36 | expect(component).toBeTruthy(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/ui/client/src/app/observations-stats/observations-stats.component.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/ui/client/src/app/observations-stats/observations-stats.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/client/src/app/observations-stats/observations-stats.component.scss -------------------------------------------------------------------------------- /src/ui/client/src/app/observations-stats/observations-stats.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 2 | 3 | import { ObservationsStatsComponent } from "./observations-stats.component"; 4 | 5 | describe("HistogramHorizontalComponent", () => { 6 | let component: ObservationsStatsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ObservationsStatsComponent], 12 | }).compileComponents(); 13 | 14 | fixture = TestBed.createComponent(ObservationsStatsComponent); 15 | component = fixture.componentInstance; 16 | fixture.detectChanges(); 17 | }); 18 | 19 | it("should create", () => { 20 | expect(component).toBeTruthy(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/ui/client/src/app/observations-stats/observations-stats.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | OnInit, 4 | Input, 5 | ChangeDetectionStrategy, 6 | } from "@angular/core" 7 | import {ChartData, ChartOptions} from "chart.js"; 8 | 9 | @Component({ 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | selector: "app-observations-stats", 12 | templateUrl: "./observations-stats.component.html", 13 | styleUrls: ["./observations-stats.component.scss"], 14 | }) 15 | export class ObservationsStatsComponent implements OnInit { 16 | @Input() data: Map = new Map(); 17 | public options: ChartOptions = { 18 | scales: { 19 | 20 | }, 21 | indexAxis: "y", 22 | plugins: { 23 | legend: { 24 | display: false, 25 | }, 26 | } 27 | } 28 | public chartData: ChartData = { 29 | labels: [] as string[], 30 | datasets: [ 31 | { 32 | label: "Observations", 33 | data: [] as number[], 34 | } 35 | ] 36 | }; 37 | max = 1; 38 | 39 | ngOnInit(): void { 40 | this.max = Math.max(...this.data.values()) 41 | this.chartData.labels = Array.from(this.data.keys()); 42 | this.chartData.datasets[0].data = Array.from(this.data.values()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ui/client/src/app/observations-table/observations-table.component.scss: -------------------------------------------------------------------------------- 1 | .mat-column-riskScore { 2 | max-width: 80px; 3 | } 4 | 5 | .mat-column-category { 6 | max-width: 350px; 7 | } 8 | 9 | .mat-column-shortDesc { 10 | min-width: 30%; 11 | } 12 | 13 | .mat-column-shortDesc, .mat-column-category { 14 | max-height: 1em; 15 | overflow: hidden; 16 | text-overflow: ellipsis; 17 | white-space: nowrap; 18 | min-width: 0; 19 | 20 | div { 21 | width: 100%; 22 | overflow: hidden; 23 | text-overflow: ellipsis; 24 | white-space: nowrap; 25 | } 26 | } 27 | 28 | .mat-cell { 29 | white-space: nowrap; 30 | overflow: hidden; 31 | text-overflow: ellipsis; 32 | 33 | > p { 34 | width: 100%; 35 | overflow: hidden; 36 | text-overflow: ellipsis; 37 | } 38 | } 39 | 40 | .mat-column-actions { 41 | display: grid; 42 | grid-template-columns: repeat(2, 1fr); 43 | justify-items: center; 44 | max-width: 100px; 45 | 46 | > * { 47 | cursor: pointer; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/ui/client/src/app/resource-group-details/resource-group-details.component.html: -------------------------------------------------------------------------------- 1 |
2 | 19 |
20 | 21 |
22 | 23 |
24 | 25 |
26 |
27 | -------------------------------------------------------------------------------- /src/ui/client/src/app/resource-group-details/resource-group-details.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing" 2 | import { ViewportScroller } from "@angular/common" 3 | import { ActivatedRoute } from "@angular/router" 4 | import { reverseSortPipe } from "../filter.pipe" 5 | import { ObservationsPipe } from "../resource-groups/resource-groups.pipe" 6 | import { AuthenticationStore } from "../state/authentication.store" 7 | import { ModronStore } from "../state/modron.store" 8 | import { NotificationStore } from "../state/notification.store" 9 | 10 | import { ResourceGroupDetailsComponent } from "./resource-group-details.component" 11 | 12 | describe("ResourceGroupDetailsComponent", () => { 13 | let component: ResourceGroupDetailsComponent 14 | let fixture: ComponentFixture 15 | 16 | beforeEach(async () => { 17 | await TestBed.configureTestingModule({ 18 | declarations: [ 19 | ResourceGroupDetailsComponent, 20 | ObservationsPipe, 21 | reverseSortPipe, 22 | ], 23 | providers: [ 24 | ModronStore, 25 | NotificationStore, 26 | AuthenticationStore, 27 | ViewportScroller, 28 | { 29 | provide: ActivatedRoute, 30 | useValue: { 31 | snapshot: { 32 | paramMap: { 33 | get(): string { 34 | return "mock-observation-id" 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | ], 41 | }).compileComponents() 42 | 43 | fixture = TestBed.createComponent(ResourceGroupDetailsComponent) 44 | component = fixture.componentInstance 45 | fixture.detectChanges() 46 | }) 47 | 48 | it("should create", () => { 49 | expect(component).toBeTruthy() 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/ui/client/src/app/resource-group-details/resource-group-details.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from "@angular/core" 2 | import * as pb from "src/proto/modron_pb" 3 | import {StructValueToStringPipe} from "../filter.pipe"; 4 | 5 | @Pipe({ name: "mapByType" }) 6 | export class MapByTypePipe implements PipeTransform { 7 | transform(obs: Map): Map { 8 | const obsByType = new Map() 9 | for (const ob of [...obs.values()].flat()) { 10 | const type = ob.getName() 11 | if (!obsByType.has(type)) { 12 | obsByType.set(type, []) 13 | } 14 | obsByType.get(type)?.push(ob) 15 | } 16 | return obsByType 17 | } 18 | } 19 | 20 | @Pipe({ name: "mapFlatRules" }) 21 | export class mapFlatRulesPipe implements PipeTransform { 22 | transform( 23 | map: Map> 24 | ): Map { 25 | const res = new Map() 26 | map.forEach((v, k) => { 27 | res.set(k, Array.from(v.values()).flat()) 28 | }) 29 | return res 30 | } 31 | } 32 | 33 | @Pipe({ name: "mapByObservedValues" }) 34 | export class MapByObservedValuesPipe implements PipeTransform { 35 | transform(obs: pb.Observation[]): Map { 36 | const obsByType = new Map() 37 | obs.forEach((o) => { 38 | const obsValue = o.getObservedValue() 39 | ? StructValueToStringPipe.prototype.transform(o.getObservedValue()) 40 | : "Observation count" 41 | if (!obsByType.has(obsValue)) { 42 | obsByType.set(obsValue, 0) 43 | } 44 | obsByType.set(obsValue, (obsByType.get(obsValue) as number) + 1) 45 | }) 46 | return obsByType 47 | } 48 | } 49 | 50 | @Pipe({ name: "filterName" }) 51 | export class FilterNamePipe implements PipeTransform { 52 | transform( 53 | obs: Map | null, 54 | name: string | null 55 | ): Map { 56 | const obsByType = new Map() 57 | obsByType.set( 58 | name ? name : "", 59 | obs?.get(name ? name : "") 60 | ? (obs.get(name ? name : "") as pb.Observation[]) 61 | : [] 62 | ) 63 | return obsByType 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/ui/client/src/app/resource-group/resource-group.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ this.provider }} 4 | {{ this.name.replace('projects/', '') }} 8 | 9 | 10 |
11 |
12 |
13 |
14 | 18 |
19 |
20 |
21 | 22 | 23 |
24 |
0 observations
25 |
26 |
27 |
28 |
32 | Last scanned: {{ this.lastScanDate! | fromNow }} 33 |
34 | 35 | 36 |
37 | Never scanned 38 |
39 |
40 |
41 | 42 | 49 | 50 | 51 | 52 | 53 | 57 | 58 |
59 | -------------------------------------------------------------------------------- /src/ui/client/src/app/resource-group/resource-group.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing" 2 | import { ModronStore } from "../state/modron.store" 3 | import { ResourceGroupComponent } from "./resource-group.component" 4 | import { MatSnackBarModule } from "@angular/material/snack-bar" 5 | 6 | describe("ResourceGroupComponent", () => { 7 | let component: ResourceGroupComponent 8 | let fixture: ComponentFixture 9 | 10 | beforeEach(async () => { 11 | await TestBed.configureTestingModule({ 12 | declarations: [ResourceGroupComponent], 13 | imports: [MatSnackBarModule], 14 | providers: [ModronStore], 15 | }).compileComponents() 16 | 17 | fixture = TestBed.createComponent(ResourceGroupComponent) 18 | component = fixture.componentInstance 19 | fixture.detectChanges() 20 | }) 21 | 22 | it("should create", () => { 23 | expect(component).toBeTruthy() 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/ui/client/src/app/resource-group/resource-group.pipe.ts: -------------------------------------------------------------------------------- 1 | import {Pipe, PipeTransform} from "@angular/core"; 2 | import * as moment from "moment"; 3 | 4 | @Pipe({name: "fromNow"}) 5 | export class FromNowPipe implements PipeTransform { 6 | transform(value: Date): string { 7 | return moment(value).fromNow() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/ui/client/src/app/resource-groups/resource-groups.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 2 | import { MatSnackBarModule } from "@angular/material/snack-bar"; 3 | import { FilterKeyValuePipe, FilterNoObservationsPipe } from "../filter.pipe"; 4 | import { mapFlatRulesPipe } from "../resource-group-details/resource-group-details.pipe"; 5 | import { ModronStore } from "../state/modron.store"; 6 | import { RouterTestingModule } from "@angular/router/testing"; 7 | import { provideHttpClientTesting } from "@angular/common/http/testing"; 8 | 9 | import { ResourceGroupsComponent } from "./resource-groups.component"; 10 | import { 11 | InvalidProjectNb, 12 | ObsNbPipe, 13 | ResourceGroupsPipe, 14 | } from "./resource-groups.pipe"; 15 | import { provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; 16 | 17 | describe("ResourceGroupsComponent", () => { 18 | let component: ResourceGroupsComponent; 19 | let fixture: ComponentFixture; 20 | 21 | beforeEach(async () => { 22 | await TestBed.configureTestingModule({ 23 | declarations: [ 24 | ResourceGroupsComponent, 25 | ResourceGroupsPipe, 26 | FilterKeyValuePipe, 27 | mapFlatRulesPipe, 28 | InvalidProjectNb, 29 | ObsNbPipe, 30 | FilterNoObservationsPipe, 31 | ], 32 | imports: [MatSnackBarModule, 33 | RouterTestingModule], 34 | providers: [ModronStore, provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()] 35 | }).compileComponents(); 36 | 37 | fixture = TestBed.createComponent(ResourceGroupsComponent); 38 | component = fixture.componentInstance; 39 | fixture.detectChanges(); 40 | }); 41 | 42 | it("should create", () => { 43 | expect(component).toBeTruthy(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/ui/client/src/app/resource-groups/resource-groups.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from "@angular/core" 2 | import * as pb from "src/proto/modron_pb" 3 | 4 | @Pipe({ name: "resourceGroups" }) 5 | export class ResourceGroupsPipe implements PipeTransform { 6 | transform(obs: Map): string[] { 7 | return [...obs.keys()] 8 | } 9 | } 10 | 11 | @Pipe({ name: "mapPerTypeName" }) 12 | export class MapPerTypeName implements PipeTransform { 13 | transform(obs: Map): Map { 14 | const obsn: Map = new Map< 15 | string, 16 | pb.Observation[] 17 | >() 18 | 19 | obsn.set("START", []) 20 | obsn.set(JSON.stringify(obs.size), []) 21 | obsn.set("END", []) 22 | 23 | return obsn 24 | } 25 | } 26 | 27 | 28 | @Pipe({ name: "mapByRiskScore" }) 29 | export class MapByRiskScorePipe implements PipeTransform { 30 | transform(obs: pb.Observation[]): [number, number][] { 31 | const sevMap = new Map(); 32 | for(const o of obs) { 33 | const amountSeverities = sevMap.get(o.getRiskScore()); 34 | if(amountSeverities === undefined){ 35 | sevMap.set(o.getRiskScore(), 1); 36 | continue; 37 | } 38 | sevMap.set(o.getRiskScore(), amountSeverities+1); 39 | } 40 | return Array.from(sevMap.entries()).sort((a, b) => b[0] - a[0]) 41 | } 42 | } 43 | 44 | @Pipe({ name: "observations" }) 45 | export class ObservationsPipe implements PipeTransform { 46 | transform(obs: Map): pb.Observation[] { 47 | return [...obs.values()].flat() 48 | } 49 | } 50 | 51 | @Pipe({ name: "invalidProjectNb" }) 52 | export class InvalidProjectNb implements PipeTransform { 53 | transform(obs: any[]): number { 54 | let res = 0 55 | obs.forEach((e) => { 56 | if (e.value.length > 0) { 57 | res += 1 58 | } 59 | }) 60 | return res 61 | } 62 | } 63 | 64 | @Pipe({ name: "obsNb" }) 65 | export class ObsNbPipe implements PipeTransform { 66 | transform(obs: any[]): number { 67 | let res = 0 68 | obs.forEach((e) => (res += e.value.length)) 69 | return res 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/ui/client/src/app/search-obs/search-obs.component.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /src/ui/client/src/app/search-obs/search-obs.component.scss: -------------------------------------------------------------------------------- 1 | .obs-search { 2 | max-height: 600px; 3 | overflow-y: scroll; 4 | 5 | .obs-search-filter { 6 | input:focus { 7 | outline: none; 8 | } 9 | background-color: rgb(231, 231, 231); 10 | display: grid; 11 | grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr; 12 | gap: 20px; 13 | padding: 5px; 14 | margin-bottom: 5px; 15 | 16 | h2 { 17 | margin: 10px; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/ui/client/src/app/search-obs/search-obs.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing"; 2 | import { FilterObsPipe } from "../filter.pipe"; 3 | 4 | import { SearchObsComponent } from "./search-obs.component"; 5 | 6 | describe("SearchObsComponent", () => { 7 | let component: SearchObsComponent; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async () => { 11 | await TestBed.configureTestingModule({ 12 | declarations: [SearchObsComponent, FilterObsPipe], 13 | }).compileComponents(); 14 | 15 | fixture = TestBed.createComponent(SearchObsComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it("should create", () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/ui/client/src/app/search-obs/search-obs.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | Input, 4 | ChangeDetectionStrategy, 5 | } from "@angular/core" 6 | import { Observation } from "src/proto/modron_pb" 7 | 8 | @Component({ 9 | selector: "app-search-obs", 10 | templateUrl: "./search-obs.component.html", 11 | styleUrls: ["./search-obs.component.scss"], 12 | changeDetection: ChangeDetectionStrategy.OnPush, 13 | }) 14 | export class SearchObsComponent { 15 | searchResource = ""; 16 | searchObservedVal = ""; 17 | searchGroup = ""; 18 | 19 | @Input() obs: Observation[] = []; 20 | 21 | applyFilter(event: Event): string { 22 | return (event.target as HTMLInputElement).value 23 | } 24 | 25 | identity(index: number, item: Observation): string { 26 | return item.getUid() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/ui/client/src/app/severity-indicator/severity-indicator.component.html: -------------------------------------------------------------------------------- 1 |
16 | 17 | {{ getIcon() }} 18 | 19 | 20 | {{ count! | severityAmount }} 21 | 22 |
23 | -------------------------------------------------------------------------------- /src/ui/client/src/app/severity-indicator/severity-indicator.component.scss: -------------------------------------------------------------------------------- 1 | 2 | .severity-circle { 3 | color: #FFF; 4 | display: block; 5 | width: 30px; 6 | height: 30px; 7 | line-height: 30px; 8 | font-size: 16px; 9 | overflow: hidden; 10 | text-align: center; 11 | border-radius: 30px; 12 | margin-right: 6px; 13 | } 14 | 15 | .severity-circle-unknown { 16 | background-color: #9E9E9E; 17 | } 18 | 19 | .severity-circle-info { 20 | background-color: #2196F3; 21 | } 22 | 23 | .severity-circle-low { 24 | background-color: #AFB42B; 25 | } 26 | 27 | 28 | .severity-circle-medium { 29 | background-color: #F9A825; 30 | } 31 | 32 | .severity-circle-high { 33 | background-color: #E65100; 34 | } 35 | 36 | .severity-circle-critical { 37 | background-color: #D50000; 38 | 39 | // Make this blink too! 40 | animation: blinker 1s linear infinite; 41 | } 42 | 43 | @keyframes blinker { 44 | 50% { 45 | background-color: #ff0000; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/ui/client/src/app/severity-indicator/severity-indicator.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Input} from "@angular/core"; 2 | import {Severity} from "../../proto/modron_pb"; 3 | 4 | @Component({ 5 | selector: "app-severity-indicator", 6 | templateUrl: "./severity-indicator.component.html", 7 | styleUrls: ["./severity-indicator.component.scss"], 8 | }) 9 | export class SeverityIndicatorComponent { 10 | @Input() 11 | severity: Severity = Severity.SEVERITY_UNKNOWN; 12 | 13 | @Input() 14 | count: number | undefined; 15 | 16 | getIcon(): string { 17 | switch (this.severity) { 18 | case Severity.SEVERITY_CRITICAL: 19 | return "C"; 20 | case Severity.SEVERITY_HIGH: 21 | return "H"; 22 | case Severity.SEVERITY_MEDIUM: 23 | return "M"; 24 | case Severity.SEVERITY_LOW: 25 | return "L"; 26 | case Severity.SEVERITY_INFO: 27 | return "I"; 28 | default: 29 | return "?"; 30 | } 31 | } 32 | 33 | constructor() {} 34 | 35 | protected readonly Severity = Severity; 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/client/src/app/severity-indicator/severity-indicator.pipe.ts: -------------------------------------------------------------------------------- 1 | import {Pipe, PipeTransform} from "@angular/core"; 2 | import {Impact, Severity} from "../../proto/modron_pb"; 3 | 4 | @Pipe({name: "severityName"}) 5 | export class SeverityNamePipe implements PipeTransform { 6 | transform(severity: number): string { 7 | switch(severity) { 8 | case Severity.SEVERITY_CRITICAL: 9 | return "Critical"; 10 | case Severity.SEVERITY_HIGH: 11 | return "High"; 12 | case Severity.SEVERITY_MEDIUM: 13 | return "Medium"; 14 | case Severity.SEVERITY_LOW: 15 | return "Low"; 16 | case Severity.SEVERITY_INFO: 17 | return "Info"; 18 | default: 19 | return "Unknown"; 20 | } 21 | } 22 | } 23 | 24 | @Pipe({name: "impactName"}) 25 | export class ImpactNamePipe implements PipeTransform { 26 | transform(impact: number): string { 27 | switch(impact) { 28 | case Impact.IMPACT_HIGH: 29 | return "High"; 30 | case Impact.IMPACT_MEDIUM: 31 | return "Medium"; 32 | case Impact.IMPACT_LOW: 33 | return "Low"; 34 | default: 35 | return "Unknown"; 36 | } 37 | } 38 | } 39 | 40 | @Pipe({name: "severityAmount"}) 41 | export class SeverityAmountPipe implements PipeTransform { 42 | transform(count: number): string { 43 | if(count > 99) { 44 | return "99+"; 45 | } 46 | return count.toString(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ui/client/src/app/sidenav/sidenav.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 12 | 13 | 29 |
30 | -------------------------------------------------------------------------------- /src/ui/client/src/app/sidenav/sidenav.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../colors.scss' as colors; 2 | 3 | $navbarWidth: 68px; 4 | 5 | .sidenav { 6 | display: flex; 7 | background-color: colors.$sideNavBackground; 8 | flex-direction: column; 9 | width: $navbarWidth; 10 | height: 100%; 11 | 12 | div.top-spacer { 13 | height: 20px; 14 | } 15 | 16 | .menu-icon-container { 17 | align-self: center; 18 | height: 24px; 19 | margin-top: 12px; 20 | margin-bottom: 12px; 21 | } 22 | 23 | div.nav-items { 24 | display: flex; 25 | flex-direction: column; 26 | align-items: center; 27 | width: 100%; 28 | gap: 24px; 29 | margin-top: 20px; 30 | 31 | .nav-item { 32 | cursor: pointer; 33 | text-align: center; 34 | width: $navbarWidth; 35 | overflow: hidden; 36 | 37 | &.active { 38 | .nav-item-icon { 39 | background-color: colors.$sideNavButtonActiveBackground; 40 | } 41 | } 42 | 43 | .nav-item-icon { 44 | font-size: 20px; 45 | line-height: 20px; 46 | height: 20px; 47 | width: 30px; 48 | padding: 4px; 49 | border-radius: 40px; 50 | transition: 250ms; 51 | &:hover { 52 | background-color: colors.$sideNavButtonActiveBackground; 53 | } 54 | } 55 | 56 | .nav-item-text { 57 | width: $navbarWidth; 58 | font-weight: bold; 59 | font-size: 12px; 60 | text-align: center; 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/ui/client/src/app/sidenav/sidenav.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {ComponentFixture, TestBed} from "@angular/core/testing"; 2 | import {SidenavComponent} from "./sidenav.component"; 3 | import {Router} from "@angular/router"; 4 | import {RouterTestingModule} from "@angular/router/testing"; 5 | import {Component} from "@angular/core"; 6 | 7 | @Component({ 8 | template: "" 9 | }) 10 | class DummyComponent { 11 | } 12 | 13 | describe("SidenavComponent", () => { 14 | let component: SidenavComponent; 15 | let fixture: ComponentFixture; 16 | let router: Router; 17 | 18 | beforeEach(async () => { 19 | const testingModule = TestBed.configureTestingModule({ 20 | imports: [SidenavComponent, RouterTestingModule.withRoutes( 21 | [{path: "modron/resourcegroups", component: DummyComponent}] 22 | )], 23 | providers: [] 24 | }) 25 | await testingModule.compileComponents(); 26 | 27 | fixture = TestBed.createComponent(SidenavComponent); 28 | component = fixture.componentInstance; 29 | router = TestBed.inject(Router); 30 | fixture.detectChanges(); 31 | }); 32 | 33 | it("should create", () => { 34 | expect(component).toBeTruthy(); 35 | }); 36 | 37 | it("should mark the icon as active when the route matches", async () => { 38 | await router.navigateByUrl("/modron/resourcegroups"); 39 | fixture.detectChanges(); 40 | const {debugElement} = fixture; 41 | const navItems = debugElement.nativeElement.querySelectorAll("div.nav-items div.nav-item") 42 | expect(navItems.length).toBe(3); 43 | expect(navItems[0].classList).toContain("active"); 44 | expect(navItems[1].classList).not.toContain("active"); 45 | expect(navItems[2].classList).not.toContain("active"); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/ui/client/src/app/sidenav/sidenav.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "@angular/core"; 2 | import { CommonModule } from "@angular/common"; 3 | import {MatIconModule} from "@angular/material/icon"; 4 | import {MatListModule} from "@angular/material/list"; 5 | import {Router, RouterLink} from "@angular/router"; 6 | import {MatRippleModule} from "@angular/material/core"; 7 | 8 | @Component({ 9 | selector: "app-sidenav", 10 | standalone: true, 11 | imports: [CommonModule, MatIconModule, MatListModule, RouterLink, MatRippleModule], 12 | templateUrl: "./sidenav.component.html", 13 | styleUrl: "./sidenav.component.scss" 14 | }) 15 | export class SidenavComponent { 16 | constructor(public router: Router) {} 17 | 18 | public navItems = [ 19 | { link: "/modron/resourcegroups", name: "Resource Groups", icon: "folder" }, 20 | { link: "/modron/stats", name: "Stats", icon: "bar_chart" }, 21 | { link: "/modron/exceptions", name: "Exceptions", icon: "notifications_paused" }, 22 | ]; 23 | } 24 | -------------------------------------------------------------------------------- /src/ui/client/src/app/state/authentication.store.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { User, AuthenticationService } from "../authentication.service"; 3 | 4 | @Injectable() 5 | export class AuthenticationStore { 6 | private _user = new User(false); 7 | 8 | constructor(private _service: AuthenticationService) { 9 | this.fetchInitialData(); 10 | } 11 | 12 | get user(): User { 13 | return this._user; 14 | } 15 | 16 | private fetchInitialData() { 17 | this._user = this._service.authenticate(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/ui/client/src/app/state/notification.store.ts: -------------------------------------------------------------------------------- 1 | import { NotificationService } from "../notification.service" 2 | import { Injectable } from "@angular/core" 3 | import { BehaviorSubject, map, Observable } from "rxjs" 4 | import { NotificationException } from "../model/notification.model" 5 | import { AuthenticationStore } from "./authentication.store" 6 | 7 | @Injectable() 8 | export class NotificationStore { 9 | private _exceptions: BehaviorSubject 10 | 11 | constructor( 12 | private _service: NotificationService, 13 | private _auth: AuthenticationStore 14 | ) { 15 | this._exceptions = new BehaviorSubject([]) 16 | this.fetchInitialData() 17 | } 18 | 19 | get exceptions$(): Observable { 20 | return new Observable((sub) => this._exceptions.subscribe(sub)) 21 | } 22 | 23 | get exceptions(): NotificationException[] { 24 | return this._exceptions.value 25 | } 26 | 27 | createException$( 28 | exp: NotificationException 29 | ): Observable { 30 | return this._service.createException$(exp.toProto()).pipe( 31 | map((proto) => { 32 | const exp = NotificationException.fromProto(proto) 33 | this._exceptions.next(this._exceptions.getValue().concat([exp])) 34 | return exp 35 | }) 36 | ) 37 | } 38 | 39 | private listExceptions(userEmail: string) { 40 | this._service.listExceptions$(userEmail).subscribe((protos) => { 41 | this._exceptions.next( 42 | protos.map((proto) => NotificationException.fromProto(proto)) 43 | ) 44 | }) 45 | } 46 | 47 | private fetchInitialData() { 48 | this.listExceptions(this._auth.user?.email ?? "") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/ui/client/src/app/stats.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from "@angular/core/testing"; 2 | 3 | import { StatsService } from "./stats.service"; 4 | 5 | describe("StatsService", () => { 6 | let service: StatsService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(StatsService); 11 | }); 12 | 13 | it("should be created", () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/ui/client/src/app/stats.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core" 2 | import { ModronService } from "./modron.service" 3 | import { Observation } from "src/proto/modron_pb" 4 | 5 | @Injectable({ 6 | providedIn: "root", 7 | }) 8 | export class StatsService { 9 | constructor(public modron: ModronService) { } 10 | 11 | getObservationsPerType(): Map { 12 | throw Error("unimplemented") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/ui/client/src/app/stats/stats.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from "@angular/core/testing" 2 | import { ActivatedRoute } from "@angular/router" 3 | import { reverseSortPipe } from "../filter.pipe" 4 | import { 5 | MapByTypePipe, 6 | mapFlatRulesPipe, 7 | } from "../resource-group-details/resource-group-details.pipe" 8 | import { 9 | InvalidProjectNb, 10 | ObservationsPipe, 11 | } from "../resource-groups/resource-groups.pipe" 12 | import { ModronStore } from "../state/modron.store" 13 | 14 | import { StatsComponent } from "./stats.component" 15 | 16 | describe("StatsComponent", () => { 17 | let component: StatsComponent 18 | let fixture: ComponentFixture 19 | 20 | beforeEach(async () => { 21 | await TestBed.configureTestingModule({ 22 | declarations: [ 23 | StatsComponent, 24 | InvalidProjectNb, 25 | ObservationsPipe, 26 | mapFlatRulesPipe, 27 | reverseSortPipe, 28 | MapByTypePipe, 29 | ], 30 | providers: [ 31 | ModronStore, 32 | { 33 | provide: ActivatedRoute, 34 | useValue: { 35 | snapshot: { 36 | paramMap: { 37 | get(): string { 38 | return "mock-observation-id" 39 | }, 40 | }, 41 | }, 42 | }, 43 | }, 44 | ], 45 | }).compileComponents() 46 | 47 | fixture = TestBed.createComponent(StatsComponent) 48 | component = fixture.componentInstance 49 | fixture.detectChanges() 50 | }) 51 | 52 | it("should create", () => { 53 | expect(component).toBeTruthy() 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/ui/client/src/app/token.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HttpEvent, 3 | HttpHandler, 4 | HttpInterceptor, 5 | HttpRequest, 6 | } from "@angular/common/http" 7 | import { Injectable } from "@angular/core" 8 | import { Observable } from "rxjs" 9 | import { AuthenticationService } from "./authentication.service" 10 | 11 | @Injectable() 12 | export class TokenInterceptor implements HttpInterceptor { 13 | constructor(public auth: AuthenticationService) { } 14 | 15 | // Using any here as we implement HttpInterceptor: https://angular.io/api/common/http/HttpInterceptor 16 | intercept( 17 | request: HttpRequest, 18 | next: HttpHandler 19 | ): Observable> { 20 | request = request.clone({ 21 | setHeaders: { 22 | Authorization: `Bearer ${this.auth.tokenId}`, 23 | }, 24 | }) 25 | return next.handle(request) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/ui/client/src/app/ui-demo/ui-demo.component.html: -------------------------------------------------------------------------------- 1 |
2 |

UI Demo

3 |

Severities

4 |

Empty

5 |
6 | 10 |
11 | 12 |

With number

13 |
14 | 19 |
20 | 21 |

Card

22 |
23 | 29 | 34 |
35 | 36 |

Single Observation (old)

37 | 40 | 41 |

Observation Dialog

42 |
43 | 46 |
47 | 48 |
49 | -------------------------------------------------------------------------------- /src/ui/client/src/app/ui-demo/ui-demo.component.scss: -------------------------------------------------------------------------------- 1 | .severity-container { 2 | display: grid; 3 | grid-template-columns: repeat(8, 50px); 4 | row-gap: 10px; 5 | } 6 | 7 | .rg-container { 8 | display: grid; 9 | padding: 10px; 10 | grid-template-columns: repeat(4, 300px); 11 | column-gap: 10px; 12 | row-gap: 10px; 13 | } 14 | 15 | .observation-dialog { 16 | width: 800px; 17 | border: 1px solid #333; 18 | padding: 10px; 19 | border-radius: 5px; 20 | margin: 16px; 21 | } 22 | -------------------------------------------------------------------------------- /src/ui/client/src/app/ui-demo/ui-demo.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component} from "@angular/core"; 2 | import {Impact, Observation, Remediation, Severity} from "../../proto/modron_pb"; 3 | import Category = Observation.Category; 4 | 5 | @Component({ 6 | selector: "app-ui-demo", 7 | templateUrl: "./ui-demo.component.html", 8 | styleUrls: ["./ui-demo.component.scss"], 9 | changeDetection: ChangeDetectionStrategy.OnPush, 10 | }) 11 | export class UIDemoComponent { 12 | protected readonly Severity = Severity; 13 | protected readonly Object = Object; 14 | protected readonly severityValues: Severity[] = Object.values(Severity).reverse() as Severity[]; 15 | protected readonly Date = Date; 16 | date: Date | null = new Date(); 17 | 18 | public demoObservation = new Observation(); 19 | constructor() { 20 | this.demoObservation.setName("EXAMPLE_DEMO_OBSERVATION"); 21 | this.demoObservation.setRiskScore(Severity.SEVERITY_CRITICAL); 22 | this.demoObservation.setSeverity(Severity.SEVERITY_HIGH); 23 | this.demoObservation.setImpact(Impact.IMPACT_HIGH); 24 | this.demoObservation.setCategory(Category.CATEGORY_MISCONFIGURATION) 25 | this.demoObservation.setImpactReason("environment=production") 26 | const remediation = new Remediation(); 27 | remediation.setDescription("Example description"); 28 | remediation.setRecommendation("Example recommendation"); 29 | this.demoObservation.setRemediation(remediation); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/ui/client/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/client/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/ui/client/src/colors.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | 3 | $my-primary: mat.m2-define-palette(mat.$m2-indigo-palette, 500); 4 | $my-accent: mat.m2-define-palette(mat.$m2-pink-palette, A200, A100, A400); 5 | 6 | $my-theme: mat.m2-define-light-theme(( 7 | color: ( 8 | primary: $my-primary, 9 | accent: $my-accent, 10 | ), 11 | density: 0, 12 | )); 13 | 14 | $sideNavBackground: mat.m2-get-color-from-palette($my-primary, default, 0.1); 15 | $sideNavButtonActiveBackground: mat.m2-get-color-from-palette($my-primary, default, 0.2); 16 | 17 | $title: #FFF; 18 | $secondaryText: #555; 19 | 20 | $danger: #da1e28; 21 | $warning: #f5a623; 22 | $allGood: #24a148; 23 | 24 | -------------------------------------------------------------------------------- /src/ui/client/src/environments/environment.base.ts: -------------------------------------------------------------------------------- 1 | // This file needs to be extended by an environment file (e.g., environment.ts, environment.local.ts, etc.) 2 | 3 | export const environment = { 4 | organization: "", 5 | }; 6 | -------------------------------------------------------------------------------- /src/ui/client/src/environments/environment.local.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | import { environment as baseEnvironment } from "./environment.base" 6 | 7 | const _environment = { 8 | production: false, 9 | local: true, 10 | } 11 | 12 | export const environment = { ...baseEnvironment, ..._environment } 13 | 14 | /* 15 | * For easier debugging in development mode, you can import the following file 16 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 17 | * 18 | * This import should be commented out in production mode because it will have a negative impact 19 | * on performance if an error is thrown. 20 | */ 21 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 22 | -------------------------------------------------------------------------------- /src/ui/client/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | import { environment as baseEnvironment } from "./environment.base" 6 | 7 | const _environment = { 8 | production: true, 9 | local: false, 10 | } 11 | 12 | export const environment = { ...baseEnvironment, ..._environment } 13 | 14 | /* 15 | * For easier debugging in development mode, you can import the following file 16 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 17 | * 18 | * This import should be commented out in production mode because it will have a negative impact 19 | * on performance if an error is thrown. 20 | */ 21 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 22 | -------------------------------------------------------------------------------- /src/ui/client/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | import { environment as baseEnvironment } from "./environment.base" 6 | 7 | const _environment = { 8 | production: false, 9 | local: false, 10 | } 11 | 12 | export const environment = { ...baseEnvironment, ..._environment } 13 | 14 | /* 15 | * For easier debugging in development mode, you can import the following file 16 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 17 | * 18 | * This import should be commented out in production mode because it will have a negative impact 19 | * on performance if an error is thrown. 20 | */ 21 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 22 | -------------------------------------------------------------------------------- /src/ui/client/src/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/client/src/favicon.png -------------------------------------------------------------------------------- /src/ui/client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Modron 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/ui/client/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from "@angular/core"; 2 | import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; 3 | 4 | import { AppModule } from "./app/app.module"; 5 | import { environment } from "./environments/environment"; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /src/ui/client/src/styles.scss: -------------------------------------------------------------------------------- 1 | @use '@angular/material' as mat; 2 | @import '@material-symbols/font-400'; 3 | @import 'colors.scss'; 4 | 5 | /* You can add global styles to this file, and also import other style files */ 6 | @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans&display=swap'); 7 | 8 | @include mat.all-component-themes($my-theme); 9 | 10 | body { 11 | font-family: 'IBM Plex Sans', sans-serif; 12 | margin: 0; 13 | height: 100% 14 | } 15 | 16 | button { 17 | border: none; 18 | cursor: pointer; 19 | } 20 | 21 | button:active { 22 | background-color: rgb(237, 237, 237); 23 | border: none; 24 | cursor: pointer; 25 | } 26 | 27 | input { 28 | border: none; 29 | background-color: rgb(249, 249, 249); 30 | } 31 | 32 | /* 33 | Styles for dynamic elements 34 | */ 35 | .mat-column-shortDesc > p{ 36 | max-width: 100%; 37 | overflow: hidden; 38 | text-overflow: ellipsis; 39 | } 40 | 41 | .markdown-content { 42 | p { 43 | margin-top: 0; 44 | margin-bottom: 0; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/ui/client/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import "zone.js/testing" 4 | import { getTestBed } from "@angular/core/testing" 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from "@angular/platform-browser-dynamic/testing" 9 | 10 | // First, initialize the Angular testing environment. 11 | getTestBed().initTestEnvironment( 12 | BrowserDynamicTestingModule, 13 | platformBrowserDynamicTesting(), 14 | ) 15 | -------------------------------------------------------------------------------- /src/ui/client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts", 10 | "src/polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts", 14 | "src/proto/**/*.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/ui/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "downlevelIteration": true, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "ES2022", 21 | "lib": [ 22 | "es2020", 23 | "dom" 24 | ], 25 | }, 26 | "angularCompilerOptions": { 27 | "enableI18nLegacyMessageIdFormat": false, 28 | "strictInjectionParameters": true, 29 | "strictInputAccessModifiers": true, 30 | "strictTemplates": true 31 | }, 32 | "exclude": [ 33 | "cypress.config.ts" 34 | ], 35 | "files": [ 36 | "cypress.config.ts" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /src/ui/client/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/ui/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | mock-grpc-server: 4 | build: 5 | context: mock-grpc-server 6 | networks: 7 | - envoy-net 8 | envoy: 9 | image: envoyproxy/envoy:v1.29-latest 10 | volumes: 11 | - ./mock-grpc-server/envoy.yaml:/etc/envoy/envoy.yaml:ro 12 | ports: 13 | - "4201:4201" 14 | networks: 15 | - envoy-net 16 | networks: 17 | envoy-net: 18 | -------------------------------------------------------------------------------- /src/ui/go.mod: -------------------------------------------------------------------------------- 1 | module server 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/golang/glog v1.2.2 7 | google.golang.org/api v0.203.0 8 | ) 9 | 10 | require ( 11 | cloud.google.com/go/auth v0.10.0 // indirect 12 | cloud.google.com/go/auth/oauth2adapt v0.2.5 // indirect 13 | cloud.google.com/go/compute/metadata v0.5.2 // indirect 14 | github.com/felixge/httpsnoop v1.0.4 // indirect 15 | github.com/go-logr/logr v1.4.2 // indirect 16 | github.com/go-logr/stdr v1.2.2 // indirect 17 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 18 | github.com/golang/protobuf v1.5.4 // indirect 19 | github.com/google/s2a-go v0.1.8 // indirect 20 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 21 | github.com/googleapis/gax-go/v2 v2.13.0 // indirect 22 | go.opencensus.io v0.24.0 // indirect 23 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect 24 | go.opentelemetry.io/otel v1.31.0 // indirect 25 | go.opentelemetry.io/otel/metric v1.31.0 // indirect 26 | go.opentelemetry.io/otel/trace v1.31.0 // indirect 27 | golang.org/x/crypto v0.28.0 // indirect 28 | golang.org/x/net v0.30.0 // indirect 29 | golang.org/x/oauth2 v0.23.0 // indirect 30 | golang.org/x/sys v0.26.0 // indirect 31 | golang.org/x/text v0.19.0 // indirect 32 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect 33 | google.golang.org/grpc v1.67.1 // indirect 34 | google.golang.org/protobuf v1.35.1 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /src/ui/mock-grpc-server/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /src/ui/mock-grpc-server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts 2 | COPY . /app 3 | WORKDIR /app 4 | RUN npm install 5 | ENV NODE_OPTIONS='--loader ts-node/esm' 6 | ENTRYPOINT ["node", "server.ts"] 7 | -------------------------------------------------------------------------------- /src/ui/mock-grpc-server/copy-proto.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | # Unfortunately Docker doesn't support symlinks, so we need to copy the proto files 4 | 5 | # Make sure we're running in the script directory 6 | pushd "$(dirname "$0")" 7 | cp ../../proto/*.proto proto/ 8 | popd 9 | 10 | -------------------------------------------------------------------------------- /src/ui/mock-grpc-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "modron-mock-server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "type": "module", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "dev": "NODE_OPTIONS='--loader ts-node/esm' npx nodemon -- server.ts", 9 | "genproto": "./generate_proto.sh" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@alenon/grpc-mock-server": "^3.0.21", 15 | "wait-for-sigint": "^0.1.0", 16 | "nodemon": "^3.0.1", 17 | "ts-node": "^10.9.1", 18 | "@grpc/grpc-js": "^1.8", 19 | "@improbable-eng/grpc-web": "^0.15.0", 20 | "@types/google-protobuf": "^3.15.6", 21 | "google-protobuf": "^3.21.2" 22 | }, 23 | "devDependencies": { 24 | "ts-protoc-gen": "^0.15.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/ui/mock-grpc-server/proto/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nianticlabs/modron/40584d1b62990a6dd62409cb96500364098c446c/src/ui/mock-grpc-server/proto/.gitkeep -------------------------------------------------------------------------------- /src/ui/mock-grpc-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "module": "ES2022", 5 | "moduleResolution": "node16", 6 | "target": "ES2022", 7 | }, 8 | "include": [ 9 | "/**/*.ts" 10 | ], 11 | "exclude": [ 12 | "node_modules" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "postinstall": "(cd client && npm install); (cd mock-grpc-server && npm install)", 6 | "dev": "concurrently --kill-others \"npm run dev:client\" \"docker-compose up -d\"", 7 | "dev:client": "cd client/ && npm run ng -- serve --verbose --proxy-config ../proxy.conf.json", 8 | "dev:mock-grpc-server-envoy": "cd mock-grpc-server/ && docker run --rm -p4201:4201 -p9901:9901 -v $(pwd)/envoy.yaml:/etc/envoy/envoy.yaml:ro -t envoyproxy/envoy:v1.24-latest", 9 | "dev:mock-grpc-server": "npm run --prefix mock-grpc-server/ dev" 10 | }, 11 | "dependencies": { 12 | "concurrently": "^8.2.2" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/ui/proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/ModronService/*": { 3 | "target": "http://localhost:4201", 4 | "secure": false, 5 | "changeOrigin": true, 6 | "logLevel": "debug" 7 | }, 8 | "/NotificationService/*": { 9 | "target": "http://localhost:4201", 10 | "secure": false, 11 | "changeOrigin": true, 12 | "logLevel": "debug" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/gke.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func GetGKEReference(resourceLink string) (projectID string, location string, clusterName string, namespace string) { 9 | // resourceLink is formatted as follows: 10 | // //container.googleapis.com/projects/project-id/zones/us-central1-b/clusters/gke-cluster-name/k8s/namespaces/kubernetes-ns-name 11 | split := strings.Split(resourceLink, "/") 12 | if len(split) != 12 { //nolint:mnd 13 | return "", "", "", "" 14 | } 15 | projectID = split[4] 16 | location = split[6] 17 | clusterName = split[8] 18 | namespace = split[11] 19 | return 20 | } 21 | 22 | func GetGkePodLink(name string, parent string) string { 23 | // name is like "my-pod-name" 24 | // parent is "//container.googleapis.com/projects/project-id/zones/us-central1-b/clusters/gke-cluster-name/k8s/namespaces/kubernetes-ns-name" 25 | projectID, location, clusterName, namespace := GetGKEReference(parent) 26 | return fmt.Sprintf("https://console.cloud.google.com/kubernetes/pod/%s/%s/%s/%s/details?project=%s", 27 | location, 28 | clusterName, 29 | namespace, 30 | name, 31 | projectID, 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/gke_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "testing" 4 | 5 | func TestGetGKEReference(t *testing.T) { 6 | tc := [][]string{ 7 | { 8 | "//container.googleapis.com/projects/project-id/zones/us-central1-b/clusters/gke-cluster-name/k8s/namespaces/kubernetes-ns-name", 9 | "project-id", 10 | "us-central1-b", 11 | "gke-cluster-name", 12 | "kubernetes-ns-name", 13 | }, 14 | } 15 | 16 | for _, tt := range tc { 17 | projectID, location, clusterName, namespace := GetGKEReference(tt[0]) 18 | if projectID != tt[1] { 19 | t.Errorf("expected projectID %s, got %s", tt[1], projectID) 20 | } 21 | if location != tt[2] { 22 | t.Errorf("expected location %s, got %s", tt[2], location) 23 | } 24 | if clusterName != tt[3] { 25 | t.Errorf("expected clusterName %s, got %s", tt[3], clusterName) 26 | } 27 | if namespace != tt[4] { 28 | t.Errorf("expected namespace %s, got %s", tt[4], namespace) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/groups.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "golang.org/x/exp/maps" 5 | 6 | pb "github.com/nianticlabs/modron/src/proto/generated" 7 | ) 8 | 9 | func GroupsFromResources(resources []*pb.Resource) (allGroups []string) { 10 | resourceGroups := map[string]struct{}{} 11 | for _, r := range resources { 12 | resourceGroups[r.ResourceGroupName] = struct{}{} 13 | } 14 | return maps.Keys(resourceGroups) 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/hierarchy.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | 6 | pb "github.com/nianticlabs/modron/src/proto/generated" 7 | ) 8 | 9 | func ComputeRgHierarchy(resources []*pb.Resource) (map[string]*pb.RecursiveResource, error) { 10 | resourceMap := make(map[string]*pb.RecursiveResource) 11 | for _, r := range resources { 12 | if r.GetResourceGroup() == nil { 13 | continue 14 | } 15 | recRes, err := ToRecursiveResource(r) 16 | if err != nil { 17 | return nil, err 18 | } 19 | resourceMap[r.Name] = recRes 20 | } 21 | 22 | for _, r := range resources { 23 | if r.Parent == "" { 24 | var err error 25 | resourceMap[""], err = ToRecursiveResource(r) 26 | if err != nil { 27 | return nil, err 28 | } 29 | continue 30 | } 31 | parent, ok := resourceMap[r.Parent] 32 | if !ok { 33 | log.Warnf("parent %q not found", r.Parent) 34 | if r.ResourceGroupName == "" { 35 | log.Errorf("resource %q has no parent and no resource group", r.Name) 36 | continue 37 | } 38 | if r.ResourceGroupName == r.Name { 39 | log.Errorf("resource %q is its own parent", r.Name) 40 | continue 41 | } 42 | parent, ok = resourceMap[r.ResourceGroupName] 43 | if !ok { 44 | log.Errorf("resource group %q not found, is %q orphan?", r.ResourceGroupName, r.Name) 45 | continue 46 | } 47 | } 48 | recRes, err := ToRecursiveResource(r) 49 | if err != nil { 50 | return nil, fmt.Errorf("toRecursiveResource: %w", err) 51 | } 52 | parent.Children = append(parent.Children, recRes) 53 | resourceMap[r.Parent] = parent 54 | } 55 | return resourceMap, nil 56 | } 57 | 58 | func ToRecursiveResource(r *pb.Resource) (*pb.RecursiveResource, error) { 59 | t, err := TypeFromResource(r) 60 | if err != nil { 61 | return nil, fmt.Errorf("typeFromResourceAsString: %w", err) 62 | } 63 | return &pb.RecursiveResource{ 64 | Uuid: r.Uid, 65 | Name: r.Name, 66 | DisplayName: r.DisplayName, 67 | Parent: r.Parent, 68 | Type: t, 69 | Labels: r.Labels, 70 | Tags: r.Tags, 71 | }, nil 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/keys.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/sirupsen/logrus" 7 | 8 | "github.com/nianticlabs/modron/src/constants" 9 | ) 10 | 11 | var log = logrus.StandardLogger().WithField(constants.LogKeyPkg, "utils") 12 | 13 | const keyParts = 6 14 | 15 | // GetKeyID converts a key reference (projects/my-project/serviceAccounts/sa-1/keys/abc) to a key ID (abc). 16 | func GetKeyID(keyRef string) string { 17 | if !strings.HasPrefix(keyRef, constants.GCPProjectsNamePrefix) { 18 | log.Errorf("keyRef %s does not start with %s", keyRef, constants.GCPProjectsNamePrefix) 19 | return keyRef 20 | } 21 | 22 | split := strings.Split(keyRef, "/") 23 | if len(split) < keyParts { 24 | log.Errorf("keyRef %s has less than 6 parts", keyRef) 25 | return keyRef 26 | } 27 | 28 | return split[5] 29 | } 30 | 31 | func GetServiceAccountNameFromKeyRef(keyRef string) string { 32 | if !strings.HasPrefix(keyRef, constants.GCPProjectsNamePrefix) { 33 | log.Errorf("keyRef %s does not start with %s", keyRef, constants.GCPProjectsNamePrefix) 34 | return keyRef 35 | } 36 | 37 | split := strings.Split(keyRef, "/") 38 | if len(split) < keyParts { 39 | log.Errorf("keyRef %s has less than 6 parts", keyRef) 40 | return keyRef 41 | } 42 | 43 | return split[3] 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/keys_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nianticlabs/modron/src/utils" 7 | ) 8 | 9 | func TestGetKeyID(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | keyRef string 13 | expected string 14 | }{ 15 | { 16 | name: "valid key reference", 17 | keyRef: "projects/my-project/serviceAccounts/sa-1/keys/abc", 18 | expected: "abc", 19 | }, 20 | { 21 | name: "invalid key reference", 22 | keyRef: "invalid-key-reference", 23 | expected: "invalid-key-reference", 24 | }, 25 | { 26 | name: "key reference with less than 6 parts", 27 | keyRef: "projects/my-project/serviceAccounts/sa-1/keys", 28 | expected: "projects/my-project/serviceAccounts/sa-1/keys", 29 | }, 30 | } 31 | for _, tt := range tests { 32 | t.Run(tt.name, func(t *testing.T) { 33 | if got := utils.GetKeyID(tt.keyRef); got != tt.expected { 34 | t.Errorf("GetKeyID() = %v, want %v", got, tt.expected) 35 | } 36 | }) 37 | } 38 | } 39 | 40 | func TestGetServiceAccountNameFromKeyRef(t *testing.T) { 41 | tests := []struct { 42 | name string 43 | keyRef string 44 | expected string 45 | }{ 46 | { 47 | name: "valid key reference", 48 | keyRef: "projects/my-project/serviceAccounts/sa-1/keys/abc", 49 | expected: "sa-1", 50 | }, 51 | { 52 | name: "invalid key reference", 53 | keyRef: "invalid-key-reference", 54 | expected: "invalid-key-reference", 55 | }, 56 | { 57 | name: "key reference with less than 6 parts", 58 | keyRef: "projects/my-project/serviceAccounts/sa-1/keys", 59 | expected: "projects/my-project/serviceAccounts/sa-1/keys", 60 | }, 61 | } 62 | for _, tt := range tests { 63 | t.Run(tt.name, func(t *testing.T) { 64 | if got := utils.GetServiceAccountNameFromKeyRef(tt.keyRef); got != tt.expected { 65 | t.Errorf("GetServiceAccountNameFromKeyRef() = %v, want %v", got, tt.expected) 66 | } 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/utils/name.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "strings" 4 | 5 | const ( 6 | containerAPI = "//container.googleapis.com/" 7 | iamAPI = "//iam.googleapis.com/" 8 | computeAPI = "//compute.googleapis.com/" 9 | ) 10 | 11 | // GetHumanReadableName returns the human-readable name of the resource - we currently use an allow-list of APIs 12 | // to avoid interpreting the resource name incorrectly. 13 | func GetHumanReadableName(resourceLink string) string { 14 | for _, p := range []string{containerAPI, iamAPI, computeAPI} { 15 | if strings.HasPrefix(resourceLink, p) { 16 | r := strings.TrimPrefix(resourceLink, p) 17 | split := strings.Split(r, "/") 18 | return split[len(split)-1] 19 | } 20 | } 21 | return resourceLink 22 | } 23 | 24 | func StripProjectsPrefix(prefixedProject string) string { 25 | return strings.TrimPrefix(prefixedProject, "projects/") 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/name_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | 8 | "github.com/nianticlabs/modron/src/utils" 9 | ) 10 | 11 | func TestGetHumanReadableName(t *testing.T) { 12 | tc := [][]string{ 13 | { 14 | "//container.googleapis.com/projects/xyz/locations/us-central1/clusters/cluster-name/k8s/namespaces/kube-system/pods/my-pod-1", 15 | "my-pod-1", 16 | }, 17 | { 18 | "//container.googleapis.com/projects/xyz/locations/us-central1/clusters/cluster-name/k8s/namespaces/kube-system", 19 | "kube-system", 20 | }, 21 | { 22 | "//container.googleapis.com/projects/xyz/locations/us-central1/clusters/cluster-name", 23 | "cluster-name", 24 | }, 25 | { 26 | "//container.googleapis.com/projects/xyz/locations/us-central1", 27 | "us-central1", 28 | }, 29 | { 30 | "//container.googleapis.com/projects/xyz", 31 | "xyz", 32 | }, 33 | { 34 | "//iam.googleapis.com/projects/example-project/serviceAccounts/my-service-account@example-project.iam.gserviceaccount.com", 35 | "my-service-account@example-project.iam.gserviceaccount.com", 36 | }, 37 | { 38 | "//iam.googleapis.com/projects/example-project/serviceAccounts/3984989392373/keys/b8ceb3f5d69d4e46acc9e74bf224d4e9", 39 | "b8ceb3f5d69d4e46acc9e74bf224d4e9", 40 | }, 41 | { 42 | "//compute.googleapis.com/projects/example-project/zones/us-central1-f/instances/my-instance-4897322-03032024-cxx1-test-a0b0c0", 43 | "my-instance-4897322-03032024-cxx1-test-a0b0c0", 44 | }, 45 | { 46 | "//container.googleapis.com/projects/example-1/zones/us-central1-b/clusters/security-runners/k8s/namespaces/twistlock", 47 | "twistlock", 48 | }, 49 | { 50 | "//container.googleapis.com/projects/example-2/zones/us-central1-b/clusters/security-runners", 51 | "security-runners", 52 | }, 53 | } 54 | 55 | for _, c := range tc { 56 | want := c[1] 57 | got := utils.GetHumanReadableName(c[0]) 58 | if diff := cmp.Diff(want, got); diff != "" { 59 | t.Errorf("GetHumanReadableName(%q) mismatch (-want +got):\n%s", c[0], diff) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/protobuf.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | 6 | "google.golang.org/protobuf/proto" 7 | 8 | pb "github.com/nianticlabs/modron/src/proto/generated" 9 | ) 10 | 11 | func TypeFromResource(rsrc *pb.Resource) (ty string, err error) { 12 | if rsrc == nil { 13 | return "", fmt.Errorf("resource must not be nil") 14 | } 15 | reflectMsg := rsrc.ProtoReflect() 16 | if reflectMsg == nil { 17 | return "", fmt.Errorf("ProtoReflect() returned nil") 18 | } 19 | typeField := reflectMsg.Descriptor().Oneofs().ByName("type") 20 | if typeField == nil { 21 | return "", fmt.Errorf("cannot find field \"type\"") 22 | } 23 | field := reflectMsg.WhichOneof(typeField) 24 | if field == nil { 25 | return "", fmt.Errorf("cannot find field in oneof") 26 | } 27 | fieldMessage := reflectMsg.Get(field).Message() 28 | if fieldMessage == nil { 29 | return "", fmt.Errorf("field message is nil") 30 | } 31 | ty = string(fieldMessage.Descriptor().FullName()) 32 | return 33 | } 34 | 35 | func ProtoAcceptsTypes(types []proto.Message) (res []string) { 36 | for _, t := range types { 37 | res = append(res, string(t.ProtoReflect().Descriptor().FullName())) 38 | } 39 | return 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/ref.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func RefOrNull(s string) *string { 4 | if s == "" { 5 | return nil 6 | } 7 | 8 | return &s 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/resource_ref.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import pb "github.com/nianticlabs/modron/src/proto/generated" 4 | 5 | func GetResourceRef(rsrc *pb.Resource) *pb.ResourceRef { 6 | if rsrc == nil { 7 | return nil 8 | } 9 | return &pb.ResourceRef{ 10 | Uid: &rsrc.Uid, 11 | GroupName: rsrc.ResourceGroupName, 12 | ExternalId: RefOrNull(rsrc.Name), 13 | CloudPlatform: pb.CloudPlatform_GCP, // TODO: Change when we have more cloud platforms 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/rule.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/nianticlabs/modron/src/model" 8 | ) 9 | 10 | func GetRuleConfig[T any](ctx context.Context, e model.Engine, name string, c *T) error { 11 | v, err := e.GetRuleConfig(ctx, name) 12 | if err != nil { 13 | log.Errorf("no config found for rule %q: %v", name, err) 14 | return err 15 | } 16 | return json.Unmarshal(v, c) 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/service_account.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | const ( 4 | appspotServiceAccountSuffix = "@appspot.gserviceaccount.com" 5 | iamGServiceAccountSuffix = ".iam.gserviceaccount.com" 6 | developerGserviceAccountSuffix = "@developer.gserviceaccount.com" 7 | ) 8 | -------------------------------------------------------------------------------- /src/utils/service_account_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | 8 | "github.com/nianticlabs/modron/src/utils" 9 | ) 10 | 11 | func TestGetProjectFromSAEmail(t *testing.T) { 12 | tc := []struct { 13 | saEmail string 14 | expected string 15 | }{ 16 | { 17 | "gitlab-sa@example-project.iam.gserviceaccount.com", 18 | "example-project", 19 | }, 20 | { 21 | "example-project@appspot.gserviceaccount.com", 22 | "example-project", 23 | }, 24 | { 25 | "123456789012-compute@developer.gserviceaccount.com", 26 | "", 27 | }, 28 | } 29 | 30 | for _, tt := range tc { 31 | if diff := cmp.Diff(tt.expected, utils.GetGCPProjectFromSAEmail(tt.saEmail)); diff != "" { 32 | t.Errorf("unexpected result (-want +got):\n%s", diff) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /terraform/README.md: -------------------------------------------------------------------------------- 1 | # Terraform config for Modron 2 | 3 | Apply the terraform config for Modron: 4 | 5 | ``` 6 | cd dev # cd prod if you want to deploy to prod 7 | tf plan -out tf.plan 8 | # Verify in the output that all the changes make sense and align with what you want to do. 9 | tf apply "tf.plan" 10 | ``` 11 | -------------------------------------------------------------------------------- /terraform/dev/main.tf.example: -------------------------------------------------------------------------------- 1 | // place your variables here 2 | 3 | module "modron" { 4 | source = "../modron" 5 | 6 | domain = "modron-dev.example.com" 7 | env = "dev" 8 | org_id = "GCP_ORGID" 9 | project = "my-modron-dev" 10 | zone = "GCP_ZONE" 11 | 12 | modron_admins = [ 13 | "group:modron-admins@example.com" 14 | ] 15 | modron_users = [ 16 | "group:modron-users@example.com", 17 | ] 18 | project_admins = [ 19 | "group:modron-project-admins@example.com" 20 | ] 21 | docker_registry = "mirror.gcr.io" 22 | notification_system = "https://notification-system.example.com" 23 | notification_system_client_id = "client-id" 24 | org_suffix = "@example.com" 25 | } 26 | -------------------------------------------------------------------------------- /terraform/modron/artifact_registry.tf: -------------------------------------------------------------------------------- 1 | resource "google_artifact_registry_repository" "registry" { 2 | location = local.region 3 | repository_id = "modron" 4 | description = "Modron Docker images" 5 | format = "DOCKER" 6 | } 7 | 8 | # writer is not enough: GitLab needs to be able to delete tags 9 | # otherwise the pipeline will fail with IAM_PERMISSION_DENIED when trying to replace the :dev / :prod tags 10 | data "google_iam_policy" "modron_repository_editor_policy" { 11 | binding { 12 | role = "organizations/0123456789/roles/ArtifactRegistryDockerEditor" 13 | members = [ 14 | "serviceAccount:${google_service_account.deployer_SA.email}" 15 | ] 16 | } 17 | } 18 | 19 | resource "google_artifact_registry_repository_iam_policy" "modron_repository_write_policy" { 20 | location = google_artifact_registry_repository.registry.location 21 | repository = google_artifact_registry_repository.registry.name 22 | policy_data = data.google_iam_policy.modron_repository_editor_policy.policy_data 23 | } 24 | -------------------------------------------------------------------------------- /terraform/modron/dns_logging.tf: -------------------------------------------------------------------------------- 1 | resource "google_project_service" "dns_service" { 2 | service = "dns.googleapis.com" 3 | } 4 | 5 | resource "google_dns_policy" "dns-logging" { 6 | name = "dns-logging" 7 | 8 | enable_logging = true 9 | 10 | networks { 11 | network_url = google_compute_network.cloud_run_network.id 12 | } 13 | depends_on = [ 14 | google_project_service.dns_service 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /terraform/modron/gitlab.tf: -------------------------------------------------------------------------------- 1 | resource "google_service_account" "deployer_SA" { 2 | account_id = "gitlab-deployer" 3 | description = "Used by Gitlab to deploy on Cloud Run." 4 | display_name = "gitlab-deployer" 5 | } 6 | 7 | data "google_iam_policy" "gitlab_deployer" { 8 | binding { 9 | role = "roles/iam.serviceAccountTokenCreator" 10 | members = [ 11 | "serviceAccount:${var.gitlab_impersonator_service_account}", 12 | ] 13 | } 14 | count = var.gitlab_impersonator_service_account != "" ? 1 : 0 15 | } 16 | 17 | resource "google_service_account_iam_policy" "gitlab_deployer_iam_policy" { 18 | policy_data = data.google_iam_policy.gitlab_deployer[0].policy_data 19 | service_account_id = google_service_account.deployer_SA.name 20 | count = var.gitlab_impersonator_service_account != "" ? 1 : 0 21 | } 22 | 23 | resource "google_project_iam_member" "gitlab_cloud_build" { 24 | project = var.project 25 | role = "roles/cloudbuild.builds.editor" 26 | member = "serviceAccount:${google_service_account.deployer_SA.email}" 27 | } 28 | 29 | resource "google_project_iam_member" "gitlab_run_developer" { 30 | project = var.project 31 | role = "roles/run.developer" 32 | member = "serviceAccount:${google_service_account.deployer_SA.email}" 33 | } 34 | 35 | # This is required to build, according to Google it is compatible with the concept of least privilege -_- 36 | # https://cloud.google.com/build/docs/securing-builds/store-manage-build-logs#viewing_build_logs 37 | # TODO: Update to a custom log bucket and remove this permission. 38 | resource "google_project_iam_member" "gitlab_cloud_build_storage" { 39 | project = var.project 40 | role = "roles/viewer" 41 | member = "serviceAccount:${google_service_account.deployer_SA.email}" 42 | } 43 | -------------------------------------------------------------------------------- /terraform/modron/iap.tf: -------------------------------------------------------------------------------- 1 | resource "google_iap_brand" "project_brand" { 2 | support_email = "modron-support${var.org_suffix}" 3 | application_title = "Modron ${title(var.env)}" 4 | } 5 | 6 | resource "google_iap_client" "project_client" { 7 | display_name = "Modron ${title(var.env)}" 8 | brand = google_iap_brand.project_brand.name 9 | } 10 | 11 | data "google_iam_policy" "iap_web_users" { 12 | binding { 13 | role = "roles/iap.httpsResourceAccessor" 14 | members = concat(var.modron_users, var.modron_admins) 15 | } 16 | } 17 | 18 | resource "google_iap_web_iam_policy" "users" { 19 | policy_data = data.google_iam_policy.iap_web_users.policy_data 20 | } 21 | 22 | resource "google_project_iam_member" "jump_host_ssh_accessors" { 23 | project = var.project 24 | role = "roles/iap.tunnelResourceAccessor" 25 | for_each = toset(var.modron_admins) 26 | member = each.key 27 | } 28 | -------------------------------------------------------------------------------- /terraform/modron/main.tf: -------------------------------------------------------------------------------- 1 | provider "google" { 2 | project = var.project 3 | region = local.region 4 | access_token = data.google_service_account_access_token.sa.access_token 5 | zone = var.zone 6 | } 7 | 8 | locals { 9 | region = substr(var.zone, 0, length(var.zone) - 2) 10 | } 11 | 12 | resource "google_compute_ssl_policy" "modern_TLS_policy" { 13 | min_tls_version = "TLS_1_2" 14 | name = "modern-ssl-policy" 15 | profile = "MODERN" 16 | } 17 | -------------------------------------------------------------------------------- /terraform/modron/network.tf: -------------------------------------------------------------------------------- 1 | resource "google_compute_network" "cloud_run_network" { 2 | auto_create_subnetworks = true 3 | mtu = 1460 4 | name = "cloud-run-network" 5 | routing_mode = "REGIONAL" 6 | depends_on = [ 7 | google_project_service.compute_service 8 | ] 9 | } 10 | 11 | resource "google_compute_region_network_endpoint_group" "grpc_web_neg" { 12 | name = "modron-grpc-web-${var.env}-endpoint" 13 | network_endpoint_type = "SERVERLESS" 14 | region = local.region 15 | cloud_run { 16 | service = google_cloud_run_v2_service.grpc_web.name 17 | } 18 | } 19 | 20 | resource "google_compute_region_network_endpoint_group" "ui_neg" { 21 | name = "modron-ui-${var.env}-endpoint" 22 | network_endpoint_type = "SERVERLESS" 23 | region = local.region 24 | cloud_run { 25 | service = google_cloud_run_v2_service.ui.name 26 | } 27 | } 28 | 29 | 30 | resource "google_vpc_access_connector" "connector" { 31 | name = "cloud-run-vpc-connector" 32 | network = google_compute_network.cloud_run_network.name 33 | ip_cidr_range = "10.42.0.0/28" 34 | } 35 | 36 | # This is required to install packages on the SQL jump host 37 | resource "google_compute_router" "router" { 38 | name = "sql-jump-host" 39 | region = local.region 40 | network = google_compute_network.cloud_run_network.id 41 | 42 | bgp { 43 | asn = 64514 44 | } 45 | } 46 | 47 | resource "google_compute_router_nat" "nat" { 48 | name = "sql-jump-host" 49 | router = google_compute_router.router.name 50 | region = google_compute_router.router.region 51 | nat_ip_allocate_option = "AUTO_ONLY" 52 | source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES" 53 | 54 | log_config { 55 | enable = true 56 | filter = "ERRORS_ONLY" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /terraform/modron/otel/README.md: -------------------------------------------------------------------------------- 1 | # otel-collector 2 | 3 | Configuration adapted from [GoogleCloudRun/opentelemetry-cloud-run](https://github.com/GoogleCloudPlatform/opentelemetry-cloud-run) 4 | -------------------------------------------------------------------------------- /terraform/modron/otel/config.yaml: -------------------------------------------------------------------------------- 1 | receivers: 2 | otlp: 3 | protocols: 4 | grpc: 5 | endpoint: 0.0.0.0:4317 6 | http: 7 | endpoint: 0.0.0.0:4318 8 | 9 | processors: 10 | batch: 11 | # batch metrics before sending to reduce API usage 12 | send_batch_max_size: 200 13 | send_batch_size: 200 14 | timeout: 5s 15 | 16 | memory_limiter: 17 | # drop metrics if memory usage gets too high 18 | check_interval: 1s 19 | limit_percentage: 65 20 | spike_limit_percentage: 20 21 | 22 | resourcedetection: 23 | detectors: [env, gcp] 24 | timeout: 2s 25 | override: false 26 | 27 | exporters: 28 | googlecloud: 29 | log: 30 | default_log_name: "otel-collector" 31 | otlphttp: 32 | endpoint: "http://10.43.0.2:80" 33 | tls: 34 | insecure: true 35 | googlemanagedprometheus: 36 | 37 | extensions: 38 | health_check: 39 | endpoint: "0.0.0.0:13133" 40 | 41 | service: 42 | extensions: [health_check] 43 | pipelines: 44 | traces: 45 | receivers: [otlp] 46 | processors: [resourcedetection] 47 | exporters: [googlecloud,otlphttp] 48 | logs: 49 | receivers: [otlp] 50 | processors: [resourcedetection] 51 | exporters: [googlecloud] 52 | metrics: 53 | receivers: [otlp] 54 | processors: [resourcedetection] 55 | exporters: [googlemanagedprometheus] 56 | -------------------------------------------------------------------------------- /terraform/modron/project.tf: -------------------------------------------------------------------------------- 1 | resource "google_project_iam_binding" "cloud_sql_admins" { 2 | project = var.project 3 | role = "roles/cloudsql.admin" 4 | members = var.project_admins 5 | } 6 | 7 | resource "google_project_iam_binding" "cloud_trace_agent" { 8 | project = var.project 9 | role = "roles/cloudtrace.agent" 10 | members = [ 11 | "serviceAccount:${google_service_account.modron_runner.email}", 12 | ] 13 | } 14 | 15 | resource "google_project_iam_binding" "monitoring_writer" { 16 | project = var.project 17 | role = "roles/monitoring.metricWriter" 18 | members = [ 19 | "serviceAccount:${google_service_account.modron_runner.email}", 20 | ] 21 | } -------------------------------------------------------------------------------- /terraform/modron/secret.tf: -------------------------------------------------------------------------------- 1 | resource "google_secret_manager_secret" "sql_connect_string_config" { 2 | secret_id = "sql_connect_string" 3 | 4 | replication { 5 | user_managed { 6 | replicas { 7 | location = local.region 8 | } 9 | } 10 | } 11 | } 12 | 13 | resource "google_secret_manager_secret_version" "sql_connect_string" { 14 | secret = google_secret_manager_secret.sql_connect_string_config.id 15 | 16 | # We use SQL proxy in cloud run, this is fine to disable SSL here as the proxy runs on the same instance and authenticates the instance. 17 | secret_data = "host=/cloudsql/${google_sql_database_instance.instance.connection_name} user=${google_sql_user.iam_user.name} dbname=modron${var.env} sslmode=disable password=${random_password.sql_user_password.result}" 18 | } 19 | 20 | data "google_iam_policy" "secret_accessors_policy" { 21 | binding { 22 | role = "roles/secretmanager.secretAccessor" 23 | members = [ 24 | "serviceAccount:${google_service_account.modron_runner.email}" 25 | ] 26 | } 27 | } 28 | 29 | resource "google_secret_manager_secret_iam_policy" "prod_accesses_secret" { 30 | secret_id = google_secret_manager_secret.sql_connect_string_config.secret_id 31 | policy_data = data.google_iam_policy.secret_accessors_policy.policy_data 32 | } 33 | -------------------------------------------------------------------------------- /terraform/modron/service_account.tf: -------------------------------------------------------------------------------- 1 | resource "google_service_account" "modron_runner" { 2 | account_id = "modron-${var.env}-runner" 3 | description = "Modron ${var.env} runner" 4 | display_name = "modron-${var.env}-runner" 5 | } 6 | 7 | locals { 8 | service_account_sa_users = [for v in compact([ 9 | google_service_account.deployer_SA.email, 10 | var.gitlab_impersonator_service_account, 11 | ]) : "serviceAccount:${v}"] 12 | } 13 | 14 | resource "google_service_account_iam_binding" "modron_runner_user" { 15 | service_account_id = google_service_account.modron_runner.name 16 | role = "roles/iam.serviceAccountUser" 17 | members = concat(local.service_account_sa_users, var.project_admins) 18 | } 19 | 20 | resource "google_project_iam_member" "runner_log_writer" { 21 | project = var.project 22 | role = "roles/logging.logWriter" 23 | member = "serviceAccount:${google_service_account.modron_runner.email}" 24 | } 25 | 26 | resource "google_project_iam_member" "project_monitoring" { 27 | project = var.project 28 | role = "roles/monitoring.metricWriter" 29 | member = "serviceAccount:${google_service_account.modron_runner.email}" 30 | } 31 | 32 | resource "google_project_iam_member" "sql_client_iam" { 33 | project = var.project 34 | role = "roles/cloudsql.client" 35 | member = "serviceAccount:${google_service_account.modron_runner.email}" 36 | } 37 | ############ 38 | 39 | resource "google_service_account" "jump_host_runner" { 40 | account_id = "modron-${var.env}-sql-jumphost" 41 | display_name = "modron-${var.env}-sql-jumphost" 42 | } 43 | 44 | resource "google_service_account_iam_binding" "jump_host_runner_user" { 45 | service_account_id = google_service_account.jump_host_runner.name 46 | role = "roles/iam.serviceAccountUser" 47 | members = var.project_admins 48 | } 49 | 50 | resource "google_project_iam_member" "jump_host_log_writer" { 51 | project = var.project 52 | role = "roles/logging.logWriter" 53 | member = "serviceAccount:${google_service_account.jump_host_runner.email}" 54 | } 55 | -------------------------------------------------------------------------------- /terraform/modron/services.tf: -------------------------------------------------------------------------------- 1 | resource "google_project_service" "apikeys_service" { 2 | service = "apikeys.googleapis.com" 3 | } 4 | resource "google_project_service" "bigquery_service" { 5 | service = "bigquery.googleapis.com" 6 | } 7 | resource "google_project_service" "cloudasset_service" { 8 | service = "cloudasset.googleapis.com" 9 | } 10 | resource "google_project_service" "cloudidentity_service" { 11 | service = "cloudidentity.googleapis.com" 12 | } 13 | resource "google_project_service" "cloud_resource_manager_service" { 14 | service = "cloudresourcemanager.googleapis.com" 15 | } 16 | resource "google_project_service" "cloudbuild_service" { 17 | service = "cloudbuild.googleapis.com" 18 | } 19 | resource "google_project_service" "compute_service" { 20 | service = "compute.googleapis.com" 21 | } 22 | resource "google_project_service" "container_service" { 23 | service = "container.googleapis.com" 24 | } 25 | resource "google_project_service" "iam_service" { 26 | service = "iam.googleapis.com" 27 | } 28 | resource "google_project_service" "iap_service" { 29 | service = "iap.googleapis.com" 30 | } 31 | resource "google_project_service" "run_service" { 32 | service = "run.googleapis.com" 33 | } 34 | resource "google_project_service" "secretmanager_service" { 35 | service = "secretmanager.googleapis.com" 36 | } 37 | resource "google_project_service" "servicenetworking_service" { 38 | service = "servicenetworking.googleapis.com" 39 | } 40 | resource "google_project_service" "serviceusage_service" { 41 | service = "serviceusage.googleapis.com" 42 | } 43 | resource "google_project_service" "stackdriver_service" { 44 | service = "stackdriver.googleapis.com" 45 | } 46 | resource "google_project_service" "spanner_service" { 47 | service = "spanner.googleapis.com" 48 | } 49 | resource "google_project_service" "vpcaccess_service" { 50 | service = "vpcaccess.googleapis.com" 51 | } 52 | -------------------------------------------------------------------------------- /terraform/modron/tracing.tf: -------------------------------------------------------------------------------- 1 | resource "google_storage_bucket" "otel_config" { 2 | name = "${var.project}-otel-config" 3 | location = local.region 4 | uniform_bucket_level_access = true 5 | } 6 | 7 | data "google_iam_policy" "otel_config" { 8 | binding { 9 | role = "roles/storage.objectViewer" 10 | members = [ 11 | "serviceAccount:${google_service_account.modron_runner.email}", 12 | ] 13 | } 14 | 15 | binding { 16 | role = "roles/storage.admin" 17 | members = concat( 18 | var.project_admins, 19 | [ 20 | "serviceAccount:${data.google_service_account.terraform_sa.email}", 21 | ] 22 | ) 23 | } 24 | } 25 | 26 | resource "google_storage_bucket_iam_policy" "otel_config" { 27 | bucket = google_storage_bucket.otel_config.name 28 | policy_data = data.google_iam_policy.otel_config.policy_data 29 | } 30 | 31 | resource "google_storage_bucket_object" "otel_config" { 32 | bucket = google_storage_bucket.otel_config.name 33 | name = "config.yaml" 34 | content = file("${path.module}/otel/config.yaml") 35 | } -------------------------------------------------------------------------------- /terraform/prod/main.tf.example: -------------------------------------------------------------------------------- 1 | // place your variables here 2 | 3 | module "modron" { 4 | source = "../modron" 5 | 6 | domain = "modron-prod.example.com" 7 | env = "prod" 8 | org_id = "GCP_ORGID" 9 | project = "my-modron-prod" 10 | zone = "GCP_ZONE" 11 | 12 | modron_admins = [ 13 | "group:modron-admins@example.com" 14 | ] 15 | modron_users = [ 16 | "group:modron-users@example.com", 17 | ] 18 | project_admins = [ 19 | "group:modron-project-admins@example.com" 20 | ] 21 | docker_registry = "mirror.gcr.io" 22 | notification_system = "https://notification-system.example.com" 23 | notification_system_client_id = "client-id" 24 | org_suffix = "@example.com" 25 | } 26 | -------------------------------------------------------------------------------- /utils/gcp_service_agents/.gitignore: -------------------------------------------------------------------------------- 1 | *.json -------------------------------------------------------------------------------- /utils/gcp_service_agents/README.md: -------------------------------------------------------------------------------- 1 | # gcp_service_agents 2 | 3 | GCP publishes a list of "Service Agents" on their [documentation pages](https://cloud.google.com/iam/docs/service-agents). 4 | Unfortunately this list is not in a machine readable format. This little helper scrapes that page and provides a list 5 | of project IDs (e.g: `service-PROJECT_NUMBER@gcp-sa-aiplatform-cc.iam.gserviceaccount.com` -> `gcp-sa-aiplatform-cc`) 6 | that Google provides, and thus that are considered "secure" to be used in IAM policies. 7 | 8 | ## Usage 9 | 10 | ```bash 11 | go run ./ -o out.json 12 | jq -r '.projects[] | "\"" + . + "\"" + ": {},"' out.json | clipcopy 13 | ``` 14 | 15 | Then paste the content of your clipboard into the `constants/gcp_sa_projects.go` file. -------------------------------------------------------------------------------- /utils/gcp_service_agents/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nianticlabs/modron/utils/gcp_service_agents 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/alexflint/go-arg v1.5.1 7 | github.com/sirupsen/logrus v1.9.3 8 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 9 | ) 10 | 11 | require ( 12 | github.com/PuerkitoBio/goquery v1.9.0 // indirect 13 | github.com/alexflint/go-scalar v1.2.0 // indirect 14 | github.com/andybalholm/cascadia v1.3.2 // indirect 15 | golang.org/x/net v0.29.0 // indirect 16 | golang.org/x/sys v0.25.0 // indirect 17 | ) 18 | --------------------------------------------------------------------------------