* Add .github/workflows/coinpay.yml via sh1pt coinpay-invoice@1.0.0 (#3) Co-authored-by: sh1pt-actions-fleet[bot] <287014002+sh1pt-actions-fleet[bot]@users.noreply.github.com> * Add .github/workflows/vu1nz-scan.yml via sh1pt vu1nz-scan@1.0.1 (#2) Co-authored-by: sh1pt-actions-fleet[bot] <287014002+sh1pt-actions-fleet[bot]@users.noreply.github.com> * chore: add ThreatCrush security scan on pull requests vu1nz-scan.yml reviews a PR's diff with an LLM and needs an API key to do it. This is the other half: offline static analysis over the whole tree, no secrets, no network beyond the install. It looks for what a diff review is worst at spotting — a credential committed three releases ago, an injection pattern in a file nobody touched this PR — and writes SARIF, so findings land in the Security tab with history rather than only in a comment. Fails on critical/high to match vu1nz-scan.yml, which already exits 1 on high/critical. That floor is quieter than it sounds: ThreatCrush caps pattern-confidence findings at medium, so a bare "this construct exists on this line" match cannot break a build. Only contextual and evidence findings reach it. main is at 0 critical / 0 high today, so this is green on arrival and needs no suppressions. Node 22, not the pack's 20: it matches engines.node and the CI matrix floor. Not 24 — better-sqlite3 has no prebuild there yet and the install falls through to a node-gyp source build and fails. The workflow is arranged so a scan that did not run can never report as a scan that found nothing. The SARIF file, not the exit code, is the evidence a scan happened; --format support is checked up front rather than inferred from an exit code, because a CLI without it exits 1 on the unknown option, which is the same code the CLI uses for "findings at or above --fail-on". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> ---------
Showing the changes to .github (3 of 3 files). Show all
Unified · Split
| @@ -0,0 +1,31 @@ | ||
| 1 | +# Managed by sh1pt Actions Fleet | |
| 2 | +# pack: coinpay-invoice@1.0.0 | |
| 3 | +# install: sh1pt-actions-store | |
| 4 | +# hash: sha256:34ad3313699d6a34845801f6d1c0963e36bee5d375678ffdd2a4bf0f46a17fa2 | |
| 5 | +name: CoinPayPortal invoice command | |
| 6 | + | |
| 7 | +on: | |
| 8 | + issue_comment: | |
| 9 | + types: [created] | |
| 10 | + | |
| 11 | +permissions: | |
| 12 | + issues: write | |
| 13 | + pull-requests: write | |
| 14 | + | |
| 15 | +concurrency: | |
| 16 | + group: coinpay-${{ github.workflow }}-${{ github.event.issue.number }} | |
| 17 | + cancel-in-progress: false | |
| 18 | + | |
| 19 | +jobs: | |
| 20 | + coinpay: | |
| 21 | + # Only spin up when a comment actually invokes the bot. | |
| 22 | + if: startsWith(github.event.comment.body, '/coinpay') | |
| 23 | + runs-on: ubuntu-latest | |
| 24 | + timeout-minutes: 15 | |
| 25 | + steps: | |
| 26 | + - uses: profullstack/coinpaybot@v0 | |
| 27 | + with: | |
| 28 | + github-token: ${{ secrets.GITHUB_TOKEN }} | |
| 29 | + coinpay-api-key: ${{ secrets.COINPAY_API_KEY }} | |
| 30 | + coinpay-business-id: ${{ secrets.COINPAY_BUSINESS_ID }} | |
| 31 | + coinpay-base-url: https://coinpayportal.com | |
| @@ -0,0 +1,291 @@ | ||
| 1 | +# Adapted from the upstream `threatcrush-scan@1.1.0` pack | |
| 2 | +# (profullstack/threatcrush → .github/workflows/threatcrush-scan.yml). | |
| 3 | +# Deviations from that pack, all to fit this repo: | |
| 4 | +# - Node 22, not 20, matching `engines.node` and the CI matrix floor | |
| 5 | +# - fails on critical/high, matching vu1nz-scan.yml, which already does | |
| 6 | +# - the legacy text-output compatibility path is dropped; see "Detect the | |
| 7 | +# CLI output interface" below | |
| 8 | +# The sh1pt fleet header is deliberately not carried over: this file is no | |
| 9 | +# longer byte-identical to the pack, and claiming a hash that does not match | |
| 10 | +# would be worse than not claiming one. | |
| 11 | +name: threatcrush security scan | |
| 12 | + | |
| 13 | +on: | |
| 14 | + pull_request: | |
| 15 | + | |
| 16 | +permissions: | |
| 17 | + contents: read | |
| 18 | + pull-requests: write | |
| 19 | + security-events: write | |
| 20 | + | |
| 21 | +# A new push to the same branch supersedes the run in flight, as in ci.yml. | |
| 22 | +concurrency: | |
| 23 | + group: threatcrush-${{ github.ref }} | |
| 24 | + cancel-in-progress: true | |
| 25 | + | |
| 26 | +jobs: | |
| 27 | + scan: | |
| 28 | + name: Scan for credentials and vulnerable patterns | |
| 29 | + runs-on: ubuntu-latest | |
| 30 | + timeout-minutes: 15 | |
| 31 | + | |
| 32 | + steps: | |
| 33 | + - uses: actions/checkout@v7 | |
| 34 | + | |
| 35 | + # 22 is this repo's floor (`engines.node`) and the low end of the CI | |
| 36 | + # matrix. Not 24: ThreatCrush pulls in better-sqlite3, which ships | |
| 37 | + # prebuilt binaries for 22 but not yet for 24, where the install falls | |
| 38 | + # through to a node-gyp source build and fails. A security gate that | |
| 39 | + # cannot install is a security gate that does not run. | |
| 40 | + - uses: actions/setup-node@v7 | |
| 41 | + with: | |
| 42 | + node-version: "22" | |
| 43 | + | |
| 44 | + # An unretried `npm i -g` is a network call to a registry that decides | |
| 45 | + # whether a security gate runs at all. Retry before giving up; a | |
| 46 | + # transient registry blip is not a security signal and should not read | |
| 47 | + # like one. | |
| 48 | + - name: Install ThreatCrush | |
| 49 | + run: | | |
| 50 | + for attempt in 1 2 3; do | |
| 51 | + if npm install -g "@profullstack/threatcrush@latest"; then | |
| 52 | + exit 0 | |
| 53 | + fi | |
| 54 | + delay=$((attempt * 10)) | |
| 55 | + echo "::warning::ThreatCrush install attempt ${attempt}/3 failed; retrying in ${delay}s" | |
| 56 | + sleep "${delay}" | |
| 57 | + done | |
| 58 | + echo "::error::ThreatCrush install failed after 3 attempts" | |
| 59 | + exit 1 | |
| 60 | + | |
| 61 | + # Recorded into every run log so a release that changes the interface | |
| 62 | + # shows up immediately, rather than silently scoring zero. | |
| 63 | + - name: Record the CLI interface | |
| 64 | + run: | | |
| 65 | + threatcrush --version || true | |
| 66 | + threatcrush scan --help || true | |
| 67 | + | |
| 68 | + # Checked up front rather than inferred from an exit code, because exit | |
| 69 | + # codes cannot tell the two failures apart. CLIs before 0.3.0 have no | |
| 70 | + # `--format`: the scan died with `error: unknown option '--format'` and | |
| 71 | + # commander exited 1 — the same code the CLI uses for "findings at or | |
| 72 | + # above --fail-on". Read as a result, that produces a green check on a | |
| 73 | + # repository nothing has scanned. | |
| 74 | + # | |
| 75 | + # Upstream converts legacy text output with a vendored Python script. | |
| 76 | + # That path is dropped here rather than carrying a converter for a CLI | |
| 77 | + # older than the current npm release. Failing closed is the same safety | |
| 78 | + # property: an unrecognised interface stops the job instead of reporting | |
| 79 | + # an unscanned diff as clean. | |
| 80 | + - name: Detect the CLI output interface | |
| 81 | + run: | | |
| 82 | + if ! threatcrush scan --help 2>&1 | grep -q -- '--format'; then | |
| 83 | + echo "::error::CLI $(threatcrush --version 2>/dev/null || echo unknown) has no --format; cannot emit SARIF. This diff was NOT scanned." | |
| 84 | + exit 1 | |
| 85 | + fi | |
| 86 | + echo "Native SARIF output available." | |
| 87 | + | |
| 88 | + # --fail-on critical,high matches vu1nz-scan.yml, which already exits 1 | |
| 89 | + # on high/critical findings. It is not as noisy as it sounds: | |
| 90 | + # `pattern`-confidence findings are capped at medium upstream, so a bare | |
| 91 | + # "this construct exists" match cannot break a build. Only `contextual` | |
| 92 | + # and `evidence` findings reach this floor. Drop the flag to make the | |
| 93 | + # scan advisory. | |
| 94 | + - name: Scan | |
| 95 | + id: scan | |
| 96 | + run: | | |
| 97 | + set -o pipefail | |
| 98 | + code=0 | |
| 99 | + threatcrush scan . --format sarif --output threatcrush.sarif --fail-on critical,high || code=$? | |
| 100 | + | |
| 101 | + # The SARIF file is the evidence that a scan happened, and it is the | |
| 102 | + # only evidence worth trusting. An exit code says what the process | |
| 103 | + # thought; the file says what it produced. Absent the file there is | |
| 104 | + # nothing to report, and reporting nothing as "no findings" is the | |
| 105 | + # failure this whole workflow is arranged to avoid. | |
| 106 | + if [ ! -s threatcrush.sarif ]; then | |
| 107 | + echo "status=error" >> "$GITHUB_OUTPUT" | |
| 108 | + echo "::error::ThreatCrush produced no SARIF (exit ${code}) — this diff was NOT scanned" | |
| 109 | + exit 1 | |
| 110 | + fi | |
| 111 | + | |
| 112 | + case "$code" in | |
| 113 | + 0) echo "status=clean" >> "$GITHUB_OUTPUT" ;; | |
| 114 | + # Exit 1 *with* a SARIF file is the documented "findings at or | |
| 115 | + # above --fail-on" result; without one it was caught above. | |
| 116 | + # Propagate it: a gate that records the finding and then lets the | |
| 117 | + # job pass is not a gate. | |
| 118 | + 1) | |
| 119 | + echo "status=findings" >> "$GITHUB_OUTPUT" | |
| 120 | + exit 1 | |
| 121 | + ;; | |
| 122 | + *) | |
| 123 | + echo "status=error" >> "$GITHUB_OUTPUT" | |
| 124 | + echo "::error::ThreatCrush scan failed with exit code ${code} — results may be incomplete" | |
| 125 | + exit "$code" | |
| 126 | + ;; | |
| 127 | + esac | |
| 128 | + | |
| 129 | + # Reached only when an earlier step already failed the job. The empty | |
| 130 | + # run exists so the upload does not error on a missing file and bury the | |
| 131 | + # real cause; it is not a result. The scan step has already set | |
| 132 | + # status=error (or never ran), so the report says NOT RUN rather than | |
| 133 | + # rendering this as a clean scan. | |
| 134 | + - name: Ensure SARIF exists | |
| 135 | + if: always() | |
| 136 | + run: | | |
| 137 | + if [ ! -f threatcrush.sarif ]; then | |
| 138 | + cat > threatcrush.sarif <<'JSON' | |
| 139 | + { | |
| 140 | + "version": "2.1.0", | |
| 141 | + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", | |
| 142 | + "runs": [{ "tool": { "driver": { "name": "ThreatCrush", "rules": [] } }, "results": [] }] | |
| 143 | + } | |
| 144 | + JSON | |
| 145 | + fi | |
| 146 | + | |
| 147 | + - name: Upload to the Security tab | |
| 148 | + if: always() | |
| 149 | + continue-on-error: true | |
| 150 | + uses: github/codeql-action/upload-sarif@v3 | |
| 151 | + with: | |
| 152 | + sarif_file: threatcrush.sarif | |
| 153 | + category: threatcrush | |
| 154 | + | |
| 155 | + - name: Build the report | |
| 156 | + if: always() | |
| 157 | + run: | | |
| 158 | + python3 << 'PYEOF' | |
| 159 | + import json, os | |
| 160 | + | |
| 161 | + status = os.environ.get("SCAN_STATUS", "") | |
| 162 | + try: | |
| 163 | + with open("threatcrush.sarif") as handle: | |
| 164 | + results = json.load(handle)["runs"][0]["results"] | |
| 165 | + except Exception as err: | |
| 166 | + results = None | |
| 167 | + print(f"::warning::could not read SARIF: {err}") | |
| 168 | + | |
| 169 | + lines = ["## ThreatCrush Security Scan", ""] | |
| 170 | + | |
| 171 | + # Fail closed: render findings only on positive evidence that a scan | |
| 172 | + # completed. Testing for `status == "error"` would be fail-open — | |
| 173 | + # when an earlier step fails, the scan step is *skipped*, so | |
| 174 | + # `status` is the empty string rather than "error", and the comment | |
| 175 | + # cheerfully reports "0 findings" for a scan that never started. Any | |
| 176 | + # state that is not a known-good outcome is NOT RUN. | |
| 177 | + if status not in ("clean", "findings") or results is None: | |
| 178 | + # Never render "no issues found" for a scan that did not finish. | |
| 179 | + # An unexamined diff is not a clean one, and the two are | |
| 180 | + # indistinguishable to whoever reads the comment. | |
| 181 | + lines += [ | |
| 182 | + "**NOT RUN** — the scan did not complete, so this diff was not examined.", | |
| 183 | + "This is not a clean result. See the job log.", | |
| 184 | + ] | |
| 185 | + else: | |
| 186 | + counts = {"error": 0, "warning": 0, "note": 0} | |
| 187 | + for result in results: | |
| 188 | + level = result.get("level", "warning") | |
| 189 | + if level in counts: | |
| 190 | + counts[level] += 1 | |
| 191 | + | |
| 192 | + lines.append(f"**{len(results)}** finding(s)") | |
| 193 | + lines.append("") | |
| 194 | + | |
| 195 | + if results: | |
| 196 | + badges = [] | |
| 197 | + if counts["error"]: | |
| 198 | + badges.append(f"**HIGH/CRITICAL**: {counts['error']}") | |
| 199 | + if counts["warning"]: | |
| 200 | + badges.append(f"**MEDIUM**: {counts['warning']}") | |
| 201 | + if counts["note"]: | |
| 202 | + badges.append(f"**LOW**: {counts['note']}") | |
| 203 | + if badges: | |
| 204 | + lines += [" | ".join(badges), ""] | |
| 205 | + | |
| 206 | + lines += ["| Severity | Rule | Location |", "|---|---|---|"] | |
| 207 | + for result in results[:50]: | |
| 208 | + location = result["locations"][0]["physicalLocation"] | |
| 209 | + uri = location["artifactLocation"]["uri"] | |
| 210 | + line_no = location.get("region", {}).get("startLine", 1) | |
| 211 | + label = {"error": "HIGH", "warning": "MEDIUM", "note": "LOW"}.get( | |
| 212 | + result.get("level", "warning"), "INFO" | |
| 213 | + ) | |
| 214 | + lines.append(f"| {label} | `{result.get('ruleId','?')}` | `{uri}`:{line_no} |") | |
| 215 | + if len(results) > 50: | |
| 216 | + # Say so. A silent truncation reads as "that was everything". | |
| 217 | + lines += ["", f"_…and {len(results) - 50} more. Full results in the Security tab._"] | |
| 218 | + lines += ["", "Snippets are redacted; ThreatCrush never prints matched credential material."] | |
| 219 | + else: | |
| 220 | + lines.append("No findings.") | |
| 221 | + | |
| 222 | + with open(os.environ["RUNNER_TEMP"] + "/threatcrush-comment.md", "w") as handle: | |
| 223 | + handle.write("\n".join(lines) + "\n") | |
| 224 | + PYEOF | |
| 225 | + env: | |
| 226 | + SCAN_STATUS: ${{ steps.scan.outputs.status }} | |
| 227 | + | |
| 228 | + - name: Write report to job summary | |
| 229 | + if: always() | |
| 230 | + run: cat "$RUNNER_TEMP/threatcrush-comment.md" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true | |
| 231 | + | |
| 232 | + - name: Upload SARIF artifact | |
| 233 | + if: always() | |
| 234 | + uses: actions/upload-artifact@v4 | |
| 235 | + with: | |
| 236 | + name: threatcrush-sarif | |
| 237 | + path: threatcrush.sarif | |
| 238 | + retention-days: 30 | |
| 239 | + | |
| 240 | + # Best-effort, exactly as vu1nz-scan.yml treats its own comment step. | |
| 241 | + # `pull_request` gives fork PRs a read-only token, so this 403s on fork | |
| 242 | + # submissions — the report is in the job summary either way, and the | |
| 243 | + # scan's pass/fail is decided by the scan step, not by whether a comment | |
| 244 | + # posted. Deliberately NOT switching to pull_request_target to get a | |
| 245 | + # writable token: that event runs with repository secrets in scope | |
| 246 | + # against a checkout of untrusted contributor code. | |
| 247 | + - name: Comment on PR | |
| 248 | + if: always() && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' | |
| 249 | + continue-on-error: true | |
| 250 | + uses: actions/github-script@v7 | |
| 251 | + with: | |
| 252 | + script: | | |
| 253 | + const fs = require('fs'); | |
| 254 | + let body; | |
| 255 | + try { | |
| 256 | + body = fs.readFileSync(`${process.env.RUNNER_TEMP}/threatcrush-comment.md`, 'utf8'); | |
| 257 | + } catch { | |
| 258 | + body = '## ThreatCrush Security Scan\n\nScan completed but the report could not be read.'; | |
| 259 | + } | |
| 260 | + | |
| 261 | + try { | |
| 262 | + const { data: comments } = await github.rest.issues.listComments({ | |
| 263 | + issue_number: context.issue.number, | |
| 264 | + owner: context.repo.owner, | |
| 265 | + repo: context.repo.repo, | |
| 266 | + }); | |
| 267 | + const existing = comments.find( | |
| 268 | + (c) => c.user.type === 'Bot' && c.body.includes('ThreatCrush Security Scan'), | |
| 269 | + ); | |
| 270 | + | |
| 271 | + if (existing) { | |
| 272 | + await github.rest.issues.updateComment({ | |
| 273 | + comment_id: existing.id, | |
| 274 | + owner: context.repo.owner, | |
| 275 | + repo: context.repo.repo, | |
| 276 | + body, | |
| 277 | + }); | |
| 278 | + } else { | |
| 279 | + await github.rest.issues.createComment({ | |
| 280 | + issue_number: context.issue.number, | |
| 281 | + owner: context.repo.owner, | |
| 282 | + repo: context.repo.repo, | |
| 283 | + body, | |
| 284 | + }); | |
| 285 | + } | |
| 286 | + } catch (err) { | |
| 287 | + core.warning( | |
| 288 | + `Could not post PR comment (status ${err.status ?? 'unknown'}): ${err.message}. ` + | |
| 289 | + 'Findings are in the job summary.', | |
| 290 | + ); | |
| 291 | + } | |
| @@ -0,0 +1,219 @@ | ||
| 1 | +# Managed by sh1pt Actions Fleet | |
| 2 | +# pack: vu1nz-scan@1.0.1 | |
| 3 | +# install: sh1pt-actions-store | |
| 4 | +# hash: sha256:69dca6b225e64533cd02750003f56fd1ccb4f178c37f48d4398a8254a3fe887b | |
| 5 | +name: vu1nz security scan | |
| 6 | + | |
| 7 | +on: | |
| 8 | + pull_request: | |
| 9 | + | |
| 10 | +permissions: | |
| 11 | + contents: read | |
| 12 | + pull-requests: write | |
| 13 | + | |
| 14 | +jobs: | |
| 15 | + review: | |
| 16 | + name: Review PR for security vulnerabilities | |
| 17 | + runs-on: ubuntu-latest | |
| 18 | + timeout-minutes: 15 | |
| 19 | + | |
| 20 | + steps: | |
| 21 | + - uses: actions/checkout@v4 | |
| 22 | + | |
| 23 | + - uses: actions/setup-python@v5 | |
| 24 | + with: | |
| 25 | + python-version: "3.12" | |
| 26 | + | |
| 27 | + - name: Install vu1nz | |
| 28 | + run: pip install --quiet git+https://github.com/profullstack/vu1nz-gh-actions.git | |
| 29 | + | |
| 30 | + - name: Load env file | |
| 31 | + env: | |
| 32 | + ENV_FILE: ${{ secrets.ENV_FILE }} | |
| 33 | + run: | | |
| 34 | + echo "$ENV_FILE" > "$RUNNER_TEMP/.env" | |
| 35 | + echo "Keys in ENV_FILE:" | |
| 36 | + grep -oP '^[A-Z_]+(?==)' "$RUNNER_TEMP/.env" || echo "(no keys found or different format)" | |
| 37 | + ANTHROPIC_API_KEY=$(grep -E '^ANTHROPIC_API_KEY=' "$RUNNER_TEMP/.env" | head -1 | sed 's/^ANTHROPIC_API_KEY=//') | |
| 38 | + if [ -n "$ANTHROPIC_API_KEY" ]; then | |
| 39 | + echo "::add-mask::$ANTHROPIC_API_KEY" | |
| 40 | + echo "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" >> "$GITHUB_ENV" | |
| 41 | + echo "ANTHROPIC_API_KEY found and exported" | |
| 42 | + else | |
| 43 | + echo "::warning::ANTHROPIC_API_KEY not found in ENV_FILE" | |
| 44 | + fi | |
| 45 | + | |
| 46 | + - name: Review PR | |
| 47 | + id: review | |
| 48 | + env: | |
| 49 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| 50 | + NO_COLOR: "1" | |
| 51 | + TERM: dumb | |
| 52 | + run: | | |
| 53 | + vu1nz review-pr main \ | |
| 54 | + ${{ github.repository }} \ | |
| 55 | + ${{ github.event.pull_request.number }} \ | |
| 56 | + --token "$GITHUB_TOKEN" \ | |
| 57 | + --json \ | |
| 58 | + | tee "$RUNNER_TEMP/vu1nz-review-raw.txt" || true | |
| 59 | + | |
| 60 | + python3 -c " | |
| 61 | + import json, re, sys | |
| 62 | + raw = open('$RUNNER_TEMP/vu1nz-review-raw.txt').read() | |
| 63 | + raw = re.sub(r'\x1b\[[0-9;]*m', '', raw) | |
| 64 | + start = raw.find('{') | |
| 65 | + if start >= 0: | |
| 66 | + obj, _ = json.JSONDecoder(strict=False).raw_decode(raw, start) | |
| 67 | + json.dump(obj, sys.stdout) | |
| 68 | + else: | |
| 69 | + print('{}') | |
| 70 | + " > "$RUNNER_TEMP/vu1nz-review.json" | |
| 71 | + | |
| 72 | + - name: Build PR comment | |
| 73 | + id: comment | |
| 74 | + run: | | |
| 75 | + python3 << 'PYEOF' | |
| 76 | + import json, os, sys | |
| 77 | + | |
| 78 | + review_file = os.environ.get("RUNNER_TEMP", "") + "/vu1nz-review.json" | |
| 79 | + comment_file = os.environ.get("RUNNER_TEMP", "") + "/vu1nz-comment.md" | |
| 80 | + | |
| 81 | + try: | |
| 82 | + with open(review_file) as f: | |
| 83 | + data = json.loads(f.read(), strict=False) | |
| 84 | + except Exception as e: | |
| 85 | + print(f"::warning::Could not parse review results: {e}") | |
| 86 | + with open(comment_file, "w") as f: | |
| 87 | + f.write("## vu1nz Security Review\n\nCould not parse review results.\n") | |
| 88 | + sys.exit(0) | |
| 89 | + | |
| 90 | + findings = data.get("findings", []) | |
| 91 | + analysis = data.get("analysis", "") | |
| 92 | + pr = data.get("pr_number", "?") | |
| 93 | + total = len(findings) | |
| 94 | + | |
| 95 | + counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} | |
| 96 | + for finding in findings: | |
| 97 | + sev = finding.get("severity", "").lower() | |
| 98 | + if sev in counts: | |
| 99 | + counts[sev] += 1 | |
| 100 | + | |
| 101 | + has_hc = counts["critical"] > 0 or counts["high"] > 0 | |
| 102 | + | |
| 103 | + lines = ["## vu1nz Security Review", ""] | |
| 104 | + lines.append(f"**{total}** finding(s) in PR #{pr}") | |
| 105 | + lines.append("") | |
| 106 | + | |
| 107 | + badge_parts = [] | |
| 108 | + for sev in ("critical", "high", "medium", "low"): | |
| 109 | + if counts[sev] > 0: | |
| 110 | + badge_parts.append(f"**{sev.upper()}**: {counts[sev]}") | |
| 111 | + if badge_parts: | |
| 112 | + lines.append(" | ".join(badge_parts)) | |
| 113 | + lines.append("") | |
| 114 | + | |
| 115 | + if has_hc: | |
| 116 | + lines.append("> **High or critical findings - review before merging.**") | |
| 117 | + lines.append("") | |
| 118 | + | |
| 119 | + if findings: | |
| 120 | + lines.append("### Findings") | |
| 121 | + lines.append("") | |
| 122 | + lines.append("| Severity | File | Issue | Suggestion |") | |
| 123 | + lines.append("|----------|------|-------|------------|") | |
| 124 | + for f in findings: | |
| 125 | + sev = f.get("severity", "?").upper() | |
| 126 | + file = f.get("file", "N/A") | |
| 127 | + issue = f.get("issue", "").replace("\n", " ")[:150] | |
| 128 | + suggestion = f.get("suggestion", "").replace("\n", " ")[:150] | |
| 129 | + lines.append(f"| {sev} | `{file}` | {issue} | {suggestion} |") | |
| 130 | + lines.append("") | |
| 131 | + else: | |
| 132 | + lines.append("No security issues found.") | |
| 133 | + lines.append("") | |
| 134 | + | |
| 135 | + if analysis: | |
| 136 | + lines.append("<details><summary>Full AI Analysis</summary>") | |
| 137 | + lines.append("") | |
| 138 | + lines.append(analysis) | |
| 139 | + lines.append("") | |
| 140 | + lines.append("</details>") | |
| 141 | + | |
| 142 | + body = "\n".join(lines) | |
| 143 | + with open(comment_file, "w") as f: | |
| 144 | + f.write(body) | |
| 145 | + | |
| 146 | + with open(os.environ.get("GITHUB_OUTPUT", ""), "a") as out: | |
| 147 | + out.write(f"total={total}\n") | |
| 148 | + out.write(f"has_high_critical={'true' if has_hc else 'false'}\n") | |
| 149 | + | |
| 150 | + if has_hc: | |
| 151 | + print(f"::error::vu1nz found high/critical vulnerabilities in PR code") | |
| 152 | + sys.exit(1) | |
| 153 | + | |
| 154 | + print(f"::notice::vu1nz review: {total} finding(s), no high/critical issues") | |
| 155 | + PYEOF | |
| 156 | + | |
| 157 | + - name: Write report to job summary | |
| 158 | + if: always() | |
| 159 | + run: | | |
| 160 | + if [ -f "$RUNNER_TEMP/vu1nz-comment.md" ]; then | |
| 161 | + cat "$RUNNER_TEMP/vu1nz-comment.md" >> "$GITHUB_STEP_SUMMARY" | |
| 162 | + else | |
| 163 | + echo "## vu1nz Security Review" >> "$GITHUB_STEP_SUMMARY" | |
| 164 | + echo "" >> "$GITHUB_STEP_SUMMARY" | |
| 165 | + echo "Scan completed but could not read results." >> "$GITHUB_STEP_SUMMARY" | |
| 166 | + fi | |
| 167 | + | |
| 168 | + - name: Comment on PR | |
| 169 | + # Best-effort only. Skip for Dependabot (read-only token can't comment) | |
| 170 | + # and never fail the job if posting the comment errors — the scan's | |
| 171 | + # pass/fail is decided by the "Build PR comment" step, and findings are | |
| 172 | + # always written to the job summary. | |
| 173 | + if: always() && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' | |
| 174 | + continue-on-error: true | |
| 175 | + uses: actions/github-script@v7 | |
| 176 | + with: | |
| 177 | + script: | | |
| 178 | + const fs = require('fs'); | |
| 179 | + const commentFile = `${process.env.RUNNER_TEMP}/vu1nz-comment.md`; | |
| 180 | + let body; | |
| 181 | + try { | |
| 182 | + body = fs.readFileSync(commentFile, 'utf8'); | |
| 183 | + } catch { | |
| 184 | + body = '## vu1nz Security Review\n\nScan completed but could not read results.'; | |
| 185 | + } | |
| 186 | + | |
| 187 | + try { | |
| 188 | + const { data: comments } = await github.rest.issues.listComments({ | |
| 189 | + issue_number: context.issue.number, | |
| 190 | + owner: context.repo.owner, | |
| 191 | + repo: context.repo.repo, | |
| 192 | + }); | |
| 193 | + | |
| 194 | + const existing = comments.find(c => | |
| 195 | + c.user.type === 'Bot' && c.body.includes('vu1nz Security Review') | |
| 196 | + ); | |
| 197 | + | |
| 198 | + if (existing) { | |
| 199 | + await github.rest.issues.updateComment({ | |
| 200 | + comment_id: existing.id, | |
| 201 | + owner: context.repo.owner, | |
| 202 | + repo: context.repo.repo, | |
| 203 | + body: body, | |
| 204 | + }); | |
| 205 | + } else { | |
| 206 | + await github.rest.issues.createComment({ | |
| 207 | + issue_number: context.issue.number, | |
| 208 | + owner: context.repo.owner, | |
| 209 | + repo: context.repo.repo, | |
| 210 | + body: body, | |
| 211 | + }); | |
| 212 | + } | |
| 213 | + } catch (err) { | |
| 214 | + // Posting the comment is best-effort. Read-only tokens return 403 | |
| 215 | + // and transient GitHub outages return 503 (the "Unicorn" HTML | |
| 216 | + // page); neither should fail the scan. Findings are in the job | |
| 217 | + // summary regardless. | |
| 218 | + core.warning(`Could not post PR comment (status ${err.status ?? 'unknown'}): ${err.message}. Findings are in the job summary.`); | |
| 219 | + } | |