* 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 | } | ||