Skip to content

Commit

Permalink
feat: CAI Monitoring Cloud Function (#1015)
Browse files Browse the repository at this point in the history
Co-authored-by: Grant Sorbo <[email protected]>
Co-authored-by: Daniel Andrade <[email protected]>
  • Loading branch information
3 people authored Dec 6, 2023
1 parent 8a4c106 commit 141f067
Show file tree
Hide file tree
Showing 24 changed files with 1,702 additions and 1 deletion.
1 change: 1 addition & 0 deletions 0-bootstrap/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ module "seed_bootstrap" {
"billingbudgets.googleapis.com",
"essentialcontacts.googleapis.com",
"assuredworkloads.googleapis.com",
"cloudasset.googleapis.com"
]

sa_org_iam_permissions = []
Expand Down
2 changes: 2 additions & 0 deletions 0-bootstrap/sa.tf
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ locals {
"roles/essentialcontacts.admin",
"roles/resourcemanager.tagAdmin",
"roles/resourcemanager.tagUser",
"roles/cloudasset.owner",
"roles/securitycenter.sourcesEditor",
], local.common_roles)),
"env" = distinct(concat([
"roles/resourcemanager.tagUser",
Expand Down
5 changes: 5 additions & 0 deletions 1-org/envs/shared/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
| audit\_logs\_table\_expiration\_days | Period before tables expire for all audit logs in milliseconds. Default is 30 days. | `number` | `30` | no |
| billing\_data\_users | Google Workspace or Cloud Identity group that have access to billing data set. | `string` | n/a | yes |
| billing\_export\_dataset\_location | The location of the dataset for billing data export. | `string` | `"US"` | no |
| cai\_monitoring\_kms\_force\_destroy | If set to true, delete KMS keyring and keys when destroying the module; otherwise, destroying the module will fail if KMS keys are present. | `bool` | `false` | no |
| create\_access\_context\_manager\_access\_policy | Whether to create access context manager access policy. | `bool` | `true` | no |
| create\_unique\_tag\_key | Creates unique organization-wide tag keys by adding a random suffix to each key. | `bool` | `false` | no |
| data\_access\_logs\_enabled | Enable Data Access logs of types DATA\_READ, DATA\_WRITE for all GCP services. Enabling Data Access logs might result in your organization being charged for the additional logs usage. See https://cloud.google.com/logging/docs/audit#data-access The ADMIN\_READ logs are enabled by default. | `bool` | `false` | no |
Expand All @@ -32,6 +33,10 @@
| Name | Description |
|------|-------------|
| base\_net\_hub\_project\_id | The Base Network hub project ID |
| cai\_monitoring\_artifact\_registry | CAI Monitoring Cloud Function Artifact Registry name. |
| cai\_monitoring\_asset\_feed | CAI Monitoring Cloud Function Organization Asset Feed name. |
| cai\_monitoring\_bucket | CAI Monitoring Cloud Function Source Bucket name. |
| cai\_monitoring\_topic | CAI Monitoring Cloud Function Pub/Sub Topic name. |
| common\_folder\_name | The common folder name |
| dns\_hub\_project\_id | The DNS hub project ID |
| domains\_to\_allow | The list of domains to allow users from in IAM. |
Expand Down
38 changes: 38 additions & 0 deletions 1-org/envs/shared/cai_monitoring.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

module "kms" {
source = "terraform-google-modules/kms/google"
version = "~> 2.1"

project_id = module.scc_notifications.project_id
keyring = "krg-cai-monitoring"
location = local.default_region
keys = ["key-cai-monitoring"]
prevent_destroy = !var.cai_monitoring_kms_force_destroy
}

module "cai_monitoring" {
source = "../../modules/cai-monitoring"

org_id = local.org_id
billing_account = local.billing_account
project_id = module.scc_notifications.project_id
location = local.default_region
enable_cmek = true
encryption_key = module.kms.keys["key-cai-monitoring"]
impersonate_sa_email = local.org_step_terraform_service_account_email
}
1 change: 1 addition & 0 deletions 1-org/envs/shared/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ locals {
group_billing_admins = data.terraform_remote_state.bootstrap.outputs.group_billing_admins
group_org_admins = data.terraform_remote_state.bootstrap.outputs.group_org_admins
networks_step_terraform_service_account_email = data.terraform_remote_state.bootstrap.outputs.networks_step_terraform_service_account_email
org_step_terraform_service_account_email = data.terraform_remote_state.bootstrap.outputs.organization_step_terraform_service_account_email
bootstrap_folder_name = data.terraform_remote_state.bootstrap.outputs.common_config.bootstrap_folder_name
cloud_build_private_worker_pool_id = try(data.terraform_remote_state.bootstrap.outputs.cloud_build_private_worker_pool_id, "")
}
Expand Down
20 changes: 20 additions & 0 deletions 1-org/envs/shared/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,23 @@ output "tags" {
value = local.tags_output
description = "Tag Values to be applied on next steps"
}

output "cai_monitoring_artifact_registry" {
value = module.cai_monitoring.artifact_registry_name
description = "CAI Monitoring Cloud Function Artifact Registry name."
}

output "cai_monitoring_asset_feed" {
value = module.cai_monitoring.asset_feed_name
description = "CAI Monitoring Cloud Function Organization Asset Feed name."
}

output "cai_monitoring_bucket" {
value = module.cai_monitoring.bucket_name
description = "CAI Monitoring Cloud Function Source Bucket name."
}

output "cai_monitoring_topic" {
value = module.cai_monitoring.topic_name
description = "CAI Monitoring Cloud Function Pub/Sub Topic name."
}
2 changes: 1 addition & 1 deletion 1-org/envs/shared/projects.tf
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ module "scc_notifications" {
org_id = local.org_id
billing_account = local.billing_account
folder_id = google_folder.common.id
activate_apis = ["logging.googleapis.com", "pubsub.googleapis.com", "securitycenter.googleapis.com", "billingbudgets.googleapis.com"]
activate_apis = ["logging.googleapis.com", "pubsub.googleapis.com", "securitycenter.googleapis.com", "billingbudgets.googleapis.com", "cloudkms.googleapis.com"]

labels = {
environment = "production"
Expand Down
6 changes: 6 additions & 0 deletions 1-org/envs/shared/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,9 @@ variable "create_unique_tag_key" {
type = bool
default = false
}

variable "cai_monitoring_kms_force_destroy" {
description = "If set to true, delete KMS keyring and keys when destroying the module; otherwise, destroying the module will fail if KMS keys are present."
type = bool
default = false
}
71 changes: 71 additions & 0 deletions 1-org/modules/cai-monitoring/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Cloud Asset Inventory Notification
Uses Google Cloud Asset Inventory to create a feed of IAM Policy change events, then process them to detect when a roles (from a preset list) is given to a member (service account, user or group). Then generates a SCC Finding with the member, role, resource where it was granted and the time that was granted.

## Usage

```hcl
module "secure_cai_notification" {
source = "terraform-google-modules/terraform-example-foundation/google//1-org/modules/cai-monitoring"
org_id = <ORG ID>
billing_account = <BILLING ACCOUNT ID>
project_id = <PROJECT ID>
region = <REGION>
encryption_key = <CMEK KEY>
labels = <LABELS>
impersonate_sa_email = <SA TO IMPERSONATE>
roles_to_monitor = <ROLES TO MONITOR>
}
```

<!-- BEGINNING OF PRE-COMMIT-TERRAFORM DOCS HOOK -->
## Inputs

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| billing\_account | The ID of the billing account to associate projects with. | `string` | n/a | yes |
| enable\_cmek | The KMS Key to Encrypt Artifact Registry repository, Cloud Storage Bucket and Pub/Sub. | `bool` | `false` | no |
| encryption\_key | The KMS Key to Encrypt Artifact Registry repository, Cloud Storage Bucket and Pub/Sub. | `string` | `null` | no |
| impersonate\_sa\_email | The Service Account email who will execute terraform code. | `string` | n/a | yes |
| labels | Labels to be assigned to resources. | `map(any)` | `{}` | no |
| location | Default location to create resources where applicable. | `string` | `"us-central1"` | no |
| org\_id | GCP Organization ID | `string` | n/a | yes |
| project\_id | The Project ID where the resources will be created | `string` | n/a | yes |
| random\_suffix | Adds a suffix of 4 random characters to the created resources names. | `bool` | `true` | no |
| roles\_to\_monitor | List of roles that will save a SCC Finding if granted to any member (service account, user or group) on an update in the IAM Policy. | `list(string)` | <pre>[<br> "roles/owner",<br> "roles/editor",<br> "roles/resourcemanager.organizationAdmin",<br> "roles/compute.networkAdmin",<br> "roles/compute.orgFirewallPolicyAdmin"<br>]</pre> | no |

## Outputs

| Name | Description |
|------|-------------|
| artifact\_registry\_name | Artifact Registry Repo to store the Cloud Function image. |
| asset\_feed\_name | Organization Asset Feed. |
| bucket\_name | Storage bucket where the source code is. |
| function\_uri | URI of the Cloud Function. |
| scc\_source | SCC Findings Source. |
| topic\_name | Pub/Sub Topic for the Asset Feed. |

<!-- END OF PRE-COMMIT-TERRAFORM DOCS HOOK -->

## Requirements

### Software

The following dependencies must be available:

* [Terraform](https://www.terraform.io/downloads.html) >= 1.3
* [Terraform Provider for GCP](https://github.com/terraform-providers/terraform-provider-google) < 5.0

### APIs

A project with the following APIs enabled must be used to host the resources of this module:

* Project
* Google Cloud Key Management Service: `cloudkms.googleapis.com`
* Cloud Resource Manager API: `cloudresourcemanager.googleapis.com`
* Cloud Functions API: `cloudfunctions.googleapis.com`
* Cloud Build API: `cloudbuild.googleapis.com`
* Cloud Asset API`cloudasset.googleapis.com`
* Clouod Pub/Sub API: `pubsub.googleapis.com`
* Identity and Access Management (IAM) API: `iam.googleapis.com`
* Cloud Billing API: `cloudbilling.googleapis.com`
195 changes: 195 additions & 0 deletions 1-org/modules/cai-monitoring/function-source/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/**
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

'use strict'

// Import
const uuid4 = require('uuid4')
const moment = require('moment')

// SCC client
const { SecurityCenterClient } = require('@google-cloud/security-center');
const client = new SecurityCenterClient();

// Environment variables
const sourceId = process.env.SOURCE_ID;
const searchroles = process.env.ROLES.split(",");

// Variables
const sccConfig = {
category: 'PRIVILEGED_ROLE_GRANTED',
findingClass: 'VULNERABILITY',
severity: 'MEDIUM'
};

// Exported function
exports.caiMonitoring = message => {
try {
var event = parseMessage(message);

// This validate is specific for the CAI Monitoring scenario.
// If you want to use this Cloud Function for other purpose, please change this validate function.
validateEvent(event);

// From this part of the code until the end of the function is specific for the CAI Monitoring scenario
var bindings = getRoleBindings(event.asset);

// If the list is not empty, search for the same member and role on the prior asset.
// Get only the new bindings that is not on the prior asset and create a new finding.
if (bindings.length > 0) {
var delta = bindingDiff(bindings, getRoleBindings(event.priorAsset));

if (delta.length > 0) {
// Map of extra properties to save on the finding with field name and value
var extraProperties = {
iamBindings: delta
};

createFinding(
event.asset.updateTime,
event.asset.name,
extraProperties
);
}
}
} catch (error) {
console.warn(`Skipping executing with message: ${error.message}`);
}
}

/**
* Parse the message received on the Cloud Function to a JSON.
*
* @param {any} message Message from Cloud Function
* @returns {JSON} Json object from the message
* @exception If some error happens while parsing, it will log the error and finish the execution
*/
function parseMessage(message) {
// If message data is missing, log a warning and exit.
if (!(message && message.data)) {
throw new Error(`Missing required fields (message or message.data)`);
}

// Extract the event data from the message
var event = JSON.parse(Buffer.from(message.data, 'base64').toString());

return event;
}

/**
* Validate if the asset is from Organizations and have the iamPolicy and bindings field.
*
* @param {any} asset Asset JSON.
* @exception If the asset is not valid it will throw the corresponding error.
*/
function validateEvent(event) {
// If the asset is not present, throw an error.
if (!(event.asset && event.asset.iamPolicy && event.asset.iamPolicy.bindings)) {
throw new Error(`Missing required fields (asset or asset.iamPolicy or asset.iamPolicy.bindings)`);
}

// If event priorAsset is missing and assetType is Project, is a new project creation, log a warning and exit
if (!(event.priorAsset && event.priorAsset.iamPolicy) && event.asset.assetType === "cloudresourcemanager.googleapis.com/Project") {
var name = event.asset.name.split("/");
throw new Error(`Creating project ${name[name.length - 1]}, prior asset is empty`);
}
}

/**
* Return an array of all members that have overprivileged roles.
* If there's no member, it will return an empty array.
*
* @param {Asset} asset The asset to find members with selected permissions
* @returns {Array} The array of found bindings ({member: String, role: String, action: String('ADD')}) sorted by role
*/
function getRoleBindings(asset) {
try {
var foundRoles = [];
var bindings = asset.iamPolicy.bindings;

// Check for bindings that include the list of roles
bindings.forEach(binding => {
if (searchroles.includes(binding.role)) {
binding.members.forEach(member => {
foundRoles.push({
member: member,
role: binding.role,
action: 'ADD'
});
});
}
});

return foundRoles;
} catch (error) {
console.warn(`Returning empty bindings with message: ${error.message}`);
return [];
}
}

/**
* Return an array of the members difference between the actual and the prior bindings.
* If there's no member, it will return an empty array.
*
* @param {Array} bindings Array of the actual binding
* @param {Array} priorBindings Array of the prior binding
* @returns {Array} The difference array between actual and prior bindings
*/
function bindingDiff(bindings, priorBindings) {
return bindings.filter(actual => !priorBindings.some(prior => (prior.member === actual.member && prior.role === actual.role)));
}

/**
* Convert string date to google.protobuf.Timestamp format
*
* @param {string} dateTimeStr date time format as a String. (e.g. 2019-02-15T10:23:13Z)
*/
function parseStrTime(dateTimeStr) {
const dateTimeStrInMillis = moment.utc(dateTimeStr).valueOf()
return {
seconds: Math.trunc(dateTimeStrInMillis / 1000),
nanos: Math.trunc((dateTimeStrInMillis % 1000) * 1000000)
}
}

/**
* Create the new SCC finding
*
* @param {string} updateTime The time that the asset was changed.
* @param {string} resourceName The resource where the role was given.
* @param {Any} extraProperties A key/value map with properties to save on the finding ({fieldName: fieldValue})
*/
async function createFinding(updateTime, resourceName, extraProperties) {
const [newFinding] = await client.createFinding(
{
parent: sourceId,
findingId: uuid4().replace(/-/g, ''),
finding: {
... {
state: 'ACTIVE',
resourceName: resourceName,
category: sccConfig.category,
eventTime: parseStrTime(updateTime),
findingClass: sccConfig.findingClass,
severity: sccConfig.severity
},
...extraProperties
}
}
);

console.log('New finding created: %j', newFinding);
}
Loading

0 comments on commit 141f067

Please sign in to comment.