diff --git a/README.md b/README.md index 3b21b3e..64e8829 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,30 @@ Python web application examples demonstrating **OWASP Top 10 (2021)** vulnerabil - Hardcoded Secrets +## 🔧 Cycode CI/CD Integration Examples + +Working examples of Cycode as a CI/CD gate in Azure Pipelines and GitHub Actions. + +### GitHub Actions (`.github/workflows/`) +- `cycode-sast-scan.yml` — full SAST scan on push to main +- `cycode-sast-pr-scan.yml` — delta scan on pull requests +- `cycode-release-gate.yml` — release-stage gate (SAST + SCA + Secrets) + +### Azure Pipelines + +| File | Pattern | +|------|---------| +| `azure-pipelines.yml` | **CLI gate** — full scan on push, delta on PR (original pattern) | +| `azure-pipelines-api-gate.yml` | **API gate** — query Cycode RIG for Open violations in the repo | +| `azure-pipelines-publish-results.yml` | **Surface results in Azure UI** — Tests tab + custom summary tab + downloadable artifact | +| `azure-pipelines-template-consumer.yml` | **Centralized template** — consumer that `extends: templates/cycode-scan.yml` | +| `templates/cycode-scan.yml` | Master template (scan + publish + gate) consumed by many pipelines | +| `scripts/cycode-gate.sh` | Standalone API-gate script (works from any shell) | +| `scripts/cycode-json-to-junit.py` | Converts `cycode -o json` output to JUnit XML | +| `scripts/cycode-summary.py` | Generates a short Markdown summary for the build summary tab | + +Secrets needed in Azure DevOps (Pipelines → Library or pipeline variables with lock icon): `CYCODE_CLIENT_ID`, `CYCODE_CLIENT_SECRET`. + ## 🚀 Getting Started Each directory contains its own README with specific vulnerability descriptions. diff --git a/azure-pipelines-api-gate.yml b/azure-pipelines-api-gate.yml new file mode 100644 index 0000000..69dc86d --- /dev/null +++ b/azure-pipelines-api-gate.yml @@ -0,0 +1,41 @@ +# Pattern 2 — Cycode API gate (standalone example). +# +# Queries Cycode's Risk Intelligence Graph for Open violations in this repo. +# Fails the build if any match the filters below. +# +# Prereqs (one-time in Azure DevOps): +# - Secret pipeline variables CYCODE_CLIENT_ID and CYCODE_CLIENT_SECRET +# - Agent pool 'Default' (self-hosted) or change to 'ubuntu-latest' +# +# Run manually: Pipelines → this pipeline → Run +trigger: none +pr: none + +pool: + name: Default + +variables: + # Must match the repo name shown in Cycode's Violations UI. + # Bare repo name as stored in Cycode's RIG — NOT "owner/repo". + REPO_NAME: "vectorvictor" + +steps: + - checkout: self + + - script: | + if ! command -v jq >/dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq jq || true + fi + jq --version + displayName: "Ensure jq is available" + + - script: bash scripts/cycode-gate.sh + displayName: "Cycode API gate — fail on Open violations" + env: + CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID) + CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET) + REPO_NAME: $(REPO_NAME) + # Optional tuning (uncomment to enable): + # SEVERITY_MIN: "High" # only High + Critical fail the build + # CATEGORY: "SAST" # scope to one scan type + # RISK_SCORE_MIN: "70" # ignore findings below risk score 70 diff --git a/azure-pipelines-publish-results.yml b/azure-pipelines-publish-results.yml new file mode 100644 index 0000000..56904da --- /dev/null +++ b/azure-pipelines-publish-results.yml @@ -0,0 +1,81 @@ +# Pattern 3 — Publish Cycode results to the Azure Pipelines UI. +# +# Populates three surfaces from a single Cycode scan: +# - Tests tab (each finding = failed test, via JUnit) +# - Custom summary (Markdown report as a tab on the build summary page) +# - Artifact (raw cycode-results.json for download) +# +# After the results are published, a final CLI gate step enforces pass/fail. +# Swap the gate to `bash scripts/cycode-gate.sh` to gate on the platform API +# instead (see azure-pipelines-api-gate.yml). +trigger: none +pr: none + +pool: + name: Default + +variables: + SCAN_PATH: "./vulnerable_apps/" + +steps: + - checkout: self + fetchDepth: 0 + + - task: UsePythonVersion@0 + displayName: "Use Python 3.12" + inputs: + versionSpec: "3.12" + + - script: | + set -e + python3 -m pip install --upgrade pip + pip install cycode + displayName: "Install Cycode CLI" + + # ---- Scan with --soft-fail so we can publish results first ------------ + - script: | + set +e + cycode -o json scan --soft-fail -t sast path $(SCAN_PATH) > cycode-results.json + echo "scan exit: $?" + ls -la cycode-results.json + displayName: "Cycode SAST scan (JSON)" + env: + CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID) + CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET) + + # ---- Surface 1: Tests tab -------------------------------------------- + - script: python3 scripts/cycode-json-to-junit.py cycode-results.json cycode-junit.xml + displayName: "Convert JSON → JUnit" + condition: succeededOrFailed() + + - task: PublishTestResults@2 + displayName: 'Publish to "Tests" tab' + condition: succeededOrFailed() + inputs: + testResultsFormat: "JUnit" + testResultsFiles: "cycode-junit.xml" + testRunTitle: "Cycode SAST" + mergeTestResults: true + failTaskOnFailedTests: false + + # ---- Surface 2: Build summary tab ------------------------------------ + - script: | + python3 scripts/cycode-summary.py cycode-results.json > cycode-summary.md + echo "##vso[task.uploadsummary]$(System.DefaultWorkingDirectory)/cycode-summary.md" + displayName: 'Publish "Cycode Scan Summary" tab' + condition: succeededOrFailed() + + # ---- Surface 3: Downloadable artifact -------------------------------- + - task: PublishBuildArtifacts@1 + displayName: "Publish raw Cycode JSON" + condition: succeededOrFailed() + inputs: + pathToPublish: "cycode-results.json" + artifactName: "cycode-report" + + # ---- Final gate ------------------------------------------------------- + - script: cycode scan -t sast path $(SCAN_PATH) + displayName: "Cycode SAST gate (fails build on findings)" + env: + CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID) + CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET) diff --git a/azure-pipelines-template-consumer.yml b/azure-pipelines-template-consumer.yml new file mode 100644 index 0000000..9be7938 --- /dev/null +++ b/azure-pipelines-template-consumer.yml @@ -0,0 +1,33 @@ +# Pattern 4 — Consumer pipeline that extends the centralized Cycode template. +# +# This same-repo example demonstrates the consumer shape. In production the +# template lives in a separate repo owned by the security team; consumers +# reference it via `resources.repositories` and `extends: ...@security-templates`. +# +# Cross-repo version (replace the local `extends` below): +# +# resources: +# repositories: +# - repository: security-templates +# type: git +# name: SecurityTeam/cycode-pipeline-templates +# ref: refs/tags/v1 +# extends: +# template: templates/cycode-scan.yml@security-templates +# parameters: { ... } +# +# App teams customize only the parameters — the scan logic, publishing, and +# gate behavior all live in the template and evolve centrally. +trigger: none +pr: none + +extends: + template: templates/cycode-scan.yml + parameters: + scanPath: "./vulnerable_apps/" + scanTypeFlags: "-t sast" + severityThreshold: "high" + repoName: "vectorvictor" # bare repo name (not "owner/repo") + gateMode: "both" # defense in depth: CLI + API + publishResults: true + poolName: "Default" diff --git a/scripts/cycode-gate.sh b/scripts/cycode-gate.sh new file mode 100755 index 0000000..828d36a --- /dev/null +++ b/scripts/cycode-gate.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Cycode API gate — fail the build if Open violations exist for $REPO_NAME. +# +# Required env: +# CYCODE_CLIENT_ID, CYCODE_CLIENT_SECRET +# REPO_NAME — BARE repo name as stored in Cycode's RIG (e.g. "vectorvictor", +# NOT "AppSecHQ/vectorvictor"). Check the Violations UI to confirm. +# +# Optional env (any combination): +# SEVERITY_MIN Critical | High | Medium | Low (inclusive threshold) +# CATEGORY SAST | SCA | Secrets | IaC | ContainerScanning +# RISK_SCORE_MIN 0–100 +# +# Exit codes: +# 0 no Open violations matching the filters +# 1 one or more Open violations found → build should fail +# 2 invalid input +# 10 auth or API error +set -euo pipefail + +: "${CYCODE_CLIENT_ID:?CYCODE_CLIENT_ID is required}" +: "${CYCODE_CLIENT_SECRET:?CYCODE_CLIENT_SECRET is required}" +: "${REPO_NAME:?REPO_NAME is required (e.g. 'AppSecHQ/vectorvictor')}" + +SEVERITY_MIN="${SEVERITY_MIN:-}" +CATEGORY="${CATEGORY:-}" +RISK_SCORE_MIN="${RISK_SCORE_MIN:-}" + +API_BASE="https://api.cycode.com" + +echo "Cycode API gate: checking Open violations for ${REPO_NAME}" + +# --- Authenticate --------------------------------------------------------- +AUTH_RESP=$(curl -sS -X POST "${API_BASE}/api/v1/auth/api-token" \ + -H "Content-Type: application/json" \ + -d "{\"clientId\":\"${CYCODE_CLIENT_ID}\",\"secret\":\"${CYCODE_CLIENT_SECRET}\"}") || { + echo "##vso[task.logissue type=error]Auth request failed" + exit 10 +} + +TOKEN=$(jq -r '.token // empty' <<<"$AUTH_RESP") +if [[ -z "$TOKEN" ]]; then + echo "##vso[task.logissue type=error]Cycode authentication returned no token" + exit 10 +fi + +# --- Build filters -------------------------------------------------------- +FILTERS=$(jq -n --arg repo "$REPO_NAME" ' + [ + {name:"status", operator:"Eq", value:"Open", type:"String"}, + {name:"detection_details.repository_name", operator:"Eq", value:$repo, type:"String"} + ]') + +if [[ -n "$SEVERITY_MIN" ]]; then + case "$SEVERITY_MIN" in + Critical|critical) SEVS="Critical" ;; + High|high) SEVS="Critical,High" ;; + Medium|medium) SEVS="Critical,High,Medium" ;; + Low|low) SEVS="Critical,High,Medium,Low" ;; + *) echo "Invalid SEVERITY_MIN='$SEVERITY_MIN'"; exit 2 ;; + esac + FILTERS=$(jq --arg v "$SEVS" '. + [{name:"severity", operator:"In", value:$v, type:"String"}]' <<<"$FILTERS") +fi + +if [[ -n "$CATEGORY" ]]; then + FILTERS=$(jq --arg v "$CATEGORY" '. + [{name:"category", operator:"Eq", value:$v, type:"String"}]' <<<"$FILTERS") +fi + +if [[ -n "$RISK_SCORE_MIN" ]]; then + FILTERS=$(jq --arg v "$RISK_SCORE_MIN" '. + [{name:"risk_score", operator:"Gte", value:$v, type:"Numeric"}]' <<<"$FILTERS") +fi + +BODY=$(jq -n --argjson filters "$FILTERS" '{ + resource_type: "detection", + filters: [{mode:"And", filters: $filters}], + sort_by: "risk_score", + sort_order: "desc", + limit: -1, + fast_query: true, + connections: [], exists: true, is_optional: false, edge_type: "", + variables: [], edge_filters: [], edge_columns: [], + parent_resource_type: "", optional_connections_minimum_count: 0 +}') + +# --- Query RIG ------------------------------------------------------------ +RESPONSE=$(curl -sS -X POST \ + "${API_BASE}/graph/api/v1/graph/query?mode=AlertWhen&page_number=0&page_size=200" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$BODY") || { + echo "##vso[task.logissue type=error]RIG query failed" + exit 10 +} + +if ! jq -e '.result' >/dev/null 2>&1 <<<"$RESPONSE"; then + echo "##vso[task.logissue type=error]Unexpected API response" + head -c 500 <<<"$RESPONSE"; echo + exit 10 +fi + +COUNT=$(jq '.result | length' <<<"$RESPONSE") +HAS_MORE=$(jq -r '.fast_query_has_more // false' <<<"$RESPONSE") +if [[ "$HAS_MORE" == "true" ]]; then + COUNT_LABEL="at least ${COUNT} (page cap hit)" +else + COUNT_LABEL="${COUNT}" +fi +echo "Open violations matching filters for ${REPO_NAME}: ${COUNT_LABEL}" + +# --- Decision ------------------------------------------------------------- +if (( COUNT > 0 )); then + echo + echo "Top findings:" + # Each .result[] item wraps the detection in a .resource object. + jq -r '.result[] | .resource | " [\(.severity // "-") / risk \(.risk_score // "-")] \(.source_policy_name // "-") — \(.detection_details.file_path // .detection_details.package_name // .source_entity_name // "-"):\(.detection_details.line // "")"' \ + <<<"$RESPONSE" | head -20 + echo + echo "##vso[task.logissue type=error]Cycode gate failed: ${COUNT_LABEL} Open violation(s) in ${REPO_NAME}" + exit 1 +fi + +echo "Cycode gate passed: no Open violations matched the filters" diff --git a/scripts/cycode-json-to-junit.py b/scripts/cycode-json-to-junit.py new file mode 100755 index 0000000..9ad7ee7 --- /dev/null +++ b/scripts/cycode-json-to-junit.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Convert Cycode CLI JSON output (cycode -o json scan ...) to JUnit XML. + +Azure Pipelines renders JUnit via PublishTestResults@2 on the Tests tab. +Each Cycode finding becomes a failed testcase so reviewers can drill into +individual violations by severity and file. + +Usage: + cycode -o json scan -t sast path ./src > cycode.json + cycode-json-to-junit.py cycode.json cycode-junit.xml +""" +from __future__ import annotations + +import json +import sys +from xml.sax.saxutils import escape + + +def extract_detections(data): + detections = [] + if isinstance(data, dict): + for block in data.get("scan_results", []) or []: + detections.extend(block.get("detections", []) or []) + detections.extend(data.get("detections", []) or []) + elif isinstance(data, list): + detections = list(data) + return detections + + +def detection_fields(d): + dd = d.get("detection_details") or {} + severity = d.get("severity") or "UNKNOWN" + path = dd.get("file_path") or d.get("file_path") or "unknown" + line = dd.get("line") or d.get("line") or "" + policy = ( + d.get("policy_display_name") + or d.get("detection_rule_id") + or d.get("policy_id") + or "Cycode" + ) + message = d.get("message") or policy + return severity, policy, path, line, message + + +def main(src: str, dst: str) -> int: + with open(src) as f: + data = json.load(f) + + detections = extract_detections(data) + total = len(detections) + + out = [''] + out.append( + f'' + ) + + for d in detections: + severity, policy, path, line, message = detection_fields(d) + classname = f"{severity}.{policy}" + name = f"{path}:{line}" if line else path + detail = json.dumps(d, indent=2, default=str) + cdata = detail.replace("]]>", "]]]]>") + out.append(f' ') + out.append( + f' ' + ) + out.append(" ") + + out.append("") + + with open(dst, "w") as f: + f.write("\n".join(out)) + + print(f"Wrote {total} findings to {dst}") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: cycode-json-to-junit.py ", file=sys.stderr) + sys.exit(2) + sys.exit(main(sys.argv[1], sys.argv[2])) diff --git a/scripts/cycode-summary.py b/scripts/cycode-summary.py new file mode 100755 index 0000000..8f8c73e --- /dev/null +++ b/scripts/cycode-summary.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Generate a short Markdown summary of a Cycode JSON scan result. + +The output is used with `##vso[task.uploadsummary]` so the summary appears +as a tab on the Azure Pipelines build summary page. + +Usage: + cycode -o json scan -t sast path ./src > cycode.json + cycode-summary.py cycode.json > cycode-summary.md +""" +from __future__ import annotations + +import json +import sys + + +def extract_detections(data): + detections = [] + if isinstance(data, dict): + for block in data.get("scan_results", []) or []: + detections.extend(block.get("detections", []) or []) + detections.extend(data.get("detections", []) or []) + elif isinstance(data, list): + detections = list(data) + return detections + + +def main(src: str) -> int: + with open(src) as f: + data = json.load(f) + + detections = extract_detections(data) + counts: dict[str, int] = {} + for d in detections: + sev = d.get("severity") or "Unknown" + counts[sev] = counts.get(sev, 0) + 1 + + lines = [] + lines.append("## Cycode Scan Summary") + lines.append("") + lines.append(f"**Total findings:** {len(detections)}") + lines.append("") + lines.append("| Severity | Count |") + lines.append("|---|---|") + for sev in ["Critical", "High", "Medium", "Low", "Info", "Unknown"]: + if counts.get(sev): + lines.append(f"| {sev} | {counts[sev]} |") + lines.append("") + + if detections: + lines.append("### Top findings") + for d in detections[:10]: + dd = d.get("detection_details") or {} + path = dd.get("file_path") or d.get("file_path") or "?" + line = dd.get("line") or d.get("line") or "" + msg = (d.get("message") or d.get("detection_rule_id") or "")[:120] + loc = f"`{path}:{line}`" if line else f"`{path}`" + lines.append(f"- **[{d.get('severity', '?')}]** {msg} — {loc}") + + print("\n".join(lines)) + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: cycode-summary.py ", file=sys.stderr) + sys.exit(2) + sys.exit(main(sys.argv[1])) diff --git a/templates/cycode-scan.yml b/templates/cycode-scan.yml new file mode 100644 index 0000000..9afbf64 --- /dev/null +++ b/templates/cycode-scan.yml @@ -0,0 +1,135 @@ +# Centralized Cycode scan template. +# +# Single source of truth for the Cycode CI/CD gate across many pipelines. +# Consumers `extends:` this template and pass app-specific parameters. +# +# Runs a scan in JSON mode, publishes results to the Tests tab + build summary +# tab + artifact, then enforces a pass/fail gate via the CLI, the API, or both. +# +# Cross-repo usage pattern (production): +# resources: +# repositories: +# - repository: security-templates +# type: git +# name: SecurityTeam/cycode-pipeline-templates +# ref: refs/tags/v1 +# extends: +# template: templates/cycode-scan.yml@security-templates +# parameters: { ... } +# +# Single-repo usage (this demo): see ../azure-pipelines-template-consumer.yml + +parameters: + - name: scanPath + type: string + default: "./" + - name: scanTypeFlags + type: string + default: "-t sast" # e.g. "-t sast -t sca -t secret" + - name: severityThreshold + type: string + default: "info" # info | low | medium | high | critical + - name: repoName + type: string # required — repo name as shown in Cycode + - name: gateMode + type: string + default: "cli" # cli | api | both | none + - name: publishResults + type: boolean + default: true + - name: poolName + type: string + default: "Default" # self-hosted pool; use 'ubuntu-latest' for MS-hosted + - name: pythonVersion + type: string + default: "3.12" + +stages: + - stage: Security + displayName: "Security — Cycode" + jobs: + - job: CycodeScan + displayName: "Cycode scan + gate" + pool: + name: ${{ parameters.poolName }} + steps: + - checkout: self + fetchDepth: 0 + + - task: UsePythonVersion@0 + displayName: "Use Python ${{ parameters.pythonVersion }}" + inputs: + versionSpec: ${{ parameters.pythonVersion }} + + - script: | + set -e + python3 -m pip install --upgrade pip + pip install cycode + if ! command -v jq >/dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq jq || true + fi + displayName: "Install Cycode CLI + jq" + + # ---- Scan (soft-fail so we can publish results, then gate) ------ + - script: | + set +e + cycode -o json scan --soft-fail \ + ${{ parameters.scanTypeFlags }} \ + --severity-threshold ${{ parameters.severityThreshold }} \ + path ${{ parameters.scanPath }} > cycode-results.json + echo "scan exit: $?" + ls -la cycode-results.json + displayName: "Cycode scan (JSON)" + env: + CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID) + CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET) + + # ---- Publish results -------------------------------------------- + - ${{ if eq(parameters.publishResults, true) }}: + - script: python3 scripts/cycode-json-to-junit.py cycode-results.json cycode-junit.xml + displayName: "Convert JSON → JUnit" + condition: succeededOrFailed() + + - task: PublishTestResults@2 + displayName: "Publish to Tests tab" + condition: succeededOrFailed() + inputs: + testResultsFormat: "JUnit" + testResultsFiles: "cycode-junit.xml" + testRunTitle: "Cycode" + mergeTestResults: true + failTaskOnFailedTests: false + + - script: | + python3 scripts/cycode-summary.py cycode-results.json > cycode-summary.md + echo "##vso[task.uploadsummary]$(System.DefaultWorkingDirectory)/cycode-summary.md" + displayName: "Publish custom summary tab" + condition: succeededOrFailed() + + - task: PublishBuildArtifacts@1 + displayName: "Publish raw Cycode JSON" + condition: succeededOrFailed() + inputs: + pathToPublish: "cycode-results.json" + artifactName: "cycode-report" + + # ---- Gates ------------------------------------------------------ + - ${{ if or(eq(parameters.gateMode, 'cli'), eq(parameters.gateMode, 'both')) }}: + - script: | + cycode scan \ + ${{ parameters.scanTypeFlags }} \ + --severity-threshold ${{ parameters.severityThreshold }} \ + path ${{ parameters.scanPath }} + displayName: "Gate: CLI scan (fails on findings)" + env: + CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID) + CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET) + + - ${{ if or(eq(parameters.gateMode, 'api'), eq(parameters.gateMode, 'both')) }}: + - script: bash scripts/cycode-gate.sh + displayName: "Gate: API (fails on Open violations)" + env: + CYCODE_CLIENT_ID: $(CYCODE_CLIENT_ID) + CYCODE_CLIENT_SECRET: $(CYCODE_CLIENT_SECRET) + REPO_NAME: ${{ parameters.repoName }} + SEVERITY_MIN: ${{ parameters.severityThreshold }}