Skip to content

Commit 1547e0c

Browse files
authored
[WTF-2695]: Escape package name before using in spawn (#186)
## This PR contains - [x] Bug fix - [ ] Feature - [ ] Refactor - [ ] Documentation - [ ] Other (describe) ## What is the purpose of this PR? Preventing shell expansion using single quotes did not work on Windows as the cmd prompt version of `npm show` would use the quote marks in the api call. The closest specification of npm package names comes from the docs where it says that it must be usable as part of a URL. We don't use encodeURIComponent() as that would escape @ and / as well, so encodeURI is enough. https://docs.npmjs.com/cli/v10/configuring-npm/package-json#name ## Relevant changes - Escape the input for spawn rather than just relying on preventing shell expansion. - During testing the audit command would get stuck in cycles present in the npm audit report. This logic was updated to avoid such cycles. - Catch errors thrown by npm when looking up available versions. ## What should be covered while testing? Audit command works on windows, mac, and linux.
2 parents c644783 + fcd4dd0 commit 1547e0c

4 files changed

Lines changed: 48 additions & 18 deletions

File tree

packages/pluggable-widgets-tools/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
99
### Fixed
1010

1111
- We fixed an issue on Windows where the generated `.mpk` was missing the widget's `.xml` files and icon/tile PNGs.
12+
- We fixed an error thrown by the `audit` command on windows. It would fail when looking up available versions for vulnerable packages.
1213

1314
### Changed
1415

packages/pluggable-widgets-tools/src/commands/audit.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export async function auditPluggableWidgetsTools(fix: boolean = false) {
2020
}
2121

2222
// Collect updateable, vulnerable packages installed by pwt
23-
const vulnerabilities = NpmAudit.collectVulnerabilities(report, report.vulnerabilities[pluggableWidgetsTools]);
23+
const vulnerabilities = NpmAudit.collectVulnerabilities(report, pluggableWidgetsTools);
2424
const vulnerableDependencies = vulnerabilities
2525
.map(v => v.name)
2626
.reduce((unique, p) => (unique.includes(p) ? unique : [...unique, p]), [] as NpmAudit.PackageName[]);
@@ -49,7 +49,8 @@ export async function auditPluggableWidgetsTools(fix: boolean = false) {
4949
const update = p.safeRange
5050
? green(`${symbols.pointerSmall} ${p.safeRange}`)
5151
: red(`${symbols.cross} No update available`);
52-
console.log(` ${whiteBright(bold(p.name))} ${p.vulnerableRange} ${update}`);
52+
const status = p.error ? red(p.error.message) : update;
53+
console.log(` ${whiteBright(bold(p.name))} ${p.vulnerableRange} ${status}`);
5354
});
5455

5556
// Add overrides for updateable dependencies
@@ -86,6 +87,7 @@ interface UpdateablePackage {
8687
name: NpmAudit.PackageName;
8788
vulnerableRange: string;
8889
safeRange?: string;
90+
error?: Error;
8991
}
9092

9193
/**
@@ -96,17 +98,23 @@ interface UpdateablePackage {
9698
* Using the ^ version range avoids this, as the version is specific enough for npm.
9799
*/
98100
async function findSafeVersion({ name, range }: NpmAudit.Dependency): Promise<UpdateablePackage> {
99-
const versions = await promisify(exec)(`npm show '${name}' versions --json`).then(
100-
({ stdout }) => JSON.parse(stdout) as string[]
101-
);
101+
const updateablePackage = { name, vulnerableRange: range };
102+
const escapedName = encodeURI(name); // npm package names must be usable as part of a URL
103+
const versions = await promisify(exec)(`npm show ${escapedName} versions --json`)
104+
.then(({ stdout }) => JSON.parse(stdout) as string[])
105+
.catch(_ => new Error("Unable to fetch available versions"));
106+
107+
if (versions instanceof Error) {
108+
return { ...updateablePackage, error: versions };
109+
}
102110

103111
const maxVulnerable = maxSatisfying(versions, range);
104112
const gtMaxVulnerable = ">" + maxVulnerable;
105113
const minNonVulnerable = minSatisfying(versions, gtMaxVulnerable);
106114

107115
if (!minNonVulnerable) {
108-
return { name, vulnerableRange: range };
116+
return updateablePackage;
109117
}
110118

111-
return { name, vulnerableRange: range, safeRange: "^" + minNonVulnerable };
119+
return { ...updateablePackage, safeRange: "^" + minNonVulnerable };
112120
}
Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
export function ensure<T>(arg?: T): T {
2-
if (arg == null) {
3-
throw new Error("Did not expect an argument to be undefined");
1+
export function ensure<T>(arg?: T, label: string = "argument"): T {
2+
if (arg === null || arg === undefined) {
3+
throw new Error(`Did not expect ${label} to be ${arg}`);
44
}
55
return arg;
66
}
7+
8+
export function partition<T, A extends T, B extends Exclude<T, A>>(
9+
input: Array<T>,
10+
predicate: (x: T) => x is A
11+
): [A[], B[]] {
12+
return [input.filter(predicate), input.filter((x): x is B => !predicate(x))] as const;
13+
}

packages/pluggable-widgets-tools/src/utils/npmAudit.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import assert from "node:assert";
2-
import { exec } from "node:child_process";
32
import { existsSync } from "node:fs";
43
import { join } from "node:path";
54
import { widgetRoot } from "../widget/paths";
5+
import { ensure, partition } from "../common";
6+
import { exec } from "node:child_process";
67

78
export type Report = {
89
auditReportVersion: 2;
@@ -59,15 +60,28 @@ export type Vulnerability = {
5960
range: string;
6061
};
6162

62-
export function collectVulnerabilities(report: Report, dependency: Dependency): Vulnerability[] {
63-
const vulnerabilities = dependency.via.filter(v => typeof v !== "string");
64-
if (vulnerabilities.length > 0) {
65-
return vulnerabilities;
63+
export function collectVulnerabilities(report: Report, rootDependency: PackageName): Vulnerability[] {
64+
const dependencies: PackageName[] = [rootDependency];
65+
const dependenciesSeen: PackageName[] = [];
66+
const allVulnerabilities: Vulnerability[] = [];
67+
68+
while (dependencies.length > 0) {
69+
const dependencyName = ensure(dependencies.shift());
70+
dependenciesSeen.push(dependencyName);
71+
72+
const [transients, vulnerabilities] = partition(
73+
report.vulnerabilities[dependencyName].via,
74+
v => typeof v === "string"
75+
);
76+
77+
if (vulnerabilities.length > 0) {
78+
allVulnerabilities.push(...vulnerabilities);
79+
continue;
80+
}
81+
dependencies.push(...transients.filter(d => !dependenciesSeen.includes(d)));
6682
}
6783

68-
return dependency.via
69-
.filter(v => typeof v === "string")
70-
.flatMap(v => collectVulnerabilities(report, report.vulnerabilities[v]));
84+
return allVulnerabilities;
7185
}
7286

7387
export async function run(): Promise<Report> {

0 commit comments

Comments
 (0)