|
| 1 | +#!/bin/bash |
| 2 | +# CodeMender Scanner Engine Module (Stage 2: Semantic Scan & Remediation) |
| 3 | +# Sourced by security_gate_hook.sh |
| 4 | + |
| 5 | +run_codemender_gate() { |
| 6 | + local in_pipeline="${1:-false}" |
| 7 | + _init_pipeline_dir |
| 8 | + |
| 9 | + if ! command -v cm >/dev/null 2>&1; then |
| 10 | + if [ "$in_pipeline" = "true" ]; then |
| 11 | + # If in pipeline and semgrep had unresolved findings, deny; otherwise allow |
| 12 | + if [ -f "$SECURITY_GATE_PIPELINE_DIR/imported_findings.jsonl" ] && [ -s "$SECURITY_GATE_PIPELINE_DIR/imported_findings.jsonl" ]; then |
| 13 | + local IMPORTED_COUNT |
| 14 | + IMPORTED_COUNT=$(wc -l < "$SECURITY_GATE_PIPELINE_DIR/imported_findings.jsonl" | tr -d ' ') |
| 15 | + deny "Stage 1 found $IMPORTED_COUNT unresolved finding(s) and CodeMender is not installed to perform Stage 2 remediation." |
| 16 | + fi |
| 17 | + allow |
| 18 | + else |
| 19 | + handle_scan_error "codemender" "the 'cm' CLI is not on PATH" true |
| 20 | + fi |
| 21 | + fi |
| 22 | + |
| 23 | + # 1. Discover modified files |
| 24 | + local MODIFIED_FILES |
| 25 | + MODIFIED_FILES=$(git diff --name-only origin/main 2>/dev/null || git diff --name-only HEAD~1 2>/dev/null || true) |
| 26 | + if [ -z "$MODIFIED_FILES" ]; then |
| 27 | + allow |
| 28 | + fi |
| 29 | + local MODIFIED_FILES_ARR |
| 30 | + read_lines_into_array MODIFIED_FILES_ARR "$MODIFIED_FILES" |
| 31 | + |
| 32 | + # 2. Run CodeMender scan on modified files |
| 33 | + echo "Stage 2: Running Semantic Analysis & Verification (CodeMender)..." >&2 |
| 34 | + for file in "${MODIFIED_FILES_ARR[@]}"; do |
| 35 | + cm find "$file" -y --bypass-warning >/dev/null 2>&1 || true |
| 36 | + done |
| 37 | + |
| 38 | + local REPORT_ERR_FILE |
| 39 | + REPORT_ERR_FILE=$(mktemp) |
| 40 | + local REPORT_RAW REPORT_EXIT |
| 41 | + if REPORT_RAW=$(cm report --status OPEN --format json 2>"$REPORT_ERR_FILE"); then |
| 42 | + REPORT_EXIT=0 |
| 43 | + else |
| 44 | + REPORT_EXIT=$? |
| 45 | + fi |
| 46 | + if [ "$REPORT_EXIT" -ne 0 ] || ! echo "$REPORT_RAW" | jq -e . >/dev/null 2>&1; then |
| 47 | + local ERR_DETAIL |
| 48 | + ERR_DETAIL=$(tail -c 500 "$REPORT_ERR_FILE" 2>/dev/null) |
| 49 | + rm -f "$REPORT_ERR_FILE" |
| 50 | + handle_scan_error "codemender" "'cm report' failed (exit $REPORT_EXIT): ${ERR_DETAIL:-no output}" true |
| 51 | + fi |
| 52 | + rm -f "$REPORT_ERR_FILE" |
| 53 | + |
| 54 | + # Filter findings to modified files |
| 55 | + local SCAN_RESULT |
| 56 | + SCAN_RESULT=$(echo "$REPORT_RAW" | jq --arg files "$MODIFIED_FILES" ' |
| 57 | + ($files | split("\n")) as $mod_files | |
| 58 | + [ .[] | select((.FilePath | gsub("\\\\"; "/")) as $fp | any($mod_files[]; . as $mf | $mf != "" and ($fp | endswith($mf)))) ] |
| 59 | + ' 2>/dev/null || echo '[]') |
| 60 | + |
| 61 | + # 3. Ingest imported findings from Stage 1 if present |
| 62 | + local WORK_DIR |
| 63 | + WORK_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'cm_work') |
| 64 | + echo "$SCAN_RESULT" | jq -c '.[]' > "$WORK_DIR/all.jsonl" |
| 65 | + |
| 66 | + if [ -f "$SECURITY_GATE_PIPELINE_DIR/imported_findings.jsonl" ]; then |
| 67 | + while IFS= read -r imp; do |
| 68 | + [ -z "$imp" ] && continue |
| 69 | + local IMP_ID IMP_FILE IMP_SEV |
| 70 | + IMP_ID=$(echo "$imp" | jq -r '.check_id // .FindingID // "imported_issue"') |
| 71 | + IMP_FILE=$(echo "$imp" | jq -r '.path // .FilePath // "unknown"') |
| 72 | + IMP_SEV=$(echo "$imp" | jq -r '.extra.severity // .Severity // "HIGH"') |
| 73 | + jq -n --arg id "$IMP_ID" --arg file "$IMP_FILE" --arg sev "$IMP_SEV" \ |
| 74 | + '{FindingID:$id, FilePath:$file, Severity:$sev, Source:"stage1_imported"}' >> "$WORK_DIR/all.jsonl" |
| 75 | + done < "$SECURITY_GATE_PIPELINE_DIR/imported_findings.jsonl" |
| 76 | + fi |
| 77 | + |
| 78 | + : > "$WORK_DIR/blocking.jsonl" |
| 79 | + : > "$WORK_DIR/advisory.jsonl" |
| 80 | + |
| 81 | + while IFS= read -r finding; do |
| 82 | + [ -z "$finding" ] && continue |
| 83 | + local SEV FILE FID |
| 84 | + SEV=$(echo "$finding" | jq -r '.Severity // .severity // "UNKNOWN"') |
| 85 | + FILE=$(echo "$finding" | jq -r '.FilePath // .path // ""') |
| 86 | + FID=$(echo "$finding" | jq -r '.FindingID // .check_id // ""') |
| 87 | + |
| 88 | + if is_blocking_severity "$SEV" && matches_threat_model "$FID" "$FILE" "$FID"; then |
| 89 | + echo "$finding" >> "$WORK_DIR/blocking.jsonl" |
| 90 | + else |
| 91 | + echo "$finding" >> "$WORK_DIR/advisory.jsonl" |
| 92 | + fi |
| 93 | + done < "$WORK_DIR/all.jsonl" |
| 94 | + |
| 95 | + local ADV_COUNT |
| 96 | + ADV_COUNT=$(wc -l < "$WORK_DIR/advisory.jsonl" | tr -d ' ') |
| 97 | + if [ "$ADV_COUNT" -gt 0 ]; then |
| 98 | + local ADV_JSON |
| 99 | + ADV_JSON=$(jq -s '.' "$WORK_DIR/advisory.jsonl") |
| 100 | + log_event "ADVISORY" "codemender" "$(jq -n --argjson f "$ADV_JSON" '{findings:$f}')" |
| 101 | + notify "ADVISORY" "$ADV_COUNT finding(s) treated as advisory." \ |
| 102 | + "$(jq -n --argjson f "$ADV_JSON" '{findings:$f}')" |
| 103 | + fi |
| 104 | + |
| 105 | + local BLOCK_COUNT |
| 106 | + BLOCK_COUNT=$(wc -l < "$WORK_DIR/blocking.jsonl" | tr -d ' ') |
| 107 | + if [ "$BLOCK_COUNT" -eq 0 ]; then |
| 108 | + rm -rf "$WORK_DIR" |
| 109 | + echo "No blocking vulnerabilities detected. Code verified clean." >&2 |
| 110 | + allow |
| 111 | + fi |
| 112 | + |
| 113 | + echo "Detected $BLOCK_COUNT blocking vulnerabilit(y/ies). Entering 3-attempt TDD remediation loop..." >&2 |
| 114 | + |
| 115 | + # 4. 3-Attempt TDD Remediation Loop (RED-GREEN) |
| 116 | + while IFS= read -r finding <&3; do |
| 117 | + [ -z "$finding" ] && continue |
| 118 | + local FINDING_ID FILE SEV |
| 119 | + FINDING_ID=$(echo "$finding" | jq -r '.FindingID // .check_id') |
| 120 | + FILE=$(echo "$finding" | jq -r '.FilePath // .path') |
| 121 | + SEV=$(echo "$finding" | jq -r '.Severity // .severity // "UNKNOWN"') |
| 122 | + |
| 123 | + echo "Vulnerability in scope: $FINDING_ID ($SEV) in $FILE" >&2 |
| 124 | + |
| 125 | + local RETRY_COUNT=0 |
| 126 | + local RESOLVED=false |
| 127 | + local LARGE_DIFF=false |
| 128 | + |
| 129 | + while [ "$RETRY_COUNT" -lt "$SECURITY_GATE_MAX_RETRIES" ]; do |
| 130 | + RETRY_COUNT=$((RETRY_COUNT+1)) |
| 131 | + echo "Attempting cm fix for: $FINDING_ID (Attempt $RETRY_COUNT/$SECURITY_GATE_MAX_RETRIES)..." >&2 |
| 132 | + if ! cm fix "$FINDING_ID" -y --bypass-warning; then |
| 133 | + echo "cm fix returned non-zero. Reverting..." >&2 |
| 134 | + git checkout -- . 2>/dev/null || true |
| 135 | + continue |
| 136 | + fi |
| 137 | + |
| 138 | + if [ -n "$SECURITY_GATE_TEST_CMD" ]; then |
| 139 | + if ! sh -c "$SECURITY_GATE_TEST_CMD" >/dev/null 2>&1; then |
| 140 | + echo "Fix failed unit/regression test assertions. Reverting..." >&2 |
| 141 | + git checkout -- . 2>/dev/null || true |
| 142 | + continue |
| 143 | + fi |
| 144 | + fi |
| 145 | + |
| 146 | + cm find "$FILE" -y --bypass-warning >/dev/null 2>&1 || true |
| 147 | + local STILL_OPEN |
| 148 | + STILL_OPEN=$(cm report --status OPEN --format json 2>/dev/null \ |
| 149 | + | jq --arg id "$FINDING_ID" '[.[] | select(.FindingID == $id)] | length' 2>/dev/null || echo 0) |
| 150 | + if [ "$STILL_OPEN" != "0" ]; then |
| 151 | + echo "Tests passed, but $FINDING_ID is still reported open after rescan." >&2 |
| 152 | + git checkout -- . 2>/dev/null || true |
| 153 | + continue |
| 154 | + fi |
| 155 | + |
| 156 | + local DIFF_STAT INS DEL CHANGED_LINES |
| 157 | + DIFF_STAT=$(git diff --shortstat 2>/dev/null || echo "") |
| 158 | + INS=$(echo "$DIFF_STAT" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo 0) |
| 159 | + DEL=$(echo "$DIFF_STAT" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo 0) |
| 160 | + CHANGED_LINES=$((INS + DEL)) |
| 161 | + if [ "$CHANGED_LINES" -gt "$SECURITY_GATE_LARGE_FIX_LINES" ]; then |
| 162 | + echo "Fix diff is large ($CHANGED_LINES lines > $SECURITY_GATE_LARGE_FIX_LINES threshold) - escalating to human review." >&2 |
| 163 | + LARGE_DIFF=true |
| 164 | + git checkout -- . 2>/dev/null || true |
| 165 | + break |
| 166 | + fi |
| 167 | + |
| 168 | + commit_fix "$FINDING_ID" "$FILE" "$SEV" |
| 169 | + evolve_context_and_skills "$FINDING_ID" "$FILE" "$SEV" |
| 170 | + log_event "FIXED" "codemender" "$(jq -n --argjson f "$finding" '{findings:[$f]}')" |
| 171 | + RESOLVED=true |
| 172 | + break |
| 173 | + done |
| 174 | + |
| 175 | + # 5. Verification Step if fix could not pass tests within 3 attempts |
| 176 | + if [ "$RESOLVED" != "true" ] && [ "$LARGE_DIFF" != "true" ]; then |
| 177 | + git checkout -- . 2>/dev/null || true |
| 178 | + echo "Fix failed tests past 3rd attempt. Running 'cm verify' to evaluate exploitability..." >&2 |
| 179 | + |
| 180 | + local VERIFY_OUT VERIFY_EXIT |
| 181 | + VERIFY_OUT=$(mktemp) |
| 182 | + if cm verify "$FINDING_ID" -y --bypass-warning >"$VERIFY_OUT" 2>&1; then |
| 183 | + VERIFY_EXIT=0 |
| 184 | + else |
| 185 | + VERIFY_EXIT=$? |
| 186 | + fi |
| 187 | + rm -f "$VERIFY_OUT" |
| 188 | + |
| 189 | + # If cm verify returns non-zero (1), the issue is conclusively not exploitable / false positive |
| 190 | + if [ "$VERIFY_EXIT" -eq 1 ]; then |
| 191 | + echo "cm verify clarified $FINDING_ID is not exploitable in this context. Recording as advisory." >&2 |
| 192 | + log_event "ADVISORY" "codemender" "$(jq -n --argjson f "$finding" '{findings:[$f], note:"cm verify clarified non-exploitable after 3 test retries"}')" |
| 193 | + notify "ADVISORY" "Finding $FINDING_ID remained after 3 failed fix attempts, but cm verify confirmed non-exploitable in context." \ |
| 194 | + "$(jq -n --argjson f "$finding" '{findings:[$f]}')" |
| 195 | + RESOLVED=true |
| 196 | + else |
| 197 | + # cm verify returned 0 (confirmed exploitable) or crashed (exit > 1) |
| 198 | + echo "cm verify confirmed finding $FINDING_ID is exploitable or verify failed (exit $VERIFY_EXIT)." >&2 |
| 199 | + fi |
| 200 | + fi |
| 201 | + |
| 202 | + # 6. Escalation to HITL |
| 203 | + if [ "$RESOLVED" != "true" ]; then |
| 204 | + local REASON_HINT="auto-fix could not satisfy test assertions after $SECURITY_GATE_MAX_RETRIES attempts" |
| 205 | + [ "$LARGE_DIFF" = true ] && REASON_HINT="fix diff exceeds safe auto-commit threshold" |
| 206 | + |
| 207 | + echo "Escalating $FINDING_ID to human review ($REASON_HINT)." >&2 |
| 208 | + |
| 209 | + # If interactive TTY is present, offer interactive prompt |
| 210 | + if [ -t 0 ] || [ -t 1 ]; then |
| 211 | + echo "Select action for finding $FINDING_ID:" >&2 |
| 212 | + echo "1) Defer with audited justification" >&2 |
| 213 | + echo "2) Abort and fix manually (blocks push)" >&2 |
| 214 | + local CHOICE |
| 215 | + read -p "Enter choice [1-2]: " CHOICE |
| 216 | + case "$CHOICE" in |
| 217 | + 1) |
| 218 | + local JUSTIFICATION |
| 219 | + read -p "Enter deferral justification: " JUSTIFICATION |
| 220 | + log_event "ADVISORY" "codemender" "$(jq -n --argjson f "$finding" --arg j "$JUSTIFICATION" '{findings:[$f], justification:$j}')" |
| 221 | + notify "ADVISORY" "Finding $FINDING_ID deferred: $JUSTIFICATION" "$(jq -n --argjson f "$finding" '{findings:[$f]}')" |
| 222 | + RESOLVED=true |
| 223 | + ;; |
| 224 | + *) |
| 225 | + log_event "BLOCKED" "codemender" "$(jq -n --argjson f "$finding" '{findings:[$f], reason:"manual resolution required"}')" |
| 226 | + rm -rf "$WORK_DIR" |
| 227 | + deny "Unresolved finding $FINDING_ID ($SEV). Push blocked for manual fix." |
| 228 | + ;; |
| 229 | + esac |
| 230 | + else |
| 231 | + # Non-interactive / headless environment fails closed |
| 232 | + log_event "BLOCKED" "codemender" "$(jq -n --argjson f "$finding" --arg r "$REASON_HINT" '{findings:[$f], reason:$r}')" |
| 233 | + rm -rf "$WORK_DIR" |
| 234 | + deny "Finding $FINDING_ID ($SEV) blocked: $REASON_HINT. See $SECURITY_GATE_LOG for details." |
| 235 | + fi |
| 236 | + fi |
| 237 | + done 3< "$WORK_DIR/blocking.jsonl" |
| 238 | + |
| 239 | + rm -rf "$WORK_DIR" |
| 240 | + allow |
| 241 | +} |
0 commit comments