DEV Community

Anton Golub
Anton Golub

Posted on

The missing `yarn audit fix` for Yarn 2+ Berry

As everybody knows, audit --fix feature is out of scope of Yarn 1 Classic yarn/7075 and it also has not been implemented yet (2021-12-12) for Yarn 2+ berry/3582.

Let's just fix it.

tldr

npm_config_yes=true npx yarn-audit-fix@latest
Enter fullscreen mode Exit fullscreen mode

1.

First of all, we need a lib to read/write yarnlock v2 files. @yarnpkg/lockfile seems the best choice, but it works with v1 only. Maybe nodejs-lockfile-parser?
Missed the mark again. It swaps checksums and does not provide dump/format API what is expected for the parser :). It turns out that we are missing yet another one lockfile processor. No problem. If we look closely, the new shiny yarn.lock v2 is a regular yaml with a little strange formatting like extra empty line delimiters, extra quotes, and so on.

import yaml from 'js-yaml'

export const parse = (raw: string): TLockfileObject => {
  const data = yaml.load(raw)
  delete data.__metadata

  return Object.entries(data).reduce<Record<string, any>>(
    (m, [key, value]: [string, any]) => {
      key.split(', ').forEach((k) => {
        m[k] = value
      })
      return m
    },
    {},
  )
}

export const format = (lockfile: TLockfileObject): string => {
  const keymap = Object.entries(lockfile).reduce<Record<string, any>>(
    (m, [k, { resolution }]) => {
      const entry = m[resolution] || (m[resolution] = [])
      entry.push(k)

      return m
    },
    {},
  )

  const data = Object.values(lockfile).reduce<Record<string, any>>(
    (m, value) => {
      const key = keymap[value.resolution].join(', ')
      m[key] = value

      return m
    },
    {
      __metadata: {
        version: 5,
        cacheKey: 8,
      },
    },
  )

  return `# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!

${yaml.dump(data, {
  quotingType: '"',
  flowLevel: -1,
  lineWidth: -1,
})
  .replace(/\n([^\s"].+):\n/g, '\n"$1":\n')
  .replace(/\n(\S)/g, '\n\n$1')
  .replace(/resolution: ([^\n"]+)/g, 'resolution: "$1"')}`
}
Enter fullscreen mode Exit fullscreen mode

2.

We need to fetch audit data. Yarn Berry integrated audit API is much better than previous Classic, which returned the report in the form of chunks.
yarn npm audit --all --recursive --json gives exactly what we need:

{
  "actions": [],
  "advisories": {
    "1004946": {
      "findings": [
        {
          "version": "4.1.0",
          "paths": [
            "ts-patch>strip-ansi>ansi-regex",
            "lerna>npmlog>gauge>ansi-regex",
            "lerna>@lerna/bootstrap>npmlog>gauge>ansi-regex",
            ...
          ]
        }
      ],
      "metadata": null,
      "vulnerable_versions": ">2.1.1 <5.0.1",
      "module_name": "ansi-regex",
      "severity": "moderate",
      "github_advisory_id": "GHSA-93q8-gq69-wqmw",
      "cves": [
        "CVE-2021-3807"
      ],
      "access": "public",
      "patched_versions": ">=5.0.1",
      "updated": "2021-09-23T15:45:50.000Z",
      "recommendation": "Upgrade to version 5.0.1 or later",
      "cwe": "CWE-918",
      "found_by": null,
      "deleted": null,
      "id": 1004946,
      "references": "- https://nvd.nist.gov/vuln/detail/CVE-2021-3807\n- https://github.com/chalk/ansi-regex/commit/8d1d7cdb586269882c4bdc1b7325d0c58c8f76f9\n- https://huntr.dev/bounties/5b3cf33b-ede0-4398-9974-800876dfd994\n- https://github.com/chalk/ansi-regex/issues/38#issuecomment-924086311\n- https://app.snyk.io/vuln/SNYK-JS-ANSIREGEX-1583908\n- https://github.com/chalk/ansi-regex/issues/38#issuecomment-925924774\n- https://github.com/advisories/GHSA-93q8-gq69-wqmw",
      "created": "2021-11-18T16:00:48.472Z",
      "reported_by": null,
      "title": " Inefficient Regular Expression Complexity in chalk/ansi-regex",
      "npm_advisory_id": null,
      "overview": "ansi-regex is vulnerable to Inefficient Regular Expression Complexity",
      "url": "https://github.com/advisories/GHSA-93q8-gq69-wqmw"
    },
Enter fullscreen mode Exit fullscreen mode

We take only significant fields: vulnerable_versions, module_name, patched_versions

export const parseAuditReport = (data: string): TAuditReport =>
  Object.values(JSON.parse(data).advisories).reduce<TAuditReport>(
    (m, { vulnerable_versions, module_name, patched_versions }: any) => {
      m[module_name] = {
        patched_versions,
        vulnerable_versions,
        module_name,
      }
      return m
    },
    {},
  )
Enter fullscreen mode Exit fullscreen mode

3.

Almost done. Now we need to replace vulnerable packages versions in lockfile with the advisories, taking into account semver-compatibility and to remove the prev checksum fields. This brilliant idea was suggested by G. Kosev. Important note Seems like dependencies of the patched entries are not reloaded automatically by Yarn, so they need to be requested from the registry by hand: yarn npm info react --fields dependencies --json.

export const patchEntry = (
  entry: TLockfileEntry,
  name: string,
  newVersion: string,
  npmBin: string,
): TLockfileEntry => {
  entry.version = newVersion
  entry.resolution = `${name}@npm:${newVersion}`

  // NOTE seems like deps are not updated by `yarn mode='--update-lockfile'`, only checksums
  entry.dependencies =
    JSON.parse(
      invoke(
        npmBin,
        ['view', `${name}@${newVersion}`, 'dependencies', '--json'],
        process.cwd(),
        true,
        false,
      ) || 'null',
    ) || undefined

  delete entry.checksum

  return entry
}

export const _patch = (
  lockfile: TLockfileObject,
  report: TAuditReport,
  { flags, bins }: TContext,
  lockfileType: TLockfileType,
): TLockfileObject => {
  if (Object.keys(report).length === 0) {
    !flags.silent && console.log('Audit check found no issues')
    return lockfile
  }

  const upgraded: string[] = []

  for (const depSpec of Object.keys(lockfile)) {
    // @babel/code-frame@^7.0.0
    // @babel/code-frame@npm:^7.0.0

    const [, pkgName, desiredRange] =
      /^(@?[^@]+)@(?:\w+:)?(.+)$/.exec(depSpec) || []

    const pkgAudit = report[pkgName]
    if (!pkgAudit) continue
    const pkgSpec = lockfile[depSpec]
    if (sv.satisfies(pkgSpec.version, pkgAudit.vulnerable_versions)) {
      const fix = sv.minVersion(pkgAudit.patched_versions)?.format()
      if (fix === undefined) {
        console.error(
          "Can't find satisfactory version for",
          pkgAudit.module_name,
          pkgAudit.patched_versions,
        )
        continue
      }
      if (!sv.satisfies(fix, desiredRange) && !flags.force) {
        console.error(
          "Can't find patched version that satisfies",
          depSpec,
          'in',
          pkgAudit.patched_versions,
        )
        continue
      }
      upgraded.push(`${pkgName}@${fix}`)

      patchEntry(pkgSpec, pkgName, fix, bins.npm)
    }
  }
Enter fullscreen mode Exit fullscreen mode

4.

The final step is to update the checksums.

yarn install mode='--update-lockfile'
Enter fullscreen mode Exit fullscreen mode

Refs

Discussion (0)