The missing `yarn audit fix` for Yarn 2+ Berry
Tags: yarn, javascript, security
Published: 2021-12-12 16:12:44

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

Let's just fix it.

## tldr
```
npm_config_yes=true npx yarn-audit-fix@latest
```

## 1. 
First of all, we need a lib to read/write yarnlock v2 files. [@yarnpkg/lockfile](https://www.npmjs.com/package/@yarnpkg/lockfile) seems the best choice, but it works with v1 only. Maybe [nodejs-lockfile-parser](https://github.com/snyk/nodejs-lockfile-parser/)?
Missed the mark again. It [swaps checksums](https://github.com/snyk/nodejs-lockfile-parser/issues/98) 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.

```ts
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"')}`
}
```

## 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](https://github.com/antongolub/yarn-audit-fix/blob/4af5c435122f82dcac874e44d693df65cca88b33/src/test/fixtures/lockfile/v1/yarn-audit-report.json#L1).
`yarn npm audit --all --recursive --json` gives exactly what we need:
```json
{
  "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"
    },
```
We take only significant fields: `vulnerable_versions, module_name, patched_versions`
```ts
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
    },
    {},
  )
```
## 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](https://github.com/hfour/yarn-audit-fix-ng/blob/a2fa7f9c8a110ee8a7eafa64c8c3b5c255b4dec9/src/index.ts#L98). **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`.

```ts
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)
    }
  }
```

## 4.
The final step is to update the checksums.
```
yarn install mode='--update-lockfile'
```

## Refs
* [yarn-audit-fix](https://github.com/antongolub/yarn-audit-fix)
* [nodejs-lockfile-parser](https://github.com/snyk/nodejs-lockfile-parser/)
* [npm-vs-yarn-which-one-to-choose](https://www.section.io/engineering-education/npm-vs-yarn-which-one-to-choose/)
* [yarn-audit-fix-workaround](https://dev.to/antongolub/yarn-audit-fix-workaround-i2a)