diff --git a/.github/actions/trivy-fs-scan/action.yaml b/.github/actions/trivy-fs-scan/action.yaml new file mode 100644 index 0000000..c8bdf2c --- /dev/null +++ b/.github/actions/trivy-fs-scan/action.yaml @@ -0,0 +1,143 @@ +name: Trivy fs Scan +description: Performs Trivy security scanning for filesystems with comprehensive reporting + +inputs: + filesystem-ref: + description: 'Filesystem reference to scan (e.g., /path/to/filesystem)' + required: true + severity: + description: 'Comma-separated list of severity levels to report' + required: false + default: 'HIGH,CRITICAL,MEDIUM,LOW,UNKNOWN' + trivy-config: + description: 'Path to Trivy configuration file' + required: false + default: 'trivy.yaml' + artifact-name: + description: 'Name for the uploaded artifact' + required: false + default: 'trivy-fs-scan-results' + fail-on-critical-high: + description: 'Whether to fail the action on critical/high findings' + required: false + default: 'true' + ignore-unfixed: + description: 'Ignore unfixed vulnerabilities' + required: false + default: 'true' + +outputs: + critical-count: + description: 'Number of critical severity findings' + value: ${{ steps.report.outputs.crit }} + high-count: + description: 'Number of high severity findings' + value: ${{ steps.report.outputs.high }} + report-path: + description: 'Path to the generated markdown report' + value: 'trivy_fs_report.md' + +runs: + using: "composite" + steps: + - name: Check if Trivy config exists + id: trivy-config-check + shell: bash + run: | + if [[ -f "${{ inputs.trivy-config }}" ]]; then + echo "config-exists=true" >> "$GITHUB_OUTPUT" + echo "config-arg=${{ inputs.trivy-config }}" >> "$GITHUB_OUTPUT" + else + echo "config-exists=false" >> "$GITHUB_OUTPUT" + echo "config-arg=" >> "$GITHUB_OUTPUT" + fi + + - name: Trivy fs scan + uses: aquasecurity/trivy-action@0.28.0 + with: + scan-type: 'fs' + scan-ref: ${{ inputs.filesystem-ref }} + format: json + output: trivy-fs-scan.json + exit-code: 0 + ignore-unfixed: ${{ inputs.ignore-unfixed }} + severity: ${{ inputs.severity }} + trivy-config: ${{ steps.trivy-config-check.outputs.config-arg }} + + - name: Upload Trivy Scan Artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: trivy-fs-scan.json + + - name: Build summary & counts + id: report + shell: bash + run: | + # Filesystem scan report + jq -r ' + def clean: (.|tostring) | gsub("\\|";"\\|") | gsub("\r?\n";" "); + def sev: ["CRITICAL","HIGH","MEDIUM","LOW","UNKNOWN"]; + def counts: + ([.Results[]? | .Vulnerabilities[]? | .Severity // "UNKNOWN"] + | reduce .[] as $s ({CRITICAL:0,HIGH:0,MEDIUM:0,LOW:0,UNKNOWN:0}; .[$s]+=1)); + . as $root + | (counts) as $c + | + # ---- TOP BANNER (only if High/Critical present) ---- + ( + if (($c.CRITICAL + $c.HIGH) > 0) then + "🚫 **Trivy gate:** **\($c.CRITICAL) Critical**, **\($c.HIGH) High** vulnerability(s) found.\n\n" + else + "āœ… **Trivy gate:** no Critical/High vulnerabilities.\n\n" + end + ) + # ---- SUMMARY REPORT ---- + + "### Trivy Filesystem Scan Summary\n\n" + + "**Filesystem:** " + ($root.ArtifactName // "'"'"'${{ inputs.filesystem-ref }}'"'"'") + "\n\n" + + "| Severity | Count |\n|---|---|\n" + + (sev | map("| " + . + " | " + ($c[.]|tostring) + " |") | join("\n")) + + (if ([.Results[]? | .Vulnerabilities[]?] | length) == 0 + then "\n\nāœ… No vulnerabilities found.\n" + else + "\n\n
Findings (top 50)\n\n" + + "| Severity | ID | Package | Installed | Fixed | Source |\n|---|---|---|---|---|---|\n" + + ( + [ .Results[]? as $r + | $r.Vulnerabilities[]? + | "| \(.Severity) | \(.VulnerabilityID) | \(.PkgName) | \(.InstalledVersion) | \((.FixedVersion // "") | clean) | \(($r.Target) | clean) |" + ] | .[:50] | join("\n") + ) + + "\n\n
\n" + end) + ' trivy-fs-scan.json > trivy_fs_report.md + + # Extract counts for gating/other steps + read CRIT HIGH < <(jq -r ' + [.Results[]? | .Vulnerabilities[]? | .Severity // "UNKNOWN"] + | reduce .[] as $s ({CRITICAL:0,HIGH:0,MEDIUM:0,LOW:0,UNKNOWN:0}; .[$s]+=1) + | "\(.CRITICAL) \(.HIGH)" + ' trivy-fs-scan.json) + + echo "crit=$CRIT" >> "$GITHUB_OUTPUT" + echo "high=$HIGH" >> "$GITHUB_OUTPUT" + + - name: Publish Trivy Summary + if: always() + shell: bash + run: cat trivy_fs_report.md >> "$GITHUB_STEP_SUMMARY" + + - name: Update Trivy PR comment + if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork }} + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: ${{ inputs.artifact-name }} + path: trivy_fs_report.md + + - name: Check Trivy Issue Thresholds + if: ${{ inputs.fail-on-critical-high == 'true' && (steps.report.outputs.crit != '0' || steps.report.outputs.high != '0') }} + shell: bash + run: | + echo "Critical vulnerabilities detected: ${{ steps.report.outputs.crit }}" + echo "High vulnerabilities detected: ${{ steps.report.outputs.high }}" + exit 1 diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index ebd803b..6f945ef 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -122,7 +122,7 @@ jobs: if: github.event.action == 'closed' run: | FN="${{ steps.names.outputs.function_name }}" - echo "Deleting preview function: $FN" + echo "Deleting preview function: $FN" aws lambda delete-function --function-name "$FN" || true - name: Output function name @@ -130,6 +130,67 @@ jobs: echo "function = ${{ steps.names.outputs.function_name }}" echo "url = ${{ steps.names.outputs.preview_url }}" + # ---------- Wait on AWS tasks and notify ---------- + - name: Get mTLS certs for testing + if: github.event.action != 'closed' + id: mtls-certs + uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802 + with: + secret-ids: | + /cds/pathology/dev/mtls/client1-key-secret + /cds/pathology/dev/mtls/client1-key-public + name-transformation: lowercase + + - name: Smoke test preview URL + if: github.event.action != 'closed' + id: smoke-test + env: + PREVIEW_URL: ${{ steps.names.outputs.preview_url }} + run: | + if [ -z "$PREVIEW_URL" ] || [ "$PREVIEW_URL" = "null" ]; then + echo "Preview URL missing" + echo "http_status=missing" >> "$GITHUB_OUTPUT" + echo "http_result=missing-url" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Reachability check: allow 404 (app routes might not exist yet) but fail otherwise + printf '%s' "$_cds_pathology_dev_mtls_client1_key_secret" > /tmp/client1-key.pem + printf '%s' "$_cds_pathology_dev_mtls_client1_key_public" > /tmp/client1-cert.pem + STATUS=$(curl \ + --cert /tmp/client1-cert.pem \ + --key /tmp/client1-key.pem \ + --silent \ + --output /tmp/preview.headers \ + --write-out '%{http_code}' \ + --head \ + --max-time 30 "$PREVIEW_URL" || true) + rm -f /tmp/client1-key.pem + rm -f /tmp/client1-cert.pem + + if [ "$STATUS" = "404" ]; then + echo "Preview responded with expected 404" + echo "http_status=404" >> "$GITHUB_OUTPUT" + echo "http_result=allowed-404" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "$STATUS" =~ ^[0-9]{3}$ ]] && [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 400 ]; then + echo "Preview responded with status $STATUS" + echo "http_status=$STATUS" >> "$GITHUB_OUTPUT" + echo "http_result=success" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Preview responded with unexpected status $STATUS" + if [ -f /tmp/preview.headers ]; then + echo "Response headers:" + cat /tmp/preview.headers + fi + echo "http_status=$STATUS" >> "$GITHUB_OUTPUT" + echo "http_result=unexpected-status" >> "$GITHUB_OUTPUT" + exit 0 + - name: Comment function name on PR if: github.event_name == 'pull_request' && github.event.action != 'closed' uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd @@ -137,9 +198,67 @@ jobs: script: | const fn = '${{ steps.names.outputs.function_name }}'; const url = '${{ steps.names.outputs.preview_url }}'; + const owner = context.repo.owner; + const repo = context.repo.repo; + const issueNumber = context.issue.number; + const smokeStatus = '${{ steps.smoke-test.outputs.http_status }}' || 'n/a'; + const smokeResult = '${{ steps.smoke-test.outputs.http_result }}' || 'not-run'; + + const smokeLabels = { + success: ':white_check_mark: Passed', + 'allowed-404': ':white_check_mark: Allowed 404', + 'unexpected-status': ':x: Unexpected status', + 'missing-url': ':x: Missing URL', + }; + + const smokeReadable = smokeLabels[smokeResult] ?? smokeResult; + + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + + for (const comment of comments) { + const isBot = comment.user?.login === 'github-actions[bot]'; + const isPreviewUpdate = comment.body?.includes('Deployment Complete'); + + if (isBot && isPreviewUpdate) { + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id, + }); + } + } + + const lines = [ + '**Deployment Complete**', + `- Preview URL: [${url}](${url})`, + `- Smoke Test: ${smokeReadable} (HTTP ${smokeStatus})`, + `- Lambda Function: ${fn}`, + ]; + await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `Preview Lambda: \`${fn}\`\nPreview URL: ${url}` + owner, + repo, + issue_number: issueNumber, + body: lines.join('\n'), }); + + # ---------- Perform trivy scan and notify ---------- + - name: Prepare lambda artifact for trivy scan + if: github.event.action != 'closed' + run: | + cd infrastructure/environments/preview + rm -rf /tmp/artifact + mkdir -p /tmp/artifact + unzip -q artifact.zip -d /tmp/artifact + + - name: Trivy filesystem scan + if: github.event.action != 'closed' + uses: ./.github/actions/trivy-fs-scan + with: + filesystem-ref: /tmp/artifact + artifact-name: trivy-fs-scan-${{ steps.branch.outputs.safe }}