test_security.sh 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. #!/usr/bin/env bash
  2. #
  3. # Local security scanning - mirrors GitHub Actions pipeline
  4. # Runs all scans in parallel and shows a consolidated summary.
  5. #
  6. # Usage:
  7. # ./test_security.sh # Run fast scans (bandit, pip-audit, npm-audit)
  8. # ./test_security.sh --full # Run full pipeline (all scans below)
  9. # ./test_security.sh bandit # Run a specific scan
  10. # ./test_security.sh codeql trivy # Run multiple specific scans
  11. #
  12. # Available scans:
  13. # bandit Python static security analysis (SAST)
  14. # codeql CodeQL analysis (Actions + JavaScript + Python)
  15. # codeql-actions CodeQL GitHub Actions only
  16. # codeql-python CodeQL Python only
  17. # codeql-js CodeQL JavaScript/TypeScript only
  18. # trivy Trivy container image + Dockerfile/IaC scan
  19. # trivy-image Trivy container image scan only
  20. # trivy-config Trivy Dockerfile/IaC scan only
  21. # pip-audit Python dependency vulnerability audit
  22. # npm-audit Frontend dependency vulnerability audit
  23. # gitleaks Secrets scan across full git history (slow — only in --full or by name)
  24. #
  25. # Prerequisites:
  26. # pip install bandit[sarif] pip-audit # Python tools
  27. # gh extension install github/gh-codeql # CodeQL CLI
  28. # curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh # Trivy
  29. # go install github.com/zricethezav/gitleaks/v8@latest # gitleaks (ensure ~/go/bin on PATH)
  30. #
  31. set -uo pipefail
  32. # Navigate to project root
  33. PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  34. cd "$PROJECT_ROOT"
  35. # Colors
  36. RED='\033[0;31m'
  37. GREEN='\033[0;32m'
  38. YELLOW='\033[1;33m'
  39. CYAN='\033[0;36m'
  40. BOLD='\033[1m'
  41. DIM='\033[2m'
  42. NC='\033[0m'
  43. # ── Temp directory for scan output ───────────────────────────────────────
  44. WORK_DIR=$(mktemp -d)
  45. trap 'rm -rf "$WORK_DIR"' EXIT
  46. # Parallel job tracking
  47. declare -A PIDS=() # scan_name -> PID
  48. declare -A RESULTS=() # scan_name -> PASS|FAIL|SKIP
  49. declare -A DURATIONS=() # scan_name -> seconds
  50. # Scan display order
  51. SCAN_ORDER=()
  52. # ── SARIF parser (used for CodeQL result display) ────────────────────────
  53. parse_sarif() {
  54. local sarif_file="$1"
  55. python3 << PYEOF
  56. import json
  57. from collections import defaultdict
  58. with open("$sarif_file") as f:
  59. data = json.load(f)
  60. rule_desc = {}
  61. for run in data.get("runs", []):
  62. for rule in run.get("tool", {}).get("driver", {}).get("rules", []):
  63. rid = rule.get("id", "")
  64. desc = rule.get("shortDescription", {}).get("text", "")
  65. rule_desc[rid] = desc
  66. by_rule = defaultdict(list)
  67. for run in data.get("runs", []):
  68. for result in run.get("results", []):
  69. rule_id = result.get("ruleId", "unknown")
  70. msg = result.get("message", {}).get("text", "")
  71. locs = result.get("locations", [])
  72. loc = ""
  73. if locs:
  74. pl = locs[0].get("physicalLocation", {})
  75. uri = pl.get("artifactLocation", {}).get("uri", "")
  76. line = pl.get("region", {}).get("startLine", "")
  77. loc = f"{uri}:{line}" if line else uri
  78. by_rule[rule_id].append((loc, msg))
  79. total = sum(len(v) for v in by_rule.values())
  80. if total == 0:
  81. print("No findings.")
  82. else:
  83. print(f"{total} findings:")
  84. print()
  85. for rule_id, findings in sorted(by_rule.items(), key=lambda x: -len(x[1])):
  86. desc = rule_desc.get(rule_id, "")
  87. print(f" {rule_id} ({len(findings)}) -- {desc}")
  88. for loc, msg in findings:
  89. short_msg = msg[:100] + "..." if len(msg) > 100 else msg
  90. print(f" {loc:60s} {short_msg}")
  91. print()
  92. PYEOF
  93. }
  94. # ── Scan functions (write to stdout, return exit code) ───────────────────
  95. check_command() {
  96. command -v "$1" &>/dev/null
  97. }
  98. has_codeql() {
  99. check_command gh && gh codeql version &>/dev/null
  100. }
  101. scan_bandit() {
  102. if ! check_command bandit; then
  103. echo "SKIP: 'bandit' not found. Install: pip install bandit[sarif]"
  104. return 2
  105. fi
  106. bandit -r backend/ --severity-level medium -x backend/tests 2>&1
  107. }
  108. scan_codeql_python() {
  109. local sarif="$PROJECT_ROOT/codeql-python-results.sarif"
  110. if ! has_codeql; then
  111. echo "SKIP: CodeQL CLI not installed. Install: gh extension install github/gh-codeql"
  112. return 2
  113. fi
  114. echo "Creating database..."
  115. gh codeql database create --overwrite --language=python --threads=0 /tmp/bambuddy-codeql-python &>/dev/null
  116. echo "Analyzing..."
  117. gh codeql database analyze /tmp/bambuddy-codeql-python \
  118. "$PROJECT_ROOT/.codeql/python-bambuddy.qls" \
  119. --threads=0 --format=sarifv2.1.0 --output="$sarif" &>/dev/null
  120. echo ""
  121. parse_sarif "$sarif"
  122. }
  123. scan_codeql_js() {
  124. local sarif="$PROJECT_ROOT/codeql-javascript-results.sarif"
  125. if ! has_codeql; then
  126. echo "SKIP: CodeQL CLI not installed."
  127. return 2
  128. fi
  129. echo "Creating database..."
  130. gh codeql database create --overwrite --language=javascript --source-root=frontend --threads=0 /tmp/bambuddy-codeql-javascript &>/dev/null
  131. echo "Analyzing..."
  132. gh codeql database analyze /tmp/bambuddy-codeql-javascript \
  133. "$PROJECT_ROOT/.codeql/javascript-bambuddy.qls" \
  134. --threads=0 --format=sarifv2.1.0 --output="$sarif" &>/dev/null
  135. echo ""
  136. parse_sarif "$sarif"
  137. }
  138. scan_codeql_actions() {
  139. local sarif="$PROJECT_ROOT/codeql-actions-results.sarif"
  140. if ! has_codeql; then
  141. echo "SKIP: CodeQL CLI not installed."
  142. return 2
  143. fi
  144. echo "Creating database..."
  145. gh codeql database create --overwrite --language=actions --threads=0 /tmp/bambuddy-codeql-actions &>/dev/null
  146. echo "Analyzing..."
  147. gh codeql database analyze /tmp/bambuddy-codeql-actions \
  148. codeql/actions-queries \
  149. --threads=0 --format=sarifv2.1.0 --output="$sarif" &>/dev/null
  150. echo ""
  151. parse_sarif "$sarif"
  152. }
  153. scan_trivy_image() {
  154. if ! check_command trivy; then
  155. echo "SKIP: 'trivy' not found. Install: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh"
  156. return 2
  157. fi
  158. if ! check_command docker; then
  159. echo "SKIP: 'docker' not found."
  160. return 2
  161. fi
  162. echo "Building Docker image..."
  163. docker build -t bambuddy:security-scan . 2>&1
  164. echo ""
  165. trivy image --severity CRITICAL,HIGH,MEDIUM bambuddy:security-scan 2>&1
  166. }
  167. scan_trivy_config() {
  168. if ! check_command trivy; then
  169. echo "SKIP: 'trivy' not found. Install: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh"
  170. return 2
  171. fi
  172. trivy config --severity CRITICAL,HIGH,MEDIUM . 2>&1
  173. }
  174. scan_pip_audit() {
  175. if ! check_command pip-audit; then
  176. echo "SKIP: 'pip-audit' not found. Install: pip install pip-audit"
  177. return 2
  178. fi
  179. pip-audit --desc on 2>&1
  180. }
  181. scan_npm_audit() {
  182. if ! check_command npm; then
  183. echo "SKIP: 'npm' not found. Install Node.js"
  184. return 2
  185. fi
  186. (cd frontend && npm audit --audit-level=high) 2>&1
  187. }
  188. scan_gitleaks() {
  189. local bin
  190. if check_command gitleaks; then
  191. bin="gitleaks"
  192. elif [ -x "$HOME/go/bin/gitleaks" ]; then
  193. bin="$HOME/go/bin/gitleaks"
  194. else
  195. echo "SKIP: 'gitleaks' not found. Install: go install github.com/zricethezav/gitleaks/v8@latest"
  196. return 2
  197. fi
  198. local report="$PROJECT_ROOT/gitleaks-report.json"
  199. echo "Scanning full git history (can take several minutes on large repos)..."
  200. "$bin" detect --source . --redact --no-banner \
  201. --max-target-megabytes=5 \
  202. --report-format=json --report-path="$report" 2>&1
  203. local exit_code=$?
  204. if [ -f "$report" ]; then
  205. python3 - "$report" << 'PYEOF'
  206. import json, sys, collections
  207. from pathlib import Path
  208. path = Path(sys.argv[1])
  209. findings = json.loads(path.read_text() or "[]")
  210. if not findings:
  211. print()
  212. print("No findings.")
  213. sys.exit(0)
  214. by_rule = collections.Counter(f.get("RuleID", "?") for f in findings)
  215. by_file = collections.Counter(f.get("File", "?") for f in findings)
  216. print()
  217. print(f"{len(findings)} findings (full details: {path.name})")
  218. print()
  219. print("By rule:")
  220. for rule, n in by_rule.most_common():
  221. print(f" {n:6d} {rule}")
  222. print()
  223. print("Top 10 files by hit count:")
  224. for fp, n in by_file.most_common(10):
  225. print(f" {n:6d} {fp}")
  226. PYEOF
  227. fi
  228. return $exit_code
  229. }
  230. # ── Job launcher (streams output live with prefix, captures to log) ──────
  231. launch_scan() {
  232. local name="$1"
  233. local func="$2"
  234. local prefix
  235. prefix=$(printf "${DIM}[%-14s]${NC} " "$name")
  236. SCAN_ORDER+=("$name")
  237. (
  238. set -o pipefail
  239. local start_time
  240. start_time=$(date +%s)
  241. "$func" 2>&1 | tee "$WORK_DIR/${name}.log" | sed "s|^|${prefix}|"
  242. local exit_code=${PIPESTATUS[0]}
  243. echo $(( $(date +%s) - start_time )) > "$WORK_DIR/${name}.duration"
  244. exit "$exit_code"
  245. ) &
  246. PIDS["$name"]=$!
  247. }
  248. # ── Wait for all scans ───────────────────────────────────────────────────
  249. wait_for_scans() {
  250. local total=${#PIDS[@]}
  251. local completed=0
  252. while [ "$completed" -lt "$total" ]; do
  253. for name in "${SCAN_ORDER[@]}"; do
  254. local pid=${PIDS[$name]:-}
  255. [ -z "$pid" ] && continue
  256. if ! kill -0 "$pid" 2>/dev/null; then
  257. wait "$pid" 2>/dev/null
  258. local exit_code=$?
  259. if [ "$exit_code" -eq 2 ]; then
  260. RESULTS["$name"]="SKIP"
  261. elif [ "$exit_code" -eq 0 ]; then
  262. RESULTS["$name"]="PASS"
  263. else
  264. RESULTS["$name"]="FAIL"
  265. fi
  266. if [ -f "$WORK_DIR/${name}.duration" ]; then
  267. DURATIONS["$name"]=$(cat "$WORK_DIR/${name}.duration")
  268. else
  269. DURATIONS["$name"]="?"
  270. fi
  271. local status_color
  272. case "${RESULTS[$name]}" in
  273. PASS) status_color="$GREEN" ;;
  274. FAIL) status_color="$RED" ;;
  275. SKIP) status_color="$YELLOW" ;;
  276. esac
  277. echo -e "${status_color}${BOLD}[${RESULTS[$name]}]${NC} ${name} ${DIM}(${DURATIONS[$name]}s)${NC}"
  278. unset "PIDS[$name]"
  279. completed=$((completed + 1))
  280. fi
  281. done
  282. sleep 0.5
  283. done
  284. }
  285. # ── Summary ──────────────────────────────────────────────────────────────
  286. print_summary() {
  287. local pass=0 fail=0 skip=0
  288. for name in "${SCAN_ORDER[@]}"; do
  289. case "${RESULTS[$name]}" in
  290. PASS) pass=$((pass + 1)) ;;
  291. FAIL) fail=$((fail + 1)) ;;
  292. SKIP) skip=$((skip + 1)) ;;
  293. esac
  294. done
  295. # ── Results table ────────────────────────────────────────────────────
  296. echo ""
  297. echo -e "${CYAN}${BOLD}══════════════════════════════════════════════════════════════${NC}"
  298. echo -e "${CYAN}${BOLD} Security Scan Results${NC}"
  299. echo -e "${CYAN}${BOLD}══════════════════════════════════════════════════════════════${NC}"
  300. echo ""
  301. printf " ${BOLD}%-6s %-24s %s${NC}\n" "Status" "Scan" "Duration"
  302. printf " %-6s %-24s %s\n" "──────" "────────────────────────" "────────"
  303. for name in "${SCAN_ORDER[@]}"; do
  304. local status="${RESULTS[$name]}"
  305. local duration="${DURATIONS[$name]:-?}s"
  306. local status_color
  307. case "$status" in
  308. PASS) status_color="$GREEN" ;;
  309. FAIL) status_color="$RED" ;;
  310. SKIP) status_color="$YELLOW" ;;
  311. esac
  312. printf " ${status_color}%-6s${NC} %-24s ${DIM}%s${NC}\n" "$status" "$name" "$duration"
  313. done
  314. echo ""
  315. echo -e " ${GREEN}$pass passed${NC} ${RED}$fail failed${NC} ${YELLOW}$skip skipped${NC}"
  316. # ── Full output per scan ─────────────────────────────────────────────
  317. for name in "${SCAN_ORDER[@]}"; do
  318. local log="$WORK_DIR/${name}.log"
  319. [ ! -f "$log" ] && continue
  320. local status="${RESULTS[$name]}"
  321. local status_color
  322. case "$status" in
  323. PASS) status_color="$GREEN" ;;
  324. FAIL) status_color="$RED" ;;
  325. SKIP) status_color="$YELLOW" ;;
  326. esac
  327. echo ""
  328. echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
  329. echo -e "${BOLD} $name${NC} ${status_color}[$status]${NC}"
  330. echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
  331. sed 's/^/ /' "$log"
  332. done
  333. echo ""
  334. echo -e "${CYAN}${BOLD}══════════════════════════════════════════════════════════════${NC}"
  335. echo ""
  336. if [ "$fail" -gt 0 ]; then
  337. exit 1
  338. fi
  339. }
  340. # ── Main ─────────────────────────────────────────────────────────────────
  341. if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
  342. head -29 "$0" | tail -27
  343. exit 0
  344. fi
  345. echo -e "${BOLD}Bambuddy Security Scanner${NC}"
  346. echo -e "${DIM}$(date '+%Y-%m-%d %H:%M:%S') • $(nproc) CPU cores available${NC}"
  347. echo ""
  348. SCANS_TO_RUN=()
  349. if [ $# -eq 0 ]; then
  350. SCANS_TO_RUN=(bandit pip-audit npm-audit)
  351. elif [ "$1" = "--full" ]; then
  352. SCANS_TO_RUN=(bandit pip-audit npm-audit codeql-actions codeql-python codeql-js trivy-image trivy-config gitleaks)
  353. else
  354. for scan in "$@"; do
  355. case "$scan" in
  356. codeql) SCANS_TO_RUN+=(codeql-actions codeql-python codeql-js) ;;
  357. trivy) SCANS_TO_RUN+=(trivy-image trivy-config) ;;
  358. bandit|codeql-actions|codeql-python|codeql-js|trivy-image|trivy-config|pip-audit|npm-audit|gitleaks)
  359. SCANS_TO_RUN+=("$scan") ;;
  360. *)
  361. echo -e "${RED}Unknown scan: $scan${NC}"
  362. echo "Run with --help for available scans"
  363. exit 1
  364. ;;
  365. esac
  366. done
  367. fi
  368. # Launch all scans in parallel
  369. for scan in "${SCANS_TO_RUN[@]}"; do
  370. case "$scan" in
  371. bandit) launch_scan "bandit" scan_bandit ;;
  372. codeql-actions) launch_scan "codeql-actions" scan_codeql_actions ;;
  373. codeql-python) launch_scan "codeql-python" scan_codeql_python ;;
  374. codeql-js) launch_scan "codeql-js" scan_codeql_js ;;
  375. trivy-image) launch_scan "trivy-image" scan_trivy_image ;;
  376. trivy-config) launch_scan "trivy-config" scan_trivy_config ;;
  377. pip-audit) launch_scan "pip-audit" scan_pip_audit ;;
  378. npm-audit) launch_scan "npm-audit" scan_npm_audit ;;
  379. gitleaks) launch_scan "gitleaks" scan_gitleaks ;;
  380. esac
  381. done
  382. echo -e "${BOLD}Running ${#SCANS_TO_RUN[@]} scan(s) in parallel...${NC}"
  383. echo ""
  384. wait_for_scans
  385. print_summary