← Back to blog

2026-09-22 · 11 min read

Scoring AWS Security Like a Credit Score: The Algorithm Behind CloudSentry

A technical deep-dive into how CloudSentry calculates a 0-100 security score for AWS accounts using weighted categories, severity deductions, per-category caps, and week-over-week trend tracking in DynamoDB.

#aws#security#lambda#dynamodb#terraform#serverless#python#open-source
Scoring AWS Security Like a Credit Score: The Algorithm Behind CloudSentry

Security posture is hard to communicate. You can show someone 47 findings in Security Hub, but that doesn't tell them whether the account is in good shape or terrible shape. Is 47 findings bad? Compared to what?

CloudSentry solves this with a single number: a 0-100 security score, calculated like a credit score. Higher is better. The score considers severity, category importance, and applies caps so one noisy area can't tank the entire grade.

I wrote about CloudSentry before covering what it does. This post goes deep into how the scoring algorithm works, why certain design decisions were made, and how week-over-week trends are tracked.

The Scoring Model

Starting Point: 100

Every account starts with a perfect score of 100. Points are deducted for each security finding or policy violation discovered during the scan.

Severity Weights

Each finding has a severity level. The point deduction per item:

SeverityPoints DeductedExample Finding
Critical10Root account has no MFA
High5Security group allows SSH from 0.0.0.0/0
Medium2VPC has no flow logs enabled
Low1Orphaned security group (unused)

A single critical finding costs as much as 10 low-severity ones. This makes the score sensitive to the things that actually matter.

Category Weights (The Multiplier)

Not all security domains are equal. An IAM misconfiguration is more dangerous than a missing tag. Category weights multiply the base severity deduction:

CATEGORY_WEIGHTS = {
    "iam": 1.5,          # IAM issues are most impactful
    "networking": 1.3,   # Network exposure is high risk
    "database": 1.2,     # Data tier is critical
    "access": 1.2,       # Access patterns matter
    "encryption": 1.0,   # Encryption at rest
    "logging": 1.0,      # Audit trail
    "storage": 1.0,      # Data exposure
    "compute": 0.8,      # Instance-level issues
    "dns": 0.8,          # DNS misconfigurations
    "architecture": 0.7, # Architecture choices
    "tag": 0.5,          # Tags are hygiene, not security
    "lifecycle": 0.5,    # Lifecycle is cost, not security
    "cost": 0.5,         # Financial, not security
    "naming": 0.3,       # Naming is low impact
}

So a critical IAM finding deducts: 10 (severity) x 1.5 (category) = 15 points.

A medium tag violation deducts: 2 (severity) x 0.5 (category) = 1 point.

This means the score cares most about the things that could actually lead to a breach.

Per-Category Cap (Preventing Noise)

Without caps, an account with 200 low-severity tag violations would score 0 (200 x 1 = 200 deductions). That's misleading: the account might have perfect IAM, encryption, and networking but terrible tag hygiene.

The cap: maximum 30 points deducted from any single category.

MAX_CATEGORY_DEDUCTION = 30

# Applied per category
total_deductions = sum(
    min(deduction, MAX_CATEGORY_DEDUCTION)
    for deduction in category_deductions.values()
)

This means even if your networking has 100 findings, it can only drag the score down by 30 points. The remaining 70 points reflect all other categories fairly.

The Full Formula

score = max(0, 100 - sum(min(category_total, 30) for each category))

where category_total = sum(severity_weight * category_multiplier for each finding in category)

Letter Grades

ScoreGradeInterpretation
85-100AExcellent security posture
70-84BGood, minor issues
50-69CNeeds attention
35-49DSignificant risks
0-34FCritical remediation needed

Multi-Account Scanning

CloudSentry runs in a "hub" account and assumes roles into target accounts:

def get_sessions(config):
    sessions = []
    # Always include the hub account
    sessions.append({"account_id": hub_id, "session": boto3.Session()})

    # Assume into each configured target account
    for account in config.accounts:
        credentials = sts.assume_role(
            RoleArn=account["role_arn"],
            RoleSessionName="CloudSentryAudit",
            ExternalId="cloudsentry-audit"
        )["Credentials"]

        session = boto3.Session(
            aws_access_key_id=credentials["AccessKeyId"],
            aws_secret_access_key=credentials["SecretAccessKey"],
            aws_session_token=credentials["SessionToken"]
        )
        sessions.append({"account_id": account["id"], "session": session})

    return sessions

The cross-account role (deployed via Terraform in each target account) grants ReadOnlyAccess plus Cost Explorer permissions, gated by an ExternalId for confused-deputy protection:

resource "aws_iam_role" "cloudsentry_audit" {
  name = "CloudSentryAuditRole"
  assume_role_policy = jsonencode({
    Statement = [{
      Effect    = "Allow"
      Principal = { AWS = "arn:aws:iam::${var.hub_account_id}:root" }
      Action    = "sts:AssumeRole"
      Condition = {
        StringEquals = { "sts:ExternalId" = "cloudsentry-audit" }
      }
    }]
  })
}

Every scan stores aggregate results in a DynamoDB table:

Table: cloudsentry-history
Partition key: account_id (String)
Sort key: scan_date (String, YYYY-MM-DD)
TTL: 1 year from scan time

Each record stores:

  • Score (as Decimal)
  • Finding counts by severity
  • Cost data (month-to-date, last month)
  • Regions scanned

To compute the trend, the previous report is retrieved:

def get_previous_report(account_id):
    response = table.query(
        KeyConditionExpression=Key("account_id").eq(account_id),
        ScanIndexForward=False,  # newest first
        Limit=2
    )
    items = response.get("Items", [])
    return items[1] if len(items) > 1 else None

The dashboard then shows: Score: 78 (+5 from last week) with green/red arrows.

For longer trends, the history page queries up to 52 weeks of data and renders a sparkline showing score trajectory over time.

The Scanner Architecture

11 scanner modules run per account, plus 6 violation checkers:

Security scanners (find misconfigurations):

  • IAM: root account, password policy, users, roles, groups
  • Networking: security groups (18 risky ports), VPCs, NACLs, load balancers
  • Compute: EC2 instances, public IPs, IMDSv1
  • Storage: S3 public access, encryption, versioning
  • Database: RDS publicly accessible, unencrypted
  • Encryption: KMS policies, unrotated keys
  • Logging: CloudTrail, GuardDuty
  • DNS: dangling CNAMEs, expiring certificates
  • Cost: spend analysis, burn rate, credits

Violation checkers (find policy drift):

  • Tag compliance, naming conventions, lifecycle policies
  • Cost thresholds, architecture rules, access patterns

Each scanner produces standardised findings with severity, resource ID, region, description, risk explanation, and copy-paste fix commands.

Remediation: Copy-Paste Fix Commands

Every finding includes the exact AWS CLI command to fix it:

{
  "title": "Security group allows SSH from anywhere",
  "severity": "high",
  "resource_id": "sg-0abc123def456",
  "fix_commands": [
    "aws ec2 revoke-security-group-ingress --group-id sg-0abc123def456 --protocol tcp --port 22 --cidr 0.0.0.0/0"
  ],
  "better_alternative": "Use SSM Session Manager instead of direct SSH access"
}

The action plan groups these by severity (critical first) and includes effort estimates so teams can prioritise: "5 critical fixes (estimated 30 minutes), 12 high fixes (estimated 2 hours)."

What It Costs

The entire system runs on free tier:

ServiceMonthly Cost
Lambda (1 execution/week)$0.00
EventBridge$0.00
DynamoDB (< 1 MB stored)$0.00
SES (1 email/week)$0.00
S3 (HTML dashboard)~$0.01
CloudFront~$0.00
Cost Explorer API~$0.20
Total~$0.21/month

Design Decisions Worth Noting

Why not use Security Hub scores directly? Security Hub has its own scoring, but it's per-standard (CIS, PCI-DSS), not per-account. It also doesn't include cost, tagging, or architecture violations. CloudSentry provides a unified score across all domains.

Why DynamoDB for history (not S3)? DynamoDB gives sub-millisecond queries on account_id + scan_date. Trend calculations need fast range queries. S3 would require downloading and parsing JSON files.

Why per-category caps? Without them, the score becomes meaningless for accounts with one very noisy category. A 500-instance fleet with missing tags shouldn't score lower than an account with root access keys exposed. The cap ensures the score reflects breadth of issues, not depth of one issue.

Why not normalise by resource count? A 1000-resource account will naturally have more findings than a 10-resource account. We considered normalising (findings per resource), but decided absolute score is clearer: it answers "how exposed am I?" not "how exposed am I per resource?" Both accounts with exposed root keys should score the same.

Deploying CloudSentry

git clone https://github.com/durrello/cloudsentry.git
cd cloudsentry/terraform
cp terraform.tfvars.example terraform.tfvars
# Edit: notification_emails, slack_webhook (optional)
terraform init && terraform apply

One terraform apply. Everything deployed. First scan runs next Sunday at 7 AM UTC, or trigger immediately:

aws lambda invoke --function-name cloudsentry-scanner --payload '{}' /dev/stdout

CloudSentry is open source at github.com/durrello/cloudsentry. Contributions welcome, especially new scanner modules.

Share:LinkedInXWhatsApp

Related articles

Reactions & comments