Skip to content

Commit 573f2ca

Browse files
authored
feat: compare versions using ecosystem-specific semantics (#157)
* feat: use spec compliant implementation to compare versions from packagist ecosystem * refactor: use a map and common type to make it easier to implement ecosystem version comparators * fix: remove build metadata when comparing build versions * feat: implement proper semver v2 comparator * feat: implement comparator for NuGet * feat: implement comparator for RubyGems * feat: implement comparator for Maven * feat: implement comparator for PyPI * feat: implement comparator for Debian * test: improve reporting for `semantic` specs * fix: minor bugs in RubyGems comparator * fix: minor bugs in Packagist comparator * fix: properly preserve original version string in parsing * test: create scripts to generate semantic fixtures for ecosystems based off their respective osv dbs * docs: update details about how comparisons are done * fix: compare Maven versions with `sp` correctly * feat: restructure `semantic` parser and comparator implementation * feat: use `CompareAs` field to allow using different comparators for csvs * feat: replace `compareComponent` with `Cmp` on `Components` * refactor: move utilities around * fix: support `Pub` ecosystem * refactor: move stuff around * refactor: deduplicate some switch statements
1 parent 9bfcd15 commit 573f2ca

File tree

72 files changed

+59772
-1050
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

72 files changed

+59772
-1050
lines changed

README.md

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ osv-detector --parse-as 'package-lock.json' path/to/my/file.lock
8181
```
8282

8383
By default, the detector attempts to detect known vulnerabilities by checking
84-
the versions of packages specified by the parsed lockfile against the versions
85-
specified by the OSVs in the loaded OSV databases, using an internal
86-
semver-based package that aims to minimize false negatives (see
84+
the versions of packages specified by the OVSs in the loaded OSV databases,
85+
comparing based on the version ordering rules for the specific ecosystem being
86+
checked as closely as possible (see
8787
[this section](#version-parsing-and-comparing) for more details about version
8888
handling).
8989

@@ -335,7 +335,7 @@ the detector doesn't know about, such as `NuGet`.
335335
You can either pass in CSV rows:
336336

337337
```
338-
osv-detector --parse-as csv-row 'npm,@typescript-eslint/types,5.13.0' 'Packagist,sentry/sdk,2.0.4'
338+
osv-detector --parse-as csv-row 'npm,,@typescript-eslint/types,5.13.0' 'Packagist,sentry/sdk,2.0.4'
339339
```
340340

341341
or you can specify paths to csv files:
@@ -344,25 +344,43 @@ or you can specify paths to csv files:
344344
osv-detector --parse-as csv-file path/to/my/first-csv path/to/my/second-csv
345345
```
346346

347-
Each CSV row must have at least three fields which hold the ecosystem, package
348-
name, and version (or commit) respectively, and CSV files cannot contain a
349-
header.
347+
Each CSV row represents a package and is made up of at least four fields:
348+
349+
1. The ecosystem that the package is from, which is used as part of identifying
350+
if an OSV is about the given package
351+
- This does not have to be one of the ecosystems referenced in the detector,
352+
or in the OSV specification
353+
- This should be omitted if you are wanting to compare a commit using an API
354+
database
355+
2. The ecosystem whose version comparison semantics to use when determining if
356+
an OSV applies to the given package
357+
- This has to be an ecosystem for which the detector supports comparing
358+
versions of; this field can be blank if the first field refers to an
359+
ecosystem the detector supports comparing, otherwise it should be the
360+
ecosystem whose version semantics most closely match that of your arbitrary
361+
ecosystem
362+
- This should be omitted if you are wanting to compare a commit using an API
363+
database
364+
3. The name of the package
365+
4. The version of the package, or the SHA of a `git` commit
366+
- If you are providing a commit, then you must leave the first two fields
367+
empty and ensure an API-based database is loaded i.e. via `--use-api`
368+
369+
> **Warning**
370+
>
371+
> Do not include a header if you are using a CSV file
350372

351373
The `ecosystem` does _not_ have to be one listed by the detector as known,
352374
meaning you can use any ecosystem that [osv.dev](https://osv.dev/) provides.
353375

354-
If the ecosystem field is empty, then the `version` field is expected to be a
355-
commit. In this case, the `package` column is decorative as only the commit is
356-
passed to the API.
357-
358376
> Remember to tell the detector to use the `osv.dev` API via the `--use-api`
359377
> flag if you're wanting to check commits!
360378

361379
You can also omit the version to have the detector list all known
362380
vulnerabilities in the loaded database that apply to the given package:
363381

364382
```
365-
osv-detector --parse-as csv-row 'NuGet,Yarp.ReverseProxy,'
383+
osv-detector --parse-as csv-row 'NuGet,,Yarp.ReverseProxy,'
366384
```
367385

368386
While this uses the `--parse-as` flag, these are _not_ considered standard
@@ -417,8 +435,10 @@ The following packages were found in /path/to/my/Gemfile.lock:
417435

418436
## Version parsing and comparing
419437

420-
Versions are compared using an internal `semver` package which aims to support
421-
any number of components followed by a build string.
438+
Versions are compared using an internal `semantic` package which aims to support
439+
compare versions accurately per the version semantics of each ecosystem, falling
440+
back to a relaxed version of SemVer that supports unlimited number components
441+
followed by a build string.
422442

423443
Components are numbers broken up by dots, e.g. `1.2.3` has the components
424444
`1, 2, 3`. Anything that is not a number or a dot is considered to be the start

fixtures/csvs-files/two-rows.csv

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
NuGet,Yarp.ReverseProxy,
2-
npm,@typescript-eslint/types,5.13.0
1+
NuGet,,Yarp.ReverseProxy,
2+
npm,,@typescript-eslint/types,5.13.0
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import org.apache.maven.artifact.versioning.ComparableVersion;
2+
3+
import org.json.JSONArray;
4+
import org.json.JSONObject;
5+
6+
import java.io.*;
7+
import java.net.URL;
8+
import java.nio.channels.Channels;
9+
import java.nio.channels.ReadableByteChannel;
10+
import java.nio.charset.StandardCharsets;
11+
import java.util.*;
12+
import java.util.stream.Collectors;
13+
import java.util.stream.IntStream;
14+
import java.util.zip.ZipEntry;
15+
import java.util.zip.ZipFile;
16+
17+
/**
18+
* Script for generating a list of maven version comparison fixtures based off
19+
* every version mentioned in the OSV Maven database, sorted using the native
20+
* Maven implementation.
21+
* <p>
22+
* To run this, you need to ensure copies of the following libraries are present
23+
* on the class path:
24+
*
25+
* <ul>
26+
* <li><a href="https://search.maven.org/artifact/org.json/json/20220924/bundle"><code>json</code></a></li>
27+
* <li><a href="https://search.maven.org/artifact/org.apache.maven/maven-artifact/3.8.6/jar"><code>maven-artifact</code></a></li>
28+
* </ul>
29+
* The easiest way to do this is by putting the jars into a <code>lib</code> subfolder and then running:
30+
* <code>
31+
* java -cp generators/lib/* generators/GenerateMavenVersions.java
32+
* </code>
33+
*/
34+
public class GenerateMavenVersions {
35+
public static String downloadMavenDb() throws IOException {
36+
URL website = new URL("https://osv-vulnerabilities.storage.googleapis.com/Maven/all.zip");
37+
String file = "./maven-db.zip";
38+
39+
ReadableByteChannel rbc = Channels.newChannel(website.openStream());
40+
41+
try(FileOutputStream fos = new FileOutputStream(file)) {
42+
fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
43+
}
44+
45+
return file;
46+
}
47+
48+
public static Map<String, List<String>> fetchPackageVersions() throws IOException {
49+
String dbPath = downloadMavenDb();
50+
List<JSONObject> osvs = loadOSVs(dbPath);
51+
52+
Map<String, List<String>> packages = new HashMap<>();
53+
54+
osvs.forEach(osv -> osv.getJSONArray("affected").forEach(aff -> {
55+
JSONObject affected = (JSONObject) aff;
56+
57+
String pkgName = affected.getJSONObject("package").getString("name");
58+
59+
if(!affected.has("versions")) {
60+
return;
61+
}
62+
JSONArray versions = affected.getJSONArray("versions");
63+
64+
packages.putIfAbsent(pkgName, new ArrayList<>());
65+
66+
if(versions.isEmpty()) {
67+
return;
68+
}
69+
70+
versions.forEach(version -> packages.get(pkgName).add((String) version));
71+
}));
72+
73+
packages.forEach((key, _ignore) -> packages.put(
74+
key,
75+
packages.get(key)
76+
.stream()
77+
.distinct()
78+
.sorted(Comparator.comparing(ComparableVersion::new))
79+
.collect(Collectors.toList())
80+
));
81+
82+
return packages;
83+
}
84+
85+
public static List<JSONObject> loadOSVs(String pathToDbZip) throws IOException {
86+
List<JSONObject> osvs = new ArrayList<>();
87+
88+
try(ZipFile zipFile = new ZipFile(pathToDbZip)) {
89+
Enumeration<? extends ZipEntry> entries = zipFile.entries();
90+
91+
while(entries.hasMoreElements()) {
92+
ZipEntry entry = entries.nextElement();
93+
InputStream stream = zipFile.getInputStream(entry);
94+
95+
BufferedReader streamReader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8));
96+
StringBuilder responseStrBuilder = new StringBuilder();
97+
98+
String inputStr;
99+
while((inputStr = streamReader.readLine()) != null) {
100+
responseStrBuilder.append(inputStr);
101+
}
102+
osvs.add(new JSONObject(responseStrBuilder.toString()));
103+
}
104+
}
105+
106+
return osvs;
107+
}
108+
109+
public static void writeToFile(String outfile, List<String> lines) throws IOException {
110+
try(PrintWriter writer = new PrintWriter(outfile, StandardCharsets.UTF_8)) {
111+
lines.forEach(writer::println);
112+
}
113+
}
114+
115+
public static boolean compareVers(String version1, String op, String version2) {
116+
ComparableVersion v1 = new ComparableVersion(version1);
117+
ComparableVersion v2 = new ComparableVersion(version2);
118+
119+
int r = v1.compareTo(v2);
120+
121+
if(op.equals("=")) {
122+
return r == 0;
123+
}
124+
125+
if(op.equals("<")) {
126+
return r < 0;
127+
}
128+
129+
if(op.equals(">")) {
130+
return r > 0;
131+
}
132+
133+
throw new RuntimeException("unsupported comparison operator " + op);
134+
}
135+
136+
public static void compareVersions(List<String> lines, String select) {
137+
lines.forEach(line -> {
138+
line = line.trim();
139+
140+
if(line.isEmpty() || line.startsWith("#") || line.startsWith("//")) {
141+
return;
142+
}
143+
144+
String[] parts = line.split(" ");
145+
String v1 = parts[0];
146+
String op = parts[1];
147+
String v2 = parts[2];
148+
149+
boolean r = compareVers(v1, op, v2);
150+
151+
if(select.equals("failures") && r) {
152+
return;
153+
}
154+
155+
if(select.equals("successes") && !r) {
156+
return;
157+
}
158+
159+
String color = r ? "\033[92m" : "\033[91m";
160+
String rs = r ? "T" : "F";
161+
162+
System.out.printf("%s%s\033[0m: \033[93m%s\033[0m\n", color, rs, line);
163+
});
164+
}
165+
166+
public static void compareVersionsInFile(String filepath, String select) throws IOException {
167+
List<String> lines = new ArrayList<>();
168+
169+
try(BufferedReader br = new BufferedReader(new FileReader(filepath))) {
170+
String line = br.readLine();
171+
172+
while(line != null) {
173+
lines.add(line);
174+
line = br.readLine();
175+
}
176+
}
177+
178+
compareVersions(lines, select);
179+
}
180+
181+
public static List<String> generateVersionCompares(List<String> versions) {
182+
return IntStream.range(1, versions.size()).mapToObj(i -> {
183+
String currentVersion = versions.get(i);
184+
String previousVersion = versions.get(i - 1);
185+
String op = compareVers(currentVersion, "=", previousVersion) ? "=" : "<";
186+
187+
return String.format("%s %s %s", previousVersion, op, currentVersion);
188+
}).collect(Collectors.toList());
189+
}
190+
191+
public static List<String> generatePackageCompares(Map<String, List<String>> packages) {
192+
return packages
193+
.values()
194+
.stream()
195+
.map(GenerateMavenVersions::generateVersionCompares)
196+
.flatMap(Collection::stream)
197+
.distinct()
198+
.collect(Collectors.toList());
199+
}
200+
201+
public static void main(String[] args) throws IOException {
202+
String outfile = "maven-versions-generated.txt";
203+
Map<String, List<String>> packages = fetchPackageVersions();
204+
205+
writeToFile(outfile, generatePackageCompares(packages));
206+
207+
compareVersionsInFile(outfile, "failures");
208+
}
209+
}

0 commit comments

Comments
 (0)