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.

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:
| Severity | Points Deducted | Example Finding |
|---|---|---|
| Critical | 10 | Root account has no MFA |
| High | 5 | Security group allows SSH from 0.0.0.0/0 |
| Medium | 2 | VPC has no flow logs enabled |
| Low | 1 | Orphaned 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
| Score | Grade | Interpretation |
|---|---|---|
| 85-100 | A | Excellent security posture |
| 70-84 | B | Good, minor issues |
| 50-69 | C | Needs attention |
| 35-49 | D | Significant risks |
| 0-34 | F | Critical 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" }
}
}]
})
}
Week-Over-Week Trends in DynamoDB
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:
| Service | Monthly 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.