-
Notifications
You must be signed in to change notification settings - Fork 0
452 lines (423 loc) · 19.9 KB
/
cycode-scan.yml
File metadata and controls
452 lines (423 loc) · 19.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
# Centralized Cycode scan reusable workflow.
#
# Single source of truth for the Cycode CI/CD gate across many repos.
# Consumers `uses:` this workflow and pass scan-type / threshold / gate inputs;
# this workflow handles install, scan, rich summary, artifacts, and gating.
#
# Usage (in the consumer repo):
# jobs:
# cycode:
# uses: <owner>/<repo>/.github/workflows/cycode-scan.yml@v1
# with:
# scanTypes: '["secret","sca","iac","sast"]'
# severityThreshold: high
# blockOnFindings: true
# outputFormats: '["json","csv","md","html"]'
# secrets: inherit
#
# Behavior summary:
# - Runs on ubuntu-latest by default (override via runsOn input).
# - Installs the Cycode CLI via `pip install cycode`.
# - Auto-detects scan mode from the triggering event
# (push / pull_request → diff, manual / schedule → full).
# - For diff scans, BASE is taken from the event payload.
# - Appends a rich per-scan-type summary (severity table + per-finding
# details with file/line links) to the job summary tab.
# - Emits `::error file=path,line=N` annotations for each finding at or
# above severityThreshold so they appear inline on the run page.
# - Uploads requested output formats as a single `cycode-scan-results`
# artifact. Raw JSON is always included (gate dependency); CSV / MD /
# HTML are added when listed in outputFormats.
# - Fails the job on findings at or above severityThreshold when
# blockOnFindings is true; otherwise scans and reports without failing.
name: Cycode Scan
on:
workflow_call:
inputs:
scanTypes:
description: 'JSON array of scan types. Any subset of ["secret","sast","sca","iac"]. Scans run in the listed order.'
type: string
default: '["secret"]'
severityThreshold:
description: 'info | low | medium | high | critical'
type: string
default: 'high'
blockOnFindings:
description: 'true = fail on findings at or above threshold; false = scan and report only'
type: boolean
default: true
runsOn:
description: 'Runner image label (ubuntu-latest | windows-2022 | macos-latest | self-hosted | ...)'
type: string
default: 'ubuntu-latest'
scanMode:
description: 'Empty string = auto-detect (push/PR → diff, manual/schedule → full). Override with "full" or "diff".'
type: string
default: ''
outputFormats:
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"].'
type: string
default: '["json"]'
secrets:
CYCODE_CLIENT_ID:
required: true
CYCODE_CLIENT_SECRET:
required: true
jobs:
cycode-scan:
name: Cycode scan + gate
runs-on: ${{ inputs.runsOn }}
# Default timeout matches the ADO template. Raise this in your caller
# workflow if you have a large monorepo or a long-stale branch whose
# first-ever diff spans many commits.
timeout-minutes: 60
permissions:
contents: read
env:
OUT_DIR: ${{ github.workspace }}/.cycode-out
CYCODE_CLIENT_ID: ${{ secrets.CYCODE_CLIENT_ID }}
CYCODE_CLIENT_SECRET: ${{ secrets.CYCODE_CLIENT_SECRET }}
# NOTE: SUMMARY_DIR + SUMMARY_SCRIPT are derived inside each step from
# the runner-provided $RUNNER_TEMP shell env var. The ${{ runner.* }}
# GH context is not allowed at job-level env, only at step level.
steps:
# ---- Validate inputs at the top so misconfiguration fails loudly --
- name: Validate inputs
shell: bash
env:
SCAN_TYPES_INPUT: ${{ inputs.scanTypes }}
SEVERITY_INPUT: ${{ inputs.severityThreshold }}
SCAN_MODE_INPUT: ${{ inputs.scanMode }}
OUTPUT_FORMATS_INPUT: ${{ inputs.outputFormats }}
run: |
set -euo pipefail
case "$SEVERITY_INPUT" in
info|low|medium|high|critical) ;;
*) echo "::error::severityThreshold must be one of info|low|medium|high|critical (got '$SEVERITY_INPUT')"; exit 1 ;;
esac
case "$SCAN_MODE_INPUT" in
""|full|diff) ;;
*) echo "::error::scanMode must be empty, 'full', or 'diff' (got '$SCAN_MODE_INPUT')"; exit 1 ;;
esac
if ! echo "$SCAN_TYPES_INPUT" | jq -e 'type == "array" and length > 0' >/dev/null 2>&1; then
echo "::error::scanTypes must be a non-empty JSON array string, e.g. '[\"secret\",\"sca\"]' (got: $SCAN_TYPES_INPUT)"
exit 1
fi
for t in $(echo "$SCAN_TYPES_INPUT" | jq -r '.[]'); do
case "$t" in
secret|sast|sca|iac) ;;
*) echo "::error::Invalid scanType '$t'. Allowed: secret, sast, sca, iac."; exit 1 ;;
esac
done
if ! echo "$OUTPUT_FORMATS_INPUT" | jq -e 'type == "array" and length > 0' >/dev/null 2>&1; then
echo "::error::outputFormats must be a non-empty JSON array string, e.g. '[\"json\",\"csv\"]' (got: $OUTPUT_FORMATS_INPUT)"
exit 1
fi
for f in $(echo "$OUTPUT_FORMATS_INPUT" | jq -r '.[]'); do
case "$f" in
json|csv|md|html) ;;
*) echo "::error::Invalid outputFormat '$f'. Allowed: json, csv, md, html."; exit 1 ;;
esac
done
# ---- Checkout caller repo with full history for diff scans --------
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0
# Long-paths flag is a no-op on Linux/macOS but matters on windows-*
# runners where Java/.NET artifact paths routinely exceed 260 chars.
- name: Enable git long paths
shell: bash
run: git config --global core.longpaths true || true
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Cycode CLI
shell: bash
run: pip install --quiet cycode
# ---- Fetch the summary-report generator script --------------------
# Lands in runner.temp (outside the workspace) so it isn't scanned as
# caller-repo source. Sourced from this templates repo's main branch;
# consumers who fork should change the URL.
- name: Fetch Cycode summary script
shell: bash
run: |
set -euo pipefail
SUMMARY_DIR="$RUNNER_TEMP/cycode-summary"
SUMMARY_SCRIPT="$RUNNER_TEMP/cycode-summary.py"
mkdir -p "$SUMMARY_DIR"
curl -fsSL \
"https://raw.githubusercontent.com/levine-cycode/cycode-github-actions-examples/main/scripts/cycode-summary.py" \
-o "$SUMMARY_SCRIPT"
python3 -c "import py_compile; py_compile.compile('$SUMMARY_SCRIPT', doraise=True)"
echo "Fetched and compiled $SUMMARY_SCRIPT"
# ---- Resolve effective scan mode + BASE commit --------------------
# GitHub Actions exposes the previous SHA directly in the event
# payload, so we don't need a REST lookup (unlike the ADO version).
- name: Resolve scan mode + BASE commit
id: resolve
shell: bash
env:
SCAN_MODE_INPUT: ${{ inputs.scanMode }}
EVENT_NAME: ${{ github.event_name }}
PUSH_BEFORE: ${{ github.event.before }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
mkdir -p "$OUT_DIR"
MODE="$SCAN_MODE_INPUT"
if [ -z "$MODE" ]; then
case "$EVENT_NAME" in
push|pull_request) MODE="diff" ;;
*) MODE="full" ;;
esac
fi
echo "event=$EVENT_NAME scanMode(input)='$SCAN_MODE_INPUT' effective=$MODE"
BASE=""
if [ "$MODE" = "diff" ]; then
case "$EVENT_NAME" in
pull_request)
BASE="$PR_BASE_SHA"
;;
push)
# New-branch push has before=00..00. Treat as no base.
if [ -n "$PUSH_BEFORE" ] && [ "$PUSH_BEFORE" != "0000000000000000000000000000000000000000" ]; then
BASE="$PUSH_BEFORE"
fi
;;
esac
if [ -z "$BASE" ]; then
BASE="$(git rev-parse HEAD~1 2>/dev/null || true)"
fi
if [ -n "$BASE" ] && ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then
echo "::warning::BASE commit $BASE is not present in local history (force-push or shallow fetch). Falling back to full scan."
BASE=""
fi
if [ -z "$BASE" ]; then
echo "::warning::Could not resolve a BASE commit for diff scan; falling back to a full scan."
MODE="full"
else
echo "Resolved BASE commit: $BASE"
fi
fi
echo "mode=$MODE" >> "$GITHUB_OUTPUT"
echo "base=$BASE" >> "$GITHUB_OUTPUT"
# ---- Run scans in declared order ----------------------------------
# Sequential loop preserves ordering. IaC always uses path mode —
# Cycode CLI does not support commit-history for IaC. --soft-fail
# ensures the scan writes its JSON even when findings exist; the
# gate step handles the fail decision.
- name: Run Cycode scans
shell: bash
env:
SCAN_TYPES_INPUT: ${{ inputs.scanTypes }}
SEVERITY_THRESHOLD: ${{ inputs.severityThreshold }}
SCAN_MODE: ${{ steps.resolve.outputs.mode }}
BASE_COMMIT: ${{ steps.resolve.outputs.base }}
run: |
set -e
mkdir -p "$OUT_DIR"
for scan_type in $(echo "$SCAN_TYPES_INPUT" | jq -r '.[]'); do
OUT_FILE="$OUT_DIR/cycode-$scan_type.json"
if [ "$SCAN_MODE" = "diff" ] && [ "$scan_type" != "iac" ] && [ -n "$BASE_COMMIT" ]; then
echo "::group::Cycode $scan_type scan (diff: $BASE_COMMIT..HEAD)"
cycode --verbose -o json scan --soft-fail \
-t "$scan_type" \
--severity-threshold "$SEVERITY_THRESHOLD" \
commit-history -r "$BASE_COMMIT..HEAD" . \
> "$OUT_FILE" || true
else
echo "::group::Cycode $scan_type scan (full tree, path mode)"
cycode --verbose -o json scan --soft-fail \
-t "$scan_type" \
--severity-threshold "$SEVERITY_THRESHOLD" \
path . \
> "$OUT_FILE" || true
fi
echo "::endgroup::"
done
ls -la "$OUT_DIR"
# ---- Generate report formats (always MD for summary; CSV/HTML on request)
# The python summary script reads each cycode-<type>.json and produces a
# rich markdown summary, plus CSV / HTML if requested. MD always goes to
# SUMMARY_DIR (consumed by the next step). MD / CSV / HTML also go to
# OUT_DIR when listed in outputFormats so they ride along in the artifact.
- name: Generate report formats
if: always()
shell: bash
env:
SCAN_TYPES_INPUT: ${{ inputs.scanTypes }}
OUTPUT_FORMATS: ${{ inputs.outputFormats }}
SCAN_MODE: ${{ steps.resolve.outputs.mode }}
BASE_COMMIT: ${{ steps.resolve.outputs.base }}
run: |
set -e
SUMMARY_DIR="$RUNNER_TEMP/cycode-summary"
SUMMARY_SCRIPT="$RUNNER_TEMP/cycode-summary.py"
mkdir -p "$OUT_DIR" "$SUMMARY_DIR"
want_md=$(echo "$OUTPUT_FORMATS" | jq -r 'index("md") | if . == null then "" else "yes" end')
want_csv=$(echo "$OUTPUT_FORMATS" | jq -r 'index("csv") | if . == null then "" else "yes" end')
want_html=$(echo "$OUTPUT_FORMATS" | jq -r 'index("html") | if . == null then "" else "yes" end')
for scan_type in $(echo "$SCAN_TYPES_INPUT" | jq -r '.[]'); do
JSON_FILE="$OUT_DIR/cycode-$scan_type.json"
if [ ! -s "$JSON_FILE" ]; then
echo "::warning::Skipping report generation for $scan_type — JSON missing or empty."
continue
fi
SUMMARY_MD="$SUMMARY_DIR/cycode-$scan_type.md"
ARGS=(--no-title --md "$SUMMARY_MD")
if [ -n "$want_csv" ]; then ARGS+=(--csv "$OUT_DIR/cycode-$scan_type.csv"); fi
if [ -n "$want_html" ]; then ARGS+=(--html "$OUT_DIR/cycode-$scan_type.html"); fi
echo "::group::Generate $scan_type reports"
CYCODE_SCAN_TYPE="$scan_type" \
CYCODE_SCAN_MODE="$SCAN_MODE" \
CYCODE_BASE_COMMIT="$BASE_COMMIT" \
python3 "$SUMMARY_SCRIPT" "$JSON_FILE" "${ARGS[@]}"
# Mirror MD into OUT_DIR if the caller requested it for the artifact.
if [ -n "$want_md" ]; then
cp "$SUMMARY_MD" "$OUT_DIR/cycode-$scan_type.md"
fi
echo "::endgroup::"
done
ls -la "$OUT_DIR"
# ---- Append per-scan-type rich MD to the job summary --------------
- name: Build job summary
if: always()
shell: bash
env:
SCAN_TYPES_INPUT: ${{ inputs.scanTypes }}
SEVERITY_THRESHOLD: ${{ inputs.severityThreshold }}
BLOCK_ON_FINDINGS: ${{ inputs.blockOnFindings }}
SCAN_MODE: ${{ steps.resolve.outputs.mode }}
BASE_COMMIT: ${{ steps.resolve.outputs.base }}
run: |
set -e
SUMMARY_DIR="$RUNNER_TEMP/cycode-summary"
{
echo "## Cycode scan summary"
echo ""
echo "- **Repo:** \`${{ github.repository }}\`"
echo "- **Ref:** \`${{ github.ref_name }}\`"
echo "- **Commit:** \`${{ github.sha }}\`"
echo "- **Scan mode:** \`$SCAN_MODE\`"
if [ "$SCAN_MODE" = "diff" ] && [ -n "$BASE_COMMIT" ]; then
echo "- **Diff base:** \`$BASE_COMMIT\`"
fi
echo "- **Severity threshold:** \`$SEVERITY_THRESHOLD\`"
echo "- **Block on findings:** \`$BLOCK_ON_FINDINGS\`"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
for scan_type in $(echo "$SCAN_TYPES_INPUT" | jq -r '.[]'); do
SUMMARY_MD="$SUMMARY_DIR/cycode-$scan_type.md"
if [ -s "$SUMMARY_MD" ]; then
cat "$SUMMARY_MD" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
else
echo "### Cycode $scan_type" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "_Scan output missing or report generation failed. See step logs._" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
fi
done
# ---- Emit per-finding ::error annotations -------------------------
# One annotation per detection at or above severityThreshold. These
# render inline in the file diff on the run page and at the top of
# the run summary — closest match to the native Cycode PR scan UX.
- name: Emit annotations
if: always()
shell: bash
env:
SCAN_TYPES_INPUT: ${{ inputs.scanTypes }}
SEVERITY_THRESHOLD: ${{ inputs.severityThreshold }}
run: |
set -e
# Lookup table: severity → numeric rank for >= comparison.
declare -A RANK=([critical]=5 [high]=4 [medium]=3 [low]=2 [info]=1)
MIN=${RANK[$SEVERITY_THRESHOLD]:-4}
for scan_type in $(echo "$SCAN_TYPES_INPUT" | jq -r '.[]'); do
JSON_FILE="$OUT_DIR/cycode-$scan_type.json"
[ -s "$JSON_FILE" ] || continue
# Emit one ::error per finding ≥ threshold.
# Fields: file (from detection_details.file_path), line, policy
# display name, short description (escaped for the annotation
# title/message single-line constraint).
jq -r --argjson min "$MIN" --arg scan_type "$scan_type" '
def rank: ({critical:5, high:4, medium:3, low:2, info:1}[. // ""]) // 0;
def clean: tostring | gsub("[\r\n]+"; " ") | gsub("%"; "%25") | gsub(":"; "%3A") | gsub(","; "%2C");
[(.scan_results[]?.detections[]?), (.detections[]?)] |
.[] |
. as $d |
(($d.severity // $d.detection_details.severity // "") | ascii_downcase | rank) as $r |
select($r >= $min) |
($d.detection_details.file_path // $d.detection_details.file // "") as $file |
($d.detection_details.line // $d.detection_details.start_position // 1) as $line |
($d.detection_details.policy_display_name // $d.message // "Cycode finding") as $policy |
($d.detection_details.description // $d.message // "") as $desc |
"::error file=" + ($file | clean) +
",line=" + ($line | tostring) +
",title=Cycode " + $scan_type + ": " + ($policy | clean) +
"::" + ($desc | clean)
' "$JSON_FILE" || true
done
# ---- Upload Cycode scan results -----------------------------------
# Glob picks up whatever's in OUT_DIR — JSON always, plus the formats
# the caller requested via outputFormats.
- name: Upload Cycode scan results
if: always()
uses: actions/upload-artifact@v4
with:
name: cycode-scan-results
path: ${{ env.OUT_DIR }}/cycode-*
if-no-files-found: warn
# OUT_DIR is a dot-prefixed directory; upload-artifact skips dotfile
# paths by default. Required to actually publish the artifact.
include-hidden-files: true
# ---- Gate ---------------------------------------------------------
# Loud-fails on missing/malformed output or non-empty .errors[] so an
# auth, network, or CLI failure upstream cannot silently bypass the
# gate as "0 findings". When blockOnFindings is false, the gate still
# validates output integrity but exits 0 on findings (warn-only).
- name: Gate
if: always()
shell: bash
env:
SCAN_TYPES_INPUT: ${{ inputs.scanTypes }}
SEVERITY_THRESHOLD: ${{ inputs.severityThreshold }}
BLOCK_ON_FINDINGS: ${{ inputs.blockOnFindings }}
run: |
set -e
GATE_FAIL=0
for scan_type in $(echo "$SCAN_TYPES_INPUT" | jq -r '.[]'); do
JSON_FILE="$OUT_DIR/cycode-$scan_type.json"
if [ ! -s "$JSON_FILE" ]; then
echo "::error::Cycode $scan_type scan produced no output ($JSON_FILE empty or missing). The scan step likely failed; review its log."
GATE_FAIL=1
continue
fi
if ! jq -e 'has("scan_results") or has("detections")' "$JSON_FILE" >/dev/null 2>&1; then
echo "::error::Cycode $scan_type scan output is malformed (no scan_results or detections key in $JSON_FILE). Review the scan log."
GATE_FAIL=1
continue
fi
err_count=$(jq '.errors // [] | length' "$JSON_FILE")
if [ "$err_count" -gt 0 ]; then
err_msg=$(jq -r '.errors | map(tostring) | join(" | ")' "$JSON_FILE")
echo "::error::Cycode $scan_type scan reported $err_count error(s): $err_msg"
GATE_FAIL=1
continue
fi
count=$(jq '[(.scan_results[]?.detections[]?), (.detections[]?)] | length' "$JSON_FILE")
echo "$scan_type findings at or above $SEVERITY_THRESHOLD: $count"
if [ "$count" -gt 0 ]; then
if [ "$BLOCK_ON_FINDINGS" = "true" ]; then
echo "::error::Cycode $scan_type scan found $count finding(s) at or above $SEVERITY_THRESHOLD. See cycode-scan-results artifact."
GATE_FAIL=1
else
echo "::warning::Cycode $scan_type scan found $count finding(s) at or above $SEVERITY_THRESHOLD. blockOnFindings=false; not failing the build."
fi
fi
done
if [ "$GATE_FAIL" -ne 0 ]; then
exit 1
fi