Files
linux-scripts/ec2-inventory-reporter.sh
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist,
same-site referer bypass, Blackbox-Exporter allowed bot, and all
new exporters, cheat sheets, and automation scripts.
2026-05-25 03:31:08 +02:00

705 lines
25 KiB
Bash

#!/usr/bin/env bash
#########################################################################################
#### ec2-inventory-reporter.sh — AWS EC2 instance inventory and compliance report ####
#### Instance metadata, uptime, cost estimates, tag compliance, SG audit ####
#### Requires: bash 4+, aws-cli v2, jq ####
#### ####
#### Author: Phil Connor ####
#### Contact: contact@mylinux.work ####
#### License: MIT ####
#### Version 1.00 ####
#### ####
#### Usage: ####
#### ./ec2-inventory-reporter.sh ####
#### ./ec2-inventory-reporter.sh --all-regions --format csv ####
#### ####
#### See --help for all options. ####
#########################################################################################
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────
AWS_REGION="${AWS_REGION:-us-east-1}"
OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}"
REQUIRED_TAGS="${REQUIRED_TAGS:-Name,Environment,Owner}"
VERBOSE="${VERBOSE:-false}"
COLOR="${COLOR:-auto}"
# ── State ─────────────────────────────────────────────────────────────
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
SCAN_REGION="$AWS_REGION"
ALL_REGIONS="false"
FILTER_STATE=""
FILTER_TAG_KEY=""
FILTER_TAG_VALUE=""
FILTER_TYPE=""
TAG_CHECK="false"
SG_AUDIT="false"
START_TIME=""
# ── Colors ────────────────────────────────────────────────────────────
RED="" GREEN="" YELLOW="" BLUE="" BOLD="" DIM="" RESET=""
setup_colors() {
if [[ "$COLOR" == "never" ]]; then
return
fi
if [[ "$COLOR" == "auto" && ! -t 1 ]]; then
return
fi
RED="\033[0;31m"
GREEN="\033[0;32m"
YELLOW="\033[0;33m"
BLUE="\033[0;34m"
BOLD="\033[1m"
DIM="\033[2m"
RESET="\033[0m"
}
# ── Logging ───────────────────────────────────────────────────────────
log_info() { printf "${GREEN}[INFO]${RESET} %s\n" "$*"; }
log_warn() { printf "${YELLOW}[WARN]${RESET} %s\n" "$*" >&2; }
log_error() { printf "${RED}[ERROR]${RESET} %s\n" "$*" >&2; }
log_debug() { [[ "$VERBOSE" == "true" ]] && printf "${DIM}[DEBUG] %s${RESET}\n" "$*"; }
# ── Helpers ───────────────────────────────────────────────────────────
die() { log_error "$@"; exit 1; }
check_deps() {
local missing=()
command -v aws >/dev/null 2>&1 || missing+=("aws-cli")
command -v jq >/dev/null 2>&1 || missing+=("jq")
if (( ${#missing[@]} > 0 )); then
die "Missing required tools: ${missing[*]}"
fi
local bash_major="${BASH_VERSINFO[0]}"
if (( bash_major < 4 )); then
die "Requires bash 4+, found ${BASH_VERSION}"
fi
}
# ── Pricing table (us-east-1 on-demand Linux, $/hr) ──────────────────
declare -A PRICING=(
# General purpose
["t3.nano"]=0.0052 ["t3.micro"]=0.0104 ["t3.small"]=0.0208
["t3.medium"]=0.0416 ["t3.large"]=0.0832 ["t3.xlarge"]=0.1664
["t3.2xlarge"]=0.3328
["t3a.nano"]=0.0047 ["t3a.micro"]=0.0094 ["t3a.small"]=0.0188
["t3a.medium"]=0.0376 ["t3a.large"]=0.0752 ["t3a.xlarge"]=0.1504
["t3a.2xlarge"]=0.3008
["m5.large"]=0.096 ["m5.xlarge"]=0.192 ["m5.2xlarge"]=0.384
["m5.4xlarge"]=0.768 ["m5.8xlarge"]=1.536
["m6i.large"]=0.096 ["m6i.xlarge"]=0.192 ["m6i.2xlarge"]=0.384
["m6i.4xlarge"]=0.768
["m7i.large"]=0.1008 ["m7i.xlarge"]=0.2016 ["m7i.2xlarge"]=0.4032
# Compute optimized
["c5.large"]=0.085 ["c5.xlarge"]=0.17 ["c5.2xlarge"]=0.34
["c5.4xlarge"]=0.68 ["c5.9xlarge"]=1.53
["c6i.large"]=0.085 ["c6i.xlarge"]=0.17 ["c6i.2xlarge"]=0.34
# Memory optimized
["r5.large"]=0.126 ["r5.xlarge"]=0.252 ["r5.2xlarge"]=0.504
["r5.4xlarge"]=1.008
["r6i.large"]=0.126 ["r6i.xlarge"]=0.252 ["r6i.2xlarge"]=0.504
# Storage optimized
["i3.large"]=0.156 ["i3.xlarge"]=0.312 ["i3.2xlarge"]=0.624
# Accelerated
["g4dn.xlarge"]=0.526 ["g4dn.2xlarge"]=0.752
# Burstable previous gen
["t2.nano"]=0.0058 ["t2.micro"]=0.0116 ["t2.small"]=0.023
["t2.medium"]=0.0464 ["t2.large"]=0.0928
)
# ── Cost estimation ──────────────────────────────────────────────────
estimate_cost() {
local instance_type="$1" state="$2"
if [[ "$state" != "running" ]]; then
echo "0.00"
return
fi
local hourly="${PRICING[$instance_type]:-}"
if [[ -z "$hourly" ]]; then
echo "N/A"
return
fi
printf "%.2f" "$(echo "$hourly * 730" | bc -l)"
}
# ── Uptime calculation ───────────────────────────────────────────────
format_uptime() {
local launch_time="$1" state="$2"
if [[ "$state" != "running" || -z "$launch_time" || "$launch_time" == "null" ]]; then
echo "—"
return
fi
local launch_epoch now_epoch diff_sec
if date --version >/dev/null 2>&1; then
launch_epoch=$(date -d "$launch_time" +%s 2>/dev/null) || { echo "—"; return; }
else
launch_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${launch_time%%.*}" +%s 2>/dev/null) || { echo "—"; return; }
fi
now_epoch=$(date -u +%s)
diff_sec=$(( now_epoch - launch_epoch ))
if (( diff_sec < 0 )); then
echo "—"
return
fi
local days=$(( diff_sec / 86400 ))
local hours=$(( (diff_sec % 86400) / 3600 ))
local mins=$(( (diff_sec % 3600) / 60 ))
printf "%dd %dh %dm" "$days" "$hours" "$mins"
}
# ── Tag compliance check ─────────────────────────────────────────────
check_tag_compliance() {
local tags_json="$1"
local missing=()
IFS=',' read -ra required <<< "$REQUIRED_TAGS"
for tag in "${required[@]}"; do
tag=$(echo "$tag" | xargs)
local found
found=$(echo "$tags_json" | jq -r --arg key "$tag" '.[] | select(.Key == $key) | .Key' 2>/dev/null)
if [[ -z "$found" ]]; then
missing+=("$tag")
fi
done
if (( ${#missing[@]} == 0 )); then
echo "PASS"
else
echo "MISSING: ${missing[*]}"
fi
}
# ── Security group audit ─────────────────────────────────────────────
audit_security_groups() {
local region="$1"
shift
local sg_ids=("$@")
local findings=()
if (( ${#sg_ids[@]} == 0 )); then
echo "—"
return
fi
local sg_data
sg_data=$(aws ec2 describe-security-groups \
--region "$region" \
--group-ids "${sg_ids[@]}" \
--output json 2>/dev/null) || { echo "ERROR"; return; }
local open_rules
open_rules=$(echo "$sg_data" | jq -r '
.SecurityGroups[].IpPermissions[] |
select(
(.IpRanges[]?.CidrIp == "0.0.0.0/0") or
(.Ipv6Ranges[]?.CidrIpv6 == "::/0")
) |
select(
(.FromPort != 80 or .ToPort != 80) and
(.FromPort != 443 or .ToPort != 443)
) |
if .FromPort == .ToPort then
"port \(.FromPort // "all")"
elif .FromPort == -1 then
"all ports"
else
"ports \(.FromPort)-\(.ToPort)"
end
' 2>/dev/null)
if [[ -z "$open_rules" ]]; then
echo "OK"
else
local unique
unique=$(echo "$open_rules" | sort -u | paste -sd ", " -)
echo "OPEN: $unique"
fi
}
# ── Query EC2 instances ──────────────────────────────────────────────
get_instances() {
local region="$1"
local filters=()
if [[ -n "$FILTER_STATE" ]]; then
filters+=("Name=instance-state-name,Values=$FILTER_STATE")
fi
if [[ -n "$FILTER_TAG_KEY" && -n "$FILTER_TAG_VALUE" ]]; then
filters+=("Name=tag:$FILTER_TAG_KEY,Values=$FILTER_TAG_VALUE")
fi
if [[ -n "$FILTER_TYPE" ]]; then
filters+=("Name=instance-type,Values=$FILTER_TYPE")
fi
local cmd=(
aws ec2 describe-instances
--region "$region"
--output json
)
if (( ${#filters[@]} > 0 )); then
cmd+=(--filters "${filters[@]}")
fi
log_debug "Running: ${cmd[*]}"
local result=""
local next_token=""
while true; do
local page_cmd=("${cmd[@]}")
if [[ -n "$next_token" ]]; then
page_cmd+=(--starting-token "$next_token")
fi
local page
page=$("${page_cmd[@]}" 2>/dev/null) || { log_warn "Failed to query EC2 in $region"; echo "[]"; return; }
local page_instances
page_instances=$(echo "$page" | jq '[.Reservations[].Instances[]]')
if [[ -z "$result" ]]; then
result="$page_instances"
else
result=$(echo "$result $page_instances" | jq -s 'add')
fi
next_token=$(echo "$page" | jq -r '.NextToken // empty')
if [[ -z "$next_token" ]]; then
break
fi
done
echo "$result"
}
# ── Get all enabled regions ──────────────────────────────────────────
get_all_regions() {
aws ec2 describe-regions \
--region "$AWS_REGION" \
--query 'Regions[].RegionName' \
--output text 2>/dev/null | tr '\t' '\n' | sort
}
# ── Text table output ────────────────────────────────────────────────
output_text() {
local region="$1" instances_json="$2"
local count
count=$(echo "$instances_json" | jq 'length')
local account_id
account_id=$(aws sts get-caller-identity --query Account --output text 2>/dev/null || echo "unknown")
echo "EC2 Inventory Reporter"
echo "Account: $account_id"
echo "Region: $region"
echo "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "Instances: $count"
echo ""
if (( count == 0 )); then
echo " No instances found."
echo ""
return
fi
local divider="─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────"
printf " %-21s %-14s %-10s %-15s %-16s %-17s %-16s %s\n" \
"INSTANCE ID" "TYPE" "STATE" "AZ" "PRIVATE IP" "PUBLIC IP" "UPTIME" "EST \$/MO"
printf " %s\n" "$divider"
local total_cost=0 running=0 stopped=0 other=0
local compliance_issues=0 sg_issues=0
while IFS=$'\t' read -r iid itype istate iaz pip eip launch_time tags_json sg_json; do
local uptime
uptime=$(format_uptime "$launch_time" "$istate")
local cost
cost=$(estimate_cost "$itype" "$istate")
[[ -z "$eip" || "$eip" == "null" ]] && eip="—"
printf " %-21s %-14s %-10s %-15s %-16s %-17s %-16s %s\n" \
"$iid" "$itype" "$istate" "$iaz" "$pip" "$eip" "$uptime" "\$$cost"
if [[ "$cost" != "N/A" ]]; then
total_cost=$(echo "$total_cost + $cost" | bc -l)
fi
case "$istate" in
running) (( running++ )) ;;
stopped) (( stopped++ )) ;;
*) (( other++ )) ;;
esac
if [[ "$TAG_CHECK" == "true" ]]; then
local compliance
compliance=$(check_tag_compliance "$tags_json")
if [[ "$compliance" != "PASS" ]]; then
(( compliance_issues++ ))
printf " ${YELLOW} ↳ Tag compliance: %s${RESET}\n" "$compliance"
fi
fi
if [[ "$SG_AUDIT" == "true" && "$istate" == "running" ]]; then
local sg_ids_list
sg_ids_list=$(echo "$sg_json" | jq -r '.[].GroupId' 2>/dev/null)
if [[ -n "$sg_ids_list" ]]; then
local sg_arr=()
while IFS= read -r sg; do
[[ -n "$sg" ]] && sg_arr+=("$sg")
done <<< "$sg_ids_list"
local sg_result
sg_result=$(audit_security_groups "$region" "${sg_arr[@]}")
if [[ "$sg_result" != "OK" && "$sg_result" != "—" ]]; then
(( sg_issues++ ))
printf " ${RED} ↳ SG audit: %s${RESET}\n" "$sg_result"
fi
fi
fi
done < <(echo "$instances_json" | jq -r '
.[] |
[
.InstanceId,
.InstanceType,
(.State.Name),
(.Placement.AvailabilityZone),
(.PrivateIpAddress // "—"),
(.PublicIpAddress // "null"),
(.LaunchTime // "null"),
(.Tags // [] | tojson),
(.SecurityGroups // [] | tojson)
] | @tsv
')
printf " %s\n" "$divider"
local summary="TOTAL: $count instances"
local parts=()
(( running > 0 )) && parts+=("$running running")
(( stopped > 0 )) && parts+=("$stopped stopped")
(( other > 0 )) && parts+=("$other other")
if (( ${#parts[@]} > 0 )); then
local joined
joined=$(printf ", %s" "${parts[@]}")
summary+=" (${joined:2})"
fi
printf " %-70s Estimated monthly cost: \$%.2f\n" "$summary" "$total_cost"
if [[ "$TAG_CHECK" == "true" ]]; then
echo ""
if (( compliance_issues > 0 )); then
printf " ${YELLOW}Tag compliance issues: %d instance(s) missing required tags${RESET}\n" "$compliance_issues"
else
printf " ${GREEN}Tag compliance: all instances have required tags${RESET}\n"
fi
fi
if [[ "$SG_AUDIT" == "true" ]]; then
if (( sg_issues > 0 )); then
printf " ${RED}Security group issues: %d instance(s) with overly permissive rules${RESET}\n" "$sg_issues"
else
printf " ${GREEN}Security groups: no overly permissive rules found${RESET}\n"
fi
fi
echo ""
}
# ── CSV output ────────────────────────────────────────────────────────
output_csv() {
local region="$1" instances_json="$2"
local count
count=$(echo "$instances_json" | jq 'length')
local header="instance_id,type,state,az,private_ip,public_ip,launch_time,uptime,est_monthly_cost"
if [[ "$TAG_CHECK" == "true" ]]; then
header+=",tag_compliance"
fi
if [[ "$SG_AUDIT" == "true" ]]; then
header+=",sg_audit"
fi
header+=",region"
echo "$header"
if (( count == 0 )); then
return
fi
while IFS=$'\t' read -r iid itype istate iaz pip eip launch_time tags_json sg_json; do
local uptime
uptime=$(format_uptime "$launch_time" "$istate")
local cost
cost=$(estimate_cost "$itype" "$istate")
[[ -z "$eip" || "$eip" == "null" ]] && eip=""
local line="$iid,$itype,$istate,$iaz,$pip,$eip,$launch_time,\"$uptime\",$cost"
if [[ "$TAG_CHECK" == "true" ]]; then
local compliance
compliance=$(check_tag_compliance "$tags_json")
line+=",\"$compliance\""
fi
if [[ "$SG_AUDIT" == "true" ]]; then
local sg_ids_list
sg_ids_list=$(echo "$sg_json" | jq -r '.[].GroupId' 2>/dev/null)
if [[ -n "$sg_ids_list" && "$istate" == "running" ]]; then
local sg_arr=()
while IFS= read -r sg; do
[[ -n "$sg" ]] && sg_arr+=("$sg")
done <<< "$sg_ids_list"
local sg_result
sg_result=$(audit_security_groups "$region" "${sg_arr[@]}")
line+=",\"$sg_result\""
else
line+=",\"—\""
fi
fi
line+=",$region"
echo "$line"
done < <(echo "$instances_json" | jq -r '
.[] |
[
.InstanceId,
.InstanceType,
(.State.Name),
(.Placement.AvailabilityZone),
(.PrivateIpAddress // "—"),
(.PublicIpAddress // "null"),
(.LaunchTime // "null"),
(.Tags // [] | tojson),
(.SecurityGroups // [] | tojson)
] | @tsv
')
}
# ── JSON output ───────────────────────────────────────────────────────
output_json() {
local region="$1" instances_json="$2"
local count
count=$(echo "$instances_json" | jq 'length')
local items="[]"
if (( count > 0 )); then
items=$(echo "$instances_json" | jq --arg region "$region" '[
.[] | {
instance_id: .InstanceId,
type: .InstanceType,
state: .State.Name,
az: .Placement.AvailabilityZone,
private_ip: (.PrivateIpAddress // null),
public_ip: (.PublicIpAddress // null),
launch_time: (.LaunchTime // null),
ami_id: (.ImageId // null),
vpc_id: (.VpcId // null),
key_name: (.KeyName // null),
tags: (.Tags // []),
security_groups: (.SecurityGroups // []),
region: $region
}
]')
fi
local account_id
account_id=$(aws sts get-caller-identity --query Account --output text 2>/dev/null || echo "unknown")
jq -n \
--arg account "$account_id" \
--arg region "$region" \
--arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--argjson count "$count" \
--argjson instances "$items" \
'{
account: $account,
region: $region,
time: $time,
instance_count: $count,
instances: $instances
}'
}
# ── Process a single region ──────────────────────────────────────────
process_region() {
local region="$1"
local csv_header_printed="$2"
log_debug "Querying region: $region"
local instances_json
instances_json=$(get_instances "$region")
local count
count=$(echo "$instances_json" | jq 'length' 2>/dev/null || echo 0)
if (( count == 0 )) && [[ "$ALL_REGIONS" == "true" ]]; then
log_debug "No instances in $region, skipping"
return
fi
case "$OUTPUT_FORMAT" in
text)
output_text "$region" "$instances_json"
;;
csv)
if [[ "$csv_header_printed" == "false" ]]; then
output_csv "$region" "$instances_json"
else
output_csv "$region" "$instances_json" | tail -n +2
fi
;;
json)
output_json "$region" "$instances_json"
;;
esac
}
# ── Usage ─────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS]
Options:
--region REGION AWS region (default: \$AWS_REGION or us-east-1)
--all-regions Scan all enabled regions
--state STATE Filter by state (running, stopped, terminated, etc.)
--tag KEY=VALUE Filter by tag
--type TYPE Filter by instance type (e.g., t3.micro, m5.*)
--format FORMAT Output: text (default), csv, json
--tag-check Check tag compliance (requires: $REQUIRED_TAGS)
--sg-audit Audit security groups for overly permissive rules
--verbose Debug output
--no-color Disable colored output
--help Show this help
Environment Variables:
AWS_PROFILE AWS CLI profile
AWS_REGION Default region (default: us-east-1)
REQUIRED_TAGS Comma-separated required tags (default: Name,Environment,Owner)
OUTPUT_FORMAT Default output format (default: text)
Examples:
$SCRIPT_NAME # All instances, default region
$SCRIPT_NAME --state running # Running instances only
$SCRIPT_NAME --all-regions --format csv # All regions, CSV output
$SCRIPT_NAME --tag-check --sg-audit # Full compliance audit
EOF
exit 0
}
# ── Argument parsing ─────────────────────────────────────────────────
parse_args() {
while (( $# > 0 )); do
case "$1" in
--region)
[[ $# -lt 2 ]] && die "--region requires a value"
SCAN_REGION="$2"; shift 2 ;;
--all-regions)
ALL_REGIONS="true"; shift ;;
--state)
[[ $# -lt 2 ]] && die "--state requires a value"
FILTER_STATE="$2"; shift 2 ;;
--tag)
[[ $# -lt 2 ]] && die "--tag requires KEY=VALUE"
[[ "$2" != *"="* ]] && die "--tag value must be KEY=VALUE"
FILTER_TAG_KEY="${2%%=*}"; FILTER_TAG_VALUE="${2#*=}"; shift 2 ;;
--type)
[[ $# -lt 2 ]] && die "--type requires a value"
FILTER_TYPE="$2"; shift 2 ;;
--format)
[[ $# -lt 2 ]] && die "--format requires a value"
OUTPUT_FORMAT="$2"; shift 2 ;;
--tag-check)
TAG_CHECK="true"; shift ;;
--sg-audit)
SG_AUDIT="true"; shift ;;
--verbose)
VERBOSE="true"; shift ;;
--no-color)
COLOR="never"; shift ;;
--help|-h)
usage ;;
*)
die "Unknown option: $1 (see --help)" ;;
esac
done
case "$OUTPUT_FORMAT" in
text|csv|json) ;;
*) die "Invalid --format: $OUTPUT_FORMAT (expected text, csv, json)" ;;
esac
}
# ── Main ──────────────────────────────────────────────────────────────
main() {
parse_args "$@"
setup_colors
check_deps
START_TIME=$(date +%s)
log_debug "Validating AWS credentials..."
aws sts get-caller-identity --output text >/dev/null 2>&1 \
|| die "AWS credentials not configured or expired"
if [[ "$ALL_REGIONS" == "true" ]]; then
log_info "Scanning all enabled regions..."
local regions
regions=$(get_all_regions)
if [[ -z "$regions" ]]; then
die "Failed to retrieve region list"
fi
local csv_header="false"
local json_first="true"
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
echo "["
fi
while IFS= read -r region; do
[[ -z "$region" ]] && continue
log_info "Scanning $region..."
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
if [[ "$json_first" == "true" ]]; then
json_first="false"
else
echo ","
fi
fi
process_region "$region" "$csv_header"
csv_header="true"
done <<< "$regions"
if [[ "$OUTPUT_FORMAT" == "json" ]]; then
echo "]"
fi
else
log_info "Scanning region: $SCAN_REGION"
process_region "$SCAN_REGION" "false"
fi
local elapsed=$(( $(date +%s) - START_TIME ))
log_info "Completed in ${elapsed}s"
}
main "$@"