Back

SAS-5: Inadequate Function Monitoring and Logging - Code Examples

Each pair below shows a vulnerable function (or configuration) and the secure version. The examples focus on what dominates real serverless findings: functions that emit no security context, missing distributed tracing, no anomaly/cost alerting, and log stores the workload can erase.

1. Lambda Handler — Node.js: Structured Security Logging

Vulnerable

// Nothing security-relevant is logged. Ephemeral invocation leaves no trace
// of who was denied, what they touched, or that anything happened at all.
exports.handler = async (event) => {
  const id = event.pathParameters.id;
  const tenant = event.requestContext.authorizer.tenantId;

  const order = await getOrder(id);
  if (order.tenantId !== tenant) {
    // Silent denial: no log, no identity, no correlation id.
    return { statusCode: 403, body: 'Forbidden' };
  }
  return { statusCode: 200, body: JSON.stringify(order) };
};
// CloudWatch shows only: START / END / REPORT. An attacker walking tenants
// produces thousands of 403s that are invisible.

Secure

// Structured, security-oriented events with identity + correlation ids.
function securityEvent(event, context, fields) {
  console.log(JSON.stringify({
    ts: new Date().toISOString(),
    level: 'SECURITY',
    request_id: context.awsRequestId,             // correlate this invocation
    trace_id: process.env._X_AMZN_TRACE_ID,       // correlate across the chain
    function: context.functionName,
    source_ip: event.requestContext?.identity?.sourceIp,
    identity: event.requestContext?.authorizer?.principalId,
    ...fields
  }));
}

exports.handler = async (event, context) => {
  const id = event.pathParameters.id;
  const tenant = event.requestContext.authorizer.tenantId;

  const order = await getOrder(id);
  if (order.tenantId !== tenant) {
    securityEvent(event, context, {
      event: 'authz_denied', resource: `orders/${id}`,
      outcome: 'DENY', reason: 'cross_tenant_access'
    });
    return { statusCode: 403, body: 'Forbidden' };
  }

  securityEvent(event, context, {
    event: 'sensitive_read', resource: `orders/${id}`, outcome: 'ALLOW'
  });
  return { statusCode: 200, body: JSON.stringify(order) };
};
// Now a tenant-walking sweep emits thousands of correlated authz_denied
// events from one identity/source -> a detectable, alertable pattern.

2. Lambda Handler — Python: Context-Rich Logging Without Leaking Secrets

Vulnerable

import json

def handler(event, context):
    # Prints the ENTIRE event -> dumps tokens, PII, and secrets into logs,
    # yet still records nothing about the security decision or outcome.
    print("event:", json.dumps(event))
    user = authenticate(event)          # failures raise and bubble up raw
    data = read_records(user)
    return {"statusCode": 200, "body": json.dumps(data)}
# Two failures at once: sensitive data leaked INTO logs, and no structured
# security event to actually detect abuse.

Secure

import json, logging, os
logger = logging.getLogger()
logger.setLevel(logging.INFO)

REDACT = {"authorization", "password", "token", "ssn", "card"}

def redact(d):
    return {k: ("***" if k.lower() in REDACT else v) for k, v in d.items()}

def security_event(context, **fields):
    logger.info(json.dumps({
        "level": "SECURITY",
        "request_id": context.aws_request_id,
        "trace_id": os.environ.get("_X_AMZN_TRACE_ID"),
        "function": context.function_name,
        **fields,
    }))

def handler(event, context):
    try:
        user = authenticate(event)
    except AuthError as e:
        security_event(context, event="auth_failed",
                       source_ip=event.get("requestContext", {})
                                     .get("identity", {}).get("sourceIp"),
                       outcome="DENY", reason=str(e.code))   # code, not secret
        return {"statusCode": 401, "body": "Unauthorized"}

    records = read_records(user)
    security_event(context, event="sensitive_read", identity=user.id,
                   outcome="ALLOW", record_count=len(records))  # volume signal
    return {"statusCode": 200, "body": json.dumps(records)}
# Logs carry decision + context (and a record_count that surfaces bulk reads),
# never the raw secrets.

3. Distributed Tracing Configuration (AWS SAM)

Vulnerable

# template.yaml - tracing disabled. A request that fans out across five
# functions and DynamoDB cannot be followed; each invocation is an island.
Resources:
  OrderApi:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      Runtime: nodejs20.x
      # no Tracing, no X-Ray permissions, no downstream instrumentation

Secure

# template.yaml - active tracing everywhere, so one trace_id spans the chain.
Globals:
  Function:
    Tracing: Active            # X-Ray active tracing on every function
  Api:
    TracingEnabled: true

Resources:
  OrderApi:
    Type: AWS::Serverless::Function
    Properties:
      Handler: index.handler
      Runtime: nodejs20.x
      Policies:
        - AWSXRayDaemonWriteAccess     # minimal permission to emit trace data
      # For vendor-neutral tracing, attach the ADOT (OpenTelemetry) layer instead.
// Node.js: instrument the AWS SDK so downstream calls become child spans
const AWSXRay = require('aws-xray-sdk-core');
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const ddb = AWSXRay.captureAWSv3Client(new DynamoDBClient({}));
// Now DynamoDB/S3/HTTP calls appear as spans under the request's trace,
// so a malicious request's full path is reconstructable.

4. Anomaly & Cost Alerting (CloudWatch)

Vulnerable

# No alarms at all. The only "monitoring" is opening the dashboard by hand.
# A denial-of-wallet loop drives invocations 1000x; the first signal is the
# monthly invoice. Successful-but-abusive reads never trip anything.

Secure

# CloudFormation: alarm on invocation-rate spikes (abuse / denial-of-wallet)
InvocationSpikeAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    Namespace: AWS/Lambda
    MetricName: Invocations
    Dimensions: [{ Name: FunctionName, Value: !Ref OrderApi }]
    Statistic: Sum
    Period: 60
    EvaluationPeriods: 2
    Threshold: 5000
    ComparisonOperator: GreaterThanThreshold
    AlarmActions: [ !Ref SecurityTopic ]        # SNS -> on-call page

# Alarm on estimated charges - cost as a security signal (ties to SAS-8)
BillingSpikeAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    Namespace: AWS/Billing
    MetricName: EstimatedCharges
    Dimensions: [{ Name: Currency, Value: USD }]
    Statistic: Maximum
    Period: 21600
    EvaluationPeriods: 1
    Threshold: 500                               # your budget ceiling
    ComparisonOperator: GreaterThanThreshold
    AlarmActions: [ !Ref SecurityTopic ]

# Alarm on error-rate spikes (probing / injection failures)
ErrorRateAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    Namespace: AWS/Lambda
    MetricName: Errors
    Dimensions: [{ Name: FunctionName, Value: !Ref OrderApi }]
    Statistic: Sum
    Period: 60
    EvaluationPeriods: 2
    Threshold: 50
    ComparisonOperator: GreaterThanThreshold
    AlarmActions: [ !Ref SecurityTopic ]

5. Detecting Unusual IAM / Role Use (EventBridge)

Vulnerable

# CloudTrail is on but nothing reacts to it. A compromised function assumes
# its role and calls AttachRolePolicy / CreateAccessKey - recorded, but no
# one is alerted, so it sits unnoticed in the trail.

Secure

# EventBridge rule: page a responder on sensitive IAM/STS API calls in near real time
IamAnomalyRule:
  Type: AWS::Events::Rule
  Properties:
    EventPattern:
      source: [ "aws.iam", "aws.sts" ]
      detail-type: [ "AWS API Call via CloudTrail" ]
      detail:
        eventName:
          - AttachRolePolicy
          - PutRolePolicy
          - CreateAccessKey
          - AssumeRole
    Targets:
      - Arn: !Ref SecurityTopic                  # SNS -> security on-call
        Id: notify-security
# Pair with GuardDuty for managed anomaly detection over CloudTrail, DNS,
# and Lambda network activity - no static thresholds to hand-tune.

6. Protecting Log Retention and Integrity

Vulnerable

# Default log group: short/undefined retention, and the function's own
# execution role can manage it. An attacker who owns the function deletes
# the streams or shortens retention and erases their own trail.
OrderFnRole:
  # ...
  Policies:
    - PolicyDocument:
        Statement:
          - Effect: Allow
            Action: [ "logs:*" ]        # includes DeleteLogStream, PutRetentionPolicy
            Resource: "*"

Secure

# Explicit, adequate retention; workload role can only WRITE, not manage/delete.
OrderFnLogGroup:
  Type: AWS::Logs::LogGroup
  Properties:
    LogGroupName: /aws/lambda/order-fn
    RetentionInDays: 365                 # long enough to investigate + comply

OrderFnRole:
  # ...
  Policies:
    - PolicyDocument:
        Statement:
          - Effect: Allow
            Action:
              - logs:CreateLogStream
              - logs:PutLogEvents        # write only - no delete/retention control
            Resource: !GetAtt OrderFnLogGroup.Arn

# Plus: ship logs off-account in near real time (subscription filter ->
# central security account) and keep immutable forensic copies (S3 Object Lock).

What Changed, and Why

GapVulnerableSecure
Security eventsOnly START/END; silent denialsStructured events with identity, resource, outcome, correlation ids
Sensitive data in logsWhole event dumped (tokens, PII)Redacted fields; log the decision, not the secret
TracingDisabled; invocations are islandsX-Ray/OTel active; one trace across the chain
AlertingNone; first signal is the invoiceError, invocation, and cost alarms to on-call
IAM anomaliesRecorded but never actionedEventBridge/GuardDuty alerts in near real time
Log integrityWorkload can delete its own logsWrite-only role, off-account shipping, immutable copies

Next Steps