second-read 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +215 -2
- package/dist/checks.js +325 -0
- package/dist/claude.js +427 -0
- package/dist/cli.js +52 -0
- package/dist/config.js +193 -0
- package/dist/gh.js +350 -0
- package/dist/git.js +531 -0
- package/dist/main.js +436 -0
- package/dist/normalize.js +44 -0
- package/dist/shell.js +396 -0
- package/dist/toml.js +170 -0
- package/package.json +32 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dean Verleger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,216 @@
|
|
|
1
|
-
#
|
|
1
|
+
# second-read
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
_A privacy check on agent-written commits, issues, PRs and comments, for any coding agent._
|
|
4
|
+
|
|
5
|
+
Coding agents write a lot of prose on your behalf: commit messages, issue and PR bodies, review comments. Some of it
|
|
6
|
+
mentions things about you that you'd never post yourself, such as a path on your machine, the name of a private
|
|
7
|
+
project, or a detail about your accounts. Nobody can read every line an agent posts, and a rule in a prompt is
|
|
8
|
+
followed most of the time, not every time. Once a slip is posted, it's hard to take back: edit histories keep old
|
|
9
|
+
revisions, and a commit is public until the history is rewritten.
|
|
10
|
+
|
|
11
|
+
second-read reads what's about to go out and stops it when it finds something private. It doesn't rewrite your text.
|
|
12
|
+
It refuses, names what it found, and leaves the fix to you or your agent.
|
|
13
|
+
|
|
14
|
+
> **Status:** early. The check, the git and `gh` guards and the Claude Code plugin run; the model check, other agent
|
|
15
|
+
> adapters and the sweep are planned.
|
|
16
|
+
> [PLAN.md](./PLAN.md) is the design.
|
|
17
|
+
|
|
18
|
+
## What it checks
|
|
19
|
+
|
|
20
|
+
- **Paths on your machine:** anything under your home folder, temporary and scratch folders.
|
|
21
|
+
- **Who you are on this machine:** your account name, your machine's name and email addresses, apart from the
|
|
22
|
+
ones you allow (such as GitHub's `noreply` address). Your own address, git's `user.email`, is checked whatever a
|
|
23
|
+
repository allows.
|
|
24
|
+
- **Money and accounts:** plans, billing, payments, spending, usage limits, what you will or won't pay for, and
|
|
25
|
+
credentials.
|
|
26
|
+
- **Your own list of private words:** project, folder, character or client names, in a file on your machine that is
|
|
27
|
+
never checked in anywhere.
|
|
28
|
+
- **What you told the agent in private**, where an agent tool can run a model check. That means your reasons,
|
|
29
|
+
feelings, frustrations and preferences, and your life outside the work, quoted or paraphrased. A post should say
|
|
30
|
+
what changed, not why you personally decided it, unless that reason is about the work.
|
|
31
|
+
- **Other paraphrases**, by the same model check: "your disk is nearly full", a description of your accounts or
|
|
32
|
+
your setup.
|
|
33
|
+
|
|
34
|
+
It isn't a secrets scanner. Tools like gitleaks find keys and tokens, and second-read is meant to run beside one.
|
|
35
|
+
|
|
36
|
+
## Where it runs
|
|
37
|
+
|
|
38
|
+
- **git hooks:** commit messages, and the lines a push adds.
|
|
39
|
+
- **A `gh` shim:** the text of issues, pull requests, comments, releases, gists, labels and repositories, and the
|
|
40
|
+
names they're given, before the real `gh` sends it.
|
|
41
|
+
- **Agent adapters**, for posts that don't go through `git` or `gh`, such as a GitHub tool an agent calls directly.
|
|
42
|
+
Claude Code comes first. Other agents follow, each written from its tool's own hook docs.
|
|
43
|
+
- **A sweep** of what's already posted to a repository, edit histories included.
|
|
44
|
+
|
|
45
|
+
The check runs on your machine, and the text it reads goes nowhere else.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
second-read needs Node 22 or newer.
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
npm install -g second-read
|
|
53
|
+
second-read init
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`init` writes your config file and says how to add your private terms. To update later, run
|
|
57
|
+
`npm install -g second-read` again.
|
|
58
|
+
|
|
59
|
+
## Using the check
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
second-read init # writes your config file, with no terms in it
|
|
63
|
+
echo "$MESSAGE" | second-read check --kind commit
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`check` exits `0` when the text is clean, `1` with findings and `2` when the check itself fails, so a guard can
|
|
67
|
+
refuse on anything but `0`. The report names each finding's category, line and text, and is printed only on your
|
|
68
|
+
machine.
|
|
69
|
+
|
|
70
|
+
Your config file is `~/.config/second-read/config.toml` (`$XDG_CONFIG_HOME`, or `%APPDATA%` on Windows):
|
|
71
|
+
|
|
72
|
+
```toml
|
|
73
|
+
terms = ["example-private-project"] # never posted; see below
|
|
74
|
+
allowed_words = ["billing"] # money words that are fine to post
|
|
75
|
+
allowed_emails = ["*@example.com"]
|
|
76
|
+
allowed_paths = ["~/.config/some-tool"]
|
|
77
|
+
allowed_names = ["juniper"] # your account or machine name, when it's an ordinary word
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A term matches in any case, with or without accents, in fullwidth forms, with invisible characters inside it, and
|
|
81
|
+
with its words joined by spaces, hyphens or nothing. It doesn't catch misspellings.
|
|
82
|
+
|
|
83
|
+
The machine name isn't flagged when it's one a system gives by default, such as `MacBook-Pro`, `raspberrypi` or
|
|
84
|
+
`penguin`, or a word for the kind of computer, such as `laptop`. Any other name is, even an ordinary word, since a
|
|
85
|
+
name someone chose can say who they are. If yours is a word that's fine to post, put it in `allowed_names`, and
|
|
86
|
+
the same goes for an account name. Paths with your account name in them are still flagged.
|
|
87
|
+
|
|
88
|
+
A repository can check in a `.second-read.toml` with the same `allowed_*` keys, but for `allowed_names`: naming
|
|
89
|
+
your machine there would post it. It can't hold `terms`, and it's an error if it names one of your private terms.
|
|
90
|
+
Since anyone who can change the repository can change it, it also can't switch a whole check off: `allowed_paths`
|
|
91
|
+
can't take in all of `~` or a temp folder, and each `allowed_emails` pattern needs a fixed domain (`*@example.com`
|
|
92
|
+
or `*@*.example.com`, not `*@*` or `*@*.com`). Your own file has none of these limits.
|
|
93
|
+
|
|
94
|
+
A pattern with a fixed domain can still take in a lot, such as `*@*.co.uk`, or `*@gmail.com` and everyone there. So
|
|
95
|
+
your own addresses, each `user.email` git uses where the check runs, are checked whatever a repository allows,
|
|
96
|
+
unless one is a noreply address. Your own file allows one only by naming it exactly, as `git commit -s` needs: a
|
|
97
|
+
pattern such as `*@gmail.com` is for other people's addresses, and doesn't take in yours.
|
|
98
|
+
|
|
99
|
+
A repository whose code or tests hold paths, emails or money words on purpose can list those files in its
|
|
100
|
+
`.second-read.toml`:
|
|
101
|
+
|
|
102
|
+
```toml
|
|
103
|
+
exempt_files = ["src/**/*.test.ts", "fixtures/"]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`pre-push` checks the lines a push adds to them for your private terms only. Each glob is a path from the top of the
|
|
107
|
+
repository: `*` and `?` stay within one folder, `**` takes in any number of them, and a trailing `/` takes in a
|
|
108
|
+
whole folder. A glob made only of wildcards and dots, such as `**` or `**/*.*`, is an error, since it takes in nearly
|
|
109
|
+
every file; one for dotfiles, such as `**/.*`, is fine. Commit and tag messages, branch and tag names, and everything
|
|
110
|
+
the `gh` shim sends are still checked in full, a gist's files included: a gist is a post of its own.
|
|
111
|
+
|
|
112
|
+
## The guards
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
second-read hooks install # in a repository: adds the commit-msg and pre-push hooks
|
|
116
|
+
second-read shim install # writes a gh shim, and says which folder to put ahead of gh on PATH
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- **`commit-msg`** checks the message git is about to commit.
|
|
120
|
+
- **`pre-push`** checks the message and the added lines of every commit the remote doesn't have yet (for a merge, the
|
|
121
|
+
lines in none of its parents, such as a conflict resolution's), and the name each branch and tag gets on the remote,
|
|
122
|
+
with the same checks as a message: a branch called `fix-billing-page` or `paid-plan` is refused for the money words
|
|
123
|
+
unless the allowlist lets them through. An annotated tag's own name is checked too, since it goes with the tag
|
|
124
|
+
whatever the remote calls it. Deleting a branch or tag is never refused, since that's how a leaked name is taken
|
|
125
|
+
back. It also refuses a push that changes a `.second-read.toml`, and prints an id for each change: the new version's
|
|
126
|
+
blob, or the commit that deletes the file. Once you've read the change, push again with
|
|
127
|
+
`SECOND_READ_ALLOWLIST_REVIEWED="<id>"`. An id covers only that change, so a later one is refused again.
|
|
128
|
+
- **The `gh` shim** checks `--title`, `--body`, `--body-file` and a body read from stdin, for `gh issue` and
|
|
129
|
+
`gh pr` `create`, `edit` and `comment`, and `gh pr review`. It also checks the `--comment` that `close` and
|
|
130
|
+
`reopen` leave, and the `--subject` and body of `gh pr merge`'s merge commit. It checks a release's title,
|
|
131
|
+
notes and tag, a gist's description and the files it uploads, stdin included, with the name each is uploaded
|
|
132
|
+
under, a label's name and description, and a repository's name and description, on `gh repo rename` too. A name
|
|
133
|
+
is checked as a branch name is, and only the new one, so a leaked name can be renamed away. Then it runs the real
|
|
134
|
+
`gh` with the same arguments and input. Every other command, `gh api` included, passes straight through.
|
|
135
|
+
|
|
136
|
+
Each guard refuses on a finding, and also when the check itself fails. A hook that was already in place is set aside
|
|
137
|
+
and still runs after the check; `second-read hooks uninstall` removes only second-read's hooks and puts it back. The
|
|
138
|
+
repository's `.second-read.toml` is read from the repository the hook runs in, or from the folder the shim runs in.
|
|
139
|
+
|
|
140
|
+
When `core.hooksPath` points at a checked-in folder, the hooks go into a folder inside `.git` instead, and this
|
|
141
|
+
clone's `core.hooksPath` points there. The repository's hooks still run, including ones added later: each hook
|
|
142
|
+
githooks(5) lists, except `push-to-checkout`. git runs `reference-transaction` and `post-index-change` very often, so
|
|
143
|
+
they run only if the repository had them when you installed; install again after adding one. `hooks uninstall` puts
|
|
144
|
+
`core.hooksPath` back. Running the repository's own setup step again, if it sets `core.hooksPath`, turns the guards
|
|
145
|
+
off until you install them again.
|
|
146
|
+
|
|
147
|
+
## The Claude Code plugin
|
|
148
|
+
|
|
149
|
+
The plugin checks what Claude Code is about to post, before the tool call runs. When it finds something, it denies
|
|
150
|
+
the call and gives Claude the report as the reason, so Claude can fix the text and try again. It needs Claude Code
|
|
151
|
+
2.1.295 or newer and Node 22.18 or newer on `PATH`, and it reads the same config files as the guards.
|
|
152
|
+
|
|
153
|
+
1. Add this repository as a plugin marketplace:
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
claude plugin marketplace add deanbot/second-read
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
2. Install the plugin:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
claude plugin install second-read@second-read
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
3. Check that it's installed and enabled:
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
claude plugin list
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
It checks:
|
|
172
|
+
|
|
173
|
+
- **Bash commands that post through `gh`:** the same `gh` commands as the shim, behind `env`, `sudo` and the like,
|
|
174
|
+
and `gh api` calls that send fields (`-f`, `-F`, including `@file`) or an `--input` body, unless they're `GET`. A GraphQL query
|
|
175
|
+
is checked when it's a mutation.
|
|
176
|
+
- **`git commit` and `git tag` messages**, from `-m`, `-F` and `--trailer`. What a push sends is the `pre-push`
|
|
177
|
+
hook's to check.
|
|
178
|
+
- **GitHub tools from MCP servers:** any tool with `github` in its name, in any case, apart from the ones that only
|
|
179
|
+
read (`get_`, `list_`, `search_`, `_read`). Every string it's given is checked, except `owner` and `repo`. A
|
|
180
|
+
file's contents are checked like the lines a push adds, so `exempt_files` applies to them.
|
|
181
|
+
|
|
182
|
+
A body can come from a heredoc (`--body "$(cat <<'EOF' … EOF)"`), a here-string, a file, or `cat`, `echo` or
|
|
183
|
+
`printf` piped in. A file the same command writes first, as in `cat > body.md <<'EOF'`, is read from the command,
|
|
184
|
+
since the hook runs before it does. Posts inside `$(…)` and backticks are checked too. A body the shell fills in
|
|
185
|
+
only when the command runs, such as `--body "$BODY"` or what `xargs` adds, can't be read beforehand, so the call is
|
|
186
|
+
denied. Put the text in the command, or in a file and pass its path.
|
|
187
|
+
|
|
188
|
+
The plugin never approves a call. A clean post goes on to Claude Code's usual permission check. Its deny holds in
|
|
189
|
+
`bypassPermissions` mode too: a session run with `--permission-mode bypassPermissions` on Claude Code 2.1.302 had
|
|
190
|
+
its leaking post refused.
|
|
191
|
+
|
|
192
|
+
If the hook itself fails or times out, the call is denied. A hook that can't run at all, because `node` isn't on
|
|
193
|
+
`PATH` or is older than 22.18, denies every Bash call, not only posts. To get out of that:
|
|
194
|
+
|
|
195
|
+
1. Turn the plugin off, from a terminal:
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
claude plugin disable second-read@second-read
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
2. Install Node 22.18 or newer.
|
|
202
|
+
|
|
203
|
+
3. Turn the plugin back on:
|
|
204
|
+
|
|
205
|
+
```sh
|
|
206
|
+
claude plugin enable second-read@second-read
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Before Claude Code 2.1.295, the hook's `onFailure` setting is ignored, so a hook that crashes lets the call through.
|
|
210
|
+
|
|
211
|
+
A setup script can tell whether the plugin is in place with `claude plugin list --json`, which lists it as
|
|
212
|
+
`"id": "second-read@second-read"` with `"enabled": true`.
|
|
213
|
+
|
|
214
|
+
## License
|
|
215
|
+
|
|
216
|
+
[MIT](./LICENSE)
|
package/dist/checks.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { escapeRegExp, fold, originalSpan, termPattern, wordPattern } from "./normalize.js";
|
|
2
|
+
export const KINDS = ["commit", "issue", "pr", "comment", "review"];
|
|
3
|
+
export const KIND_NAMES = {
|
|
4
|
+
commit: "this commit message",
|
|
5
|
+
issue: "this issue",
|
|
6
|
+
pr: "this pull request",
|
|
7
|
+
comment: "this comment",
|
|
8
|
+
review: "this review",
|
|
9
|
+
};
|
|
10
|
+
export const DEFAULT_ALLOWED_EMAILS = [
|
|
11
|
+
"*@users.noreply.github.com",
|
|
12
|
+
"noreply@github.com",
|
|
13
|
+
"git@github.com",
|
|
14
|
+
"noreply@*",
|
|
15
|
+
"no-reply@*",
|
|
16
|
+
"*@users.noreply.gitlab.com",
|
|
17
|
+
"*@example.com",
|
|
18
|
+
"*@example.org",
|
|
19
|
+
"*@example.net",
|
|
20
|
+
"*@*.example",
|
|
21
|
+
"*@*.test",
|
|
22
|
+
"*@*.invalid",
|
|
23
|
+
];
|
|
24
|
+
// Generic places under the home folder that say nothing about the person.
|
|
25
|
+
export const DEFAULT_ALLOWED_PATHS = [
|
|
26
|
+
"~/.config",
|
|
27
|
+
"~/.local",
|
|
28
|
+
"~/.cache",
|
|
29
|
+
"~/.ssh",
|
|
30
|
+
"~/.gitconfig",
|
|
31
|
+
"~/.npmrc",
|
|
32
|
+
"~/.bashrc",
|
|
33
|
+
"~/.bash_profile",
|
|
34
|
+
"~/.zshrc",
|
|
35
|
+
"~/.profile",
|
|
36
|
+
];
|
|
37
|
+
// Account and machine names too generic to flag.
|
|
38
|
+
const GENERIC_NAMES = new Set([
|
|
39
|
+
"admin", "app", "dev", "ec2-user", "guest", "localhost", "node", "nobody", "root", "runner", "ubuntu", "user",
|
|
40
|
+
"vagrant",
|
|
41
|
+
].map(nameKey));
|
|
42
|
+
// Machine names a system or an installer sets, and words for the kind or model of computer. A word someone might pick
|
|
43
|
+
// as a name stays out, since a chosen name can say who they are; the user's own allowed_names takes in those. These
|
|
44
|
+
// never apply to the account name, which is often the person's own name, and some of these are given names.
|
|
45
|
+
const DEFAULT_MACHINE_NAMES = new Set([
|
|
46
|
+
"Mac", "MacBook", "MacBook-Air", "MacBook-Pro", "Mac-mini", "Mac-Pro", "Mac-Studio", "iMac", "iMac-Pro",
|
|
47
|
+
"computer", "desktop", "laptop", "notebook", "workstation", "server",
|
|
48
|
+
"raspberrypi", "penguin", "steamdeck", "debian", "fedora", "kali", "archiso", "manjaro", "pop-os", "nixos",
|
|
49
|
+
"docker-desktop",
|
|
50
|
+
].map(nameKey));
|
|
51
|
+
// A name as it's compared with the lists above: folded, without separators, and without the number a system adds
|
|
52
|
+
// to tell two machines apart, as in "MacBook-Pro-2".
|
|
53
|
+
function nameKey(name) {
|
|
54
|
+
return fold(name.replace(/[-_ ]\d+$/, "")).text.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
55
|
+
}
|
|
56
|
+
const TEMP_FOLDERS = ["/tmp", "/var/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
|
|
57
|
+
// Money and accounts, over folded text. Each entry is [word, pattern]; `allowed_words` switches off a word.
|
|
58
|
+
// A word that's also ordinary dev language (subscription, plan, tier, budget, quota) counts only beside a money word.
|
|
59
|
+
const MONEY = [
|
|
60
|
+
["billing", "billing|billed"],
|
|
61
|
+
["invoice", "invoices?|invoiced"],
|
|
62
|
+
["payment", "payments?|(?:pay|paid|paying) for|pay as you go|pay-as-you-go"],
|
|
63
|
+
[
|
|
64
|
+
"subscription",
|
|
65
|
+
"(?:my|monthly|annual|yearly|weekly|free|paid|premium|pro|business|enterprise|trial) subscriptions?" +
|
|
66
|
+
"|subscriptions? (?:fees?|prices?|pricing|costs?|charges?|renewals?|payments?|billing|plans?|tiers?)" +
|
|
67
|
+
"|(?:(?:upgrad|downgrad)(?:e|es|ed|ing)|cancel(?:s|led|ling|ed|ing)?|renew(?:s|ed|ing)?)" +
|
|
68
|
+
" (?:my|our|the|your|a|their) subscriptions?",
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
"spending",
|
|
72
|
+
"spend(?:ing)? (?:limits?|caps?|alerts?)|spending money" +
|
|
73
|
+
"|(?:my|our|their|monthly|annual|yearly|personal|household|cloud) spending",
|
|
74
|
+
],
|
|
75
|
+
["budget", "(?:my|our|monthly|annual|yearly|weekly|personal|household|money|cash|spending) budgets?"],
|
|
76
|
+
[
|
|
77
|
+
"plan",
|
|
78
|
+
"(?:free|paid|pro|max|plus|team|business|enterprise|premium|pricing|billing|monthly|annual|yearly) plans?" +
|
|
79
|
+
"|(?:(?:upgrad|downgrad|chang)(?:e|es|ed|ing)|cancel(?:s|led|ling|ed|ing)?) (?:my|our|the|your|a|their) plan",
|
|
80
|
+
],
|
|
81
|
+
["tier", "(?:free|paid|pro|premium) tiers?"],
|
|
82
|
+
["pricing", "pricing"],
|
|
83
|
+
[
|
|
84
|
+
"usage limit",
|
|
85
|
+
"usage (?:limits?|caps?|quotas?)|overage (?:charges?|fees?|costs?|pricing)" +
|
|
86
|
+
"|(?:billing|plan|paid|subscription|account) quotas?",
|
|
87
|
+
],
|
|
88
|
+
["card", "credit cards?|debit cards?|bank accounts?|bank statements?"],
|
|
89
|
+
["refund", "refunds?|refunded|chargebacks?"],
|
|
90
|
+
["trial", "free trials?|trial period"],
|
|
91
|
+
];
|
|
92
|
+
// Amounts of money. A "$" before a single digit is left alone, since "$1" is usually a shell argument.
|
|
93
|
+
const AMOUNT = /(?<![\p{L}\p{N}$])(?:\$\s?(?:\d{1,3}(?:,\d{3})+|\d{2,}|\d(?=[.,]\d|\s?(?:k|m|bn)(?![\p{L}])|\s?(?:\/|a |per )))|[€£¥₹]\s?\d)[\d,]*(?:\.\d+)?(?:\s?(?:k|m|bn)(?![\p{L}]))?|(?<![\p{L}\p{N}.])\d[\d,]*(?:\.\d+)?\s?(?:usd|eur|gbp|dollars?|euros?|bucks)(?![\p{L}])/gu;
|
|
94
|
+
const EMAIL = /(?<![\p{L}\p{N}._%+-])[\p{L}\p{N}._%+-]+@[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.\p{L}{2,}(?![\p{L}\p{N}-])/gu;
|
|
95
|
+
const PATH_TAIL = "[^\\s'\"`<>()\\[\\]{}|,;]*";
|
|
96
|
+
const TRAILING_PUNCTUATION = /[.,:;!?]+$/;
|
|
97
|
+
export function check(input, options) {
|
|
98
|
+
const { identity, rules } = options;
|
|
99
|
+
const folded = fold(input);
|
|
100
|
+
const allowedWords = new Set(rules.allowedWords.map((w) => collapse(fold(w).text)));
|
|
101
|
+
const allowedPaths = [...DEFAULT_ALLOWED_PATHS, ...rules.allowedPaths].map(canonicalPath);
|
|
102
|
+
const allowedEmails = [...DEFAULT_ALLOWED_EMAILS, ...rules.allowedEmails].map(globPattern);
|
|
103
|
+
const spans = [];
|
|
104
|
+
// Text that's fine to post: an allowed email or path. A name or word inside it isn't a finding either.
|
|
105
|
+
const allowed = [];
|
|
106
|
+
for (const prefix of homePrefixes(identity)) {
|
|
107
|
+
const re = new RegExp(`(?<![\\w.-])${escapeRegExp(prefix)}(?![\\w.-])${PATH_TAIL}`, "gi");
|
|
108
|
+
for (const m of input.matchAll(re))
|
|
109
|
+
spans.push(pathSpan("home path", m));
|
|
110
|
+
}
|
|
111
|
+
const tilde = new RegExp(`(?<![\\w~/.$-])(?:~|\\$HOME|\\$\\{HOME\\}|%USERPROFILE%)([\\\\/]${PATH_TAIL})`, "g");
|
|
112
|
+
for (const m of input.matchAll(tilde)) {
|
|
113
|
+
const span = pathSpan("home path", m);
|
|
114
|
+
const path = canonicalPath("~" + input.slice(m.index + m[0].length - m[1].length, span.end));
|
|
115
|
+
if (path === "~" || isAllowedPath(path, allowedPaths))
|
|
116
|
+
allowed.push([span.start, span.end]);
|
|
117
|
+
else
|
|
118
|
+
spans.push(span);
|
|
119
|
+
}
|
|
120
|
+
const temps = [...TEMP_FOLDERS, ...identity.tempDirs.map(trimSlash).filter((d) => d.length > 1)];
|
|
121
|
+
for (const prefix of new Set(temps)) {
|
|
122
|
+
const re = new RegExp(`(?<![\\w.~/$%-])${escapeRegExp(prefix)}(?![\\w.-])${PATH_TAIL}`, "g");
|
|
123
|
+
for (const m of input.matchAll(re)) {
|
|
124
|
+
const span = pathSpan("temp folder", m);
|
|
125
|
+
if (isAllowedPath(canonicalPath(input.slice(span.start, span.end)), allowedPaths)) {
|
|
126
|
+
allowed.push([span.start, span.end]);
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
spans.push(span);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const m of input.matchAll(EMAIL)) {
|
|
133
|
+
const email = m[0].toLowerCase();
|
|
134
|
+
const span = { category: "email", start: m.index, end: m.index + m[0].length };
|
|
135
|
+
if (allowedEmails.some((re) => re.test(email)))
|
|
136
|
+
allowed.push([span.start, span.end]);
|
|
137
|
+
else
|
|
138
|
+
spans.push(span);
|
|
139
|
+
}
|
|
140
|
+
const names = [
|
|
141
|
+
["account name", identity.user],
|
|
142
|
+
["machine name", identity.host.split(".")[0] ?? ""],
|
|
143
|
+
];
|
|
144
|
+
// Only the user's own file can allow a name: naming it in a repository would post it.
|
|
145
|
+
const allowedNames = new Set(rules.allowedNames.map(nameKey));
|
|
146
|
+
for (const [category, name] of names) {
|
|
147
|
+
const key = nameKey(name);
|
|
148
|
+
if (name.length < 3 || GENERIC_NAMES.has(key) || allowedNames.has(key))
|
|
149
|
+
continue;
|
|
150
|
+
if (category === "machine name" && DEFAULT_MACHINE_NAMES.has(key))
|
|
151
|
+
continue;
|
|
152
|
+
const re = termPattern(name);
|
|
153
|
+
if (re)
|
|
154
|
+
spans.push(...foldedSpans(folded, re, category));
|
|
155
|
+
}
|
|
156
|
+
for (const [word, source] of MONEY) {
|
|
157
|
+
if (allowedWords.has(word))
|
|
158
|
+
continue;
|
|
159
|
+
spans.push(...foldedSpans(folded, wordPattern(source), "money", allowedWords, word));
|
|
160
|
+
}
|
|
161
|
+
if (!allowedWords.has("amounts"))
|
|
162
|
+
spans.push(...foldedSpans(folded, AMOUNT, "money", allowedWords, "amounts"));
|
|
163
|
+
// Private terms and the user's own addresses are never allowed by a repository, wherever they appear. They come
|
|
164
|
+
// first, so the user's address is reported as theirs rather than as an email outside the allowlist.
|
|
165
|
+
const shown = [...neverAllowed(folded, options), ...outsideAllowed(spans, allowed)];
|
|
166
|
+
return toFindings(input, outermost(shown));
|
|
167
|
+
}
|
|
168
|
+
// Only the private terms and the user's own addresses, for text whose other findings are the repository's own
|
|
169
|
+
// business, such as an exempt file's added lines. A term inside a path or an email is found here, where check
|
|
170
|
+
// reports the wider span.
|
|
171
|
+
export function checkTerms(input, options) {
|
|
172
|
+
return toFindings(input, outermost(neverAllowed(fold(input), options)));
|
|
173
|
+
}
|
|
174
|
+
function neverAllowed(folded, { identity, rules }) {
|
|
175
|
+
const terms = rules.terms.flatMap((term) => {
|
|
176
|
+
const re = termPattern(term);
|
|
177
|
+
return re ? foldedSpans(folded, re, "private term") : [];
|
|
178
|
+
});
|
|
179
|
+
return [...terms, ...ownEmailSpans(folded, identity.emails, rules.userAllowedEmails)];
|
|
180
|
+
}
|
|
181
|
+
// The user's own addresses, whatever a repository allows: a pattern there such as "*@*.co.uk" or "*@gmail.com"
|
|
182
|
+
// takes in theirs too. A noreply address is made to be posted. The user's own file allows one only by naming it
|
|
183
|
+
// exactly, since a pattern there is for other people's addresses.
|
|
184
|
+
function ownEmailSpans(folded, emails, userAllowed) {
|
|
185
|
+
const allowed = new Set(userAllowed.map((e) => fold(e).text));
|
|
186
|
+
const own = new Set(emails.map((e) => fold(e.trim()).text).filter((e) => !isNoreply(e) && !allowed.has(e)));
|
|
187
|
+
if (own.size === 0)
|
|
188
|
+
return [];
|
|
189
|
+
const spans = [];
|
|
190
|
+
for (const m of folded.text.matchAll(EMAIL)) {
|
|
191
|
+
if (!own.has(m[0]))
|
|
192
|
+
continue;
|
|
193
|
+
const [start, end] = originalSpan(folded, m.index, m.index + m[0].length);
|
|
194
|
+
spans.push({ category: "your email", start, end });
|
|
195
|
+
}
|
|
196
|
+
return spans;
|
|
197
|
+
}
|
|
198
|
+
const NOREPLY_DOMAINS = ["users.noreply.github.com", "users.noreply.gitlab.com"];
|
|
199
|
+
function isNoreply(email) {
|
|
200
|
+
const at = email.lastIndexOf("@");
|
|
201
|
+
const local = email.slice(0, at);
|
|
202
|
+
return local === "noreply" || local === "no-reply" || NOREPLY_DOMAINS.includes(email.slice(at + 1));
|
|
203
|
+
}
|
|
204
|
+
// An allowed path that takes in the whole home folder or a whole temp folder, which leaves that check nothing to do:
|
|
205
|
+
// "~" in any spelling, a temp folder's root, or a folder above one, such as "/var".
|
|
206
|
+
export function isBlanketPath(path) {
|
|
207
|
+
const allowed = [canonicalPath(path)];
|
|
208
|
+
return ["~", ...TEMP_FOLDERS].some((root) => isAllowedPath(root, allowed));
|
|
209
|
+
}
|
|
210
|
+
// An allowed email pattern with no fixed domain: "*@*", "someone@*", "*@*.com". The domain must be written out,
|
|
211
|
+
// or be "*." and then one with a dot in it, as in "*@*.example.org". A pattern with no "@" matches no email.
|
|
212
|
+
export function isBlanketEmail(glob) {
|
|
213
|
+
const at = glob.lastIndexOf("@");
|
|
214
|
+
if (at < 0)
|
|
215
|
+
return false;
|
|
216
|
+
const domain = glob.slice(at + 1);
|
|
217
|
+
return domain.includes("*") && !/^\*\.[^*.]+(?:\.[^*.]+)+$/.test(domain);
|
|
218
|
+
}
|
|
219
|
+
function homePrefixes(identity) {
|
|
220
|
+
const prefixes = new Set();
|
|
221
|
+
const home = trimSlash(identity.home);
|
|
222
|
+
if (home.length > 1) {
|
|
223
|
+
prefixes.add(home);
|
|
224
|
+
prefixes.add(home.replaceAll("\\", "/"));
|
|
225
|
+
}
|
|
226
|
+
if (identity.user) {
|
|
227
|
+
for (const p of ["/Users/", "/home/", "C:\\Users\\", "C:/Users/"])
|
|
228
|
+
prefixes.add(p + identity.user);
|
|
229
|
+
}
|
|
230
|
+
return prefixes;
|
|
231
|
+
}
|
|
232
|
+
function pathSpan(category, m) {
|
|
233
|
+
const text = m[0].replace(TRAILING_PUNCTUATION, "");
|
|
234
|
+
return { category, start: m.index, end: m.index + text.length };
|
|
235
|
+
}
|
|
236
|
+
function foldedSpans(folded, re, category, allowed, word) {
|
|
237
|
+
const spans = [];
|
|
238
|
+
for (const m of folded.text.matchAll(re)) {
|
|
239
|
+
if (m[0].length === 0)
|
|
240
|
+
continue;
|
|
241
|
+
if (allowed?.has(collapse(m[0])))
|
|
242
|
+
continue;
|
|
243
|
+
const [start, end] = originalSpan(folded, m.index, m.index + m[0].length);
|
|
244
|
+
spans.push(word === undefined ? { category, start, end } : { category, word, start, end });
|
|
245
|
+
}
|
|
246
|
+
return spans;
|
|
247
|
+
}
|
|
248
|
+
// Keeps a span only when no other span contains it, so a path isn't also reported for the account name in it.
|
|
249
|
+
function outermost(spans) {
|
|
250
|
+
const sorted = [...spans].sort((a, b) => a.start - b.start || b.end - a.end);
|
|
251
|
+
const kept = [];
|
|
252
|
+
let reach = -1;
|
|
253
|
+
for (const s of sorted) {
|
|
254
|
+
if (s.end <= reach)
|
|
255
|
+
continue;
|
|
256
|
+
kept.push(s);
|
|
257
|
+
reach = s.end;
|
|
258
|
+
}
|
|
259
|
+
return kept;
|
|
260
|
+
}
|
|
261
|
+
function outsideAllowed(spans, allowed) {
|
|
262
|
+
if (allowed.length === 0)
|
|
263
|
+
return spans;
|
|
264
|
+
const sortedAllowed = [...allowed].sort((a, b) => a[0] - b[0]);
|
|
265
|
+
const kept = [];
|
|
266
|
+
let next = 0;
|
|
267
|
+
let reach = -1;
|
|
268
|
+
for (const s of [...spans].sort((a, b) => a.start - b.start)) {
|
|
269
|
+
while (next < sortedAllowed.length && sortedAllowed[next][0] <= s.start) {
|
|
270
|
+
reach = Math.max(reach, sortedAllowed[next][1]);
|
|
271
|
+
next++;
|
|
272
|
+
}
|
|
273
|
+
if (s.end > reach)
|
|
274
|
+
kept.push(s);
|
|
275
|
+
}
|
|
276
|
+
return kept;
|
|
277
|
+
}
|
|
278
|
+
// Spans arrive sorted by start, so line and column are counted in one pass over the input.
|
|
279
|
+
function toFindings(input, spans) {
|
|
280
|
+
let i = 0;
|
|
281
|
+
let line = 1;
|
|
282
|
+
let column = 1;
|
|
283
|
+
return spans.map((s) => {
|
|
284
|
+
while (i < s.start) {
|
|
285
|
+
const code = input.codePointAt(i);
|
|
286
|
+
if (code === 0x0a) {
|
|
287
|
+
line++;
|
|
288
|
+
column = 1;
|
|
289
|
+
}
|
|
290
|
+
else
|
|
291
|
+
column++;
|
|
292
|
+
i += code > 0xffff ? 2 : 1;
|
|
293
|
+
}
|
|
294
|
+
const finding = {
|
|
295
|
+
category: s.category,
|
|
296
|
+
line,
|
|
297
|
+
column,
|
|
298
|
+
text: input.slice(s.start, s.end),
|
|
299
|
+
start: s.start,
|
|
300
|
+
end: s.end,
|
|
301
|
+
};
|
|
302
|
+
if (s.word !== undefined)
|
|
303
|
+
finding.word = s.word;
|
|
304
|
+
return finding;
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
function canonicalPath(path) {
|
|
308
|
+
const p = path
|
|
309
|
+
.replaceAll("\\", "/")
|
|
310
|
+
.replace(/^(?:\$HOME|\$\{HOME\}|%USERPROFILE%)(?=\/|$)/, "~");
|
|
311
|
+
return trimSlash(p);
|
|
312
|
+
}
|
|
313
|
+
function isAllowedPath(path, allowed) {
|
|
314
|
+
return allowed.some((a) => path === a || path.startsWith(a + "/"));
|
|
315
|
+
}
|
|
316
|
+
function globPattern(glob) {
|
|
317
|
+
const source = glob.toLowerCase().split("*").map(escapeRegExp).join("[^@\\s]*");
|
|
318
|
+
return new RegExp(`^${source}$`, "u");
|
|
319
|
+
}
|
|
320
|
+
function trimSlash(path) {
|
|
321
|
+
return path.length > 1 ? path.replace(/[\\/]+$/, "") : path;
|
|
322
|
+
}
|
|
323
|
+
function collapse(s) {
|
|
324
|
+
return s.trim().replace(/\s+/g, " ");
|
|
325
|
+
}
|