Add cycode-summary.py + outputFormats input; rich step summary + anno… #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Centralized Cycode scan reusable workflow. | ||
|
Check failure on line 1 in .github/workflows/cycode-scan.yml
|
||
| # | ||
| # 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 | ||
| # Summary MDs live in runner.temp so they're available to the job-summary | ||
| # step even when the caller didn't request md as an artifact format. | ||
| SUMMARY_DIR: ${{ runner.temp }}/cycode-summary | ||
| SUMMARY_SCRIPT: ${{ runner.temp }}/cycode-summary.py | ||
| CYCODE_CLIENT_ID: ${{ secrets.CYCODE_CLIENT_ID }} | ||
| CYCODE_CLIENT_SECRET: ${{ secrets.CYCODE_CLIENT_SECRET }} | ||
| 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 | ||
| 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 | ||
| 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 | ||
| { | ||
| 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 | ||