From 941c8a86b41e62b9e8ca91b75dd7a33663cee83a Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 5 May 2026 17:20:22 +0530 Subject: [PATCH 1/2] fix: use a dedicated PAT for ghsa linear sync gh action (#14364) The default `GITHUB_TOKEN` cannot read `security-advisories`; that endpoint requires the `repository_advisories` permission, which is not available to the GitHub Actions installation token. Switched to a fine-grained PAT stored in `GHSA_READ_TOKEN`. Tested locally: the same PAT returns the full triage list Changes ---- - Switch to custom token - Add a discord alert for new advisories - Switch to python --- .github/scripts/ghsa_linear_sync.py | 195 +++++++++++++++++++++++++ .github/workflows/ghsa-linear-sync.yml | 94 ++---------- 2 files changed, 208 insertions(+), 81 deletions(-) create mode 100644 .github/scripts/ghsa_linear_sync.py diff --git a/.github/scripts/ghsa_linear_sync.py b/.github/scripts/ghsa_linear_sync.py new file mode 100644 index 000000000..064361b26 --- /dev/null +++ b/.github/scripts/ghsa_linear_sync.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Sync triage GitHub security advisories to Linear issues.""" + +from __future__ import annotations + +import os +import sys +from typing import Any + +import requests + +GITHUB_API = "https://api.github.com" +LINEAR_API = "https://api.linear.app/graphql" + +SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4} +SEVERITY_COLOR = { + "critical": 15548997, + "high": 15105570, + "medium": 15844367, + "low": 3066993, +} +DEFAULT_COLOR = 9807270 + + +def required_env(name: str) -> str: + value = os.environ.get(name) + if not value: + sys.exit(f"Missing required env var: {name}") + return value + + +def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]: + url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories" + params: dict[str, Any] | None = {"state": "triage", "per_page": 100} + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + } + advisories: list[dict[str, Any]] = [] + while url: + r = requests.get(url, headers=headers, params=params, timeout=30) + r.raise_for_status() + advisories.extend(r.json()) + next_link = r.links.get("next") + url = next_link["url"] if next_link else None + params = None + return advisories + + +def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]: + r = requests.post( + LINEAR_API, + headers={"Authorization": api_key}, + json={"query": query, "variables": variables}, + timeout=30, + ) + r.raise_for_status() + return r.json() + + +def linear_issue_exists(ghsa_id: str, api_key: str) -> bool: + query = ( + "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) " + "{ nodes { id } } }" + ) + resp = linear_call(query, {"q": ghsa_id}, api_key) + return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0 + + +def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None: + query = ( + "mutation($input: IssueCreateInput!) { issueCreate(input: $input) " + "{ success issue { identifier url } } }" + ) + resp = linear_call(query, {"input": input_data}, api_key) + create = resp.get("data", {}).get("issueCreate") or {} + if not create.get("success"): + return None + return create.get("issue") + + +def reporter_login(advisory: dict[str, Any]) -> str: + for credit in advisory.get("credits") or []: + user = (credit or {}).get("user") or {} + if user.get("login"): + return user["login"] + return "unknown" + + +def cvss_score(advisory: dict[str, Any]) -> str: + score = (advisory.get("cvss") or {}).get("score") + return str(score) if score is not None else "n/a" + + +def build_description(adv: dict[str, Any]) -> str: + return ( + f"**GHSA:** {adv['ghsa_id']}\n" + f"**CVE:** {adv.get('cve_id') or 'n/a'}\n" + f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n" + f"**Reporter:** {reporter_login(adv)}\n" + f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n" + f"**Advisory:** {adv['html_url']}\n\n" + f"---\n\n" + f"{adv.get('description') or 'No description provided.'}" + ) + + +def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None: + severity = adv.get("severity") or "unknown" + title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250] + payload = { + "username": "GHSA Sync", + "embeds": [ + { + "title": title, + "url": issue["url"], + "color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR), + "fields": [ + {"name": "Linear", "value": issue["identifier"], "inline": True}, + { + "name": "Severity", + "value": f"{severity} (CVSS {cvss_score(adv)})", + "inline": True, + }, + { + "name": "Advisory", + "value": f"[GitHub]({adv['html_url']})", + "inline": True, + }, + ], + } + ], + } + try: + requests.post(webhook_url, json=payload, timeout=10) + except requests.RequestException: + pass + + +def main() -> int: + repo = required_env("GITHUB_REPOSITORY") + gh_token = required_env("GHSA_READ_TOKEN") + linear_api_key = required_env("LINEAR_API_KEY") + team_id = required_env("LINEAR_TEAM_ID") + project_id = required_env("LINEAR_PROJECT_ID") + label_id = required_env("LINEAR_LABEL_ID") + discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None + + advisories = fetch_triage_advisories(repo, gh_token) + print(f"Fetched {len(advisories)} triage advisories") + + created = skipped = failed = 0 + + for adv in advisories: + ghsa_id = adv.get("ghsa_id") + if not ghsa_id: + failed += 1 + continue + + try: + if linear_issue_exists(ghsa_id, linear_api_key): + skipped += 1 + continue + + severity = adv.get("severity") or "unknown" + issue = linear_create_issue( + { + "title": f"[{ghsa_id}] {adv.get('summary', '')}", + "description": build_description(adv), + "teamId": team_id, + "projectId": project_id, + "labelIds": [label_id], + "priority": SEVERITY_PRIORITY.get(severity, 3), + }, + linear_api_key, + ) + except requests.RequestException: + failed += 1 + continue + + if not issue: + failed += 1 + continue + + created += 1 + if discord_webhook: + post_discord(adv, issue, discord_webhook) + + print(f"Created {created}, skipped {skipped}, failed {failed}") + return 1 if failed > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ghsa-linear-sync.yml b/.github/workflows/ghsa-linear-sync.yml index 961114bc9..a21fbea7f 100644 --- a/.github/workflows/ghsa-linear-sync.yml +++ b/.github/workflows/ghsa-linear-sync.yml @@ -5,93 +5,25 @@ on: - cron: '0 4 * * *' # daily at 09:30 IST workflow_dispatch: {} +permissions: + contents: read + jobs: sync: runs-on: ubuntu-latest - permissions: - security-events: read steps: - - name: Fetch triage advisories - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh api --paginate \ - -H "Accept: application/vnd.github+json" \ - "/repos/${{ github.repository }}/security-advisories?state=triage&per_page=100" \ - | jq -cs 'add | [.[] | { - ghsa_id, cve_id, summary, severity, state, html_url, - description, created_at, - cvss_score: .cvss.score, - reporter: ([.credits[]?.user.login] | first // "unknown") - }]' > advisories.json - echo "Fetched $(jq 'length' advisories.json) triage advisories" - - - name: Create Linear issues for new advisories + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: pip install requests==2.32.3 + - name: Sync advisories env: + GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }} LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }} LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }} - run: | - created_count=0 - skipped_count=0 - failed_count=0 - while read -r advisory; do - ghsa_id=$(printf '%s' "$advisory" | jq -r '.ghsa_id') - summary=$(printf '%s' "$advisory" | jq -r '.summary') - severity=$(printf '%s' "$advisory" | jq -r '.severity // "unknown"') - cve_id=$(printf '%s' "$advisory" | jq -r '.cve_id // "n/a"') - cvss=$(printf '%s' "$advisory" | jq -r '.cvss_score // "n/a"') - reporter=$(printf '%s' "$advisory" | jq -r '.reporter') - html_url=$(printf '%s' "$advisory" | jq -r '.html_url') - created_date=$(printf '%s' "$advisory" | jq -r '.created_at' | cut -dT -f1) - description=$(printf '%s' "$advisory" | jq -r '.description // "No description provided."') - - existing=$(curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d "$(jq -n --arg q "$ghsa_id" '{query: "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) { nodes { id } } }", variables: {q: $q}}')" \ - | jq '.data.issues.nodes | length') - - if [ "${existing:-0}" -gt 0 ] 2>/dev/null; then - skipped_count=$((skipped_count+1)) - continue - fi - - priority=3 - case "$severity" in - critical) priority=1 ;; - high) priority=2 ;; - medium) priority=3 ;; - low) priority=4 ;; - esac - - title="[$ghsa_id] $summary" - body=$(printf '**GHSA:** %s\n**CVE:** %s\n**Severity:** %s (CVSS %s)\n**Reporter:** %s\n**Reported:** %s\n**Advisory:** %s\n\n---\n\n%s' \ - "$ghsa_id" "$cve_id" "$severity" "$cvss" "$reporter" "$created_date" "$html_url" "$description") - - success=$(curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d "$(jq -n \ - --arg title "$title" \ - --arg body "$body" \ - --arg teamId "$LINEAR_TEAM_ID" \ - --arg projectId "$LINEAR_PROJECT_ID" \ - --arg labelId "$LINEAR_LABEL_ID" \ - --argjson priority "$priority" \ - '{ - query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success } }", - variables: {input: {title: $title, description: $body, teamId: $teamId, projectId: $projectId, labelIds: [$labelId], priority: $priority}} - }')" | jq -r '.data.issueCreate.success // false') - - if [ "$success" = "true" ]; then - created_count=$((created_count+1)) - else - failed_count=$((failed_count+1)) - fi - done < <(jq -c '.[]' advisories.json) - echo "Created $created_count, skipped $skipped_count, failed $failed_count" - if [ "$failed_count" -gt 0 ]; then - exit 1 - fi + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + run: python3 .github/scripts/ghsa_linear_sync.py From 2192af80f405c70e3021191ff8b3f7e539c35e9d Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 5 May 2026 17:46:21 +0530 Subject: [PATCH 2/2] fix: html-escape captured values in helpcenter article markdown embeds (#14140) Embed templates interpolate regex captures from user-authored article URLs into HTML attribute values. CommonMark's angle-bracket link destination syntax allows characters that the capture regexes don't filter, so the unescaped substitution could produce malformed attribute output. Escaping at substitution time keeps the render deterministic regardless of the URL. ### How was this tested? Added specs. Fixes [CW-6934](https://linear.app/chatwoot/issue/CW-6934/) Co-authored-by: Sony Mathew Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- lib/custom_markdown_renderer.rb | 5 +++-- spec/lib/custom_markdown_renderer_spec.rb | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/custom_markdown_renderer.rb b/lib/custom_markdown_renderer.rb index e7eefe5c8..d5c9a3351 100644 --- a/lib/custom_markdown_renderer.rb +++ b/lib/custom_markdown_renderer.rb @@ -77,9 +77,10 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer return nil unless embed_config template = embed_config['template'] - # Use Ruby's built-in named captures with gsub to handle CSS % values + # Use gsub (not format) so CSS `%` values in templates don't need escaping. + # Captured values are HTML-escaped since they land inside HTML attribute contexts. match_data.named_captures.each do |var_name, value| - template = template.gsub("%{#{var_name}}", value) + template = template.gsub("%{#{var_name}}", CGI.escapeHTML(value)) end template end diff --git a/spec/lib/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb index 3415a811d..cb13f6cf9 100644 --- a/spec/lib/custom_markdown_renderer_spec.rb +++ b/spec/lib/custom_markdown_renderer_spec.rb @@ -238,5 +238,23 @@ describe CustomMarkdownRenderer do expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"') end end + + context 'when captured values contain HTML-special characters' do + # CommonMark angle-bracket link destinations `[text]()` permit characters + # like `"` that the embed regex captures would otherwise pass through raw into + # attribute values. Captures are HTML-escaped before interpolation so the + # substituted value cannot break out of the surrounding attribute context. + it 'escapes double quotes in captured YouTube video_id' do + markdown = "\n[demo]()\n" + output = render_markdown(markdown) + expect(output).not_to include('onload="alert(1)"') + expect(output).to include('"') + end + + it 'leaves legitimate alphanumeric IDs untouched' do + output = render_markdown_link('https://www.youtube.com/watch?v=dQw4w9WgXcQ') + expect(output).to include('src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"') + end + end end end