diff --git a/exports.js b/exports.js index ed0e07a616..055269026a 100644 --- a/exports.js +++ b/exports.js @@ -1128,6 +1128,7 @@ module.exports = { 'recoveryVaultByokEncrypted' : require(__dirname + '/plugins/azure/recoveryService/recoveryVaultByokEncrypted.js'), 'recoveryVaultLoggingEnabled' : require(__dirname + '/plugins/azure/recoveryService/recoveryVaultLoggingEnabled.js'), + 'recoveryVaultPrivateEndpoint' : require(__dirname + '/plugins/azure/recoveryService/recoveryVaultPrivateEndpoint.js'), 'domainPublicAccessEnabled' : require(__dirname + '/plugins/azure/eventGrid/domainPublicAccess.js'), 'domainMinimumTlsVersion' : require(__dirname + '/plugins/azure/eventGrid/domainMinimumTlsVersion.js'), diff --git a/helpers/azure/api.js b/helpers/azure/api.js index 8b77b76ff1..6e3016097d 100644 --- a/helpers/azure/api.js +++ b/helpers/azure/api.js @@ -601,7 +601,7 @@ var postcalls = { getRecoveryServiceVault: { reliesOnPath: 'recoveryServiceVaults.listBySubscriptionId', properties: ['id'], - url: 'https://management.azure.com/{id}?api-version=2022-04-01' + url: 'https://management.azure.com/{id}?api-version=2024-04-01' } }, webhooks: { diff --git a/package.json b/package.json index a21cd2500a..a587d2bfd7 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "dependencies": { "@alicloud/pop-core": "^1.7.10", "@azure/data-tables": "^13.2.2", + "@azure/identity": "^4.13.0", "@azure/storage-file-share": "^12.14.0", "@azure/storage-queue": "^12.13.0", "@azure/storage-blob": "^12.14.0", @@ -68,4 +69,4 @@ "nodemon": "^1.19.4", "nyc": "^14.1.1" } -} +} \ No newline at end of file diff --git a/plugins/azure/recoveryService/recoveryVaultPrivateEndpoint.js b/plugins/azure/recoveryService/recoveryVaultPrivateEndpoint.js new file mode 100644 index 0000000000..de2efcb2ac --- /dev/null +++ b/plugins/azure/recoveryService/recoveryVaultPrivateEndpoint.js @@ -0,0 +1,123 @@ +const async = require("async"); +const helpers = require("../../../helpers/azure"); + +module.exports = { + title: "Recovery Services Vault Private Endpoint", + category: "Recovery Service Vault", + domain: "Backup", + severity: "High", + description: + "Ensure that Azure Recovery Services Vaults are not publicly accessible and have private endpoints configured.", + more_info: + "Private endpoints restrict access to the vault over a private network, reducing exposure to the public internet.", + recommended_action: + "Configure a private endpoint for the Recovery Services Vault.", + link: "https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-overview", + apis: [ + "recoveryServiceVaults:getRecoveryServiceVault", + "recoveryServiceVaults:listBySubscriptionId", + ], + realtime_triggers: [ + "microsoftrecoveryservices:vaults:write", + "microsoftrecoveryservices:vaults:delete", + ], + + run: function (cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each( + locations.recoveryServiceVaults, + (location, rcb) => { + const serviceVaults = helpers.addSource(cache, source, [ + "recoveryServiceVaults", + "listBySubscriptionId", + location, + ]); + + if (!serviceVaults || serviceVaults.err || !serviceVaults.data) + return rcb(); + + for (let vault of serviceVaults.data) { + if (!vault.id) continue; + + const getVault = helpers.addSource(cache, source, [ + "recoveryServiceVaults", + "getRecoveryServiceVault", + location, + vault.id, + ]); + + if (!getVault || getVault.err || !getVault.data) { + helpers.addResult( + results, + 3, + "Unable to query Recovery Service Vault details: " + + helpers.addError(getVault), + location, + vault.id + ); + continue; + } + + // Use new API response structure + // Check for publicNetworkAccess property + const publicAccess = + typeof getVault.data.publicNetworkAccess === "string" + ? getVault.data.publicNetworkAccess + : "Enabled"; // Default to 'Enabled' if missing + + // Check for private endpoint states (new API: privateEndpointStateForBackup, privateEndpointStateForSiteRecovery) + // Consider vault private if either backup or site recovery endpoint is not 'None' + const backupPrivateState = + getVault.data.privateEndpointStateForBackup || "None"; + const siteRecoveryPrivateState = + getVault.data.privateEndpointStateForSiteRecovery || "None"; + const hasPrivate = + backupPrivateState !== "None" || + siteRecoveryPrivateState !== "None"; + const isPublicDisabled = publicAccess.toLowerCase() === "disabled"; + + if (hasPrivate && isPublicDisabled) { + helpers.addResult( + results, + 0, + "Recovery Service Vault is private: public network access is disabled and private endpoints are configured", + location, + vault.id + ); + } else if (!hasPrivate && isPublicDisabled) { + helpers.addResult( + results, + 2, + "Recovery Service Vault has public network access disabled but NO private endpoints configured (not accessible)", + location, + vault.id + ); + } else if (hasPrivate && !isPublicDisabled) { + helpers.addResult( + results, + 2, + "Recovery Service Vault has private endpoints but public network access is still enabled (partially public)", + location, + vault.id + ); + } else { + helpers.addResult( + results, + 2, + "Recovery Service Vault is PUBLIC: no private endpoints and public network access is enabled", + location, + vault.id + ); + } + } + rcb(); + }, + function () { + callback(null, results, source); + } + ); + }, +}; diff --git a/plugins/azure/recoveryService/recoveryVaultPrivateEndpoint.spec.js b/plugins/azure/recoveryService/recoveryVaultPrivateEndpoint.spec.js new file mode 100644 index 0000000000..195376405c --- /dev/null +++ b/plugins/azure/recoveryService/recoveryVaultPrivateEndpoint.spec.js @@ -0,0 +1,81 @@ +const expect = require("chai").expect; +const plugin = require("./recoveryVaultPrivateEndpoint"); + +const vaultId = + "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/ExampleRG/providers/Microsoft.RecoveryServices/vaults/ExampleVault"; +const location = "eastus"; + +function createCache(vaultData) { + return { + recoveryServiceVaults: { + listBySubscriptionId: { + [location]: { data: [{ id: vaultId }] }, + }, + getRecoveryServiceVault: { + [location]: { + [vaultId]: { data: vaultData }, + }, + }, + }, + }; +} + +describe("recoveryVaultPrivateEndpoint", function () { + it("should FAIL when public access is enabled and no private endpoints", function (done) { + const cache = createCache({ + publicNetworkAccess: "Enabled", + privateEndpointStateForBackup: "None", + privateEndpointStateForSiteRecovery: "None", + }); + plugin.run(cache, {}, (err, results) => { + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include("PUBLIC"); + done(); + }); + }); + + it("should FAIL when public access is enabled and private endpoints exist", function (done) { + const cache = createCache({ + publicNetworkAccess: "Enabled", + privateEndpointStateForBackup: "Approved", + privateEndpointStateForSiteRecovery: "None", + }); + plugin.run(cache, {}, (err, results) => { + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "private endpoints but public network access is still enabled" + ); + done(); + }); + }); + + it("should PASS (OK) when public access is disabled and private endpoints exist", function (done) { + const cache = createCache({ + publicNetworkAccess: "Disabled", + privateEndpointStateForBackup: "Approved", + privateEndpointStateForSiteRecovery: "None", + }); + plugin.run(cache, {}, (err, results) => { + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include( + "is private: public network access is disabled and private endpoints are configured" + ); + done(); + }); + }); + + it("should FAIL when public access is disabled and no private endpoints", function (done) { + const cache = createCache({ + publicNetworkAccess: "Disabled", + privateEndpointStateForBackup: "None", + privateEndpointStateForSiteRecovery: "None", + }); + plugin.run(cache, {}, (err, results) => { + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include( + "public network access disabled but NO private endpoints configured" + ); + done(); + }); + }); +});