476 lines
15 KiB
Bash
Executable File
476 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# GORM Security Scanner v1.0.0
|
|
# Detects GORM security issues and common mistakes
|
|
|
|
set -euo pipefail
|
|
|
|
# Color codes
|
|
RED='\033[0;31m'
|
|
YELLOW='\033[1;33m'
|
|
GREEN='\033[0;32m'
|
|
BLUE='\033[0;34m'
|
|
CYAN='\033[0;36m'
|
|
BOLD='\033[1m'
|
|
NC='\033[0m' # No Color
|
|
|
|
# Configuration
|
|
MODE="${1:---report}"
|
|
VERBOSE="${VERBOSE:-0}"
|
|
SCAN_DIR="backend"
|
|
|
|
# State
|
|
ISSUES_FOUND=0
|
|
CRITICAL_COUNT=0
|
|
HIGH_COUNT=0
|
|
MEDIUM_COUNT=0
|
|
INFO_COUNT=0
|
|
SUPPRESSED_COUNT=0
|
|
FILES_SCANNED=0
|
|
LINES_PROCESSED=0
|
|
START_TIME=$(date +%s)
|
|
|
|
# Exit codes
|
|
EXIT_SUCCESS=0
|
|
EXIT_ISSUES_FOUND=1
|
|
EXIT_INVALID_ARGS=2
|
|
EXIT_FS_ERROR=3
|
|
|
|
# Helper Functions
|
|
log_debug() {
|
|
if [[ $VERBOSE -eq 1 ]]; then
|
|
echo -e "${BLUE}[DEBUG]${NC} $*" >&2
|
|
fi
|
|
}
|
|
|
|
log_warning() {
|
|
echo -e "${YELLOW}⚠️ WARNING:${NC} $*" >&2
|
|
}
|
|
|
|
log_error() {
|
|
echo -e "${RED}❌ ERROR:${NC} $*" >&2
|
|
}
|
|
|
|
print_header() {
|
|
echo -e "${BOLD}🔍 GORM Security Scanner v1.0.0${NC}"
|
|
echo -e "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo ""
|
|
}
|
|
|
|
print_summary() {
|
|
local end_time=$(date +%s)
|
|
local duration=$((end_time - START_TIME))
|
|
|
|
echo ""
|
|
echo -e "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo -e "${BOLD}📊 SUMMARY${NC}"
|
|
echo -e "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo ""
|
|
echo " Scanned: $FILES_SCANNED Go files ($LINES_PROCESSED lines)"
|
|
echo " Duration: ${duration} seconds"
|
|
echo ""
|
|
echo -e " ${RED}🔴 CRITICAL:${NC} $CRITICAL_COUNT issues"
|
|
echo -e " ${YELLOW}🟡 HIGH:${NC} $HIGH_COUNT issues"
|
|
echo -e " ${BLUE}🔵 MEDIUM:${NC} $MEDIUM_COUNT issues"
|
|
echo -e " ${GREEN}🟢 INFO:${NC} $INFO_COUNT suggestions"
|
|
|
|
if [[ $SUPPRESSED_COUNT -gt 0 ]]; then
|
|
echo ""
|
|
echo -e " 🔇 Suppressed: $SUPPRESSED_COUNT issues (see --verbose for details)"
|
|
fi
|
|
|
|
echo ""
|
|
local total_issues=$((CRITICAL_COUNT + HIGH_COUNT + MEDIUM_COUNT))
|
|
echo " Total Issues: $total_issues (excluding informational)"
|
|
echo ""
|
|
echo -e "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
echo ""
|
|
|
|
if [[ $total_issues -gt 0 ]]; then
|
|
echo -e "${RED}❌ FAILED:${NC} $total_issues security issues detected"
|
|
echo ""
|
|
echo "Run './scripts/scan-gorm-security.sh --help' for usage information"
|
|
else
|
|
echo -e "${GREEN}✅ PASSED:${NC} No security issues detected"
|
|
fi
|
|
}
|
|
|
|
has_suppression_comment() {
|
|
local file="$1"
|
|
local line_num="$2"
|
|
|
|
# Check for // gorm-scanner:ignore comment on the line or the line before
|
|
local start_line=$((line_num > 1 ? line_num - 1 : line_num))
|
|
|
|
if sed -n "${start_line},${line_num}p" "$file" 2>/dev/null | grep -q '//.*gorm-scanner:ignore'; then
|
|
log_debug "Suppression comment found at $file:$line_num"
|
|
((SUPPRESSED_COUNT++))
|
|
return 0
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
is_gorm_model() {
|
|
local file="$1"
|
|
local struct_name="$2"
|
|
|
|
# Heuristic 1: File in internal/models/ directory
|
|
if [[ "$file" == *"/internal/models/"* ]]; then
|
|
log_debug "$struct_name is in models directory"
|
|
return 0
|
|
fi
|
|
|
|
# Heuristic 2: Struct has 2+ fields with gorm: tags
|
|
local gorm_tag_count=$(grep -A 30 "^type $struct_name struct" "$file" 2>/dev/null | grep -c 'gorm:' || true)
|
|
if [[ $gorm_tag_count -ge 2 ]]; then
|
|
log_debug "$struct_name has $gorm_tag_count gorm tags"
|
|
return 0
|
|
fi
|
|
|
|
# Heuristic 3: Embeds gorm.Model
|
|
if grep -A 5 "^type $struct_name struct" "$file" 2>/dev/null | grep -q 'gorm\.Model'; then
|
|
log_debug "$struct_name embeds gorm.Model"
|
|
return 0
|
|
fi
|
|
|
|
log_debug "$struct_name is not a GORM model"
|
|
return 1
|
|
}
|
|
|
|
report_issue() {
|
|
local severity="$1"
|
|
local code="$2"
|
|
local file="$3"
|
|
local line_num="$4"
|
|
local struct_name="$5"
|
|
local message="$6"
|
|
local fix="$7"
|
|
|
|
local color=""
|
|
local emoji=""
|
|
local severity_label=""
|
|
|
|
case "$severity" in
|
|
CRITICAL)
|
|
color="$RED"
|
|
emoji="🔴"
|
|
severity_label="CRITICAL"
|
|
((CRITICAL_COUNT++))
|
|
;;
|
|
HIGH)
|
|
color="$YELLOW"
|
|
emoji="🟡"
|
|
severity_label="HIGH"
|
|
((HIGH_COUNT++))
|
|
;;
|
|
MEDIUM)
|
|
color="$BLUE"
|
|
emoji="🔵"
|
|
severity_label="MEDIUM"
|
|
((MEDIUM_COUNT++))
|
|
;;
|
|
INFO)
|
|
color="$GREEN"
|
|
emoji="🟢"
|
|
severity_label="INFO"
|
|
((INFO_COUNT++))
|
|
;;
|
|
esac
|
|
|
|
((ISSUES_FOUND++))
|
|
|
|
echo ""
|
|
echo -e "${color}${emoji} ${severity_label}: ${message}${NC}"
|
|
echo -e " 📄 File: ${file}:${line_num}"
|
|
echo -e " 🏗️ Struct: ${struct_name}"
|
|
echo ""
|
|
echo -e " ${fix}"
|
|
echo ""
|
|
}
|
|
|
|
detect_id_leak() {
|
|
log_debug "Running Pattern 1: ID Leak Detection"
|
|
|
|
# Use a more efficient single grep pass
|
|
local model_files=$(find "$SCAN_DIR/internal/models" -name "*.go" -type f 2>/dev/null || true)
|
|
|
|
if [[ -z "$model_files" ]]; then
|
|
log_debug "No model files found in $SCAN_DIR/internal/models"
|
|
return 0
|
|
fi
|
|
|
|
echo "$model_files" | while IFS= read -r file; do
|
|
[[ -z "$file" ]] && continue
|
|
|
|
((FILES_SCANNED++))
|
|
local line_count=$(wc -l < "$file" 2>/dev/null || echo 0)
|
|
((LINES_PROCESSED+=line_count))
|
|
|
|
log_debug "Scanning $file"
|
|
|
|
# Look for ID fields with numeric types that have json:"id" and gorm primaryKey
|
|
grep -n 'ID.*uint\|ID.*int64\|ID.*int[^6]' "$file" 2>/dev/null | while IFS=: read -r line_num line_content; do
|
|
# Skip if not a field definition (e.g., inside comments or other contexts)
|
|
if ! echo "$line_content" | grep -E '^\s*(ID|Id)\s+\*?(u?int|int64)' >/dev/null; then
|
|
continue
|
|
fi
|
|
|
|
# Check if has both json:"id" and gorm primaryKey
|
|
if echo "$line_content" | grep 'json:"id"' >/dev/null && \
|
|
echo "$line_content" | grep -iE 'gorm:"[^"]*primarykey' >/dev/null; then
|
|
|
|
# Check for suppression
|
|
if has_suppression_comment "$file" "$line_num"; then
|
|
continue
|
|
fi
|
|
|
|
# Get struct name by looking backwards
|
|
local struct_name=$(awk -v line="$line_num" 'NR<line && /^type .* struct/ {name=$2} END {print name}' "$file")
|
|
|
|
if [[ -z "$struct_name" ]]; then
|
|
struct_name="Unknown"
|
|
fi
|
|
|
|
report_issue "CRITICAL" "ID-LEAK" "$file" "$line_num" "$struct_name" \
|
|
"GORM Model ID Field Exposed in JSON" \
|
|
"💡 Fix: Change json:\"id\" to json:\"-\" and use UUID field for external references"
|
|
fi
|
|
done
|
|
done
|
|
}
|
|
|
|
detect_dto_embedding() {
|
|
log_debug "Running Pattern 2: DTO Embedding Detection"
|
|
|
|
# Scan handlers and services for Response/DTO structs
|
|
local scan_dirs="$SCAN_DIR/internal/api/handlers $SCAN_DIR/internal/services"
|
|
|
|
for dir in $scan_dirs; do
|
|
if [[ ! -d "$dir" ]]; then
|
|
continue
|
|
fi
|
|
|
|
find "$dir" -name "*.go" -type f 2>/dev/null | while IFS= read -r file; do
|
|
[[ -z "$file" ]] && continue
|
|
|
|
# Look for Response/DTO structs with embedded models
|
|
grep -n 'type.*\(Response\|DTO\).*struct' "$file" 2>/dev/null | while IFS=: read -r line_num line_content; do
|
|
local struct_name=$(echo "$line_content" | sed 's/^type \([^ ]*\) struct.*/\1/')
|
|
|
|
# Check next 20 lines for embedded models
|
|
local struct_body=$(sed -n "$((line_num+1)),$((line_num+20))p" "$file" 2>/dev/null)
|
|
|
|
if echo "$struct_body" | grep -E '^\s+models\.[A-Z]' >/dev/null; then
|
|
local embedded_line=$(echo "$struct_body" | grep -n -E '^\s+models\.[A-Z]' | head -1 | cut -d: -f1)
|
|
local actual_line=$((line_num + embedded_line))
|
|
|
|
if has_suppression_comment "$file" "$actual_line"; then
|
|
continue
|
|
fi
|
|
|
|
report_issue "HIGH" "DTO-EMBED" "$file" "$actual_line" "$struct_name" \
|
|
"Response DTO Embeds Model" \
|
|
"💡 Fix: Explicitly define response fields instead of embedding the model"
|
|
fi
|
|
done
|
|
done
|
|
done
|
|
}
|
|
|
|
detect_exposed_secrets() {
|
|
log_debug "Running Pattern 5: Exposed API Keys/Secrets Detection"
|
|
|
|
# Only scan model files for this pattern
|
|
find "$SCAN_DIR/internal/models" -name "*.go" -type f 2>/dev/null | while IFS= read -r file; do
|
|
[[ -z "$file" ]] && continue
|
|
|
|
# Find fields with sensitive names that don't have json:"-"
|
|
grep -n -iE '(APIKey|Secret|Token|Password|Hash)\s+string' "$file" 2>/dev/null | while IFS=: read -r line_num line_content; do
|
|
# Skip if already has json:"-"
|
|
if echo "$line_content" | grep 'json:"-"' >/dev/null; then
|
|
continue
|
|
fi
|
|
|
|
# Skip if no json tag at all (might be internal-only field)
|
|
if ! echo "$line_content" | grep 'json:' >/dev/null; then
|
|
continue
|
|
fi
|
|
|
|
# Check for suppression
|
|
if has_suppression_comment "$file" "$line_num"; then
|
|
continue
|
|
fi
|
|
|
|
local struct_name=$(awk -v line="$line_num" 'NR<line && /^type .* struct/ {name=$2} END {print name}' "$file")
|
|
local field_name=$(echo "$line_content" | awk '{print $1}')
|
|
|
|
report_issue "CRITICAL" "SECRET-LEAK" "$file" "$line_num" "${struct_name:-Unknown}" \
|
|
"Sensitive Field '$field_name' Exposed in JSON" \
|
|
"💡 Fix: Change json tag to json:\"-\" to hide sensitive data"
|
|
done
|
|
done
|
|
}
|
|
|
|
detect_missing_primary_key() {
|
|
log_debug "Running Pattern 3: Missing Primary Key Tag Detection"
|
|
|
|
find "$SCAN_DIR/internal/models" -name "*.go" -type f 2>/dev/null | while IFS= read -r file; do
|
|
[[ -z "$file" ]] && continue
|
|
|
|
# Look for ID fields with gorm tag but no primaryKey
|
|
grep -n 'ID.*gorm:' "$file" 2>/dev/null | while IFS=: read -r line_num line_content; do
|
|
# Skip if has primaryKey
|
|
if echo "$line_content" | grep -iE 'gorm:"[^"]*primarykey' >/dev/null; then
|
|
continue
|
|
fi
|
|
|
|
# Skip if doesn't have gorm tag
|
|
if ! echo "$line_content" | grep 'gorm:' >/dev/null; then
|
|
continue
|
|
fi
|
|
|
|
if has_suppression_comment "$file" "$line_num"; then
|
|
continue
|
|
fi
|
|
|
|
local struct_name=$(awk -v line="$line_num" 'NR<line && /^type .* struct/ {name=$2} END {print name}' "$file")
|
|
|
|
report_issue "MEDIUM" "MISSING-PK" "$file" "$line_num" "${struct_name:-Unknown}" \
|
|
"ID Field Missing Primary Key Tag" \
|
|
"💡 Fix: Add 'primaryKey' to gorm tag: gorm:\"primaryKey\""
|
|
done
|
|
done
|
|
}
|
|
|
|
detect_foreign_key_index() {
|
|
log_debug "Running Pattern 4: Foreign Key Index Detection"
|
|
|
|
find "$SCAN_DIR/internal/models" -name "*.go" -type f 2>/dev/null | while IFS= read -r file; do
|
|
[[ -z "$file" ]] && continue
|
|
|
|
# Find fields ending with ID that have gorm tag but no index
|
|
grep -n -E '\s+[A-Z][a-zA-Z]*ID\s+\*?uint.*gorm:' "$file" 2>/dev/null | while IFS=: read -r line_num line_content; do
|
|
# Skip primary key
|
|
if echo "$line_content" | grep -E '^\s+ID\s+' >/dev/null; then
|
|
continue
|
|
fi
|
|
|
|
# Skip if has index
|
|
if echo "$line_content" | grep -E 'gorm:"[^"]*index' >/dev/null; then
|
|
continue
|
|
fi
|
|
|
|
if has_suppression_comment "$file" "$line_num"; then
|
|
continue
|
|
fi
|
|
|
|
local struct_name=$(awk -v line="$line_num" 'NR<line && /^type .* struct/ {name=$2} END {print name}' "$file")
|
|
local field_name=$(echo "$line_content" | awk '{print $1}')
|
|
|
|
report_issue "INFO" "MISSING-INDEX" "$file" "$line_num" "${struct_name:-Unknown}" \
|
|
"Foreign Key '$field_name' Missing Index" \
|
|
"💡 Suggestion: Add gorm:\"index\" for better query performance"
|
|
done
|
|
done
|
|
}
|
|
|
|
detect_missing_uuid() {
|
|
log_debug "Running Pattern 6: Missing UUID Detection"
|
|
|
|
# This pattern is complex and less critical, skip for now to improve performance
|
|
log_debug "Pattern 6 skipped for performance (can be enabled later)"
|
|
}
|
|
|
|
show_help() {
|
|
cat << EOF
|
|
GORM Security Scanner v1.0.0
|
|
Detects GORM security issues and common mistakes
|
|
|
|
USAGE:
|
|
$0 [MODE] [OPTIONS]
|
|
|
|
MODES:
|
|
--report Report all issues but always exit 0 (default)
|
|
--check Report issues and exit 1 if any found
|
|
--enforce Same as --check (block on issues)
|
|
|
|
OPTIONS:
|
|
--help Show this help message
|
|
--verbose Enable verbose debug output
|
|
|
|
ENVIRONMENT:
|
|
VERBOSE=1 Enable debug logging
|
|
|
|
EXAMPLES:
|
|
# Report mode (no failure)
|
|
$0 --report
|
|
|
|
# Check mode (fails if issues found)
|
|
$0 --check
|
|
|
|
# Verbose output
|
|
VERBOSE=1 $0 --report
|
|
|
|
EXIT CODES:
|
|
0 - Success (report mode) or no issues (check/enforce mode)
|
|
1 - Issues found (check/enforce mode)
|
|
2 - Invalid arguments
|
|
3 - File system error
|
|
|
|
For more information, see: docs/plans/gorm_security_scanner_spec.md
|
|
EOF
|
|
}
|
|
|
|
# Main execution
|
|
main() {
|
|
# Parse arguments
|
|
case "${MODE}" in
|
|
--help|-h)
|
|
show_help
|
|
exit 0
|
|
;;
|
|
--report)
|
|
;;
|
|
--check|--enforce)
|
|
;;
|
|
*)
|
|
log_error "Invalid mode: $MODE"
|
|
show_help
|
|
exit $EXIT_INVALID_ARGS
|
|
;;
|
|
esac
|
|
|
|
# Check if scan directory exists
|
|
if [[ ! -d "$SCAN_DIR" ]]; then
|
|
log_error "Scan directory not found: $SCAN_DIR"
|
|
exit $EXIT_FS_ERROR
|
|
fi
|
|
|
|
print_header
|
|
echo "📂 Scanning: $SCAN_DIR/"
|
|
echo ""
|
|
|
|
# Run all detection patterns
|
|
detect_id_leak
|
|
detect_dto_embedding
|
|
detect_exposed_secrets
|
|
detect_missing_primary_key
|
|
detect_foreign_key_index
|
|
detect_missing_uuid
|
|
|
|
print_summary
|
|
|
|
# Exit based on mode
|
|
local total_issues=$((CRITICAL_COUNT + HIGH_COUNT + MEDIUM_COUNT))
|
|
|
|
if [[ "$MODE" == "--report" ]]; then
|
|
exit $EXIT_SUCCESS
|
|
elif [[ $total_issues -gt 0 ]]; then
|
|
exit $EXIT_ISSUES_FOUND
|
|
else
|
|
exit $EXIT_SUCCESS
|
|
fi
|
|
}
|
|
|
|
main "$@"
|