@avi2dg/checks 0.57.1 → 0.58.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 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.58.0
6
+
7
+ Released 2026-10-29.
8
+
9
+ ### Features
10
+
11
+ - **delivery:** scan commit messages for secrets and pack terms [#17](https://github.com/avi2d/checks/pull/17)
12
+
5
13
  ## 0.57.1
6
14
 
7
15
  Released 2026-10-29.
package/docs/design.md CHANGED
@@ -323,6 +323,8 @@ A finding location prints as it stands, such as a file name or a link.
323
323
  So a term inside a file name or a repository name shows there.
324
324
  That location is already public in the scanned commit or the scanned pull request.
325
325
 
326
+ A pushed branch has already sent each commit message to the forge.
327
+ So `checks-secrets` scans each message in the range with the same rules and the same pack.
326
328
  A pull request's title, body and comments reach the forge without a commit, so [checks-pr-text](gates/checks-pr-text.md) scans them with the same rules and the same pack.
327
329
 
328
330
  ## The frontend checks are opt-in
@@ -12,7 +12,10 @@ A private pack adds terms no public file may carry, such as a person's name, an
12
12
  It scans the lines each commit in the range adds, in every file the commit adds or modifies, with gitleaks.
13
13
  In a merge commit it scans the lines the merge's own resolution adds, such as a key written while resolving a conflict, and not what the merge brings in from the other parent.
14
14
  It exits 2 when a commit it scans is an octopus merge, one with three or more parents, because git gives no resolution diff for one.
15
+ It scans the message of each commit in the range with the rules and the private pack [checks-pr-text](checks-pr-text.md) runs on a pull request's text.
16
+ A merge commit's own message is scanned too, though the file scan skips what the merge brings in from the other parent.
15
17
  It fails on each secret the scan finds, and names its file, its line, the rule that matched and the commit that added it.
18
+ A finding in a message names `the message` and its line in place of a file, where the subject is line 1.
16
19
  A secret one commit adds and a later commit in the range removes still fails, because the first commit still holds it.
17
20
  It guards new changes only, and never scans a commit before the range, so a secret already in the history takes no part.
18
21
 
@@ -40,14 +43,15 @@ These rules do not catch:
40
43
  No setting accepts a finding.
41
44
  It ignores a repository's `.gitleaks.toml`, its `.gitleaksignore` and every `gitleaks:allow` comment.
42
45
  It skips the paths gitleaks' default config skips, such as lockfiles, images and `node_modules/`.
43
- It scans what a commit adds to its files, and not its message or its author, which [checks-commit-identity](checks-commit-identity.md) judges.
46
+ It scans what a commit adds to its files and its message, and not its author, which [checks-commit-identity](checks-commit-identity.md) judges.
44
47
 
45
48
  ## The private pack
46
49
 
47
50
  The private pack is a JSON file of terms that lives outside every public repository, and `CHECKS_SECRETS_PACK` names its path.
48
51
  The kit ships no pack and no term, so a public repository can run the pack without carrying what it hides.
49
52
  With `CHECKS_SECRETS_PACK` unset the gate runs the kit's rules alone, exactly as it does without a pack, and its last line says so.
50
- With it set the gate adds a rule for each kind of term the pack lists, and fails a range that adds any of them.
53
+ With it set the gate adds a rule for each kind of term the pack lists.
54
+ It fails a range that adds any of them to a file or a commit message.
51
55
 
52
56
  Each key is optional, and each holds a list of strings:
53
57
 
@@ -78,7 +82,7 @@ An IPv6 range has no key, so a pack lists its prefix under `hosts` as its addres
78
82
  The gitleaks default allowlist skips paths such as lockfiles, go.mod and go.sum, images and documents such as PDFs.
79
83
  It also skips node_modules and some Go and Ruby vendor paths, so the pack terms are not searched there.
80
84
  A lockfile's resolved URLs can carry a private registry host, so keep such a host out of lockfiles, go.mod and go.sum by other means.
81
- Commit messages are not scanned by the pack, and checks-commit-identity covers only the author, the committer and Co-authored-by trailers.
85
+ The pack scans each commit message, trailers included, and checks-commit-identity also judges the author, the committer and Co-authored-by trailers.
82
86
 
83
87
  The gate exits 2, and scans nothing, when `CHECKS_SECRETS_PACK` is set but the pack cannot serve:
84
88
 
@@ -119,11 +123,12 @@ Unlike [checks-pr-text](checks-pr-text.md), this gate has no skip: a job that wr
119
123
 
120
124
  ## What it reads
121
125
 
122
- It reads the commits of the range from git, not the working tree.
126
+ It reads the commits of the range and their messages from git, not the working tree.
123
127
  It needs git 2.36 or newer, which diffs a merge against the merge git would make on its own, and it exits 2 on an older git.
124
128
  It reads its rules from `src/delivery/gitleaks.toml` in the installed package.
125
129
  It reads the path of the private pack from `CHECKS_SECRETS_PACK`.
126
- It writes the kit's rules and the pack's into one config in a temporary directory it deletes when the scan ends.
130
+ It writes the kit's rules and the pack's into one config, and each commit message into a file of its own.
131
+ Each goes in a temporary directory it deletes when the scan ends.
127
132
 
128
133
  It runs the gitleaks release `src/delivery/gitleaks.ts` pins, by the SHA-256 of each platform's archive and of the binary inside it.
129
134
  On first use it downloads the archive from the scanner's GitHub release and unpacks the binary into `~/.cache/avi2dg-checks/gitleaks/<version>/`.
@@ -146,8 +151,8 @@ With one it scans that commit alone.
146
151
 
147
152
  | Code | When |
148
153
  | --- | --- |
149
- | 0 | no commit in the range adds a secret |
150
- | 1 | a commit in the range adds a secret |
154
+ | 0 | no commit in the range adds a secret, in its files or its message |
155
+ | 1 | a commit in the range adds a secret, in its files or its message |
151
156
  | 2 | a ref does not resolve, or a commit it scans is an octopus merge, or no verified scanner is at hand, or git is older than 2.36, or gitleaks fails, or `CHECKS_SECRETS_PACK` is set and the pack cannot serve |
152
157
 
153
158
  ## Sample output
@@ -163,8 +168,9 @@ secrets: CHECKS_SECRETS_PACK is unset, so the kit's rules ran without a private
163
168
  With a pack set, a finding of the pack names the kind of term and never the term:
164
169
 
165
170
  ```
166
- secrets: the range adds 1 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:
171
+ secrets: the range adds 2 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:
167
172
  docs/setup.md:12 private-domain in 4f1c09ab: A domain the private pack lists
173
+ the message:3 private-name in 4f1c09ab: A name form the private pack lists
168
174
  secrets: the kit's rules and the private pack CHECKS_SECRETS_PACK names ran
169
175
  ```
170
176
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.57.1",
3
+ "version": "0.58.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Schema } from "effect";
3
- import { commitOf, git, refArgs } from "../core/git.ts";
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { GitFailure, commitOf, git, refArgs } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { cacheRoot } from "../dependencies/cache-root.ts";
6
- import { pinnedGitleaks, scanCommits, type Leak } from "./gitleaks.ts";
6
+ import { pinnedGitleaks, scanCommits, scanDirectory, type DirectoryScan, type Leak } from "./gitleaks.ts";
7
7
  import { scanConfig } from "./secrets-pack.ts";
8
8
 
9
9
  const NAME = "secrets";
@@ -27,8 +27,7 @@ const revisionsOf = Effect.fn("revisionsOf")(function* (args: readonly string[],
27
27
 
28
28
  // Without --remerge-diff git log prints no diff for a merge, and a first-parent diff would rescan what the merge brings in.
29
29
  // git skips --remerge-diff for an octopus merge with only a warning, so the scan would pass a commit it never read.
30
- const logOptionsOf = Effect.fn("logOptionsOf")(function* (args: readonly string[], root: string) {
31
- const revisions = yield* revisionsOf(args, root);
30
+ const logOptionsOf = Effect.fn("logOptionsOf")(function* (revisions: readonly string[], root: string) {
32
31
  const [octopus] = (yield* git(["rev-list", `--min-parents=${OCTOPUS_PARENTS}`, ...revisions], root)).split("\n");
33
32
  if (octopus !== undefined && octopus !== "") {
34
33
  return yield* new OctopusMerge({
@@ -48,10 +47,42 @@ const atRepositoryPath = Effect.fn("atRepositoryPath")(function* (leak: Leak, ro
48
47
  return held ? leak : { ...leak, File: leak.File.slice(NEW_SIDE_PREFIX.length) };
49
48
  });
50
49
 
50
+ type Message = { readonly commit: string; readonly text: string };
51
+
52
+ // git refuses a NUL in a commit message, so the NUL -z writes after each record ends it.
53
+ const messagesOf = Effect.fn("messagesOf")(function* (revisions: readonly string[], root: string) {
54
+ const records = (yield* git(["log", "--no-show-signature", "-z", "--format=%H%n%B", ...revisions], root)).split("\0").filter((record) => record !== "");
55
+ return yield* Effect.forEach(records, (record) => {
56
+ const newline = record.indexOf("\n");
57
+ const commit = newline === -1 ? "" : record.slice(0, newline);
58
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(commit)) {
59
+ return Effect.fail(new GitFailure({ message: "git log: a record does not start with a commit id" }));
60
+ }
61
+ return Effect.succeed({ commit, text: record.slice(newline + 1) } satisfies Message);
62
+ });
63
+ });
64
+
65
+ const messageLeaksOf = Effect.fn("messageLeaksOf")(function* (messages: readonly Message[], scanner: Omit<DirectoryScan, "dir">) {
66
+ const fs = yield* FileSystem.FileSystem;
67
+ const path = yield* Path.Path;
68
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-messages-" });
69
+ yield* Effect.forEach(messages, ({ commit, text }) => fs.writeFileString(path.join(dir, commit), text));
70
+ const leaks = yield* scanDirectory({ ...scanner, dir });
71
+ return leaks.map((leak) => ({ ...leak, Commit: path.basename(leak.File) }));
72
+ });
73
+
51
74
  function leakLine({ File, StartLine, RuleID, Commit, Description }: Leak): string {
52
75
  return ` ${File}:${StartLine} ${RuleID} in ${Commit.slice(0, SHORT_SHA)}: ${Description}`;
53
76
  }
54
77
 
78
+ function messageLine({ StartLine, RuleID, Commit, Description }: Leak): string {
79
+ return ` the message:${StartLine} ${RuleID} in ${Commit.slice(0, SHORT_SHA)}: ${Description}`;
80
+ }
81
+
82
+ function byMessage(left: Leak, right: Leak): number {
83
+ return left.Commit.localeCompare(right.Commit) || left.StartLine - right.StartLine || left.RuleID.localeCompare(right.RuleID);
84
+ }
85
+
55
86
  function byPlace(left: Leak, right: Leak): number {
56
87
  return (
57
88
  left.File.localeCompare(right.File) ||
@@ -61,27 +92,31 @@ function byPlace(left: Leak, right: Leak): number {
61
92
  );
62
93
  }
63
94
 
64
- export function report(leaks: readonly Leak[]): string {
65
- if (leaks.length === 0) return `${NAME}: the range adds no secret`;
95
+ export function report(fileLeaks: readonly Leak[], messageLeaks: readonly Leak[]): string {
96
+ const count = fileLeaks.length + messageLeaks.length;
97
+ if (count === 0) return `${NAME}: the range adds no secret`;
66
98
  return [
67
- `${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:`,
68
- ...leaks.toSorted(byPlace).map(leakLine),
99
+ `${NAME}: the range adds ${count} 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:`,
100
+ ...fileLeaks.toSorted(byPlace).map(leakLine),
101
+ ...messageLeaks.toSorted(byMessage).map(messageLine),
69
102
  ].join("\n");
70
103
  }
71
104
 
72
105
  const secrets = Effect.gen(function* () {
73
106
  const fs = yield* FileSystem.FileSystem;
74
107
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
75
- const logOptions = yield* logOptionsOf(process.argv.slice(2), root);
108
+ const revisions = yield* revisionsOf(process.argv.slice(2), root);
109
+ const logOptions = yield* logOptionsOf(revisions, root);
76
110
  const ignoreDir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-" });
77
111
  const { config, notice } = yield* scanConfig(NAME, yield* fs.makeTempDirectoryScoped({ prefix: "checks-secrets-pack-" }));
78
112
  const binary = yield* pinnedGitleaks(yield* cacheRoot());
79
113
  const gitDir = (yield* git(["rev-parse", "--absolute-git-dir"], root)).trim();
80
114
  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));
115
+ const fileLeaks = yield* Effect.forEach(reported, (leak) => atRepositoryPath(leak, root));
116
+ const messageLeaks = yield* messageLeaksOf(yield* messagesOf(revisions, root), { binary, config, ignoreDir });
117
+ yield* Console.log(report(fileLeaks, messageLeaks));
83
118
  yield* Console.log(notice);
84
- return leaks.length === 0;
119
+ return fileLeaks.length + messageLeaks.length === 0;
85
120
  }).pipe(Effect.scoped);
86
121
 
87
122
  if (import.meta.main) runMain(NAME, secrets);