Terraform State Exposure Detector
from kittysploit import *
import json
import re
from urllib.parse import urlparse
from lib.protocols.http.http_client import Http_client
class Module(Auxiliary, Http_client):
__info__ = {
"name": "Terraform State Exposure Detector",
"author": ["KittySploit Team"],
"description": (
"Passive detection of Terraform state exposure indicators: tfstate paths/URLs, remote backends "
"(S3, GCS, Azure, HTTP, Consul, Terraform Cloud), and bucket or state file names in fetched text."
),
"tags": ["osint", "terraform", "iac", "cloud", "passive"],
}
target = OptString("", "Target domain, base URL, or keyword label for this run", required=True)
sources = OptString("", "Comma-separated source URLs to scan (repos, paste, CI logs, mirrors)", required=False)
probe_state_paths = OptBool(
False,
"If target looks like a host, also GET common tfstate paths on https/http root",
required=False,
)
timeout = OptString("10", "HTTP timeout in seconds", required=False)
output_file = OptString("", "Optional JSON output file", required=False)
_STATE_PATH_HINTS = (
"/terraform.tfstate",
"/tf/terraform.tfstate",
"/infra/terraform.tfstate",
"/.terraform/terraform.tfstate",
"/state/terraform.tfstate",
"/state/default.tfstate",
"/terraform/state",
"/backend.tfstate",
)
TFSTATE_IN_URL_RX = re.compile(
r"(?i)\b(?:https?://[^\s\"'<>]+|s3://[^\s\"'<>]+|gs://[^\s\"'<>]+|[^\s\"'<>]*\.tfstate[^\s\"'<>]*)\b"
)
S3_STATE_RX = re.compile(r"\bs3://([a-z0-9][a-z0-9.\-_]{1,253}[a-z0-9])/([^\s\"'<>]{1,512})\b", re.I)
GCS_STATE_RX = re.compile(r"\bgs://([a-z0-9][a-z0-9.\-_]{1,220}[a-z0-9])/([^\s\"'<>]{1,512})\b", re.I)
AZURE_STATE_RX = re.compile(
r"(?i)\bhttps?://([a-z0-9\-]{3,24})\.blob\.core\.windows\.net/[^\s\"'<>]+\.tfstate\b"
)
TFC_URL_RX = re.compile(r"(?i)\bhttps?://app\.terraform\.io/[^\s\"'<>]+")
TFC_HOSTNAME_BLOCK_RX = re.compile(
r'(?is)hostname\s*=\s*["\']app\.terraform\.io["\']',
)
BACKEND_S3_BLOCK_RX = re.compile(
r'(?is)backend\s+["\']s3["\']\s*\{[^}]{0,8000}?bucket\s*=\s*["\']([^"\']+)["\'][^}]{0,8000}?key\s*=\s*["\']([^"\']+)["\']',
)
BACKEND_S3_KEY_BUCKET_RX = re.compile(
r'(?is)backend\s+["\']s3["\']\s*\{[^}]{0,8000}?key\s*=\s*["\']([^"\']+)["\'][^}]{0,8000}?bucket\s*=\s*["\']([^"\']+)["\']',
)
KEY_TFSTATE_RX = re.compile(r'(?i)\bkey\s*=\s*["\']([^"\']*\.tfstate[^"\']*)["\']')
HTTP_BACKEND_RX = re.compile(r'(?i)backend\s+["\']http["\']\s*\{[^}]{0,4000}?address\s*=\s*["\']([^"\']+)["\']')
CONSUL_BACKEND_RX = re.compile(
r'(?i)backend\s+["\']consul["\']\s*\{[^}]{0,4000}?address\s*=\s*["\']([^"\']+)["\']'
)
ETCD_BACKEND_RX = re.compile(r'(?i)backend\s+["\']etcdv3["\']\s*\{[^}]{0,4000}?endpoints\s*=\s*\[([^\]]+)\]')
def _to_int(self, value, default_value):
try:
return max(1, int(str(value).strip()))
except Exception:
return default_value
def _http_get_url(self, url, timeout_seconds):
parsed = urlparse(url)
host = parsed.hostname
if not host:
return None
scheme = (parsed.scheme or "https").lower()
port = parsed.port or (443 if scheme == "https" else 80)
path = parsed.path or "/"
if parsed.query:
path = f"{path}?{parsed.query}"
old_target = self.target
old_port = getattr(self, "port", 443)
old_ssl = getattr(self, "ssl", True)
try:
self.target = host
self.port = int(port)
self.ssl = scheme == "https"
return self.http_request("GET", path=path, timeout=timeout_seconds, allow_redirects=True)
except Exception:
return None
finally:
self.target = old_target
self.port = old_port
self.ssl = old_ssl
def _normalize_host(self, value):
s = str(value).strip().lower().replace("https://", "").replace("http://", "")
s = s.split("/", 1)[0].strip(".")
if not s or "." not in s or " " in s:
return None
return s
def _collect_urls(self):
src = [x.strip() for x in str(self.sources).split(",") if x.strip()]
host = self._normalize_host(self.target)
timeout_seconds = self._to_int(self.timeout, 10)
if src:
return src, timeout_seconds
if not host:
return [], timeout_seconds
roots = [f"https://{host}", f"http://{host}"]
out = list(roots)
if self.probe_state_paths:
for root in roots:
base = root.rstrip("/")
for p in self._STATE_PATH_HINTS:
path = p if p.startswith("/") else f"/{p}"
out.append(f"{base}{path}")
return out, timeout_seconds
def _looks_like_state_json(self, text):
if not text or len(text) < 80:
return False
low = text.lstrip()[:4000]
if '"resources"' not in low and "'resources'" not in low:
return False
markers = ('"version"', '"terraform_version"', '"serial"', '"lineage"')
return sum(1 for m in markers if m in low) >= 2
def _score(self, finding):
kind = finding.get("kind", "")
score = 2
if kind == "likely_state_json_body":
score = 10
elif kind in ("s3_backend_block", "gcs_uri", "s3_uri", "azure_blob_state_url"):
score = 8
elif kind in ("http_backend_address", "terraform_cloud_ref", "consul_backend"):
score = 7
elif kind == "tfstate_url_in_text":
score = 6
elif kind in ("key_tfstate_assignment", "etcd_backend"):
score = 5
detail = str(finding.get("detail", "")).lower()
if any(x in detail for x in ("prod", "production", "live", "master", "admin", "secret")):
score = min(10, score + 1)
return score
def _scan_body(self, body, source_url):
findings = []
if not body:
return findings
if self._looks_like_state_json(body):
findings.append({
"kind": "likely_state_json_body",
"detail": "Body matches Terraform state JSON shape (version/resources/serial/lineage)",
"source": source_url,
})
for m in self.TFSTATE_IN_URL_RX.findall(body):
if ".tfstate" in m.lower() or "terraform.tfstate" in m.lower():
findings.append({"kind": "tfstate_url_in_text", "detail": m[:500], "source": source_url})
for bucket, key in self.S3_STATE_RX.findall(body):
findings.append({
"kind": "s3_uri",
"detail": f"s3://{bucket}/{key[:400]}",
"source": source_url,
})
for bucket, key in self.GCS_STATE_RX.findall(body):
findings.append({
"kind": "gcs_uri",
"detail": f"gs://{bucket}/{key[:400]}",
"source": source_url,
})
for acct in self.AZURE_STATE_RX.findall(body):
findings.append({
"kind": "azure_blob_state_url",
"detail": f"account={acct}",
"source": source_url,
})
tfc_url = self.TFC_URL_RX.search(body)
if tfc_url:
findings.append(
{"kind": "terraform_cloud_ref", "detail": tfc_url.group(0)[:500], "source": source_url}
)
elif self.TFC_HOSTNAME_BLOCK_RX.search(body):
findings.append(
{"kind": "terraform_cloud_ref", "detail": "hostname=app.terraform.io (remote backend block)", "source": source_url}
)
for b, k in self.BACKEND_S3_BLOCK_RX.findall(body):
findings.append({"kind": "s3_backend_block", "detail": f"bucket={b} key={k[:400]}", "source": source_url})
for k, b in self.BACKEND_S3_KEY_BUCKET_RX.findall(body):
findings.append({"kind": "s3_backend_block", "detail": f"bucket={b} key={k[:400]}", "source": source_url})
for k in self.KEY_TFSTATE_RX.findall(body):
findings.append({"kind": "key_tfstate_assignment", "detail": k[:400], "source": source_url})
for addr in self.HTTP_BACKEND_RX.findall(body):
findings.append({"kind": "http_backend_address", "detail": addr[:500], "source": source_url})
for addr in self.CONSUL_BACKEND_RX.findall(body):
findings.append({"kind": "consul_backend", "detail": addr[:500], "source": source_url})
if self.ETCD_BACKEND_RX.search(body):
findings.append({"kind": "etcd_backend", "detail": "etcdv3 endpoints block", "source": source_url})
return findings
def run(self):
urls, timeout_seconds = self._collect_urls()
if not urls:
print_error("Provide sources= URLs or a domain target with optional probe_state_paths")
return {"error": "no_urls", "target": str(self.target).strip()}
print_info(f"Terraform state exposure scan: {len(urls)} URL(s)")
all_findings = []
http_meta = []
for u in urls:
resp = self._http_get_url(u, timeout_seconds)
meta = {"url": u, "status_code": getattr(resp, "status_code", None)}
http_meta.append(meta)
if not resp or not resp.text:
continue
body = resp.text
meta["bytes"] = len(body)
if resp.status_code == 200 and self._looks_like_state_json(body):
meta["warning"] = "response_body_looks_like_tf_state"
for f in self._scan_body(body, u):
f["score"] = self._score(f)
all_findings.append(f)
dedup = {}
for f in all_findings:
key = f"{f.get('kind')}:{f.get('detail','')[:200]}"
if key not in dedup or f.get("score", 0) > dedup[key].get("score", 0):
dedup[key] = f
findings = sorted(dedup.values(), key=lambda x: int(x.get("score", 0)), reverse=True)
max_score = max((int(x.get("score", 0)) for x in findings), default=0)
risk_level = "LOW" if max_score <= 4 else ("MEDIUM" if max_score <= 7 else "HIGH")
data = {
"target": str(self.target).strip(),
"urls_fetched": urls,
"count_findings": len(findings),
"max_score": max_score,
"risk_level": risk_level,
"findings": findings,
"http": http_meta,
}
print_success(
f"Terraform exposure indicators: findings={data['count_findings']} max_score={max_score} risk={risk_level}"
)
if self.output_file:
try:
with open(str(self.output_file), "w") as fp:
json.dump(data, fp, indent=2)
print_success(f"Results saved to {self.output_file}")
except Exception as e:
print_error(f"Failed to save output: {e}")
return data
def get_graph_nodes(self, data):
if not isinstance(data, dict) or data.get("error"):
return [], []
root = data.get("target", self.target)
nodes, edges = [], []
for i, f in enumerate(data.get("findings", [])[:30]):
nid = f"tf_{i}_{root}"
label = f"{f.get('kind', '?')}: {str(f.get('detail', ''))[:48]}"
nodes.append({"id": nid, "label": label, "group": "iac", "icon": "📦"})
edges.append({"from": root, "to": nid, "label": str(f.get("score", ""))})
return nodes, edges
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
02 May 2026 21:27Current
5.9Medium risk
Vulners AI Score5.9