From 3484a2771fcfbec7c49790cbd3e35e4d623f01a0 Mon Sep 17 00:00:00 2001 From: rharutyunyan Date: Tue, 20 Oct 2026 16:27:20 +0400 Subject: [PATCH 1/6] ci(DPA2-6761): add the weekday dependency audit Adds the weekday dependency-audit job, which scans the default branch with Trivy and keeps one fix pull request. --- .github/workflows/dependency-audit.yaml | 285 ++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 .github/workflows/dependency-audit.yaml diff --git a/.github/workflows/dependency-audit.yaml b/.github/workflows/dependency-audit.yaml new file mode 100644 index 0000000..386d83a --- /dev/null +++ b/.github/workflows/dependency-audit.yaml @@ -0,0 +1,285 @@ +# Inlined copy of 1inch/workflows/.github/workflows/dependency-audit.yaml (dependency-audit-v1): this repository cannot call that private workflow. +name: Dependency Audit + +on: + schedule: + - cron: "35 1 * * 1-5" + workflow_dispatch: + +permissions: + contents: read + packages: read + +jobs: + dependency-audit: + name: Dependency Audit + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + packages: read + concurrency: + group: dependency-audit-${{ github.repository }} + cancel-in-progress: false + env: + BASE_BRANCH: main + BRANCH: feature/DPA2-6761_dependency-audit + TICKET: DPA2-6761 + REPO: ${{ github.repository }} + TRIVY_VERSION: v0.76.0 + steps: + - name: Require secrets + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + AUDIT_PAT: ${{ secrets.DEPENDENCY_AUDIT_PAT }} + run: | + missing=0 + if [ -z "$CURSOR_API_KEY" ]; then + echo "::error::Secret CURSOR_API_KEY is not set" + missing=1 + fi + if [ -z "$AUDIT_PAT" ]; then + echo "::error::Secret DEPENDENCY_AUDIT_PAT is not set" + missing=1 + fi + exit "$missing" + + - name: Checkout default branch + uses: actions/checkout@v7 + with: + ref: ${{ env.BASE_BRANCH }} + fetch-depth: 0 + token: ${{ secrets.DEPENDENCY_AUDIT_PAT }} + + - name: Install Trivy + uses: aquasecurity/setup-trivy@v0.3.1 + with: + version: ${{ env.TRIVY_VERSION }} + + - name: Scan lockfiles + id: scan + run: | + critical_high_report="$RUNNER_TEMP/trivy-critical-high.json" + medium_low_report="$RUNNER_TEMP/trivy-medium-low.json" + + trivy fs --scanners vuln --pkg-types library --severity CRITICAL,HIGH --ignore-unfixed \ + --format json --output "$critical_high_report" . + trivy fs --skip-db-update --scanners vuln --pkg-types library --severity MEDIUM,LOW \ + --format json --output "$medium_low_report" . + + accepted_advisories=$(git ls-files | grep -E '(^|/)package\.json$' \ + | xargs -r -d '\n' jq -r '.pnpm.auditConfig.ignoreGhsas[]?, .pnpm.auditConfig.ignoreCves[]?' 2>/dev/null \ + | jq -R . | jq -sc 'unique') + count_findings() { + jq --argjson accepted "$accepted_advisories" \ + '[.Results[]?.Vulnerabilities[]? | ([.VulnerabilityID] + (.VendorIDs // [])) as $ids | select(($ids - $accepted) == $ids)] | length' "$1" + } + critical_high=$(count_findings "$critical_high_report") + medium_low=$(count_findings "$medium_low_report") + + echo "critical_high=$critical_high" >> "$GITHUB_OUTPUT" + { + echo "TRIVY_CRITICAL_HIGH_REPORT=$critical_high_report" + echo "TRIVY_MEDIUM_LOW_REPORT=$medium_low_report" + echo "ACCEPTED_ADVISORIES=$accepted_advisories" + } >> "$GITHUB_ENV" + { + echo "### Dependency audit" + echo "" + echo "| Severity | Findings |" + echo "| --- | --- |" + echo "| Critical and high, with a fixed version | $critical_high |" + echo "| Medium and low | $medium_low |" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Detect toolchains + id: toolchains + if: steps.scan.outputs.critical_high != '0' + run: | + tracked() { git ls-files | grep -Eq "(^|/)$1\$"; } + + node=false + pnpm=false + yarn=false + go=false + rust=false + if tracked 'pnpm-lock\.yaml'; then node=true; pnpm=true; fi + if tracked 'yarn\.lock'; then node=true; yarn=true; fi + if tracked 'package-lock\.json'; then node=true; fi + if tracked 'go\.mod'; then go=true; fi + if tracked 'Cargo\.lock'; then rust=true; fi + + node_version_file="" + for candidate in .nvmrc .node-version; do + if [ -f "$candidate" ]; then + node_version_file="$candidate" + break + fi + done + if [ -z "$node_version_file" ] && [ -f package.json ] && jq -e '.volta.node // .engines.node' package.json >/dev/null; then + node_version_file=package.json + fi + node_version="" + if [ -z "$node_version_file" ]; then + node_version="lts/*" + fi + + pnpm_version="10" + if [ -f package.json ]; then + if jq -e '(.packageManager // "") | startswith("pnpm@")' package.json >/dev/null; then + pnpm_version="" + else + pnpm_version=$(jq -r '.engines.pnpm // "10"' package.json) + fi + fi + + go_version_file="" + if [ "$go" = true ]; then + if [ -f go.mod ]; then + go_version_file=go.mod + else + go_version_file=$(git ls-files | grep -E '(^|/)go\.mod$' | head -n 1) + fi + fi + + { + echo "node=$node" + echo "pnpm=$pnpm" + echo "yarn=$yarn" + echo "go=$go" + echo "rust=$rust" + echo "node_version=$node_version" + echo "node_version_file=$node_version_file" + echo "pnpm_version=$pnpm_version" + echo "go_version_file=$go_version_file" + } >> "$GITHUB_OUTPUT" + + - name: Setup pnpm + if: steps.toolchains.outputs.pnpm == 'true' + uses: pnpm/action-setup@v6 + with: + version: ${{ steps.toolchains.outputs.pnpm_version }} + + - name: Setup Node.js with the @1inch registry + if: steps.toolchains.outputs.node == 'true' + uses: actions/setup-node@v7 + with: + node-version: ${{ steps.toolchains.outputs.node_version }} + node-version-file: ${{ steps.toolchains.outputs.node_version_file }} + registry-url: https://npm.pkg.github.com + scope: "@1inch" + + - name: Enable Corepack for Yarn + if: steps.toolchains.outputs.yarn == 'true' + run: corepack enable + + - name: Setup Go + if: steps.toolchains.outputs.go == 'true' + uses: actions/setup-go@v7 + with: + go-version-file: ${{ steps.toolchains.outputs.go_version_file }} + cache: false + + - name: Setup Rust when cargo is missing + if: steps.toolchains.outputs.rust == 'true' + run: | + if ! command -v cargo >/dev/null; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + fi + + - name: Install Cursor CLI and add ~/.local/bin to PATH for ARC runners + if: steps.scan.outputs.critical_high != '0' + run: | + curl https://cursor.com/install -fsS | bash + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$PATH" + command -v agent >/dev/null || { echo "::error::Cursor CLI 'agent' not found in ~/.local/bin after install"; exit 1; } + + - name: Configure git identity + if: steps.scan.outputs.critical_high != '0' + run: | + git config user.name "Cursor Agent" + git config user.email "cursoragent@cursor.com" + + - name: Fix critical and high vulnerabilities + if: steps.scan.outputs.critical_high != '0' + env: + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + GH_TOKEN: ${{ secrets.DEPENDENCY_AUDIT_PAT }} + NODE_AUTH_TOKEN: ${{ github.token }} + PROMPT: | + You are running in a GitHub Actions job that fixes dependency vulnerabilities. Read the environment variables named below with the shell. + + The GitHub CLI `gh` is authenticated through `GH_TOKEN`, and `git push` to origin is authenticated. Node.js, pnpm, Yarn (through Corepack), Go and Rust are installed when the repository uses them, and the private `@1inch` npm registry is configured. + + # Context + - Repository: `$REPO` + - Default branch, checked out now: `$BASE_BRANCH` + - Fix branch: `$BRANCH` + - Jira ticket: `$TICKET` + - Findings to fix: the Trivy JSON report at `$TRIVY_CRITICAL_HIGH_REPORT` (CRITICAL and HIGH, each with a fixed version) + - Findings to list only: the Trivy JSON report at `$TRIVY_MEDIUM_LOW_REPORT` (MEDIUM and LOW) + + Each entry in `Results` has a `Target` (the lockfile path), and each of its `Vulnerabilities` has `PkgName`, `InstalledVersion`, `FixedVersion`, `VulnerabilityID` and `Severity`. + + # Security exceptions + If `SECURITY_EXCEPTIONS.md` exists in the repository root, read it first. Skip every package it lists as an accepted risk: do not fix it and do not count it as unresolved. Mention it in the pull request body as "excepted per SECURITY_EXCEPTIONS.md". + + `$ACCEPTED_ADVISORIES` is a JSON list of advisory IDs from `pnpm.auditConfig.ignoreGhsas` and `ignoreCves` in the repository's `package.json` files. Treat a finding whose `VulnerabilityID` or any of its `VendorIDs` is in that list as an accepted risk in the same way, and mention it in the pull request body as "excepted per pnpm.auditConfig". Trivy already applies `.trivyignore`. + + # How to fix each CRITICAL or HIGH finding + Work in the directory of the lockfile named in `Target`. Use the first option that works: + 1. Bump the direct dependency in that directory's manifest (`package.json`, `go.mod` or `Cargo.toml`) to a version that pulls in `FixedVersion`. + 2. Only when no parent bump reaches `FixedVersion`: add an override (`pnpm.overrides`, npm `overrides`, Yarn `resolutions`, `go get @`, or `cargo update -p --precise `) and explain why in the commit message and the pull request body. + + Regenerate the lockfile with the tool that owns it, in the same directory: + - `pnpm-lock.yaml`: `pnpm install --no-frozen-lockfile` + - `package-lock.json`: `npm install --package-lock-only` + - `yarn.lock`: `yarn install` + - `go.mod`: `go mod tidy` + - `Cargo.lock`: `cargo update -p ` + + Every lockfile is independent: never fix a nested lockfile from the root manifest. + + After fixing, scan the same directory again and repeat until its CRITICAL and HIGH findings are gone or cannot be fixed: + `trivy fs --skip-db-update --scanners vuln --pkg-types library --severity CRITICAL,HIGH --ignore-unfixed ` + + # Scope + - Fix only the CRITICAL and HIGH findings from the report. + - Do not fix MEDIUM or LOW findings; list them in the pull request body. + - Do not change anything unrelated to these findings, and never edit files under `.github/workflows/`. + - Do not run `pnpm audit`, `npm audit` or `yarn audit`. + + # Branch + 1. If `$BRANCH` exists on origin (`git ls-remote --exit-code --heads origin "$BRANCH"`): fetch it, check it out and rebase it onto `origin/$BASE_BRANCH`. Never delete or recreate it. + 2. Otherwise create it from `origin/$BASE_BRANCH`. + 3. Commit with the message `fix($TICKET): resolve critical and high dependency vulnerabilities` and push with `git push --force-with-lease origin "$BRANCH"`. Never use `git push --force`. + + # Pull request + Keep exactly one open pull request from `$BRANCH` into `$BASE_BRANCH`: + - If `gh pr list --head "$BRANCH" --base "$BASE_BRANCH" --state open --json number` returns one, update its body with `gh pr edit`. Never create a second one. + - Otherwise create it with `gh pr create --base "$BASE_BRANCH" --head "$BRANCH" --title "fix($TICKET): resolve critical and high dependency vulnerabilities"`. + + The pull request body must contain: + - `Tracked in $TICKET.` + - A table with the columns Lockfile, Package, Installed, Fixed, Advisory, Severity, Fix applied. + - Whether every CRITICAL and HIGH finding is resolved, and the reason for each one that is not. + - The MEDIUM and LOW findings in a collapsed `
` list. + + If nothing needs changing because every finding is excepted, do not push and do not open a pull request. + + # Result + Finish by writing exactly one of these lines to `$GITHUB_ENV`: + - `echo "UNRESOLVED_CRITICAL_HIGH=true" >> "$GITHUB_ENV"` when any CRITICAL or HIGH finding that is not excepted remains. + - `echo "UNRESOLVED_CRITICAL_HIGH=false" >> "$GITHUB_ENV"` otherwise. + run: agent -p "$PROMPT" --force --model grok-4.7-high --output-format=text + + - name: Fail if critical or high vulnerabilities remain + if: steps.scan.outputs.critical_high != '0' + run: | + if [ "${UNRESOLVED_CRITICAL_HIGH:-true}" != "false" ]; then + echo "::error::Critical or high vulnerabilities remain unresolved. See the open pull request from $BRANCH." + exit 1 + fi + echo "No unresolved critical or high vulnerabilities." From b89b2cd6a1a9f6163fe120f8559498778b10716e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 12:02:40 +0000 Subject: [PATCH 2/6] ci(DPA2-6761): run the audit fix agent through the Cursor SDK The shared workflow now creates a Cursor cloud agent with @cursor/sdk instead of starting the Cursor CLI, so each run is tagged with the repository, use case and ticket and its spend can be attributed. The fix work itself goes to a DeepSeek Flash subagent. CURSOR_API_KEY is now the organization secret, so this repository needs no copy of its own. --- .github/workflows/dependency-audit.yaml | 659 ++++++++++++++++++------ 1 file changed, 499 insertions(+), 160 deletions(-) diff --git a/.github/workflows/dependency-audit.yaml b/.github/workflows/dependency-audit.yaml index 386d83a..44a32fd 100644 --- a/.github/workflows/dependency-audit.yaml +++ b/.github/workflows/dependency-audit.yaml @@ -1,4 +1,7 @@ -# Inlined copy of 1inch/workflows/.github/workflows/dependency-audit.yaml (dependency-audit-v1): this repository cannot call that private workflow. +# Inlined copy of 1inch/workflows/.github/workflows/dependency-audit.yaml: this repository cannot +# call that private reusable workflow. Generated by tools/dependency-audit/inline-workflow.py — +# edit the shared workflow, then regenerate; do not hand-edit this file. +# version: v0.2.0 name: Dependency Audit on: @@ -14,7 +17,7 @@ jobs: dependency-audit: name: Dependency Audit runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 permissions: contents: read packages: read @@ -23,12 +26,18 @@ jobs: cancel-in-progress: false env: BASE_BRANCH: main - BRANCH: feature/DPA2-6761_dependency-audit + BRANCH: feature/DPA2-6761_weekday-caller TICKET: DPA2-6761 REPO: ${{ github.repository }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} TRIVY_VERSION: v0.76.0 + # Pinned: the runner is the only place the findings and the verdict are assembled, so a + # silent minor bump would change how the agent is started. + CURSOR_SDK_VERSION: 1.2.0 steps: - name: Require secrets + # CURSOR_API_KEY resolves to the organization secret; + # DEPENDENCY_AUDIT_PAT is per-repository because it pushes this repository's branch. env: CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} AUDIT_PAT: ${{ secrets.DEPENDENCY_AUDIT_PAT }} @@ -99,181 +108,511 @@ jobs: tracked() { git ls-files | grep -Eq "(^|/)$1\$"; } node=false - pnpm=false - yarn=false go=false rust=false - if tracked 'pnpm-lock\.yaml'; then node=true; pnpm=true; fi - if tracked 'yarn\.lock'; then node=true; yarn=true; fi + if tracked 'pnpm-lock\.yaml'; then node=true; fi + if tracked 'yarn\.lock'; then node=true; fi if tracked 'package-lock\.json'; then node=true; fi if tracked 'go\.mod'; then go=true; fi if tracked 'Cargo\.lock'; then rust=true; fi - node_version_file="" - for candidate in .nvmrc .node-version; do - if [ -f "$candidate" ]; then - node_version_file="$candidate" - break - fi - done - if [ -z "$node_version_file" ] && [ -f package.json ] && jq -e '.volta.node // .engines.node' package.json >/dev/null; then - node_version_file=package.json - fi - node_version="" - if [ -z "$node_version_file" ]; then - node_version="lts/*" - fi - - pnpm_version="10" - if [ -f package.json ]; then - if jq -e '(.packageManager // "") | startswith("pnpm@")' package.json >/dev/null; then - pnpm_version="" - else - pnpm_version=$(jq -r '.engines.pnpm // "10"' package.json) - fi - fi - - go_version_file="" - if [ "$go" = true ]; then - if [ -f go.mod ]; then - go_version_file=go.mod - else - go_version_file=$(git ls-files | grep -E '(^|/)go\.mod$' | head -n 1) - fi - fi + # Only a summary string reaches the agent; it installs the toolchains itself inside + # its own VM, so the runner does not need them. + summary="" + if [ "$node" = true ]; then summary="node ${summary}"; fi + if [ "$go" = true ]; then summary="go ${summary}"; fi + if [ "$rust" = true ]; then summary="rust ${summary}"; fi + echo "toolchains=$(echo "$summary" | xargs)" >> "$GITHUB_OUTPUT" - { - echo "node=$node" - echo "pnpm=$pnpm" - echo "yarn=$yarn" - echo "go=$go" - echo "rust=$rust" - echo "node_version=$node_version" - echo "node_version_file=$node_version_file" - echo "pnpm_version=$pnpm_version" - echo "go_version_file=$go_version_file" - } >> "$GITHUB_OUTPUT" - - - name: Setup pnpm - if: steps.toolchains.outputs.pnpm == 'true' - uses: pnpm/action-setup@v6 - with: - version: ${{ steps.toolchains.outputs.pnpm_version }} - - - name: Setup Node.js with the @1inch registry - if: steps.toolchains.outputs.node == 'true' + - name: Setup Node.js for the Cursor SDK + if: steps.scan.outputs.critical_high != '0' uses: actions/setup-node@v7 with: - node-version: ${{ steps.toolchains.outputs.node_version }} - node-version-file: ${{ steps.toolchains.outputs.node_version_file }} - registry-url: https://npm.pkg.github.com - scope: "@1inch" - - - name: Enable Corepack for Yarn - if: steps.toolchains.outputs.yarn == 'true' - run: corepack enable - - - name: Setup Go - if: steps.toolchains.outputs.go == 'true' - uses: actions/setup-go@v7 - with: - go-version-file: ${{ steps.toolchains.outputs.go_version_file }} - cache: false + node-version: "22" - - name: Setup Rust when cargo is missing - if: steps.toolchains.outputs.rust == 'true' - run: | - if ! command -v cargo >/dev/null; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - fi - - - name: Install Cursor CLI and add ~/.local/bin to PATH for ARC runners + - name: Write the fix-agent runner if: steps.scan.outputs.critical_high != '0' run: | - curl https://cursor.com/install -fsS | bash - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - export PATH="$HOME/.local/bin:$PATH" - command -v agent >/dev/null || { echo "::error::Cursor CLI 'agent' not found in ~/.local/bin after install"; exit 1; } + cat > "$RUNNER_TEMP/run-cursor-agent.mjs" <<'RUNNER_EOF' + /* __RUNNER_SCRIPT__ */ + // Runs the dependency-audit fix agent through the Cursor Agent SDK. + // + // Why the SDK instead of the Cursor CLI: the SDK creates a real cloud agent, so the run is + // attributable. `cloud.metadata` tags the agent for its whole lifetime, and every usage and + // billing export carries `cloud_agent_id`. That is what lets spend be reported per repository, + // per use case and per ticket instead of as one undifferentiated Cursor invoice line. + // + // The fix work is delegated to a named subagent pinned to DeepSeek Flash: the scan already says + // which package, which version and which lockfile, so the loop is mechanical and a large + // frontier model buys nothing. + // + // The scan runs on the GitHub runner while the agent runs in its own VM, so the findings travel + // as env vars (FINDINGS_CRITICAL_HIGH, FINDINGS_MEDIUM_LOW, TOOLCHAINS) and the verdict comes + // back in the agent's final message. Nothing is shared through the filesystem. + + import { appendFile, readFile } from "node:fs/promises"; + import { pathToFileURL } from "node:url"; + import { Agent } from "@cursor/sdk"; + + /** Model the fix subagent runs on. Fixed here; the workflow does not let callers override it. */ + const FIX_MODEL_ID = "deepseek-v41-flash-1017-high"; + + /** + * Cloud env vars are encrypted and injected per session; keeping the findings payload under + * this cap leaves room for the rest of the environment and fails loudly if a repository has an + * absurd number of findings rather than truncating silently. + */ + const MAX_FINDINGS_BYTES = 60 * 1024; + + const REQUIRED_ENV = [ + "CURSOR_API_KEY", + "REPO_URL", + "REPO", + "BASE_BRANCH", + "BRANCH", + "TICKET", + "TRIVY_CRITICAL_HIGH_REPORT", + "TRIVY_MEDIUM_LOW_REPORT", + "ACCEPTED_ADVISORIES", + "GH_TOKEN" + ]; + + function requireEnv(env, name) { + const value = env[name]; + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`required environment variable ${name} is empty`); + } + return value; + } - - name: Configure git identity - if: steps.scan.outputs.critical_high != '0' - run: | - git config user.name "Cursor Agent" - git config user.email "cursoragent@cursor.com" + /** + * Tags persisted on the cloud agent. Keys are short and stable on purpose: they are joined + * against Cursor usage and billing exports by `cloud_agent_id`, so renaming one silently + * orphans the history already collected. Cursor caps metadata at 20 keys. + */ + function agentMetadata(env) { + return { + repository: env.REPO, + usecase: "dependency-update", + ticket: env.TICKET, + workflow: "dependency-audit", + base_branch: env.BASE_BRANCH, + fix_branch: env.BRANCH, + runner: env.RUNNER || "unknown", + run_id: env.GITHUB_RUN_ID || "local", + run_attempt: env.GITHUB_RUN_ATTEMPT || "1", + server_url: env.GITHUB_SERVER_URL || "https://github.com" + }; + } + + /** + * Trims a Trivy JSON report to the fields the agent acts on. The raw report carries package + * paths, layer ids and long descriptions that only bloat the payload. + */ + function compactFindings(report) { + const findings = []; + for (const result of report.Results ?? []) { + for (const vulnerability of result.Vulnerabilities ?? []) { + findings.push({ + target: result.Target, + pkg: vulnerability.PkgName, + installed: vulnerability.InstalledVersion, + fixed: vulnerability.FixedVersion, + id: vulnerability.VulnerabilityID, + severity: vulnerability.Severity, + vendorIds: vulnerability.VendorIDs ?? [] + }); + } + } + return findings; + } - - name: Fix critical and high vulnerabilities + /** + * Drops advisories the repository has already accepted, because Trivy does not read + * `pnpm.auditConfig` and an accepted finding would otherwise send the agent to work it again. + */ + function excludeAccepted(findings, accepted) { + const acceptedSet = new Set(accepted); + return findings.filter( + (finding) => ![finding.id, ...finding.vendorIds].some((identifier) => acceptedSet.has(identifier)) + ); + } + + async function buildFindingsPayload(env) { + const [rawCriticalHigh, rawMediumLow] = await Promise.all([ + readFile(env.TRIVY_CRITICAL_HIGH_REPORT, "utf8"), + readFile(env.TRIVY_MEDIUM_LOW_REPORT, "utf8") + ]); + + let accepted = []; + try { + const parsed = JSON.parse(env.ACCEPTED_ADVISORIES); + if (Array.isArray(parsed)) { + accepted = parsed.filter((value) => typeof value === "string"); + } + } catch { + // The workflow always writes valid JSON; an unreadable value must not stop the audit. + } + + const payload = { + FINDINGS_CRITICAL_HIGH: JSON.stringify(excludeAccepted(compactFindings(JSON.parse(rawCriticalHigh)), accepted)), + FINDINGS_MEDIUM_LOW: JSON.stringify(excludeAccepted(compactFindings(JSON.parse(rawMediumLow)), accepted)) + }; + + for (const [key, value] of Object.entries(payload)) { + const bytes = Buffer.byteLength(value, "utf8"); + if (bytes > MAX_FINDINGS_BYTES) { + throw new Error( + `${key} is ${bytes} bytes, above the ${MAX_FINDINGS_BYTES}-byte cloud env var cap; ` + + "split the scan per lockfile before running the agent" + ); + } + } + + return payload; + } + + /** + * The prompt is one string and the agent starts with no shell history, so everything it needs + * is spelled out. The findings arrive through env vars because the scan runs on the GitHub + * runner and the agent runs in its own VM. + */ + function buildPrompt(env) { + return `You fix dependency vulnerabilities in a GitHub repository. You are running as a cloud agent started by a GitHub Actions job that has already scanned the default branch. + + The repository is cloned for you. The GitHub CLI \`gh\` is authenticated through the GH_TOKEN environment variable. If \`git push\` is rejected, authenticate the remote with it: + \`git remote set-url origin "https://x-access-token:$GH_TOKEN@github.com/${env.REPO}.git"\` + + # Context + - Repository: \`${env.REPO}\` + - Default branch: \`${env.BASE_BRANCH}\` + - Fix branch: \`${env.BRANCH}\` + - Jira ticket: \`${env.TICKET}\` + - CRITICAL and HIGH findings, each with a fixed version: the JSON array in \`$FINDINGS_CRITICAL_HIGH\` + - MEDIUM and LOW findings, to list only: the JSON array in \`$FINDINGS_MEDIUM_LOW\` + - Advisory ids the repository already accepts: the JSON array in \`$ACCEPTED_ADVISORIES\` + - Toolchains detected in the repository: \`$TOOLCHAINS\` + + Each finding has \`target\` (the lockfile path), \`pkg\`, \`installed\`, \`fixed\`, \`id\`, \`severity\` and \`vendorIds\`. Findings whose advisory id is in \`$ACCEPTED_ADVISORIES\` are already filtered out; do not re-derive them and do not re-fix them. + + # Toolchains + Install only the toolchains named in \`$TOOLCHAINS\` before regenerating any lockfile, at the version the repository pins in \`.nvmrc\`, \`.node-version\`, \`package.json#engines\`, \`packageManager\`, \`go.mod\` or \`rust-toolchain.toml\`. The private \`@1inch\` npm registry needs a token: use \`$GH_TOKEN\` as \`NODE_AUTH_TOKEN\`, never a literal. + + # Security exceptions + If \`SECURITY_EXCEPTIONS.md\` exists in the repository root, read it first. Skip every package it lists as an accepted risk: do not fix it and do not count it as unresolved. Mention it in the pull request body as "excepted per SECURITY_EXCEPTIONS.md". + + \`.trivyignore\` is already applied by the scan that produced these findings. + + # How to fix each CRITICAL or HIGH finding + Work in the directory of the lockfile named in \`target\`. Use the first option that works: + 1. Bump the direct dependency in that directory's manifest (\`package.json\`, \`go.mod\` or \`Cargo.toml\`) to a version that pulls in \`fixed\`. + 2. Only when no parent bump reaches \`fixed\`: add an override (\`pnpm.overrides\`, npm \`overrides\`, Yarn \`resolutions\`, \`go get @\`, or \`cargo update -p --precise \`) and explain why in the commit message and the pull request body. + + Regenerate the lockfile with the tool that owns it, in the same directory: + - \`pnpm-lock.yaml\`: \`pnpm install --no-frozen-lockfile\` + - \`package-lock.json\`: \`npm install --package-lock-only\` + - \`yarn.lock\`: \`yarn install\` + - \`go.mod\`: \`go mod tidy\` + - \`Cargo.lock\`: \`cargo update -p \` + + Every lockfile is independent: never fix a nested lockfile from the root manifest. + + Verify each directory you touched by installing Trivy if it is missing (\`curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin\`) and re-scanning it, repeating until its CRITICAL and HIGH findings are gone or cannot be fixed: + \`trivy fs --skip-db-update --scanners vuln --pkg-types library --severity CRITICAL,HIGH --ignore-unfixed \` + + # Scope + - Fix only the CRITICAL and HIGH findings you were given. + - Do not fix MEDIUM or LOW findings; list them in the pull request body. + - Do not change anything unrelated to these findings, and never edit files under \`.github/workflows/\`. + - Do not run \`pnpm audit\`, \`npm audit\` or \`yarn audit\`. + + # Branch + 1. If \`${env.BRANCH}\` exists on origin (\`git ls-remote --exit-code --heads origin "${env.BRANCH}"\`): fetch it, check it out and rebase it onto \`origin/${env.BASE_BRANCH}\`. Never delete or recreate it. + 2. Otherwise create it from \`origin/${env.BASE_BRANCH}\`. + 3. Commit with the message \`fix(${env.TICKET}): resolve critical and high dependency vulnerabilities\` and push with \`git push --force-with-lease origin "${env.BRANCH}"\`. Never use \`git push --force\`. + + # Pull request + Keep exactly one open pull request from \`${env.BRANCH}\` into \`${env.BASE_BRANCH}\`: + - If \`gh pr list --head "${env.BRANCH}" --base "${env.BASE_BRANCH}" --state open --json number\` returns one, update its body with \`gh pr edit\`. Never create a second one. + - Otherwise create it with \`gh pr create --base "${env.BASE_BRANCH}" --head "${env.BRANCH}" --title "fix(${env.TICKET}): resolve critical and high dependency vulnerabilities"\`. + + The pull request body must contain: + - \`Tracked in ${env.TICKET}.\` + - A table with the columns Lockfile, Package, Installed, Fixed, Advisory, Severity, Fix applied. + - Whether every CRITICAL and HIGH finding is resolved, and the reason for each one that is not. + - The MEDIUM and LOW findings in a collapsed \`
\` list. + + If nothing needs changing because every finding is excepted, do not push and do not open a pull request. + + # Delegation + Do the mechanical bump-and-regenerate work through the \`dependency-fixer\` subagent, which runs on a fast, cheap model. Keep the planning, the verification, the commit, the push and the pull request body yourself. + + # Result + End your final message with a single fenced \`json\` block holding exactly these keys: + - \`unresolvedCriticalHigh\`: boolean, true when any CRITICAL or HIGH finding that is not excepted remains. + - \`pullRequestUrl\`: string, the pull request you opened or updated, or an empty string when you did not push. + - \`summary\`: string, one or two sentences naming what you changed. + Do not put this block anywhere else and do not commit it to the repository.`; + } + + /** + * The fix subagent. DeepSeek Flash is ample for this loop and far cheaper than the account + * default, while the parent agent keeps the default model so the plan, the verification and the + * pull request body stay coherent. + */ + function subagents() { + return { + "dependency-fixer": { + description: + "Fixes CRITICAL and HIGH dependency findings: bumps the parent dependency or adds an override, regenerates the owning lockfile, and verifies with a re-scan.", + model: { id: FIX_MODEL_ID }, + prompt: `You fix dependency vulnerabilities in a checked-out repository. You are a subagent: the parent agent has already established the scope, the findings and the branch, and will handle the commit, the push and the pull request. + + For every CRITICAL or HIGH finding you are given: + 1. Work in the directory that owns the vulnerable lockfile. Every lockfile is independent, so never fix a nested lockfile from the root manifest. + 2. Prefer bumping the direct dependency in that directory's manifest. Add an override (\`pnpm.overrides\`, npm \`overrides\`, Yarn \`resolutions\`, \`go get @\`, \`cargo update -p --precise \`) only when no parent bump reaches the fixed version, and say why when you do. + 3. Regenerate the lockfile with the tool that owns it: \`pnpm install --no-frozen-lockfile\`, \`npm install --package-lock-only\`, \`yarn install\`, \`go mod tidy\`, or \`cargo update -p \`. + 4. Re-scan that directory and repeat until its CRITICAL and HIGH findings are gone or cannot be fixed. + + Report back, per finding, the package, the installed version, the fixed version, the advisory id and what you did. Say plainly which findings you could not fix and why. Do not run \`pnpm audit\`, \`npm audit\` or \`yarn audit\`. Do not touch anything unrelated to these findings, and never edit files under \`.github/workflows/\`.` + } + }; + } + + /** + * Builds the job-summary markdown. Pure, so tests can assert on it without a network call. + */ + function buildReport({ repo, ticket, branch, agentId, modelId, result, usage }) { + const lines = [ + "### Dependency audit fix agent", + "", + "| Field | Value |", + "| --- | --- |", + `| Repository | \`${repo}\` |`, + `| Ticket | \`${ticket}\` |`, + `| Branch | \`${branch}\` |`, + `| Cloud agent | \`${agentId}\` |`, + `| Fix subagent model | \`${modelId}\` |`, + `| Status | ${result.status} |` + ]; + + if (typeof result.durationMs === "number") { + lines.push(`| Duration | ${Math.round(result.durationMs / 1000)}s |`); + } + + if (usage) { + lines.push( + `| Tokens | ${usage.inputTokens} in / ${usage.outputTokens} out / ${usage.cacheReadTokens} cache read |` + ); + } + + if (result.git?.branches?.length) { + const branches = result.git.branches + .map((entry) => (entry.prUrl ? `${entry.branch} (${entry.prUrl})` : entry.branch)) + .filter(Boolean) + .join(", "); + if (branches) { + lines.push(`| Git | ${branches} |`); + } + } + + lines.push( + "", + `The tags on cloud agent \`${agentId}\` are what let usage and billing exports be joined to the repository, the use case and the ticket.` + ); + return lines.join("\n"); + } + + /** + * Pulls the verdict out of the agent's final message. It is the last fenced json block, or the + * last JSON object if the model ignored the fence. Anything unreadable is treated as unresolved: + * reporting "resolved" there would hide findings the agent never judged. + */ + function parseVerdict(text) { + if (typeof text !== "string" || text.trim() === "") { + return undefined; + } + + const blocks = [...text.matchAll(/```json\s*([\s\S]*?)```/gi)].map((match) => match[1]); + for (const candidate of [...blocks.reverse(), text]) { + const start = candidate.indexOf("{"); + const end = candidate.lastIndexOf("}"); + if (start === -1 || end <= start) { + continue; + } + try { + const parsed = JSON.parse(candidate.slice(start, end + 1)); + if (parsed && typeof parsed === "object" && "unresolvedCriticalHigh" in parsed) { + return { + unresolvedCriticalHigh: parsed.unresolvedCriticalHigh !== false, + pullRequestUrl: typeof parsed.pullRequestUrl === "string" ? parsed.pullRequestUrl : "", + summary: typeof parsed.summary === "string" ? parsed.summary : "" + }; + } + } catch { + // Try the next candidate: a fenced block that is not the verdict is not fatal. + } + } + return undefined; + } + + /** + * Turns a finished run into job outputs. A run that did not finish, or whose final message holds + * no verdict, counts as unresolved. + */ + function collectResult({ status, error, text, git }) { + const gitPullRequest = git?.branches?.find((branch) => typeof branch.prUrl === "string" && branch.prUrl !== "")?.prUrl ?? ""; + + if (status !== "finished") { + return { + unresolvedCriticalHigh: true, + pullRequestUrl: gitPullRequest, + summary: `agent run ended with status ${status}${error?.message ? `: ${error.message}` : ""}` + }; + } + + const verdict = parseVerdict(text); + if (!verdict) { + return { + unresolvedCriticalHigh: true, + pullRequestUrl: gitPullRequest, + summary: "agent finished without a readable verdict in its final message" + }; + } + + return { + ...verdict, + pullRequestUrl: verdict.pullRequestUrl || gitPullRequest + }; + } + + async function main() { + const env = {}; + for (const name of REQUIRED_ENV) { + env[name] = requireEnv(process.env, name); + } + for (const name of [ + "RUNNER", + "TOOLCHAINS", + "GITHUB_RUN_ID", + "GITHUB_RUN_ATTEMPT", + "GITHUB_SERVER_URL", + "GITHUB_ENV", + "GITHUB_OUTPUT", + "GITHUB_STEP_SUMMARY" + ]) { + env[name] = process.env[name]; + } + + const findings = await buildFindingsPayload(env); + + const agent = await Agent.create({ + apiKey: env.CURSOR_API_KEY, + name: `${env.REPO} dependency audit`, + cloud: { + repos: [{ url: env.REPO_URL, startingRef: env.BASE_BRANCH }], + autoCreatePR: false, + skipReviewerRequest: true, + metadata: agentMetadata(env), + // The PAT is here so `gh` can read and write pull requests. Names may not start with + // CURSOR_, so the injected name is GH_TOKEN. + envVars: { + GH_TOKEN: env.GH_TOKEN, + TOOLCHAINS: env.TOOLCHAINS || "unknown", + ...findings + } + }, + agents: subagents() + }); + + // `Agent.create()` resolves before the VM is provisioned, so log the id immediately: a run + // that dies during provisioning is otherwise untraceable from the job log. + process.stdout.write(`cloud agent ${agent.agentId} created for ${env.REPO}\n`); + + try { + const run = await agent.send(buildPrompt(env)); + const result = await run.wait(); + + let usage = result.usage; + if (!usage) { + try { + usage = (await agent.getUsage()).usage; + } catch (usageError) { + process.stdout.write(`agent usage unavailable: ${usageError.message}\n`); + } + } + + const collected = collectResult({ + status: result.status, + error: result.error, + text: result.result, + git: result.git + }); + + const outputs = [ + `unresolved_critical_high=${collected.unresolvedCriticalHigh}`, + `pull_request_url=${collected.pullRequestUrl}`, + `agent_id=${agent.agentId}`, + `agent_status=${result.status}` + ].join("\n"); + + await appendFile(env.GITHUB_OUTPUT ?? "/dev/null", `${outputs}\n`); + await appendFile( + env.GITHUB_ENV ?? "/dev/null", + `UNRESOLVED_CRITICAL_HIGH=${collected.unresolvedCriticalHigh}\n` + ); + await appendFile( + env.GITHUB_STEP_SUMMARY ?? "/dev/null", + `${buildReport({ + repo: env.REPO, + ticket: env.TICKET, + branch: env.BRANCH, + agentId: agent.agentId, + modelId: FIX_MODEL_ID, + result, + usage + })}\n` + ); + + if (collected.summary) { + process.stdout.write(`${collected.summary}\n`); + } + process.exitCode = collected.unresolvedCriticalHigh || result.status !== "finished" ? 1 : 0; + } finally { + agent.close(); + } + } + + export { + FIX_MODEL_ID, + MAX_FINDINGS_BYTES, + agentMetadata, + buildFindingsPayload, + buildPrompt, + buildReport, + collectResult, + compactFindings, + excludeAccepted, + parseVerdict, + subagents + }; + + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); + } + RUNNER_EOF + + - name: Run the fix agent through the Cursor SDK + id: agent if: steps.scan.outputs.critical_high != '0' env: CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} GH_TOKEN: ${{ secrets.DEPENDENCY_AUDIT_PAT }} - NODE_AUTH_TOKEN: ${{ github.token }} - PROMPT: | - You are running in a GitHub Actions job that fixes dependency vulnerabilities. Read the environment variables named below with the shell. - - The GitHub CLI `gh` is authenticated through `GH_TOKEN`, and `git push` to origin is authenticated. Node.js, pnpm, Yarn (through Corepack), Go and Rust are installed when the repository uses them, and the private `@1inch` npm registry is configured. - - # Context - - Repository: `$REPO` - - Default branch, checked out now: `$BASE_BRANCH` - - Fix branch: `$BRANCH` - - Jira ticket: `$TICKET` - - Findings to fix: the Trivy JSON report at `$TRIVY_CRITICAL_HIGH_REPORT` (CRITICAL and HIGH, each with a fixed version) - - Findings to list only: the Trivy JSON report at `$TRIVY_MEDIUM_LOW_REPORT` (MEDIUM and LOW) - - Each entry in `Results` has a `Target` (the lockfile path), and each of its `Vulnerabilities` has `PkgName`, `InstalledVersion`, `FixedVersion`, `VulnerabilityID` and `Severity`. - - # Security exceptions - If `SECURITY_EXCEPTIONS.md` exists in the repository root, read it first. Skip every package it lists as an accepted risk: do not fix it and do not count it as unresolved. Mention it in the pull request body as "excepted per SECURITY_EXCEPTIONS.md". - - `$ACCEPTED_ADVISORIES` is a JSON list of advisory IDs from `pnpm.auditConfig.ignoreGhsas` and `ignoreCves` in the repository's `package.json` files. Treat a finding whose `VulnerabilityID` or any of its `VendorIDs` is in that list as an accepted risk in the same way, and mention it in the pull request body as "excepted per pnpm.auditConfig". Trivy already applies `.trivyignore`. - - # How to fix each CRITICAL or HIGH finding - Work in the directory of the lockfile named in `Target`. Use the first option that works: - 1. Bump the direct dependency in that directory's manifest (`package.json`, `go.mod` or `Cargo.toml`) to a version that pulls in `FixedVersion`. - 2. Only when no parent bump reaches `FixedVersion`: add an override (`pnpm.overrides`, npm `overrides`, Yarn `resolutions`, `go get @`, or `cargo update -p --precise `) and explain why in the commit message and the pull request body. - - Regenerate the lockfile with the tool that owns it, in the same directory: - - `pnpm-lock.yaml`: `pnpm install --no-frozen-lockfile` - - `package-lock.json`: `npm install --package-lock-only` - - `yarn.lock`: `yarn install` - - `go.mod`: `go mod tidy` - - `Cargo.lock`: `cargo update -p ` - - Every lockfile is independent: never fix a nested lockfile from the root manifest. - - After fixing, scan the same directory again and repeat until its CRITICAL and HIGH findings are gone or cannot be fixed: - `trivy fs --skip-db-update --scanners vuln --pkg-types library --severity CRITICAL,HIGH --ignore-unfixed ` - - # Scope - - Fix only the CRITICAL and HIGH findings from the report. - - Do not fix MEDIUM or LOW findings; list them in the pull request body. - - Do not change anything unrelated to these findings, and never edit files under `.github/workflows/`. - - Do not run `pnpm audit`, `npm audit` or `yarn audit`. - - # Branch - 1. If `$BRANCH` exists on origin (`git ls-remote --exit-code --heads origin "$BRANCH"`): fetch it, check it out and rebase it onto `origin/$BASE_BRANCH`. Never delete or recreate it. - 2. Otherwise create it from `origin/$BASE_BRANCH`. - 3. Commit with the message `fix($TICKET): resolve critical and high dependency vulnerabilities` and push with `git push --force-with-lease origin "$BRANCH"`. Never use `git push --force`. - - # Pull request - Keep exactly one open pull request from `$BRANCH` into `$BASE_BRANCH`: - - If `gh pr list --head "$BRANCH" --base "$BASE_BRANCH" --state open --json number` returns one, update its body with `gh pr edit`. Never create a second one. - - Otherwise create it with `gh pr create --base "$BASE_BRANCH" --head "$BRANCH" --title "fix($TICKET): resolve critical and high dependency vulnerabilities"`. - - The pull request body must contain: - - `Tracked in $TICKET.` - - A table with the columns Lockfile, Package, Installed, Fixed, Advisory, Severity, Fix applied. - - Whether every CRITICAL and HIGH finding is resolved, and the reason for each one that is not. - - The MEDIUM and LOW findings in a collapsed `
` list. - - If nothing needs changing because every finding is excepted, do not push and do not open a pull request. - - # Result - Finish by writing exactly one of these lines to `$GITHUB_ENV`: - - `echo "UNRESOLVED_CRITICAL_HIGH=true" >> "$GITHUB_ENV"` when any CRITICAL or HIGH finding that is not excepted remains. - - `echo "UNRESOLVED_CRITICAL_HIGH=false" >> "$GITHUB_ENV"` otherwise. - run: agent -p "$PROMPT" --force --model grok-4.7-high --output-format=text + TOOLCHAINS: ${{ steps.toolchains.outputs.toolchains }} + run: | + set -euo pipefail + npm install --no-save --no-audit --no-fund "@cursor/sdk@$CURSOR_SDK_VERSION" + node "$RUNNER_TEMP/run-cursor-agent.mjs" - name: Fail if critical or high vulnerabilities remain if: steps.scan.outputs.critical_high != '0' From ef60ecdfe8e15c3e440b9c01b1ecfd652b6f3f98 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 13:33:29 +0000 Subject: [PATCH 3/6] ci(DPA2-6761): run the audit fix agent through the Cursor SDK The shared workflow now creates a Cursor cloud agent with @cursor/sdk instead of starting the Cursor CLI, so each run is tagged with the repository, use case and ticket and its spend can be attributed. The fix work itself goes to a DeepSeek Flash subagent. CURSOR_API_KEY is now the organization secret, so this repository needs no copy of its own. --- .github/workflows/dependency-audit.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependency-audit.yaml b/.github/workflows/dependency-audit.yaml index 44a32fd..d92bc3a 100644 --- a/.github/workflows/dependency-audit.yaml +++ b/.github/workflows/dependency-audit.yaml @@ -1,7 +1,7 @@ # Inlined copy of 1inch/workflows/.github/workflows/dependency-audit.yaml: this repository cannot # call that private reusable workflow. Generated by tools/dependency-audit/inline-workflow.py — # edit the shared workflow, then regenerate; do not hand-edit this file. -# version: v0.2.0 +# version: v0.1.0 name: Dependency Audit on: @@ -155,7 +155,7 @@ jobs: import { Agent } from "@cursor/sdk"; /** Model the fix subagent runs on. Fixed here; the workflow does not let callers override it. */ - const FIX_MODEL_ID = "deepseek-v41-flash-1017-high"; + const FIX_MODEL_ID = "deepseek-v4.1-flash-1017"; /** * Cloud env vars are encrypted and injected per session; keeping the findings payload under From c8844af899f596bdbe0afab12fd3281dd51e303f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 14:16:05 +0000 Subject: [PATCH 4/6] ci(DPA2-6761): run the audit fix agent through the Cursor SDK The shared workflow now creates a Cursor cloud agent with @cursor/sdk instead of starting the Cursor CLI, so each run is tagged with the repository, use case and ticket and its spend can be attributed. The fix work itself goes to a DeepSeek Flash subagent. The fix pull request is opened by the Cursor GitHub App, so AUDIT_PAT is gone and CURSOR_API_KEY (organization-scoped) is the only secret this repository needs. --- .github/workflows/dependency-audit.yaml | 174 +++++++++++++++++++----- 1 file changed, 138 insertions(+), 36 deletions(-) diff --git a/.github/workflows/dependency-audit.yaml b/.github/workflows/dependency-audit.yaml index d92bc3a..c790ccf 100644 --- a/.github/workflows/dependency-audit.yaml +++ b/.github/workflows/dependency-audit.yaml @@ -10,7 +10,8 @@ on: workflow_dispatch: permissions: - contents: read + contents: write + pull-requests: write packages: read jobs: @@ -19,7 +20,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 90 permissions: - contents: read + # `contents: write` + `pull-requests: write` are only for the job's own `GITHUB_TOKEN`, which + # sets the body of the pull request the agent app opened. Nothing pushes with it. + contents: write + pull-requests: write packages: read concurrency: group: dependency-audit-${{ github.repository }} @@ -36,29 +40,23 @@ jobs: CURSOR_SDK_VERSION: 1.2.0 steps: - name: Require secrets - # CURSOR_API_KEY resolves to the organization secret; - # DEPENDENCY_AUDIT_PAT is per-repository because it pushes this repository's branch. + # Organization secret: no per-repository copy is needed. env: CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} - AUDIT_PAT: ${{ secrets.DEPENDENCY_AUDIT_PAT }} run: | - missing=0 if [ -z "$CURSOR_API_KEY" ]; then echo "::error::Secret CURSOR_API_KEY is not set" - missing=1 - fi - if [ -z "$AUDIT_PAT" ]; then - echo "::error::Secret DEPENDENCY_AUDIT_PAT is not set" - missing=1 + exit 1 fi - exit "$missing" - name: Checkout default branch uses: actions/checkout@v7 with: ref: ${{ env.BASE_BRANCH }} fetch-depth: 0 - token: ${{ secrets.DEPENDENCY_AUDIT_PAT }} + # The job token is only for `gh pr list` and setting the PR body. Leaving it in + # `.git/config` would hand the agent's tooling a write credential it does not need. + persist-credentials: false - name: Install Trivy uses: aquasecurity/setup-trivy@v0.3.1 @@ -68,12 +66,16 @@ jobs: - name: Scan lockfiles id: scan run: | + # `--exit-code 0` is explicit: Trivy auto-loads a repository `trivy.yaml` / `.trivy.yaml`, + # and a non-zero exit code there would fail this step exactly when fixable findings + # exist — i.e. precisely when the agent should run. These are report scans; the verdict + # comes from counting findings below, and a second scan after the agent. critical_high_report="$RUNNER_TEMP/trivy-critical-high.json" medium_low_report="$RUNNER_TEMP/trivy-medium-low.json" - trivy fs --scanners vuln --pkg-types library --severity CRITICAL,HIGH --ignore-unfixed \ + trivy fs --exit-code 0 --scanners vuln --pkg-types library --severity CRITICAL,HIGH --ignore-unfixed \ --format json --output "$critical_high_report" . - trivy fs --skip-db-update --scanners vuln --pkg-types library --severity MEDIUM,LOW \ + trivy fs --exit-code 0 --skip-db-update --scanners vuln --pkg-types library --severity MEDIUM,LOW \ --format json --output "$medium_low_report" . accepted_advisories=$(git ls-files | grep -E '(^|/)package\.json$' \ @@ -105,7 +107,11 @@ jobs: id: toolchains if: steps.scan.outputs.critical_high != '0' run: | - tracked() { git ls-files | grep -Eq "(^|/)$1\$"; } + # Materialise the file list once. `git ls-files | grep -q` closes the pipe on the first + # match, and under GitHub's default `bash -eo pipefail` the resulting SIGPIPE makes the + # whole pipeline fail — so a lockfile that exists reads as missing. + git ls-files > "$RUNNER_TEMP/tracked-files.txt" + tracked() { grep -Eq "(^|/)$1\$" "$RUNNER_TEMP/tracked-files.txt"; } node=false go=false @@ -124,6 +130,23 @@ jobs: if [ "$rust" = true ]; then summary="rust ${summary}"; fi echo "toolchains=$(echo "$summary" | xargs)" >> "$GITHUB_OUTPUT" + - name: Note whether the fix pull request already exists + id: existing_pr + if: steps.scan.outputs.critical_high != '0' + env: + GH_TOKEN: ${{ github.token }} + run: | + # The SDK opens the pull request, and only a newly opened one should have its body set by + # this job. An existing one carries the agent's accumulated description, which the agent + # maintains itself. + number=$(gh pr list --head "$BRANCH" --base "$BASE_BRANCH" --state open --json number --jq '.[0].number // empty') || number="" + if [ -n "$number" ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Fix pull request #$number already exists; the agent updates its body." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + - name: Setup Node.js for the Cursor SDK if: steps.scan.outputs.critical_high != '0' uses: actions/setup-node@v7 @@ -173,8 +196,7 @@ jobs: "TICKET", "TRIVY_CRITICAL_HIGH_REPORT", "TRIVY_MEDIUM_LOW_REPORT", - "ACCEPTED_ADVISORIES", - "GH_TOKEN" + "ACCEPTED_ADVISORIES" ]; function requireEnv(env, name) { @@ -280,8 +302,7 @@ jobs: function buildPrompt(env) { return `You fix dependency vulnerabilities in a GitHub repository. You are running as a cloud agent started by a GitHub Actions job that has already scanned the default branch. - The repository is cloned for you. The GitHub CLI \`gh\` is authenticated through the GH_TOKEN environment variable. If \`git push\` is rejected, authenticate the remote with it: - \`git remote set-url origin "https://x-access-token:$GH_TOKEN@github.com/${env.REPO}.git"\` + The repository is cloned for you and your commits are attributed to the Cursor GitHub App, which can push them and open the pull request. \`gh\` in this VM is authenticated the same way. If \`git push\` is rejected, the App is missing access to this repository: say so in your final answer and stop, do not look for another credential. # Context - Repository: \`${env.REPO}\` @@ -296,7 +317,7 @@ jobs: Each finding has \`target\` (the lockfile path), \`pkg\`, \`installed\`, \`fixed\`, \`id\`, \`severity\` and \`vendorIds\`. Findings whose advisory id is in \`$ACCEPTED_ADVISORIES\` are already filtered out; do not re-derive them and do not re-fix them. # Toolchains - Install only the toolchains named in \`$TOOLCHAINS\` before regenerating any lockfile, at the version the repository pins in \`.nvmrc\`, \`.node-version\`, \`package.json#engines\`, \`packageManager\`, \`go.mod\` or \`rust-toolchain.toml\`. The private \`@1inch\` npm registry needs a token: use \`$GH_TOKEN\` as \`NODE_AUTH_TOKEN\`, never a literal. + Install only the toolchains named in \`$TOOLCHAINS\` before regenerating any lockfile, at the version the repository pins in \`.nvmrc\`, \`.node-version\`, \`package.json#engines\`, \`packageManager\`, \`go.mod\` or \`rust-toolchain.toml\`. The private \`@1inch\` npm registry needs a token: use \`$(gh auth token)\` as \`NODE_AUTH_TOKEN\`, never a literal. # Security exceptions If \`SECURITY_EXCEPTIONS.md\` exists in the repository root, read it first. Skip every package it lists as an accepted risk: do not fix it and do not count it as unresolved. Mention it in the pull request body as "excepted per SECURITY_EXCEPTIONS.md". @@ -317,8 +338,10 @@ jobs: Every lockfile is independent: never fix a nested lockfile from the root manifest. - Verify each directory you touched by installing Trivy if it is missing (\`curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin\`) and re-scanning it, repeating until its CRITICAL and HIGH findings are gone or cannot be fixed: - \`trivy fs --skip-db-update --scanners vuln --pkg-types library --severity CRITICAL,HIGH --ignore-unfixed \` + Verify each directory you touched. Trivy is not installed on this VM and the database is not downloaded, so install it, then scan once *without* \`--skip-db-update\` so the database is fetched: + \`curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin\` + \`trivy fs --scanners vuln --pkg-types library --severity CRITICAL,HIGH --ignore-unfixed \` + Repeat until its CRITICAL and HIGH findings are gone or cannot be fixed, dropping \`--skip-db-update\` only after the first scan has populated the database. # Scope - Fix only the CRITICAL and HIGH findings you were given. @@ -332,16 +355,19 @@ jobs: 3. Commit with the message \`fix(${env.TICKET}): resolve critical and high dependency vulnerabilities\` and push with \`git push --force-with-lease origin "${env.BRANCH}"\`. Never use \`git push --force\`. # Pull request - Keep exactly one open pull request from \`${env.BRANCH}\` into \`${env.BASE_BRANCH}\`: - - If \`gh pr list --head "${env.BRANCH}" --base "${env.BASE_BRANCH}" --state open --json number\` returns one, update its body with \`gh pr edit\`. Never create a second one. - - Otherwise create it with \`gh pr create --base "${env.BASE_BRANCH}" --head "${env.BRANCH}" --title "fix(${env.TICKET}): resolve critical and high dependency vulnerabilities"\`. + This run opens the pull request for you from \`${env.BRANCH}\` into \`${env.BASE_BRANCH}\`, so do not call \`gh pr create\`. What is left to you is its description: - The pull request body must contain: + - If \`gh pr list --head "${env.BRANCH}" --base "${env.BASE_BRANCH}" --state open --json number --jq '.[0].number'\` returns a number, update that pull request's body with \`gh pr edit --body-file \`. + - Otherwise write the description to a file and leave it; report its path in your final answer and the workflow will set it on the pull request this run opens. + + The body must contain: - \`Tracked in ${env.TICKET}.\` - A table with the columns Lockfile, Package, Installed, Fixed, Advisory, Severity, Fix applied. - Whether every CRITICAL and HIGH finding is resolved, and the reason for each one that is not. - The MEDIUM and LOW findings in a collapsed \`
\` list. + Keep exactly one open pull request from \`${env.BRANCH}\`: never open a second one, and never recreate the branch. + If nothing needs changing because every finding is excepted, do not push and do not open a pull request. # Delegation @@ -372,7 +398,7 @@ jobs: 1. Work in the directory that owns the vulnerable lockfile. Every lockfile is independent, so never fix a nested lockfile from the root manifest. 2. Prefer bumping the direct dependency in that directory's manifest. Add an override (\`pnpm.overrides\`, npm \`overrides\`, Yarn \`resolutions\`, \`go get @\`, \`cargo update -p --precise \`) only when no parent bump reaches the fixed version, and say why when you do. 3. Regenerate the lockfile with the tool that owns it: \`pnpm install --no-frozen-lockfile\`, \`npm install --package-lock-only\`, \`yarn install\`, \`go mod tidy\`, or \`cargo update -p \`. - 4. Re-scan that directory and repeat until its CRITICAL and HIGH findings are gone or cannot be fixed. + 4. Re-scan that directory and repeat until its CRITICAL and HIGH findings are gone or cannot be fixed. The database is not on the VM, so the first scan must run without \`--skip-db-update\`. Report back, per finding, the package, the installed version, the fixed version, the advisory id and what you did. Say plainly which findings you could not fix and why. Do not run \`pnpm audit\`, \`npm audit\` or \`yarn audit\`. Do not touch anything unrelated to these findings, and never edit files under \`.github/workflows/\`.` } @@ -456,6 +482,43 @@ jobs: return undefined; } + /** + * Sets the body on the pull request this run opened. Best effort and never fatal: the fix is + * already pushed and the pull request already open, so a body that could not be set is a + * cosmetic miss, not a failed audit. + */ + async function applyPullRequestBody({ repo, branch, bodyPath, pullRequestUrl, existedBefore, token }) { + if (!pullRequestUrl || existedBefore || !token) { + return; + } + + let body; + try { + body = await readFile(bodyPath, "utf8"); + } catch { + return; + } + const number = pullRequestUrl.match(/\/pull\/(\d+)$/)?.[1]; + if (!number) { + return; + } + + const response = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, { + method: "PATCH", + headers: { + authorization: `Bearer ${token}`, + accept: "application/vnd.github+json", + "content-type": "application/json", + "user-agent": "dependency-audit" + }, + body: JSON.stringify({ body }) + }); + + if (!response.ok) { + process.stdout.write(`could not set the pull request body on ${branch}: HTTP ${response.status}\n`); + } + } + /** * Turns a finished run into job outputs. A run that did not finish, or whose final message holds * no verdict, counts as unresolved. @@ -492,7 +555,6 @@ jobs: env[name] = requireEnv(process.env, name); } for (const name of [ - "RUNNER", "TOOLCHAINS", "GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT", @@ -503,6 +565,13 @@ jobs: ]) { env[name] = process.env[name]; } + // `RUNNER` is not exported by the workflow; the tag has to come from the `runner` input, or + // every agent records `unknown` and the billing export cannot be split by runner pool. + env.RUNNER = process.env.INPUT_RUNNER || process.env.RUNNER; + // Only the runner's own `GITHUB_TOKEN` is used, and only to set the body of the pull request the + // SDK opens. Without a PAT nothing here can write to the repository. + env.GITHUB_TOKEN = process.env.GITHUB_TOKEN; + env.PR_EXISTED_BEFORE = process.env.PR_EXISTED_BEFORE; const findings = await buildFindingsPayload(env); @@ -511,14 +580,19 @@ jobs: name: `${env.REPO} dependency audit`, cloud: { repos: [{ url: env.REPO_URL, startingRef: env.BASE_BRANCH }], - autoCreatePR: false, + // The Cursor GitHub App owns the clone, the push and the pull request, so no PAT is needed + // and nothing in this job has to be able to write to the repository. Letting the SDK open + // the PR is what keeps it to one: `gh pr create` cannot see that a PR from the branch + // already exists when the head is identical, and would fail or open a second one. + autoCreatePR: true, + openAsCursorGithubApp: true, skipReviewerRequest: true, metadata: agentMetadata(env), - // The PAT is here so `gh` can read and write pull requests. Names may not start with - // CURSOR_, so the injected name is GH_TOKEN. + // `ACCEPTED_ADVISORIES` has to travel too: the agent re-scans after fixing, and without it + // an accepted advisory looks like a leftover finding and fails the job. envVars: { - GH_TOKEN: env.GH_TOKEN, TOOLCHAINS: env.TOOLCHAINS || "unknown", + ACCEPTED_ADVISORIES: env.ACCEPTED_ADVISORIES || "[]", ...findings } }, @@ -549,6 +623,19 @@ jobs: git: result.git }); + // The SDK opens the pull request, so its body is set here. Only when nothing was open yet: + // an existing pull request's body is the agent's to maintain, and overwriting it every + // weekday would erase the accumulated history of what it already fixed. + const bodyPath = `${process.env.RUNNER_TEMP ?? process.cwd()}/dependency-audit-pr-body.md`; + await applyPullRequestBody({ + repo: env.REPO, + branch: env.BRANCH, + bodyPath, + pullRequestUrl: collected.pullRequestUrl, + existedBefore: env.PR_EXISTED_BEFORE === "true", + token: env.GITHUB_TOKEN + }); + const outputs = [ `unresolved_critical_high=${collected.unresolvedCriticalHigh}`, `pull_request_url=${collected.pullRequestUrl}`, @@ -587,6 +674,7 @@ jobs: FIX_MODEL_ID, MAX_FINDINGS_BYTES, agentMetadata, + applyPullRequestBody, buildFindingsPayload, buildPrompt, buildReport, @@ -607,12 +695,26 @@ jobs: if: steps.scan.outputs.critical_high != '0' env: CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} - GH_TOKEN: ${{ secrets.DEPENDENCY_AUDIT_PAT }} TOOLCHAINS: ${{ steps.toolchains.outputs.toolchains }} + # The metadata tag has to name the runner pool the job actually used. `RUNNER` is not + # exported by the runner into the step environment, so pass the input explicitly. + INPUT_RUNNER: ubuntu-latest + # The job token, used only to set the body on a pull request the agent just opened. + GITHUB_TOKEN: ${{ github.token }} + PR_EXISTED_BEFORE: ${{ steps.existing_pr.outputs.exists }} run: | set -euo pipefail - npm install --no-save --no-audit --no-fund "@cursor/sdk@$CURSOR_SDK_VERSION" - node "$RUNNER_TEMP/run-cursor-agent.mjs" + # Node resolves a bare ESM specifier by walking up from the *importing file's* directory, + # not from `cwd`. The script lives in $RUNNER_TEMP, so installing into $GITHUB_WORKSPACE + # leaves `@cursor/sdk` unresolvable and the step dies before the agent is created. + # Install beside the script instead. + agent_dir="$RUNNER_TEMP/dependency-audit-agent" + mkdir -p "$agent_dir" + cp "$RUNNER_TEMP/run-cursor-agent.mjs" "$agent_dir/run-cursor-agent.mjs" + printf '{"name":"dependency-audit-agent","private":true,"type":"module"}\n' > "$agent_dir/package.json" + cd "$agent_dir" + npm install --no-audit --no-fund --no-save "@cursor/sdk@$CURSOR_SDK_VERSION" + node run-cursor-agent.mjs - name: Fail if critical or high vulnerabilities remain if: steps.scan.outputs.critical_high != '0' From 92f0e5943e3ea4f5c247e5be8fbdec9b93a1fcc3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 15:00:23 +0000 Subject: [PATCH 5/6] ci(DPA2-6761): run the audit fix agent through the Cursor SDK The shared workflow now creates a Cursor cloud agent with @cursor/sdk instead of starting the Cursor CLI, so each run is tagged with the repository, use case and ticket and its spend can be attributed. The fix work itself goes to a DeepSeek Flash subagent. The fix pull request is opened by the Cursor GitHub App, so AUDIT_PAT is gone and CURSOR_API_KEY (organization-scoped) is the only secret this repository needs. The job takes no Jira ticket: it runs on a weekday schedule. --- .github/workflows/dependency-audit.yaml | 68 +++++++++++++++---------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/.github/workflows/dependency-audit.yaml b/.github/workflows/dependency-audit.yaml index c790ccf..b3265a9 100644 --- a/.github/workflows/dependency-audit.yaml +++ b/.github/workflows/dependency-audit.yaml @@ -31,7 +31,6 @@ jobs: env: BASE_BRANCH: main BRANCH: feature/DPA2-6761_weekday-caller - TICKET: DPA2-6761 REPO: ${{ github.repository }} REPO_URL: ${{ github.server_url }}/${{ github.repository }} TRIVY_VERSION: v0.76.0 @@ -163,7 +162,7 @@ jobs: // Why the SDK instead of the Cursor CLI: the SDK creates a real cloud agent, so the run is // attributable. `cloud.metadata` tags the agent for its whole lifetime, and every usage and // billing export carries `cloud_agent_id`. That is what lets spend be reported per repository, - // per use case and per ticket instead of as one undifferentiated Cursor invoice line. + // per use case instead of as one undifferentiated Cursor invoice line. // // The fix work is delegated to a named subagent pinned to DeepSeek Flash: the scan already says // which package, which version and which lockfile, so the loop is mechanical and a large @@ -177,6 +176,12 @@ jobs: import { pathToFileURL } from "node:url"; import { Agent } from "@cursor/sdk"; + /** + * Title of the single fix pull request. A plain description, not a conventional commit with a + * ticket scope: this runs on a weekday schedule, so there is no ticket to reference. + */ + const PR_TITLE = "Resolve critical and high dependency vulnerabilities"; + /** Model the fix subagent runs on. Fixed here; the workflow does not let callers override it. */ const FIX_MODEL_ID = "deepseek-v4.1-flash-1017"; @@ -193,7 +198,6 @@ jobs: "REPO", "BASE_BRANCH", "BRANCH", - "TICKET", "TRIVY_CRITICAL_HIGH_REPORT", "TRIVY_MEDIUM_LOW_REPORT", "ACCEPTED_ADVISORIES" @@ -216,7 +220,6 @@ jobs: return { repository: env.REPO, usecase: "dependency-update", - ticket: env.TICKET, workflow: "dependency-audit", base_branch: env.BASE_BRANCH, fix_branch: env.BRANCH, @@ -308,7 +311,6 @@ jobs: - Repository: \`${env.REPO}\` - Default branch: \`${env.BASE_BRANCH}\` - Fix branch: \`${env.BRANCH}\` - - Jira ticket: \`${env.TICKET}\` - CRITICAL and HIGH findings, each with a fixed version: the JSON array in \`$FINDINGS_CRITICAL_HIGH\` - MEDIUM and LOW findings, to list only: the JSON array in \`$FINDINGS_MEDIUM_LOW\` - Advisory ids the repository already accepts: the JSON array in \`$ACCEPTED_ADVISORIES\` @@ -352,7 +354,7 @@ jobs: # Branch 1. If \`${env.BRANCH}\` exists on origin (\`git ls-remote --exit-code --heads origin "${env.BRANCH}"\`): fetch it, check it out and rebase it onto \`origin/${env.BASE_BRANCH}\`. Never delete or recreate it. 2. Otherwise create it from \`origin/${env.BASE_BRANCH}\`. - 3. Commit with the message \`fix(${env.TICKET}): resolve critical and high dependency vulnerabilities\` and push with \`git push --force-with-lease origin "${env.BRANCH}"\`. Never use \`git push --force\`. + 3. Commit with the message \`fix(deps): resolve critical and high dependency vulnerabilities\` and push with \`git push --force-with-lease origin "${env.BRANCH}"\`. Never use \`git push --force\`. This is a scheduled job with no Jira ticket, so the commit scope is \`deps\`. # Pull request This run opens the pull request for you from \`${env.BRANCH}\` into \`${env.BASE_BRANCH}\`, so do not call \`gh pr create\`. What is left to you is its description: @@ -361,7 +363,6 @@ jobs: - Otherwise write the description to a file and leave it; report its path in your final answer and the workflow will set it on the pull request this run opens. The body must contain: - - \`Tracked in ${env.TICKET}.\` - A table with the columns Lockfile, Package, Installed, Fixed, Advisory, Severity, Fix applied. - Whether every CRITICAL and HIGH finding is resolved, and the reason for each one that is not. - The MEDIUM and LOW findings in a collapsed \`
\` list. @@ -408,14 +409,13 @@ jobs: /** * Builds the job-summary markdown. Pure, so tests can assert on it without a network call. */ - function buildReport({ repo, ticket, branch, agentId, modelId, result, usage }) { + function buildReport({ repo, branch, agentId, modelId, result, usage }) { const lines = [ "### Dependency audit fix agent", "", "| Field | Value |", "| --- | --- |", `| Repository | \`${repo}\` |`, - `| Ticket | \`${ticket}\` |`, `| Branch | \`${branch}\` |`, `| Cloud agent | \`${agentId}\` |`, `| Fix subagent model | \`${modelId}\` |`, @@ -444,7 +444,7 @@ jobs: lines.push( "", - `The tags on cloud agent \`${agentId}\` are what let usage and billing exports be joined to the repository, the use case and the ticket.` + `The tags on cloud agent \`${agentId}\` are what let usage and billing exports be joined to the repository and the use case.` ); return lines.join("\n"); } @@ -483,26 +483,39 @@ jobs: } /** - * Sets the body on the pull request this run opened. Best effort and never fatal: the fix is - * already pushed and the pull request already open, so a body that could not be set is a - * cosmetic miss, not a failed audit. + * Sets the title and body on the pull request this run opened. Best effort and never fatal: the + * fix is already pushed and the pull request already open, so a title or body that could not be + * set is a cosmetic miss, not a failed audit. + * + * The title is set here because the SDK names the pull request from the prompt, and a scheduled + * job has no ticket to put in a conventional-commit scope. The body is set only on a newly opened + * pull request: an existing one carries the agent's accumulated description. */ - async function applyPullRequestBody({ repo, branch, bodyPath, pullRequestUrl, existedBefore, token }) { - if (!pullRequestUrl || existedBefore || !token) { + async function applyPullRequestDetails({ repo, branch, bodyPath, pullRequestUrl, existedBefore, token, title }) { + if (!pullRequestUrl || !token) { return; } - let body; - try { - body = await readFile(bodyPath, "utf8"); - } catch { - return; - } const number = pullRequestUrl.match(/\/pull\/(\d+)$/)?.[1]; if (!number) { return; } + const payload = {}; + if (title) { + payload.title = title; + } + if (!existedBefore) { + try { + payload.body = await readFile(bodyPath, "utf8"); + } catch { + // A missing description is not worth failing the audit over. + } + } + if (Object.keys(payload).length === 0) { + return; + } + const response = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, { method: "PATCH", headers: { @@ -511,11 +524,11 @@ jobs: "content-type": "application/json", "user-agent": "dependency-audit" }, - body: JSON.stringify({ body }) + body: JSON.stringify(payload) }); if (!response.ok) { - process.stdout.write(`could not set the pull request body on ${branch}: HTTP ${response.status}\n`); + process.stdout.write(`could not set the pull request details on ${branch}: HTTP ${response.status}\n`); } } @@ -627,13 +640,14 @@ jobs: // an existing pull request's body is the agent's to maintain, and overwriting it every // weekday would erase the accumulated history of what it already fixed. const bodyPath = `${process.env.RUNNER_TEMP ?? process.cwd()}/dependency-audit-pr-body.md`; - await applyPullRequestBody({ + await applyPullRequestDetails({ repo: env.REPO, branch: env.BRANCH, bodyPath, pullRequestUrl: collected.pullRequestUrl, existedBefore: env.PR_EXISTED_BEFORE === "true", - token: env.GITHUB_TOKEN + token: env.GITHUB_TOKEN, + title: PR_TITLE }); const outputs = [ @@ -652,7 +666,6 @@ jobs: env.GITHUB_STEP_SUMMARY ?? "/dev/null", `${buildReport({ repo: env.REPO, - ticket: env.TICKET, branch: env.BRANCH, agentId: agent.agentId, modelId: FIX_MODEL_ID, @@ -673,8 +686,9 @@ jobs: export { FIX_MODEL_ID, MAX_FINDINGS_BYTES, + PR_TITLE, agentMetadata, - applyPullRequestBody, + applyPullRequestDetails, buildFindingsPayload, buildPrompt, buildReport, From 65021a022596b3b67aa145cf616dcea5b2122f7e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 2 Nov 2026 17:39:36 +0000 Subject: [PATCH 6/6] ci(DPA2-6761): run the audit fix agent through the Cursor SDK The shared workflow now creates a Cursor cloud agent with @cursor/sdk instead of starting the Cursor CLI, so each run is tagged with the repository, use case and ticket and its spend can be attributed. The fix work itself goes to a DeepSeek Flash subagent. The fix pull request is opened by the Cursor GitHub App, so AUDIT_PAT is gone and CURSOR_API_KEY (organization-scoped) is the only secret this repository needs. The job takes no Jira ticket: it runs on a weekday schedule. --- .../dependency-audit-agent/action.yaml | 80 +++ .../actions/dependency-audit-agent/index.mjs | 539 +++++++++++++++ .github/workflows/dependency-audit.yaml | 624 +----------------- 3 files changed, 655 insertions(+), 588 deletions(-) create mode 100644 .github/actions/dependency-audit-agent/action.yaml create mode 100644 .github/actions/dependency-audit-agent/index.mjs diff --git a/.github/actions/dependency-audit-agent/action.yaml b/.github/actions/dependency-audit-agent/action.yaml new file mode 100644 index 0000000..72f2356 --- /dev/null +++ b/.github/actions/dependency-audit-agent/action.yaml @@ -0,0 +1,80 @@ +name: Dependency audit fix agent +description: | + Creates a Cursor cloud agent that fixes the CRITICAL and HIGH dependency findings found by the + weekday Trivy scan, and opens or updates the single fix pull request. + + The agent is tagged with `cloud.metadata` (repository, use case, workflow, branches, runner and + run) so its spend can be joined to a repository in usage and billing exports, and the mechanical + bump-and-regenerate loop is delegated to a subagent pinned to DeepSeek Flash. + + Findings reach the agent as environment variables because the scan runs on this runner while the + agent runs in its own VM. The verdict comes back in the agent's final message: a run that did not + finish, or that ends without a readable verdict, is treated as unresolved. + + The clone, the push and the pull request all go through the Cursor GitHub App, which must be + installed on the target repository. No repository-write token is needed. + + This action is consumed by `dependency-audit.yaml`. It exists as an action rather than as steps + in that workflow so a public or internal repository — which cannot call a private reusable + workflow — can still `uses:` it, keeping a single copy of the logic across every repository. + +inputs: + cursor-api-key: + description: Cursor API key that creates the cloud fix agent. + required: true + repository: + description: Target repository as `owner/name`. + required: true + repository-url: + description: Clone URL of the target repository. + required: true + base-branch: + description: Default branch that the agent branches from and that the fix pull request targets. + required: true + fix-branch: + description: Long-lived branch that carries the single fix pull request. + required: true + findings-critical-high: + description: JSON array of the CRITICAL and HIGH findings to fix, already filtered for accepted advisories. + required: true + findings-medium-low: + description: JSON array of the MEDIUM and LOW findings, listed in the pull request body but never fixed. + required: true + accepted-advisories: + description: JSON array of advisory ids the repository already accepts. + required: false + default: "[]" + toolchains: + description: Space-separated toolchains the repository uses, e.g. `node go rust`. + required: false + default: "unknown" + runner-label: + description: The runner pool the job is using, recorded in the agent metadata tag. + required: false + default: "unknown" + pull-request-existed-before: + description: >- + `true` when a fix pull request was already open before this run. When false, the body of the + newly opened pull request is set from the agent's description. + required: false + default: "false" + github-token: + description: >- + The job's own `GITHUB_TOKEN`, used only to set the body on a newly opened pull request. The + action does not push with it; the Cursor GitHub App owns the branch. + required: false + default: "" + +outputs: + unresolved-critical-high: + description: `true` when a CRITICAL or HIGH finding that is not excepted is still unresolved. + pull-request-url: + description: The fix pull request, when one was opened or already open. + agent-id: + description: The Cursor cloud agent id. Join this against usage and billing exports. + agent-status: + description: The terminal status of the agent run. + +runs: + using: node24 + main: index.mjs diff --git a/.github/actions/dependency-audit-agent/index.mjs b/.github/actions/dependency-audit-agent/index.mjs new file mode 100644 index 0000000..e41e1f9 --- /dev/null +++ b/.github/actions/dependency-audit-agent/index.mjs @@ -0,0 +1,539 @@ +// Action entry point: runs the dependency-audit fix agent through the Cursor Agent SDK. +// +// Why the SDK instead of the Cursor CLI: the SDK creates a real cloud agent, so the run is +// attributable. `cloud.metadata` tags the agent for its whole lifetime, and every usage and +// billing export carries `cloud_agent_id`. That is what lets spend be reported per repository and +// per use case instead of as one undifferentiated Cursor invoice line. +// +// The fix work is delegated to a named subagent pinned to DeepSeek Flash: the scan already says +// which package, which version and which lockfile, so the loop is mechanical and a large frontier +// model buys nothing. +// +// The scan runs on the GitHub runner while the agent runs in its own VM, so the findings arrive as +// action inputs and are forwarded as env vars, and the verdict comes back in the agent's final +// message. Nothing is shared through the filesystem. + +import { appendFileSync, writeFileSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; +import { pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; + +/** + * Title of the single fix pull request. A plain description, not a conventional commit with a + * ticket scope: this runs on a weekday schedule, so there is no ticket to reference. + */ +const PR_TITLE = "Resolve critical and high dependency vulnerabilities"; + +/** + * Pinned SDK version. The SDK ships a ~16 MB arch-specific native package alongside ~21 MB of JS, + * so it cannot be vendored into a repository (and vendoring would pin one architecture, while the + * org runs both amd64 and arm64 pools). It is installed at run time instead. + */ +const CURSOR_SDK_VERSION = "1.2.0"; + +/** Model the fix subagent runs on. Fixed here; the workflow does not let callers override it. */ +const FIX_MODEL_ID = "deepseek-v4.1-flash-1017"; + +/** + * Cloud env vars are encrypted and injected per session; keeping the findings payload under this + * cap leaves room for the rest of the environment and fails loudly if a repository has an absurd + * number of findings rather than truncating silently. + */ +const MAX_FINDINGS_BYTES = 60 * 1024; + +/** + * Installs the pinned SDK into a throwaway directory and imports it from there. + * + * Installing into the action's own directory would dirty the checkout, and this action is copied + * verbatim into repositories that cannot reference a private action — so it must not depend on + * anything outside its own file. `createRequire` resolves the real entry point, which is imported by + * URL rather than by bare specifier, because a bare specifier would be resolved from this file's + * directory where no `node_modules` exists. + */ +async function loadAgentSdk() { + const dir = await mkdtemp(join(tmpdir(), "dependency-audit-sdk-")); + writeFileSync(join(dir, "package.json"), '{"name":"dependency-audit-sdk","private":true}\n'); + + const install = spawnSync( + "npm", + ["install", "--no-audit", "--no-fund", "--omit=dev", "--loglevel=error", `@cursor/sdk@${CURSOR_SDK_VERSION}`], + { cwd: dir, encoding: "utf8" } + ); + if (install.status !== 0) { + throw new Error(`could not install @cursor/sdk@${CURSOR_SDK_VERSION}: ${install.stderr || install.stdout}`); + } + + // `require.resolve` returns the CJS entry, whose only export is `default`. The package's `import` + // condition points at the ESM build, which is what exposes the named exports. + const resolved = createRequire(join(dir, "resolve.cjs")).resolve("@cursor/sdk"); + const esm = resolved.replace(`${sep}dist${sep}cjs${sep}`, `${sep}dist${sep}esm${sep}`); + let sdk = await import(pathToFileURL(esm).href); + if (typeof sdk.Agent?.create !== "function" && esm !== resolved) { + // Fall back to whatever `resolve` returned, in case the layout changes. + sdk = await import(pathToFileURL(resolved).href); + } + if (typeof sdk.Agent?.create !== "function") { + throw new Error(`@cursor/sdk did not export Agent.create (resolved ${resolved})`); + } + return sdk.Agent; +} + +/** + * Reads an action input. Inputs arrive in `INPUT_` with the dashes turned into underscores, + * and GitHub does not strip surrounding whitespace. + */ +function input(name, { required = false, fallback = "" } = {}) { + const key = `INPUT_${name.replace(/-/g, "_").toUpperCase()}`; + const value = (process.env[key] ?? "").trim(); + if (value === "" && required) { + throw new Error(`input ${name} is required but empty`); + } + return value === "" ? fallback : value; +} + +/** Writes a step output through the command file, which handles multi-line values. */ +function setOutput(name, value) { + const file = process.env.GITHUB_OUTPUT; + if (!file) { + return; + } + const text = String(value); + if (text.includes("\n")) { + const delimiter = `EOF_${name.toUpperCase()}_${Date.now()}`; + appendFileSync(file, `${name}<<${delimiter}\n${text}\n${delimiter}\n`); + } else { + appendFileSync(file, `${name}=${text}\n`); + } +} + +function setSummaryError(message) { + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n> [!WARNING]\n> ${message}\n`); + } +} + +/** + * Tags persisted on the cloud agent. Keys are short and stable on purpose: they are joined against + * Cursor usage and billing exports by `cloud_agent_id`, so renaming one silently orphans the + * history already collected. Cursor caps metadata at 20 keys. + */ +function agentMetadata(cfg) { + return { + repository: cfg.repository, + usecase: "dependency-update", + workflow: "dependency-audit", + base_branch: cfg.baseBranch, + fix_branch: cfg.fixBranch, + runner: cfg.runnerLabel, + run_id: process.env.GITHUB_RUN_ID || "local", + run_attempt: process.env.GITHUB_RUN_ATTEMPT || "1", + server_url: process.env.GITHUB_SERVER_URL || "https://github.com" + }; +} + +/** Parses a JSON-array input, keeping only strings. An unreadable value is not fatal. */ +function parseArrayInput(value) { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : []; + } catch { + return []; + } +} + +/** + * The prompt is one string and the agent starts with no shell history, so everything it needs is + * spelled out. Findings arrive through env vars set on the agent's session. + */ +function buildPrompt(cfg) { + return `You fix dependency vulnerabilities in a GitHub repository. You are running as a cloud agent started by a GitHub Actions job that has already scanned the default branch. + +The repository is cloned for you and your commits are attributed to the Cursor GitHub App, which can push them and open the pull request. \`gh\` in this VM is authenticated the same way. If \`git push\` is rejected, the App is missing access to this repository: say so in your final answer and stop, do not look for another credential. + +# Context +- Repository: \`${cfg.repository}\` +- Default branch: \`${cfg.baseBranch}\` +- Fix branch: \`${cfg.fixBranch}\` +- CRITICAL and HIGH findings, each with a fixed version: the JSON array in \`$FINDINGS_CRITICAL_HIGH\` +- MEDIUM and LOW findings, to list only: the JSON array in \`$FINDINGS_MEDIUM_LOW\` +- Advisory ids the repository already accepts: the JSON array in \`$ACCEPTED_ADVISORIES\` +- Toolchains detected in the repository: \`$TOOLCHAINS\` + +Each finding has \`target\` (the lockfile path), \`pkg\`, \`installed\`, \`fixed\`, \`id\`, \`severity\` and \`vendorIds\`. Findings whose advisory id is in \`$ACCEPTED_ADVISORIES\` are already filtered out; do not re-derive them and do not re-fix them. + +# Toolchains +Install only the toolchains named in \`$TOOLCHAINS\` before regenerating any lockfile, at the version the repository pins in \`.nvmrc\`, \`.node-version\`, \`package.json#engines\`, \`packageManager\`, \`go.mod\` or \`rust-toolchain.toml\`. The private \`@1inch\` npm registry needs a token: use \`$(gh auth token)\` as \`NODE_AUTH_TOKEN\`, never a literal. + +# Security exceptions +If \`SECURITY_EXCEPTIONS.md\` exists in the repository root, read it first. Skip every package it lists as an accepted risk: do not fix it and do not count it as unresolved. Mention it in the pull request body as "excepted per SECURITY_EXCEPTIONS.md". + +\`.trivyignore\` is already applied by the scan that produced these findings. + +# How to fix each CRITICAL or HIGH finding +Work in the directory of the lockfile named in \`target\`. Use the first option that works: +1. Bump the direct dependency in that directory's manifest (\`package.json\`, \`go.mod\` or \`Cargo.toml\`) to a version that pulls in \`fixed\`. +2. Only when no parent bump reaches \`fixed\`: add an override (\`pnpm.overrides\`, npm \`overrides\`, Yarn \`resolutions\`, \`go get @\`, or \`cargo update -p --precise \`) and explain why in the commit message and the pull request body. + +Regenerate the lockfile with the tool that owns it, in the same directory: +- \`pnpm-lock.yaml\`: \`pnpm install --no-frozen-lockfile\` +- \`package-lock.json\`: \`npm install --package-lock-only\` +- \`yarn.lock\`: \`yarn install\` +- \`go.mod\`: \`go mod tidy\` +- \`Cargo.lock\`: \`cargo update -p \` + +Every lockfile is independent: never fix a nested lockfile from the root manifest. + +Verify each directory you touched. Trivy is not installed on this VM and the database is not downloaded, so install it, then scan once *without* \`--skip-db-update\` so the database is fetched: +\`curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin\` +\`trivy fs --scanners vuln --pkg-types library --severity CRITICAL,HIGH --ignore-unfixed \` +Repeat until its CRITICAL and HIGH findings are gone or cannot be fixed, dropping \`--skip-db-update\` only after the first scan has populated the database. + +# Scope +- Fix only the CRITICAL and HIGH findings you were given. +- Do not fix MEDIUM or LOW findings; list them in the pull request body. +- Do not change anything unrelated to these findings, and never edit files under \`.github/workflows/\`. +- Do not run \`pnpm audit\`, \`npm audit\` or \`yarn audit\`. + +# Branch +1. If \`${cfg.fixBranch}\` exists on origin (\`git ls-remote --exit-code --heads origin "${cfg.fixBranch}"\`): fetch it, check it out and rebase it onto \`origin/${cfg.baseBranch}\`. Never delete or recreate it. +2. Otherwise create it from \`origin/${cfg.baseBranch}\`. +3. Commit with the message \`fix(deps): resolve critical and high dependency vulnerabilities\` and push with \`git push --force-with-lease origin "${cfg.fixBranch}"\`. Never use \`git push --force\`. This is a scheduled job with no Jira ticket, so the commit scope is \`deps\`. + +# Pull request +This run opens the pull request for you from \`${cfg.fixBranch}\` into \`${cfg.baseBranch}\`, so do not call \`gh pr create\`. What is left to you is its description: + +- If \`gh pr list --head "${cfg.fixBranch}" --base "${cfg.baseBranch}" --state open --json number --jq '.[0].number'\` returns a number, update that pull request's body with \`gh pr edit --body-file \`. +- Otherwise report the description in your final answer and the workflow will set it on the pull request this run opens. + +The body must contain: +- A table with the columns Lockfile, Package, Installed, Fixed, Advisory, Severity, Fix applied. +- Whether every CRITICAL and HIGH finding is resolved, and the reason for each one that is not. +- The MEDIUM and LOW findings in a collapsed \`
\` list. + +Keep exactly one open pull request from \`${cfg.fixBranch}\`: never open a second one, and never recreate the branch. + +If nothing needs changing because every finding is excepted, do not push and do not open a pull request. + +# Delegation +Do the mechanical bump-and-regenerate work through the \`dependency-fixer\` subagent, which runs on a fast, cheap model. Keep the planning, the verification, the commit, the push and the pull request body yourself. + +# Result +End your final message with a single fenced \`json\` block holding exactly these keys: +- \`unresolvedCriticalHigh\`: boolean, true when any CRITICAL or HIGH finding that is not excepted remains. +- \`pullRequestBody\`: string, the markdown body for the pull request (empty when you did not push). +- \`summary\`: string, one or two sentences naming what you changed. +Do not put this block anywhere else and do not commit it to the repository.`; +} + +/** + * The fix subagent. DeepSeek Flash is ample for this loop and far cheaper than the account + * default, while the parent agent keeps the default model so the plan, the verification and the + * pull request body stay coherent. + */ +function subagents() { + return { + "dependency-fixer": { + description: + "Fixes CRITICAL and HIGH dependency findings: bumps the parent dependency or adds an override, regenerates the owning lockfile, and verifies with a re-scan.", + model: { id: FIX_MODEL_ID }, + prompt: `You fix dependency vulnerabilities in a checked-out repository. You are a subagent: the parent agent has already established the scope, the findings and the branch, and will handle the commit, the push and the pull request. + +For every CRITICAL or HIGH finding you are given: +1. Work in the directory that owns the vulnerable lockfile. Every lockfile is independent, so never fix a nested lockfile from the root manifest. +2. Prefer bumping the direct dependency in that directory's manifest. Add an override (\`pnpm.overrides\`, npm \`overrides\`, Yarn \`resolutions\`, \`go get @\`, \`cargo update -p --precise \`) only when no parent bump reaches the fixed version, and say why when you do. +3. Regenerate the lockfile with the tool that owns it: \`pnpm install --no-frozen-lockfile\`, \`npm install --package-lock-only\`, \`yarn install\`, \`go mod tidy\`, or \`cargo update -p \`. +4. Re-scan that directory and repeat until its CRITICAL and HIGH findings are gone or cannot be fixed. The database is not on the VM, so the first scan must run without \`--skip-db-update\`. + +Report back, per finding, the package, the installed version, the fixed version, the advisory id and what you did. Say plainly which findings you could not fix and why. Do not run \`pnpm audit\`, \`npm audit\` or \`yarn audit\`. Do not touch anything unrelated to these findings, and never edit files under \`.github/workflows/\`.` + } + }; +} + +/** Builds the job-summary markdown. Pure, so tests can assert on it without a network call. */ +function buildReport({ repository, baseBranch, fixBranch, agentId, result, usage }) { + const lines = [ + "### Dependency audit fix agent", + "", + "| Field | Value |", + "| --- | --- |", + `| Repository | \`${repository}\` |`, + `| Branch | \`${fixBranch}\` |`, + `| Base | \`${baseBranch}\` |`, + `| Cloud agent | \`${agentId}\` |`, + `| Fix subagent model | \`${FIX_MODEL_ID}\` |`, + `| Status | ${result.status} |` + ]; + + if (typeof result.durationMs === "number") { + lines.push(`| Duration | ${Math.round(result.durationMs / 1000)}s |`); + } + + if (usage) { + lines.push( + `| Tokens | ${usage.inputTokens} in / ${usage.outputTokens} out / ${usage.cacheReadTokens} cache read |` + ); + } + + if (result.git?.branches?.length) { + const branches = result.git.branches + .map((entry) => (entry.prUrl ? `${entry.branch} (${entry.prUrl})` : entry.branch)) + .filter(Boolean) + .join(", "); + if (branches) { + lines.push(`| Git | ${branches} |`); + } + } + + lines.push( + "", + `The tags on cloud agent \`${agentId}\` are what let usage and billing exports be joined to the repository and the use case.` + ); + return lines.join("\n"); +} + +/** + * Pulls the verdict out of the agent's final message: the last fenced json block, or the last JSON + * object if the model ignored the fence. + */ +function parseVerdict(text) { + if (typeof text !== "string" || text.trim() === "") { + return undefined; + } + + const blocks = [...text.matchAll(/```json\s*([\s\S]*?)```/gi)].map((match) => match[1]); + for (const candidate of [...blocks.reverse(), text]) { + const start = candidate.indexOf("{"); + const end = candidate.lastIndexOf("}"); + if (start === -1 || end <= start) { + continue; + } + try { + const parsed = JSON.parse(candidate.slice(start, end + 1)); + if (parsed && typeof parsed === "object" && "unresolvedCriticalHigh" in parsed) { + return { + unresolvedCriticalHigh: parsed.unresolvedCriticalHigh !== false, + // Some runs report the URL explicitly; otherwise the run's git info supplies it. + pullRequestUrl: typeof parsed.pullRequestUrl === "string" ? parsed.pullRequestUrl : "", + pullRequestBody: typeof parsed.pullRequestBody === "string" ? parsed.pullRequestBody : "", + summary: typeof parsed.summary === "string" ? parsed.summary : "" + }; + } + } catch { + // A fenced block that is not the verdict is not fatal; try the next candidate. + } + } + return undefined; +} + +/** + * Turns a finished run into an outcome. A run that did not finish, or whose message holds no + * verdict, counts as unresolved: reporting green there would hide findings nobody judged. + */ +function collectResult({ status, error, text, git }) { + const gitPullRequest = + git?.branches?.find((branch) => typeof branch.prUrl === "string" && branch.prUrl !== "")?.prUrl ?? ""; + + if (status !== "finished") { + return { + unresolvedCriticalHigh: true, + pullRequestUrl: gitPullRequest, + pullRequestBody: "", + summary: `agent run ended with status ${status}${error?.message ? `: ${error.message}` : ""}` + }; + } + + const verdict = parseVerdict(text); + if (!verdict) { + return { + unresolvedCriticalHigh: true, + pullRequestUrl: gitPullRequest, + pullRequestBody: "", + summary: "agent finished without a readable verdict in its final message" + }; + } + + return { ...verdict, pullRequestUrl: verdict.pullRequestUrl || gitPullRequest }; +} + +/** + * Sets the title and body on the pull request this run opened. Best effort and never fatal: the fix + * is already pushed and the pull request already open, so a title or body that could not be set is + * a cosmetic miss, not a failed audit. + * + * The body is set only on a newly opened pull request: an existing one carries the agent's + * accumulated description. + */ +async function applyPullRequestDetails({ repository, pullRequestUrl, existedBefore, token, requestBody }) { + if (!pullRequestUrl || !token) { + return; + } + + const number = pullRequestUrl.match(/\/pull\/(\d+)$/)?.[1]; + if (!number) { + return; + } + + const payload = { title: PR_TITLE }; + if (!existedBefore && requestBody) { + payload.body = requestBody; + } + + const response = await fetch(`https://api.github.com/repos/${repository}/pulls/${number}`, { + method: "PATCH", + headers: { + authorization: `Bearer ${token}`, + accept: "application/vnd.github+json", + "content-type": "application/json", + "user-agent": "dependency-audit" + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + process.stdout.write(`could not set the pull request details: HTTP ${response.status}\n`); + } +} + +async function main() { + const cfg = { + apiKey: input("cursor-api-key", { required: true }), + repository: input("repository", { required: true }), + repositoryUrl: input("repository-url", { required: true }), + baseBranch: input("base-branch", { required: true }), + fixBranch: input("fix-branch", { required: true }), + toolchains: input("toolchains", { fallback: "unknown" }), + runnerLabel: input("runner-label", { fallback: "unknown" }), + githubToken: input("github-token"), + prExistedBefore: input("pull-request-existed-before", { fallback: "false" }) === "true" + }; + + const findings = { + FINDINGS_CRITICAL_HIGH: input("findings-critical-high", { required: true }), + FINDINGS_MEDIUM_LOW: input("findings-medium-low", { required: true }), + ACCEPTED_ADVISORIES: input("accepted-advisories", { fallback: "[]" }) + }; + + for (const [key, value] of Object.entries(findings)) { + const bytes = Buffer.byteLength(value, "utf8"); + if (bytes > MAX_FINDINGS_BYTES) { + throw new Error( + `${key} is ${bytes} bytes, above the ${MAX_FINDINGS_BYTES}-byte action input cap; ` + + "split the scan per lockfile before running the agent" + ); + } + } + + // Fail before spending anything on a malformed findings payload. + parseArrayInput(findings.FINDINGS_CRITICAL_HIGH); + + const Agent = await loadAgentSdk(); + + const agent = await Agent.create({ + apiKey: cfg.apiKey, + name: `${cfg.repository} dependency audit`, + cloud: { + repos: [{ url: cfg.repositoryUrl, startingRef: cfg.baseBranch }], + // The Cursor GitHub App owns the clone, the push and the pull request, so nothing here needs + // a repository-write token. Letting the SDK open the PR is what keeps it to one: `gh pr + // create` cannot see that a PR from the branch already exists when the head is identical. + autoCreatePR: true, + openAsCursorGithubApp: true, + skipReviewerRequest: true, + metadata: agentMetadata(cfg), + envVars: { + TOOLCHAINS: cfg.toolchains, + ...findings + } + }, + agents: subagents() + }); + + // `Agent.create()` resolves before the VM is provisioned, so surface the id immediately: a run + // that dies during provisioning is otherwise untraceable from the job log. + process.stdout.write(`cloud agent ${agent.agentId} created for ${cfg.repository}\n`); + setOutput("agent-id", agent.agentId); + + try { + const run = await agent.send(buildPrompt(cfg)); + const result = await run.wait(); + + let usage = result.usage; + if (!usage) { + try { + usage = (await agent.getUsage()).usage; + } catch (usageError) { + process.stdout.write(`agent usage unavailable: ${usageError.message}\n`); + } + } + + const collected = collectResult({ + status: result.status, + error: result.error, + text: result.result, + git: result.git + }); + + await applyPullRequestDetails({ + repository: cfg.repository, + pullRequestUrl: collected.pullRequestUrl, + existedBefore: cfg.prExistedBefore, + token: cfg.githubToken, + requestBody: collected.pullRequestBody + }); + + setOutput("unresolved-critical-high", collected.unresolvedCriticalHigh); + setOutput("pull-request-url", collected.pullRequestUrl); + setOutput("agent-status", result.status); + + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `${buildReport({ + repository: cfg.repository, + baseBranch: cfg.baseBranch, + fixBranch: cfg.fixBranch, + agentId: agent.agentId, + result, + usage + })}\n` + ); + } + + if (collected.summary) { + process.stdout.write(`${collected.summary}\n`); + } + if (result.status !== "finished") { + setSummaryError(collected.summary); + } + } finally { + agent.close(); + } +} + +export { + CURSOR_SDK_VERSION, + FIX_MODEL_ID, + MAX_FINDINGS_BYTES, + PR_TITLE, + agentMetadata, + applyPullRequestDetails, + buildPrompt, + buildReport, + collectResult, + loadAgentSdk, + parseArrayInput, + parseVerdict, + subagents +}; + +/** + * Actions always run this file directly. The guard keeps the module importable by its own tests, + * which is what lets the prompt, the verdict parser and the metadata tags be asserted without a + * network call. + */ +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + await main(); +} diff --git a/.github/workflows/dependency-audit.yaml b/.github/workflows/dependency-audit.yaml index b3265a9..f61ad6d 100644 --- a/.github/workflows/dependency-audit.yaml +++ b/.github/workflows/dependency-audit.yaml @@ -34,9 +34,6 @@ jobs: REPO: ${{ github.repository }} REPO_URL: ${{ github.server_url }}/${{ github.repository }} TRIVY_VERSION: v0.76.0 - # Pinned: the runner is the only place the findings and the verdict are assembled, so a - # silent minor bump would change how the agent is started. - CURSOR_SDK_VERSION: 1.2.0 steps: - name: Require secrets # Organization secret: no per-repository copy is needed. @@ -88,11 +85,25 @@ jobs: medium_low=$(count_findings "$medium_low_report") echo "critical_high=$critical_high" >> "$GITHUB_OUTPUT" + # The action takes the findings as inputs. Emit the compact shape it expects — target, + # package, versions, advisory id, severity — rather than the whole Trivy report, whose + # package paths and descriptions only bloat the payload and hit the input cap. + compact() { + jq --argjson accepted "$accepted_advisories" -c ' + [ .Results[]? as $result + | $result.Vulnerabilities[]? + | ([.VulnerabilityID] + (.VendorIDs // [])) as $ids + | select(($ids - $accepted) == $ids) + | { target: $result.Target, pkg: .PkgName, installed: .InstalledVersion, + fixed: .FixedVersion, id: .VulnerabilityID, severity: .Severity, + vendorIds: (.VendorIDs // []) } ]' "$1" + } { - echo "TRIVY_CRITICAL_HIGH_REPORT=$critical_high_report" - echo "TRIVY_MEDIUM_LOW_REPORT=$medium_low_report" - echo "ACCEPTED_ADVISORIES=$accepted_advisories" - } >> "$GITHUB_ENV" + echo "findings_critical_high=$(compact "$critical_high_report")" + echo "findings_medium_low=$(compact "$medium_low_report")" + echo "accepted_advisories=$accepted_advisories" + } >> "$GITHUB_OUTPUT" + echo "ACCEPTED_ADVISORIES=$accepted_advisories" >> "$GITHUB_ENV" { echo "### Dependency audit" echo "" @@ -146,593 +157,30 @@ jobs: echo "exists=false" >> "$GITHUB_OUTPUT" fi - - name: Setup Node.js for the Cursor SDK - if: steps.scan.outputs.critical_high != '0' - uses: actions/setup-node@v7 - with: - node-version: "22" - - - name: Write the fix-agent runner - if: steps.scan.outputs.critical_high != '0' - run: | - cat > "$RUNNER_TEMP/run-cursor-agent.mjs" <<'RUNNER_EOF' - /* __RUNNER_SCRIPT__ */ - // Runs the dependency-audit fix agent through the Cursor Agent SDK. - // - // Why the SDK instead of the Cursor CLI: the SDK creates a real cloud agent, so the run is - // attributable. `cloud.metadata` tags the agent for its whole lifetime, and every usage and - // billing export carries `cloud_agent_id`. That is what lets spend be reported per repository, - // per use case instead of as one undifferentiated Cursor invoice line. - // - // The fix work is delegated to a named subagent pinned to DeepSeek Flash: the scan already says - // which package, which version and which lockfile, so the loop is mechanical and a large - // frontier model buys nothing. - // - // The scan runs on the GitHub runner while the agent runs in its own VM, so the findings travel - // as env vars (FINDINGS_CRITICAL_HIGH, FINDINGS_MEDIUM_LOW, TOOLCHAINS) and the verdict comes - // back in the agent's final message. Nothing is shared through the filesystem. - - import { appendFile, readFile } from "node:fs/promises"; - import { pathToFileURL } from "node:url"; - import { Agent } from "@cursor/sdk"; - - /** - * Title of the single fix pull request. A plain description, not a conventional commit with a - * ticket scope: this runs on a weekday schedule, so there is no ticket to reference. - */ - const PR_TITLE = "Resolve critical and high dependency vulnerabilities"; - - /** Model the fix subagent runs on. Fixed here; the workflow does not let callers override it. */ - const FIX_MODEL_ID = "deepseek-v4.1-flash-1017"; - - /** - * Cloud env vars are encrypted and injected per session; keeping the findings payload under - * this cap leaves room for the rest of the environment and fails loudly if a repository has an - * absurd number of findings rather than truncating silently. - */ - const MAX_FINDINGS_BYTES = 60 * 1024; - - const REQUIRED_ENV = [ - "CURSOR_API_KEY", - "REPO_URL", - "REPO", - "BASE_BRANCH", - "BRANCH", - "TRIVY_CRITICAL_HIGH_REPORT", - "TRIVY_MEDIUM_LOW_REPORT", - "ACCEPTED_ADVISORIES" - ]; - - function requireEnv(env, name) { - const value = env[name]; - if (typeof value !== "string" || value.trim() === "") { - throw new Error(`required environment variable ${name} is empty`); - } - return value; - } - - /** - * Tags persisted on the cloud agent. Keys are short and stable on purpose: they are joined - * against Cursor usage and billing exports by `cloud_agent_id`, so renaming one silently - * orphans the history already collected. Cursor caps metadata at 20 keys. - */ - function agentMetadata(env) { - return { - repository: env.REPO, - usecase: "dependency-update", - workflow: "dependency-audit", - base_branch: env.BASE_BRANCH, - fix_branch: env.BRANCH, - runner: env.RUNNER || "unknown", - run_id: env.GITHUB_RUN_ID || "local", - run_attempt: env.GITHUB_RUN_ATTEMPT || "1", - server_url: env.GITHUB_SERVER_URL || "https://github.com" - }; - } - - /** - * Trims a Trivy JSON report to the fields the agent acts on. The raw report carries package - * paths, layer ids and long descriptions that only bloat the payload. - */ - function compactFindings(report) { - const findings = []; - for (const result of report.Results ?? []) { - for (const vulnerability of result.Vulnerabilities ?? []) { - findings.push({ - target: result.Target, - pkg: vulnerability.PkgName, - installed: vulnerability.InstalledVersion, - fixed: vulnerability.FixedVersion, - id: vulnerability.VulnerabilityID, - severity: vulnerability.Severity, - vendorIds: vulnerability.VendorIDs ?? [] - }); - } - } - return findings; - } - - /** - * Drops advisories the repository has already accepted, because Trivy does not read - * `pnpm.auditConfig` and an accepted finding would otherwise send the agent to work it again. - */ - function excludeAccepted(findings, accepted) { - const acceptedSet = new Set(accepted); - return findings.filter( - (finding) => ![finding.id, ...finding.vendorIds].some((identifier) => acceptedSet.has(identifier)) - ); - } - - async function buildFindingsPayload(env) { - const [rawCriticalHigh, rawMediumLow] = await Promise.all([ - readFile(env.TRIVY_CRITICAL_HIGH_REPORT, "utf8"), - readFile(env.TRIVY_MEDIUM_LOW_REPORT, "utf8") - ]); - - let accepted = []; - try { - const parsed = JSON.parse(env.ACCEPTED_ADVISORIES); - if (Array.isArray(parsed)) { - accepted = parsed.filter((value) => typeof value === "string"); - } - } catch { - // The workflow always writes valid JSON; an unreadable value must not stop the audit. - } - - const payload = { - FINDINGS_CRITICAL_HIGH: JSON.stringify(excludeAccepted(compactFindings(JSON.parse(rawCriticalHigh)), accepted)), - FINDINGS_MEDIUM_LOW: JSON.stringify(excludeAccepted(compactFindings(JSON.parse(rawMediumLow)), accepted)) - }; - - for (const [key, value] of Object.entries(payload)) { - const bytes = Buffer.byteLength(value, "utf8"); - if (bytes > MAX_FINDINGS_BYTES) { - throw new Error( - `${key} is ${bytes} bytes, above the ${MAX_FINDINGS_BYTES}-byte cloud env var cap; ` + - "split the scan per lockfile before running the agent" - ); - } - } - - return payload; - } - - /** - * The prompt is one string and the agent starts with no shell history, so everything it needs - * is spelled out. The findings arrive through env vars because the scan runs on the GitHub - * runner and the agent runs in its own VM. - */ - function buildPrompt(env) { - return `You fix dependency vulnerabilities in a GitHub repository. You are running as a cloud agent started by a GitHub Actions job that has already scanned the default branch. - - The repository is cloned for you and your commits are attributed to the Cursor GitHub App, which can push them and open the pull request. \`gh\` in this VM is authenticated the same way. If \`git push\` is rejected, the App is missing access to this repository: say so in your final answer and stop, do not look for another credential. - - # Context - - Repository: \`${env.REPO}\` - - Default branch: \`${env.BASE_BRANCH}\` - - Fix branch: \`${env.BRANCH}\` - - CRITICAL and HIGH findings, each with a fixed version: the JSON array in \`$FINDINGS_CRITICAL_HIGH\` - - MEDIUM and LOW findings, to list only: the JSON array in \`$FINDINGS_MEDIUM_LOW\` - - Advisory ids the repository already accepts: the JSON array in \`$ACCEPTED_ADVISORIES\` - - Toolchains detected in the repository: \`$TOOLCHAINS\` - - Each finding has \`target\` (the lockfile path), \`pkg\`, \`installed\`, \`fixed\`, \`id\`, \`severity\` and \`vendorIds\`. Findings whose advisory id is in \`$ACCEPTED_ADVISORIES\` are already filtered out; do not re-derive them and do not re-fix them. - - # Toolchains - Install only the toolchains named in \`$TOOLCHAINS\` before regenerating any lockfile, at the version the repository pins in \`.nvmrc\`, \`.node-version\`, \`package.json#engines\`, \`packageManager\`, \`go.mod\` or \`rust-toolchain.toml\`. The private \`@1inch\` npm registry needs a token: use \`$(gh auth token)\` as \`NODE_AUTH_TOKEN\`, never a literal. - - # Security exceptions - If \`SECURITY_EXCEPTIONS.md\` exists in the repository root, read it first. Skip every package it lists as an accepted risk: do not fix it and do not count it as unresolved. Mention it in the pull request body as "excepted per SECURITY_EXCEPTIONS.md". - - \`.trivyignore\` is already applied by the scan that produced these findings. - - # How to fix each CRITICAL or HIGH finding - Work in the directory of the lockfile named in \`target\`. Use the first option that works: - 1. Bump the direct dependency in that directory's manifest (\`package.json\`, \`go.mod\` or \`Cargo.toml\`) to a version that pulls in \`fixed\`. - 2. Only when no parent bump reaches \`fixed\`: add an override (\`pnpm.overrides\`, npm \`overrides\`, Yarn \`resolutions\`, \`go get @\`, or \`cargo update -p --precise \`) and explain why in the commit message and the pull request body. - - Regenerate the lockfile with the tool that owns it, in the same directory: - - \`pnpm-lock.yaml\`: \`pnpm install --no-frozen-lockfile\` - - \`package-lock.json\`: \`npm install --package-lock-only\` - - \`yarn.lock\`: \`yarn install\` - - \`go.mod\`: \`go mod tidy\` - - \`Cargo.lock\`: \`cargo update -p \` - - Every lockfile is independent: never fix a nested lockfile from the root manifest. - - Verify each directory you touched. Trivy is not installed on this VM and the database is not downloaded, so install it, then scan once *without* \`--skip-db-update\` so the database is fetched: - \`curl -sSfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin\` - \`trivy fs --scanners vuln --pkg-types library --severity CRITICAL,HIGH --ignore-unfixed \` - Repeat until its CRITICAL and HIGH findings are gone or cannot be fixed, dropping \`--skip-db-update\` only after the first scan has populated the database. - - # Scope - - Fix only the CRITICAL and HIGH findings you were given. - - Do not fix MEDIUM or LOW findings; list them in the pull request body. - - Do not change anything unrelated to these findings, and never edit files under \`.github/workflows/\`. - - Do not run \`pnpm audit\`, \`npm audit\` or \`yarn audit\`. - - # Branch - 1. If \`${env.BRANCH}\` exists on origin (\`git ls-remote --exit-code --heads origin "${env.BRANCH}"\`): fetch it, check it out and rebase it onto \`origin/${env.BASE_BRANCH}\`. Never delete or recreate it. - 2. Otherwise create it from \`origin/${env.BASE_BRANCH}\`. - 3. Commit with the message \`fix(deps): resolve critical and high dependency vulnerabilities\` and push with \`git push --force-with-lease origin "${env.BRANCH}"\`. Never use \`git push --force\`. This is a scheduled job with no Jira ticket, so the commit scope is \`deps\`. - - # Pull request - This run opens the pull request for you from \`${env.BRANCH}\` into \`${env.BASE_BRANCH}\`, so do not call \`gh pr create\`. What is left to you is its description: - - - If \`gh pr list --head "${env.BRANCH}" --base "${env.BASE_BRANCH}" --state open --json number --jq '.[0].number'\` returns a number, update that pull request's body with \`gh pr edit --body-file \`. - - Otherwise write the description to a file and leave it; report its path in your final answer and the workflow will set it on the pull request this run opens. - - The body must contain: - - A table with the columns Lockfile, Package, Installed, Fixed, Advisory, Severity, Fix applied. - - Whether every CRITICAL and HIGH finding is resolved, and the reason for each one that is not. - - The MEDIUM and LOW findings in a collapsed \`
\` list. - - Keep exactly one open pull request from \`${env.BRANCH}\`: never open a second one, and never recreate the branch. - - If nothing needs changing because every finding is excepted, do not push and do not open a pull request. - - # Delegation - Do the mechanical bump-and-regenerate work through the \`dependency-fixer\` subagent, which runs on a fast, cheap model. Keep the planning, the verification, the commit, the push and the pull request body yourself. - - # Result - End your final message with a single fenced \`json\` block holding exactly these keys: - - \`unresolvedCriticalHigh\`: boolean, true when any CRITICAL or HIGH finding that is not excepted remains. - - \`pullRequestUrl\`: string, the pull request you opened or updated, or an empty string when you did not push. - - \`summary\`: string, one or two sentences naming what you changed. - Do not put this block anywhere else and do not commit it to the repository.`; - } - - /** - * The fix subagent. DeepSeek Flash is ample for this loop and far cheaper than the account - * default, while the parent agent keeps the default model so the plan, the verification and the - * pull request body stay coherent. - */ - function subagents() { - return { - "dependency-fixer": { - description: - "Fixes CRITICAL and HIGH dependency findings: bumps the parent dependency or adds an override, regenerates the owning lockfile, and verifies with a re-scan.", - model: { id: FIX_MODEL_ID }, - prompt: `You fix dependency vulnerabilities in a checked-out repository. You are a subagent: the parent agent has already established the scope, the findings and the branch, and will handle the commit, the push and the pull request. - - For every CRITICAL or HIGH finding you are given: - 1. Work in the directory that owns the vulnerable lockfile. Every lockfile is independent, so never fix a nested lockfile from the root manifest. - 2. Prefer bumping the direct dependency in that directory's manifest. Add an override (\`pnpm.overrides\`, npm \`overrides\`, Yarn \`resolutions\`, \`go get @\`, \`cargo update -p --precise \`) only when no parent bump reaches the fixed version, and say why when you do. - 3. Regenerate the lockfile with the tool that owns it: \`pnpm install --no-frozen-lockfile\`, \`npm install --package-lock-only\`, \`yarn install\`, \`go mod tidy\`, or \`cargo update -p \`. - 4. Re-scan that directory and repeat until its CRITICAL and HIGH findings are gone or cannot be fixed. The database is not on the VM, so the first scan must run without \`--skip-db-update\`. - - Report back, per finding, the package, the installed version, the fixed version, the advisory id and what you did. Say plainly which findings you could not fix and why. Do not run \`pnpm audit\`, \`npm audit\` or \`yarn audit\`. Do not touch anything unrelated to these findings, and never edit files under \`.github/workflows/\`.` - } - }; - } - - /** - * Builds the job-summary markdown. Pure, so tests can assert on it without a network call. - */ - function buildReport({ repo, branch, agentId, modelId, result, usage }) { - const lines = [ - "### Dependency audit fix agent", - "", - "| Field | Value |", - "| --- | --- |", - `| Repository | \`${repo}\` |`, - `| Branch | \`${branch}\` |`, - `| Cloud agent | \`${agentId}\` |`, - `| Fix subagent model | \`${modelId}\` |`, - `| Status | ${result.status} |` - ]; - - if (typeof result.durationMs === "number") { - lines.push(`| Duration | ${Math.round(result.durationMs / 1000)}s |`); - } - - if (usage) { - lines.push( - `| Tokens | ${usage.inputTokens} in / ${usage.outputTokens} out / ${usage.cacheReadTokens} cache read |` - ); - } - - if (result.git?.branches?.length) { - const branches = result.git.branches - .map((entry) => (entry.prUrl ? `${entry.branch} (${entry.prUrl})` : entry.branch)) - .filter(Boolean) - .join(", "); - if (branches) { - lines.push(`| Git | ${branches} |`); - } - } - - lines.push( - "", - `The tags on cloud agent \`${agentId}\` are what let usage and billing exports be joined to the repository and the use case.` - ); - return lines.join("\n"); - } - - /** - * Pulls the verdict out of the agent's final message. It is the last fenced json block, or the - * last JSON object if the model ignored the fence. Anything unreadable is treated as unresolved: - * reporting "resolved" there would hide findings the agent never judged. - */ - function parseVerdict(text) { - if (typeof text !== "string" || text.trim() === "") { - return undefined; - } - - const blocks = [...text.matchAll(/```json\s*([\s\S]*?)```/gi)].map((match) => match[1]); - for (const candidate of [...blocks.reverse(), text]) { - const start = candidate.indexOf("{"); - const end = candidate.lastIndexOf("}"); - if (start === -1 || end <= start) { - continue; - } - try { - const parsed = JSON.parse(candidate.slice(start, end + 1)); - if (parsed && typeof parsed === "object" && "unresolvedCriticalHigh" in parsed) { - return { - unresolvedCriticalHigh: parsed.unresolvedCriticalHigh !== false, - pullRequestUrl: typeof parsed.pullRequestUrl === "string" ? parsed.pullRequestUrl : "", - summary: typeof parsed.summary === "string" ? parsed.summary : "" - }; - } - } catch { - // Try the next candidate: a fenced block that is not the verdict is not fatal. - } - } - return undefined; - } - - /** - * Sets the title and body on the pull request this run opened. Best effort and never fatal: the - * fix is already pushed and the pull request already open, so a title or body that could not be - * set is a cosmetic miss, not a failed audit. - * - * The title is set here because the SDK names the pull request from the prompt, and a scheduled - * job has no ticket to put in a conventional-commit scope. The body is set only on a newly opened - * pull request: an existing one carries the agent's accumulated description. - */ - async function applyPullRequestDetails({ repo, branch, bodyPath, pullRequestUrl, existedBefore, token, title }) { - if (!pullRequestUrl || !token) { - return; - } - - const number = pullRequestUrl.match(/\/pull\/(\d+)$/)?.[1]; - if (!number) { - return; - } - - const payload = {}; - if (title) { - payload.title = title; - } - if (!existedBefore) { - try { - payload.body = await readFile(bodyPath, "utf8"); - } catch { - // A missing description is not worth failing the audit over. - } - } - if (Object.keys(payload).length === 0) { - return; - } - - const response = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, { - method: "PATCH", - headers: { - authorization: `Bearer ${token}`, - accept: "application/vnd.github+json", - "content-type": "application/json", - "user-agent": "dependency-audit" - }, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - process.stdout.write(`could not set the pull request details on ${branch}: HTTP ${response.status}\n`); - } - } - - /** - * Turns a finished run into job outputs. A run that did not finish, or whose final message holds - * no verdict, counts as unresolved. - */ - function collectResult({ status, error, text, git }) { - const gitPullRequest = git?.branches?.find((branch) => typeof branch.prUrl === "string" && branch.prUrl !== "")?.prUrl ?? ""; - - if (status !== "finished") { - return { - unresolvedCriticalHigh: true, - pullRequestUrl: gitPullRequest, - summary: `agent run ended with status ${status}${error?.message ? `: ${error.message}` : ""}` - }; - } - - const verdict = parseVerdict(text); - if (!verdict) { - return { - unresolvedCriticalHigh: true, - pullRequestUrl: gitPullRequest, - summary: "agent finished without a readable verdict in its final message" - }; - } - - return { - ...verdict, - pullRequestUrl: verdict.pullRequestUrl || gitPullRequest - }; - } - - async function main() { - const env = {}; - for (const name of REQUIRED_ENV) { - env[name] = requireEnv(process.env, name); - } - for (const name of [ - "TOOLCHAINS", - "GITHUB_RUN_ID", - "GITHUB_RUN_ATTEMPT", - "GITHUB_SERVER_URL", - "GITHUB_ENV", - "GITHUB_OUTPUT", - "GITHUB_STEP_SUMMARY" - ]) { - env[name] = process.env[name]; - } - // `RUNNER` is not exported by the workflow; the tag has to come from the `runner` input, or - // every agent records `unknown` and the billing export cannot be split by runner pool. - env.RUNNER = process.env.INPUT_RUNNER || process.env.RUNNER; - // Only the runner's own `GITHUB_TOKEN` is used, and only to set the body of the pull request the - // SDK opens. Without a PAT nothing here can write to the repository. - env.GITHUB_TOKEN = process.env.GITHUB_TOKEN; - env.PR_EXISTED_BEFORE = process.env.PR_EXISTED_BEFORE; - - const findings = await buildFindingsPayload(env); - - const agent = await Agent.create({ - apiKey: env.CURSOR_API_KEY, - name: `${env.REPO} dependency audit`, - cloud: { - repos: [{ url: env.REPO_URL, startingRef: env.BASE_BRANCH }], - // The Cursor GitHub App owns the clone, the push and the pull request, so no PAT is needed - // and nothing in this job has to be able to write to the repository. Letting the SDK open - // the PR is what keeps it to one: `gh pr create` cannot see that a PR from the branch - // already exists when the head is identical, and would fail or open a second one. - autoCreatePR: true, - openAsCursorGithubApp: true, - skipReviewerRequest: true, - metadata: agentMetadata(env), - // `ACCEPTED_ADVISORIES` has to travel too: the agent re-scans after fixing, and without it - // an accepted advisory looks like a leftover finding and fails the job. - envVars: { - TOOLCHAINS: env.TOOLCHAINS || "unknown", - ACCEPTED_ADVISORIES: env.ACCEPTED_ADVISORIES || "[]", - ...findings - } - }, - agents: subagents() - }); - - // `Agent.create()` resolves before the VM is provisioned, so log the id immediately: a run - // that dies during provisioning is otherwise untraceable from the job log. - process.stdout.write(`cloud agent ${agent.agentId} created for ${env.REPO}\n`); - - try { - const run = await agent.send(buildPrompt(env)); - const result = await run.wait(); - - let usage = result.usage; - if (!usage) { - try { - usage = (await agent.getUsage()).usage; - } catch (usageError) { - process.stdout.write(`agent usage unavailable: ${usageError.message}\n`); - } - } - - const collected = collectResult({ - status: result.status, - error: result.error, - text: result.result, - git: result.git - }); - - // The SDK opens the pull request, so its body is set here. Only when nothing was open yet: - // an existing pull request's body is the agent's to maintain, and overwriting it every - // weekday would erase the accumulated history of what it already fixed. - const bodyPath = `${process.env.RUNNER_TEMP ?? process.cwd()}/dependency-audit-pr-body.md`; - await applyPullRequestDetails({ - repo: env.REPO, - branch: env.BRANCH, - bodyPath, - pullRequestUrl: collected.pullRequestUrl, - existedBefore: env.PR_EXISTED_BEFORE === "true", - token: env.GITHUB_TOKEN, - title: PR_TITLE - }); - - const outputs = [ - `unresolved_critical_high=${collected.unresolvedCriticalHigh}`, - `pull_request_url=${collected.pullRequestUrl}`, - `agent_id=${agent.agentId}`, - `agent_status=${result.status}` - ].join("\n"); - - await appendFile(env.GITHUB_OUTPUT ?? "/dev/null", `${outputs}\n`); - await appendFile( - env.GITHUB_ENV ?? "/dev/null", - `UNRESOLVED_CRITICAL_HIGH=${collected.unresolvedCriticalHigh}\n` - ); - await appendFile( - env.GITHUB_STEP_SUMMARY ?? "/dev/null", - `${buildReport({ - repo: env.REPO, - branch: env.BRANCH, - agentId: agent.agentId, - modelId: FIX_MODEL_ID, - result, - usage - })}\n` - ); - - if (collected.summary) { - process.stdout.write(`${collected.summary}\n`); - } - process.exitCode = collected.unresolvedCriticalHigh || result.status !== "finished" ? 1 : 0; - } finally { - agent.close(); - } - } - - export { - FIX_MODEL_ID, - MAX_FINDINGS_BYTES, - PR_TITLE, - agentMetadata, - applyPullRequestDetails, - buildFindingsPayload, - buildPrompt, - buildReport, - collectResult, - compactFindings, - excludeAccepted, - parseVerdict, - subagents - }; - - if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main(); - } - RUNNER_EOF - - name: Run the fix agent through the Cursor SDK id: agent if: steps.scan.outputs.critical_high != '0' - env: - CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} - TOOLCHAINS: ${{ steps.toolchains.outputs.toolchains }} - # The metadata tag has to name the runner pool the job actually used. `RUNNER` is not - # exported by the runner into the step environment, so pass the input explicitly. - INPUT_RUNNER: ubuntu-latest - # The job token, used only to set the body on a pull request the agent just opened. - GITHUB_TOKEN: ${{ github.token }} - PR_EXISTED_BEFORE: ${{ steps.existing_pr.outputs.exists }} - run: | - set -euo pipefail - # Node resolves a bare ESM specifier by walking up from the *importing file's* directory, - # not from `cwd`. The script lives in $RUNNER_TEMP, so installing into $GITHUB_WORKSPACE - # leaves `@cursor/sdk` unresolvable and the step dies before the agent is created. - # Install beside the script instead. - agent_dir="$RUNNER_TEMP/dependency-audit-agent" - mkdir -p "$agent_dir" - cp "$RUNNER_TEMP/run-cursor-agent.mjs" "$agent_dir/run-cursor-agent.mjs" - printf '{"name":"dependency-audit-agent","private":true,"type":"module"}\n' > "$agent_dir/package.json" - cd "$agent_dir" - npm install --no-audit --no-fund --no-save "@cursor/sdk@$CURSOR_SDK_VERSION" - node run-cursor-agent.mjs - + uses: ./.github/actions/dependency-audit-agent + with: + cursor-api-key: ${{ secrets.CURSOR_API_KEY }} + repository: ${{ env.REPO }} + repository-url: ${{ env.REPO_URL }} + base-branch: ${{ env.BASE_BRANCH }} + fix-branch: ${{ env.BRANCH }} + findings-critical-high: ${{ steps.scan.outputs.findings_critical_high }} + findings-medium-low: ${{ steps.scan.outputs.findings_medium_low }} + accepted-advisories: ${{ steps.scan.outputs.accepted_advisories }} + toolchains: ${{ steps.toolchains.outputs.toolchains }} + runner-label: ubuntu-latest + pull-request-existed-before: ${{ steps.existing_pr.outputs.exists }} + github-token: ${{ github.token }} - name: Fail if critical or high vulnerabilities remain if: steps.scan.outputs.critical_high != '0' + env: + UNRESOLVED_CRITICAL_HIGH: ${{ steps.agent.outputs.unresolved-critical-high }} run: | + # The action treats a run that did not finish, or that ended without a readable verdict, + # as unresolved — so an empty output means the step died, and that must fail too. if [ "${UNRESOLVED_CRITICAL_HIGH:-true}" != "false" ]; then echo "::error::Critical or high vulnerabilities remain unresolved. See the open pull request from $BRANCH." exit 1