Update Managed Files #207
Workflow file for this run
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
| # Managed by repo-content-updater | |
| # Dependency Cursor Review workflow | |
| # | |
| # Runs Cursor CLI analysis for Dependabot/Renovate PRs by using: | |
| # - Dependabot PR body release notes + commit list | |
| # - An upstream dependency checkout | |
| # - Local usage hints in the target repo | |
| # | |
| # Source documentation: https://cursor.com/docs/cli/github-actions | |
| name: Dependency Cursor Review | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened] | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: "Dependabot/Renovate PR number to analyze" | |
| required: true | |
| type: number | |
| concurrency: | |
| group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', github.workflow_ref, github.event.inputs.pr_number) || github.event_name == 'pull_request' && format('{0}-{1}', github.workflow_ref, github.event.pull_request.number) || github.run_id }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| jobs: | |
| dependency-review: | |
| if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && (github.event.pull_request.user.login == 'dependabot[bot]' || github.event.pull_request.user.login == 'renovate[bot]')) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Resolve target PR context | |
| id: target_pr | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| let pr; | |
| if (context.eventName === 'pull_request') { | |
| pr = context.payload.pull_request; | |
| } else { | |
| const raw = context.payload.inputs?.pr_number; | |
| const prNumber = Number(raw); | |
| if (!Number.isInteger(prNumber) || prNumber <= 0) { | |
| core.setFailed(`Invalid pr_number input: ${raw}`); | |
| return; | |
| } | |
| const { data } = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber, | |
| }); | |
| pr = data; | |
| } | |
| if (!pr) { | |
| core.setFailed('Could not resolve target pull request context.'); | |
| return; | |
| } | |
| const allowedBots = ['dependabot[bot]', 'renovate[bot]']; | |
| if (!allowedBots.includes(pr.user?.login)) { | |
| core.setFailed(`Target PR #${pr.number} is not opened by an allowed bot. Author: ${pr.user?.login}`); | |
| return; | |
| } | |
| core.setOutput('number', String(pr.number)); | |
| core.setOutput('title', pr.title || ''); | |
| core.setOutput('body', pr.body || ''); | |
| core.setOutput('head_sha', pr.head?.sha || ''); | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ steps.target_pr.outputs.head_sha }} | |
| persist-credentials: false | |
| - name: Install Cursor CLI | |
| shell: bash | |
| run: | | |
| installer="$(mktemp)" | |
| trap 'rm -f "$installer"' EXIT | |
| curl https://cursor.com/install -fsSL -o "$installer" | |
| if [ ! -s "$installer" ]; then | |
| echo "Cursor installer download was empty." | |
| exit 1 | |
| fi | |
| bash "$installer" | |
| for bin_dir in "$HOME/.cursor/bin" "$HOME/.local/bin"; do | |
| if [ -d "$bin_dir" ]; then | |
| echo "$bin_dir" >> "$GITHUB_PATH" | |
| export PATH="$bin_dir:$PATH" | |
| fi | |
| done | |
| if ! command -v agent >/dev/null 2>&1; then | |
| echo "Could not locate 'agent' binary after Cursor CLI install." | |
| exit 1 | |
| fi | |
| - name: Extract Dependabot comment context | |
| id: dependabot_context | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const body = ${{ toJSON(steps.target_pr.outputs.body) }} || ''; | |
| function escapeRegex(text) { | |
| return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| } | |
| function extractDetailsSection(text, summaryLabel) { | |
| // Allow optional trailing text after the label (e.g. Renovate appends " (pkgalias)"). | |
| const summaryRe = new RegExp( | |
| `<details[^>]*>\\s*<summary>\\s*${escapeRegex(summaryLabel)}[^<]*</summary>`, | |
| 'i' | |
| ); | |
| const summaryMatch = summaryRe.exec(text); | |
| if (!summaryMatch) return ''; | |
| const start = summaryMatch.index; | |
| const tagRe = /<details\b[^>]*>|<\/details>/gi; | |
| tagRe.lastIndex = start; | |
| let depth = 0; | |
| let end = -1; | |
| let tag; | |
| while ((tag = tagRe.exec(text)) !== null) { | |
| if (tag[0].toLowerCase().startsWith('<details')) { | |
| depth += 1; | |
| } else { | |
| depth -= 1; | |
| } | |
| if (depth === 0) { | |
| end = tagRe.lastIndex; | |
| break; | |
| } | |
| } | |
| if (end === -1) return ''; | |
| const block = text.slice(start, end); | |
| return block | |
| .replace(summaryRe, '') | |
| .replace(/<\/details>\s*$/i, '') | |
| .trim(); | |
| } | |
| // Prefer the dependency link from Dependabot's lead sentence. | |
| // Fallback to any GitHub repo URL if that sentence format changes. | |
| // Both github.com and redirect.github.com (used by Renovate) are matched. | |
| const dependencyRepoMatch = body.match( | |
| /^\s*(?:Bumps|Update(?:s|d)?)\s+\[[^\]]+\]\(\s*https?:\/\/(?:redirect\.)?github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?:[\/#?][^)\s]*)?\s*\)/im | |
| ); | |
| const repoMatch = dependencyRepoMatch || body.match( | |
| /https?:\/\/(?:redirect\.)?github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?=\/|$|[)\]>\s"'?#])/i | |
| ); | |
| const upstreamRepo = repoMatch ? repoMatch[1].replace(/\.git$/i, '') : ''; | |
| // Release notes: Dependabot uses <details><summary>Release notes</summary>, | |
| // Renovate uses <details><summary>org/repo (pkg)</summary> under a ### Release Notes heading. | |
| let releaseNotes = extractDetailsSection(body, 'Release notes'); | |
| if (!releaseNotes && upstreamRepo) { | |
| releaseNotes = extractDetailsSection(body, upstreamRepo); | |
| } | |
| if (!releaseNotes) { | |
| const rnHeading = body.match(/###\s*Release\s+Notes?\s*\n([\s\S]*?)(?=\n---|\n###\s|$)/i); | |
| if (rnHeading) releaseNotes = rnHeading[1].trim(); | |
| } | |
| // Commits: Dependabot uses <details><summary>Commits</summary>. | |
| // Renovate omits this section, so fall back to extracting any SHA-like hex strings from the body. | |
| let commits = extractDetailsSection(body, 'Commits'); | |
| if (!commits) { | |
| const shaMatches = body.match(/\b[0-9a-f]{7,40}\b/gi); | |
| if (shaMatches && shaMatches.length > 0) commits = shaMatches.join('\n'); | |
| } | |
| const title = ${{ toJSON(steps.target_pr.outputs.title) }} || ''; | |
| // Dependabot-style titles (3 groups: pkg, fromVersion, toVersion). | |
| const titleMatch = | |
| title.match(/[Uu]pdate\s+(.+?)\s+requirement\s+from\s+([^\s]+)\s+to\s+([^\s]+)/) || | |
| title.match(/[Bb]ump\s+(.+?)\s+from\s+([^\s]+)\s+to\s+([^\s]+)/) || | |
| title.match(/[Uu]pdate\s+(.+?)\s+from\s+([^\s]+)\s+to\s+([^\s]+)/); | |
| // Renovate-style titles (2 groups: pkg, toVersion — no "from" clause). | |
| const renovateTitleMatch = !titleMatch && ( | |
| title.match(/(?:update|pin)\s+dependency\s+(.+?)\s+to\s+v?([^\s]+)/i) || | |
| title.match(/(?:update|pin)\s+(.+?)\s+(?:action|digest|docker\s+tag)\s+to\s+v?([^\s]+)/i) || | |
| title.match(/(?:update|pin)\s+(.+?)\s+to\s+v?([^\s]+)/i) | |
| ); | |
| let packageName, fromVersion, toVersion; | |
| if (titleMatch) { | |
| packageName = titleMatch[1].trim(); | |
| fromVersion = titleMatch[2].trim(); | |
| toVersion = titleMatch[3].trim(); | |
| } else if (renovateTitleMatch) { | |
| packageName = renovateTitleMatch[1].trim(); | |
| fromVersion = ''; | |
| toVersion = renovateTitleMatch[2].trim(); | |
| } else { | |
| packageName = ''; | |
| fromVersion = ''; | |
| toVersion = ''; | |
| } | |
| // Body-based version fallback: recover missing from/to from Renovate's table (e.g. `1.2.3` -> `1.2.4`). | |
| if (!fromVersion || !toVersion) { | |
| const bodyVersionMatch = body.match(/`([^`\s]+)`\s*(?:->|→)\s*`([^`\s]+)`/); | |
| if (bodyVersionMatch) { | |
| if (!fromVersion) fromVersion = bodyVersionMatch[1].replace(/^v/i, ''); | |
| if (!toVersion) toVersion = bodyVersionMatch[2].replace(/^v/i, ''); | |
| } | |
| } | |
| if (!upstreamRepo) { | |
| core.setFailed('PR body is missing an upstream GitHub repository link.'); | |
| return; | |
| } | |
| const normalizedReleaseNotes = | |
| releaseNotes || 'PR body did not include a Release notes details section.'; | |
| const normalizedCommits = | |
| commits || 'PR body did not include a Commits details section.'; | |
| const out = { | |
| prNumber: Number('${{ steps.target_pr.outputs.number }}'), | |
| upstreamRepo, | |
| packageName, | |
| fromVersion, | |
| toVersion, | |
| releaseNotes: normalizedReleaseNotes, | |
| commits: normalizedCommits, | |
| }; | |
| fs.writeFileSync('dependabot_comment_context.json', JSON.stringify(out, null, 2)); | |
| fs.writeFileSync('dependabot_release_notes.md', normalizedReleaseNotes); | |
| fs.writeFileSync('dependabot_commits.md', normalizedCommits); | |
| core.setOutput('upstream_repo', upstreamRepo); | |
| core.setOutput('package_name', packageName); | |
| core.setOutput('from_version', fromVersion); | |
| core.setOutput('to_version', toVersion); | |
| - name: Prepare upstream checkout directory | |
| run: mkdir -p ".upstream-dependency" | |
| - name: Checkout upstream repository | |
| uses: actions/checkout@v6 | |
| with: | |
| repository: ${{ steps.dependabot_context.outputs.upstream_repo }} | |
| path: .upstream-dependency | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Remove upstream agent-instruction files | |
| shell: bash | |
| run: | | |
| rm -rf .upstream-dependency/.cursor || true | |
| rm -f .upstream-dependency/.cursorrules || true | |
| rm -f .upstream-dependency/.cursorignore || true | |
| rm -f .upstream-dependency/AGENTS.md || true | |
| rm -f .upstream-dependency/CLAUDE.md || true | |
| - name: Run upstream malware scan | |
| id: malware_scan | |
| shell: bash | |
| env: | |
| PACKAGE_NAME: ${{ steps.dependabot_context.outputs.package_name }} | |
| FROM_VERSION: ${{ steps.dependabot_context.outputs.from_version }} | |
| TO_VERSION: ${{ steps.dependabot_context.outputs.to_version }} | |
| MALWARE_WARN_ONLY: >- | |
| 1 | |
| MALWARE_IOC_PATTERNS: >- | |
| ["axios@1\\.14\\.1", "axios@0\\.30\\.4", "plain-crypto-js", "sfrclak\\.com", "@shadanai/openclaw", "@shadanai/[a-z0-9._-]+", "2026\\.3\\.28-2", "2026\\.3\\.28-3", "2026\\.3\\.31-1", "2026\\.3\\.31-2"] | |
| MALWARE_IOC_ALLOWLIST: >- | |
| [] | |
| MALWARE_UNICODE_ALLOWLIST: >- | |
| [] | |
| MALWARE_CONFUSABLE_ALLOWLIST: >- | |
| [] | |
| MALWARE_HEURISTIC_ALLOWLIST: >- | |
| [] | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y ripgrep jq | |
| set -euo pipefail | |
| output_json="malware_scan_report.json" | |
| output_summary="malware_scan_summary.md" | |
| changed_files_txt="upstream_changed_files.txt" | |
| work_dir=".malware-scan" | |
| rm -rf "$work_dir" | |
| mkdir -p "$work_dir" | |
| errors_file="$work_dir/errors.txt" | |
| touched_file="$work_dir/changed_files_raw.txt" | |
| file_list0="$work_dir/changed_files.nul" | |
| unicode_jsonl="$work_dir/unicode.jsonl" | |
| confusable_jsonl="$work_dir/confusable.jsonl" | |
| ioc_jsonl="$work_dir/ioc.jsonl" | |
| heuristic_jsonl="$work_dir/heuristic.jsonl" | |
| : > "$errors_file" | |
| : > "$touched_file" | |
| : > "$file_list0" | |
| : > "$unicode_jsonl" | |
| : > "$confusable_jsonl" | |
| : > "$ioc_jsonl" | |
| : > "$heuristic_jsonl" | |
| if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${MALWARE_IOC_PATTERNS:-[]}"; then MALWARE_IOC_PATTERNS="[]"; fi | |
| if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${MALWARE_IOC_ALLOWLIST:-[]}"; then MALWARE_IOC_ALLOWLIST="[]"; fi | |
| if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${MALWARE_UNICODE_ALLOWLIST:-[]}"; then MALWARE_UNICODE_ALLOWLIST="[]"; fi | |
| if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${MALWARE_CONFUSABLE_ALLOWLIST:-[]}"; then MALWARE_CONFUSABLE_ALLOWLIST="[]"; fi | |
| if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${MALWARE_HEURISTIC_ALLOWLIST:-[]}"; then MALWARE_HEURISTIC_ALLOWLIST="[]"; fi | |
| mapfile -t ioc_allowlist < <(jq -r '.[]?' <<<"$MALWARE_IOC_ALLOWLIST") | |
| mapfile -t unicode_allowlist < <(jq -r '.[]?' <<<"$MALWARE_UNICODE_ALLOWLIST") | |
| mapfile -t confusable_allowlist < <(jq -r '.[]?' <<<"$MALWARE_CONFUSABLE_ALLOWLIST") | |
| mapfile -t heuristic_allowlist < <(jq -r '.[]?' <<<"$MALWARE_HEURISTIC_ALLOWLIST") | |
| mapfile -t ioc_patterns < <(jq -r '.[]?' <<<"$MALWARE_IOC_PATTERNS") | |
| warn_only=true | |
| case "${MALWARE_WARN_ONLY:-1}" in | |
| 0|false|False|no) warn_only=false ;; | |
| esac | |
| resolve_ref() { | |
| local version="$1" | |
| local candidate | |
| [ -n "$version" ] || return 1 | |
| for candidate in \ | |
| "refs/tags/${version}^{commit}" \ | |
| "refs/tags/v${version}^{commit}" \ | |
| "refs/tags/${version}" \ | |
| "refs/tags/v${version}" \ | |
| "${version}" \ | |
| "v${version}" | |
| do | |
| if git -C .upstream-dependency rev-parse --verify --quiet "$candidate" >/dev/null 2>&1; then | |
| git -C .upstream-dependency rev-parse --verify --quiet "$candidate" | head -n1 | |
| return 0 | |
| fi | |
| done | |
| return 1 | |
| } | |
| from_ref="" | |
| to_ref="" | |
| resolved_range="" | |
| resolution_strategy="unresolved" | |
| from_ref="$(resolve_ref "${FROM_VERSION:-}" || true)" | |
| to_ref="$(resolve_ref "${TO_VERSION:-}" || true)" | |
| if [ -n "$from_ref" ] && [ -n "$to_ref" ]; then | |
| resolved_range="${from_ref}..${to_ref}" | |
| if ! git -C .upstream-dependency diff --name-only "$resolved_range" > "$touched_file" 2>>"$errors_file"; then | |
| resolution_strategy="tag_range_failed" | |
| resolved_range="" | |
| : > "$touched_file" | |
| else | |
| resolution_strategy="tag_range" | |
| fi | |
| fi | |
| if [ ! -s "$touched_file" ] && [ -f "dependabot_commits.md" ]; then | |
| while IFS= read -r sha; do | |
| resolved_sha="$(git -C .upstream-dependency rev-parse --verify --quiet "${sha}^{commit}" 2>/dev/null || true)" | |
| [ -n "$resolved_sha" ] || continue | |
| git -C .upstream-dependency show --name-only --pretty=format: "$resolved_sha" >> "$touched_file" 2>>"$errors_file" || true | |
| done < <(rg -o --pcre2 '\b[0-9a-f]{7,40}\b' dependabot_commits.md | sort -u) | |
| if [ -s "$touched_file" ]; then | |
| resolution_strategy="commit_list" | |
| fi | |
| fi | |
| if [ ! -s "$touched_file" ] && [ -n "$to_ref" ]; then | |
| resolution_strategy="to_version_single_commit" | |
| git -C .upstream-dependency show --name-only --pretty=format: "$to_ref" > "$touched_file" 2>>"$errors_file" || true | |
| fi | |
| repo_abs="$(cd .upstream-dependency && pwd -P)" | |
| while IFS= read -r rel; do | |
| [ -n "$rel" ] || continue | |
| abs_path="$(readlink -f ".upstream-dependency/$rel" 2>/dev/null || true)" | |
| if [ -n "$abs_path" ] && [ -f "$abs_path" ] && [[ "$abs_path" == "$repo_abs/"* ]]; then | |
| printf '%s\n' "$rel" | |
| fi | |
| done < "$touched_file" | sort -u > "$changed_files_txt" | |
| while IFS= read -r rel; do | |
| [ -n "$rel" ] || continue | |
| printf '%s\0' ".upstream-dependency/$rel" >> "$file_list0" | |
| done < "$changed_files_txt" | |
| is_allowlisted() { | |
| local entry="$1" | |
| shift | |
| local pattern | |
| for pattern in "$@"; do | |
| [ -n "$pattern" ] || continue | |
| if [[ "$entry" =~ $pattern ]]; then | |
| return 0 | |
| fi | |
| done | |
| return 1 | |
| } | |
| append_finding() { | |
| local out_file="$1" | |
| local kind="$2" | |
| local pattern="$3" | |
| local rel="$4" | |
| local line="$5" | |
| local match="$6" | |
| local line_num=0 | |
| local max_match_len=500 | |
| if [[ "$line" =~ ^[0-9]+$ ]]; then | |
| line_num="$line" | |
| fi | |
| if [ "${#match}" -gt "$max_match_len" ]; then | |
| match="${match:0:$max_match_len}...[truncated]" | |
| fi | |
| jq -nc \ | |
| --arg kind "$kind" \ | |
| --arg pattern "$pattern" \ | |
| --arg file "$rel" \ | |
| --arg match "$match" \ | |
| --argjson line "$line_num" \ | |
| '{kind: $kind, pattern: $pattern, file: $file, line: $line, match: $match}' >> "$out_file" | |
| } | |
| scan_with_rg() { | |
| local kind="$1" | |
| local regex="$2" | |
| local out_file="$3" | |
| local case_flag="$4" | |
| shift 4 | |
| local allowlist=("$@") | |
| local hit file rest line text rel entry | |
| if [ ! -s "$file_list0" ]; then | |
| return 0 | |
| fi | |
| local rg_args=(--pcre2 --hidden -nH --no-heading --color=never) | |
| if [ "$case_flag" = "i" ]; then | |
| rg_args+=(-i) | |
| fi | |
| rg_args+=("$regex") | |
| while IFS= read -r hit; do | |
| file="${hit%%:*}" | |
| rest="${hit#*:}" | |
| line="${rest%%:*}" | |
| text="${rest#*:}" | |
| rel="${file#.upstream-dependency/}" | |
| entry="${kind}:${rel}:${line}:${text}" | |
| if is_allowlisted "$entry" "${allowlist[@]}"; then | |
| continue | |
| fi | |
| append_finding "$out_file" "$kind" "$regex" "$rel" "$line" "$text" | |
| done < <(xargs -0 rg "${rg_args[@]}" < "$file_list0" || true) | |
| } | |
| # GlassWorm defense: raw-byte unicode/control scan. These characters can be | |
| # visually invisible in rendered diffs, so we scan bytes before LLM review. | |
| unicode_regex='[\x{FE00}-\x{FE0F}\x{E0100}-\x{E01EF}\x{200B}-\x{200D}\x{FEFF}\x{3164}\x{115F}\x{1160}\x{202A}-\x{202E}\x{2066}-\x{2069}]' | |
| # Confusable glyph scan to catch visually similar operator/identifier swaps. | |
| confusable_regex='[\x{FF0F}\x{2215}\x{2044}\x{2217}\x{066D}\x{01C3}\x{FF01}\x{FE57}\x{A789}\x{FF02}\x{FF07}\x{FF40}\x{FE68}\x{FF3C}]' | |
| scan_with_rg "unicode" "$unicode_regex" "$unicode_jsonl" "" "${unicode_allowlist[@]}" | |
| scan_with_rg "confusable" "$confusable_regex" "$confusable_jsonl" "" "${confusable_allowlist[@]}" | |
| for ioc_pattern in "${ioc_patterns[@]}"; do | |
| [ -n "$ioc_pattern" ] || continue | |
| scan_with_rg "ioc_match" "$ioc_pattern" "$ioc_jsonl" "i" "${ioc_allowlist[@]}" | |
| done | |
| heuristic_specs=( | |
| 'eval_function_blank_arg::\b(?:eval|Function)\s*\(\s*([\"'"'"'`])\s*\1\s*\)' | |
| 'codepoint_decoder::(?:codePointAt|fromCodePoint|charCodeAt)\s*\(' | |
| 'dynamic_require_import::\b(?:require|import)\s*\(\s*(?:atob|Buffer\.from|decodeURIComponent|[\"'"'"'`].*https?://)' | |
| 'shell_process_spawn::\b(?:child_process|spawn|exec|subprocess|os\.system)\b' | |
| 'filesystem_persistence::(?:/etc/(?:init\\.d|rc\\.local|cron\\.)|/Library/Launch(?:Agents|Daemons)|\\.config/autostart|CurrentVersion\\\\Run|crontab)' | |
| 'network_c2_indicator::https?://(?:[0-9]{1,3}(?:\.[0-9]{1,3}){3}|[^/\s\"'"'"')]+(?:\.onion|\.top|\.xyz|\.click|\.gq|\.tk|\.ml|\.cf|\.ga|\.work|\.support)|(?:pastebin\.com|raw\.githubusercontent\.com|discord(?:app)?\.com/api/webhooks|api\.telegram\.org|ipfs\.io|gateway\.pinata\.cloud|tinyurl\.com|bit\.ly|sfrclak\.com))' | |
| 'credential_exfil_indicator::(?:token|secret|api[_-]?key|authorization|cookie|passwd|password).{0,80}(?:fetch|axios|http|https|curl|wget|post|send)' | |
| 'lifecycle_script::"(?:preinstall|install|postinstall)"\s*:' | |
| 'obfuscation_indicator::\b(?:atob|btoa|base64|xor|decodeURIComponent)\b' | |
| ) | |
| for spec in "${heuristic_specs[@]}"; do | |
| kind="${spec%%::*}" | |
| regex="${spec#*::}" | |
| scan_with_rg "$kind" "$regex" "$heuristic_jsonl" "i" "${heuristic_allowlist[@]}" | |
| done | |
| if [ -s "$changed_files_txt" ]; then | |
| while IFS= read -r rel; do | |
| [ -n "$rel" ] || continue | |
| if [[ "$rel" == .github/workflows/* ]]; then | |
| entry="workflow_path_touch:${rel}:0:path" | |
| if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then | |
| append_finding "$heuristic_jsonl" "workflow_path_touch" '\.github/workflows/' "$rel" "0" "path-touch" | |
| fi | |
| fi | |
| if [[ "$rel" =~ \.(png|jpg|jpeg|gif|bmp|webp|svg|ico|mp3|mp4|mov|avi|wav)$ ]]; then | |
| entry="steganography_media_change:${rel}:0:media-file-changed" | |
| if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then | |
| append_finding "$heuristic_jsonl" "steganography_media_change" 'media-file-change' "$rel" "0" "media-file-changed" | |
| fi | |
| fi | |
| done < "$changed_files_txt" | |
| fi | |
| # Minified/bundled payload heuristic: very long lines outside common build output dirs. | |
| if [ -s "$file_list0" ]; then | |
| while IFS= read -r hit; do | |
| file="${hit%%:*}" | |
| rest="${hit#*:}" | |
| line="${rest%%:*}" | |
| text="${rest#*:}" | |
| rel="${file#.upstream-dependency/}" | |
| if [[ "$rel" =~ ^(dist/|build/|coverage/|vendor/) ]]; then | |
| continue | |
| fi | |
| entry="minified_payload_indicator:${rel}:${line}:${text}" | |
| if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then | |
| append_finding "$heuristic_jsonl" "minified_payload_indicator" '.{1200,}' "$rel" "$line" "$text" | |
| fi | |
| done < <(xargs -0 rg --pcre2 --hidden -nH --no-heading --color=never '.{1200,}' < "$file_list0" || true) | |
| fi | |
| # Dependency integrity and Dependabot-context checks. | |
| node_vendor_count="$( (rg -n '^(node_modules/|vendor/)' "$changed_files_txt" || true) | wc -l | tr -d ' ' )" | |
| lockfile_count="$( (rg -n '(?:^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|npm-shrinkwrap\.json|Gemfile\.lock|go\.sum|Cargo\.lock|poetry\.lock|Pipfile\.lock)$' "$changed_files_txt" || true) | wc -l | tr -d ' ' )" | |
| if [ -n "${TO_VERSION:-}" ] && [ -z "$to_ref" ]; then | |
| entry="ghost_version_or_missing_tag:${TO_VERSION}:0:missing-tag" | |
| if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then | |
| append_finding "$heuristic_jsonl" "ghost_version_or_missing_tag" 'missing-tag' "${PACKAGE_NAME:-unknown}" "0" "${TO_VERSION}" | |
| fi | |
| fi | |
| parse_semver_num() { | |
| local version="$1" | |
| version="${version#v}" | |
| version="${version%%[-+]*}" | |
| local major minor patch | |
| IFS='.' read -r major minor patch <<< "$version" | |
| if [[ "$major" =~ ^[0-9]+$ ]] && [[ "${minor:-0}" =~ ^[0-9]+$ ]] && [[ "${patch:-0}" =~ ^[0-9]+$ ]]; then | |
| echo "$((10#$major)) $((10#${minor:-0})) $((10#${patch:-0}))" | |
| fi | |
| } | |
| from_semver="$(parse_semver_num "${FROM_VERSION:-}" || true)" | |
| to_semver="$(parse_semver_num "${TO_VERSION:-}" || true)" | |
| if [ -n "$from_semver" ] && [ -n "$to_semver" ]; then | |
| read -r fmaj fmin _ <<< "$from_semver" | |
| read -r tmaj tmin _ <<< "$to_semver" | |
| if [ "$tmaj" -gt "$fmaj" ] || { [ "$tmaj" -eq "$fmaj" ] && [ "$((tmin - fmin))" -gt 5 ]; }; then | |
| entry="version_jump_anomaly:${PACKAGE_NAME:-unknown}:0:${FROM_VERSION:-}->${TO_VERSION:-}" | |
| if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then | |
| append_finding "$heuristic_jsonl" "version_jump_anomaly" 'semver-jump' "${PACKAGE_NAME:-unknown}" "0" "${FROM_VERSION:-}->${TO_VERSION:-}" | |
| fi | |
| fi | |
| fi | |
| if [ -n "$from_ref" ] && [ -n "$to_ref" ]; then | |
| from_dep_count="$(git -C .upstream-dependency show "${from_ref}:package.json" 2>/dev/null | jq -r '((.dependencies // {})|length)+((.optionalDependencies // {})|length)+((.peerDependencies // {})|length)' 2>/dev/null || true)" | |
| to_dep_count="$(git -C .upstream-dependency show "${to_ref}:package.json" 2>/dev/null | jq -r '((.dependencies // {})|length)+((.optionalDependencies // {})|length)+((.peerDependencies // {})|length)' 2>/dev/null || true)" | |
| if [[ "$from_dep_count" =~ ^[0-9]+$ ]] && [[ "$to_dep_count" =~ ^[0-9]+$ ]]; then | |
| if [ "$to_dep_count" -gt "$((from_dep_count + 8))" ]; then | |
| entry="dependency_count_jump:${PACKAGE_NAME:-unknown}:0:${from_dep_count}->${to_dep_count}" | |
| if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then | |
| append_finding "$heuristic_jsonl" "dependency_count_jump" 'dependency-count' "${PACKAGE_NAME:-unknown}" "0" "${from_dep_count}->${to_dep_count}" | |
| fi | |
| fi | |
| if [ "${PACKAGE_NAME:-}" = "axios" ] && [ "$from_dep_count" -gt 0 ] && [ "$to_dep_count" -gt "$((from_dep_count + 2))" ]; then | |
| entry="axios_dependency_count_anomaly:${PACKAGE_NAME:-unknown}:0:${from_dep_count}->${to_dep_count}" | |
| if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then | |
| append_finding "$heuristic_jsonl" "axios_dependency_count_anomaly" 'axios-dependency-jump' "${PACKAGE_NAME:-unknown}" "0" "${from_dep_count}->${to_dep_count}" | |
| fi | |
| fi | |
| fi | |
| fi | |
| if [ -n "$resolved_range" ]; then | |
| while IFS= read -r rel; do | |
| [ -n "$rel" ] || continue | |
| added_count="0" | |
| if [[ "$rel" =~ (package-lock\.json|npm-shrinkwrap\.json)$ ]]; then | |
| added_count="$( (git -C .upstream-dependency diff -U0 "$resolved_range" -- "$rel" | rg -n '^\+[^+].*"node_modules/[^"]+"\s*:' || true) | wc -l | tr -d ' ' )" | |
| elif [[ "$rel" =~ yarn\.lock$ ]]; then | |
| added_count="$( (git -C .upstream-dependency diff -U0 "$resolved_range" -- "$rel" | rg -n '^\+[^+].+@[^:]+:' || true) | wc -l | tr -d ' ' )" | |
| elif [[ "$rel" =~ (pnpm-lock\.yaml|go\.sum|Cargo\.lock|Gemfile\.lock|poetry\.lock|Pipfile\.lock)$ ]]; then | |
| added_count="$( (git -C .upstream-dependency diff -U0 "$resolved_range" -- "$rel" | rg -n '^\+[^+]' || true) | wc -l | tr -d ' ' )" | |
| fi | |
| if [ "${added_count:-0}" -gt 0 ]; then | |
| entry="transitive_dependencies_added:${rel}:0:${added_count}" | |
| if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then | |
| append_finding "$heuristic_jsonl" "transitive_dependencies_added" 'transitive-diff' "$rel" "0" "$added_count" | |
| fi | |
| fi | |
| done < "$changed_files_txt" | |
| fi | |
| # Lock checksum/integrity anomaly checks (structure-level, not registry verification). | |
| if [ -s "$file_list0" ]; then | |
| while IFS= read -r hit; do | |
| file="${hit%%:*}" | |
| rest="${hit#*:}" | |
| line="${rest%%:*}" | |
| text="${rest#*:}" | |
| rel="${file#.upstream-dependency/}" | |
| entry="lock_hash_anomaly:${rel}:${line}:${text}" | |
| if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then | |
| append_finding "$heuristic_jsonl" "lock_hash_anomaly" 'integrity-format' "$rel" "$line" "$text" | |
| fi | |
| done < <(xargs -0 rg --pcre2 --hidden -nH --no-heading --color=never '"integrity"\s*:\s*"(?!sha(?:1|256|384|512)-)[^"]+"' < "$file_list0" || true) | |
| fi | |
| # Typosquatting indicators from changed dependency metadata. | |
| if [ -s "$file_list0" ]; then | |
| while IFS= read -r hit; do | |
| file="${hit%%:*}" | |
| rest="${hit#*:}" | |
| line="${rest%%:*}" | |
| text="${rest#*:}" | |
| rel="${file#.upstream-dependency/}" | |
| entry="typosquatting_indicator:${rel}:${line}:${text}" | |
| if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then | |
| append_finding "$heuristic_jsonl" "typosquatting_indicator" '(xn--|[-._]{2,}|[^[:ascii:]])' "$rel" "$line" "$text" | |
| fi | |
| done < <(xargs -0 rg --pcre2 --hidden -nH --no-heading --color=never '(?i)(xn--[a-z0-9-]+|@[a-z0-9._-]*[._-]{2,}[a-z0-9._-]*|\"[^"]*[^[:ascii:]][^"]*\"\s*:)' < "$file_list0" || true) | |
| fi | |
| # Maintainer drift check for npm packages when npm is available. | |
| if command -v npm >/dev/null 2>&1 && [ -n "${PACKAGE_NAME:-}" ] && [ -n "${FROM_VERSION:-}" ] && [ -n "${TO_VERSION:-}" ]; then | |
| from_maint="$(npm view "${PACKAGE_NAME}@${FROM_VERSION}" maintainers --json 2>/dev/null | jq -cS '. // []' 2>/dev/null || true)" | |
| to_maint="$(npm view "${PACKAGE_NAME}@${TO_VERSION}" maintainers --json 2>/dev/null | jq -cS '. // []' 2>/dev/null || true)" | |
| if [ -n "$from_maint" ] && [ -n "$to_maint" ] && [ "$from_maint" != "$to_maint" ]; then | |
| entry="maintainer_drift:${PACKAGE_NAME}:0:${FROM_VERSION}->${TO_VERSION}" | |
| if ! is_allowlisted "$entry" "${heuristic_allowlist[@]}"; then | |
| append_finding "$heuristic_jsonl" "maintainer_drift" 'npm-maintainers' "${PACKAGE_NAME}" "0" "${FROM_VERSION}->${TO_VERSION}" | |
| fi | |
| fi | |
| fi | |
| unicode_json="$work_dir/unicode.json" | |
| confusable_json="$work_dir/confusable.json" | |
| ioc_json="$work_dir/ioc.json" | |
| heuristic_json="$work_dir/heuristic.json" | |
| jq -s '.' "$unicode_jsonl" > "$unicode_json" | |
| jq -s '.' "$confusable_jsonl" > "$confusable_json" | |
| jq -s '.' "$ioc_jsonl" > "$ioc_json" | |
| jq -s '.' "$heuristic_jsonl" > "$heuristic_json" | |
| unicode_count="$(jq 'length' "$unicode_json")" | |
| confusable_count="$(jq 'length' "$confusable_json")" | |
| ioc_count="$(jq 'length' "$ioc_json")" | |
| heuristic_count="$(jq 'length' "$heuristic_json")" | |
| total_count=$((unicode_count + confusable_count + ioc_count + heuristic_count)) | |
| changed_count="$(jq -Rs 'split("\n") | map(select(length > 0)) | length' "$changed_files_txt")" | |
| status="clean" | |
| if [ "$total_count" -gt 0 ]; then | |
| if [ "$warn_only" = true ]; then | |
| status="warn" | |
| else | |
| status="fail" | |
| fi | |
| fi | |
| changed_files_json_file="$work_dir/changed_files.json" | |
| errors_json_file="$work_dir/errors.json" | |
| jq -Rs 'split("\n") | map(select(length > 0))' "$changed_files_txt" > "$changed_files_json_file" | |
| jq -Rs 'split("\n") | map(select(length > 0))' "$errors_file" > "$errors_json_file" | |
| warn_only_json=false | |
| if [ "$warn_only" = true ]; then warn_only_json=true; fi | |
| jq -n \ | |
| --arg status "$status" \ | |
| --argjson warn_only "$warn_only_json" \ | |
| --arg resolution_strategy "$resolution_strategy" \ | |
| --arg resolved_range "$resolved_range" \ | |
| --arg resolved_from "$from_ref" \ | |
| --arg resolved_to "$to_ref" \ | |
| --argjson changed_files_count "$changed_count" \ | |
| --slurpfile changed_files "$changed_files_json_file" \ | |
| --slurpfile errors "$errors_json_file" \ | |
| --slurpfile unicode "$unicode_json" \ | |
| --slurpfile confusable "$confusable_json" \ | |
| --slurpfile ioc "$ioc_json" \ | |
| --slurpfile heuristic "$heuristic_json" \ | |
| '{ | |
| status: $status, | |
| warn_only: $warn_only, | |
| resolution_strategy: $resolution_strategy, | |
| resolved_range: $resolved_range, | |
| resolved_from: $resolved_from, | |
| resolved_to: $resolved_to, | |
| changed_files_count: $changed_files_count, | |
| changed_files: $changed_files[0], | |
| errors: $errors[0], | |
| findings: { | |
| unicode: $unicode[0], | |
| confusable: $confusable[0], | |
| ioc: $ioc[0], | |
| heuristic: $heuristic[0] | |
| } | |
| }' > "$output_json" | |
| { | |
| echo "## Malware Scan Summary" | |
| echo | |
| echo "- Status: **$status**" | |
| echo "- Warn only mode: \`$warn_only\`" | |
| echo "- Changed upstream files scanned: \`$changed_count\`" | |
| echo "- Resolution strategy: \`$resolution_strategy\`" | |
| echo "- Changed node/vendor paths: \`${node_vendor_count:-0}\`" | |
| echo "- Changed lockfiles: \`${lockfile_count:-0}\`" | |
| if [ -n "$resolved_range" ]; then | |
| echo "- Resolved upstream range: \`$resolved_range\`" | |
| fi | |
| echo "- Resolved refs: from=\`${from_ref:-n/a}\` to=\`${to_ref:-n/a}\`" | |
| echo "- Unicode findings (post-allowlist): \`$unicode_count\`" | |
| echo "- Confusable findings (post-allowlist): \`$confusable_count\`" | |
| echo "- IOC findings (post-allowlist): \`$ioc_count\`" | |
| echo "- Heuristic findings (post-allowlist): \`$heuristic_count\`" | |
| if [ "$total_count" -gt 0 ]; then | |
| echo | |
| echo "### Top findings" | |
| jq -r -s 'add | .[:20] | .[] | "- `\(.file):\(.line)` \(.kind) :: `\(.match | gsub("`"; ""))`"' \ | |
| "$unicode_json" "$confusable_json" "$ioc_json" "$heuristic_json" | |
| fi | |
| } > "$output_summary" | |
| _gheof="GHEOF_$(uuidgen)" | |
| { | |
| echo "status=$status" | |
| echo "changed_files_count=$changed_count" | |
| echo "summary<<${_gheof}" | |
| cat "$output_summary" | |
| echo "${_gheof}" | |
| } >> "$GITHUB_OUTPUT" | |
| if [ "$total_count" -gt 0 ] && [ "$warn_only" = true ]; then | |
| echo "::warning::Malware scan produced $total_count finding(s). Continuing because warn-only mode is enabled." | |
| elif [ "$total_count" -gt 0 ] && [ "$warn_only" = false ]; then | |
| echo "Malware scan failed with $total_count finding(s)." >&2 | |
| exit 1 | |
| fi | |
| - name: Gather local usage hints | |
| shell: bash | |
| env: | |
| PACKAGE_NAME: ${{ steps.dependabot_context.outputs.package_name }} | |
| run: | | |
| if [ -z "$PACKAGE_NAME" ]; then | |
| echo "No package detected from PR metadata." > package_usage.txt | |
| else | |
| { | |
| echo "Search pattern: $PACKAGE_NAME" | |
| echo | |
| rg -n --fixed-strings --hidden --glob '!.git' --glob '!node_modules' --glob '!.upstream-dependency/**' -- "$PACKAGE_NAME" . || true | |
| } > package_usage.txt | |
| fi | |
| - name: Run Cursor analysis | |
| timeout-minutes: 10 | |
| env: | |
| CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} | |
| PR_TITLE: ${{ steps.target_pr.outputs.title }} | |
| PR_BODY: ${{ steps.target_pr.outputs.body }} | |
| PACKAGE_NAME: ${{ steps.dependabot_context.outputs.package_name }} | |
| FROM_VERSION: ${{ steps.dependabot_context.outputs.from_version }} | |
| TO_VERSION: ${{ steps.dependabot_context.outputs.to_version }} | |
| shell: bash | |
| run: | | |
| if [ -z "$CURSOR_API_KEY" ]; then | |
| echo '{"result":"CURSOR_API_KEY is not set; analysis was skipped."}' > cursor_output.json | |
| exit 0 | |
| fi | |
| python3 - <<'PY' | |
| import os | |
| def read_file(path): | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| return f.read() | |
| except FileNotFoundError: | |
| return "" | |
| base_context = f""" | |
| This is a dependency bot PR review request. | |
| PR title: | |
| {os.getenv("PR_TITLE", "")} | |
| PR body: | |
| {os.getenv("PR_BODY", "")} | |
| Package metadata: | |
| - package: {os.getenv("PACKAGE_NAME", "")} | |
| - from: {os.getenv("FROM_VERSION", "")} | |
| - to: {os.getenv("TO_VERSION", "")} | |
| PR context JSON: | |
| {read_file("dependabot_comment_context.json")} | |
| Release notes: | |
| {read_file("dependabot_release_notes.md")} | |
| Commits: | |
| {read_file("dependabot_commits.md")} | |
| Local usage hints (non-authoritative rg hits): | |
| {read_file("package_usage.txt")[:12000]} | |
| Repository layout: | |
| - Current repository root: . | |
| - Upstream dependency repository: .upstream-dependency (full git history is available) | |
| """.strip() | |
| malware_context = f""" | |
| {base_context} | |
| Malware scan summary: | |
| {read_file("malware_scan_summary.md")} | |
| Malware scan report JSON: | |
| {read_file("malware_scan_report.json")[:16000]} | |
| """.strip() | |
| malware_prompt = f""" | |
| {malware_context} | |
| Task 1: Supply-chain malware review. | |
| Review the dependency update for signs of compromise. Use this checklist and explicitly consider each category: | |
| ### Classic obfuscation | |
| - Obfuscated code (base64, exec, eval, XOR, encoded strings) | |
| - Network calls to unexpected hosts (non-package-related URLs) | |
| - File system writes to startup/persistence locations | |
| - Process spawning, shell commands | |
| - Steganography or data hiding in media files | |
| - Credential/token exfiltration | |
| - Typosquatting indicators | |
| - Suspicious npm lifecycle scripts (preinstall, install, postinstall) in package.json | |
| - Dynamic require() or import() of obfuscated or encoded URLs | |
| - Minified or bundled payloads added outside normal build artifacts | |
| ### Invisible Unicode / GlassWorm technique | |
| This class of attack can appear blank in rendered code review. Flag: | |
| - Unicode Private Use Area characters (U+FE00–U+FE0F, U+E0100–U+E01EF) | |
| - Zero-width characters (U+200B, U+200C, U+200D, U+FEFF) | |
| - Hangul filler characters (U+3164, U+115F, U+1160) | |
| - Bidi control characters (U+202A–U+202E, U+2066–U+2069, Trojan Source) | |
| - Homoglyph substitutions in operators/identifiers (e.g. /, ∗, ǃ) | |
| - Strings that look empty in diff but have non-zero bytes | |
| - eval()/Function() receiving visually blank strings | |
| - Decoder patterns using codePointAt()/fromCodePoint()/charCodeAt() for hidden payload assembly | |
| - Commit metadata consistency anomalies suggesting force-push/rewrite concealment | |
| ### Dependency integrity | |
| - Unexpected new transitive dependencies vs prior dependency graph | |
| - Known-safe packages with sudden dependency count increase (e.g., axios expected dependency count) | |
| - Lock file hashes/checksums inconsistent with expected integrity formats or release metadata | |
| - Version jumps skipping many semver minors, or ghost versions missing corresponding tags/releases | |
| - Maintainer/publisher identity drift from historical account patterns | |
| ### Dependabot-specific context | |
| - Focus on files changed in node_modules/, vendor/, and dependency/lock manifests (package-lock.json, yarn.lock, pnpm-lock.yaml, Gemfile.lock, go.sum, Cargo.toml, Cargo.lock, .cargo/config.toml, .cargo/config, pyproject.toml, poetry.lock, Pipfile.lock, requirements.txt, requirements-dev.txt, requirements/*.txt, etc.) | |
| - Flag new transitive dependencies introduced alongside the direct update | |
| - Flag new preinstall/postinstall scripts that were not present previously | |
| - Treat .github/workflows/ modifications as highly suspicious in a pure dependency update PR | |
| Use the provided malware scanner report as hard evidence and incorporate it into your conclusion. | |
| If scanner findings and your interpretation disagree, call that out explicitly. | |
| Start your response with exactly one line: | |
| Verdict: malicious | |
| or: | |
| Verdict: benign | |
| Then explain your reasoning briefly with top evidence. | |
| Do not include intermediate reasoning or self-talk. | |
| Keep it concise and actionable. | |
| """.strip() | |
| compatibility_prompt = f""" | |
| {base_context} | |
| Task 2: Compatibility and adoption analysis. | |
| 1) Where in this repo the dependency appears to be used (treat rg hints as directional, not exhaustive). | |
| 2) Whether those usage sites intersect with likely changed APIs based on release notes, commits, and direct inspection of .upstream-dependency. | |
| 3) Risks / unknowns for runtime/build compatibility. | |
| 4) Recommendation: merge / merge-with-caveats / hold. | |
| Do not include intermediate reasoning or self-talk. | |
| Keep it concise and actionable. | |
| """.strip() | |
| with open("cursor_prompt_malware.txt", "w", encoding="utf-8") as f: | |
| f.write(malware_prompt) | |
| with open("cursor_prompt_compatibility.txt", "w", encoding="utf-8") as f: | |
| f.write(compatibility_prompt) | |
| PY | |
| run_agent_prompt() { | |
| local prompt_file="$1" | |
| local output_file="$2" | |
| if ! agent -f --mode ask -p --output-format json < "$prompt_file" > "$output_file"; then | |
| FALLBACK_PROMPT="$(python3 -c 'from pathlib import Path; import sys; path = sys.argv[1]; max_bytes = 60000; raw = Path(path).read_bytes(); text = raw[:max_bytes].decode("utf-8", errors="ignore"); text += "\n\n[Prompt truncated for CLI argument compatibility.]" if len(raw) > max_bytes else ""; print(text, end="")' "$prompt_file")" | |
| agent -f --mode ask -p --output-format json "$FALLBACK_PROMPT" > "$output_file" | |
| fi | |
| } | |
| run_agent_prompt "cursor_prompt_malware.txt" "cursor_output_malware.json" | |
| run_agent_prompt "cursor_prompt_compatibility.txt" "cursor_output_compatibility.json" | |
| python3 - <<'PY' | |
| import json | |
| def load_any(path): | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| raw = f.read() | |
| except FileNotFoundError: | |
| return {"result": f"Missing output file: {path}"} | |
| try: | |
| return json.loads(raw) | |
| except Exception: | |
| return {"result": raw} | |
| def extract_text(payload): | |
| if not isinstance(payload, dict): | |
| try: | |
| return json.dumps(payload, indent=2) | |
| except Exception: | |
| return str(payload) | |
| for key in ("result", "output", "text", "message"): | |
| val = payload.get(key) | |
| if isinstance(val, str) and val.strip(): | |
| return val | |
| try: | |
| return json.dumps(payload, indent=2) | |
| except Exception: | |
| return str(payload) | |
| malware_payload = load_any("cursor_output_malware.json") | |
| compatibility_payload = load_any("cursor_output_compatibility.json") | |
| malware_text = extract_text(malware_payload) | |
| compatibility_text = extract_text(compatibility_payload) | |
| combined_text = ( | |
| "## Supply-Chain Malware Review\n\n" | |
| f"{malware_text}\n\n" | |
| "## Compatibility Analysis\n\n" | |
| f"{compatibility_text}" | |
| ) | |
| combined = { | |
| "result": combined_text, | |
| "malware_review": malware_payload, | |
| "compatibility_review": compatibility_payload, | |
| } | |
| with open("cursor_output.json", "w", encoding="utf-8") as f: | |
| json.dump(combined, f, indent=2) | |
| PY | |
| - name: Upload malware scan artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: dependabot-malware-scan-${{ steps.target_pr.outputs.number }} | |
| if-no-files-found: warn | |
| path: | | |
| malware_scan_report.json | |
| malware_scan_summary.md | |
| upstream_changed_files.txt | |
| - name: Post or update PR comment | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const marker = '<!-- cursor-dependabot-review -->'; | |
| const analysisMaxLen = 48000; | |
| const malwareMaxLen = 10000; | |
| const githubCommentLimit = 65536; | |
| const malwareScanStatus = ${{ toJSON(steps.malware_scan.outputs.status) }} || ''; | |
| const malwareScanChangedCount = ${{ toJSON(steps.malware_scan.outputs.changed_files_count) }} || ''; | |
| const malwareScanSummaryOutput = ${{ toJSON(steps.malware_scan.outputs.summary) }} || ''; | |
| function readText(path, fallback = '') { | |
| try { | |
| return fs.readFileSync(path, 'utf8'); | |
| } catch (_) { | |
| return fallback; | |
| } | |
| } | |
| const raw = readText('cursor_output.json', '{"result":"No Cursor output generated."}'); | |
| let parsed; | |
| try { | |
| parsed = JSON.parse(raw); | |
| } catch (_) { | |
| parsed = { result: raw }; | |
| } | |
| const analysis = | |
| parsed.result || | |
| parsed.output || | |
| parsed.text || | |
| parsed.message || | |
| raw; | |
| const analysisText = | |
| typeof analysis === 'string' | |
| ? analysis | |
| : JSON.stringify(analysis, null, 2) || String(analysis); | |
| const malwareSummaryFallback = | |
| (typeof malwareScanSummaryOutput === 'string' && malwareScanSummaryOutput.trim()) || | |
| [ | |
| '## Malware Scan Summary', | |
| '', | |
| `- Status: **${malwareScanStatus || 'unknown'}**`, | |
| `- Changed upstream files scanned: \`${malwareScanChangedCount || 'unknown'}\``, | |
| '- Scanner output file missing.', | |
| ].join('\n'); | |
| const malwareSummary = readText('malware_scan_summary.md', malwareSummaryFallback); | |
| let trimmedAnalysis = analysisText.slice(0, analysisMaxLen); | |
| let trimmedMalware = malwareSummary.slice(0, malwareMaxLen); | |
| const renderBody = (analysisPart, malwarePart) => | |
| [ | |
| marker, | |
| '## 🤖 Cursor Dependency Analysis', | |
| '', | |
| analysisPart, | |
| '', | |
| '---', | |
| '', | |
| malwarePart, | |
| ].join('\n'); | |
| let body = renderBody(trimmedAnalysis, trimmedMalware); | |
| if (body.length > githubCommentLimit) { | |
| const allowedAnalysis = Math.max( | |
| 0, | |
| analysisMaxLen - (body.length - githubCommentLimit) - 256 | |
| ); | |
| trimmedAnalysis = analysisText.slice(0, allowedAnalysis); | |
| body = renderBody(trimmedAnalysis, trimmedMalware); | |
| } | |
| const { owner, repo } = context.repo; | |
| const issue_number = Number('${{ steps.target_pr.outputs.number }}'); | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number, | |
| per_page: 100, | |
| }); | |
| const markerComments = comments | |
| .filter((c) => typeof c.body === 'string' && c.body.includes(marker)) | |
| .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); | |
| const latestMarker = markerComments.length > 0 ? markerComments[markerComments.length - 1] : null; | |
| // Hybrid policy: default to updating the latest managed marker comment. | |
| // If any non-managed commentary (human or bot) appears after it, create a | |
| // new managed comment so prior discussion stays anchored to older analysis. | |
| const hasNonManagedCommentaryAfterLatest = | |
| latestMarker && | |
| comments.some( | |
| (c) => | |
| c.id !== latestMarker.id && | |
| !(typeof c.body === 'string' && c.body.includes(marker)) && | |
| new Date(c.created_at).getTime() > new Date(latestMarker.created_at).getTime() | |
| ); | |
| if (latestMarker && !hasNonManagedCommentaryAfterLatest) { | |
| await github.rest.issues.updateComment({ | |
| owner, | |
| repo, | |
| comment_id: latestMarker.id, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number, | |
| body, | |
| }); | |
| } |