Skip to content

Commit 9be55be

Browse files
committed
Add cycode-summary.py + outputFormats input; rich step summary + annotations
1 parent 02f945e commit 9be55be

2 files changed

Lines changed: 1591 additions & 32 deletions

File tree

.github/workflows/cycode-scan.yml

Lines changed: 148 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
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:
@@ -12,19 +12,22 @@
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

@@ -53,6 +56,10 @@ on:
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
@@ -71,6 +78,10 @@ jobs:
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:
@@ -81,6 +92,7 @@ jobs:
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

Comments
 (0)