released-cve-audit #41
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
| name: released-cve-audit | |
| on: | |
| schedule: | |
| # Run after the nightly release window so newly published CVEs are noticed daily. | |
| - cron: "15 19 * * *" | |
| workflow_dispatch: | |
| inputs: | |
| release_count: | |
| description: "Number of latest channel releases to scan when release_sequences is blank" | |
| required: false | |
| type: string | |
| default: "3" | |
| channel: | |
| description: "Replicated channel to audit" | |
| required: false | |
| type: string | |
| default: "Stable" | |
| release_sequences: | |
| description: "Optional comma-separated Replicated release sequences to scan instead of resolving by channel" | |
| required: false | |
| type: string | |
| default: "" | |
| notify_slack: | |
| description: "Send the final audit summary to Slack" | |
| required: false | |
| type: boolean | |
| default: true | |
| permissions: | |
| contents: read | |
| env: | |
| AWS_REGION: us-east-1 | |
| ECR_REGISTRY: 008971668139.dkr.ecr.us-east-1.amazonaws.com | |
| GRYPE_VERSION: v0.115.0 | |
| OPRA_CI_DOPPLER_IDENTITY_ID: fc94b8db-1149-4b8a-8ba7-1fd4833bb870 | |
| SLACK_IMPLEMENTATIONS_USERGROUP_ID: S092C0T771V | |
| jobs: | |
| audit: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 180 | |
| permissions: | |
| id-token: write | |
| contents: read | |
| steps: | |
| - name: Fetch audit secrets from Doppler | |
| id: doppler | |
| uses: dopplerhq/secrets-fetch-action@451892f16195f9ac360e1a5bcbf0b5fd0e957534 | |
| with: | |
| auth-method: oidc | |
| doppler-identity-id: ${{ env.OPRA_CI_DOPPLER_IDENTITY_ID }} | |
| doppler-project: opra-ci | |
| doppler-config: prd | |
| inject-env-vars: true | |
| - name: Validate audit secrets | |
| shell: bash | |
| env: | |
| NOTIFY_SLACK: ${{ github.event_name != 'workflow_dispatch' || inputs.notify_slack }} | |
| run: | | |
| set -euo pipefail | |
| missing=() | |
| for name in REPLICATED_API_TOKEN REPLICATED_APP; do | |
| if [[ -z "${!name:-}" ]]; then | |
| missing+=("${name}") | |
| fi | |
| done | |
| if [[ "${NOTIFY_SLACK}" == "true" && -z "${NIGHTLY_SLACK_WEBHOOK_URL:-}" ]]; then | |
| missing+=("NIGHTLY_SLACK_WEBHOOK_URL") | |
| fi | |
| if (( ${#missing[@]} > 0 )); then | |
| printf 'Missing required Doppler secret(s): %s\n' "${missing[*]}" >&2 | |
| exit 1 | |
| fi | |
| - name: Checkout CVE audit scripts | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 | |
| with: | |
| sparse-checkout: | | |
| .github/scripts/cve | |
| sparse-checkout-cone-mode: false | |
| persist-credentials: false | |
| - name: Install Helm | |
| uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 | |
| with: | |
| version: 'latest' | |
| - name: Install yq | |
| uses: mikefarah/yq@1b9b4ac5187171d2e5e3129be0cfa827c7f9d53d | |
| - name: Configure AWS credentials | |
| uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a | |
| with: | |
| role-to-assume: arn:aws:iam::008971668139:role/github-actions-helm-charts-ecr | |
| aws-region: ${{ env.AWS_REGION }} | |
| - name: Login to ECR for Grype registry scans | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| aws ecr get-login-password --region "${AWS_REGION}" \ | |
| | docker login --username AWS --password-stdin "${ECR_REGISTRY}" | |
| - name: Install Grype | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| curl -sSfL https://get.anchore.io/grype -o /tmp/install-grype.sh | |
| sudo sh /tmp/install-grype.sh -b /usr/local/bin "${GRYPE_VERSION}" | |
| grype version | |
| - name: Install Replicated CLI | |
| shell: bash | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| curl -fsSL \ | |
| -H "Authorization: Bearer ${GITHUB_TOKEN}" \ | |
| -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| https://api.github.com/repos/replicatedhq/replicated/releases/latest \ | |
| -o replicated-release.json | |
| version="$(jq -r '.tag_name | sub("^v"; "")' replicated-release.json)" | |
| curl -fsSL \ | |
| "https://github.com/replicatedhq/replicated/releases/download/v${version}/replicated_${version}_linux_amd64.tar.gz" \ | |
| -o replicated.tar.gz | |
| tar xf replicated.tar.gz replicated | |
| sudo install replicated /usr/local/bin/replicated | |
| - name: Resolve releases to audit | |
| id: releases | |
| shell: bash | |
| env: | |
| CHANNEL: ${{ inputs.channel || 'Stable' }} | |
| RELEASE_COUNT: ${{ inputs.release_count || '3' }} | |
| RELEASE_SEQUENCES: ${{ inputs.release_sequences || '' }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p audit-reports | |
| if ! [[ "${RELEASE_COUNT}" =~ ^[0-9]+$ ]]; then | |
| echo "release_count must be a positive integer" >&2 | |
| exit 1 | |
| fi | |
| if [[ -n "${RELEASE_SEQUENCES//[[:space:],]/}" ]]; then | |
| printf '%s\n' "${RELEASE_SEQUENCES}" \ | |
| | tr ',' '\n' \ | |
| | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \ | |
| | grep -E '^[0-9]+$' \ | |
| | jq -R -s 'split("\n") | map(select(. != "")) | map({sequence: (tonumber), channels: ["manual"], created_at: "", chart_version: ""})' \ | |
| > audit-reports/releases.json | |
| else | |
| replicated api get /v3/apps \ | |
| --app "${REPLICATED_APP}" \ | |
| --token "${REPLICATED_API_TOKEN}" \ | |
| > audit-reports/replicated-apps.raw.json | |
| sed -n '/^{/,$p' audit-reports/replicated-apps.raw.json > audit-reports/replicated-apps.json | |
| app_id="$( | |
| jq -r \ | |
| --arg app "${REPLICATED_APP}" \ | |
| '.apps[]? | |
| | select(.slug == $app or .id == $app or .name == $app) | |
| | .id' \ | |
| audit-reports/replicated-apps.json \ | |
| | head -n1 | |
| )" | |
| channel_id="$( | |
| jq -r \ | |
| --arg app "${REPLICATED_APP}" \ | |
| --arg channel "${CHANNEL}" \ | |
| '.apps[]? | |
| | select(.slug == $app or .id == $app or .name == $app) | |
| | .channels[]? | |
| | select(.name == $channel or .channelSlug == ($channel | ascii_downcase) or .id == $channel) | |
| | .id' \ | |
| audit-reports/replicated-apps.json \ | |
| | head -n1 | |
| )" | |
| if [[ -z "${app_id}" || -z "${channel_id}" ]]; then | |
| echo "Could not resolve Replicated app/channel for app=${REPLICATED_APP} channel=${CHANNEL}" >&2 | |
| exit 1 | |
| fi | |
| replicated api get "/v3/app/${app_id}/channel/${channel_id}/releases" \ | |
| --app "${REPLICATED_APP}" \ | |
| --token "${REPLICATED_API_TOKEN}" \ | |
| > audit-reports/replicated-channel-releases.raw.json | |
| sed -n '/^{/,$p' audit-reports/replicated-channel-releases.raw.json > audit-reports/replicated-channel-releases.json | |
| jq \ | |
| --arg channel "${CHANNEL}" \ | |
| --argjson limit "${RELEASE_COUNT}" \ | |
| '(.releases // []) | |
| | sort_by(.channelSequence // 0) | |
| | reverse | |
| | .[:$limit] | |
| | map({ | |
| sequence, | |
| created_at: (.releasedAt // .created // .createdAt // ""), | |
| channels: [(.channelName // $channel)], | |
| chart_version: ((.chartReleases[]? | select(.name == "composio") | .version) // .semver // .version // ""), | |
| channel_sequence: (.channelSequence // null) | |
| })' \ | |
| audit-reports/replicated-channel-releases.json \ | |
| > audit-reports/releases.json | |
| fi | |
| jq empty audit-reports/releases.json | |
| release_total="$(jq 'length' audit-reports/releases.json)" | |
| if (( release_total == 0 )); then | |
| echo "No releases resolved for CVE audit." >&2 | |
| exit 1 | |
| fi | |
| echo "Resolved releases:" | |
| jq -r '.[] | "- sequence \(.sequence) channel-sequence=\(.channel_sequence // "manual") channels=\(.channels | join(",")) chart=\(.chart_version // "unknown")"' audit-reports/releases.json | |
| - name: Scan released charts | |
| id: audit | |
| shell: bash | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| CHANNEL: ${{ inputs.channel || 'Stable' }} | |
| run: | | |
| set -euo pipefail | |
| scan_started_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" | |
| artifact_name="released-grype-cve-audit-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| artifact_download_command="gh run download ${GITHUB_RUN_ID} --repo ${GITHUB_REPOSITORY} --name ${artifact_name} --dir ./released-cve-audit" | |
| echo '[]' > audit-reports/release-results.json | |
| mapfile -t releases < <(jq -c '.[]' audit-reports/releases.json) | |
| for release in "${releases[@]}"; do | |
| sequence="$(jq -r '.sequence' <<<"${release}")" | |
| created_at="$(jq -r '.created_at // ""' <<<"${release}")" | |
| release_dir="audit-reports/release-${sequence}" | |
| mkdir -p "${release_dir}/chart" "${release_dir}/baseline" | |
| echo "Inspecting Replicated release sequence ${sequence}" | |
| replicated release inspect "${sequence}" \ | |
| --output json \ | |
| --app "${REPLICATED_APP}" \ | |
| --token "${REPLICATED_API_TOKEN}" \ | |
| > "${release_dir}/replicated-release.raw.json" | |
| sed -n '/^{/,$p' "${release_dir}/replicated-release.raw.json" > "${release_dir}/replicated-release.json" | |
| chart_version="$( | |
| jq -r '(.charts[]? | select(.name == "composio") | .version) // ""' "${release_dir}/replicated-release.json" | |
| )" | |
| chart_content="$( | |
| jq -r 'first(.config | fromjson[] | select(.name | test("^composio-[^/]+[.]tgz$")) | .content) // ""' \ | |
| "${release_dir}/replicated-release.json" | |
| )" | |
| if [[ -z "${chart_content}" || "${chart_content}" == "null" ]]; then | |
| jq -n \ | |
| --arg sequence "${sequence}" \ | |
| --arg created_at "${created_at}" \ | |
| --arg chart_version "${chart_version}" \ | |
| --arg error "Replicated release did not include a composio chart archive" \ | |
| '{ | |
| scanResult: "failed", | |
| totalHigh: 0, | |
| totalCritical: 0, | |
| fixedNow: 0, | |
| fixedAtReleaseScan: 0, | |
| newlyDetected: 0, | |
| findings: [], | |
| imageStats: [], | |
| release_sequence: ($sequence | tonumber), | |
| release_created_at: $created_at, | |
| release_chart_version: $chart_version, | |
| detected_release_tag: "", | |
| baseline_source: "missing", | |
| error: $error | |
| }' > "${release_dir}/report-result.json" | |
| else | |
| printf '%s' "${chart_content}" | base64 -d > "${release_dir}/composio.tgz" | |
| tar -xzf "${release_dir}/composio.tgz" -C "${release_dir}/chart" | |
| chart_root="$(find "${release_dir}/chart" -mindepth 1 -maxdepth 1 -type d | head -n1)" | |
| CHART_DIR="${chart_root}" \ | |
| OUTPUT_DIR="${release_dir}" \ | |
| OVERRIDE_IMAGE_TAGS=0 \ | |
| RENDER_TEMPORAL=1 \ | |
| .github/scripts/cve/render-scan-targets.sh | |
| detected_release_tag="$( | |
| jq -r \ | |
| --arg prefix "${ECR_REGISTRY}/composio-self-host/" \ | |
| '[.[]?.scan_image | |
| | select(startswith($prefix)) | |
| | capture(":(?<tag>[^:@]+)$").tag] | |
| | group_by(.) | |
| | sort_by(length) | |
| | reverse | |
| | .[0][0] // ""' \ | |
| "${release_dir}/scan-targets.json" | |
| )" | |
| baseline_file="" | |
| baseline_source="missing" | |
| for candidate in "${detected_release_tag}" "${chart_version}" "v${chart_version}"; do | |
| if [[ -z "${candidate}" || "${candidate}" == "v" ]]; then | |
| continue | |
| fi | |
| rm -f "${release_dir}/baseline/release-cve-baseline.json" | |
| if gh release download "${candidate}" \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --pattern release-cve-baseline.json \ | |
| --dir "${release_dir}/baseline" \ | |
| >/dev/null 2>&1; then | |
| baseline_file="${release_dir}/baseline/release-cve-baseline.json" | |
| baseline_source="${candidate}" | |
| break | |
| fi | |
| done | |
| OUTPUT_DIR="${release_dir}" .github/scripts/cve/run-grype-scan.sh | |
| baseline_args=() | |
| if [[ -n "${baseline_file}" ]]; then | |
| baseline_args=(--baseline-findings "${baseline_file}") | |
| fi | |
| node .github/scripts/cve/build-cve-report.mjs \ | |
| --reports-dir "${release_dir}" \ | |
| --scan-targets "${release_dir}/scan-targets.json" \ | |
| --output-dir "${release_dir}" \ | |
| --release-tag "${detected_release_tag:-replicated-${sequence}}" \ | |
| --release-version "${chart_version:-replicated-sequence-${sequence}}" \ | |
| --scan-started-at "${scan_started_at}" \ | |
| "${baseline_args[@]}" \ | |
| > "${release_dir}/report-result.raw.json" | |
| jq \ | |
| --arg sequence "${sequence}" \ | |
| --arg created_at "${created_at}" \ | |
| --arg chart_version "${chart_version}" \ | |
| --arg detected_release_tag "${detected_release_tag}" \ | |
| --arg baseline_source "${baseline_source}" \ | |
| '. + { | |
| release_sequence: ($sequence | tonumber), | |
| release_created_at: $created_at, | |
| release_chart_version: $chart_version, | |
| detected_release_tag: $detected_release_tag, | |
| baseline_source: $baseline_source | |
| }' \ | |
| "${release_dir}/report-result.raw.json" \ | |
| > "${release_dir}/report-result.json" | |
| fi | |
| jq -c -s '.[0] + [.[1]]' \ | |
| audit-reports/release-results.json \ | |
| "${release_dir}/report-result.json" \ | |
| > audit-reports/release-results.tmp.json | |
| mv audit-reports/release-results.tmp.json audit-reports/release-results.json | |
| done | |
| node .github/scripts/cve/build-released-cve-audit.mjs \ | |
| --release-results audit-reports/release-results.json \ | |
| --output-dir audit-reports \ | |
| --channel "${CHANNEL}" \ | |
| --scan-started-at "${scan_started_at}" \ | |
| --artifact-name "${artifact_name}" \ | |
| --artifact-download-command "${artifact_download_command}" \ | |
| --slack-usergroup-id "${SLACK_IMPLEMENTATIONS_USERGROUP_ID}" | |
| cat audit-reports/summary.md >> "${GITHUB_STEP_SUMMARY}" | |
| status="$(jq -r '.status' audit-reports/released-cve-audit-result.json)" | |
| title="$(jq -r '.slackTitle' audit-reports/released-cve-audit-result.json)" | |
| { | |
| echo "status=${status}" | |
| echo "title=${title}" | |
| echo "artifact_name=${artifact_name}" | |
| echo "artifact_download_command=${artifact_download_command}" | |
| echo "summary<<__AUDIT_SUMMARY__" | |
| cat audit-reports/slack-summary.md | |
| echo "__AUDIT_SUMMARY__" | |
| } >> "${GITHUB_OUTPUT}" | |
| - name: Build released CVE audit Slack alert | |
| id: alert | |
| if: always() | |
| shell: bash | |
| env: | |
| CHANNEL: ${{ inputs.channel || 'Stable' }} | |
| RELEASE_COUNT: ${{ inputs.release_count || '3' }} | |
| RELEASE_SEQUENCES: ${{ inputs.release_sequences || '' }} | |
| RELEASES_OUTCOME: ${{ steps.releases.outcome }} | |
| AUDIT_OUTCOME: ${{ steps.audit.outcome }} | |
| ARTIFACT_NAME: ${{ steps.audit.outputs.artifact_name || format('released-grype-cve-audit-{0}-{1}', github.run_id, github.run_attempt) }} | |
| ARTIFACT_DOWNLOAD_COMMAND: ${{ steps.audit.outputs.artifact_download_command || format('gh run download {0} --repo {1} --name released-grype-cve-audit-{0}-{2} --dir ./released-cve-audit', github.run_id, github.repository, github.run_attempt) }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p audit-reports | |
| if [[ -f .github/scripts/cve/build-released-cve-audit-alert.mjs ]]; then | |
| node .github/scripts/cve/build-released-cve-audit-alert.mjs \ | |
| --output-dir audit-reports \ | |
| --channel "${CHANNEL}" \ | |
| --release-count "${RELEASE_COUNT}" \ | |
| --release-sequences "${RELEASE_SEQUENCES}" \ | |
| --releases-outcome "${RELEASES_OUTCOME}" \ | |
| --audit-outcome "${AUDIT_OUTCOME}" \ | |
| --artifact-name "${ARTIFACT_NAME}" \ | |
| --artifact-download-command "${ARTIFACT_DOWNLOAD_COMMAND}" \ | |
| --run-url "${RUN_URL}" \ | |
| --slack-usergroup-id "${SLACK_IMPLEMENTATIONS_USERGROUP_ID}" | |
| else | |
| if [[ -n "${RELEASE_SEQUENCES}" ]]; then | |
| requested="explicit Replicated release sequence(s): \`${RELEASE_SEQUENCES}\`" | |
| else | |
| requested="channel \`${CHANNEL}\`, latest ${RELEASE_COUNT} release(s)" | |
| fi | |
| jq -n \ | |
| --arg usergroup "${SLACK_IMPLEMENTATIONS_USERGROUP_ID}" \ | |
| --arg requested "${requested}" \ | |
| --arg releases_outcome "${RELEASES_OUTCOME}" \ | |
| --arg audit_outcome "${AUDIT_OUTCOME}" \ | |
| --arg artifact_name "${ARTIFACT_NAME}" \ | |
| --arg artifact_download_command "${ARTIFACT_DOWNLOAD_COMMAND}" \ | |
| --arg run_url "${RUN_URL}" \ | |
| '{ | |
| status: "FAILURE", | |
| title: ":rotating_light: Released CVE audit: WORKFLOW FAILED", | |
| summary: ( | |
| (if $usergroup == "" then "" else "<!subteam^\($usergroup)|@implementations>\n" end) + | |
| "*Good to go:* NO - released CVE audit did not complete\n" + | |
| "*Scope:* Already released channel versions only; independent of the nightly release workflow.\n" + | |
| "*Release blocking:* NO - this audit is alert-only.\n" + | |
| "*Requested audit:* \($requested)\n" + | |
| "*Findings:* UNKNOWN - the audit failed before producing the CVE summary.\n" + | |
| "*Stages:*\n" + | |
| "- Release resolution: `\($releases_outcome)`\n" + | |
| "- Audit scan/report: `\($audit_outcome)`\n" + | |
| "*Artifact:* `\($artifact_name)`\n" + | |
| "*Download partial reports, if any were uploaded:*\n" + | |
| "```\n\($artifact_download_command)\n```\n" + | |
| "*Workflow:* <\($run_url)|released CVE audit run>\n" | |
| ) | |
| }' > audit-reports/released-cve-audit-alert.json | |
| jq -r '.summary' audit-reports/released-cve-audit-alert.json > audit-reports/released-cve-audit-alert-summary.md | |
| fi | |
| status="$(jq -r '.status' audit-reports/released-cve-audit-alert.json)" | |
| title="$(jq -r '.title' audit-reports/released-cve-audit-alert.json)" | |
| { | |
| echo "status=${status}" | |
| echo "title=${title}" | |
| echo "artifact_name=${ARTIFACT_NAME}" | |
| echo "artifact_download_command=${ARTIFACT_DOWNLOAD_COMMAND}" | |
| echo "summary<<__AUDIT_ALERT_SUMMARY__" | |
| jq -r '.summary' audit-reports/released-cve-audit-alert.json | |
| echo "__AUDIT_ALERT_SUMMARY__" | |
| } >> "${GITHUB_OUTPUT}" | |
| - name: Upload released CVE audit reports | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 | |
| with: | |
| name: ${{ steps.alert.outputs.artifact_name || steps.audit.outputs.artifact_name || format('released-grype-cve-audit-{0}-{1}', github.run_id, github.run_attempt) }} | |
| if-no-files-found: warn | |
| path: audit-reports | |
| - name: Announce released CVE audit in Slack | |
| if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.notify_slack) && steps.alert.outputs.summary != '' && steps.doppler.outputs.NIGHTLY_SLACK_WEBHOOK_URL != '' }} | |
| shell: bash | |
| env: | |
| INPUT_STATUS: ${{ steps.alert.outputs.status }} | |
| INPUT_TITLE: ${{ steps.alert.outputs.title }} | |
| INPUT_SUMMARY: ${{ steps.alert.outputs.summary }} | |
| SLACK_WEBHOOK_URL: ${{ steps.doppler.outputs.NIGHTLY_SLACK_WEBHOOK_URL }} | |
| run: | | |
| set -euo pipefail | |
| case "${INPUT_STATUS}" in | |
| SUCCESS) color="#28a745" ;; | |
| FAILURE) color="#cb2431" ;; | |
| *) color="#808080" ;; | |
| esac | |
| payload="$( | |
| jq -n \ | |
| --arg color "${color}" \ | |
| --arg title "${INPUT_TITLE}" \ | |
| --arg summary "${INPUT_SUMMARY}" \ | |
| ' | |
| def chunks($text; $size): | |
| [range(0; ($text | length); $size) as $start | $text[$start:($start + $size)]] | |
| | map(select(length > 0)); | |
| { | |
| text: ($title + "\n" + $summary[0:3000]), | |
| attachments: [{ | |
| color: $color, | |
| blocks: ([ | |
| { | |
| type: "section", | |
| text: { type: "mrkdwn", text: ("*" + $title + "*") } | |
| } | |
| ] + (chunks($summary; 2900) | .[:49] | map({ | |
| type: "section", | |
| text: { type: "mrkdwn", text: . } | |
| }))) | |
| }], | |
| unfurl_links: false, | |
| unfurl_media: false | |
| }' | |
| )" | |
| response="$( | |
| curl -fsS -X POST "${SLACK_WEBHOOK_URL}" \ | |
| -H "Content-Type: application/json" \ | |
| -d "${payload}" | |
| )" | |
| if [[ "${response}" != "ok" ]]; then | |
| echo "::error::Slack webhook returned unexpected response: ${response}" >&2 | |
| exit 1 | |
| fi |