Cloud & Container Security · beginner · ~10 min
**What you will learn** - Recognise the classic cloud public-exposure misconfigurations: public object-storage buckets, security groups open to `0.0.0.0/0`, and public snapshots, databases, and dashboards. - Explain *why* these resources end up public — checkbox defaults, overly broad policies, and ACLs that grant access to `AllUsers`. - Read a security group rule and decide whether a source range is safe or dangerously wide, building on CIDR notation from *The cloud model and IAM*. - Apply the universal fix pattern: default-deny, account-level public-access blocks, least-privilege policies, and restricted source ranges. - Test a cloud account for exposure by asking two questions of every resource: *Is this reachable from the internet? Should it be?* - Set up basic detection and logging so exposure is caught early, without ever logging the secrets you are trying to protect.
Most real-world cloud security incidents are not clever exploits. They are accidents of configuration — a resource that was meant to be private but was left open to the entire internet. No malware, no zero-day, no password cracking is required: the attacker simply requests the data and the cloud hands it over.
Two cases dominate the findings auditors and attackers see again and again:
0.0.0.0/0, which is shorthand for the whole internet.This lesson builds directly on The cloud model and IAM. There you learned that the cloud is a shared-responsibility model — the provider secures the infrastructure, but you are responsible for how you configure access. You also met CIDR notation (0.0.0.0/0, 10.0.0.0/8) and the principle of least privilege. Public-exposure bugs are what happens when least privilege is forgotten: a permission that should have been narrow was left wide open.
The terminology is small and worth fixing early. A bucket is a container for files ("objects") in object storage such as Amazon S3, Azure Blob Storage, or Google Cloud Storage. An ACL (access control list) and a bucket policy are two ways to say who may read or write those objects. A security group is a stateful, instance-level firewall that lists which inbound and outbound connections are allowed. 0.0.0.0/0 is the CIDR block that matches every possible IPv4 address. Keep these straight and the rest of the lesson reads naturally.
Public buckets and 0.0.0.0/0 firewall rules are among the most common and most damaging cloud security findings, year after year, across every major provider.
The damage is direct and immediate:
What makes these so dangerous is the asymmetry: they are trivial for an attacker to find (automated scanners sweep the entire internet continuously) and they require no exploitation skill, yet they can leak an entire company's data or compromise a server in minutes. They are also embarrassingly easy to introduce — one wrong checkbox, one copied-from-a-tutorial firewall rule. Understanding them is the highest-leverage cloud security skill a beginner can learn.
Definition. A storage bucket is a named container for files in a cloud object store (S3, Azure Blob, GCS). Buckets are private by default — only the owning account can read them — but they can be opened to the public through configuration.
How it goes wrong. Exposure usually arrives by one of three routes:
Principal of * (everyone) and an Allow on read actions.READ to the predefined AllUsers group.Once public, the bucket's contents are readable by anyone who knows or guesses the name. Names are often predictable (acme-backups, acme-prod-logs), so attackers run bucket-name enumeration — trying lists of likely names — as routine reconnaissance.
Structure — how a request reaches a public object:
Attacker Internet Cloud object store
| GET /acme-backups/db.sql -----> [ bucket: acme-backups ]
| policy: Principal "*" Allow s3:GetObject
| <----- 200 OK + file contents --- (no auth checked -> returns data)
When public is legitimate: a public website's static assets, published open data, software downloads. When it is NOT: anything containing personal data, backups, logs, configuration, or secrets. The default should always be private; public is a deliberate, reviewed exception.
Pitfall. Teams disable account-level "Block Public Access" to make one harmless asset reachable, then forget — every future bucket is now one bad policy away from exposure.
Knowledge check. A bucket policy contains
"Principal": "*"with"Action": "s3:GetObject"and"Effect": "Allow". In one sentence, who can download objects from this bucket?
0.0.0.0/0 ruleDefinition. A security group is a virtual, stateful firewall attached to cloud instances. Each rule allows traffic to a port from a source CIDR range. "Stateful" means return traffic for an allowed connection is automatically permitted.
How it works internally. When a packet arrives for an instance, the cloud checks it against the security group's inbound rules. If any rule matches the destination port and the source IP falls inside an allowed CIDR, the packet is permitted; otherwise it is dropped (default-deny). Recall CIDR matching from The cloud model and IAM: 203.0.113.5/32 matches one address; 0.0.0.0/0 matches all 4.3 billion IPv4 addresses.
The classic mistake is opening a sensitive port to 0.0.0.0/0:
| Port | Service | Risk when open to 0.0.0.0/0 |
|---|---|---|
| 22 | SSH | Internet-wide brute force, key-guessing |
| 3389 | RDP | Brute force, ransomware foothold |
| 3306 | MySQL | Direct attack on the database |
| 5432 | PostgreSQL | Direct attack on the database |
| 6379 | Redis (often no auth) | Trivial data theft / takeover |
Data flow — narrow vs wide source:
WIDE (insecure): inbound TCP 22 <- 0.0.0.0/0 every host on earth can knock
NARROW (better): inbound TCP 22 <- 203.0.113.0/24 only the office network can
BEST (no public ingress): SSH only via a bastion host or VPN; instance has no public 22
When 0.0.0.0/0 is acceptable: ports 80/443 on a public web server — those are meant to serve the world. It is almost never acceptable on admin or database ports.
Pitfall. Copying a "quick start" rule that opens 0.0.0.0/0 to get something working, intending to tighten it later, and never doing so.
Knowledge check (predict). A security group has one inbound rule: TCP 3389 from
0.0.0.0/0. An attacker in another country runs an RDP scanner. Will their connection attempt reach the instance? Why?
The same default-deny lesson applies beyond buckets and SSH:
Threat model (text diagram):
Assets: customer data, backups, credentials, compute capacity
Trust boundary: [ Internet ] | [ Your cloud account ]
Entry points: public bucket URL, open SG port, public snapshot,
unauthenticated dashboard, public DB endpoint
Goal: keep every entry point either closed or behind auth
Knowledge check (explain). In your own words, why is "public exposure + no authentication" a complete breach on its own, with no exploit needed?
Every one of these findings has the same remedy shape:
* for sensitive data.Two configuration shapes appear constantly. Learn to read them at a glance.
A dangerously public bucket policy (JSON):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*", // "*" = everyone on the internet <-- DANGER
"Action": "s3:GetObject", // anyone may download objects
"Resource": "arn:aws:s3:::acme-backups/*"
}]
}
A security group inbound rule (conceptual table form):
Type Protocol Port Source
SSH TCP 22 0.0.0.0/0 <-- the whole internet (DANGER for SSH)
SSH TCP 22 203.0.113.0/24 <-- only the office range (safer)
The two signals to scan for: Principal": "*" in any policy on sensitive data, and 0.0.0.0/0 on any admin or database port.
The most common cloud findings are resources accidentally exposed to the internet.
Object storage (S3, Azure Blob, GCS) is private by default. But it is frequently made public by mistake.
This can happen through:
AllUsers.The result: customer data, backups, and secrets become readable by anyone who knows, or guesses, the bucket name. Guessing bucket names (bucket-name enumeration) is a routine part of attacker reconnaissance.
The fix:
Cloud security groups are virtual firewalls attached to instances.
The classic mistake is opening sensitive ports to 0.0.0.0/0, which means the whole internet. For example:
When these are exposed globally, anyone on the internet can reach them.
The fix:
Watch for:
List the account's resources. For each one, ask two questions:
Public exposure combined with weak or missing authentication is a direct breach. No exploit is required.
Below is a small, self-contained defensive audit script you can run against a local, authorised lab (for example LocalStack or a test account you own). It reads a JSON description of resources and flags public exposure. It invents no cloud APIs — it works on a plain data file, so you can study the logic without credentials.
#!/usr/bin/env python3
"""exposure_audit.py - flag publicly exposed cloud resources.
Authorised lab use only. Run against accounts/data you own.
Reads a JSON inventory; never contacts real cloud APIs.
"""
import json
import sys
# Ports that must never be open to the whole internet.
SENSITIVE_PORTS = {22: "SSH", 3389: "RDP", 3306: "MySQL",
5432: "PostgreSQL", 6379: "Redis"}
WHOLE_INTERNET = "0.0.0.0/0"
def audit_bucket(bucket):
findings = []
name = bucket.get("name", "<unnamed>")
# A wildcard principal with a read action == public download.
for stmt in bucket.get("policy_statements", []):
if stmt.get("effect") == "Allow" and stmt.get("principal") == "*":
findings.append(f"Bucket '{name}': policy allows '*' (public)")
if "AllUsers" in bucket.get("acl_grants", []):
findings.append(f"Bucket '{name}': ACL grants AllUsers (public)")
return findings
def audit_security_group(sg):
findings = []
name = sg.get("name", "<unnamed>")
for rule in sg.get("inbound", []):
port = rule.get("port")
src = rule.get("source")
if src == WHOLE_INTERNET and port in SENSITIVE_PORTS:
svc = SENSITIVE_PORTS[port]
findings.append(
f"SG '{name}': {svc} (port {port}) open to {WHOLE_INTERNET}")
return findings
def main():
if len(sys.argv) != 2:
print("usage: exposure_audit.py <inventory.json>", file=sys.stderr)
return 2
try:
with open(sys.argv[1], "r", encoding="utf-8") as f:
inventory = json.load(f) # closes via the with-block
except (OSError, json.JSONDecodeError) as e:
print(f"could not read inventory: {e}", file=sys.stderr)
return 1
findings = []
for b in inventory.get("buckets", []):
findings += audit_bucket(b)
for sg in inventory.get("security_groups", []):
findings += audit_security_group(sg)
if findings:
print("PUBLIC EXPOSURE FINDINGS:")
for f in findings:
print(" -", f)
return 1 # non-zero exit so CI/CD can fail the build
print("No public exposure found.")
return 0
if __name__ == "__main__":
sys.exit(main())
Example input (inventory.json):
{
"buckets": [
{"name": "acme-backups",
"policy_statements": [{"effect": "Allow", "principal": "*"}]},
{"name": "acme-website-assets", "acl_grants": ["OwnerFullControl"]}
],
"security_groups": [
{"name": "db-sg", "inbound": [{"port": 5432, "source": "0.0.0.0/0"}]},
{"name": "web-sg", "inbound": [{"port": 443, "source": "0.0.0.0/0"}]}
]
}
What it does and expected output. The script loads the inventory, checks each bucket for a wildcard principal or an AllUsers ACL, and checks each security group for sensitive ports open to 0.0.0.0/0. With the input above it prints:
PUBLIC EXPOSURE FINDINGS:
- Bucket 'acme-backups': policy allows '*' (public)
- SG 'db-sg': PostgreSQL (port 5432) open to 0.0.0.0/0
and exits with status 1. Note what it correctly does not flag: acme-website-assets (no public grant) and web-sg (port 443 to the world is normal for a web server). Edge cases: a missing or malformed JSON file is caught and reported; a port not in SENSITIVE_PORTS open to the world is ignored on purpose; this is a static check and would not see a setting that exists only in the live cloud — treat it as one layer, not the whole defence.
Walking the key paths of exposure_audit.py:
SENSITIVE_PORTS / WHOLE_INTERNET — the policy of the tool, expressed as data. Putting the rules at the top makes them easy to review and extend.main() argument check — if the inventory path is missing, print usage to stderr and return 2. Distinct exit codes let an automated pipeline tell "bad usage" from "findings" from "clean".open(...) as f + json.load — the with block guarantees the file is closed even if parsing raises. The try/except turns a crash into a clean 1 with a message.audit_bucket — for each policy statement, the condition effect == "Allow" and principal == "*" is the single line that defines "public". It also checks for the AllUsers ACL grant, the other common route to a public bucket.audit_security_group — for each inbound rule, it flags only when both source == 0.0.0.0/0 and port is sensitive. This is why web-sg's 443 rule is not flagged — the port is not in the sensitive set.findings — bucket and SG findings are concatenated into one list, so a single pass over the report shows everything.1; clean → return 0. A CI job running python exposure_audit.py inventory.json will fail the build on any exposure, turning the lesson into an enforced guardrail.Trace with the example input:
| Step | Resource | Test | Result |
|---|---|---|---|
| 1 | bucket acme-backups |
principal * Allow |
FLAG |
| 2 | bucket acme-website-assets |
no *, no AllUsers |
clean |
| 3 | SG db-sg |
port 5432 + 0.0.0.0/0 |
FLAG |
| 4 | SG web-sg |
port 443 (not sensitive) | clean |
Two findings reach the output; exit code 1.
Mistake 1 — "Make it public to fix the 403."
Wrong: an asset returns Access Denied, so the developer toggles the bucket to public.
{"Effect": "Allow", "Principal": "*", "Action": "s3:GetObject"}
Why it is wrong: it solves the symptom by exposing the entire bucket to the internet. The real problem was usually a missing grant for one specific identity.
Corrected: grant access to the exact principal that needs it, and keep account-level Block Public Access on.
{"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::123456789012:role/app-reader"},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::acme-backups/reports/*"}
Recognise it: any Principal": "*" on a bucket holding non-public data.
Mistake 2 — 0.0.0.0/0 on SSH "just for now."
Wrong: inbound TCP 22 from 0.0.0.0/0 to log in from a coffee shop.
Why it is wrong: it advertises an SSH login prompt to every scanner on the planet within minutes; brute-force traffic starts almost immediately.
Corrected: restrict to your network (203.0.113.0/24), or remove public SSH entirely and connect through a bastion or VPN.
Recognise it: 0.0.0.0/0 paired with port 22, 3389, or any database port.
Mistake 3 — Confusing "private network" with "private resource."
Wrong: assuming a database is safe because it is "in the VPC," while its security group still allows 0.0.0.0/0 on 5432 and it has a public IP.
Why it is wrong: network placement does not override an open firewall rule and a public endpoint.
Corrected: no public IP/endpoint, and a security group that only allows the app tier's range.
Recognise it: a resource with both a public endpoint and a wide ingress rule.
Mistake 4 — Storing secrets in a bucket "because it is convenient."
Wrong: s3://acme-config/prod.env containing DB_PASSWORD=....
Why it is wrong: the day that bucket is misconfigured, the secret leaks with it, and bucket exposure is the single most common finding.
Corrected: store secrets in a dedicated secrets manager and reference them at runtime (the focus of the next lesson).
Because this is configuration rather than code, "debugging" means verifying the state of the cloud and your audit logic.
Audit-script errors
json.decoder.JSONDecodeError — your inventory file is not valid JSON. Validate it (python -m json.tool inventory.json) before running the audit.KeyError while extending the script — use .get(key, default) rather than dict[key] so a missing field does not crash the audit; missing data should be treated as "unknown," not silently "safe."Verifying exposure findings (authorised lab only)
200 with content confirms public read; a 403 confirms it is locked down. Never do this against systems you do not own.Logic errors to watch for
0.0.0.0/0 on a web server is expected; do not "fix" it. Keep an allow-list of intentionally public ports.Questions to ask when something looks wrong
Security & safety
Authorisation and ethics. Everything here is defensive and must run only on systems you own or are explicitly authorised to test — a local lab (LocalStack/MinIO), a container, or your own sandbox account. Bucket-name guessing or port scanning against third parties is unauthorised access and is unlawful in most jurisdictions. Practising the audit side on your own resources is the right way to learn.
The risks, restated as a defender.
Asset Exposure mechanism Consequence
------------ --------------------- -----------------------------
Object data public bucket policy/ACL full data download by anyone
Server access SG 22/3389 -> 0.0.0.0/0 brute force, ransomware
Database SG 3306/5432 -> 0/0 direct data theft
Disk image public snapshot/AMI offline secret extraction
Dashboard no auth + public data browsing / control
Defensive practices, in priority order.
* on sensitive data; narrow CIDR ranges, never 0.0.0.0/0 on admin/DB ports.Detection and logging — what to log (and what never to). Enable object-access logging on buckets and flow logs on networks. Log who accessed which resource, from where, and whether it was allowed — plus configuration changes ("bucket made public," "SG rule added for 0.0.0.0/0"). Never log the data itself, object contents, passwords, API keys, or tokens. When you must reference a secret in a log, log a non-reversible identifier, not the value. Alert on any new Principal": "*" or 0.0.0.0/0 on a sensitive port.
Mitigation verification. After locking a bucket down, re-request an object unauthenticated from your lab and confirm a 403. After tightening a security group, confirm the sensitive port no longer answers from outside the allowed range. A fix is not done until you have re-tested that the hole is closed.
Concrete use case. A company stores nightly database backups in a bucket named acme-prod-backups. A developer, fighting an access error during a late-night deploy, sets the bucket policy to Principal": "*" to "unblock" themselves and forgets to revert. Within hours, automated scanners that enumerate predictable bucket names find it and download every backup — including a .env file with credentials. The same class of mistake on the network side (RDP open to 0.0.0.0/0) is a leading initial-access vector for ransomware. Both are prevented by the habits in this lesson.
Where these controls live in professional work:
0.0.0.0/0 on a sensitive port without it showing up in a pull request.Professional best-practice habits.
Beginner rules:
0.0.0.0/0.Advanced practices:
Beginner 1 — Spot the public bucket. Given three bucket policies, identify which one is public.
Statement; flag any with Effect: Allow and Principal: "*".Principal: "*"; policies B and C name a specific role. Answer: A is public.Beginner 2 — Classify firewall rules. For five security-group rules (port + source), label each safe, risky, or depends.
443 from 0.0.0.0/0 → safe; 22 from 0.0.0.0/0 → risky; 5432 from 10.0.1.0/24 → depends (only safe if that range is trusted and internal).0.0.0.0/0 = risky.Intermediate 1 — Extend the audit script. Add a check that flags any bucket whose name appears in a configurable list of "never-public" prefixes (e.g. *-backups, *-secrets) when it is public.
*-backups bucket that is public produces a HIGH severity line.audit_bucket; do not break existing tests.Intermediate 2 — Write the lock-down policy. Given a public acme-backups bucket, write (a) a least-privilege bucket policy granting read only to one named role on one prefix, and (b) the account setting that prevents future public access.
Resource.Challenge — Build a mini posture report. Extend the audit to also handle snapshots (flag public: true) and dashboards (flag auth: "none" with a public endpoint), assign each finding a severity, and emit a sorted report (highest severity first) plus a non-zero exit on any HIGH finding.
audit_bucket and audit_security_group are modelled.0.0.0.0/0.Principal: "*" policy, or an AllUsers ACL. Attackers find them with bucket-name enumeration.0.0.0.0/0 means the entire internet. It is fine on web ports 80/443 but dangerous on SSH (22), RDP (3389), and database ports (3306/5432/6379).