22#
33# Single source of truth for the Cycode CI/CD gate across many repos.
44# Consumers `uses:` this workflow and pass scan-type / threshold / gate inputs;
5- # this workflow handles install, scan, summary, artifacts, and gating.
5+ # this workflow handles install, scan, rich summary, artifacts, and gating.
66#
77# Usage (in the consumer repo):
88# jobs:
1212# scanTypes: '["secret","sca","iac","sast"]'
1313# severityThreshold: high
1414# blockOnFindings: true
15+ # outputFormats: '["json","csv","md","html"]'
1516# secrets: inherit
1617#
1718# Behavior summary:
1819# - Runs on ubuntu-latest by default (override via runsOn input).
1920# - Installs the Cycode CLI via `pip install cycode`.
2021# - Auto-detects scan mode from the triggering event
2122# (push / pull_request → diff, manual / schedule → full).
22- # - For diff scans, BASE is taken from the event payload:
23- # push: github.event.before
24- # pull_request: github.event.pull_request.base.sha
25- # Falls back to a full scan if BASE cannot be resolved.
26- # - Appends a per-scan-type summary to the job summary tab.
27- # - Uploads raw Cycode JSON output as a single `cycode-scan-results` artifact.
23+ # - For diff scans, BASE is taken from the event payload.
24+ # - Appends a rich per-scan-type summary (severity table + per-finding
25+ # details with file/line links) to the job summary tab.
26+ # - Emits `::error file=path,line=N` annotations for each finding at or
27+ # above severityThreshold so they appear inline on the run page.
28+ # - Uploads requested output formats as a single `cycode-scan-results`
29+ # artifact. Raw JSON is always included (gate dependency); CSV / MD /
30+ # HTML are added when listed in outputFormats.
2831# - Fails the job on findings at or above severityThreshold when
2932# blockOnFindings is true; otherwise scans and reports without failing.
3033
5356 description : ' Empty string = auto-detect (push/PR → diff, manual/schedule → full). Override with "full" or "diff".'
5457 type : string
5558 default : ' '
59+ outputFormats :
60+ description : ' JSON array of artifact formats. Subset of ["json","csv","md","html"]. JSON is always included regardless (gate dependency); the others are added on request. Default ["json"].'
61+ type : string
62+ default : ' ["json"]'
5663 secrets :
5764 CYCODE_CLIENT_ID :
5865 required : true
7178 contents : read
7279 env :
7380 OUT_DIR : ${{ github.workspace }}/.cycode-out
81+ # Summary MDs live in runner.temp so they're available to the job-summary
82+ # step even when the caller didn't request md as an artifact format.
83+ SUMMARY_DIR : ${{ runner.temp }}/cycode-summary
84+ SUMMARY_SCRIPT : ${{ runner.temp }}/cycode-summary.py
7485 CYCODE_CLIENT_ID : ${{ secrets.CYCODE_CLIENT_ID }}
7586 CYCODE_CLIENT_SECRET : ${{ secrets.CYCODE_CLIENT_SECRET }}
7687 steps :
8192 SCAN_TYPES_INPUT : ${{ inputs.scanTypes }}
8293 SEVERITY_INPUT : ${{ inputs.severityThreshold }}
8394 SCAN_MODE_INPUT : ${{ inputs.scanMode }}
95+ OUTPUT_FORMATS_INPUT : ${{ inputs.outputFormats }}
8496 run : |
8597 set -euo pipefail
8698 case "$SEVERITY_INPUT" in
@@ -101,6 +113,16 @@ jobs:
101113 *) echo "::error::Invalid scanType '$t'. Allowed: secret, sast, sca, iac."; exit 1 ;;
102114 esac
103115 done
116+ if ! echo "$OUTPUT_FORMATS_INPUT" | jq -e 'type == "array" and length > 0' >/dev/null 2>&1; then
117+ echo "::error::outputFormats must be a non-empty JSON array string, e.g. '[\"json\",\"csv\"]' (got: $OUTPUT_FORMATS_INPUT)"
118+ exit 1
119+ fi
120+ for f in $(echo "$OUTPUT_FORMATS_INPUT" | jq -r '.[]'); do
121+ case "$f" in
122+ json|csv|md|html) ;;
123+ *) echo "::error::Invalid outputFormat '$f'. Allowed: json, csv, md, html."; exit 1 ;;
124+ esac
125+ done
104126
105127 # ---- Checkout caller repo with full history for diff scans --------
106128 - name : Checkout repo
@@ -123,6 +145,21 @@ jobs:
123145 shell : bash
124146 run : pip install --quiet cycode
125147
148+ # ---- Fetch the summary-report generator script --------------------
149+ # Lands in runner.temp (outside the workspace) so it isn't scanned as
150+ # caller-repo source. Sourced from this templates repo's main branch;
151+ # consumers who fork should change the URL.
152+ - name : Fetch Cycode summary script
153+ shell : bash
154+ run : |
155+ set -euo pipefail
156+ mkdir -p "$SUMMARY_DIR"
157+ curl -fsSL \
158+ "https://raw.githubusercontent.com/levine-cycode/cycode-github-actions-examples/main/scripts/cycode-summary.py" \
159+ -o "$SUMMARY_SCRIPT"
160+ python3 -c "import py_compile; py_compile.compile('$SUMMARY_SCRIPT', doraise=True)"
161+ echo "Fetched and compiled $SUMMARY_SCRIPT"
162+
126163 # ---- Resolve effective scan mode + BASE commit --------------------
127164 # GitHub Actions exposes the previous SHA directly in the event
128165 # payload, so we don't need a REST lookup (unlike the ADO version).
@@ -217,7 +254,52 @@ jobs:
217254 done
218255 ls -la "$OUT_DIR"
219256
220- # ---- Append per-scan-type summary to the job summary --------------
257+ # ---- Generate report formats (always MD for summary; CSV/HTML on request)
258+ # The python summary script reads each cycode-<type>.json and produces a
259+ # rich markdown summary, plus CSV / HTML if requested. MD always goes to
260+ # SUMMARY_DIR (consumed by the next step). MD / CSV / HTML also go to
261+ # OUT_DIR when listed in outputFormats so they ride along in the artifact.
262+ - name : Generate report formats
263+ if : always()
264+ shell : bash
265+ env :
266+ SCAN_TYPES_INPUT : ${{ inputs.scanTypes }}
267+ OUTPUT_FORMATS : ${{ inputs.outputFormats }}
268+ SCAN_MODE : ${{ steps.resolve.outputs.mode }}
269+ BASE_COMMIT : ${{ steps.resolve.outputs.base }}
270+ run : |
271+ set -e
272+ mkdir -p "$OUT_DIR" "$SUMMARY_DIR"
273+ want_md=$(echo "$OUTPUT_FORMATS" | jq -r 'index("md") | if . == null then "" else "yes" end')
274+ want_csv=$(echo "$OUTPUT_FORMATS" | jq -r 'index("csv") | if . == null then "" else "yes" end')
275+ want_html=$(echo "$OUTPUT_FORMATS" | jq -r 'index("html") | if . == null then "" else "yes" end')
276+
277+ for scan_type in $(echo "$SCAN_TYPES_INPUT" | jq -r '.[]'); do
278+ JSON_FILE="$OUT_DIR/cycode-$scan_type.json"
279+ if [ ! -s "$JSON_FILE" ]; then
280+ echo "::warning::Skipping report generation for $scan_type — JSON missing or empty."
281+ continue
282+ fi
283+
284+ SUMMARY_MD="$SUMMARY_DIR/cycode-$scan_type.md"
285+ ARGS=(--no-title --md "$SUMMARY_MD")
286+ if [ -n "$want_csv" ]; then ARGS+=(--csv "$OUT_DIR/cycode-$scan_type.csv"); fi
287+ if [ -n "$want_html" ]; then ARGS+=(--html "$OUT_DIR/cycode-$scan_type.html"); fi
288+
289+ echo "::group::Generate $scan_type reports"
290+ CYCODE_SCAN_TYPE="$scan_type" \
291+ CYCODE_SCAN_MODE="$SCAN_MODE" \
292+ CYCODE_BASE_COMMIT="$BASE_COMMIT" \
293+ python3 "$SUMMARY_SCRIPT" "$JSON_FILE" "${ARGS[@]}"
294+ # Mirror MD into OUT_DIR if the caller requested it for the artifact.
295+ if [ -n "$want_md" ]; then
296+ cp "$SUMMARY_MD" "$OUT_DIR/cycode-$scan_type.md"
297+ fi
298+ echo "::endgroup::"
299+ done
300+ ls -la "$OUT_DIR"
301+
302+ # ---- Append per-scan-type rich MD to the job summary --------------
221303 - name : Build job summary
222304 if : always()
223305 shell : bash
@@ -233,7 +315,8 @@ jobs:
233315 echo "## Cycode scan summary"
234316 echo ""
235317 echo "- **Repo:** \`${{ github.repository }}\`"
236- echo "- **Ref:** \`${{ github.ref }}\`"
318+ echo "- **Ref:** \`${{ github.ref_name }}\`"
319+ echo "- **Commit:** \`${{ github.sha }}\`"
237320 echo "- **Scan mode:** \`$SCAN_MODE\`"
238321 if [ "$SCAN_MODE" = "diff" ] && [ -n "$BASE_COMMIT" ]; then
239322 echo "- **Diff base:** \`$BASE_COMMIT\`"
@@ -243,41 +326,74 @@ jobs:
243326 echo ""
244327 } >> "$GITHUB_STEP_SUMMARY"
245328
246- # jq filter that pulls detections from either envelope shape
247- # Cycode emits (.scan_results[].detections OR top-level .detections)
248- # and normalizes severity to lowercase for counting.
249- DETECT_FILTER='[(.scan_results[]?.detections[]?), (.detections[]?)]'
250-
251329 for scan_type in $(echo "$SCAN_TYPES_INPUT" | jq -r '.[]'); do
252- JSON_FILE="$OUT_DIR/cycode-$scan_type.json"
253- echo "### Cycode $scan_type" >> "$GITHUB_STEP_SUMMARY"
254- echo "" >> "$GITHUB_STEP_SUMMARY"
255- if [ ! -s "$JSON_FILE" ] || ! jq -e 'has("scan_results") or has("detections")' "$JSON_FILE" >/dev/null 2>&1; then
256- echo "Scan output missing or malformed. See the \`Run Cycode scans\` step log for the failure." >> "$GITHUB_STEP_SUMMARY"
330+ SUMMARY_MD="$SUMMARY_DIR/cycode-$scan_type.md"
331+ if [ -s "$SUMMARY_MD" ]; then
332+ cat "$SUMMARY_MD" >> "$GITHUB_STEP_SUMMARY"
333+ echo "" >> "$GITHUB_STEP_SUMMARY"
334+ else
335+ echo "### Cycode $scan_type" >> "$GITHUB_STEP_SUMMARY"
336+ echo "" >> "$GITHUB_STEP_SUMMARY"
337+ echo "_Scan output missing or report generation failed. See step logs._" >> "$GITHUB_STEP_SUMMARY"
257338 echo "" >> "$GITHUB_STEP_SUMMARY"
258- continue
259339 fi
260- total=$(jq "$DETECT_FILTER | length" "$JSON_FILE")
261- crit=$(jq "$DETECT_FILTER | map(select((.severity // .detection_details.severity // \"\" | ascii_downcase) == \"critical\")) | length" "$JSON_FILE")
262- high=$(jq "$DETECT_FILTER | map(select((.severity // .detection_details.severity // \"\" | ascii_downcase) == \"high\")) | length" "$JSON_FILE")
263- med=$(jq "$DETECT_FILTER | map(select((.severity // .detection_details.severity // \"\" | ascii_downcase) == \"medium\")) | length" "$JSON_FILE")
264- low=$(jq "$DETECT_FILTER | map(select((.severity // .detection_details.severity // \"\" | ascii_downcase) == \"low\")) | length" "$JSON_FILE")
265- {
266- echo "Total: $total · Critical: $crit · High: $high · Medium: $med · Low: $low"
267- echo ""
268- } >> "$GITHUB_STEP_SUMMARY"
269340 done
270341
271- # ---- Upload raw JSON for every scan type as one artifact ----------
342+ # ---- Emit per-finding ::error annotations -------------------------
343+ # One annotation per detection at or above severityThreshold. These
344+ # render inline in the file diff on the run page and at the top of
345+ # the run summary — closest match to the native Cycode PR scan UX.
346+ - name : Emit annotations
347+ if : always()
348+ shell : bash
349+ env :
350+ SCAN_TYPES_INPUT : ${{ inputs.scanTypes }}
351+ SEVERITY_THRESHOLD : ${{ inputs.severityThreshold }}
352+ run : |
353+ set -e
354+ # Lookup table: severity → numeric rank for >= comparison.
355+ declare -A RANK=([critical]=5 [high]=4 [medium]=3 [low]=2 [info]=1)
356+ MIN=${RANK[$SEVERITY_THRESHOLD]:-4}
357+
358+ for scan_type in $(echo "$SCAN_TYPES_INPUT" | jq -r '.[]'); do
359+ JSON_FILE="$OUT_DIR/cycode-$scan_type.json"
360+ [ -s "$JSON_FILE" ] || continue
361+
362+ # Emit one ::error per finding ≥ threshold.
363+ # Fields: file (from detection_details.file_path), line, policy
364+ # display name, short description (escaped for the annotation
365+ # title/message single-line constraint).
366+ jq -r --argjson min "$MIN" --arg scan_type "$scan_type" '
367+ def rank: ({critical:5, high:4, medium:3, low:2, info:1}[. // ""]) // 0;
368+ def clean: tostring | gsub("[\r\n]+"; " ") | gsub("%"; "%25") | gsub(":"; "%3A") | gsub(","; "%2C");
369+ [(.scan_results[]?.detections[]?), (.detections[]?)] |
370+ .[] |
371+ . as $d |
372+ (($d.severity // $d.detection_details.severity // "") | ascii_downcase | rank) as $r |
373+ select($r >= $min) |
374+ ($d.detection_details.file_path // $d.detection_details.file // "") as $file |
375+ ($d.detection_details.line // $d.detection_details.start_position // 1) as $line |
376+ ($d.detection_details.policy_display_name // $d.message // "Cycode finding") as $policy |
377+ ($d.detection_details.description // $d.message // "") as $desc |
378+ "::error file=" + ($file | clean) +
379+ ",line=" + ($line | tostring) +
380+ ",title=Cycode " + $scan_type + ": " + ($policy | clean) +
381+ "::" + ($desc | clean)
382+ ' "$JSON_FILE" || true
383+ done
384+
385+ # ---- Upload Cycode scan results -----------------------------------
386+ # Glob picks up whatever's in OUT_DIR — JSON always, plus the formats
387+ # the caller requested via outputFormats.
272388 - name : Upload Cycode scan results
273389 if : always()
274390 uses : actions/upload-artifact@v4
275391 with :
276392 name : cycode-scan-results
277- path : ${{ env.OUT_DIR }}/cycode-*.json
393+ path : ${{ env.OUT_DIR }}/cycode-*
278394 if-no-files-found : warn
279395 # OUT_DIR is a dot-prefixed directory; upload-artifact skips dotfile
280- # paths by default. Required to actually publish the JSON .
396+ # paths by default. Required to actually publish the artifact .
281397 include-hidden-files : true
282398
283399 # ---- Gate ---------------------------------------------------------
0 commit comments