@avi2dg/checks 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +1 -0
- package/docs/design.md +22 -0
- package/docs/gates/checks-advisories.md +128 -0
- package/docs/gates/checks-lint.md +2 -1
- package/package.json +7 -2
- package/src/core/gates.ts +3 -0
- package/src/core/git.ts +10 -0
- package/src/dependencies/advisories.ts +115 -0
- package/src/dependencies/advisory-rules.ts +206 -0
- package/src/dependencies/cache-root.ts +15 -0
- package/src/dependencies/osv-scanner.ts +176 -0
- package/src/dependencies/vendor.ts +2 -10
- package/src/testing/quarantine-clock.ts +2 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.28.0
|
|
6
|
+
|
|
7
|
+
Released 2026-09-28.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **dependencies:** add checks-advisories gate for advisories a bun.lock change adds [#98](https://github.com/avi2d/checks/pull/98)
|
|
12
|
+
|
|
5
13
|
## 0.27.0
|
|
6
14
|
|
|
7
15
|
Released 2026-09-27.
|
package/README.md
CHANGED
|
@@ -127,6 +127,7 @@ The table groups the gates by vector, the part of a repository each one judges.
|
|
|
127
127
|
| docs | [`checks-docs`](docs/gates/checks-docs.md) | the range | every repository |
|
|
128
128
|
| delivery | [`checks-commit-identity`](docs/gates/checks-commit-identity.md) | the range | every repository |
|
|
129
129
|
| delivery | [`checks-ci-wiring`](docs/gates/checks-ci-wiring.md) | the working tree | every repository |
|
|
130
|
+
| dependencies | [`checks-advisories`](docs/gates/checks-advisories.md) | the range | a repository tracking `bun.lock` |
|
|
130
131
|
|
|
131
132
|
<!-- end generated gates -->
|
|
132
133
|
|
package/docs/design.md
CHANGED
|
@@ -213,6 +213,28 @@ So `checks-vendor` strips an owner write bit and checks the tree against its rec
|
|
|
213
213
|
A write it finds there still fails the run.
|
|
214
214
|
A group or other write bit fails the run, because another user could have edited `.git/config` through it before `git status` reads it.
|
|
215
215
|
|
|
216
|
+
## Advisories fail only the range that adds them
|
|
217
|
+
|
|
218
|
+
`checks-advisories` compares the advisories at both ends of a range instead of failing on every advisory at the head.
|
|
219
|
+
An advisory published against a package that landed a month earlier would otherwise fail every open pull request, including one that touches only docs.
|
|
220
|
+
The scheduled `--all` run finds those advisories, and `advisory-acks.json` carries the ones a repository accepts for a while.
|
|
221
|
+
|
|
222
|
+
The gate runs OSV-Scanner rather than Trivy, Grype or `bun audit`.
|
|
223
|
+
Trivy 0.74.0 reads a nested `bun.lock` entry such as `mkdirp/minimist` under a name no advisory carries, so it misses every package bun nests.
|
|
224
|
+
Grype 0.119.0 drops the dev dependencies of `bun.lock` with no setting that keeps them.
|
|
225
|
+
`bun audit` asks the npm registry on every run and has no offline mode.
|
|
226
|
+
OSV.dev's npm export also holds OpenSSF's reports of malicious packages, which GitHub's reviewed advisories leave out.
|
|
227
|
+
|
|
228
|
+
The kit pins the scanner by version and by the SHA-256 of each build, because a scanner release is code that runs on every runner.
|
|
229
|
+
Trivy's own advisory GHSA-69fq-xp46-6x23 records a malicious release published with stolen credentials.
|
|
230
|
+
The scan runs offline against a database refreshed once a day, so most runs need no network.
|
|
231
|
+
|
|
232
|
+
The acknowledgement file belongs to the kit rather than to `osv-scanner.toml`.
|
|
233
|
+
OSV-Scanner's `ignoreUntil` accepts any day, such as 2099-01-01, and measures it against the clock of the machine that runs the scan.
|
|
234
|
+
The kit caps each entry at 30 days and measures a range from the head's dates, as `checks-quarantine-clock` does, so a commit gets the same verdict on every run.
|
|
235
|
+
`--all` measures from the current time instead, because a head's dates never move in a repository that takes no commit, and an entry measured from them would never expire.
|
|
236
|
+
The file is JSON because `Bun.TOML` cannot parse a TOML date.
|
|
237
|
+
|
|
216
238
|
## Related topics
|
|
217
239
|
|
|
218
240
|
- [checks](../README.md)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: reference
|
|
3
|
+
audience: consumers
|
|
4
|
+
---
|
|
5
|
+
# checks-advisories
|
|
6
|
+
|
|
7
|
+
`checks-advisories` is the gate that fails a range whose `bun.lock` change adds a package version with a known security advisory.
|
|
8
|
+
|
|
9
|
+
## What it checks
|
|
10
|
+
|
|
11
|
+
It scans `bun.lock` at both ends of the range with OSV-Scanner and fails on each advisory the head's lockfile has and the base's lockfile lacks.
|
|
12
|
+
It matches an advisory across the range by package name and the id OSV-Scanner reports, so a range that moves a package between two affected versions adds nothing.
|
|
13
|
+
It ignores aliases, because two live advisories can list each other as aliases.
|
|
14
|
+
An advisory published against a package the base already held shows at both ends, so it fails no range, and `--all` reports it instead.
|
|
15
|
+
Each failure names the package, its version, the advisory id, its severity and its summary.
|
|
16
|
+
A range that leaves `bun.lock` unchanged runs no scan.
|
|
17
|
+
It also holds `advisory-acks.json` to the rules in [The acknowledgement file](#the-acknowledgement-file) on every run.
|
|
18
|
+
|
|
19
|
+
## What it reads
|
|
20
|
+
|
|
21
|
+
It reads `bun.lock` at the base and at the head of the range from git, not from the working tree.
|
|
22
|
+
It scans copies of both in a scratch directory with an empty config, so a repository's own `osv-scanner.toml` takes no part.
|
|
23
|
+
It reads the advisories OSV.dev exports for npm, which hold GitHub's reviewed advisories and OpenSSF's reports of malicious packages.
|
|
24
|
+
|
|
25
|
+
It runs OSV-Scanner 2.6.0, pinned by the SHA-256 of each platform's build.
|
|
26
|
+
On first use it downloads the build from the scanner's GitHub release into `~/.cache/avi2dg-checks/osv-scanner/2.6.0/`.
|
|
27
|
+
It checks the SHA-256 again on every run and exits 2 on a copy that differs.
|
|
28
|
+
Builds are pinned for macOS and Linux, each on x64 and arm64.
|
|
29
|
+
On any other platform it exits 2, and no setting runs a scanner other than the pinned build.
|
|
30
|
+
|
|
31
|
+
It scans offline against OSV-Scanner's npm database in `~/.cache/avi2dg-checks/osv-scanner/db/`.
|
|
32
|
+
When the last refresh is more than 24 hours old, the scan asks for the database again, and OSV-Scanner downloads it only when the copy differs.
|
|
33
|
+
A last refresh dated ahead of the clock counts as no refresh.
|
|
34
|
+
When that download fails, it scans the cached copy and says so, as long as that copy was refreshed within 7 days.
|
|
35
|
+
With no copy refreshed within 7 days it exits 2.
|
|
36
|
+
A cold cache downloads about 55 MB of scanner and 217 MB of database.
|
|
37
|
+
|
|
38
|
+
`--all` appends its report to the file `GITHUB_STEP_SUMMARY` names, which is the job summary.
|
|
39
|
+
|
|
40
|
+
## The acknowledgement file
|
|
41
|
+
|
|
42
|
+
`advisory-acks.json` at the repository root lists the advisories the repository accepts for a while, such as a false positive or a fix that waits on an upstream release:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
[
|
|
46
|
+
{
|
|
47
|
+
"package": "minimist",
|
|
48
|
+
"id": "GHSA-xvch-5gv4-984h",
|
|
49
|
+
"until": "2026-10-20",
|
|
50
|
+
"reason": "mkdirp 0.5.1 never parses untrusted argv here, and leaves with the next test runner"
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
It reads the file at the head of the range.
|
|
56
|
+
Each entry names the package, the advisory's id as OSV-Scanner reports it, the day the entry stops holding, and why the repository accepts the advisory.
|
|
57
|
+
An entry covers only the advisory with that id, so one naming an alias covers nothing.
|
|
58
|
+
An entry holds until its `until` day begins in UTC.
|
|
59
|
+
A range measures from the later of the head's author and committer dates, so a commit gets the same verdict on every run.
|
|
60
|
+
`--all` measures from the current time, so an entry expires in a repository that takes no commit.
|
|
61
|
+
An entry whose `until` falls more than 30 days after that moment fails the run and covers nothing, and no setting raises the limit.
|
|
62
|
+
Once that moment passes `until`, the entry fails every run until it is deleted or renewed with a later `until`, whether or not the range touches `bun.lock`.
|
|
63
|
+
Delete it once the package is upgraded.
|
|
64
|
+
When a scan runs, an entry that matches no advisory at the head fails, so the file holds only live entries.
|
|
65
|
+
A file that does not decode as that list exits 2.
|
|
66
|
+
|
|
67
|
+
## Arguments
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
checks-advisories <base-ref> <head-ref>
|
|
71
|
+
checks-advisories <ref>
|
|
72
|
+
checks-advisories --all
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
With two arguments it judges the range from their merge base to the head.
|
|
76
|
+
With one it judges that commit against its parent, or against an empty tree for a repository's first commit.
|
|
77
|
+
With `--all` it fails on every advisory in `bun.lock` at `HEAD` that no entry covers.
|
|
78
|
+
|
|
79
|
+
## Exit codes
|
|
80
|
+
|
|
81
|
+
| Code | When |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| 0 | the range adds no advisory to `bun.lock`, and every acknowledgement holds |
|
|
84
|
+
| 1 | the range adds an advisory no acknowledgement covers, or an acknowledgement does not hold |
|
|
85
|
+
| 2 | a ref does not resolve, `advisory-acks.json` does not decode, or no verified scanner or usable database is at hand |
|
|
86
|
+
|
|
87
|
+
## Sample output
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
advisories: the range adds 2 advisory(ies) to bun.lock (0 at the head predate the range, 0 acknowledged); upgrade each package, or acknowledge its advisory in advisory-acks.json:
|
|
91
|
+
lodash@4.17.20 GHSA-35jh-r3h4-6jhm high: Command Injection in lodash
|
|
92
|
+
minimist@0.0.8 GHSA-xvch-5gv4-984h critical: Prototype Pollution in minimist
|
|
93
|
+
advisories: 1 acknowledgement(s) in advisory-acks.json do not hold:
|
|
94
|
+
qs GHSA-4mjr-xmp4-gh2g expired on 2026-10-20; upgrade the package and delete the entry, or renew it with a later day
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## When it runs
|
|
98
|
+
|
|
99
|
+
`checks-lint` runs it over each pull request's range in a repository that tracks `bun.lock`, as [checks-lint](checks-lint.md) says.
|
|
100
|
+
A range that leaves `bun.lock` unchanged runs in under a second, and one that changes it scans for about 10 seconds on a warm cache.
|
|
101
|
+
|
|
102
|
+
## Running it on a schedule
|
|
103
|
+
|
|
104
|
+
A range never fails on an advisory published after its package landed, so a scheduled `--all` run finds those:
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
on:
|
|
108
|
+
schedule:
|
|
109
|
+
- cron: "41 4 * * *"
|
|
110
|
+
workflow_dispatch:
|
|
111
|
+
jobs:
|
|
112
|
+
advisories:
|
|
113
|
+
runs-on: self-hosted
|
|
114
|
+
timeout-minutes: 10
|
|
115
|
+
steps:
|
|
116
|
+
- uses: actions/checkout@v5
|
|
117
|
+
- uses: oven-sh/setup-bun@v2
|
|
118
|
+
- run: bun install --frozen-lockfile
|
|
119
|
+
- run: ./node_modules/.bin/checks-advisories --all
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
A self-hosted runner keeps `~/.cache/avi2dg-checks/` between runs, so it downloads the scanner once per pinned version and the database about once a day.
|
|
123
|
+
A hosted runner starts each run with an empty cache, so each run downloads both.
|
|
124
|
+
|
|
125
|
+
## Related topics
|
|
126
|
+
|
|
127
|
+
- [checks-lint](checks-lint.md)
|
|
128
|
+
- [Why it is shaped this way](../design.md)
|
|
@@ -10,6 +10,7 @@ audience: consumers
|
|
|
10
10
|
|
|
11
11
|
It runs the gates under [What runs](../../README.md#what-runs), each in its own process.
|
|
12
12
|
Gates requiring tracked TypeScript files begin running when the repository tracks TypeScript.
|
|
13
|
+
`checks-advisories` begins running when the repository tracks `bun.lock`.
|
|
13
14
|
All other gates run for every repository.
|
|
14
15
|
|
|
15
16
|
## What it reads
|
|
@@ -44,7 +45,7 @@ With two arguments, the base and head override range discovery.
|
|
|
44
45
|
|
|
45
46
|
```
|
|
46
47
|
checks-lint: range 2504acf098d120e73a8ece3c96f22b934f35c6a8..10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
|
|
47
|
-
checks-lint: 1 of
|
|
48
|
+
checks-lint: 1 of 12 gate(s) failed: checks-comment-gate
|
|
48
49
|
```
|
|
49
50
|
|
|
50
51
|
<!-- end generated lint-sample -->
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Deterministic checks shared across a set of TypeScript repositories",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -53,6 +53,10 @@
|
|
|
53
53
|
"src/docs/docs.ts",
|
|
54
54
|
"src/dependencies/vendor.ts",
|
|
55
55
|
"src/dependencies/vendor-args.ts",
|
|
56
|
+
"src/dependencies/cache-root.ts",
|
|
57
|
+
"src/dependencies/advisories.ts",
|
|
58
|
+
"src/dependencies/advisory-rules.ts",
|
|
59
|
+
"src/dependencies/osv-scanner.ts",
|
|
56
60
|
"src/docs/doc-outline.ts",
|
|
57
61
|
"src/docs/doc-rules.ts",
|
|
58
62
|
"src/docs/doc-references.ts",
|
|
@@ -100,7 +104,8 @@
|
|
|
100
104
|
"checks-exports": "src/complexity/exports.ts",
|
|
101
105
|
"checks-quarantine-clock": "src/testing/quarantine-clock.ts",
|
|
102
106
|
"checks-docs": "src/docs/docs.ts",
|
|
103
|
-
"checks-vendor": "src/dependencies/vendor.ts"
|
|
107
|
+
"checks-vendor": "src/dependencies/vendor.ts",
|
|
108
|
+
"checks-advisories": "src/dependencies/advisories.ts"
|
|
104
109
|
},
|
|
105
110
|
"scripts": {
|
|
106
111
|
"prepare": "bun src/dependencies/vendor.ts --library effect --package effect --repository https://github.com/Effect-TS/effect.git --tag 'effect@{version}' --path packages/effect/package.json",
|
package/src/core/gates.ts
CHANGED
|
@@ -31,6 +31,8 @@ export const DEFAULT_BRANCH = "main";
|
|
|
31
31
|
|
|
32
32
|
const TYPESCRIPT_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
|
|
33
33
|
|
|
34
|
+
const BUN_LOCKFILE: TrackedContent = { pathspecs: ["bun.lock"], content: "a bun lockfile" };
|
|
35
|
+
|
|
34
36
|
export const KIT_GATES = [
|
|
35
37
|
{ bin: "checks-lint-coverage", vector: "quality", file: "lint-coverage.sh", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
36
38
|
{ bin: "checks-test-layout", vector: "testing", file: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
@@ -43,4 +45,5 @@ export const KIT_GATES = [
|
|
|
43
45
|
{ bin: "checks-unused", vector: "complexity", file: "unused.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
44
46
|
{ bin: "checks-exports", vector: "complexity", file: "exports.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
45
47
|
{ bin: "checks-quarantine-clock", vector: "testing", file: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
48
|
+
{ bin: "checks-advisories", vector: "dependencies", file: "advisories.ts", reads: "range", appliesTo: BUN_LOCKFILE },
|
|
46
49
|
] as const satisfies readonly KitGate[];
|
package/src/core/git.ts
CHANGED
|
@@ -205,6 +205,16 @@ export const commitOf = Effect.fn("commitOf")(function* (rev: string, cwd?: stri
|
|
|
205
205
|
return (yield* git(["rev-parse", "--verify", `${rev}^{commit}`], cwd)).trim();
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
+
// The later of the author and committer dates, in seconds, so a rebase neither restarts nor stops a clock read from it.
|
|
209
|
+
export const writtenAt = Effect.fn("writtenAt")(function* (rev: string, cwd?: string) {
|
|
210
|
+
const shown = yield* git(["show", "-s", "--format=%at %ct", rev], cwd);
|
|
211
|
+
const dates = shown.trim().split(" ").map(Number);
|
|
212
|
+
if (dates.length !== 2 || !dates.every(Number.isInteger)) {
|
|
213
|
+
return yield* new GitFailure({ message: `cannot read when ${rev} was written` });
|
|
214
|
+
}
|
|
215
|
+
return Math.max(...dates);
|
|
216
|
+
});
|
|
217
|
+
|
|
208
218
|
// A scratch index leaves the repository's own index and working tree untouched.
|
|
209
219
|
export const checkoutFiles = Effect.fn("checkoutFiles")(function* (rev: string, files: readonly string[], scratch: string, cwd?: string) {
|
|
210
220
|
const path = yield* Path.Path;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Clock, Config, Console, Effect, FileSystem, type Layer, Option, Path, Schema } from "effect";
|
|
3
|
+
import { changedPaths, commitOf, git, pathsAt, rangeFromArgs, writtenAt } from "../core/git.ts";
|
|
4
|
+
import type { BunServices } from "@effect/platform-bun";
|
|
5
|
+
import { runMain, Usage } from "../core/main.ts";
|
|
6
|
+
import {
|
|
7
|
+
ACKNOWLEDGEMENTS,
|
|
8
|
+
clockOf,
|
|
9
|
+
decodeAcknowledgements,
|
|
10
|
+
decodeOsvReport,
|
|
11
|
+
findingsIn,
|
|
12
|
+
judge,
|
|
13
|
+
LOCKFILE,
|
|
14
|
+
passes,
|
|
15
|
+
report,
|
|
16
|
+
summaryOf,
|
|
17
|
+
type AcknowledgementClock,
|
|
18
|
+
type Outcome,
|
|
19
|
+
} from "./advisory-rules.ts";
|
|
20
|
+
import { cacheRoot } from "./cache-root.ts";
|
|
21
|
+
import { scanLockfiles, Scanner } from "./osv-scanner.ts";
|
|
22
|
+
|
|
23
|
+
const NAME = "advisories";
|
|
24
|
+
const ALL = "--all";
|
|
25
|
+
const USAGE = `usage: advisories.ts <ref> | <base-ref> <head-ref> | ${ALL}`;
|
|
26
|
+
const SIDES = ["base", "head"] as const;
|
|
27
|
+
|
|
28
|
+
class AdvisoriesError extends Schema.TaggedError<AdvisoriesError>()("AdvisoriesError", {
|
|
29
|
+
message: Schema.String,
|
|
30
|
+
}) {}
|
|
31
|
+
|
|
32
|
+
type Request = { readonly kind: "range"; readonly base: string; readonly head: string } | { readonly kind: "head"; readonly head: string };
|
|
33
|
+
|
|
34
|
+
const requestOf = Effect.fn("requestOf")(function* (args: readonly string[]) {
|
|
35
|
+
if (args[0] === ALL) {
|
|
36
|
+
if (args.length > 1) return yield* new Usage({ message: USAGE });
|
|
37
|
+
return { kind: "head", head: yield* commitOf("HEAD") } satisfies Request;
|
|
38
|
+
}
|
|
39
|
+
const { base, head } = yield* rangeFromArgs(args, USAGE);
|
|
40
|
+
return { kind: "range", base, head: yield* commitOf(head) } satisfies Request;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const fileAt = Effect.fn("fileAt")(function* (rev: string, file: string, root: string) {
|
|
44
|
+
if (!(yield* pathsAt(rev, [file], root)).includes(file)) return Option.none<string>();
|
|
45
|
+
return Option.some(yield* git(["show", `${rev}:${file}`], root));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const acknowledgementsAt = Effect.fn("acknowledgementsAt")(function* (head: string, root: string) {
|
|
49
|
+
const text = yield* fileAt(head, ACKNOWLEDGEMENTS, root);
|
|
50
|
+
if (Option.isNone(text)) return [];
|
|
51
|
+
return yield* decodeAcknowledgements(text.value).pipe(
|
|
52
|
+
Effect.mapError((cause) => new AdvisoriesError({ message: `${ACKNOWLEDGEMENTS} at ${head} does not decode: ${cause.message}` })),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
type Lockfiles = Readonly<Record<(typeof SIDES)[number], Option.Option<string>>>;
|
|
57
|
+
|
|
58
|
+
// Scanning copies keeps the working tree and a repository's own osv-scanner.toml out of the verdict.
|
|
59
|
+
const findingsOf = Effect.fn("findingsOf")(function* (lockfiles: Lockfiles) {
|
|
60
|
+
const fs = yield* FileSystem.FileSystem;
|
|
61
|
+
const path = yield* Path.Path;
|
|
62
|
+
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-advisories-" });
|
|
63
|
+
const config = path.join(dir, "empty.toml");
|
|
64
|
+
yield* fs.writeFileString(config, "");
|
|
65
|
+
const written: string[] = [];
|
|
66
|
+
for (const side of SIDES) {
|
|
67
|
+
const text = lockfiles[side];
|
|
68
|
+
if (Option.isNone(text)) continue;
|
|
69
|
+
yield* fs.makeDirectory(path.join(dir, side));
|
|
70
|
+
yield* fs.writeFileString(path.join(dir, side, LOCKFILE), text.value);
|
|
71
|
+
written.push(path.join(dir, side, LOCKFILE));
|
|
72
|
+
}
|
|
73
|
+
const cache = yield* cacheRoot();
|
|
74
|
+
const { stdout, note } = yield* scanLockfiles(yield* (yield* Scanner).binary(cache), cache, config, written);
|
|
75
|
+
if (Option.isSome(note)) yield* Console.error(`${NAME}: ${note.value}`);
|
|
76
|
+
const scanned = yield* decodeOsvReport(stdout).pipe(
|
|
77
|
+
Effect.mapError((cause) => new AdvisoriesError({ message: `cannot read the report OSV-Scanner wrote: ${cause.message}` })),
|
|
78
|
+
);
|
|
79
|
+
const at = (side: string) => (source: string) => source.endsWith(`${path.sep}${side}${path.sep}${LOCKFILE}`);
|
|
80
|
+
return { base: findingsIn(scanned, at("base")), head: findingsIn(scanned, at("head")) };
|
|
81
|
+
}, Effect.scoped);
|
|
82
|
+
|
|
83
|
+
const outcomeOf = Effect.fn("outcomeOf")(function* (request: Request, root: string, clock: AcknowledgementClock) {
|
|
84
|
+
if (request.kind === "range" && (yield* changedPaths(request.base, request.head, [LOCKFILE], root)).length === 0) {
|
|
85
|
+
return { kind: "unchanged", problems: clock.problems } satisfies Outcome;
|
|
86
|
+
}
|
|
87
|
+
const head = yield* fileAt(request.head, LOCKFILE, root);
|
|
88
|
+
const base = request.kind === "range" ? yield* fileAt(request.base, LOCKFILE, root) : Option.none<string>();
|
|
89
|
+
const findings = Option.isNone(head) ? { base: [], head: [] } : yield* findingsOf({ base, head });
|
|
90
|
+
return { kind: "scanned", scope: { kind: request.kind }, judged: judge(findings.base, findings.head, clock) } satisfies Outcome;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const toStepSummary = Effect.fn("toStepSummary")(function* (text: string) {
|
|
94
|
+
const stepSummary = yield* Config.option(Config.String("GITHUB_STEP_SUMMARY"));
|
|
95
|
+
if (Option.isNone(stepSummary)) return;
|
|
96
|
+
yield* (yield* FileSystem.FileSystem).writeFileString(stepSummary.value, `${summaryOf(text)}\n`, { flag: "a" });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const advisories = Effect.gen(function* () {
|
|
100
|
+
const request = yield* requestOf(process.argv.slice(2));
|
|
101
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
102
|
+
const dates = { head: (yield* writtenAt(request.head, root)) * 1000, now: yield* Clock.currentTimeMillis };
|
|
103
|
+
const clock = clockOf(yield* acknowledgementsAt(request.head, root), { kind: request.kind }, dates);
|
|
104
|
+
const outcome = yield* outcomeOf(request, root, clock);
|
|
105
|
+
const text = report(outcome);
|
|
106
|
+
yield* Console.log(text);
|
|
107
|
+
if (request.kind === "head") yield* toStepSummary(text);
|
|
108
|
+
return passes(outcome);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export function main(scanner: Layer.Layer<Scanner, never, BunServices.BunServices>): void {
|
|
112
|
+
runMain(NAME, advisories.pipe(Effect.provide(scanner)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (import.meta.main) main(Scanner.pinned);
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export const LOCKFILE = "bun.lock";
|
|
4
|
+
export const ACKNOWLEDGEMENTS = "advisory-acks.json";
|
|
5
|
+
const ACKNOWLEDGEMENT_DAYS = 30;
|
|
6
|
+
const DAY_MS = 86_400_000;
|
|
7
|
+
const UNRATED = "unrated";
|
|
8
|
+
|
|
9
|
+
// Date.parse rolls a day past the month's end, such as 2026-02-30, into the next month, so the round trip catches it.
|
|
10
|
+
function isCalendarDay(day: string): boolean {
|
|
11
|
+
const ms = Date.parse(`${day}T00:00:00Z`);
|
|
12
|
+
return !Number.isNaN(ms) && new Date(ms).toISOString().startsWith(day);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const Day = Schema.String.check(
|
|
16
|
+
Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/, { message: "is not a day such as 2026-10-20" }),
|
|
17
|
+
Schema.makeFilter((day: string) => isCalendarDay(day) || "is not a day of the calendar"),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const Acknowledgement = Schema.Struct({
|
|
21
|
+
package: Schema.NonEmptyString,
|
|
22
|
+
id: Schema.NonEmptyString,
|
|
23
|
+
until: Day,
|
|
24
|
+
reason: Schema.NonEmptyString,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type Acknowledgement = typeof Acknowledgement.Type;
|
|
28
|
+
|
|
29
|
+
export const decodeAcknowledgements = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Acknowledgement)));
|
|
30
|
+
|
|
31
|
+
const Vulnerability = Schema.Struct({
|
|
32
|
+
id: Schema.String,
|
|
33
|
+
summary: Schema.optionalKey(Schema.String),
|
|
34
|
+
database_specific: Schema.optionalKey(Schema.Struct({ severity: Schema.optionalKey(Schema.String) })),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const OsvReport = Schema.Struct({
|
|
38
|
+
results: Schema.Array(
|
|
39
|
+
Schema.Struct({
|
|
40
|
+
source: Schema.Struct({ path: Schema.String }),
|
|
41
|
+
packages: Schema.Array(
|
|
42
|
+
Schema.Struct({
|
|
43
|
+
package: Schema.Struct({ name: Schema.String, version: Schema.String }),
|
|
44
|
+
vulnerabilities: Schema.optionalKey(Schema.Array(Vulnerability)),
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
type OsvReport = typeof OsvReport.Type;
|
|
52
|
+
|
|
53
|
+
export const decodeOsvReport = Schema.decodeUnknownEffect(Schema.fromJsonString(OsvReport));
|
|
54
|
+
|
|
55
|
+
export type Finding = {
|
|
56
|
+
readonly name: string;
|
|
57
|
+
readonly version: string;
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly severity: string;
|
|
60
|
+
readonly summary: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// OSV-Scanner leaves a lockfile with no advisory out of its results, so an absent lockfile reads as clean.
|
|
64
|
+
export function findingsIn(scanned: OsvReport, lockfile: (path: string) => boolean): readonly Finding[] {
|
|
65
|
+
return scanned.results
|
|
66
|
+
.filter(({ source }) => lockfile(source.path))
|
|
67
|
+
.flatMap(({ packages }) => packages)
|
|
68
|
+
.flatMap(({ package: { name, version }, vulnerabilities = [] }) =>
|
|
69
|
+
vulnerabilities.map((vulnerability) => ({
|
|
70
|
+
name,
|
|
71
|
+
version,
|
|
72
|
+
id: vulnerability.id,
|
|
73
|
+
severity: vulnerability.database_specific?.severity?.toLowerCase() ?? UNRATED,
|
|
74
|
+
summary: vulnerability.summary ?? "",
|
|
75
|
+
})),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Two live records can list each other as aliases, so only the id the scan reports names an advisory.
|
|
80
|
+
function keyOf({ name, id }: { readonly name: string; readonly id: string }): string {
|
|
81
|
+
return `${name} ${id}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Keyed by name and id rather than version, so moving between two affected versions adds nothing.
|
|
85
|
+
export function introducedBy(base: readonly Finding[], head: readonly Finding[]): readonly Finding[] {
|
|
86
|
+
const known = new Set(base.map(keyOf));
|
|
87
|
+
return head.filter((finding) => !known.has(keyOf(finding)));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type AcknowledgementProblem =
|
|
91
|
+
| { readonly kind: "expired"; readonly acknowledgement: Acknowledgement }
|
|
92
|
+
| { readonly kind: "too-far"; readonly acknowledgement: Acknowledgement; readonly latest: string }
|
|
93
|
+
| { readonly kind: "unmatched"; readonly acknowledgement: Acknowledgement };
|
|
94
|
+
|
|
95
|
+
export type AcknowledgementClock = {
|
|
96
|
+
readonly live: readonly Acknowledgement[];
|
|
97
|
+
readonly problems: readonly AcknowledgementProblem[];
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type Scope = { readonly kind: "range" } | { readonly kind: "head" };
|
|
101
|
+
|
|
102
|
+
export type Dates = { readonly head: number; readonly now: number };
|
|
103
|
+
|
|
104
|
+
// A range reads the head's date so a commit gets one verdict, and --all reads today so an entry expires in an idle repository.
|
|
105
|
+
function measuredFrom(scope: Scope, { head, now }: Dates): number {
|
|
106
|
+
return scope.kind === "head" ? now : head;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function dayOf(ms: number): string {
|
|
110
|
+
return new Date(ms).toISOString().slice(0, "YYYY-MM-DD".length);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function clockOf(acknowledgements: readonly Acknowledgement[], scope: Scope, dates: Dates): AcknowledgementClock {
|
|
114
|
+
const from = measuredFrom(scope, dates);
|
|
115
|
+
const limit = from + ACKNOWLEDGEMENT_DAYS * DAY_MS;
|
|
116
|
+
const live: Acknowledgement[] = [];
|
|
117
|
+
const problems: AcknowledgementProblem[] = [];
|
|
118
|
+
for (const acknowledgement of acknowledgements) {
|
|
119
|
+
const until = Date.parse(`${acknowledgement.until}T00:00:00Z`);
|
|
120
|
+
if (until <= from) problems.push({ kind: "expired", acknowledgement });
|
|
121
|
+
else if (until > limit) problems.push({ kind: "too-far", acknowledgement, latest: dayOf(limit) });
|
|
122
|
+
else live.push(acknowledgement);
|
|
123
|
+
}
|
|
124
|
+
return { live, problems };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function covers(acknowledgement: Acknowledgement, finding: Finding): boolean {
|
|
128
|
+
return acknowledgement.package === finding.name && acknowledgement.id === finding.id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type Judged = {
|
|
132
|
+
readonly failing: readonly Finding[];
|
|
133
|
+
readonly acknowledged: number;
|
|
134
|
+
readonly predating: number;
|
|
135
|
+
readonly problems: readonly AcknowledgementProblem[];
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export function judge(base: readonly Finding[], head: readonly Finding[], clock: AcknowledgementClock): Judged {
|
|
139
|
+
const added = introducedBy(base, head);
|
|
140
|
+
const failing = added.filter((finding) => !clock.live.some((acknowledgement) => covers(acknowledgement, finding)));
|
|
141
|
+
const unmatched = clock.live
|
|
142
|
+
.filter((acknowledgement) => !head.some((finding) => covers(acknowledgement, finding)))
|
|
143
|
+
.map((acknowledgement): AcknowledgementProblem => ({ kind: "unmatched", acknowledgement }));
|
|
144
|
+
return {
|
|
145
|
+
failing,
|
|
146
|
+
acknowledged: added.length - failing.length,
|
|
147
|
+
predating: head.length - added.length,
|
|
148
|
+
problems: [...clock.problems, ...unmatched],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type Outcome =
|
|
153
|
+
| { readonly kind: "unchanged"; readonly problems: readonly AcknowledgementProblem[] }
|
|
154
|
+
| { readonly kind: "scanned"; readonly scope: Scope; readonly judged: Judged };
|
|
155
|
+
|
|
156
|
+
export function passes(outcome: Outcome): boolean {
|
|
157
|
+
if (outcome.kind === "unchanged") return outcome.problems.length === 0;
|
|
158
|
+
return outcome.judged.failing.length === 0 && outcome.judged.problems.length === 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function problemLine(problem: AcknowledgementProblem): string {
|
|
162
|
+
const { package: name, id, until } = problem.acknowledgement;
|
|
163
|
+
switch (problem.kind) {
|
|
164
|
+
case "expired":
|
|
165
|
+
return ` ${name} ${id} expired on ${until}; upgrade the package and delete the entry, or renew it with a later day`;
|
|
166
|
+
case "too-far":
|
|
167
|
+
return ` ${name} ${id} runs until ${until}, more than ${ACKNOWLEDGEMENT_DAYS} days out; name a day no later than ${problem.latest}`;
|
|
168
|
+
case "unmatched":
|
|
169
|
+
return ` ${name} ${id} matches nothing in ${LOCKFILE} at the head; delete it`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function problemLines(problems: readonly AcknowledgementProblem[]): readonly string[] {
|
|
174
|
+
if (problems.length === 0) return [];
|
|
175
|
+
return [`advisories: ${problems.length} acknowledgement(s) in ${ACKNOWLEDGEMENTS} do not hold:`, ...problems.map(problemLine)];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function findingLine({ name, version, id, severity, summary }: Finding): string {
|
|
179
|
+
return ` ${name}@${version} ${id} ${severity}${summary === "" ? "" : `: ${summary}`}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function scannedLines(scope: Scope, { failing, acknowledged, predating }: Judged): readonly string[] {
|
|
183
|
+
const fix = `upgrade each package, or acknowledge its advisory in ${ACKNOWLEDGEMENTS}:`;
|
|
184
|
+
if (scope.kind === "head") {
|
|
185
|
+
if (failing.length === 0) return [`advisories: ${LOCKFILE} at the head holds no unacknowledged advisory (${acknowledged} acknowledged)`];
|
|
186
|
+
return [`advisories: ${LOCKFILE} at the head holds ${failing.length} unacknowledged advisory(ies); ${fix}`, ...failing.map(findingLine)];
|
|
187
|
+
}
|
|
188
|
+
const counts = `${predating} at the head predate the range, ${acknowledged} acknowledged`;
|
|
189
|
+
if (failing.length === 0) return [`advisories: the range adds no advisory to ${LOCKFILE} (${counts})`];
|
|
190
|
+
return [`advisories: the range adds ${failing.length} advisory(ies) to ${LOCKFILE} (${counts}); ${fix}`, ...failing.map(findingLine)];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function report(outcome: Outcome): string {
|
|
194
|
+
if (outcome.kind === "unchanged") {
|
|
195
|
+
return [`advisories: ${LOCKFILE} is unchanged in the range, so nothing was scanned`, ...problemLines(outcome.problems)].join("\n");
|
|
196
|
+
}
|
|
197
|
+
return [...scannedLines(outcome.scope, outcome.judged), ...problemLines(outcome.judged.problems)].join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The job summary is Markdown, where an indented line would merge into the paragraph above it.
|
|
201
|
+
export function summaryOf(text: string): string {
|
|
202
|
+
return text
|
|
203
|
+
.split("\n")
|
|
204
|
+
.map((line) => (line.startsWith(" ") ? `- ${line.trim()}` : line))
|
|
205
|
+
.join("\n");
|
|
206
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Config, Effect, Path, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
const CACHE_HOME = ".cache/avi2dg-checks";
|
|
4
|
+
|
|
5
|
+
class CacheUnrooted extends Schema.TaggedError<CacheUnrooted>()("CacheUnrooted", {
|
|
6
|
+
message: Schema.String,
|
|
7
|
+
}) {}
|
|
8
|
+
|
|
9
|
+
export const cacheRoot = Effect.fn("cacheRoot")(function* () {
|
|
10
|
+
const path = yield* Path.Path;
|
|
11
|
+
const home = yield* Config.NonEmptyString("HOME").pipe(
|
|
12
|
+
Effect.mapError(() => new CacheUnrooted({ message: "HOME is missing, so the shared cache has no root" })),
|
|
13
|
+
);
|
|
14
|
+
return path.join(home, CACHE_HOME);
|
|
15
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Clock, Context, Crypto, Effect, Encoding, FileSystem, Layer, Option, Path, Schema } from "effect";
|
|
2
|
+
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
|
|
3
|
+
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
|
|
4
|
+
import { collect } from "../core/git.ts";
|
|
5
|
+
|
|
6
|
+
export const OSV_SCANNER_VERSION = "2.6.0";
|
|
7
|
+
const RELEASES = `https://github.com/google/osv-scanner/releases/download/v${OSV_SCANNER_VERSION}`;
|
|
8
|
+
const EXECUTABLE_MODE = 0o755;
|
|
9
|
+
const HOUR_MS = 3_600_000;
|
|
10
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
11
|
+
export const REFRESH_HOURS = 24;
|
|
12
|
+
export const USABLE_DAYS = 7;
|
|
13
|
+
const FOUND_NOTHING = 0;
|
|
14
|
+
const FOUND_ADVISORIES = 1;
|
|
15
|
+
const FOUND_NO_PACKAGE = 128;
|
|
16
|
+
const NO_RESULTS = JSON.stringify({ results: [] });
|
|
17
|
+
|
|
18
|
+
type Build = { readonly asset: string; readonly sha256: string };
|
|
19
|
+
|
|
20
|
+
// The SHA-256 of each asset as osv-scanner_SHA256SUMS of the release lists it.
|
|
21
|
+
const BUILDS: Readonly<Record<string, Build>> = {
|
|
22
|
+
"darwin-arm64": { asset: "osv-scanner_darwin_arm64", sha256: "98c460dcd37de25819babd757d04542045b6243113e209edcd4d89fedb0256b4" },
|
|
23
|
+
"darwin-x64": { asset: "osv-scanner_darwin_amd64", sha256: "60c5296637e977b28eeda5c7f13573e447659a632922737f94d11fa7e30ad6ca" },
|
|
24
|
+
"linux-arm64": { asset: "osv-scanner_linux_arm64", sha256: "2c71403eb443d05891c4f268c3ad771cf4f16e5443463fd7851ef8f454d3c7e4" },
|
|
25
|
+
"linux-x64": { asset: "osv-scanner_linux_amd64", sha256: "ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
class OsvScannerError extends Schema.TaggedError<OsvScannerError>()("OsvScannerError", {
|
|
29
|
+
message: Schema.String,
|
|
30
|
+
}) {}
|
|
31
|
+
|
|
32
|
+
export function buildFor(platform: string, arch: string): Option.Option<Build> {
|
|
33
|
+
return Option.fromNullishOr(BUILDS[`${platform}-${arch}`]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sha256Of = Effect.fn("sha256Of")(function* (bytes: Uint8Array) {
|
|
37
|
+
const crypto = yield* Crypto.Crypto;
|
|
38
|
+
return Encoding.encodeHex(yield* crypto.digest("SHA-256", bytes));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const verified = Effect.fn("verified")(function* (binary: string, sha256: string) {
|
|
42
|
+
const fs = yield* FileSystem.FileSystem;
|
|
43
|
+
const found = yield* sha256Of(yield* fs.readFile(binary));
|
|
44
|
+
if (found !== sha256) {
|
|
45
|
+
return yield* new OsvScannerError({ message: `${binary} has SHA-256 ${found}, not the pinned ${sha256}; delete it and rerun` });
|
|
46
|
+
}
|
|
47
|
+
return binary;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const download = Effect.fn("download")(function* (url: string) {
|
|
51
|
+
const response = yield* HttpClient.get(url).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
|
|
52
|
+
return new Uint8Array(yield* response.arrayBuffer);
|
|
53
|
+
}, Effect.provide(FetchHttpClient.layer));
|
|
54
|
+
|
|
55
|
+
// The binary lands through a rename in its own directory, so a concurrent run sees it whole or not at all.
|
|
56
|
+
export const installPinned = Effect.fn("installPinned")(function* (url: string, sha256: string, binary: string) {
|
|
57
|
+
const fs = yield* FileSystem.FileSystem;
|
|
58
|
+
const path = yield* Path.Path;
|
|
59
|
+
if (yield* fs.exists(binary)) return yield* verified(binary, sha256);
|
|
60
|
+
const bytes = yield* download(url).pipe(
|
|
61
|
+
Effect.mapError((cause) => new OsvScannerError({ message: `cannot download ${url}: ${cause.message}` })),
|
|
62
|
+
);
|
|
63
|
+
const found = yield* sha256Of(bytes);
|
|
64
|
+
if (found !== sha256) {
|
|
65
|
+
return yield* new OsvScannerError({ message: `${url} has SHA-256 ${found}, not the pinned ${sha256}, so nothing was installed` });
|
|
66
|
+
}
|
|
67
|
+
yield* fs.makeDirectory(path.dirname(binary), { recursive: true });
|
|
68
|
+
const staging = yield* fs.makeTempDirectoryScoped({ directory: path.dirname(binary), prefix: `.${path.basename(binary)}-` });
|
|
69
|
+
const staged = path.join(staging, path.basename(binary));
|
|
70
|
+
yield* fs.writeFile(staged, bytes);
|
|
71
|
+
yield* fs.chmod(staged, EXECUTABLE_MODE);
|
|
72
|
+
yield* fs.rename(staged, binary);
|
|
73
|
+
return binary;
|
|
74
|
+
}, Effect.scoped);
|
|
75
|
+
|
|
76
|
+
const pinnedBinary = Effect.fn("pinnedBinary")(function* (cache: string) {
|
|
77
|
+
const build = buildFor(process.platform, process.arch);
|
|
78
|
+
if (Option.isNone(build)) {
|
|
79
|
+
return yield* new OsvScannerError({ message: `no pinned OSV-Scanner build runs on ${process.platform}-${process.arch}` });
|
|
80
|
+
}
|
|
81
|
+
const path = yield* Path.Path;
|
|
82
|
+
const { asset, sha256 } = build.value;
|
|
83
|
+
return yield* installPinned(`${RELEASES}/${asset}`, sha256, path.join(cache, "osv-scanner", OSV_SCANNER_VERSION, asset));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
type PinnedBinary = ReturnType<typeof pinnedBinary>;
|
|
87
|
+
|
|
88
|
+
export class Scanner extends Context.Service<
|
|
89
|
+
Scanner,
|
|
90
|
+
{ readonly binary: (cache: string) => Effect.Effect<string, Effect.Error<PinnedBinary>> }
|
|
91
|
+
>()("@avi2dg/checks/dependencies/Scanner") {
|
|
92
|
+
static readonly pinned = Layer.effect(
|
|
93
|
+
Scanner,
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
const services = yield* Effect.context<Effect.Services<PinnedBinary>>();
|
|
96
|
+
return Scanner.of({ binary: (cache) => pinnedBinary(cache).pipe(Effect.provideContext(services)) });
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type RefreshPlan = "refresh" | "offline";
|
|
102
|
+
|
|
103
|
+
export function refreshPlan(refreshedAgo: Option.Option<number>): RefreshPlan {
|
|
104
|
+
return Option.isSome(refreshedAgo) && refreshedAgo.value <= REFRESH_HOURS * HOUR_MS ? "offline" : "refresh";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function usableWithoutRefresh(refreshedAgo: Option.Option<number>): boolean {
|
|
108
|
+
return Option.isSome(refreshedAgo) && refreshedAgo.value <= USABLE_DAYS * DAY_MS;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type Database = { readonly dir: string; readonly marker: string };
|
|
112
|
+
|
|
113
|
+
// An unparsable marker gives NaN and one dated ahead of the clock a negative age, and neither says how old the database is.
|
|
114
|
+
export function refreshAge(recorded: Option.Option<string>, now: number): Option.Option<number> {
|
|
115
|
+
return Option.filter(Option.map(recorded, (text) => now - Date.parse(text.trim())), (ago) => ago >= 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const refreshedAgo = Effect.fn("refreshedAgo")(function* ({ marker }: Database) {
|
|
119
|
+
const fs = yield* FileSystem.FileSystem;
|
|
120
|
+
const recorded = yield* fs.readFileString(marker).pipe(Effect.option);
|
|
121
|
+
return refreshAge(recorded, yield* Clock.currentTimeMillis);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const FLAGS: Readonly<Record<RefreshPlan, readonly string[]>> = {
|
|
125
|
+
refresh: ["--offline-vulnerabilities", "--download-offline-databases", "--no-resolve"],
|
|
126
|
+
offline: ["--offline"],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
type Scanned =
|
|
130
|
+
| { readonly kind: "scanned"; readonly stdout: string }
|
|
131
|
+
| { readonly kind: "no-package" }
|
|
132
|
+
| { readonly kind: "failed"; readonly reason: string };
|
|
133
|
+
|
|
134
|
+
const scanOnce = Effect.fn("scanOnce")(function* (binary: string, database: Database, config: string, lockfiles: readonly string[], plan: RefreshPlan) {
|
|
135
|
+
const args = ["scan", "source", ...FLAGS[plan], "--format", "json", "--config", config, ...lockfiles.flatMap((file) => ["-L", file])];
|
|
136
|
+
const { stdout, stderr, exitCode } = yield* collect(binary, args, undefined, { env: { OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY: database.dir } }).pipe(
|
|
137
|
+
Effect.mapError((cause) => new OsvScannerError({ message: `cannot run ${binary}: ${cause.message}` })),
|
|
138
|
+
);
|
|
139
|
+
if (exitCode === FOUND_NOTHING || exitCode === FOUND_ADVISORIES) return { kind: "scanned", stdout } satisfies Scanned;
|
|
140
|
+
if (exitCode === FOUND_NO_PACKAGE) return { kind: "no-package" } satisfies Scanned;
|
|
141
|
+
const reason = stderr.trim().split("\n").at(-1) ?? "";
|
|
142
|
+
return { kind: "failed", reason: `${binary} exited ${exitCode}: ${reason}` } satisfies Scanned;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
type ScanResult = { readonly stdout: string; readonly note: Option.Option<string> };
|
|
146
|
+
|
|
147
|
+
// A failed refresh leaves the cached database in place, which still serves until USABLE_DAYS pass without a refresh.
|
|
148
|
+
export const scanLockfiles = Effect.fn("scanLockfiles")(function* (binary: string, cache: string, config: string, lockfiles: readonly string[]) {
|
|
149
|
+
const fs = yield* FileSystem.FileSystem;
|
|
150
|
+
const path = yield* Path.Path;
|
|
151
|
+
const dir = path.join(cache, "osv-scanner", "db");
|
|
152
|
+
const database = { dir, marker: path.join(dir, "refreshed") };
|
|
153
|
+
const ago = yield* refreshedAgo(database);
|
|
154
|
+
const plan = refreshPlan(ago);
|
|
155
|
+
const first = yield* scanOnce(binary, database, config, lockfiles, plan);
|
|
156
|
+
// A lockfile with no package stops the scanner before it downloads anything, so no refresh is recorded.
|
|
157
|
+
if (first.kind === "no-package") return { stdout: NO_RESULTS, note: Option.none() } satisfies ScanResult;
|
|
158
|
+
if (first.kind === "scanned") {
|
|
159
|
+
if (plan === "refresh") {
|
|
160
|
+
yield* fs.makeDirectory(dir, { recursive: true });
|
|
161
|
+
yield* fs.writeFileString(database.marker, `${new Date(yield* Clock.currentTimeMillis).toISOString()}\n`);
|
|
162
|
+
}
|
|
163
|
+
return { stdout: first.stdout, note: Option.none() } satisfies ScanResult;
|
|
164
|
+
}
|
|
165
|
+
if (plan === "offline") return yield* new OsvScannerError({ message: first.reason });
|
|
166
|
+
if (Option.isNone(ago) || !usableWithoutRefresh(ago)) {
|
|
167
|
+
return yield* new OsvScannerError({
|
|
168
|
+
message: `could not refresh the OSV database, and no copy was refreshed in the last ${USABLE_DAYS} days: ${first.reason}`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const fallback = yield* scanOnce(binary, database, config, lockfiles, "offline");
|
|
172
|
+
if (fallback.kind === "failed") return yield* new OsvScannerError({ message: fallback.reason });
|
|
173
|
+
if (fallback.kind === "no-package") return { stdout: NO_RESULTS, note: Option.none() } satisfies ScanResult;
|
|
174
|
+
const days = Math.floor(ago.value / DAY_MS);
|
|
175
|
+
return { stdout: fallback.stdout, note: Option.some(`could not refresh the OSV database, so the scan read the copy refreshed ${days} day(s) ago: ${first.reason}`) } satisfies ScanResult;
|
|
176
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
2
|
+
import { Console, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
3
|
import { git } from "../core/git.ts";
|
|
4
4
|
import { runMain } from "../core/main.ts";
|
|
5
|
+
import { cacheRoot } from "./cache-root.ts";
|
|
5
6
|
import { librariesFrom, NAME, OPENER, type Library } from "./vendor-args.ts";
|
|
6
7
|
|
|
7
|
-
const CACHE_HOME = ".cache/avi2dg-checks";
|
|
8
8
|
const RECORD_SUFFIX = ".commit";
|
|
9
9
|
const LINKS = "repos";
|
|
10
10
|
const VERSION_TOKEN = "{version}";
|
|
@@ -303,14 +303,6 @@ const vend = Effect.fn("vend")(function* (root: string, cache: string, library:
|
|
|
303
303
|
yield* ensureLink(root, library, dir);
|
|
304
304
|
});
|
|
305
305
|
|
|
306
|
-
const cacheRoot = Effect.fn("cacheRoot")(function* () {
|
|
307
|
-
const path = yield* Path.Path;
|
|
308
|
-
const home = yield* Config.NonEmptyString("HOME").pipe(
|
|
309
|
-
Effect.mapError(() => new VendorError({ message: "HOME is missing, so the shared cache has no root" })),
|
|
310
|
-
);
|
|
311
|
-
return path.join(home, CACHE_HOME);
|
|
312
|
-
});
|
|
313
|
-
|
|
314
306
|
const main = Effect.gen(function* () {
|
|
315
307
|
const libraries = yield* librariesFrom(process.argv.slice(2));
|
|
316
308
|
if (libraries.length === 0) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Schema } from "effect";
|
|
3
|
-
import { commitOf, git, isShallowBoundary, refArgs } from "../core/git.ts";
|
|
3
|
+
import { commitOf, git, isShallowBoundary, refArgs, writtenAt } from "../core/git.ts";
|
|
4
4
|
import { runMain } from "../core/main.ts";
|
|
5
5
|
import { TEST_FILE } from "./test-layout.ts";
|
|
6
6
|
|
|
@@ -125,19 +125,10 @@ const entryAt = Effect.fn("entryAt")(function* (root: string, head: string, file
|
|
|
125
125
|
return { file, at: entry.at, day: entry.day };
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
-
const headAt = Effect.fn("headAt")(function* (root: string, head: string) {
|
|
129
|
-
const shown = yield* git(["show", "-s", "--format=%at %ct", head], root);
|
|
130
|
-
const dates = shown.trim().split(" ").map(Number);
|
|
131
|
-
if (dates.length !== 2 || !dates.every(Number.isInteger)) {
|
|
132
|
-
return yield* new QuarantineError({ message: `cannot read when ${head} was written` });
|
|
133
|
-
}
|
|
134
|
-
return Math.max(...dates);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
128
|
export const runHead = Effect.fn("runHead")(function* (root: string, head: string) {
|
|
138
129
|
const files = yield* filesAt(root, head);
|
|
139
130
|
const entries = yield* Effect.forEach(files, (file) => entryAt(root, head, file));
|
|
140
|
-
return { checked: files.length, overdue: overdueOf(entries, yield*
|
|
131
|
+
return { checked: files.length, overdue: overdueOf(entries, yield* writtenAt(head, root)) } satisfies ClockResult;
|
|
141
132
|
});
|
|
142
133
|
|
|
143
134
|
const clock = Effect.gen(function* () {
|