diff --git a/exports.js b/exports.js index 6d0032525d..2795a4d3d0 100644 --- a/exports.js +++ b/exports.js @@ -732,6 +732,14 @@ module.exports = { 'blobServiceEncryption' : require(__dirname + '/plugins/azure/storageaccounts/blobServiceEncryption.js'), 'trustedMsAccessEnabled' : require(__dirname + '/plugins/azure/storageaccounts/trustedMsAccessEnabled.js'), 'blobSoftDeletionEnabled' : require(__dirname + '/plugins/azure/storageaccounts/blobSoftDeletionEnabled.js'), + 'storageAccountKeyRotationReminder': require(__dirname + '/plugins/azure/storageaccounts/storageAccountKeyRotationReminder.js'), + 'storageAccountKeyRotation' : require(__dirname + '/plugins/azure/storageaccounts/storageAccountKeyRotation.js'), + 'sharedKeyAccessDisabled' : require(__dirname + '/plugins/azure/storageaccounts/sharedKeyAccessDisabled.js'), + 'storageAccountEntraIdAuthDefault': require(__dirname + '/plugins/azure/storageaccounts/storageAccountEntraIdAuthDefault.js'), + 'geoRedundantStorage' : require(__dirname + '/plugins/azure/storageaccounts/geoRedundantStorage.js'), + 'fileShareSoftDeletionEnabled' : require(__dirname + '/plugins/azure/storageaccounts/fileShareSoftDeletionEnabled.js'), + 'fileShareSmbProtocolVersion' : require(__dirname + '/plugins/azure/storageaccounts/fileShareSmbProtocolVersion.js'), + 'fileShareSmbChannelEncryption' : require(__dirname + '/plugins/azure/storageaccounts/fileShareSmbChannelEncryption.js'), 'storageAccountsTlsVersion' : require(__dirname + '/plugins/azure/storageaccounts/storageAccountsTlsVersion.js'), 'storageAccountHasTags' : require(__dirname + '/plugins/azure/storageaccounts/storageAccountHasTags.js'), 'storageAccountPrivateEndpoint' : require(__dirname + '/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.js'), @@ -1057,6 +1065,8 @@ module.exports = { 'passwordRequiresUppercase' : require(__dirname + '/plugins/azure/entraid/passwordRequiresUppercase.js'), 'minPasswordLength' : require(__dirname + '/plugins/azure/entraid/minPasswordLength.js'), 'ensureNoGuestUser' : require(__dirname + '/plugins/azure/entraid/ensureNoGuestUser.js'), + 'userAccessAdminRestricted' : require(__dirname + '/plugins/azure/entraid/userAccessAdminRestricted.js'), + 'subscriptionOwnerCount' : require(__dirname + '/plugins/azure/entraid/subscriptionOwnerCount.js'), 'noCustomOwnerRoles' : require(__dirname + '/plugins/azure/entraid/noCustomOwnerRoles.js'), 'appOrgnaizationalDirectoryAccess' : require(__dirname + '/plugins/azure/entraid/appOrgnaizationalDirectoryAccess.js'), @@ -1150,6 +1160,7 @@ module.exports = { 'agHttpsListenerOnly' : require(__dirname + '/plugins/azure/applicationGateway/agHttpsListenerOnly.js'), 'subscriptionHasTags' : require(__dirname + '/plugins/azure/subscription/subscriptionHasTags.js'), + 'subscriptionTenantPolicy' : require(__dirname + '/plugins/azure/subscription/subscriptionTenantPolicy.js'), 'rgHasTags' : require(__dirname + '/plugins/azure/resourceGroup/rgHasTags.js'), @@ -1256,6 +1267,9 @@ module.exports = { 'workspaceManagedServicesCmk' : require(__dirname + '/plugins/azure/databricks/workspaceManagedServicesCmk.js'), 'workspaceManagedDiskCmk' : require(__dirname + '/plugins/azure/databricks/workspaceManagedDiskCmk.js'), 'workspaceHasTags' : require(__dirname + '/plugins/azure/databricks/workspaceHasTags.js'), + 'workspaceVnetInjection' : require(__dirname + '/plugins/azure/databricks/workspaceVnetInjection.js'), + 'workspacePublicAccess' : require(__dirname + '/plugins/azure/databricks/workspacePublicAccess.js'), + 'workspacePrivateEndpoints' : require(__dirname + '/plugins/azure/databricks/workspacePrivateEndpoints.js'), 'workspaceManagedIdentity' : require(__dirname + '/plugins/azure/synapse/workspaceManagedIdentity.js'), 'synapseWorkspaceAdAuthEnabled' : require(__dirname + '/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.js'), diff --git a/helpers/azure/api.js b/helpers/azure/api.js index 4682c1e2d1..a2d0b6cc87 100644 --- a/helpers/azure/api.js +++ b/helpers/azure/api.js @@ -473,6 +473,13 @@ var calls = { hasListResponse: true } }, + securityContactv3: { + listAll: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Security/securityContacts?api-version=2023-12-01-preview', + ignoreLocation: true, + hasListResponse: true + } + }, subscriptions: { listLocations: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/locations?api-version=2020-01-01' @@ -510,10 +517,29 @@ var calls = { }, users: { list: { - url: 'https://graph.microsoft.com/v1.0/users', + url: 'https://graph.microsoft.com/v1.0/users?$select=id,displayName,userPrincipalName,mail,userType,accountEnabled', graph: true } }, + securityDefaultsPolicy: { + get: { + url: 'https://graph.microsoft.com/v1.0/policies/identitySecurityDefaultsEnforcementPolicy', + graph: true, + getCompleteResponse: true + } + }, + subscriptionPolicies: { + get: { + url: 'https://management.azure.com/providers/Microsoft.Subscription/policies/default?api-version=2021-10-01', + getCompleteResponse: true + } + }, + appInsights: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/microsoft.insights/components?api-version=2020-02-02', + ignoreLocation: true + } + }, applications: { list: { url: 'https://graph.microsoft.com/v1.0/applications/', @@ -958,6 +984,14 @@ var postcalls = { rateLimit: 3000 } }, + fileServices: { + getServiceProperties: { + reliesOnPath: 'storageAccounts.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/fileServices/default?api-version=2023-01-01', + rateLimit: 500 + } + }, storageAccounts: { listKeys: { reliesOnPath: 'storageAccounts.list', @@ -1049,6 +1083,11 @@ var postcalls = { url: '{vaultUri}keys?api-version=7.0', vault: true }, + listKeys: { + reliesOnPath: 'vaults.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/keys?api-version=2023-07-01' + }, getSecrets: { reliesOnPath: 'vaults.list', properties: ['vaultUri'], @@ -1468,6 +1507,13 @@ var tertiarycalls = { vault: true } }, + getKey: { + get: { + reliesOnPath: 'vaults.listKeys', + properties: ['id'], + url: 'https://management.azure.com/{id}?api-version=2023-07-01' + } + }, syncGroups: { list: { reliesOnPath: 'databases.listByServer', diff --git a/helpers/azure/locations.js b/helpers/azure/locations.js index 9cd3dcff0f..5777198bb9 100644 --- a/helpers/azure/locations.js +++ b/helpers/azure/locations.js @@ -94,6 +94,9 @@ module.exports = { networkGatewayConnections: locations, natGateways: locations, users: ['global'], + securityDefaultsPolicy: ['global'], + subscriptionPolicies: ['global'], + appInsights: ['global'], registries: locations, redisCaches: locations, pricings: ['global'], @@ -112,6 +115,7 @@ module.exports = { privateDnsZones: ['global'], privateEndpoints: locations, securityContactv2: ['global'], + securityContactv3: ['global'], images: locations, vmScaleSet: locations, applicationGateway: locations, 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..5411196f76 --- /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/subscriptionOwnerCount.js b/plugins/azure/entraid/subscriptionOwnerCount.js new file mode 100644 index 0000000000..455abd03d0 --- /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: 'Low', + 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..45c981504e --- /dev/null +++ b/plugins/azure/entraid/userAccessAdminRestricted.js @@ -0,0 +1,70 @@ +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 at the root scope.', + more_info: 'Assigning the User Access Administrator role at the root scope allows viewing all resources and managing access assignments across every subscription and management group in the tenant. This elevated access is intended to be temporary and 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 at the root scope 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()) && + roleAssignment.scope === '/'); + + if (!adminAssignments.length) { + helpers.addResult(results, 0, 'User Access Administrator role is not assigned at root scope', location); + return rcb(); + } + + adminAssignments.forEach(roleAssignment => { + helpers.addResult(results, 2, 'User Access Administrator role is assigned at root scope', 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..b6707c91ec --- /dev/null +++ b/plugins/azure/entraid/userAccessAdminRestricted.spec.js @@ -0,0 +1,141 @@ +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" + }, + { + "id": "/providers/Microsoft.Authorization/roleAssignments/2b45e3ef-59f3-4a95-9c4f-471b97cdeaf1", + "type": "Microsoft.Authorization/roleAssignments", + "name": "2b45e3ef-59f3-4a95-9c4f-471b97cdeaf1", + "roleDefinitionId": "/subscriptions/123/providers/Microsoft.Authorization/roleDefinitions/18d7d88d-d35e-4fb5-a5c3-7773c20a72d9", + "principalId": "358a9a70-2e04-4def-829c-239922b43dd0", + "principalType": "User", + "scope": "/" + } +]; + +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 at root scope'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give passing result if User Access Administrator role is assigned below root scope', function (done) { + const cache = createCache(roleDefinitions, [roleAssignments[0]], 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 at root scope'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + + it('should give failing result if User Access Administrator role is assigned at root scope', function (done) { + const cache = createCache(roleDefinitions, [roleAssignments[2]], 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 at root scope'); + expect(results[0].region).to.equal('global'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/storageaccounts/fileShareSmbChannelEncryption.js b/plugins/azure/storageaccounts/fileShareSmbChannelEncryption.js new file mode 100644 index 0000000000..36e9e5ee88 --- /dev/null +++ b/plugins/azure/storageaccounts/fileShareSmbChannelEncryption.js @@ -0,0 +1,75 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +const ACCEPTED_CHANNEL_ENCRYPTIONS = ['aes-256-gcm']; + +module.exports = { + title: 'File Share SMB Channel Encryption', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that SMB file shares are configured to only allow AES-256-GCM or higher for SMB channel encryption.', + more_info: 'Weaker SMB channel encryption algorithms such as AES-128-CCM and AES-128-GCM offer less protection against eavesdropping and man-in-the-middle attacks. Restricting SMB file shares to AES-256-GCM only helps ensure data confidentiality and integrity in transit.', + recommended_action: 'Modify the SMB security settings for the storage account file service and allow only AES-256-GCM for channel encryption.', + link: 'https://learn.microsoft.com/en-us/azure/storage/files/files-smb-protocol', + apis: ['storageAccounts:list', 'fileServices:getServiceProperties'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete', 'microsoftstorage:storageaccounts:fileservices:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.storageAccounts, function(location, rcb) { + const storageAccounts = helpers.addSource( + cache, source, ['storageAccounts', 'list', location]); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for storage accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + storageAccounts.data.forEach(storageAccount => { + const getServiceProperties = helpers.addSource(cache, source, + ['fileServices', 'getServiceProperties', location, storageAccount.id]); + + if (!getServiceProperties || getServiceProperties.err || !getServiceProperties.data) { + helpers.addResult(results, 3, + `Unable to get file service properties: ${helpers.addError(getServiceProperties)}`, + location, storageAccount.id); + } else { + const channelEncryption = getServiceProperties.data.protocolSettings && + getServiceProperties.data.protocolSettings.smb && + getServiceProperties.data.protocolSettings.smb.channelEncryption; + + const encryptionList = channelEncryption ? + channelEncryption.split(';').map(algorithm => algorithm.trim()).filter(algorithm => algorithm) : []; + + const acceptedList = ACCEPTED_CHANNEL_ENCRYPTIONS.map(algorithm => algorithm.toUpperCase()).join(', '); + + if (encryptionList.length && encryptionList.every(algorithm => ACCEPTED_CHANNEL_ENCRYPTIONS.includes(algorithm.toLowerCase()))) { + helpers.addResult(results, 0, + `File share SMB channel encryption is set to ${encryptionList.join(', ')}`, + location, storageAccount.id); + } else { + helpers.addResult(results, 2, + `File share SMB channel encryption is set to ${encryptionList.length ? encryptionList.join(', ') : 'all SMB channel encryption algorithms'} instead of ${acceptedList} only`, + location, storageAccount.id); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/fileShareSmbChannelEncryption.spec.js b/plugins/azure/storageaccounts/fileShareSmbChannelEncryption.spec.js new file mode 100644 index 0000000000..c30ee0c884 --- /dev/null +++ b/plugins/azure/storageaccounts/fileShareSmbChannelEncryption.spec.js @@ -0,0 +1,156 @@ +var expect = require('chai').expect; +var fileShareSmbChannelEncryption = require('./fileShareSmbChannelEncryption'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus' + } +]; + +const fileServiceProperties = [ + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc/fileServices/default', + 'protocolSettings': { + 'smb': { + 'channelEncryption': 'AES-256-GCM;' + } + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc/fileServices/default', + 'protocolSettings': { + 'smb': { + 'channelEncryption': 'AES-128-CCM;AES-128-GCM;AES-256-GCM;' + } + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc/fileServices/default', + 'protocolSettings': { + 'smb': {} + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc/fileServices/default' + } +]; + +const createCache = (storageAccounts, serviceProperties, servicePropertiesErr) => { + const accountId = storageAccounts && storageAccounts.length ? storageAccounts[0].id : null; + const propsObj = {}; + if (accountId) { + if (servicePropertiesErr) { + propsObj[accountId] = { err: servicePropertiesErr }; + } else if (serviceProperties) { + propsObj[accountId] = { data: serviceProperties }; + } + } + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + }, + fileServices: { + getServiceProperties: { + 'eastus': propsObj + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('fileShareSmbChannelEncryption', function () { + describe('run', function () { + it('should give passing result if no storage accounts found', function (done) { + const cache = createCache([], null); + fileShareSmbChannelEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function (done) { + const cache = createErrorCache(); + fileShareSmbChannelEncryption.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 storage accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to get file service properties', function (done) { + const cache = createCache(storageAccounts, null, ['Forbidden']); + fileShareSmbChannelEncryption.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 get file service properties'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if only AES-256-GCM is allowed', function (done) { + const cache = createCache(storageAccounts, fileServiceProperties[0]); + fileShareSmbChannelEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('File share SMB channel encryption is set to AES-256-GCM'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if weaker channel encryption algorithms are also allowed', function (done) { + const cache = createCache(storageAccounts, fileServiceProperties[1]); + fileShareSmbChannelEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('AES-128-CCM, AES-128-GCM, AES-256-GCM instead of AES-256-GCM only'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no channel encryption algorithms are configured', function (done) { + const cache = createCache(storageAccounts, fileServiceProperties[2]); + fileShareSmbChannelEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('all SMB channel encryption algorithms instead of AES-256-GCM only'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if protocol settings are not present', function (done) { + const cache = createCache(storageAccounts, fileServiceProperties[3]); + fileShareSmbChannelEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('all SMB channel encryption algorithms instead of AES-256-GCM only'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/storageaccounts/fileShareSmbProtocolVersion.js b/plugins/azure/storageaccounts/fileShareSmbProtocolVersion.js new file mode 100644 index 0000000000..f762727f40 --- /dev/null +++ b/plugins/azure/storageaccounts/fileShareSmbProtocolVersion.js @@ -0,0 +1,75 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +const ACCEPTED_SMB_VERSIONS = ['smb3.1.1']; + +module.exports = { + title: 'File Share SMB Protocol Version', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that SMB file shares are configured to only allow SMB 3.1.1 or higher.', + more_info: 'Older SMB protocol versions such as SMB 2.1 and SMB 3.0 may contain known vulnerabilities and lack modern security controls. Restricting SMB file shares to SMB 3.1.1 only helps mitigate the risk of exploitation.', + recommended_action: 'Modify the SMB security settings for the storage account file service and allow only SMB 3.1.1.', + link: 'https://learn.microsoft.com/en-us/azure/storage/files/files-smb-protocol', + apis: ['storageAccounts:list', 'fileServices:getServiceProperties'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete', 'microsoftstorage:storageaccounts:fileservices:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.storageAccounts, function(location, rcb) { + const storageAccounts = helpers.addSource( + cache, source, ['storageAccounts', 'list', location]); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for storage accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + storageAccounts.data.forEach(storageAccount => { + const getServiceProperties = helpers.addSource(cache, source, + ['fileServices', 'getServiceProperties', location, storageAccount.id]); + + if (!getServiceProperties || getServiceProperties.err || !getServiceProperties.data) { + helpers.addResult(results, 3, + `Unable to get file service properties: ${helpers.addError(getServiceProperties)}`, + location, storageAccount.id); + } else { + const smbVersions = getServiceProperties.data.protocolSettings && + getServiceProperties.data.protocolSettings.smb && + getServiceProperties.data.protocolSettings.smb.versions; + + const versionList = smbVersions ? + smbVersions.split(';').map(version => version.trim()).filter(version => version) : []; + + const acceptedList = ACCEPTED_SMB_VERSIONS.map(version => version.toUpperCase()).join(', '); + + if (versionList.length && versionList.every(version => ACCEPTED_SMB_VERSIONS.includes(version.toLowerCase()))) { + helpers.addResult(results, 0, + `File share SMB protocol version is set to ${versionList.join(', ')}`, + location, storageAccount.id); + } else { + helpers.addResult(results, 2, + `File share SMB protocol version is set to ${versionList.length ? versionList.join(', ') : 'all SMB versions'} instead of ${acceptedList} only`, + location, storageAccount.id); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/fileShareSmbProtocolVersion.spec.js b/plugins/azure/storageaccounts/fileShareSmbProtocolVersion.spec.js new file mode 100644 index 0000000000..d294f6c4f4 --- /dev/null +++ b/plugins/azure/storageaccounts/fileShareSmbProtocolVersion.spec.js @@ -0,0 +1,156 @@ +var expect = require('chai').expect; +var fileShareSmbProtocolVersion = require('./fileShareSmbProtocolVersion'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus' + } +]; + +const fileServiceProperties = [ + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc/fileServices/default', + 'protocolSettings': { + 'smb': { + 'versions': 'SMB3.1.1;' + } + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc/fileServices/default', + 'protocolSettings': { + 'smb': { + 'versions': 'SMB2.1;SMB3.0;SMB3.1.1;' + } + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc/fileServices/default', + 'protocolSettings': { + 'smb': {} + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc/fileServices/default' + } +]; + +const createCache = (storageAccounts, serviceProperties, servicePropertiesErr) => { + const accountId = storageAccounts && storageAccounts.length ? storageAccounts[0].id : null; + const propsObj = {}; + if (accountId) { + if (servicePropertiesErr) { + propsObj[accountId] = { err: servicePropertiesErr }; + } else if (serviceProperties) { + propsObj[accountId] = { data: serviceProperties }; + } + } + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + }, + fileServices: { + getServiceProperties: { + 'eastus': propsObj + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('fileShareSmbProtocolVersion', function () { + describe('run', function () { + it('should give passing result if no storage accounts found', function (done) { + const cache = createCache([], null); + fileShareSmbProtocolVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function (done) { + const cache = createErrorCache(); + fileShareSmbProtocolVersion.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 storage accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to get file service properties', function (done) { + const cache = createCache(storageAccounts, null, ['Forbidden']); + fileShareSmbProtocolVersion.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 get file service properties'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if only SMB3.1.1 is allowed', function (done) { + const cache = createCache(storageAccounts, fileServiceProperties[0]); + fileShareSmbProtocolVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('File share SMB protocol version is set to SMB3.1.1'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if older SMB versions are also allowed', function (done) { + const cache = createCache(storageAccounts, fileServiceProperties[1]); + fileShareSmbProtocolVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('SMB2.1, SMB3.0, SMB3.1.1 instead of SMB3.1.1 only'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no SMB versions are configured', function (done) { + const cache = createCache(storageAccounts, fileServiceProperties[2]); + fileShareSmbProtocolVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('all SMB versions instead of SMB3.1.1 only'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if protocol settings are not present', function (done) { + const cache = createCache(storageAccounts, fileServiceProperties[3]); + fileShareSmbProtocolVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('all SMB versions instead of SMB3.1.1 only'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/storageaccounts/fileShareSoftDeletionEnabled.js b/plugins/azure/storageaccounts/fileShareSoftDeletionEnabled.js new file mode 100644 index 0000000000..f0114c801c --- /dev/null +++ b/plugins/azure/storageaccounts/fileShareSoftDeletionEnabled.js @@ -0,0 +1,86 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'File Share Soft Deletion Enabled', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensure that soft delete feature is enabled for Microsoft Azure file shares.', + more_info: 'When soft delete for file shares is enabled for a storage account, a deleted file share is retained for the configured retention period before being permanently deleted, allowing data to be recovered if it is deleted by mistake or by a malicious actor.', + recommended_action: 'Enable soft delete for file shares and set the retention policy to keep deleted file shares for more than desired number of days.', + link: 'https://learn.microsoft.com/en-us/azure/storage/files/storage-files-prevent-file-share-deletion', + apis: ['storageAccounts:list', 'fileServices:getServiceProperties'], + settings: { + keep_deleted_file_shares_for_days: { + name: 'Keep Deleted File Shares for Days', + description: 'Number of days that a deleted file share is retained until it is permanently deleted', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '7' + } + }, + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete', 'microsoftstorage:storageaccounts:fileservices:write'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + const config = { + keepForDays: parseInt(settings.keep_deleted_file_shares_for_days || this.settings.keep_deleted_file_shares_for_days.default) + }; + + async.each(locations.storageAccounts, function(location, rcb) { + const storageAccounts = helpers.addSource( + cache, source, ['storageAccounts', 'list', location]); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for storage accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + storageAccounts.data.forEach(storageAccount => { + const getServiceProperties = helpers.addSource(cache, source, + ['fileServices', 'getServiceProperties', location, storageAccount.id]); + + if (!getServiceProperties || getServiceProperties.err || !getServiceProperties.data) { + helpers.addResult(results, 3, + `Unable to get file service properties: ${helpers.addError(getServiceProperties)}`, + location, storageAccount.id); + } else { + if (getServiceProperties.data.shareDeleteRetentionPolicy && + getServiceProperties.data.shareDeleteRetentionPolicy.enabled && + getServiceProperties.data.shareDeleteRetentionPolicy.days) { + const retentionDays = getServiceProperties.data.shareDeleteRetentionPolicy.days; + + if (retentionDays >= config.keepForDays) { + helpers.addResult(results, 0, + `File shares deletion policy is configured to persist deleted file shares for ${retentionDays} of ${config.keepForDays} days desired limit`, + location, storageAccount.id); + } else { + helpers.addResult(results, 2, + `File shares deletion policy is configured to persist deleted file shares for ${retentionDays} of ${config.keepForDays} days desired limit`, + location, storageAccount.id); + } + } else { + helpers.addResult(results, 2, + 'File shares soft delete feature is not enabled for Storage Account', + location, storageAccount.id); + } + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/fileShareSoftDeletionEnabled.spec.js b/plugins/azure/storageaccounts/fileShareSoftDeletionEnabled.spec.js new file mode 100644 index 0000000000..02b3f6073a --- /dev/null +++ b/plugins/azure/storageaccounts/fileShareSoftDeletionEnabled.spec.js @@ -0,0 +1,154 @@ +var expect = require('chai').expect; +var fileShareSoftDeletionEnabled = require('./fileShareSoftDeletionEnabled'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus' + } +]; + +const fileServiceProperties = [ + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc/fileServices/default', + 'shareDeleteRetentionPolicy': { + 'enabled': true, + 'days': 30 + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc/fileServices/default', + 'shareDeleteRetentionPolicy': { + 'enabled': true, + 'days': 3 + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc/fileServices/default', + 'shareDeleteRetentionPolicy': { + 'enabled': false + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc/fileServices/default' + } +]; + +const createCache = (storageAccounts, serviceProperties, servicePropertiesErr) => { + const accountId = storageAccounts && storageAccounts.length ? storageAccounts[0].id : null; + const propsObj = {}; + if (accountId) { + if (servicePropertiesErr) { + propsObj[accountId] = { err: servicePropertiesErr }; + } else if (serviceProperties) { + propsObj[accountId] = { data: serviceProperties }; + } + } + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + }, + fileServices: { + getServiceProperties: { + 'eastus': propsObj + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('fileShareSoftDeletionEnabled', function () { + describe('run', function () { + it('should give passing result if no storage accounts found', function (done) { + const cache = createCache([], null); + fileShareSoftDeletionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function (done) { + const cache = createErrorCache(); + fileShareSoftDeletionEnabled.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 storage accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to get file service properties', function (done) { + const cache = createCache(storageAccounts, null, ['Forbidden']); + fileShareSoftDeletionEnabled.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 get file service properties'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if retention days meet the desired limit', function (done) { + const cache = createCache(storageAccounts, fileServiceProperties[0]); + fileShareSoftDeletionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('persist deleted file shares for 30 of 7 days desired limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if retention days are less than the desired limit', function (done) { + const cache = createCache(storageAccounts, fileServiceProperties[1]); + fileShareSoftDeletionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('persist deleted file shares for 3 of 7 days desired limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if soft delete is disabled', function (done) { + const cache = createCache(storageAccounts, fileServiceProperties[2]); + fileShareSoftDeletionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('File shares soft delete feature is not enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no share delete retention policy exists', function (done) { + const cache = createCache(storageAccounts, fileServiceProperties[3]); + fileShareSoftDeletionEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('File shares soft delete feature is not enabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/storageaccounts/geoRedundantStorage.js b/plugins/azure/storageaccounts/geoRedundantStorage.js new file mode 100644 index 0000000000..f8f5067e74 --- /dev/null +++ b/plugins/azure/storageaccounts/geoRedundantStorage.js @@ -0,0 +1,65 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +const GEO_REDUNDANT_SKUS = ['standard_grs', 'standard_ragrs', 'standard_gzrs', 'standard_ragzrs']; + +module.exports = { + title: 'Storage Account Geo-Redundancy', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that geo-redundant storage is configured for Microsoft Azure Storage Accounts.', + more_info: 'Geo-redundant storage replicates data to a secondary region hundreds of miles away from the primary region, providing 16 nines of durability and protecting data against a complete regional outage. Locally redundant and zone-redundant storage only replicate within the primary region and do not protect against regional disasters.', + recommended_action: 'Modify the redundancy setting of the storage account and select a geo-redundant option such as geo-redundant storage (GRS).', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-redundancy', + apis: ['storageAccounts:list'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.storageAccounts, function(location, rcb) { + const storageAccounts = helpers.addSource( + cache, source, ['storageAccounts', 'list', location]); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for storage accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + storageAccounts.data.forEach(storageAccount => { + if (!storageAccount.id) return; + + const skuName = storageAccount.sku ? storageAccount.sku.name : undefined; + + if (!skuName) { + helpers.addResult(results, 3, + 'Unable to determine Storage Account redundancy setting', + location, storageAccount.id); + } else if (GEO_REDUNDANT_SKUS.includes(skuName.toLowerCase())) { + helpers.addResult(results, 0, + `Storage Account redundancy is set to ${skuName} which is geo-redundant`, + location, storageAccount.id); + } else { + helpers.addResult(results, 2, + `Storage Account redundancy is set to ${skuName} which is not geo-redundant`, + location, storageAccount.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/geoRedundantStorage.spec.js b/plugins/azure/storageaccounts/geoRedundantStorage.spec.js new file mode 100644 index 0000000000..67c3de5d7d --- /dev/null +++ b/plugins/azure/storageaccounts/geoRedundantStorage.spec.js @@ -0,0 +1,131 @@ +var expect = require('chai').expect; +var geoRedundantStorage = require('./geoRedundantStorage'); + +const account = (skuName) => { + const acc = { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus' + }; + if (skuName) acc.sku = { 'name': skuName, 'tier': 'Standard' }; + return acc; +}; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('geoRedundantStorage', function () { + describe('run', function () { + it('should give passing result if no storage accounts found', function (done) { + const cache = createCache([]); + geoRedundantStorage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function (done) { + const cache = createErrorCache(); + geoRedundantStorage.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 storage accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if redundancy is Standard_GRS', function (done) { + const cache = createCache([account('Standard_GRS')]); + geoRedundantStorage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Standard_GRS which is geo-redundant'); + done(); + }); + }); + + it('should give passing result if redundancy is Standard_RAGRS', function (done) { + const cache = createCache([account('Standard_RAGRS')]); + geoRedundantStorage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Standard_RAGRS which is geo-redundant'); + done(); + }); + }); + + it('should give passing result if redundancy is Standard_GZRS', function (done) { + const cache = createCache([account('Standard_GZRS')]); + geoRedundantStorage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Standard_GZRS which is geo-redundant'); + done(); + }); + }); + + it('should give passing result if redundancy is Standard_RAGZRS', function (done) { + const cache = createCache([account('Standard_RAGZRS')]); + geoRedundantStorage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Standard_RAGZRS which is geo-redundant'); + done(); + }); + }); + + it('should give failing result if redundancy is Standard_LRS', function (done) { + const cache = createCache([account('Standard_LRS')]); + geoRedundantStorage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Standard_LRS which is not geo-redundant'); + done(); + }); + }); + + it('should give failing result if redundancy is Standard_ZRS', function (done) { + const cache = createCache([account('Standard_ZRS')]); + geoRedundantStorage.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Standard_ZRS which is not geo-redundant'); + done(); + }); + }); + + it('should give unknown result if sku is not present', function (done) { + const cache = createCache([account(null)]); + geoRedundantStorage.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 determine Storage Account redundancy setting'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/storageaccounts/sharedKeyAccessDisabled.js b/plugins/azure/storageaccounts/sharedKeyAccessDisabled.js new file mode 100644 index 0000000000..cb8828f62a --- /dev/null +++ b/plugins/azure/storageaccounts/sharedKeyAccessDisabled.js @@ -0,0 +1,57 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Storage Account Shared Key Access Disabled', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that shared key access is disabled for Microsoft Azure Storage Accounts.', + more_info: 'Requests to a storage account can be authorized with either Microsoft Entra credentials or with the account access key using Shared Key authorization. Disabling Shared Key access requires clients to use Microsoft Entra ID, which provides superior security and ease of use compared to Shared Key.', + recommended_action: 'Disable Allow storage account key access from the configuration settings of the storage account.', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/shared-key-authorization-prevent', + apis: ['storageAccounts:list'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.storageAccounts, function(location, rcb) { + const storageAccounts = helpers.addSource( + cache, source, ['storageAccounts', 'list', location]); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for storage accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + storageAccounts.data.forEach(storageAccount => { + if (!storageAccount.id) return; + + if (storageAccount.allowSharedKeyAccess === false) { + helpers.addResult(results, 0, + 'Storage Account has shared key access disabled', + location, storageAccount.id); + } else { + helpers.addResult(results, 2, + 'Storage Account does not have shared key access disabled', + location, storageAccount.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/sharedKeyAccessDisabled.spec.js b/plugins/azure/storageaccounts/sharedKeyAccessDisabled.spec.js new file mode 100644 index 0000000000..dce39c5674 --- /dev/null +++ b/plugins/azure/storageaccounts/sharedKeyAccessDisabled.spec.js @@ -0,0 +1,106 @@ +var expect = require('chai').expect; +var sharedKeyAccessDisabled = require('./sharedKeyAccessDisabled'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus', + 'allowSharedKeyAccess': false + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus', + 'allowSharedKeyAccess': true + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus' + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('sharedKeyAccessDisabled', function () { + describe('run', function () { + it('should give passing result if no storage accounts found', function (done) { + const cache = createCache([]); + sharedKeyAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function (done) { + const cache = createErrorCache(); + sharedKeyAccessDisabled.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 storage accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if shared key access is disabled', function (done) { + const cache = createCache([storageAccounts[0]]); + sharedKeyAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has shared key access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if shared key access is enabled', function (done) { + const cache = createCache([storageAccounts[1]]); + sharedKeyAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have shared key access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if allowSharedKeyAccess is not set', function (done) { + const cache = createCache([storageAccounts[2]]); + sharedKeyAccessDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have shared key access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/storageaccounts/storageAccountEntraIdAuthDefault.js b/plugins/azure/storageaccounts/storageAccountEntraIdAuthDefault.js new file mode 100644 index 0000000000..9bb434afbc --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountEntraIdAuthDefault.js @@ -0,0 +1,57 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Storage Account Entra ID Authorization Default', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that the Azure portal defaults to Microsoft Entra Authorization for Microsoft Azure Storage Accounts.', + more_info: 'When this property is enabled, the Azure portal authorizes requests to blobs, files, queues, and tables with Microsoft Entra ID by default, which provides superior security and ease of use over Shared Key.', + recommended_action: 'Enable Default to Microsoft Entra authorization in the Azure portal from the configuration settings of the storage account.', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/authorize-data-access', + apis: ['storageAccounts:list'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.storageAccounts, function(location, rcb) { + const storageAccounts = helpers.addSource( + cache, source, ['storageAccounts', 'list', location]); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for storage accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + storageAccounts.data.forEach(storageAccount => { + if (!storageAccount.id) return; + + if (storageAccount.defaultToOAuthAuthentication) { + helpers.addResult(results, 0, + 'Storage Account defaults to Microsoft Entra authorization in the Azure portal', + location, storageAccount.id); + } else { + helpers.addResult(results, 2, + 'Storage Account does not default to Microsoft Entra authorization in the Azure portal', + location, storageAccount.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/storageAccountEntraIdAuthDefault.spec.js b/plugins/azure/storageaccounts/storageAccountEntraIdAuthDefault.spec.js new file mode 100644 index 0000000000..2c389b338e --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountEntraIdAuthDefault.spec.js @@ -0,0 +1,106 @@ +var expect = require('chai').expect; +var storageAccountEntraIdAuthDefault = require('./storageAccountEntraIdAuthDefault'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus', + 'defaultToOAuthAuthentication': true + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus', + 'defaultToOAuthAuthentication': false + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus' + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('storageAccountEntraIdAuthDefault', function () { + describe('run', function () { + it('should give passing result if no storage accounts found', function (done) { + const cache = createCache([]); + storageAccountEntraIdAuthDefault.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function (done) { + const cache = createErrorCache(); + storageAccountEntraIdAuthDefault.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 storage accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Entra ID authorization is the default', function (done) { + const cache = createCache([storageAccounts[0]]); + storageAccountEntraIdAuthDefault.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('defaults to Microsoft Entra authorization'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Entra ID authorization is not the default', function (done) { + const cache = createCache([storageAccounts[1]]); + storageAccountEntraIdAuthDefault.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not default to Microsoft Entra authorization'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if defaultToOAuthAuthentication is not set', function (done) { + const cache = createCache([storageAccounts[2]]); + storageAccountEntraIdAuthDefault.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not default to Microsoft Entra authorization'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/storageaccounts/storageAccountKeyRotation.js b/plugins/azure/storageaccounts/storageAccountKeyRotation.js new file mode 100644 index 0000000000..7992d63256 --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountKeyRotation.js @@ -0,0 +1,82 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Storage Account Keys Rotated', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that Microsoft Azure Storage Account access keys are regenerated periodically.', + more_info: 'Azure generates two access keys for each storage account which are used for authentication when the storage account is accessed. Regenerating these keys periodically ensures that inadvertent access or exposure does not result from the compromise of these keys.', + recommended_action: 'Rotate the storage account access keys from the access keys settings of the storage account.', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-account-keys-manage', + apis: ['storageAccounts:list'], + settings: { + storage_account_keys_rotation_days: { + name: 'Storage Account Keys Rotation Days', + description: 'Maximum number of days allowed since the storage account access keys were last rotated', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '90' + } + }, + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete', 'microsoftstorage:storageaccounts:regeneratekey'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + const config = { + rotationDays: parseInt(settings.storage_account_keys_rotation_days || this.settings.storage_account_keys_rotation_days.default) + }; + + async.each(locations.storageAccounts, function(location, rcb) { + const storageAccounts = helpers.addSource( + cache, source, ['storageAccounts', 'list', location]); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for storage accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + storageAccounts.data.forEach(storageAccount => { + if (!storageAccount.id) return; + + const keyCreationTime = storageAccount.keyCreationTime; + + if (!keyCreationTime || (!keyCreationTime.key1 && !keyCreationTime.key2)) { + helpers.addResult(results, 2, + 'Storage Account access keys have never been rotated', + location, storageAccount.id); + return; + } + + const keyAges = ['key1', 'key2'].filter(key => keyCreationTime[key]) + .map(key => helpers.daysBetween(new Date(), new Date(keyCreationTime[key]))); + const oldestKeyAge = Math.max(...keyAges); + + if (oldestKeyAge <= config.rotationDays) { + helpers.addResult(results, 0, + `Storage Account access keys were last rotated ${oldestKeyAge} days ago which is equal to or less than ${config.rotationDays} days limit`, + location, storageAccount.id); + } else { + helpers.addResult(results, 2, + `Storage Account access keys were last rotated ${oldestKeyAge} days ago which is more than ${config.rotationDays} days limit`, + location, storageAccount.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/storageAccountKeyRotation.spec.js b/plugins/azure/storageaccounts/storageAccountKeyRotation.spec.js new file mode 100644 index 0000000000..c992b842b1 --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountKeyRotation.spec.js @@ -0,0 +1,139 @@ +var expect = require('chai').expect; +var storageAccountKeyRotation = require('./storageAccountKeyRotation'); + +const daysAgo = (days) => { + const date = new Date(); + date.setDate(date.getDate() - days); + return date.toISOString(); +}; + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus', + 'keyCreationTime': { + 'key1': daysAgo(10), + 'key2': daysAgo(10) + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus', + 'keyCreationTime': { + 'key1': daysAgo(200), + 'key2': daysAgo(200) + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus', + 'keyCreationTime': { + 'key1': daysAgo(10), + 'key2': daysAgo(200) + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus' + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('storageAccountKeyRotation', function () { + describe('run', function () { + it('should give passing result if no storage accounts found', function (done) { + const cache = createCache([]); + storageAccountKeyRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function (done) { + const cache = createErrorCache(); + storageAccountKeyRotation.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 storage accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if keys were rotated within the desired limit', function (done) { + const cache = createCache([storageAccounts[0]]); + storageAccountKeyRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('equal to or less than 90 days limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if keys were not rotated within the desired limit', function (done) { + const cache = createCache([storageAccounts[1]]); + storageAccountKeyRotation.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 90 days limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if only one key is outdated', function (done) { + const cache = createCache([storageAccounts[2]]); + storageAccountKeyRotation.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 90 days limit'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if keys have never been rotated', function (done) { + const cache = createCache([storageAccounts[3]]); + storageAccountKeyRotation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('have never been rotated'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); diff --git a/plugins/azure/storageaccounts/storageAccountKeyRotationReminder.js b/plugins/azure/storageaccounts/storageAccountKeyRotationReminder.js new file mode 100644 index 0000000000..6a09d2baea --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountKeyRotationReminder.js @@ -0,0 +1,75 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Storage Account Key Rotation Reminder', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that key rotation reminders are enabled for Microsoft Azure Storage Accounts.', + more_info: 'Access keys authenticate application access requests to data contained in storage accounts. Setting a key rotation reminder helps maintain a regular cadence for regenerating access keys, so that a potentially compromised key cannot be used as a long-term credential.', + recommended_action: 'Enable key rotation reminders from the access keys settings of the storage account and set the reminder period to the desired number of days.', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-account-keys-manage', + apis: ['storageAccounts:list'], + settings: { + storage_account_key_rotation_reminder_days: { + name: 'Storage Account Key Rotation Reminder Days', + description: 'Maximum number of days allowed for the storage account access key rotation reminder period', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '90' + } + }, + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + const config = { + reminderDays: parseInt(settings.storage_account_key_rotation_reminder_days || this.settings.storage_account_key_rotation_reminder_days.default) + }; + + async.each(locations.storageAccounts, function(location, rcb) { + const storageAccounts = helpers.addSource( + cache, source, ['storageAccounts', 'list', location]); + + if (!storageAccounts) return rcb(); + + if (storageAccounts.err || !storageAccounts.data) { + helpers.addResult(results, 3, + 'Unable to query for storage accounts: ' + helpers.addError(storageAccounts), location); + return rcb(); + } + + if (!storageAccounts.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + storageAccounts.data.forEach(storageAccount => { + if (!storageAccount.id) return; + + const reminderDays = storageAccount.keyPolicy ? storageAccount.keyPolicy.keyExpirationPeriodInDays : undefined; + + if (!reminderDays) { + helpers.addResult(results, 2, + 'Storage Account does not have key rotation reminder enabled', + location, storageAccount.id); + } else if (reminderDays <= config.reminderDays) { + helpers.addResult(results, 0, + `Storage Account key rotation reminder is set to ${reminderDays} days`, + location, storageAccount.id); + } else { + helpers.addResult(results, 2, + `Storage Account key rotation reminder is set to ${reminderDays} days which is greater than ${config.reminderDays} days`, + location, storageAccount.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/storageAccountKeyRotationReminder.spec.js b/plugins/azure/storageaccounts/storageAccountKeyRotationReminder.spec.js new file mode 100644 index 0000000000..cd8c332ccd --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountKeyRotationReminder.spec.js @@ -0,0 +1,110 @@ +var expect = require('chai').expect; +var storageAccountKeyRotationReminder = require('./storageAccountKeyRotationReminder'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus', + 'keyPolicy': { + 'keyExpirationPeriodInDays': 90 + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus', + 'keyPolicy': { + 'keyExpirationPeriodInDays': 180 + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'name': 'acc', + 'type': 'Microsoft.Storage/storageAccounts', + 'location': 'eastus' + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('storageAccountKeyRotationReminder', function () { + describe('run', function () { + it('should give passing result if no storage accounts found', function (done) { + const cache = createCache([]); + storageAccountKeyRotationReminder.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function (done) { + const cache = createErrorCache(); + storageAccountKeyRotationReminder.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 storage accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if reminder period is within the desired limit', function (done) { + const cache = createCache([storageAccounts[0]]); + storageAccountKeyRotationReminder.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('key rotation reminder is set to 90 days'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if reminder period exceeds the desired limit', function (done) { + const cache = createCache([storageAccounts[1]]); + storageAccountKeyRotationReminder.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('key rotation reminder is set to 180 days which is greater than 90 days'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if key rotation reminder is not enabled', function (done) { + const cache = createCache([storageAccounts[2]]); + storageAccountKeyRotationReminder.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have key rotation reminder enabled'); + expect(results[0].region).to.equal('eastus'); + 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(); + }); + }); + }); +});