diff --git a/helpers/aws/functions.js b/helpers/aws/functions.js index 4791e488da..cbbdce4f0c 100644 --- a/helpers/aws/functions.js +++ b/helpers/aws/functions.js @@ -1239,48 +1239,174 @@ var getAttachedELBs = function(cache, source, region, resourceId, lbField, lbAt return elbs; }; -var checkNetworkExposure = function(cache, source, subnets, securityGroups, elbs, region, results, resource) { +var checkNetworkExposure = function(cache, source, subnets, securityGroups, elbs, region, results, resource) { var internetExposed = ''; var isSubnetPrivate = false; + if (resource && resource.functionArn) { + // Check Function URL exposure + if (resource.functionUrlConfig && resource.functionUrlConfig.data) { + if (resource.functionUrlConfig.data.AuthType === 'NONE') { + internetExposed += 'public function URL'; + } else if (resource.functionUrlConfig.data.AuthType === 'AWS_IAM' && + resource.functionPolicy && resource.functionPolicy.data) { + let authConfig = resource.functionPolicy.data; + if (authConfig.Policy) { + let statements = normalizePolicyDocument(authConfig.Policy); + + if (statements) { + let hasDenyAll = false; + let hasPublicAllow = false; + let hasRestrictiveConditions = false; + + for (let statement of statements) { + // Check for explicit deny statements first + if (statement.Effect === 'Deny') { + // Check if there's a deny for all principals + if ((!statement.Condition || Object.keys(statement.Condition).length === 0) && + globalPrincipal(statement.Principal)) { + hasDenyAll = true; + break; + } + + // Check for deny with IP restrictions + if (statement.Condition && + (statement.Condition['NotIpAddress'] || + statement.Condition['IpAddress'])) { + hasRestrictiveConditions = true; + } + } else if (statement.Effect === 'Allow') { + // Skip if the statement doesn't include relevant Lambda actions + if (!statement.Action || + (!Array.isArray(statement.Action) ? + !statement.Action.includes('lambda:InvokeFunctionUrl') : + !statement.Action.some(action => + action === '*' || + action === 'lambda:*' || + action === 'lambda:InvokeFunctionUrl' + ))) { + continue; + } + + // Check for * principal with no conditions + if (globalPrincipal(statement.Principal)) { + if (!statement.Condition || Object.keys(statement.Condition).length === 0) { + hasPublicAllow = true; + } else { + // Check for common restrictive conditions + const restrictiveConditions = [ + 'aws:SourceIp', + 'aws:SourceVpc', + 'aws:SourceVpce', + 'aws:PrincipalOrgID', + 'aws:PrincipalArn', + 'aws:SourceAccount' + ]; + + const hasRestriction = restrictiveConditions.some(condition => + Object.keys(statement.Condition).some(key => + key.toLowerCase().includes(condition.toLowerCase()) + ) + ); + + if (hasRestriction) { + hasRestrictiveConditions = true; + } else if (statement.Condition['StringEquals'] && + statement.Condition['StringEquals']['lambda:FunctionUrlAuthType'] === 'NONE') { + hasPublicAllow = true; + } + } + } + } + } + + // Only mark as exposed if we have a public allow and no restrictions + if (hasPublicAllow && !hasDenyAll && !hasRestrictiveConditions) { + internetExposed += internetExposed.length ? + ', function URL with global IAM access' : + 'function URL with global IAM access'; + } + } + } + } + } + + // Check API Gateway exposure + let getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (getRestApis && getRestApis.data) { + for (let api of getRestApis.data) { + if (!api.id || !api.name) continue; + + // Get stages to check if API is deployed + let getStages = helpers.addSource(cache, source, + ['apigateway', 'getStages', region, api.id]); + + // Only include if API has at least one stage deployed + if (!getStages || getStages.err || !getStages.data || !getStages.data.item || !getStages.data.item.length) continue; + + // Get integrations for this API + let getIntegration = helpers.addSource(cache, source, + ['apigateway', 'getIntegration', region, api.id]); + + if (!getIntegration || getIntegration.err || !Object.keys(getIntegration).length) continue; + + for (let apiResource of Object.values(getIntegration)) { + // Check if any integration points to this Lambda function + let lambdaIntegrations = Object.values(apiResource).filter(integration => { + return integration && integration.data && (integration.data.type === 'AWS' || integration.data.type === 'AWS_PROXY') && + integration.data.uri && + integration.data.uri.includes(resource.functionArn); + }); + + if (lambdaIntegrations.length) { + internetExposed += internetExposed.length ? `, API Gateway ${api.name}` : `API Gateway ${api.name}`; + } + } + } + } + } + // Check public endpoint access for specific resources like EKS if (resource && resource.resourcesVpcConfig && resource.resourcesVpcConfig.endpointPublicAccess) { return 'public endpoint access'; } + + if (!resource.functionArn) { // Scenario 1: check if resource is in a private subnet - let subnetRouteTableMap, privateSubnets; - var describeSubnets = helpers.addSource(cache, source, - ['ec2', 'describeSubnets', region]); - var describeRouteTables = helpers.addSource(cache, {}, - ['ec2', 'describeRouteTables', region]); + let subnetRouteTableMap, privateSubnets; + var describeSubnets = helpers.addSource(cache, source, + ['ec2', 'describeSubnets', region]); + var describeRouteTables = helpers.addSource(cache, {}, + ['ec2', 'describeRouteTables', region]); - if (!describeRouteTables || describeRouteTables.err || !describeRouteTables.data) { - helpers.addResult(results, 3, - 'Unable to query for route tables: ' + helpers.addError(describeRouteTables), region); - } else if (!describeSubnets || describeSubnets.err || !describeSubnets.data) { - helpers.addResult(results, 3, - 'Unable to query for subnets: ' + helpers.addError(describeSubnets), region); - } else if (describeSubnets.data.length && subnets.length) { - subnetRouteTableMap = getSubnetRTMap(describeSubnets.data, describeRouteTables.data); - privateSubnets = getPrivateSubnets(subnetRouteTableMap, describeSubnets.data, describeRouteTables.data); - if (privateSubnets && privateSubnets.length) { - isSubnetPrivate = !subnets.some(subnet => !privateSubnets.includes(subnet.id)); - } + if (!describeRouteTables || describeRouteTables.err || !describeRouteTables.data) { + helpers.addResult(results, 3, + 'Unable to query for route tables: ' + helpers.addError(describeRouteTables), region); + } else if (!describeSubnets || describeSubnets.err || !describeSubnets.data) { + helpers.addResult(results, 3, + 'Unable to query for subnets: ' + helpers.addError(describeSubnets), region); + } else if (describeSubnets.data.length && subnets.length) { + subnetRouteTableMap = getSubnetRTMap(describeSubnets.data, describeRouteTables.data); + privateSubnets = getPrivateSubnets(subnetRouteTableMap, describeSubnets.data, describeRouteTables.data); + if (privateSubnets && privateSubnets.length) { + isSubnetPrivate = !subnets.some(subnet => !privateSubnets.includes(subnet.id)); + } - // if it's in a private subnet and has no ELBs attached then its not exposed - if (isSubnetPrivate && (!elbs || !elbs.length)) { - return ''; + // if it's in a private subnet and has no ELBs attached then its not exposed + if (isSubnetPrivate && (!elbs || !elbs.length) && !resource.functionArn) { + return ''; + } } } - // If the subnet is not private we will check if security groups and Network ACLs allow internal traffic - // Scenario 2: check if security group allows all traffic - var describeSecurityGroups = helpers.addSource(cache, source, - ['ec2', 'describeSecurityGroups', region]); - - if (!isSubnetPrivate) { + var describeSecurityGroups; + if (!isSubnetPrivate && !resource.functionArn) { + describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); if (!describeSecurityGroups || describeSecurityGroups.err || !describeSecurityGroups.data) { helpers.addResult(results, 3, 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); @@ -1294,9 +1420,8 @@ var checkNetworkExposure = function(cache, source, subnets, securityGroups, elbs } } - // if security group allows all traffic we need to check NACLs - if (internetExposed.length) { + if (internetExposed.length && !resource.functionArn) { let subnetIds = subnets.map(s => s.id); // Scenario 3: check if Network ACLs associated with the resource allow all traffic var describeNetworkAcls = helpers.addSource(cache, source, @@ -1350,7 +1475,7 @@ var checkNetworkExposure = function(cache, source, subnets, securityGroups, elbs }); // exposed - if NACL has an allow all rule - if (exposed) { + if (exposed && !resource.functionArn) { internetExposed += `, nacl ${instanceACL.NetworkAclId}`; } @@ -1364,29 +1489,34 @@ var checkNetworkExposure = function(cache, source, subnets, securityGroups, elbs } // not exposed - if all NACLs have deny rules - if (naclDeny) { + if (naclDeny && !resource.functionArn) { return ''; } } - } } // if there are no explicit allow or deny rules, we look at ELBs - - if (elbs && elbs.length) { - for (const lb of elbs) { + if (!describeSecurityGroups || !describeSecurityGroups.data) { + describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + } + elbs.forEach(lb => { let isLBPublic = false; if (lb.Scheme && lb.Scheme.toLowerCase() === 'internet-facing') { - if (lb.SecurityGroups && lb.SecurityGroups.length && describeSecurityGroups && - !describeSecurityGroups.err && describeSecurityGroups.data && describeSecurityGroups.data.length) { - let elbSGs = describeSecurityGroups.data.filter(sg => lb.SecurityGroups.includes(sg.GroupId)); - for (var elbSG of elbSGs) { - let exposedSG = checkSecurityGroup(elbSG, cache, region, false); - if (exposedSG) { - isLBPublic = true; + if (lb.SecurityGroups && lb.SecurityGroups.length) { + var describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + if (describeSecurityGroups && + !describeSecurityGroups.err && describeSecurityGroups.data && describeSecurityGroups.data.length) { + let elbSGs = describeSecurityGroups.data.filter(sg => lb.SecurityGroups.includes(sg.GroupId)); + for (var elbSG of elbSGs) { + let exposedSG = checkSecurityGroup(elbSG, cache, region, false); + if (exposedSG) { + isLBPublic = true; + } } } } @@ -1395,12 +1525,74 @@ var checkNetworkExposure = function(cache, source, subnets, securityGroups, elbs if (isLBPublic) { internetExposed += internetExposed.length ? `, elb ${lb.LoadBalancerName}`: `elb ${lb.LoadBalancerName}`; } - } + }); } return internetExposed; }; +let getLambdaTargetELBs = function(cache, source, region) { + let lambdaELBMap = {}; + + var describeLoadBalancersv2 = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!describeLoadBalancersv2 || describeLoadBalancersv2.err || !describeLoadBalancersv2.data) { + return lambdaELBMap; + } + + describeLoadBalancersv2.data.forEach(lb => { + var describeTargetGroups = helpers.addSource(cache, source, + ['elbv2', 'describeTargetGroups', region, lb.DNSName]); + + if (!describeTargetGroups || describeTargetGroups.err || !describeTargetGroups.data || + !describeTargetGroups.data.TargetGroups) return; + + describeTargetGroups.data.TargetGroups.forEach(tg => { + var describeTargetHealth = helpers.addSource(cache, source, + ['elbv2', 'describeTargetHealth', region, tg.TargetGroupArn]); + + if (!describeTargetHealth || describeTargetHealth.err || !describeTargetHealth.data || + !describeTargetHealth.data.TargetHealthDescriptions) return; + + describeTargetHealth.data.TargetHealthDescriptions.forEach(target => { + if (target.Target && target.Target.Id && + target.Target.Id.startsWith('arn:aws:lambda')) { + if (!lambdaELBMap[target.Target.Id]) { + lambdaELBMap[target.Target.Id] = []; + } + lb.targetGroups = lb.targetGroups || []; + lb.targetGroups.push({ + targetGroupName: tg.TargetGroupName, + targetGroupArn: tg.TargetGroupArn, + targets: [target.Target] + }); + + // Check if there's an active listener for this target group + let hasListener = false; + var describeListeners = helpers.addSource(cache, source, + ['elbv2', 'describeListeners', region, lb.DNSName]); + + if (describeListeners && describeListeners.data && + describeListeners.data.Listeners) { + hasListener = describeListeners.data.Listeners.some(listener => + listener.DefaultActions.some(action => + action.TargetGroupArn === tg.TargetGroupArn + ) + ); + } + + if (hasListener) { + lambdaELBMap[target.Target.Id].push(lb); + } + } + }); + }); + }); + + return lambdaELBMap; +}; + module.exports = { addResult: addResult, findOpenPorts: findOpenPorts, @@ -1439,6 +1631,7 @@ module.exports = { processFieldSelectors: processFieldSelectors, checkNetworkInterface: checkNetworkInterface, checkNetworkExposure: checkNetworkExposure, - getAttachedELBs: getAttachedELBs + getAttachedELBs: getAttachedELBs, + getLambdaTargetELBs }; diff --git a/plugins/aws/lambda/lambdaNetworkExposure.js b/plugins/aws/lambda/lambdaNetworkExposure.js new file mode 100644 index 0000000000..d552ca1723 --- /dev/null +++ b/plugins/aws/lambda/lambdaNetworkExposure.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Network Exposure', + category: 'Lambda', + domain: 'Serverless', + severity: 'Info', + description: 'Check if Lambda functions are exposed to the internet.', + more_info: 'Lambda functions can be exposed to the internet through Function URLs with public access policies or through API Gateway integrations. It\'s important to ensure these endpoints are properly secured.', + link: 'https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html', + recommended_action: 'Ensure Lambda Function URLs have proper authorization configured and API Gateway integrations use appropriate security measures.', + apis: ['Lambda:listFunctions', 'Lambda:getFunctionUrlConfig', 'Lambda:getPolicy', + 'APIGateway:getRestApis','APIGateway:getResources', 'APIGateway:getStages', 'APIGateway:getIntegration', 'ELBv2:describeLoadBalancers', 'ELBv2:describeTargetGroups', + 'ELBv2:describeTargetHealth', 'ELBv2:describeListeners', 'EC2:describeSecurityGroups'], + realtime_triggers: ['lambda:CreateFunctionUrlConfig', 'lambda:UpdateFunctionUrlConfig', 'lambda:DeleteFunctionUrlConfig', + 'lambda:AddPermission', 'lambda:RemovePermission', + 'apigateway:CreateRestApi', 'apigateway:DeleteRestApi', 'apigateway:UpdateRestApi', + 'apigateway:CreateStage', 'apigateway:DeleteStage', 'apigateway:UpdateStage', + 'apigateway:PutIntegration', 'apigateway:DeleteIntegration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.lambda, function(region, rcb) { + var listFunctions = helpers.addSource(cache, source, + ['lambda', 'listFunctions', region]); + + if (!listFunctions) return rcb(); + + if (listFunctions.err || !listFunctions.data) { + helpers.addResult(results, 3, + 'Unable to query for Lambda functions: ' + helpers.addError(listFunctions), region); + return rcb(); + } + + if (!listFunctions.data.length) { + helpers.addResult(results, 0, 'No Lambda functions found', region); + return rcb(); + } + + let lambdaELBMap = helpers.getLambdaTargetELBs(cache, source, region); + + for (var lambda of listFunctions.data) { + if (!lambda.FunctionArn) continue; + + // Get function URL config and policy for Lambda-specific checks + var getFunctionUrlConfig = helpers.addSource(cache, source, + ['lambda', 'getFunctionUrlConfig', region, lambda.FunctionName]); + + var getPolicy = helpers.addSource(cache, source, + ['lambda', 'getPolicy', region, lambda.FunctionName]); + + let lambdaResource = { + functionUrlConfig: getFunctionUrlConfig, + functionPolicy: getPolicy, + functionArn: lambda.FunctionArn + }; + + let targetingELBs = lambdaELBMap[lambda.FunctionArn] || []; + + let internetExposed = helpers.checkNetworkExposure(cache, source, [], [], targetingELBs, region, results, lambdaResource); + + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, + `Lambda function is exposed to the internet through: ${internetExposed}`, + region, lambda.FunctionArn); + } else { + helpers.addResult(results, 0, + 'Lambda function is not exposed to the internet', + region, lambda.FunctionArn); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.spec.js b/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.spec.js index d209c8b1a9..1c5431cb1f 100644 --- a/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.spec.js +++ b/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.spec.js @@ -5,7 +5,7 @@ var keyExpiryPass = new Date(); keyExpiryPass.setMonth(keyExpiryPass.getMonth() + 2); var keyExpiryFail = new Date(); -keyExpiryFail.setMonth(keyExpiryFail.getMonth() + 1); +keyExpiryFail.setDate(keyExpiryFail.getDate() + 25); // Set to 35 days in the future var keyExpired = new Date(); keyExpired.setMonth(keyExpired.getMonth() - 1); diff --git a/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.spec.js b/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.spec.js index fd7b70bfd3..2a64720d2e 100644 --- a/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.spec.js +++ b/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.spec.js @@ -5,7 +5,7 @@ var secretExpiryPass = new Date(); secretExpiryPass.setMonth(secretExpiryPass.getMonth() + 2); var secretExpiryFail = new Date(); -secretExpiryFail.setMonth(secretExpiryFail.getMonth() + 1); +secretExpiryFail.setDate(secretExpiryFail.getDate() + 25); // Set to 35 days in the future var secretExpired = new Date(); secretExpired.setMonth(secretExpired.getMonth() - 1);