Reporting Yarn Audit Results to Team City
Using Powershell and Yarn to Fail Vulnerable Builds
Parsing Yarn Audit
yarn npm audit -R -A
is a useful command to check your dependencies for vulnerabilities before using them in production and it displays a neat human readable list of problems and advice on how to correct them.
├─ browserslist: 4.14.2
│ ├─ Issue: Regular Expression Denial of Service
│ ├─ URL: https://npmjs.com/advisories/1747
│ ├─ Severity: moderate
│ ├─ Vulnerable Versions: >=4.0.0 <4.16.5
│ ├─ Patched Versions: >=4.16.5
│ ├─ Via: gatsby, webpack
│ └─ Recommendation: Upgrade to version 4.16.5 or later
│
└─ trim: 0.0.1
├─ Issue: Regular Expression Denial of Service in trim
├─ URL: https://npmjs.com/advisories/1700
├─ Severity: high
├─ Vulnerable Versions: <0.0.3
├─ Patched Versions: >=0.0.3
├─ Via: gatsby
└─ Recommendation: Upgrade to version 0.0.3 or later
Yarn even offers a machine parsable format to for any local tools to consume or to use for reporting via --json
. It's got more detail and looks very much like yarn spilled a bucket of JSON on you desk.
{
"actions": [],
"advisories": {
"1700": {
"findings": [
{
"version": "0.0.1",
"paths": ["gatsby>gatsby-cli>gatsby-recipes>remark-parse>trim"]
}
],
"id": 1700,
"created": "2021-05-10T18:48:44.988Z",
"updated": "2021-05-10T18:53:15.621Z",
"deleted": null,
"title": "Regular Expression Denial of Service in trim",
"found_by": { "link": "", "name": "Anonymous", "email": "" },
"reported_by": { "link": "", "name": "Anonymous", "email": "" },
"module_name": "trim",
"cves": ["CVE-2020-7753"],
"vulnerable_versions": "<0.0.3",
"patched_versions": ">=0.0.3",
"overview": "Versions of `trim` lower than 0.0.3 are vulnerable to Regular Expression Denial of Service (ReDoS) via trim().",
"recommendation": "Upgrade to version 0.0.3 or later",
"references": "- [CVE](https://nvd.nist.gov/vuln/detail/CVE-2020-7753)\n- [GitHub Advisory](https://github.com/advisories/GHSA-w5p7-h5w8-2hfq)\n",
"access": "public",
"severity": "high",
"cwe": "CWE-400",
"metadata": {
"module_type": "",
"exploitability": 7,
"affected_components": ""
},
"url": "https://npmjs.com/advisories/1700"
},
"1747": {
"findings": [
{
"version": "4.14.2",
"paths": [
"gatsby>browserslist",
"gatsby>gatsby-cli>gatsby-recipes>remark-mdxjs>@babel/plugin-proposal-object-rest-spread>@babel/helper-compilation-targets>browserslist",
"gatsby>babel-preset-gatsby>@babel/preset-env>@babel/plugin-proposal-object-rest-spread>@babel/helper-compilation-targets>browserslist",
"gatsby>babel-preset-gatsby>@babel/plugin-transform-runtime>babel-plugin-polyfill-corejs2>@babel/helper-define-polyfill-provider>@babel/helper-compilation-targets>browserslist",
"gatsby>babel-preset-gatsby>@babel/preset-env>babel-plugin-polyfill-corejs2>@babel/helper-define-polyfill-provider>@babel/helper-compilation-targets>browserslist",
"gatsby>babel-preset-gatsby>@babel/plugin-transform-runtime>babel-plugin-polyfill-corejs3>@babel/helper-define-polyfill-provider>@babel/helper-compilation-targets>browserslist",
"gatsby>babel-preset-gatsby>@babel/preset-env>babel-plugin-polyfill-corejs3>@babel/helper-define-polyfill-provider>@babel/helper-compilation-targets>browserslist",
"gatsby>babel-preset-gatsby>@babel/plugin-transform-runtime>babel-plugin-polyfill-regenerator>@babel/helper-define-polyfill-provider>@babel/helper-compilation-targets>browserslist",
"gatsby>babel-preset-gatsby>@babel/preset-env>babel-plugin-polyfill-regenerator>@babel/helper-define-polyfill-provider>@babel/helper-compilation-targets>browserslist",
"gatsby>babel-preset-gatsby>@babel/preset-env>@babel/helper-compilation-targets>browserslist",
"gatsby>gatsby-legacy-polyfills>core-js-compat>browserslist",
"gatsby>babel-preset-gatsby>gatsby-legacy-polyfills>core-js-compat>browserslist",
"gatsby>babel-preset-gatsby>@babel/plugin-transform-runtime>babel-plugin-polyfill-corejs3>core-js-compat>browserslist",
"gatsby>babel-preset-gatsby>@babel/preset-env>babel-plugin-polyfill-corejs3>core-js-compat>browserslist",
"gatsby>babel-preset-gatsby>@babel/preset-env>core-js-compat>browserslist",
"gatsby>react-dev-utils>browserslist",
"gatsby>@pmmmwh/react-refresh-webpack-plugin>webpack>browserslist",
"gatsby>terser-webpack-plugin>webpack>browserslist",
"gatsby>@pmmmwh/react-refresh-webpack-plugin>webpack-dev-server>webpack>browserslist",
"gatsby>webpack-dev-server>webpack>browserslist",
"gatsby>@pmmmwh/react-refresh-webpack-plugin>webpack-dev-server>webpack-dev-middleware>webpack>browserslist",
"gatsby>webpack-dev-server>webpack-dev-middleware>webpack>browserslist",
"gatsby>webpack-dev-middleware>webpack>browserslist",
"gatsby>babel-loader>webpack>browserslist",
"gatsby>css-loader>webpack>browserslist",
"gatsby>css-minimizer-webpack-plugin>webpack>browserslist",
"gatsby>eslint-webpack-plugin>webpack>browserslist",
"gatsby>file-loader>webpack>browserslist",
"gatsby>url-loader>file-loader>webpack>browserslist",
"gatsby>mini-css-extract-plugin>webpack>browserslist",
"gatsby>null-loader>webpack>browserslist",
"gatsby>postcss-loader>webpack>browserslist",
"gatsby>raw-loader>webpack>browserslist",
"gatsby>style-loader>webpack>browserslist",
"gatsby>url-loader>webpack>browserslist",
"gatsby>webpack>browserslist",
"webpack>browserslist",
"gatsby>autoprefixer>browserslist",
"gatsby>css-minimizer-webpack-plugin>cssnano>cssnano-preset-default>postcss-colormin>browserslist",
"gatsby>css-minimizer-webpack-plugin>cssnano>cssnano-preset-default>postcss-colormin>caniuse-api>browserslist",
"gatsby>css-minimizer-webpack-plugin>cssnano>cssnano-preset-default>postcss-merge-rules>caniuse-api>browserslist",
"gatsby>css-minimizer-webpack-plugin>cssnano>cssnano-preset-default>postcss-reduce-initial>caniuse-api>browserslist",
"gatsby>css-minimizer-webpack-plugin>cssnano>cssnano-preset-default>postcss-merge-longhand>stylehacks>browserslist",
"gatsby>css-minimizer-webpack-plugin>cssnano>cssnano-preset-default>postcss-merge-rules>browserslist",
"gatsby>css-minimizer-webpack-plugin>cssnano>cssnano-preset-default>postcss-minify-params>browserslist",
"gatsby>css-minimizer-webpack-plugin>cssnano>cssnano-preset-default>postcss-normalize-unicode>browserslist",
"gatsby>css-minimizer-webpack-plugin>cssnano>cssnano-preset-default>postcss-reduce-initial>browserslist"
]
}
],
"id": 1747,
"created": "2021-05-24T19:56:39.062Z",
"updated": "2021-05-24T19:59:05.419Z",
"deleted": null,
"title": "Regular Expression Denial of Service",
"found_by": { "link": "", "name": "Anonymous", "email": "" },
"reported_by": { "link": "", "name": "Anonymous", "email": "" },
"module_name": "browserslist",
"cves": ["CVE-2021-23364"],
"vulnerable_versions": ">=4.0.0 <4.16.5",
"patched_versions": ">=4.16.5",
"overview": "The package `browserslist` from 4.0.0 and before 4.16.5 are vulnerable to Regular Expression Denial of Service (ReDoS) during parsing of queries.",
"recommendation": "Upgrade to version 4.16.5 or later",
"references": "- [CVE](https://nvd.nist.gov/vuln/detail/CVE-2021-23364)\n- [GitHub Advisory](https://github.com/advisories/GHSA-w8qv-6jwh-64r5)\n",
"access": "public",
"severity": "moderate",
"cwe": "CWE-400",
"metadata": {
"module_type": "",
"exploitability": 5,
"affected_components": ""
},
"url": "https://npmjs.com/advisories/1747"
},
"1751": {
"findings": [
{
"version": "3.1.0",
"paths": [
"babel-eslint>eslint>glob-parent",
"gatsby>@typescript-eslint/eslint-plugin>@typescript-eslint/experimental-utils>eslint-utils>eslint>glob-parent",
"gatsby>eslint-config-react-app>@typescript-eslint/eslint-plugin>@typescript-eslint/experimental-utils>eslint-utils>eslint>glob-parent",
"eslint>glob-parent",
"gatsby>eslint>glob-parent",
"gatsby>@babel/eslint-parser>eslint>glob-parent",
"gatsby>@typescript-eslint/eslint-plugin>@typescript-eslint/parser>eslint>glob-parent",
"gatsby>eslint-config-react-app>@typescript-eslint/eslint-plugin>@typescript-eslint/parser>eslint>glob-parent",
"gatsby>@typescript-eslint/parser>eslint>glob-parent",
"gatsby>eslint-config-react-app>@typescript-eslint/parser>eslint>glob-parent",
"gatsby>@typescript-eslint/eslint-plugin>eslint>glob-parent",
"gatsby>eslint-config-react-app>@typescript-eslint/eslint-plugin>eslint>glob-parent",
"gatsby>@typescript-eslint/eslint-plugin>@typescript-eslint/experimental-utils>eslint>glob-parent",
"gatsby>eslint-config-react-app>@typescript-eslint/eslint-plugin>@typescript-eslint/experimental-utils>eslint>glob-parent",
"gatsby>eslint-config-react-app>eslint>glob-parent",
"gatsby>eslint-config-react-app>eslint-plugin-flowtype>eslint>glob-parent",
"gatsby>eslint-plugin-flowtype>eslint>glob-parent",
"gatsby>eslint-config-react-app>eslint-plugin-import>eslint>glob-parent",
"gatsby>eslint-plugin-import>eslint>glob-parent",
"gatsby>eslint-config-react-app>eslint-plugin-jsx-a11y>eslint>glob-parent",
"gatsby>eslint-plugin-jsx-a11y>eslint>glob-parent",
"gatsby>eslint-config-react-app>eslint-plugin-react>eslint>glob-parent",
"gatsby>eslint-plugin-react>eslint>glob-parent",
"gatsby>eslint-config-react-app>eslint-plugin-react-hooks>eslint>glob-parent",
"gatsby>eslint-plugin-react-hooks>eslint>glob-parent",
"gatsby>eslint-webpack-plugin>eslint>glob-parent",
"gatsby>chokidar>glob-parent",
"gatsby>gatsby-cli>gatsby-recipes>chokidar>glob-parent",
"gatsby>@pmmmwh/react-refresh-webpack-plugin>webpack-dev-server>chokidar>glob-parent",
"gatsby>webpack-dev-server>chokidar>glob-parent",
"gatsby>gatsby-plugin-page-creator>chokidar>glob-parent",
"gatsby>gatsby-plugin-page-creator>gatsby-page-utils>chokidar>glob-parent"
]
}
],
"id": 1751,
"created": "2021-06-07T21:57:10.135Z",
"updated": "2021-06-07T21:58:07.745Z",
"deleted": null,
"title": "Regular expression denial of service",
"found_by": { "link": "", "name": "Anonymous", "email": "" },
"reported_by": { "link": "", "name": "Anonymous", "email": "" },
"module_name": "glob-parent",
"cves": ["CVE-2020-28469"],
"vulnerable_versions": "<5.1.2",
"patched_versions": ">=5.1.2",
"overview": "`glob-parent` before 5.1.2 has a regular expression denial of service vulnerability. The enclosure regex used to check for strings ending in enclosure containing path separator.",
"recommendation": "Upgrade to version 5.1.2 or later",
"references": "- [CVE](https://nvd.nist.gov/vuln/detail/CVE-2020-28469)\n- [GitHub Advisory](https://github.com/advisories/GHSA-ww39-953v-wcq6)\n",
"access": "public",
"severity": "moderate",
"cwe": "CWE-400",
"metadata": {
"module_type": "",
"exploitability": 5,
"affected_components": ""
},
"url": "https://npmjs.com/advisories/1751"
},
"1770": {
"findings": [
{
"version": "6.1.0",
"paths": [
"gatsby>chokidar>fsevents>nan>node-gyp>make-fetch-happen>cacache>tar",
"gatsby>gatsby-cli>gatsby-recipes>chokidar>fsevents>nan>node-gyp>make-fetch-happen>cacache>tar",
"gatsby>@pmmmwh/react-refresh-webpack-plugin>webpack-dev-server>chokidar>fsevents>nan>node-gyp>make-fetch-happen>cacache>tar",
"gatsby>webpack-dev-server>chokidar>fsevents>nan>node-gyp>make-fetch-happen>cacache>tar",
"gatsby>gatsby-plugin-page-creator>chokidar>fsevents>nan>node-gyp>make-fetch-happen>cacache>tar",
"gatsby>gatsby-plugin-page-creator>gatsby-page-utils>chokidar>fsevents>nan>node-gyp>make-fetch-happen>cacache>tar",
"gatsby>chokidar>fsevents>nan>node-gyp>tar",
"gatsby>gatsby-cli>gatsby-recipes>chokidar>fsevents>nan>node-gyp>tar",
"gatsby>@pmmmwh/react-refresh-webpack-plugin>webpack-dev-server>chokidar>fsevents>nan>node-gyp>tar",
"gatsby>webpack-dev-server>chokidar>fsevents>nan>node-gyp>tar",
"gatsby>gatsby-plugin-page-creator>chokidar>fsevents>nan>node-gyp>tar",
"gatsby>gatsby-plugin-page-creator>gatsby-page-utils>chokidar>fsevents>nan>node-gyp>tar"
]
}
],
"id": 1770,
"created": "2021-08-03T18:11:06.582Z",
"updated": "2021-08-03T19:07:08.152Z",
"deleted": null,
"title": "Arbitrary File Creation/Overwrite due to insufficient absolute path sanitization",
"found_by": { "link": "", "name": "Anonymous", "email": "" },
"reported_by": { "link": "", "name": "Anonymous", "email": "" },
"module_name": "tar",
"cves": ["CVE-2021-32804"],
"vulnerable_versions": "<3.2.2 || >=4.0.0 <4.4.14 || >=5.0.0 <5.0.6 || >=6.0.0 <6.1.1",
"patched_versions": ">=3.2.2 <4.0.0 || >=4.4.14 <5.0.0 || >=5.0.6 <6.0.0 || >=6.1.1",
"overview": "The `tar` package has a high severity vulnerability before versions 3.2.2, 4.4.14, 5.0.6, and 6.1.1.\n\n### Impact\n\nArbitrary File Creation, Arbitrary File Overwrite, Arbitrary Code Execution\n\n`node-tar` aims to prevent extraction of absolute file paths by turning absolute paths into relative paths when the `preservePaths` flag is not set to `true`. This is achieved by stripping the absolute path root from any absolute file paths contained in a tar file. For example `/home/user/.bashrc` would turn into `home/user/.bashrc`. \n\nThis logic was insufficient when file paths contained repeated path roots such as `////home/user/.bashrc`. `node-tar` would only strip a single path root from such paths. When given an absolute file path with repeating path roots, the resulting path (e.g. `///home/user/.bashrc`) would still resolve to an absolute path, thus allowing arbitrary file creation and overwrite. \n\n### Workarounds\n\nUsers may work around this vulnerability without upgrading by creating a custom `onentry` method which sanitizes the `entry.path` or a `filter` method which removes entries with absolute paths.\n\n```js\nconst path = require('path')\nconst tar = require('tar')\n\ntar.x({\n file: 'archive.tgz',\n // either add this function...\n onentry: (entry) => {\n if (path.isAbsolute(entry.path)) {\n entry.path = sanitizeAbsolutePathSomehow(entry.path)\n entry.absolute = path.resolve(entry.path)\n }\n },\n\n // or this one\n filter: (file, entry) => {\n if (path.isAbsolute(entry.path)) {\n return false\n } else {\n return true\n }\n }\n})\n```\n\nUsers are encouraged to upgrade to the latest patch versions, rather than attempt to sanitize tar input themselves.",
"recommendation": "Upgrade to version 3.2.2, 4.4.14, 5.0.6, 6.1.1 or later",
"references": "- [GitHub Advisory](https://github.com/npm/node-tar/security/advisories/GHSA-3jfq-g458-7qm9)\n- [CVE](https://nvd.nist.gov/vuln/detail/CVE-2021-32804)\n- [Related but distinct advisory involving symlinks](https://www.npmjs.com/advisories/1771)",
"access": "public",
"severity": "high",
"cwe": "CWE-22",
"metadata": {
"module_type": "",
"exploitability": 8,
"affected_components": ""
},
"url": "https://npmjs.com/advisories/1770"
},
"1771": {
"findings": [
{
"version": "6.1.0",
"paths": [
"gatsby>chokidar>fsevents>nan>node-gyp>make-fetch-happen>cacache>tar",
"gatsby>gatsby-cli>gatsby-recipes>chokidar>fsevents>nan>node-gyp>make-fetch-happen>cacache>tar",
"gatsby>@pmmmwh/react-refresh-webpack-plugin>webpack-dev-server>chokidar>fsevents>nan>node-gyp>make-fetch-happen>cacache>tar",
"gatsby>webpack-dev-server>chokidar>fsevents>nan>node-gyp>make-fetch-happen>cacache>tar",
"gatsby>gatsby-plugin-page-creator>chokidar>fsevents>nan>node-gyp>make-fetch-happen>cacache>tar",
"gatsby>gatsby-plugin-page-creator>gatsby-page-utils>chokidar>fsevents>nan>node-gyp>make-fetch-happen>cacache>tar",
"gatsby>chokidar>fsevents>nan>node-gyp>tar",
"gatsby>gatsby-cli>gatsby-recipes>chokidar>fsevents>nan>node-gyp>tar",
"gatsby>@pmmmwh/react-refresh-webpack-plugin>webpack-dev-server>chokidar>fsevents>nan>node-gyp>tar",
"gatsby>webpack-dev-server>chokidar>fsevents>nan>node-gyp>tar",
"gatsby>gatsby-plugin-page-creator>chokidar>fsevents>nan>node-gyp>tar",
"gatsby>gatsby-plugin-page-creator>gatsby-page-utils>chokidar>fsevents>nan>node-gyp>tar"
]
}
],
"id": 1771,
"created": "2021-08-03T18:14:17.499Z",
"updated": "2021-08-03T19:01:35.564Z",
"deleted": null,
"title": "Arbitrary File Creation/Overwrite via insufficient symlink protection due to directory cache poisoning",
"found_by": { "link": "", "name": "Anonymous", "email": "" },
"reported_by": { "link": "", "name": "Anonymous", "email": "" },
"module_name": "tar",
"cves": ["CVE-2021-32803"],
"vulnerable_versions": "<3.2.3 || >=4.0.0 <4.4.15 || >=5.0.0 <5.0.7 || >=6.0.0 <6.1.2",
"patched_versions": ">=3.2.3 <4.0.0 || >=4.4.15 <5.0.0 || >=5.0.7 <6.0.0 || >=6.1.2",
"overview": "The `tar` package has a high severity vulnerability before versions 3.2.3, 4.4.15, 5.0.7, and 6.1.2.\n\n### Impact\n\nArbitrary File Creation, Arbitrary File Overwrite, Arbitrary Code Execution\n\n`node-tar` aims to prevent extraction of absolute file paths by turning absolute paths into relative paths when the `preservePaths` flag is not set to `true`. This is achieved by stripping the absolute path root from any absolute file paths contained in a tar file. For example `/home/user/.bashrc` would turn into `home/user/.bashrc`. \n\nThis logic was insufficient when file paths contained repeated path roots such as `////home/user/.bashrc`. `node-tar` would only strip a single path root from such paths. When given an absolute file path with repeating path roots, the resulting path (e.g. `///home/user/.bashrc`) would still resolve to an absolute path, thus allowing arbitrary file creation and overwrite. \n\n### Workarounds\n\nUsers may work around this vulnerability without upgrading by creating a custom `onentry` method which sanitizes the `entry.path` or a `filter` method which removes entries with absolute paths.\n\n```js\nconst path = require('path')\nconst tar = require('tar')\n\ntar.x({\n file: 'archive.tgz',\n // either add this function...\n onentry: (entry) => {\n if (path.isAbsolute(entry.path)) {\n entry.path = sanitizeAbsolutePathSomehow(entry.path)\n entry.absolute = path.resolve(entry.path)\n }\n },\n\n // or this one\n filter: (file, entry) => {\n if (path.isAbsolute(entry.path)) {\n return false\n } else {\n return true\n }\n }\n})\n```\n\nUsers are encouraged to upgrade to the latest patch versions, rather than attempt to sanitize tar input themselves.",
"recommendation": "Upgrade to version 3.2.3, 4.4.15, 5.0.7, 6.1.2 or later",
"references": "- [GitHub Advisory](https://github.com/npm/node-tar/security/advisories/GHSA-3jfq-g458-7qm9)\n- [CVE](https://nvd.nist.gov/vuln/detail/CVE-2021-32803)\n- [Related but distinct advisory involving absolute paths](https://www.npmjs.com/advisories/1770)",
"access": "public",
"severity": "high",
"cwe": "CWE-22",
"metadata": {
"module_type": "",
"exploitability": 8,
"affected_components": ""
},
"url": "https://npmjs.com/advisories/1771"
}
},
"muted": [],
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 79,
"high": 25,
"critical": 0
},
"dependencies": 1236,
"devDependencies": 0,
"optionalDependencies": 0,
"totalDependencies": 1236
}
}
But what stops a malicious commit from putting these up to Team City? At this time, Team City doesn't offer a build step that runs Yarn Audit explicitly and reports the results. You could use some build log patterns in the fail conditions to fail the build, but then you'd still have to drill through potentially hundreds of lines looking for which packages were vulnerable, what used them and what needs to be done. And if you need to ignore some vulnerabilities (either because they don't affect you or there is no fix available yet) your only option is the --severity
flag.
But that only lets you ignore vulnerabilities above a certain level. You certainly can't take down an enterprise application for a critical issue that should be fixed any minute. So instead you try mitigate the issue, but then you either have to manually deploy or turn off the vulnerability check to publish the mitigation or ignore everything below at or below that severity. And what if due to that you accidentally miss a vulnerability that is far more serious?
Team City Service Messages and a healthy dose of cross-platform powershell to the rescue.
Ok, that looks neat but what does it do?
#!/usr/bin/pwsh
$Audit = yarn npm audit -R -A --json | ConvertFrom-Json;
"##teamcity[blockOpened name='YarnAudit' description='yarn npm audit -R -A']"
Foreach ($advisory in $Audit.advisories.PSObject.Properties){
$package = $($advisory.Value.findings[0].paths[0].Split('>')[-1]).Replace('|', '||').Replace('[', '|[').Replace(']', '|]').Replace('''', '|''');
$overview = $advisory.Value.overview.Replace('|', '||').Replace('[', '|[').Replace(']', '|]').Replace('''', '|''');
"##teamcity[buildProblem description='Advisory for $package: $($advisory.Value.url) | $overview' identity='VulnerableDependency']"
}
"##teamcity[blockClosed name='YarnAudit']"
We start by running Yarn audit with all the bells and whistles we want. The blockOpened/blockClosed service messages help to group this in the build log.The most important thing is to output json. ConvertFrom-Json will take care of all parsing for us. After that it's a matter of checking either the advisories
property to report individual issues, or the metadata
property for info on the whole run.
Since I wanted to be able to look at the output and see exactly what was wrong, I output a build problem per dependency with vulnerabilities. Since the description
field is truncated after 400 symbols, we need to make sure to output the url
. With the url anyone can follow the link for all the information that we have here even if the overview is cut short.
I've chosen to report individual vulnerable packages as build problems, but you could also do some script-wizardry and report them as tests for each individual test. Remember though, if you have build metric fail condition on test cases, this could cause issues you aren't expecting.
With a little more work, we can setup build parameters to ignore specific vulnerabilities by CVE, severity, package, etc, but that will have to wait for the next post.
If you liked what you read, consider supporting further work either with monetary donations, spreading the word, or contributing to any of my open source projects such as NuGetDefense.
History
- 8/6/2021: Initial publication
- 8/9/2021: Added Escapes to TeamCity Service Messages