@avi2dg/checks 0.31.0 → 0.32.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.
@@ -0,0 +1,79 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
+ import { groupOf } from "./changelog.ts";
4
+ import { git } from "../core/git.ts";
5
+
6
+ class ReleaseUnreadable extends Schema.TaggedError<ReleaseUnreadable>()("ReleaseUnreadable", {
7
+ message: Schema.String,
8
+ }) {}
9
+
10
+ class BuildFailed extends Schema.TaggedError<BuildFailed>()("BuildFailed", {
11
+ message: Schema.String,
12
+ }) {}
13
+
14
+ export type Pending = {
15
+ readonly since: string | undefined;
16
+ readonly unreleased: readonly string[];
17
+ };
18
+
19
+ const RELEASE_TAG = "v[0-9]*.[0-9]*.[0-9]*";
20
+ const PLAIN_VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
21
+ const RELEASE_SUBJECT = /^chore: release (\d+\.\d+\.\d+)(?: \(#\d+\))?$/;
22
+
23
+ export function unreleasedOf(subjects: readonly string[]): readonly string[] {
24
+ return subjects.filter((subject) => groupOf(subject) !== undefined);
25
+ }
26
+
27
+ // Under 0.x a breaking change moves the minor, as semver leaves a 0.x version free to break.
28
+ export function nextVersion(version: string, unreleased: readonly string[]): string | undefined {
29
+ const [, major, minor, patch] = PLAIN_VERSION.exec(version)?.map(Number) ?? [];
30
+ if (major === undefined || minor === undefined || patch === undefined) return undefined;
31
+ const groups = new Set(unreleased.map(groupOf));
32
+ if (groups.has("Breaking changes") && major > 0) return `${major + 1}.0.0`;
33
+ if (groups.has("Breaking changes") || groups.has("Features")) return `${major}.${minor + 1}.0`;
34
+ return `${major}.${minor}.${patch + 1}`;
35
+ }
36
+
37
+ // Rewrites only the version's own text, so the rest of the manifest keeps the formatting its repository gave it.
38
+ export function withVersion(manifest: string, from: string, to: string): string | undefined {
39
+ const field = new RegExp(`("version"\\s*:\\s*")${from.replaceAll(".", "\\.")}(")`);
40
+ return field.test(manifest) ? manifest.replace(field, `$1${to}$2`) : undefined;
41
+ }
42
+
43
+ export function releaseTitle(version: string): string {
44
+ return `chore: release ${version}`;
45
+ }
46
+
47
+ export function releasedVersionOf(subject: string): string | undefined {
48
+ return RELEASE_SUBJECT.exec(subject)?.[1];
49
+ }
50
+
51
+ export const runBuild = Effect.fn("runBuild")(function* (root: string) {
52
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
53
+ const exitCode = yield* spawner.exitCode(
54
+ ChildProcess.make(process.execPath, ["run", "build"], { cwd: root, stdin: "ignore", stdout: "inherit", stderr: "inherit" }),
55
+ );
56
+ if (exitCode !== ChildProcessSpawner.ExitCode(0)) return yield* new BuildFailed({ message: `bun run build exited ${exitCode}` });
57
+ });
58
+
59
+ export const lastTag = Effect.fn("lastTag")(function* (root: string) {
60
+ const described = yield* git(["describe", "--tags", "--abbrev=0", `--match=${RELEASE_TAG}`, "HEAD"], root).pipe(
61
+ Effect.map((tag) => tag.trim()),
62
+ Effect.catchTag("GitFailure", () => Effect.succeed("")),
63
+ );
64
+ return described === "" ? undefined : described;
65
+ });
66
+
67
+ const subjectsSince = Effect.fn("subjectsSince")(function* (root: string, since: string | undefined) {
68
+ const range = since === undefined ? "HEAD" : `${since}..HEAD`;
69
+ const log = yield* git(["log", "--topo-order", "--no-merges", "--format=%s", range], root);
70
+ return log.split("\n").filter((subject) => subject !== "");
71
+ });
72
+
73
+ export const readPending = Effect.fn("readPending")(function* (root: string) {
74
+ if ((yield* git(["rev-parse", "--is-shallow-repository"], root)).trim() === "true") {
75
+ return yield* new ReleaseUnreadable({ message: "the checkout is shallow, so the tag it sees may not be the last one; fetch all of it" });
76
+ }
77
+ const since = yield* lastTag(root);
78
+ return { since, unreleased: unreleasedOf(yield* subjectsSince(root, since)) } satisfies Pending;
79
+ });
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bun
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { commitOf, git, refArgs } from "../core/git.ts";
4
+ import { runMain } from "../core/main.ts";
5
+ import { cacheRoot } from "../dependencies/cache-root.ts";
6
+ import { pinnedGitleaks, scanCommits, type Leak } from "./gitleaks.ts";
7
+
8
+ const NAME = "secrets";
9
+ const USAGE = "usage: secrets.ts <ref> | <base-ref> <head-ref>";
10
+ const SHORT_SHA = 8;
11
+ const NEW_SIDE_PREFIX = "b/";
12
+ const OCTOPUS_PARENTS = 3;
13
+
14
+ class OctopusMerge extends Schema.TaggedError<OctopusMerge>()("OctopusMerge", {
15
+ message: Schema.String,
16
+ }) {}
17
+
18
+ // git reads a lone commit as its whole ancestry, and -1 limits only what a walk prints, so a single ref is bounded with --no-walk.
19
+ const revisionsOf = Effect.fn("revisionsOf")(function* (args: readonly string[], root: string) {
20
+ const { first, second } = yield* refArgs(args, USAGE);
21
+ if (second === undefined) return ["--no-walk", yield* commitOf(first, root)];
22
+ const head = yield* commitOf(second, root);
23
+ const base = (yield* git(["merge-base", yield* commitOf(first, root), head], root)).trim();
24
+ return [`${base}..${head}`];
25
+ });
26
+
27
+ // Without --remerge-diff git log prints no diff for a merge, and a first-parent diff would rescan what the merge brings in.
28
+ // git skips --remerge-diff for an octopus merge with only a warning, so the scan would pass a commit it never read.
29
+ const logOptionsOf = Effect.fn("logOptionsOf")(function* (args: readonly string[], root: string) {
30
+ const revisions = yield* revisionsOf(args, root);
31
+ const [octopus] = (yield* git(["rev-list", `--min-parents=${OCTOPUS_PARENTS}`, ...revisions], root)).split("\n");
32
+ if (octopus !== undefined && octopus !== "") {
33
+ return yield* new OctopusMerge({
34
+ message: `git gives no resolution diff for the octopus merge ${octopus}, so the gate cannot scan it`,
35
+ });
36
+ }
37
+ return ["--remerge-diff", ...revisions].join(" ");
38
+ });
39
+
40
+ // gitleaks names a file under a remerge conflict header by its `+++ b/` line, so a path the commit does not hold carries that prefix.
41
+ const atRepositoryPath = Effect.fn("atRepositoryPath")(function* (leak: Leak, root: string) {
42
+ if (!leak.File.startsWith(NEW_SIDE_PREFIX)) return leak;
43
+ const held = yield* git(["cat-file", "-e", `${leak.Commit}:${leak.File}`], root).pipe(
44
+ Effect.as(true),
45
+ Effect.catchTag("GitFailure", () => Effect.succeed(false)),
46
+ );
47
+ return held ? leak : { ...leak, File: leak.File.slice(NEW_SIDE_PREFIX.length) };
48
+ });
49
+
50
+ function leakLine({ File, StartLine, RuleID, Commit, Description }: Leak): string {
51
+ return ` ${File}:${StartLine} ${RuleID} in ${Commit.slice(0, SHORT_SHA)}: ${Description}`;
52
+ }
53
+
54
+ function byPlace(left: Leak, right: Leak): number {
55
+ return (
56
+ left.File.localeCompare(right.File) ||
57
+ left.StartLine - right.StartLine ||
58
+ left.RuleID.localeCompare(right.RuleID) ||
59
+ left.Commit.localeCompare(right.Commit)
60
+ );
61
+ }
62
+
63
+ export function report(leaks: readonly Leak[]): string {
64
+ if (leaks.length === 0) return `${NAME}: the range adds no secret`;
65
+ return [
66
+ `${NAME}: the range adds ${leaks.length} secret(s); put a placeholder such as <private-key> in its place in the commit that added it, and rotate any secret that left this machine:`,
67
+ ...leaks.toSorted(byPlace).map(leakLine),
68
+ ].join("\n");
69
+ }
70
+
71
+ const secrets = Effect.gen(function* () {
72
+ const fs = yield* FileSystem.FileSystem;
73
+ const path = yield* Path.Path;
74
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
75
+ const logOptions = yield* logOptionsOf(process.argv.slice(2), root);
76
+ const binary = yield* pinnedGitleaks(yield* cacheRoot());
77
+ const config = path.join(import.meta.dir, "gitleaks.toml");
78
+ const ignoreDir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-" });
79
+ const gitDir = (yield* git(["rev-parse", "--absolute-git-dir"], root)).trim();
80
+ const reported = yield* scanCommits({ binary, gitDir, logOptions, config, ignoreDir });
81
+ const leaks = yield* Effect.forEach(reported, (leak) => atRepositoryPath(leak, root));
82
+ yield* Console.log(report(leaks));
83
+ return leaks.length === 0;
84
+ }).pipe(Effect.scoped);
85
+
86
+ if (import.meta.main) runMain(NAME, secrets);
@@ -1,11 +1,9 @@
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";
1
+ import { Clock, Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
4
2
  import { collect } from "../core/git.ts";
3
+ import { installPinned } from "./pinned-binary.ts";
5
4
 
6
5
  export const OSV_SCANNER_VERSION = "2.6.0";
7
6
  const RELEASES = `https://github.com/google/osv-scanner/releases/download/v${OSV_SCANNER_VERSION}`;
8
- const EXECUTABLE_MODE = 0o755;
9
7
  const HOUR_MS = 3_600_000;
10
8
  const DAY_MS = 24 * HOUR_MS;
11
9
  export const REFRESH_HOURS = 24;
@@ -33,46 +31,6 @@ export function buildFor(platform: string, arch: string): Option.Option<Build> {
33
31
  return Option.fromNullishOr(BUILDS[`${platform}-${arch}`]);
34
32
  }
35
33
 
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
34
  const pinnedBinary = Effect.fn("pinnedBinary")(function* (cache: string) {
77
35
  const build = buildFor(process.platform, process.arch);
78
36
  if (Option.isNone(build)) {
@@ -80,7 +38,7 @@ const pinnedBinary = Effect.fn("pinnedBinary")(function* (cache: string) {
80
38
  }
81
39
  const path = yield* Path.Path;
82
40
  const { asset, sha256 } = build.value;
83
- return yield* installPinned(`${RELEASES}/${asset}`, sha256, path.join(cache, "osv-scanner", OSV_SCANNER_VERSION, asset));
41
+ return yield* installPinned({ kind: "binary", url: `${RELEASES}/${asset}`, sha256 }, path.join(cache, "osv-scanner", OSV_SCANNER_VERSION, asset));
84
42
  });
85
43
 
86
44
  type PinnedBinary = ReturnType<typeof pinnedBinary>;
@@ -0,0 +1,79 @@
1
+ import { Crypto, Effect, Encoding, FileSystem, 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
+ const EXECUTABLE_MODE = 0o755;
7
+
8
+ export type Asset =
9
+ | { readonly kind: "binary"; readonly url: string; readonly sha256: string }
10
+ | { readonly kind: "tar.gz"; readonly url: string; readonly sha256: string; readonly member: string; readonly memberSha256: string };
11
+
12
+ class PinnedBinaryError extends Schema.TaggedError<PinnedBinaryError>()("PinnedBinaryError", {
13
+ message: Schema.String,
14
+ }) {}
15
+
16
+ function installedSha256(asset: Asset): string {
17
+ return asset.kind === "binary" ? asset.sha256 : asset.memberSha256;
18
+ }
19
+
20
+ const sha256Of = Effect.fn("sha256Of")(function* (bytes: Uint8Array) {
21
+ const crypto = yield* Crypto.Crypto;
22
+ return Encoding.encodeHex(yield* crypto.digest("SHA-256", bytes));
23
+ });
24
+
25
+ const verified = Effect.fn("verified")(function* (binary: string, sha256: string) {
26
+ const fs = yield* FileSystem.FileSystem;
27
+ const found = yield* sha256Of(yield* fs.readFile(binary));
28
+ if (found !== sha256) {
29
+ return yield* new PinnedBinaryError({ message: `${binary} has SHA-256 ${found}, not the pinned ${sha256}; delete it and rerun` });
30
+ }
31
+ return binary;
32
+ });
33
+
34
+ const download = Effect.fn("download")(function* (url: string) {
35
+ const response = yield* HttpClient.get(url).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
36
+ return new Uint8Array(yield* response.arrayBuffer);
37
+ }, Effect.provide(FetchHttpClient.layer));
38
+
39
+ const unpacked = Effect.fn("unpacked")(function* (asset: Asset, bytes: Uint8Array, staging: string) {
40
+ const fs = yield* FileSystem.FileSystem;
41
+ const path = yield* Path.Path;
42
+ const staged = path.join(staging, path.basename(asset.url));
43
+ yield* fs.writeFile(staged, bytes);
44
+ if (asset.kind === "binary") return staged;
45
+ const { stderr, exitCode } = yield* collect("tar", ["-xzf", staged, "-C", staging, asset.member]).pipe(
46
+ Effect.mapError((cause) => new PinnedBinaryError({ message: `cannot run tar: ${cause.message}` })),
47
+ );
48
+ if (exitCode !== 0) {
49
+ return yield* new PinnedBinaryError({ message: `cannot unpack ${asset.member} from ${asset.url}: ${stderr.trim()}` });
50
+ }
51
+ const member = path.join(staging, asset.member);
52
+ const found = yield* sha256Of(yield* fs.readFile(member));
53
+ if (found !== asset.memberSha256) {
54
+ return yield* new PinnedBinaryError({
55
+ message: `${asset.member} in ${asset.url} has SHA-256 ${found}, not the pinned ${asset.memberSha256}, so nothing was installed`,
56
+ });
57
+ }
58
+ return member;
59
+ });
60
+
61
+ // The binary lands through a rename in its own directory, so a concurrent run sees it whole or not at all.
62
+ export const installPinned = Effect.fn("installPinned")(function* (asset: Asset, binary: string) {
63
+ const fs = yield* FileSystem.FileSystem;
64
+ const path = yield* Path.Path;
65
+ if (yield* fs.exists(binary)) return yield* verified(binary, installedSha256(asset));
66
+ const bytes = yield* download(asset.url).pipe(
67
+ Effect.mapError((cause) => new PinnedBinaryError({ message: `cannot download ${asset.url}: ${cause.message}` })),
68
+ );
69
+ const found = yield* sha256Of(bytes);
70
+ if (found !== asset.sha256) {
71
+ return yield* new PinnedBinaryError({ message: `${asset.url} has SHA-256 ${found}, not the pinned ${asset.sha256}, so nothing was installed` });
72
+ }
73
+ yield* fs.makeDirectory(path.dirname(binary), { recursive: true });
74
+ const staging = yield* fs.makeTempDirectoryScoped({ directory: path.dirname(binary), prefix: `.${path.basename(binary)}-` });
75
+ const staged = yield* unpacked(asset, bytes, staging);
76
+ yield* fs.chmod(staged, EXECUTABLE_MODE);
77
+ yield* fs.rename(staged, binary);
78
+ return binary;
79
+ }, Effect.scoped);