From b65d5847117fb644afab2137aafb485ffc3be974 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Sun, 16 Aug 2026 15:43:09 +0530 Subject: [PATCH] feat: add resolve-snyk skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the resolve-snyk skill for end-to-end Snyk vulnerability resolution on any GitHub npm repo. Covers full audit (npm audit + snyk test + snyk code), safe dep upgrades, transitive overrides, single clean install, build verification, and PR creation — all with explicit approval gates. Co-Authored-By: Claude Sonnet 4.6 --- skills/resolve-snyk/SKILL.md | 497 ++++++++++++++++++ .../resolve-snyk/scripts/apply-upgrades.mjs | 117 +++++ skills/resolve-snyk/scripts/audit-scan.mjs | 225 ++++++++ skills/resolve-snyk/scripts/find-upgrades.mjs | 219 ++++++++ 4 files changed, 1058 insertions(+) create mode 100644 skills/resolve-snyk/SKILL.md create mode 100644 skills/resolve-snyk/scripts/apply-upgrades.mjs create mode 100644 skills/resolve-snyk/scripts/audit-scan.mjs create mode 100644 skills/resolve-snyk/scripts/find-upgrades.mjs diff --git a/skills/resolve-snyk/SKILL.md b/skills/resolve-snyk/SKILL.md new file mode 100644 index 0000000..bae24d1 --- /dev/null +++ b/skills/resolve-snyk/SKILL.md @@ -0,0 +1,497 @@ +--- +name: resolve-snyk +description: > + Resolve Snyk vulnerabilities for any GitHub repo. Clones the repo, runs a full audit + (npm audit + snyk test + snyk code test), shows all findings, determines upgrade versions, + edits package.json (direct deps + overrides for transitive deps), then does a single clean + install → re-audit → build → commit/PR. Explicit user approval required before any file + changes or destructive steps. + Triggers on: /resolve-snyk, "resolve snyk", "fix snyk issues", "fix snyk vulnerabilities", + "snyk fix for", "clean up snyk". +--- + +# /resolve-snyk Skill + +Guides the user through a full Snyk vulnerability resolution cycle for a GitHub repo. +Never edit files, delete files, update dependencies, or run any install command without +explicit user approval first. + +--- + +## Usage + +When this skill activates, greet the user with this help block before doing anything else: + +``` +👋 /resolve-snyk — Snyk vulnerability resolver + +How to use: + /resolve-snyk — start with a specific repo + /resolve-snyk — I'll ask you for the repo URL + +What I'll do: + 1. Preflight — verify Snyk CLI + gh CLI are authenticated + 2. Clone — clone the repo to a temp directory + 3. Audit — run npm audit + snyk test + snyk code (full baseline) + 4. Plan — show safe upgrades (patch/minor) and transitive overrides to apply + 5. Approve — you pick which changes to apply (all / specific / none) + 6. Apply — update package.json only (no install yet) + 7. Install — delete lock file + node_modules, single clean install (your approval required) + 8. Re-audit — before vs after comparison + 9. Build — verify build still passes (hard gate before commit) + 10. PR — commit + raise PR via gh (your approval required) + 11. Cleanup — optionally delete the cloned temp directory + +Options you'll be asked about along the way: + • Which safe upgrades to apply (all / list packages / none) + • Which transitive overrides (all / list packages / none) + • Whether to bump the package version (patch / minor / no) + • Confirm before deleting node_modules + lock file + • Target branch for the PR (defaults to main) + +What I won't touch automatically: + ✗ Major version bumps — listed for awareness, never applied + ✗ npm audit fix --force — surfaced as a decision, never run + ✗ Any file before you approve — every destructive step requires confirmation + +Requirements: + snyk CLI → npm install -g snyk && snyk auth + gh CLI → brew install gh && gh auth login + Node.js → nodejs.org +``` + +Only show this block once at the start. Then proceed to Step 1. + +--- + +## Requirements + +The following tools must be installed and authenticated on the machine before this skill +can run. If any are missing, stop and tell the user exactly what to install or configure. + +| Tool | Purpose | Install | Auth | +|------|---------|---------|------| +| **Snyk CLI** | SCA + code scanning | `npm install -g snyk` | `snyk auth` | +| **GitHub CLI (`gh`)** | Raising PRs | `brew install gh` or [cli.github.com](https://cli.github.com) | `gh auth login` | +| **Node.js + npm** | Installing deps, running audits | [nodejs.org](https://nodejs.org) | — | +| **yarn** *(if repo uses it)* | Installing deps | `npm install -g yarn` | — | +| **pnpm** *(if repo uses it)* | Installing deps | `npm install -g pnpm` | — | +| **git** | Cloning, branching, committing | pre-installed on most systems | SSH or HTTPS access to the repo | + +These are hard requirements — the skill cannot proceed without them. + +> **Package manager support:** npm and pnpm are fully supported. For yarn repos, `npm audit` +> is used as a fallback (yarn audit outputs a different JSONL format); results are accurate +> but the audit command targets the npm registry endpoint directly rather than the yarn +> lockfile. Overrides are written as npm-style `overrides` for yarn repos (yarn v1 honours +> this; yarn berry uses `resolutions` — flag this to the user if the repo uses yarn berry). + +--- + +## Step 1 — Preflight checks and GitHub URL + +Before doing anything else, verify the required tools are present and authenticated: + +```bash +snyk whoami 2>&1 # must succeed — if not, run `snyk auth` first +gh auth status 2>&1 # must succeed — if not, run `gh auth login` first +``` + +If either check fails, stop and tell the user exactly which tool needs authenticating. +Do not proceed until both pass. + +If the user has not already provided a GitHub repo URL, ask for it. +Store it as `REPO_URL`. + +--- + +## Step 2 — Clone, detect package manager, and install + +```bash +REPO_DIR=$(mktemp -d) +git clone "$REPO_DIR" +``` + +**Detect the package manager** from the lock file present in the cloned repo: +- `package-lock.json` → use `npm` +- `yarn.lock` → use `yarn` +- `pnpm-lock.yaml` → use `pnpm` + +Use the detected package manager for every install, audit, and outdated command throughout +the skill. If no lock file exists, default to `npm` and note this to the user. + +Install dependencies so the audit scans have actual resolved packages to analyse: + +```bash +# npm +cd "$REPO_DIR" && npm install + +# yarn +cd "$REPO_DIR" && yarn + +# pnpm +cd "$REPO_DIR" && pnpm install +``` + +> **Never use `--prefix` (npm) or `--cwd` / `--dir` flags for install commands.** These cause +> npm to embed absolute paths from the current working directory into `package-lock.json` instead +> of the standard `node_modules/…` relative keys. The resulting lockfile will fail Snyk CI scans +> and `npm ci` runs on any machine other than the one that generated it. Always `cd` into the +> repo directory first. + +Confirm both the clone and install succeeded. Print the temp path. +If install fails, report the error and stop — do not proceed to the audit. + +--- + +## Step 3 — Initial full audit (before any changes) + +Run `audit-scan.mjs` and save its output as the baseline for later diff comparison: + +```bash +node "$SKILL_DIR/scripts/audit-scan.mjs" "$REPO_DIR" > /tmp/snyk-baseline.json +``` + +Read `/tmp/snyk-baseline.json` and present a combined summary to the user: + +### npm audit findings + +Severity count table (`npmAudit.severityCounts`), then three groups: + +**Minor/patch fix available** (`npmAudit.directPatch` + `npmAudit.directMinor`) — show exact safe version per package: + +| Package | Current | Safe fix version | Severity | Title | +|---------|---------|-----------------|----------|-------| + +**Requires major version bump** (`npmAudit.directMajor`) — list for awareness; never touch automatically: + +| Package | Current | Major fix version | Severity | Title | +|---------|---------|------------------|----------|-------| + +**Transitive only** (`npmAudit.transitiveOnly`) — NOT in `package.json`; addressed via overrides in Step 4b: + +| Package | Current vulnerable range | Introduced via | Severity | +|---------|--------------------------|----------------|----------| + +If a package has no fix, it appears in `npmAudit.noFix` — list it as "no fix available". + +### Snyk SCA findings + +Severity count table (`snykTest.severityCounts`). Vulns from `snykTest.vulns`, licenses from `snykTest.licenseIssues`. + +### Snyk Code findings + +Severity count table (`snykCode.severityCounts`). Each finding from `snykCode.findings`: file:line, severity, title, CWE. + +> Note: Snyk Code findings are source-level static analysis — they will NOT change as a +> result of dependency updates. Do not expect them to move in the Step 9 re-audit. + +If any scan returns no issues, say so clearly. + +--- + +## Step 4a — Determine direct dependency upgrade candidates + +Run `find-upgrades.mjs` passing the repo dir and the baseline audit file: + +```bash +node "$SKILL_DIR/scripts/find-upgrades.mjs" "$REPO_DIR" /tmp/snyk-baseline.json > /tmp/snyk-upgrade-plan.json +``` + +Read `/tmp/snyk-upgrade-plan.json` and present two tables to the user: + +**Safe upgrades (patch + minor)** from `safeUpgrades`: + +| Package | Pin type | Current | Patch upgrade | Minor upgrade | +|---------|----------|---------|---------------|---------------| +| express | `^` range | 4.18.1 | 4.18.3 | 4.19.2 | +| lodash | exact | 4.17.19 | 4.17.21 | — | + +**Requires major bump** from `majorUpgrades` — list for awareness only; never apply automatically: + +| Package | Current | Major upgrade | +|---------|---------|---------------| +| react | 17.0.2 | 18.3.1 | + +Ask the user: + +> "Which of the safe (patch + minor) upgrades above would you like to apply? +> You can say 'all', list specific packages, or 'none'." + +Wait for the answer before continuing. + +--- + +## Step 4b — Handle transitive vulnerable deps via overrides + +No additional command needed — `find-upgrades.mjs` (Step 4a) already wrote both `safeUpgrades` +and `transitiveOverrides` into `/tmp/snyk-upgrade-plan.json`. Read `transitiveOverrides` from +that same file. The script has already: +- Found the lowest non-deprecated safe version above the vulnerable range for each package +- Checked deprecation status via `npm view @ deprecated` +- Confirmed each version exists on npm + +Present the overrides table to the user: + +| Package | Current vulnerable | Override to | Deprecated? | Skip? | Introduced via | +|---------|--------------------|-------------|-------------|-------|----------------| +| undici | <7.28.1 | 7.28.1 | No | No | some-framework | + +Before presenting the table, apply these three manual checks — the script does NOT catch all of them: + +1. **Vulnerable range is `*`** — if `currentVulnerable` is `*`, every version of the package is + flagged. Any override you pick is still inside the range and fixes nothing. Mark it SKIP and + explain there is no safe version to pin. + +2. **Override version falls inside the vulnerable range** — verify `overrideTo` is actually above + (not inside) `currentVulnerable`. If the script resolved a version that is still within the + range (e.g. the lowest available version on npm predates the CVE fix), mark it SKIP. + +3. **Cross-major override conflicting with direct deps** — if the proposed `overrideTo` jumps + to a different major version than what the project's direct deps already resolve to for that + package, mark it SKIP. A cross-major override forces incompatible peer dep versions into the + tree and often introduces *more* vulnerabilities than it removes. Check by comparing the + major of `overrideTo` against the major already locked in `package-lock.json` for that + package. + +For any entry that is skipped (script `skip: true`, or caught by the checks above), flag it +clearly in the table with the reason — do not include it in the approved plan. + +Ask the user: + +> "These transitive deps will be added to the `overrides` field in `package.json`. +> Which would you like to apply? 'all', specific packages, or 'none'." + +Wait for the answer before continuing. + +--- + +## Step 4c — Package version bump + +Ask the user: + +> "Should I bump this package's own `version` field? +> Current: X.Y.Z — patch bump would be X.Y.(Z+1). +> Yes (patch) / yes (minor) / no?" + +Wait for the answer before continuing. + +--- + +## Step 5 — Apply all approved changes to package.json only + +Only after all three questions in Steps 4a, 4b, and 4c are answered, build the approved +plan JSON and run `apply-upgrades.mjs`: + +```bash +# Write the approved plan to a temp file, e.g.: +cat > /tmp/snyk-approved-plan.json << 'EOF' +{ + "directUpgrades": [ + { "package": "express", "targetVersion": "^4.19.2" }, + { "package": "lodash", "targetVersion": "4.17.21" } + ], + "overrides": [ + { "package": "undici", "targetVersion": "7.28.1" } + ], + "versionBump": "patch" +} +EOF + +node "$SKILL_DIR/scripts/apply-upgrades.mjs" "$REPO_DIR" /tmp/snyk-approved-plan.json +``` + +The script handles: +- Exact-pinned packages → writes bare version (no prefix) +- Range-pinned packages → preserves original `^` or `~` prefix +- Overrides block created/merged in `package.json` +- Version field bumped atomically in the same pass + +Read the JSON output from `apply-upgrades.mjs` and show the user a diff-style summary +of every change made (`changes` array: section, package, before, after). + +**Do not run any install yet. Do not delete anything yet.** + +--- + +## Step 6 — Approval gate: delete package-lock.json and node_modules + +Ask the user: + +> "`package.json` is fully updated. To get a clean, in-sync install I need to delete +> `package-lock.json` and `node_modules`, then run a fresh install. +> +> **Proceed? [yes/no]**" + +Do NOT proceed until the user explicitly confirms. + +--- + +## Step 7 — Single authoritative install + +Only after approval: + +```bash +rm "$REPO_DIR/package-lock.json" # (or yarn.lock / pnpm-lock.yaml) +rm -rf "$REPO_DIR/node_modules" +cd "$REPO_DIR" && npm install # (or yarn / pnpm equivalent) +``` + +This is the **only authoritative install** — the one that produces the committed lock file. +`package.json` was fully finalised in Step 5 before anything was deleted, so the resulting +lock file is guaranteed to be in sync with `package.json`. + +**If install fails:** +- Identify the conflicting package from the error output. +- Roll back just that package's version in `package.json` (restore from `git diff`). +- Delete `node_modules` and the lock file again and retry once. +- If it still fails, stop and ask the user how to proceed. +- Do NOT add `--legacy-peer-deps` or `--force` without explicit user approval. + +--- + +## Step 8 — Run npm audit fix + +```bash +cd "$REPO_DIR" && npm audit fix # (or yarn/pnpm equivalent) +``` + +> Note: `npm audit fix` only modifies `package-lock.json`, never `package.json`. Any changes +> it makes are captured when we stage the lock file in Step 11. + +Show how many vulnerabilities were fixed and how many remain. If `--force` is the only +remaining option, tell the user — do NOT run it automatically. + +--- + +## Step 9 — Full re-audit (npm audit + Snyk) + +Run `audit-scan.mjs` again, passing the baseline for automatic diff computation: + +```bash +node "$SKILL_DIR/scripts/audit-scan.mjs" "$REPO_DIR" --baseline /tmp/snyk-baseline.json > /tmp/snyk-reaudit.json +``` + +Read `/tmp/snyk-reaudit.json` and present the **before vs after comparison** from the +`diff` field (computed automatically by the script): + +| Severity | Before | After | Fixed | +|----------|--------|-------|-------| +| High | 15 | 4 | ✓ 11 | +| Moderate | 6 | 3 | ✓ 3 | +| Low | 2 | 2 | — | + +For Snyk Code: show results separately with a note that findings are unchanged by design +(source-level analysis, not affected by dep updates). + +List all still-remaining issues from `npmAudit` and `snykTest` with package and title so +the user knows what needs manual attention. + +--- + +## Step 10 — Build verification (hard gate) + +First check whether a `build` script exists in `package.json`: + +```bash +node -e "const p=require('$REPO_DIR/package.json'); process.exit(p.scripts?.build ? 0 : 1);" 2>/dev/null \ + && echo "BUILD_SCRIPT=yes" || echo "BUILD_SCRIPT=no" +``` + +- If **no `build` script exists**: skip this step entirely. Note it to the user — this is + normal for library packages or repos that build via a separate pipeline. Proceed to Step 11. + +If a build script exists, run it using `cd`: + +```bash +cd "$REPO_DIR" && npm run build 2>&1 # (or yarn build / pnpm build) +``` + +> Do NOT use `--prefix` with `npm run` — it does not work reliably for scripts. + +- If the **build passes**: note any warnings (e.g. CommonJS bailout notices) but proceed. + Warnings from pre-existing issues in third-party deps are not blockers. +- If the **build fails due to a missing env var** from a prebuild script: note this is a + project configuration issue, not caused by the dep changes. Ask the user to confirm + before proceeding anyway. +- If the **build fails with a real error**: stop immediately. Do NOT commit. Show the full + error, identify which dep change likely caused it, and ask the user whether to roll back + that package or investigate further. + +--- + +## Step 11 — Commit and raise PR (approval gate) + +Ask the user: + +> "Build passed. Ready to commit and raise a PR? +> Suggested branch: `fix/snyk-patch-deps-YYYYMMDD` (today's date) +> Target branch: `main` — or specify another. +> **Proceed? [yes/no]**" + +Only after approval: + +1. `git checkout -b fix/snyk-patch-deps-YYYYMMDD` +2. Stage only `package.json` and the lock file — explicitly exclude any generated files + (e.g. `dist/`, generated config files, `.env`) that the prebuild or build step created: + ```bash + git add package.json package-lock.json # (or yarn.lock / pnpm-lock.yaml) + ``` +3. Commit — fill in actual numbers from Steps 4 and 9, not placeholder text: + ``` + fix: bump N dependencies and add M overrides to resolve Snyk vulnerabilities + + Direct dep upgrades: [list packages + versions] + Overrides added: [list transitive packages + versions] + Package version: X.Y.Z → X.Y.(Z+1) + Vuln count: N (before) → M (after). Build verified passing. + ``` +4. Push and raise PR via `gh pr create` with a body containing: + - Table of direct dep changes (before → after) + - Table of overrides added + - Before/after vuln count table (from Step 9) + - Remaining issues and why they need `--force` or manual intervention + - Build status: passed + +--- + +## Step 12 — Cleanup + +Ask: + +> "Done! The cloned repo is at ``. Want me to delete it? [yes/no]" + +If yes: `rm -rf "$REPO_DIR"` + +--- + +## Important rules + +- **Preflight first** — Snyk auth and `gh` auth must both pass before Step 2. Fail fast. +- **Steps 1–4 are fully read-only** — no file edits, no deletions, no installs beyond the + initial scan install in Step 2. +- **Only one authoritative install** — `package.json` must be fully finalised (Steps 4a/4b/4c + 5) + before deleting the lock file and `node_modules`. One wipe, one install, guaranteed sync. +- **Preserve range prefixes** — for `^`/`~` pinned packages, keep the prefix when writing + the upgraded version back. Never silently change a flexible range to an exact pin. +- **Never touch major bumps automatically** — list them for awareness, never apply them. +- **Always check overrides for deprecation before adding them** — a deprecated override + version is worse than the vulnerability it was meant to fix. +- **Never run `npm audit fix --force`** — surface it as a user decision, never execute it. +- **Build is a hard gate** — do not commit if the build fails with a real error. +- **Always run both npm audit and Snyk** — mandatory at baseline (Step 3) and after changes + (Step 9). Never skip one. +- **Snyk Code findings do not change from dep updates** — present them separately in Step 9, + never imply they were fixed by dependency changes. +- **Stage only `package.json` and the lock file** — never stage generated or environment files. +- **On install failure** — roll back the specific conflicting dep and retry once before stopping. +- **Never use `--prefix` for installs** — `npm install --prefix ` embeds absolute paths in + `package-lock.json`, producing a lockfile that breaks Snyk CI and `npm ci` on any other machine. + Always `cd "$REPO_DIR"` first, then run the bare install command. +- **pnpm overrides go in `pnpm.overrides`** — `apply-upgrades.mjs` handles this automatically; + never manually write transitive overrides to the top-level `overrides` field for pnpm repos. +- **No build script = skip Step 10** — library packages and pipeline-built repos often have no + `build` script. Absence is not an error; skip gracefully and note it in the PR body. +- **Yarn berry uses `resolutions`, not `overrides`** — if the repo uses yarn berry (has + `.yarnrc.yml` or `packageManager: yarn@>=2`), flag this to the user; the overrides written + by the skill will not be honoured by yarn berry and the user must rename the field manually. diff --git a/skills/resolve-snyk/scripts/apply-upgrades.mjs b/skills/resolve-snyk/scripts/apply-upgrades.mjs new file mode 100644 index 0000000..80c9798 --- /dev/null +++ b/skills/resolve-snyk/scripts/apply-upgrades.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +/** + * apply-upgrades.mjs + * + * Applies an approved upgrade plan to package.json in one atomic pass: + * - Direct dep version bumps (preserving ^ / ~ prefix for range pins) + * - Transitive dep overrides block + * - Package version bump + * + * Usage: + * node apply-upgrades.mjs + * + * approved-plan.json shape: + * { + * "directUpgrades": [ + * { "package": "@angular/common", "targetVersion": "21.2.19" }, + * { "package": "express", "targetVersion": "^4.19.2" } + * ], + * "overrides": [ + * { "package": "undici", "targetVersion": "7.28.1" } + * ], + * "versionBump": "patch" | "minor" | null + * } + * + * Output: JSON diff summary of changes made to stdout. + */ + +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; + +function detectPkgManager(repoDir) { + if (existsSync(resolve(repoDir, 'pnpm-lock.yaml'))) return 'pnpm'; + if (existsSync(resolve(repoDir, 'yarn.lock'))) return 'yarn'; + return 'npm'; +} + +const [, , repoDirArg, planFile] = process.argv; +if (!repoDirArg || !planFile) { + console.error('Usage: node apply-upgrades.mjs '); + process.exit(1); +} + +const repoDir = resolve(repoDirArg); +const pkgPath = resolve(repoDir, 'package.json'); +const plan = JSON.parse(readFileSync(resolve(planFile), 'utf8')); +const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + +const changes = []; + +// ── direct dep upgrades ─────────────────────────────────────────────────────── + +for (const { package: name, targetVersion } of (plan.directUpgrades || [])) { + const sections = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; + let applied = false; + + for (const section of sections) { + if (pkg[section]?.[name] !== undefined) { + const before = pkg[section][name]; + pkg[section][name] = targetVersion; + changes.push({ section, package: name, before, after: targetVersion }); + applied = true; + break; + } + } + + if (!applied) { + console.error(`Warning: package "${name}" not found in any dep section — skipping`); + } +} + +// ── transitive overrides ────────────────────────────────────────────────────── + +if (plan.overrides?.length) { + const pkgManager = detectPkgManager(repoDir); + + if (pkgManager === 'pnpm') { + // pnpm uses pnpm.overrides, not overrides + pkg.pnpm = pkg.pnpm || {}; + pkg.pnpm.overrides = pkg.pnpm.overrides || {}; + for (const { package: name, targetVersion } of plan.overrides) { + const before = pkg.pnpm.overrides[name] || null; + pkg.pnpm.overrides[name] = targetVersion; + changes.push({ section: 'pnpm.overrides', package: name, before, after: targetVersion }); + } + } else { + // npm and yarn both use the overrides field + pkg.overrides = pkg.overrides || {}; + for (const { package: name, targetVersion } of plan.overrides) { + const before = pkg.overrides[name] || null; + pkg.overrides[name] = targetVersion; + changes.push({ section: 'overrides', package: name, before, after: targetVersion }); + } + } +} + +// ── package version bump ────────────────────────────────────────────────────── + +if (plan.versionBump) { + const parts = (pkg.version || '0.0.0').split('.').map(Number); + const before = pkg.version; + + if (plan.versionBump === 'patch') { + parts[2]++; + } else if (plan.versionBump === 'minor') { + parts[1]++; + parts[2] = 0; + } + + pkg.version = parts.join('.'); + changes.push({ section: 'version', package: 'self', before, after: pkg.version }); +} + +// ── write ───────────────────────────────────────────────────────────────────── + +writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8'); + +process.stdout.write(JSON.stringify({ appliedAt: new Date().toISOString(), changes }, null, 2)); diff --git a/skills/resolve-snyk/scripts/audit-scan.mjs b/skills/resolve-snyk/scripts/audit-scan.mjs new file mode 100644 index 0000000..b6de338 --- /dev/null +++ b/skills/resolve-snyk/scripts/audit-scan.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +/** + * audit-scan.mjs + * + * Runs npm audit + snyk test + snyk code test in JSON mode, parses all three, + * and outputs a single structured JSON summary ready for the LLM to present. + * + * Usage: + * node audit-scan.mjs + * node audit-scan.mjs --baseline + * + * When --baseline is provided, also computes a before/after diff for re-audit. + * + * Output: JSON to stdout. Errors to stderr. + */ + +import { execSync } from 'child_process'; +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; + +function detectPkgManager(repoDir) { + if (existsSync(resolve(repoDir, 'pnpm-lock.yaml'))) return 'pnpm'; + if (existsSync(resolve(repoDir, 'yarn.lock'))) return 'yarn'; + return 'npm'; +} + +const args = process.argv.slice(2); +if (!args[0]) { + console.error('Usage: node audit-scan.mjs [--baseline ]'); + process.exit(1); +} + +const repoDir = resolve(args[0]); +const baselineIndex = args.indexOf('--baseline'); +const baselineFile = baselineIndex !== -1 ? args[baselineIndex + 1] : null; + +function run(cmd, cwd) { + try { + return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (e) { + // npm audit / snyk exit non-zero when findings exist — that's expected + return e.stdout || ''; + } +} + +function semverCompare(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if (pa[i] > pb[i]) return 1; + if (pa[i] < pb[i]) return -1; + } + return 0; +} + +function classifyFix(currentVer, fixVer) { + if (!fixVer) return 'none'; + const [cMaj, cMin] = currentVer.replace(/[^0-9.]/g, '').split('.').map(Number); + const [fMaj, fMin] = fixVer.replace(/[^0-9.]/g, '').split('.').map(Number); + if (fMaj > cMaj) return 'major'; + if (fMin > cMin) return 'minor'; + return 'patch'; +} + +// ── npm/pnpm audit ─────────────────────────────────────────────────────────── + +function parseNpmAudit(repoDir) { + const pkgManager = detectPkgManager(repoDir); + // pnpm audit --json uses the same JSON schema as npm audit --json + // yarn audit --json uses a different JSONL format; fall back to npm audit for yarn repos + const auditCmd = pkgManager === 'pnpm' ? 'pnpm audit --json' : 'npm audit --json'; + const raw = run(auditCmd, repoDir); + let data; + try { data = JSON.parse(raw); } catch { return { error: 'Failed to parse npm audit JSON', raw }; } + + const directDeps = new Set( + Object.keys({ + ...(JSON.parse(readFileSync(resolve(repoDir, 'package.json'), 'utf8')).dependencies || {}), + ...(JSON.parse(readFileSync(resolve(repoDir, 'package.json'), 'utf8')).devDependencies || {}), + ...(JSON.parse(readFileSync(resolve(repoDir, 'package.json'), 'utf8')).optionalDependencies || {}), + }) + ); + + const severityCounts = { critical: 0, high: 0, moderate: 0, low: 0, info: 0 }; + const directPatch = [], directMinor = [], directMajor = [], transitiveOnly = [], noFix = []; + + const vulns = data.vulnerabilities || {}; + for (const [name, vuln] of Object.entries(vulns)) { + const severity = vuln.severity || 'unknown'; + if (severityCounts[severity] !== undefined) severityCounts[severity]++; + + const isDirect = directDeps.has(name); + const fixAvailable = vuln.fixAvailable; + let fixVersion = null; + let fixType = 'none'; + let requiresForce = false; + + if (fixAvailable === true) { + fixType = 'patch'; // npm says it can fix without breaking changes + } else if (fixAvailable && typeof fixAvailable === 'object') { + fixVersion = fixAvailable.version || null; + requiresForce = fixAvailable.isSemVerMajor || false; + fixType = requiresForce ? 'major' : classifyFix(vuln.range || '', fixVersion || ''); + } + + const entry = { + package: name, + severity, + title: (vuln.via || []).map(v => typeof v === 'string' ? v : v.title).filter(Boolean).join('; '), + currentRange: vuln.range || '', + fixVersion, + requiresForce, + introducedVia: isDirect ? null : Object.keys(vulns).filter(k => + (vulns[k].nodes || []).some(n => n.includes(`node_modules/${name}`)) && k !== name + ).slice(0, 2), + }; + + if (!fixAvailable) { + noFix.push(entry); + } else if (!isDirect) { + transitiveOnly.push({ ...entry, fixType }); + } else if (fixType === 'patch' || fixType === 'minor') { + (fixType === 'patch' ? directPatch : directMinor).push(entry); + } else { + directMajor.push(entry); + } + } + + return { severityCounts, directPatch, directMinor, directMajor, transitiveOnly, noFix }; +} + +// ── snyk test ──────────────────────────────────────────────────────────────── + +function parseSnykTest(repoDir) { + const raw = run('snyk test --json', repoDir); + let data; + try { data = JSON.parse(raw); } catch { return { error: 'Failed to parse snyk test JSON', raw }; } + + const severityCounts = { critical: 0, high: 0, medium: 0, low: 0 }; + const vulns = []; + const licenseIssues = []; + + for (const vuln of (data.vulnerabilities || [])) { + const sev = vuln.severity || 'low'; + if (severityCounts[sev] !== undefined) severityCounts[sev]++; + + const entry = { + package: vuln.packageName, + version: vuln.version, + severity: sev, + title: vuln.title, + id: vuln.id, + fixedIn: (vuln.fixedIn || []).join(', ') || null, + isLicense: vuln.type === 'license', + }; + + if (vuln.type === 'license') licenseIssues.push(entry); + else vulns.push(entry); + } + + return { severityCounts, vulns, licenseIssues }; +} + +// ── snyk code test ─────────────────────────────────────────────────────────── + +function parseSnykCode(repoDir) { + const raw = run('snyk code test --json', repoDir); + let data; + try { data = JSON.parse(raw); } catch { return { error: 'Failed to parse snyk code JSON', raw }; } + + const severityCounts = { high: 0, medium: 0, low: 0 }; + const findings = []; + + for (const run_ of (data.runs || [])) { + for (const result of (run_.results || [])) { + const sev = (result.level || 'note') === 'error' ? 'high' + : (result.level === 'warning') ? 'medium' : 'low'; + if (severityCounts[sev] !== undefined) severityCounts[sev]++; + + const loc = (result.locations || [])[0]; + const region = loc?.physicalLocation?.region || {}; + findings.push({ + file: loc?.physicalLocation?.artifactLocation?.uri || 'unknown', + line: region.startLine || null, + severity: sev, + title: result.message?.text || '', + cwe: (result.taxa || []).map(t => t.id).join(', ') || null, + }); + } + } + + return { severityCounts, findings }; +} + +// ── diff (for re-audit) ────────────────────────────────────────────────────── + +function computeDiff(baseline, current) { + const diff = {}; + for (const scanner of ['npmAudit', 'snykTest']) { + const bCounts = baseline[scanner]?.severityCounts || {}; + const cCounts = current[scanner]?.severityCounts || {}; + diff[scanner] = {}; + for (const sev of Object.keys(bCounts)) { + diff[scanner][sev] = { before: bCounts[sev] || 0, after: cCounts[sev] || 0, fixed: (bCounts[sev] || 0) - (cCounts[sev] || 0) }; + } + } + return diff; +} + +// ── main ───────────────────────────────────────────────────────────────────── + +const result = { + repoDir, + scannedAt: new Date().toISOString(), + npmAudit: parseNpmAudit(repoDir), + snykTest: parseSnykTest(repoDir), + snykCode: parseSnykCode(repoDir), +}; + +if (baselineFile && existsSync(baselineFile)) { + const baseline = JSON.parse(readFileSync(baselineFile, 'utf8')); + result.diff = computeDiff(baseline, result); +} + +process.stdout.write(JSON.stringify(result, null, 2)); diff --git a/skills/resolve-snyk/scripts/find-upgrades.mjs b/skills/resolve-snyk/scripts/find-upgrades.mjs new file mode 100644 index 0000000..c42f928 --- /dev/null +++ b/skills/resolve-snyk/scripts/find-upgrades.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +/** + * find-upgrades.mjs + * + * Reads package.json, detects exact pins vs range pins, finds the latest + * patch and minor upgrade for each direct dep, and checks transitive-only + * vulnerable deps for safe override versions (including deprecation checks). + * + * Usage: + * node find-upgrades.mjs + * + * Output: JSON upgrade plan to stdout. + */ + +import { execSync } from 'child_process'; +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; + +function detectPkgManager(repoDir) { + if (existsSync(resolve(repoDir, 'pnpm-lock.yaml'))) return 'pnpm'; + if (existsSync(resolve(repoDir, 'yarn.lock'))) return 'yarn'; + return 'npm'; +} + +const [, , repoDirArg, auditFile] = process.argv; +if (!repoDirArg || !auditFile) { + console.error('Usage: node find-upgrades.mjs '); + process.exit(1); +} + +const repoDir = resolve(repoDirArg); +const pkg = JSON.parse(readFileSync(resolve(repoDir, 'package.json'), 'utf8')); +const audit = JSON.parse(readFileSync(resolve(auditFile), 'utf8')); + +function run(cmd) { + try { + return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (e) { + return e.stdout || ''; + } +} + +function semverParts(v) { + return v.replace(/[^0-9.]/g, '').split('.').map(Number); +} + +function semverCompare(a, b) { + const pa = semverParts(a), pb = semverParts(b); + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) > (pb[i] || 0)) return 1; + if ((pa[i] || 0) < (pb[i] || 0)) return -1; + } + return 0; +} + +function getVersions(pkg) { + const raw = run(`npm view ${pkg} versions --json`); + try { return JSON.parse(raw); } catch { return []; } +} + +function isDeprecated(pkg, version) { + const raw = run(`npm view ${pkg}@${version} deprecated 2>/dev/null`); + return raw.trim().length > 0; +} + +function versionExists(pkg, version) { + const raw = run(`npm view ${pkg}@${version} version 2>/dev/null`); + return raw.trim() === version; +} + +function findLatestPatch(versions, currentVer) { + const bare = currentVer.replace(/[^^~>=<]/g, match => /[0-9.]/.test(match) ? match : '').trim(); + const [maj, min] = semverParts(bare); + const patches = versions + .filter(v => { const [vMaj, vMin] = semverParts(v); return vMaj === maj && vMin === min && semverCompare(v, bare) > 0; }) + .sort(semverCompare); + return patches[patches.length - 1] || null; +} + +function findLatestMinor(versions, currentVer) { + const bare = currentVer.replace(/[^^~>=<]/g, match => /[0-9.]/.test(match) ? match : '').trim(); + const [maj, min] = semverParts(bare); + const minors = versions + .filter(v => { const [vMaj, vMin] = semverParts(v); return vMaj === maj && vMin > min; }) + .sort(semverCompare); + return minors[minors.length - 1] || null; +} + +function findLatestMajor(versions, currentVer) { + const bare = currentVer.replace(/[^^~>=<]/g, match => /[0-9.]/.test(match) ? match : '').trim(); + const [maj] = semverParts(bare); + const majors = versions + .filter(v => semverParts(v)[0] > maj) + .sort(semverCompare); + return majors[majors.length - 1] || null; +} + +function isExactPin(version) { + return /^[0-9]/.test(version); +} + +// ── direct dep upgrade candidates ──────────────────────────────────────────── + +const allDeps = { + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.optionalDependencies, +}; + +// npm outdated for range-pinned packages (npm only — pnpm/yarn use different formats; +// version discovery via npm view below covers those repos adequately) +const pkgManager = detectPkgManager(repoDir); +let outdated = {}; +if (pkgManager === 'npm') { + const outdatedRaw = run(`npm outdated --prefix ${repoDir} --json 2>/dev/null`); + try { outdated = JSON.parse(outdatedRaw); } catch { outdated = {}; } +} + +const safeUpgrades = []; // patch + minor, user to approve +const majorUpgrades = []; // major bumps, awareness only + +for (const [name, currentSpec] of Object.entries(allDeps)) { + const versions = getVersions(name); + if (!versions.length) continue; + + const latestPatch = findLatestPatch(versions, currentSpec); + const latestMinor = findLatestMinor(versions, currentSpec); + const latestMajor = findLatestMajor(versions, currentSpec); + const pinType = isExactPin(currentSpec) ? 'exact' : 'range'; + const prefix = isExactPin(currentSpec) ? '' : currentSpec.match(/^[^^~>=<]*/)?.[0] || '^'; + + // For range-pinned, also pick up what npm outdated already found + const outdatedInfo = outdated[name]; + const effectivePatch = latestPatch || (outdatedInfo?.wanted !== outdatedInfo?.current ? outdatedInfo?.wanted : null) || null; + const effectiveMinor = latestMinor || null; + + if (effectivePatch || effectiveMinor) { + safeUpgrades.push({ + package: name, + pinType, + prefix, + current: currentSpec, + patchUpgrade: effectivePatch ? `${prefix}${effectivePatch}` : null, + minorUpgrade: effectiveMinor ? `${prefix}${effectiveMinor}` : null, + }); + } + + if (latestMajor) { + majorUpgrades.push({ + package: name, + pinType, + current: currentSpec, + majorUpgrade: `${prefix}${latestMajor}`, + }); + } +} + +// ── transitive dep overrides ────────────────────────────────────────────────── + +const transitiveOverrides = []; +const transitiveVulns = audit.npmAudit?.transitiveOnly || []; + +for (const vuln of transitiveVulns) { + const name = vuln.package; + const versions = getVersions(name); + if (!versions.length) continue; + + // Find lowest non-vulnerable version above the vulnerable range + // Parse the vulnerable range upper bound from the advisory + const bare = (vuln.currentRange || '').replace(/[<>=^~]/g, '').trim().split(' ').pop() || ''; + const [refMaj, refMin, refPatch] = semverParts(bare || '0.0.0'); + + // Get candidates: versions above the vulnerable range + const candidates = versions + .filter(v => semverCompare(v, bare || '0.0.0') > 0) + .sort(semverCompare); + + let safeVersion = null; + let deprecated = false; + + for (const candidate of candidates) { + const dep = isDeprecated(name, candidate); + if (!dep) { + safeVersion = candidate; + break; + } + } + + if (!safeVersion) { + // All candidates deprecated — pick latest and flag + safeVersion = candidates[candidates.length - 1] || null; + deprecated = true; + } + + const exists = safeVersion ? versionExists(name, safeVersion) : false; + + transitiveOverrides.push({ + package: name, + currentVulnerable: vuln.currentRange, + overrideTo: safeVersion, + deprecated, + exists, + introducedVia: vuln.introducedVia || [], + severity: vuln.severity, + skip: deprecated || !exists, // flag for user — don't auto-apply flagged ones + }); +} + +// ── output ──────────────────────────────────────────────────────────────────── + +const plan = { + generatedAt: new Date().toISOString(), + packageVersion: pkg.version, + safeUpgrades, + majorUpgrades, + transitiveOverrides, +}; + +process.stdout.write(JSON.stringify(plan, null, 2));