From e7a4ce2d7c91c5e5a4982b963d6400d84e2fdcb7 Mon Sep 17 00:00:00 2001 From: Hamza Aziz Date: Thu, 27 Aug 2026 08:14:44 +0000 Subject: [PATCH] SLK-133892/azure-plugins-batch-1 --- .../databricks/workspacePrivateEndpoints.js | 59 +++++++ .../workspacePrivateEndpoints.spec.js | 140 ++++++++++++++++ .../azure/databricks/workspacePublicAccess.js | 53 ++++++ .../databricks/workspacePublicAccess.spec.js | 93 +++++++++++ .../databricks/workspaceVnetInjection.js | 53 ++++++ .../databricks/workspaceVnetInjection.spec.js | 107 ++++++++++++ .../entraid/disabledUserRoleAssignments.js | 66 ++++++++ .../disabledUserRoleAssignments.spec.js | 118 ++++++++++++++ .../azure/entraid/resourceLockAdminRole.js | 54 ++++++ .../entraid/resourceLockAdminRole.spec.js | 111 +++++++++++++ .../azure/entraid/securityDefaultsEnabled.js | 49 ++++++ .../entraid/securityDefaultsEnabled.spec.js | 79 +++++++++ .../azure/entraid/subscriptionOwnerCount.js | 88 ++++++++++ .../entraid/subscriptionOwnerCount.spec.js | 154 ++++++++++++++++++ .../entraid/userAccessAdminRestricted.js | 69 ++++++++ .../entraid/userAccessAdminRestricted.spec.js | 121 ++++++++++++++ .../subscription/subscriptionTenantPolicy.js | 51 ++++++ .../subscriptionTenantPolicy.spec.js | 105 ++++++++++++ .../subnetNetworkSecurityGroup.js | 68 ++++++++ .../subnetNetworkSecurityGroup.spec.js | 153 +++++++++++++++++ 20 files changed, 1791 insertions(+) create mode 100644 plugins/azure/databricks/workspacePrivateEndpoints.js create mode 100644 plugins/azure/databricks/workspacePrivateEndpoints.spec.js create mode 100644 plugins/azure/databricks/workspacePublicAccess.js create mode 100644 plugins/azure/databricks/workspacePublicAccess.spec.js create mode 100644 plugins/azure/databricks/workspaceVnetInjection.js create mode 100644 plugins/azure/databricks/workspaceVnetInjection.spec.js create mode 100644 plugins/azure/entraid/disabledUserRoleAssignments.js create mode 100644 plugins/azure/entraid/disabledUserRoleAssignments.spec.js create mode 100644 plugins/azure/entraid/resourceLockAdminRole.js create mode 100644 plugins/azure/entraid/resourceLockAdminRole.spec.js create mode 100644 plugins/azure/entraid/securityDefaultsEnabled.js create mode 100644 plugins/azure/entraid/securityDefaultsEnabled.spec.js create mode 100644 plugins/azure/entraid/subscriptionOwnerCount.js create mode 100644 plugins/azure/entraid/subscriptionOwnerCount.spec.js create mode 100644 plugins/azure/entraid/userAccessAdminRestricted.js create mode 100644 plugins/azure/entraid/userAccessAdminRestricted.spec.js create mode 100644 plugins/azure/subscription/subscriptionTenantPolicy.js create mode 100644 plugins/azure/subscription/subscriptionTenantPolicy.spec.js create mode 100644 plugins/azure/virtualnetworks/subnetNetworkSecurityGroup.js create mode 100644 plugins/azure/virtualnetworks/subnetNetworkSecurityGroup.spec.js diff --git a/plugins/azure/databricks/workspacePrivateEndpoints.js b/plugins/azure/databricks/workspacePrivateEndpoints.js new file mode 100644 index 0000000000..fc31edc7ab --- /dev/null +++ b/plugins/azure/databricks/workspacePrivateEndpoints.js @@ -0,0 +1,59 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Databricks Workspace Private Endpoints', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures that Azure Databricks Workspace has an approved private endpoint connection.', + more_info: 'Private endpoints allow clients and services to access the Databricks workspace over an encrypted Private Link using a private IP address from the virtual network. This keeps traffic off the public internet and reduces the attack surface. A private endpoint connection only carries traffic once its connection state is approved.', + recommended_action: 'Create a private endpoint for the Databricks workspace and approve the private endpoint connection.', + link: 'https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/private-link', + apis: ['databricks:listWorkspaces'], + realtime_triggers: ['microsoftdatabricks:workspaces:write','microsoftdatabricks:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databricks, function(location, rcb) { + const databricks = helpers.addSource(cache, source, + ['databricks', 'listWorkspaces', location]); + + if (!databricks) return rcb(); + + if (databricks.err || !databricks.data) { + helpers.addResult(results, 3, 'Unable to query for Databricks Workspaces: ' + helpers.addError(databricks), location); + return rcb(); + } + + if (!databricks.data.length) { + helpers.addResult(results, 0, 'No existing Databricks Workspaces found', location); + return rcb(); + } + + for (let workspace of databricks.data) { + if (!workspace.id) continue; + + var approved = workspace.privateEndpointConnections && workspace.privateEndpointConnections.length ? + workspace.privateEndpointConnections.some(connection => connection.properties && + connection.properties.privateLinkServiceConnectionState && + connection.properties.privateLinkServiceConnectionState.status && + connection.properties.privateLinkServiceConnectionState.status.toLowerCase() === 'approved') : false; + + if (approved) { + helpers.addResult(results, 0, 'Databricks workspace has an approved private endpoint connection', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Databricks workspace does not have an approved private endpoint connection', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/databricks/workspacePrivateEndpoints.spec.js b/plugins/azure/databricks/workspacePrivateEndpoints.spec.js new file mode 100644 index 0000000000..3d6adca4ce --- /dev/null +++ b/plugins/azure/databricks/workspacePrivateEndpoints.spec.js @@ -0,0 +1,140 @@ +var expect = require('chai').expect; +var workspacePrivateEndpoints = require('./workspacePrivateEndpoints.js'); + +const workspaces = [ + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "privateEndpointConnections": [], + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {} + }, + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "privateEndpointConnections": [ + { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace/privateEndpointConnections/test-connection", + "name": "test-connection", + "type": "Microsoft.Databricks/workspaces/privateEndpointConnections", + "properties": { + "privateLinkServiceConnectionState": { + "status": "Pending", + "actionsRequired": "None" + } + } + } + ], + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {} + }, + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "privateEndpointConnections": [ + { + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace/privateEndpointConnections/test-connection", + "name": "test-connection", + "type": "Microsoft.Databricks/workspaces/privateEndpointConnections", + "properties": { + "privateLinkServiceConnectionState": { + "status": "Approved", + "actionsRequired": "None" + } + } + } + ], + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {} + }, +]; + + +const createCache = (workspaces, err) => { + + return { + databricks: { + listWorkspaces: { + 'eastus': { + data: workspaces, + err: err + } + } + } + }; +}; + +describe('workspacePrivateEndpoints', function () { + describe('run', function () { + + it('should give a passing result if no Databricks workspaces are found', function (done) { + const cache = createCache([], null); + workspacePrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Databricks Workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Databricks workspaces', function (done) { + const cache = createCache(null, ['error']); + workspacePrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Databricks Workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Databricks workspace has an approved private endpoint connection', function (done) { + const cache = createCache([workspaces[2]], null); + workspacePrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Databricks workspace has an approved private endpoint connection'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Databricks workspace does not have private endpoint connections', function (done) { + const cache = createCache([workspaces[0]], null); + workspacePrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Databricks workspace does not have an approved private endpoint connection'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Databricks workspace private endpoint connection is not approved', function (done) { + const cache = createCache([workspaces[1]], null); + workspacePrivateEndpoints.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Databricks workspace does not have an approved private endpoint connection'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/databricks/workspacePublicAccess.js b/plugins/azure/databricks/workspacePublicAccess.js new file mode 100644 index 0000000000..f222138537 --- /dev/null +++ b/plugins/azure/databricks/workspacePublicAccess.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Databricks Workspace Public Access', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures that Azure Databricks Workspace has public network access disabled.', + more_info: 'Disabling public network access ensures that the Databricks workspace is not reachable over the public internet and can only be accessed through private endpoints within trusted networks. This reduces the attack surface and the risk of unauthorized access.', + recommended_action: 'Modify Databricks workspace networking settings and set Allow Public Network Access to Disabled.', + link: 'https://learn.microsoft.com/en-us/azure/databricks/security/network/front-end/front-end-private-connect', + apis: ['databricks:listWorkspaces'], + realtime_triggers: ['microsoftdatabricks:workspaces:write','microsoftdatabricks:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databricks, function(location, rcb) { + const databricks = helpers.addSource(cache, source, + ['databricks', 'listWorkspaces', location]); + + if (!databricks) return rcb(); + + if (databricks.err || !databricks.data) { + helpers.addResult(results, 3, 'Unable to query for Databricks Workspaces: ' + helpers.addError(databricks), location); + return rcb(); + } + + if (!databricks.data.length) { + helpers.addResult(results, 0, 'No existing Databricks Workspaces found', location); + return rcb(); + } + + for (let workspace of databricks.data) { + if (!workspace.id) continue; + + if (workspace.publicNetworkAccess && workspace.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, 'Databricks workspace has public network access disabled', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Databricks workspace has public network access enabled', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/databricks/workspacePublicAccess.spec.js b/plugins/azure/databricks/workspacePublicAccess.spec.js new file mode 100644 index 0000000000..16da344bc7 --- /dev/null +++ b/plugins/azure/databricks/workspacePublicAccess.spec.js @@ -0,0 +1,93 @@ +var expect = require('chai').expect; +var workspacePublicAccess = require('./workspacePublicAccess.js'); + +const workspaces = [ + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "publicNetworkAccess": "Enabled", + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {} + }, + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "publicNetworkAccess": "Disabled", + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {} + }, +]; + + +const createCache = (workspaces, err) => { + + return { + databricks: { + listWorkspaces: { + 'eastus': { + data: workspaces, + err: err + } + } + } + }; +}; + +describe('workspacePublicAccess', function () { + describe('run', function () { + + it('should give a passing result if no Databricks workspaces are found', function (done) { + const cache = createCache([], null); + workspacePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Databricks Workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Databricks workspaces', function (done) { + const cache = createCache(null, ['error']); + workspacePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Databricks Workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Databricks workspace has public network access disabled', function (done) { + const cache = createCache([workspaces[1]], null); + workspacePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Databricks workspace has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Databricks workspace has public network access enabled', function (done) { + const cache = createCache([workspaces[0]], null); + workspacePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Databricks workspace has public network access enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/databricks/workspaceVnetInjection.js b/plugins/azure/databricks/workspaceVnetInjection.js new file mode 100644 index 0000000000..fe871d391a --- /dev/null +++ b/plugins/azure/databricks/workspaceVnetInjection.js @@ -0,0 +1,53 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Databricks Workspace VNet Injection', + category: 'AI & ML', + domain: 'Machine Learning', + severity: 'Medium', + description: 'Ensures that Azure Databricks Workspace is deployed in a customer-managed virtual network.', + more_info: 'By default, Azure Databricks creates a managed virtual network which provides limited control over network security policies, firewall configurations and routing. Deploying the workspace in a customer-managed virtual network (VNet injection) keeps compute clusters within the organization network boundary and allows restricted outbound access, fine-grained NSG policies and private connectivity.', + recommended_action: 'Recreate the Databricks workspace with a customer-managed virtual network.', + link: 'https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/vnet-inject', + apis: ['databricks:listWorkspaces'], + realtime_triggers: ['microsoftdatabricks:workspaces:write','microsoftdatabricks:workspaces:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.databricks, function(location, rcb) { + const databricks = helpers.addSource(cache, source, + ['databricks', 'listWorkspaces', location]); + + if (!databricks) return rcb(); + + if (databricks.err || !databricks.data) { + helpers.addResult(results, 3, 'Unable to query for Databricks Workspaces: ' + helpers.addError(databricks), location); + return rcb(); + } + + if (!databricks.data.length) { + helpers.addResult(results, 0, 'No existing Databricks Workspaces found', location); + return rcb(); + } + + for (let workspace of databricks.data) { + if (!workspace.id) continue; + + if (workspace.parameters && workspace.parameters.customVirtualNetworkId && workspace.parameters.customVirtualNetworkId.value) { + helpers.addResult(results, 0, 'Databricks workspace is deployed in a customer-managed virtual network', location, workspace.id); + } else { + helpers.addResult(results, 2, 'Databricks workspace is not deployed in a customer-managed virtual network', location, workspace.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/databricks/workspaceVnetInjection.spec.js b/plugins/azure/databricks/workspaceVnetInjection.spec.js new file mode 100644 index 0000000000..04dd935768 --- /dev/null +++ b/plugins/azure/databricks/workspaceVnetInjection.spec.js @@ -0,0 +1,107 @@ +var expect = require('chai').expect; +var workspaceVnetInjection = require('./workspaceVnetInjection.js'); + +const workspaces = [ + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "enableNoPublicIp": { + "type": "Bool", + "value": true + }, + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "trial" + }, + "location": "eastus", + "tags": {} + }, + { + "managedResourceGroupId": "/subscriptions/1234/resourceGroups/test", + "parameters": { + "enableNoPublicIp": { + "type": "Bool", + "value": true + }, + "customVirtualNetworkId": { + "type": "String", + "value": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Network/virtualNetworks/test-vnet" + }, + }, + "id": "/subscriptions/1234/resourceGroups/test/providers/Microsoft.Databricks/workspaces/test-workspace", + "name": "test-workspace", + "type": "Microsoft.Databricks/workspaces", + "sku": { + "name": "premium" + }, + "location": "eastus", + "tags": {} + }, +]; + + +const createCache = (workspaces, err) => { + + return { + databricks: { + listWorkspaces: { + 'eastus': { + data: workspaces, + err: err + } + } + } + }; +}; + +describe('workspaceVnetInjection', function () { + describe('run', function () { + + it('should give a passing result if no Databricks workspaces are found', function (done) { + const cache = createCache([], null); + workspaceVnetInjection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Databricks Workspaces found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for Databricks workspaces', function (done) { + const cache = createCache(null, ['error']); + workspaceVnetInjection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Databricks Workspaces'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Databricks workspace is deployed in a customer-managed virtual network', function (done) { + const cache = createCache([workspaces[1]], null); + workspaceVnetInjection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Databricks workspace is deployed in a customer-managed virtual network'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Databricks workspace is not deployed in a customer-managed virtual network', function (done) { + const cache = createCache([workspaces[0]], null); + workspaceVnetInjection.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Databricks workspace is not deployed in a customer-managed virtual network'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/entraid/disabledUserRoleAssignments.js b/plugins/azure/entraid/disabledUserRoleAssignments.js new file mode 100644 index 0000000000..81f67e822c --- /dev/null +++ b/plugins/azure/entraid/disabledUserRoleAssignments.js @@ -0,0 +1,66 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Disabled User Role Assignments', + category: 'Entra ID', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures that disabled user accounts do not have role assignments.', + more_info: 'Disabled user accounts retain their role assignments by default. Removing role assignments from disabled accounts ensures that access is revoked when an account is blocked and enforces the principle of least privilege.', + recommended_action: 'Remove role assignments from disabled user accounts.', + link: 'https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-remove', + apis: ['users:list', 'aad:listRoleAssignments'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.aad, function(location, rcb) { + + const users = helpers.addSource(cache, source, + ['users', 'list', location]); + + if (!users) return rcb(); + + if (users.err || !users.data) { + helpers.addResult(results, 3, 'Unable to query for users: ' + helpers.addError(users), location); + return rcb(); + } + + const roleAssignments = helpers.addSource(cache, source, + ['aad', 'listRoleAssignments', location]); + + if (!roleAssignments) return rcb(); + + if (roleAssignments.err || !roleAssignments.data) { + helpers.addResult(results, 3, 'Unable to query for role assignments: ' + helpers.addError(roleAssignments), location); + return rcb(); + } + + var disabledUsers = users.data.filter(user => user.id && user.accountEnabled === false); + + if (!disabledUsers.length) { + helpers.addResult(results, 0, 'No disabled user accounts found', location); + return rcb(); + } + + var assignedPrincipals = roleAssignments.data.filter(roleAssignment => roleAssignment.principalId) + .map(roleAssignment => roleAssignment.principalId); + + disabledUsers.forEach(user => { + if (assignedPrincipals.includes(user.id)) { + helpers.addResult(results, 2, 'Disabled user account has role assignments', location, user.id); + } else { + helpers.addResult(results, 0, 'Disabled user account does not have role assignments', location, user.id); + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/entraid/disabledUserRoleAssignments.spec.js b/plugins/azure/entraid/disabledUserRoleAssignments.spec.js new file mode 100644 index 0000000000..0280dc32c9 --- /dev/null +++ b/plugins/azure/entraid/disabledUserRoleAssignments.spec.js @@ -0,0 +1,118 @@ +var expect = require('chai').expect; +var disabledUserRoleAssignments = require('./disabledUserRoleAssignments.js'); + +const users = [ + { + "id": "bb19fc40-d2c4-40a7-a990-9399ee840888", + "displayName": "Enabled User", + "userPrincipalName": "enabled@example.com", + "userType": "Member", + "accountEnabled": true + }, + { + "id": "1d50af91-73f6-45f8-95ff-d21aec6d5d56", + "displayName": "Disabled User With Roles", + "userPrincipalName": "disabled1@example.com", + "userType": "Member", + "accountEnabled": false + }, + { + "id": "8a4c3288-1317-42ef-a8f7-ec60ab455e0a", + "displayName": "Disabled User Without Roles", + "userPrincipalName": "disabled2@example.com", + "userType": "Member", + "accountEnabled": false + } +]; + +const roleAssignments = [ + { + "id": "/subscriptions/123/providers/Microsoft.Authorization/roleAssignments/0d25e3ef-59f3-4a95-9c4f-471b97cdeae9", + "name": "0d25e3ef-59f3-4a95-9c4f-471b97cdeae9", + "roleDefinitionId": "/subscriptions/123/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c", + "principalId": "1d50af91-73f6-45f8-95ff-d21aec6d5d56", + "principalType": "User", + "scope": "/subscriptions/123" + } +]; + +const createCache = (userList, assignments, usersErr, assignmentsErr) => { + return { + users: { + list: { + 'global': { + data: userList, + err: usersErr + } + } + }, + aad: { + listRoleAssignments: { + 'global': { + data: assignments, + err: assignmentsErr + } + } + } + }; +}; + +describe('disabledUserRoleAssignments', function () { + describe('run', function () { + + it('should give unknown result if unable to query for users', function (done) { + const cache = createCache(null, roleAssignments, ['error'], null); + disabledUserRoleAssignments.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for users'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for role assignments', function (done) { + const cache = createCache(users, null, null, ['error']); + disabledUserRoleAssignments.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for role assignments'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if no disabled user accounts found', function (done) { + const cache = createCache([users[0]], roleAssignments, null, null); + disabledUserRoleAssignments.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No disabled user accounts found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if disabled user account does not have role assignments', function (done) { + const cache = createCache([users[2]], roleAssignments, null, null); + disabledUserRoleAssignments.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Disabled user account does not have role assignments'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if disabled user account has role assignments', function (done) { + const cache = createCache([users[1]], roleAssignments, null, null); + disabledUserRoleAssignments.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Disabled user account has role assignments'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/entraid/resourceLockAdminRole.js b/plugins/azure/entraid/resourceLockAdminRole.js new file mode 100644 index 0000000000..ee2194f8a7 --- /dev/null +++ b/plugins/azure/entraid/resourceLockAdminRole.js @@ -0,0 +1,54 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Resource Lock Administrator Role', + category: 'Entra ID', + domain: 'Identity and Access Management', + severity: 'Low', + description: 'Ensures that a custom role is assigned permissions for administering resource locks.', + more_info: 'Resource locks prevent inadvertent modification or deletion of resources. Managing locks requires the Microsoft.Authorization/locks permission, which is otherwise only available through broad roles such as Owner or User Access Administrator. Creating a custom role limited to lock administration follows the principle of least privilege.', + recommended_action: 'Create a custom role granting the Microsoft.Authorization/locks permission and assign it to the members responsible for administering resource locks.', + link: 'https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/lock-resources', + apis: ['roleDefinitions:list'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.roleDefinitions, function(location, rcb) { + + const roleDefinitions = helpers.addSource(cache, source, + ['roleDefinitions', 'list', location]); + + if (!roleDefinitions) return rcb(); + + if (roleDefinitions.err || !roleDefinitions.data) { + helpers.addResult(results, 3, 'Unable to query for role definitions: ' + helpers.addError(roleDefinitions), location); + return rcb(); + } + + if (!roleDefinitions.data.length) { + helpers.addResult(results, 0, 'No role definitions found', location); + return rcb(); + } + + var lockRole = roleDefinitions.data.find(roleDefinition => roleDefinition.roleType && + roleDefinition.roleType.toLowerCase() === 'customrole' && + (roleDefinition.permissions || []).some(permission => (permission.actions || []).some(action => + action.toLowerCase().startsWith('microsoft.authorization/locks')))); + + if (lockRole) { + helpers.addResult(results, 0, 'Custom role for administering resource locks exists', location, lockRole.id); + } else { + helpers.addResult(results, 2, 'No custom role for administering resource locks found', location); + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/entraid/resourceLockAdminRole.spec.js b/plugins/azure/entraid/resourceLockAdminRole.spec.js new file mode 100644 index 0000000000..5aae64a3de --- /dev/null +++ b/plugins/azure/entraid/resourceLockAdminRole.spec.js @@ -0,0 +1,111 @@ +var expect = require('chai').expect; +var resourceLockAdminRole = require('./resourceLockAdminRole.js'); + +const roleDefinitions = [ + { + "id": "/subscriptions/123/providers/Microsoft.Authorization/roleDefinitions/11111111-1111-1111-1111-111111111111", + "roleName": "Resource Lock Administrator", + "roleType": "CustomRole", + "permissions": [ + { + "actions": ["Microsoft.Authorization/locks/*"], + "notActions": [] + } + ] + }, + { + "id": "/subscriptions/123/providers/Microsoft.Authorization/roleDefinitions/22222222-2222-2222-2222-222222222222", + "roleName": "Owner2", + "roleType": "CustomRole", + "permissions": [ + { + "actions": ["*"], + "notActions": [] + } + ] + }, + { + "id": "/subscriptions/123/providers/Microsoft.Authorization/roleDefinitions/33333333-3333-3333-3333-333333333333", + "roleName": "Locks Contributor", + "roleType": "BuiltInRole", + "permissions": [ + { + "actions": ["Microsoft.Authorization/locks/*"], + "notActions": [] + } + ] + } +]; + +const createCache = (definitions, err) => { + return { + roleDefinitions: { + list: { + 'global': { + data: definitions, + err: err + } + } + } + }; +}; + +describe('resourceLockAdminRole', function () { + describe('run', function () { + + it('should give unknown result if unable to query for role definitions', function (done) { + const cache = createCache(null, ['error']); + resourceLockAdminRole.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for role definitions'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if no role definitions found', function (done) { + const cache = createCache([], null); + resourceLockAdminRole.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No role definitions found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if a custom role for administering resource locks exists', function (done) { + const cache = createCache([roleDefinitions[0]], null); + resourceLockAdminRole.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Custom role for administering resource locks exists'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if only a wildcard custom role grants lock permissions', function (done) { + const cache = createCache([roleDefinitions[1]], null); + resourceLockAdminRole.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No custom role for administering resource locks found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if only a built-in role grants lock permissions', function (done) { + const cache = createCache([roleDefinitions[2]], null); + resourceLockAdminRole.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('No custom role for administering resource locks found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/entraid/securityDefaultsEnabled.js b/plugins/azure/entraid/securityDefaultsEnabled.js new file mode 100644 index 0000000000..1cac1e0538 --- /dev/null +++ b/plugins/azure/entraid/securityDefaultsEnabled.js @@ -0,0 +1,49 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Security Defaults Enabled', + category: 'Entra ID', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures that security defaults are enabled in Microsoft Entra ID.', + more_info: 'Security defaults are preconfigured identity security settings that require all users and administrators to register for multi-factor authentication, challenge users with multi-factor authentication when needed and block legacy authentication protocols. Enabling security defaults provides a basic level of identity protection at no extra cost.', + recommended_action: 'Enable security defaults from Microsoft Entra ID properties.', + link: 'https://learn.microsoft.com/en-us/entra/fundamentals/security-defaults', + apis: ['securityDefaultsPolicy:get'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.securityDefaultsPolicy, function(location, rcb) { + + const policy = helpers.addSource(cache, source, + ['securityDefaultsPolicy', 'get', location]); + + if (!policy) return rcb(); + + if (policy.err || !policy.data) { + helpers.addResult(results, 3, 'Unable to query for security defaults policy: ' + helpers.addError(policy), location); + return rcb(); + } + + if (!policy.data.length) { + helpers.addResult(results, 0, 'No existing security defaults policy found', location); + return rcb(); + } + + if (policy.data[0].isEnabled) { + helpers.addResult(results, 0, 'Security defaults are enabled', location); + } else { + helpers.addResult(results, 2, 'Security defaults are not enabled', location); + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/entraid/securityDefaultsEnabled.spec.js b/plugins/azure/entraid/securityDefaultsEnabled.spec.js new file mode 100644 index 0000000000..6ee1eeb013 --- /dev/null +++ b/plugins/azure/entraid/securityDefaultsEnabled.spec.js @@ -0,0 +1,79 @@ +var expect = require('chai').expect; +var securityDefaultsEnabled = require('./securityDefaultsEnabled.js'); + +const policies = [ + { + "id": "00000000-0000-0000-0000-000000000005", + "displayName": "Security Defaults", + "description": "Security defaults is a set of basic identity security mechanisms recommended by Microsoft.", + "isEnabled": true + }, + { + "id": "00000000-0000-0000-0000-000000000005", + "displayName": "Security Defaults", + "description": "Security defaults is a set of basic identity security mechanisms recommended by Microsoft.", + "isEnabled": false + } +]; + +const createCache = (policy, err) => { + return { + securityDefaultsPolicy: { + get: { + 'global': { + data: policy, + err: err + } + } + } + }; +}; + +describe('securityDefaultsEnabled', function () { + describe('run', function () { + + it('should give unknown result if unable to query for security defaults policy', function (done) { + const cache = createCache(null, ['error']); + securityDefaultsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for security defaults policy'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if no security defaults policy found', function (done) { + const cache = createCache([], null); + securityDefaultsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing security defaults policy found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if security defaults are enabled', function (done) { + const cache = createCache([policies[0]], null); + securityDefaultsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Security defaults are enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if security defaults are not enabled', function (done) { + const cache = createCache([policies[1]], null); + securityDefaultsEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Security defaults are not enabled'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/entraid/subscriptionOwnerCount.js b/plugins/azure/entraid/subscriptionOwnerCount.js new file mode 100644 index 0000000000..366877fb35 --- /dev/null +++ b/plugins/azure/entraid/subscriptionOwnerCount.js @@ -0,0 +1,88 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Subscription Owner Count', + category: 'Entra ID', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures that the number of subscription owners is within the desired range.', + more_info: 'The Owner role grants full control over all resources in a subscription, including the ability to assign roles to others. Keeping the number of owners low limits privilege sprawl, while keeping more than one avoids losing administrative access. All principal types count towards the total, including users, groups, service principals and managed identities.', + recommended_action: 'Remove unnecessary Owner role assignments, or add an additional owner if only one exists.', + link: 'https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#owner', + apis: ['roleDefinitions:list', 'aad:listRoleAssignments'], + settings: { + subscription_owners_min: { + name: 'Subscription Owners Minimum', + description: 'Return a failing result when the number of subscription owners is below this value', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: 2 + }, + subscription_owners_max: { + name: 'Subscription Owners Maximum', + description: 'Return a failing result when the number of subscription owners exceeds this value', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: 3 + } + }, + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + var config = { + subscription_owners_min: parseInt(settings.subscription_owners_min || this.settings.subscription_owners_min.default), + subscription_owners_max: parseInt(settings.subscription_owners_max || this.settings.subscription_owners_max.default) + }; + + async.each(locations.aad, function(location, rcb) { + + const roleDefinitions = helpers.addSource(cache, source, + ['roleDefinitions', 'list', location]); + + if (!roleDefinitions) return rcb(); + + if (roleDefinitions.err || !roleDefinitions.data) { + helpers.addResult(results, 3, 'Unable to query for role definitions: ' + helpers.addError(roleDefinitions), location); + return rcb(); + } + + const roleAssignments = helpers.addSource(cache, source, + ['aad', 'listRoleAssignments', location]); + + if (!roleAssignments) return rcb(); + + if (roleAssignments.err || !roleAssignments.data) { + helpers.addResult(results, 3, 'Unable to query for role assignments: ' + helpers.addError(roleAssignments), location); + return rcb(); + } + + var ownerRoleIds = roleDefinitions.data.filter(roleDefinition => roleDefinition.roleName && + roleDefinition.roleName.toLowerCase() === 'owner' && roleDefinition.id) + .map(roleDefinition => roleDefinition.id.split('/').pop()); + + if (!ownerRoleIds.length) { + helpers.addResult(results, 0, 'No Owner role definition found', location); + return rcb(); + } + + var owners = roleAssignments.data.filter(roleAssignment => roleAssignment.roleDefinitionId && + ownerRoleIds.includes(roleAssignment.roleDefinitionId.split('/').pop()) && + roleAssignment.scope && !roleAssignment.scope.toLowerCase().includes('/resourcegroups/')); + + if (owners.length < config.subscription_owners_min) { + helpers.addResult(results, 2, `Subscription has ${owners.length} owners, fewer than the desired minimum of ${config.subscription_owners_min}`, location); + } else if (owners.length > config.subscription_owners_max) { + helpers.addResult(results, 2, `Subscription has ${owners.length} owners, more than the desired maximum of ${config.subscription_owners_max}`, location); + } else { + helpers.addResult(results, 0, `Subscription has ${owners.length} owners`, location); + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/entraid/subscriptionOwnerCount.spec.js b/plugins/azure/entraid/subscriptionOwnerCount.spec.js new file mode 100644 index 0000000000..e5b72fd667 --- /dev/null +++ b/plugins/azure/entraid/subscriptionOwnerCount.spec.js @@ -0,0 +1,154 @@ +var expect = require('chai').expect; +var subscriptionOwnerCount = require('./subscriptionOwnerCount.js'); + +const roleDefinitions = [ + { + "id": "/subscriptions/123/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635", + "roleName": "Owner", + "roleType": "BuiltInRole" + }, + { + "id": "/subscriptions/123/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c", + "roleName": "Contributor", + "roleType": "BuiltInRole" + } +]; + +const ownerAssignment = (name, principalId, scope, principalType) => { + return { + "id": '/subscriptions/123/providers/Microsoft.Authorization/roleAssignments/' + name, + "name": name, + "roleDefinitionId": "/subscriptions/123/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635", + "principalId": principalId, + "principalType": principalType || "User", + "scope": scope + }; +}; + +const contributorAssignment = { + "id": "/subscriptions/123/providers/Microsoft.Authorization/roleAssignments/contrib", + "name": "contrib", + "roleDefinitionId": "/subscriptions/123/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c", + "principalId": "99999999-9999-9999-9999-999999999999", + "principalType": "User", + "scope": "/subscriptions/123" +}; + +const createCache = (definitions, assignments, definitionsErr, assignmentsErr) => { + return { + roleDefinitions: { + list: { + 'global': { + data: definitions, + err: definitionsErr + } + } + }, + aad: { + listRoleAssignments: { + 'global': { + data: assignments, + err: assignmentsErr + } + } + } + }; +}; + +describe('subscriptionOwnerCount', function () { + describe('run', function () { + + it('should give unknown result if unable to query for role definitions', function (done) { + const cache = createCache(null, [], ['error'], null); + subscriptionOwnerCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for role definitions'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for role assignments', function (done) { + const cache = createCache(roleDefinitions, null, null, ['error']); + subscriptionOwnerCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for role assignments'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if no Owner role definition found', function (done) { + const cache = createCache([roleDefinitions[1]], [contributorAssignment], null, null); + subscriptionOwnerCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Owner role definition found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if the number of owners is within range', function (done) { + const assignments = [ + ownerAssignment('a', '1', '/subscriptions/123'), + ownerAssignment('b', '2', '/subscriptions/123') + ]; + const cache = createCache(roleDefinitions, assignments, null, null); + subscriptionOwnerCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Subscription has 2 owners'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if there are fewer owners than the minimum', function (done) { + const assignments = [ownerAssignment('a', '1', '/subscriptions/123')]; + const cache = createCache(roleDefinitions, assignments, null, null); + subscriptionOwnerCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('fewer than the desired minimum'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if there are more owners than the maximum', function (done) { + const assignments = [ + ownerAssignment('a', '1', '/subscriptions/123'), + ownerAssignment('b', '2', '/subscriptions/123', 'Group'), + ownerAssignment('c', '3', '/subscriptions/123', 'ServicePrincipal'), + ownerAssignment('d', '4', '/providers/Microsoft.Management/managementGroups/mg1') + ]; + const cache = createCache(roleDefinitions, assignments, null, null); + subscriptionOwnerCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('more than the desired maximum'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should not count owner assignments scoped below the subscription', function (done) { + const assignments = [ + ownerAssignment('a', '1', '/subscriptions/123'), + ownerAssignment('b', '2', '/subscriptions/123'), + ownerAssignment('c', '3', '/subscriptions/123/resourceGroups/rg1/providers/Microsoft.Storage/storageAccounts/test') + ]; + const cache = createCache(roleDefinitions, assignments, null, null); + subscriptionOwnerCount.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Subscription has 2 owners'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/entraid/userAccessAdminRestricted.js b/plugins/azure/entraid/userAccessAdminRestricted.js new file mode 100644 index 0000000000..6ab405d4ff --- /dev/null +++ b/plugins/azure/entraid/userAccessAdminRestricted.js @@ -0,0 +1,69 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'User Access Administrator Role Restricted', + category: 'Entra ID', + domain: 'Identity and Access Management', + severity: 'Medium', + description: 'Ensures that the User Access Administrator role is not assigned.', + more_info: 'The User Access Administrator role allows viewing all resources and managing access assignments across the tenant. Because of its high privilege level, the role assignment should be removed once the required changes are complete to reduce the risk of privilege escalation and unauthorized access.', + recommended_action: 'Remove User Access Administrator role assignments that are no longer required.', + link: 'https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#user-access-administrator', + apis: ['roleDefinitions:list', 'aad:listRoleAssignments'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.aad, function(location, rcb) { + + const roleDefinitions = helpers.addSource(cache, source, + ['roleDefinitions', 'list', location]); + + if (!roleDefinitions) return rcb(); + + if (roleDefinitions.err || !roleDefinitions.data) { + helpers.addResult(results, 3, 'Unable to query for role definitions: ' + helpers.addError(roleDefinitions), location); + return rcb(); + } + + const roleAssignments = helpers.addSource(cache, source, + ['aad', 'listRoleAssignments', location]); + + if (!roleAssignments) return rcb(); + + if (roleAssignments.err || !roleAssignments.data) { + helpers.addResult(results, 3, 'Unable to query for role assignments: ' + helpers.addError(roleAssignments), location); + return rcb(); + } + + var adminRoleIds = roleDefinitions.data.filter(roleDefinition => roleDefinition.roleName && + roleDefinition.roleName.toLowerCase() === 'user access administrator' && roleDefinition.id) + .map(roleDefinition => roleDefinition.id.split('/').pop()); + + if (!adminRoleIds.length) { + helpers.addResult(results, 0, 'No User Access Administrator role definition found', location); + return rcb(); + } + + var adminAssignments = roleAssignments.data.filter(roleAssignment => roleAssignment.roleDefinitionId && + adminRoleIds.includes(roleAssignment.roleDefinitionId.split('/').pop())); + + if (!adminAssignments.length) { + helpers.addResult(results, 0, 'User Access Administrator role is not assigned', location); + return rcb(); + } + + adminAssignments.forEach(roleAssignment => { + helpers.addResult(results, 2, 'User Access Administrator role is assigned', location, roleAssignment.id); + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/entraid/userAccessAdminRestricted.spec.js b/plugins/azure/entraid/userAccessAdminRestricted.spec.js new file mode 100644 index 0000000000..c4993ab397 --- /dev/null +++ b/plugins/azure/entraid/userAccessAdminRestricted.spec.js @@ -0,0 +1,121 @@ +var expect = require('chai').expect; +var userAccessAdminRestricted = require('./userAccessAdminRestricted.js'); + +const roleDefinitions = [ + { + "id": "/subscriptions/123/providers/Microsoft.Authorization/roleDefinitions/18d7d88d-d35e-4fb5-a5c3-7773c20a72d9", + "type": "Microsoft.Authorization/roleDefinitions", + "name": "18d7d88d-d35e-4fb5-a5c3-7773c20a72d9", + "roleName": "User Access Administrator", + "roleType": "BuiltInRole" + }, + { + "id": "/subscriptions/123/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c", + "type": "Microsoft.Authorization/roleDefinitions", + "name": "b24988ac-6180-42a0-ab88-20f7382dd24c", + "roleName": "Contributor", + "roleType": "BuiltInRole" + } +]; + +const roleAssignments = [ + { + "id": "/subscriptions/123/providers/Microsoft.Authorization/roleAssignments/0d25e3ef-59f3-4a95-9c4f-471b97cdeae9", + "type": "Microsoft.Authorization/roleAssignments", + "name": "0d25e3ef-59f3-4a95-9c4f-471b97cdeae9", + "roleDefinitionId": "/subscriptions/123/providers/Microsoft.Authorization/roleDefinitions/18d7d88d-d35e-4fb5-a5c3-7773c20a72d9", + "principalId": "158a9a70-2e04-4def-829c-239922b43dc8", + "principalType": "User", + "scope": "/subscriptions/123" + }, + { + "id": "/subscriptions/123/providers/Microsoft.Authorization/roleAssignments/1a35e3ef-59f3-4a95-9c4f-471b97cdeaf0", + "type": "Microsoft.Authorization/roleAssignments", + "name": "1a35e3ef-59f3-4a95-9c4f-471b97cdeaf0", + "roleDefinitionId": "/subscriptions/123/providers/Microsoft.Authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c", + "principalId": "258a9a70-2e04-4def-829c-239922b43dc9", + "principalType": "User", + "scope": "/subscriptions/123" + } +]; + +const createCache = (definitions, assignments, definitionsErr, assignmentsErr) => { + return { + roleDefinitions: { + list: { + 'global': { + data: definitions, + err: definitionsErr + } + } + }, + aad: { + listRoleAssignments: { + 'global': { + data: assignments, + err: assignmentsErr + } + } + } + }; +}; + +describe('userAccessAdminRestricted', function () { + describe('run', function () { + + it('should give unknown result if unable to query for role definitions', function (done) { + const cache = createCache(null, roleAssignments, ['error'], null); + userAccessAdminRestricted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for role definitions'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give unknown result if unable to query for role assignments', function (done) { + const cache = createCache(roleDefinitions, null, null, ['error']); + userAccessAdminRestricted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for role assignments'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if no User Access Administrator role definition found', function (done) { + const cache = createCache([roleDefinitions[1]], roleAssignments, null, null); + userAccessAdminRestricted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No User Access Administrator role definition found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if User Access Administrator role is not assigned', function (done) { + const cache = createCache(roleDefinitions, [roleAssignments[1]], null, null); + userAccessAdminRestricted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('User Access Administrator role is not assigned'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if User Access Administrator role is assigned', function (done) { + const cache = createCache(roleDefinitions, roleAssignments, null, null); + userAccessAdminRestricted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('User Access Administrator role is assigned'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/subscription/subscriptionTenantPolicy.js b/plugins/azure/subscription/subscriptionTenantPolicy.js new file mode 100644 index 0000000000..56a1c07df7 --- /dev/null +++ b/plugins/azure/subscription/subscriptionTenantPolicy.js @@ -0,0 +1,51 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Subscription Tenant Transfer Policy', + category: 'Subscription', + domain: 'Management', + severity: 'Medium', + description: 'Ensures that subscriptions cannot be moved into or out of the Microsoft Entra tenant.', + more_info: 'Subscription owners are able to move subscriptions into and out of a Microsoft Entra tenant. A subscription moved into a tenant may sit under a scope where other users hold elevated permissions, and a subscription moved out takes its resources with it. Blocking both transfer directions prevents loss of data and unapproved changes.', + recommended_action: 'Set Subscription leaving Microsoft Entra tenant and Subscription entering Microsoft Entra tenant to Permit no one from the subscription policy management page.', + link: 'https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/manage-azure-subscription-policy', + apis: ['subscriptionPolicies:get'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.subscriptionPolicies, function(location, rcb) { + + var policies = helpers.addSource(cache, source, + ['subscriptionPolicies', 'get', location]); + + if (!policies) return rcb(); + + if (policies.err || !policies.data) { + helpers.addResult(results, 3, 'Unable to query for subscription tenant policy: ' + helpers.addError(policies), location); + return rcb(); + } + + if (!policies.data.length) { + helpers.addResult(results, 0, 'No existing subscription tenant policy found', location); + return rcb(); + } + + var policy = policies.data[0]; + + if (policy.blockSubscriptionsLeavingTenant && policy.blockSubscriptionsIntoTenant) { + helpers.addResult(results, 0, 'Subscriptions cannot be moved into or out of the tenant', location, policy.id); + } else { + helpers.addResult(results, 2, 'Subscriptions can be moved into or out of the tenant', location, policy.id); + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/subscription/subscriptionTenantPolicy.spec.js b/plugins/azure/subscription/subscriptionTenantPolicy.spec.js new file mode 100644 index 0000000000..6ab92f3da0 --- /dev/null +++ b/plugins/azure/subscription/subscriptionTenantPolicy.spec.js @@ -0,0 +1,105 @@ +var expect = require('chai').expect; +var subscriptionTenantPolicy = require('./subscriptionTenantPolicy.js'); + +const policies = [ + { + "id": "providers/Microsoft.Subscription/policies/default", + "name": "default", + "type": "providers/Microsoft.Subscription/policies", + "policyId": "d207c7bd-fcb1-4dd3-855a-cfd2f9b651e8", + "blockSubscriptionsLeavingTenant": true, + "blockSubscriptionsIntoTenant": true, + "exemptedPrincipals": [] + }, + { + "id": "providers/Microsoft.Subscription/policies/default", + "name": "default", + "type": "providers/Microsoft.Subscription/policies", + "policyId": "d207c7bd-fcb1-4dd3-855a-cfd2f9b651e8", + "blockSubscriptionsLeavingTenant": true, + "blockSubscriptionsIntoTenant": false, + "exemptedPrincipals": [] + }, + { + "id": "providers/Microsoft.Subscription/policies/default", + "name": "default", + "type": "providers/Microsoft.Subscription/policies", + "policyId": "d207c7bd-fcb1-4dd3-855a-cfd2f9b651e8", + "blockSubscriptionsLeavingTenant": false, + "blockSubscriptionsIntoTenant": false, + "exemptedPrincipals": [] + } +]; + +const createCache = (policy, err) => { + return { + subscriptionPolicies: { + get: { + 'global': { + data: policy, + err: err + } + } + } + }; +}; + +describe('subscriptionTenantPolicy', function () { + describe('run', function () { + + it('should give unknown result if unable to query for subscription tenant policy', function (done) { + const cache = createCache(null, ['error']); + subscriptionTenantPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for subscription tenant policy'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if no subscription tenant policy found', function (done) { + const cache = createCache([], null); + subscriptionTenantPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing subscription tenant policy found'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if subscriptions cannot be moved into or out of the tenant', function (done) { + const cache = createCache([policies[0]], null); + subscriptionTenantPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Subscriptions cannot be moved into or out of the tenant'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if subscriptions can be moved into the tenant', function (done) { + const cache = createCache([policies[1]], null); + subscriptionTenantPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Subscriptions can be moved into or out of the tenant'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if subscriptions can be moved into and out of the tenant', function (done) { + const cache = createCache([policies[2]], null); + subscriptionTenantPolicy.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Subscriptions can be moved into or out of the tenant'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/virtualnetworks/subnetNetworkSecurityGroup.js b/plugins/azure/virtualnetworks/subnetNetworkSecurityGroup.js new file mode 100644 index 0000000000..c988e20e07 --- /dev/null +++ b/plugins/azure/virtualnetworks/subnetNetworkSecurityGroup.js @@ -0,0 +1,68 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +var exemptSubnets = ['gatewaysubnet', 'azurefirewallsubnet', 'azurefirewallmanagementsubnet', 'routeserversubnet']; + +module.exports = { + title: 'Subnet Network Security Group Association', + category: 'Virtual Networks', + domain: 'Network Access Control', + severity: 'Medium', + description: 'Ensures that virtual network subnets are associated with a network security group.', + more_info: 'Network security groups filter inbound and outbound traffic for a subnet using security rules. Subnets without an associated network security group do not have this filtering in place and can expose their resources to unauthorized access.', + recommended_action: 'Associate a network security group with each subnet from the subnet security settings.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview', + apis: ['virtualNetworks:listAll'], + realtime_triggers: ['microsoftnetwork:virtualnetworks:write', 'microsoftnetwork:virtualnetworks:delete', 'microsoftnetwork:virtualnetworks:subnets:write', 'microsoftnetwork:virtualnetworks:subnets:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.virtualNetworks, function(location, rcb) { + var virtualNetworks = helpers.addSource(cache, source, + ['virtualNetworks', 'listAll', location]); + + if (!virtualNetworks) return rcb(); + + if (virtualNetworks.err || !virtualNetworks.data) { + helpers.addResult(results, 3, 'Unable to query for Virtual Networks: ' + helpers.addError(virtualNetworks), location); + return rcb(); + } + + if (!virtualNetworks.data.length) { + helpers.addResult(results, 0, 'No existing Virtual Networks found', location); + return rcb(); + } + + var found = false; + + for (let virtualNetwork of virtualNetworks.data) { + if (!virtualNetwork.subnets || !virtualNetwork.subnets.length) continue; + + for (let subnet of virtualNetwork.subnets) { + if (!subnet.id || !subnet.properties) continue; + + if (subnet.name && exemptSubnets.includes(subnet.name.toLowerCase())) continue; + + found = true; + + if (subnet.properties.networkSecurityGroup && subnet.properties.networkSecurityGroup.id) { + helpers.addResult(results, 0, 'Subnet has a network security group associated', location, subnet.id); + } else { + helpers.addResult(results, 2, 'Subnet does not have a network security group associated', location, subnet.id); + } + } + } + + if (!found) { + helpers.addResult(results, 0, 'No existing subnets found', location); + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualnetworks/subnetNetworkSecurityGroup.spec.js b/plugins/azure/virtualnetworks/subnetNetworkSecurityGroup.spec.js new file mode 100644 index 0000000000..2826e198de --- /dev/null +++ b/plugins/azure/virtualnetworks/subnetNetworkSecurityGroup.spec.js @@ -0,0 +1,153 @@ +var expect = require('chai').expect; +var subnetNetworkSecurityGroup = require('./subnetNetworkSecurityGroup'); + +const virtualNetworks = [ + { + 'name': 'test-vnet', + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + 'type': 'Microsoft.Network/virtualNetworks', + 'location': 'eastus', + 'subnets': [ + { + 'name': 'default', + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/default', + 'properties': { + 'networkSecurityGroup': { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Network/networkSecurityGroups/test-nsg' + } + } + } + ] + }, + { + 'name': 'test-vnet', + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + 'type': 'Microsoft.Network/virtualNetworks', + 'location': 'eastus', + 'subnets': [ + { + 'name': 'default', + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/default', + 'properties': {} + } + ] + }, + { + 'name': 'test-vnet', + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + 'type': 'Microsoft.Network/virtualNetworks', + 'location': 'eastus', + 'subnets': [ + { + 'name': 'GatewaySubnet', + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/GatewaySubnet', + 'properties': {} + } + ] + }, + { + 'name': 'test-vnet', + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet', + 'type': 'Microsoft.Network/virtualNetworks', + 'location': 'eastus', + 'subnets': [ + { + 'name': 'AzureBastionSubnet', + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Network/virtualNetworks/test-vnet/subnets/AzureBastionSubnet', + 'properties': {} + } + ] + } +]; + +const createCache = (virtualNetworks) => { + return { + virtualNetworks: { + listAll: { + 'eastus': { + data: virtualNetworks + } + } + } + }; +}; + +const createErrorCache = () => { + return { + virtualNetworks: { + listAll: { + 'eastus': {} + } + } + }; +}; + +describe('subnetNetworkSecurityGroup', function () { + describe('run', function () { + it('should give passing result if no virtual networks found', function (done) { + const cache = createCache([]); + subnetNetworkSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing Virtual Networks found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for virtual networks', function (done) { + const cache = createErrorCache(); + subnetNetworkSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Virtual Networks'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if subnet has a network security group associated', function (done) { + const cache = createCache([virtualNetworks[0]]); + subnetNetworkSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Subnet has a network security group associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if subnet does not have a network security group associated', function (done) { + const cache = createCache([virtualNetworks[1]]); + subnetNetworkSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Subnet does not have a network security group associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should not evaluate subnets that do not support network security groups', function (done) { + const cache = createCache([virtualNetworks[2]]); + subnetNetworkSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing subnets found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if bastion subnet does not have a network security group associated', function (done) { + const cache = createCache([virtualNetworks[3]]); + subnetNetworkSecurityGroup.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Subnet does not have a network security group associated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +});