@open-tomato/gates 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 +202 -0
- package/NOTICE +10 -0
- package/README.md +114 -2
- package/package.json +61 -4
- package/src/control-byte-gate.ts +411 -0
- package/src/privacy-config.ts +106 -0
- package/src/privacy-gate.ts +234 -0
- package/src/privacy-rules.ts +209 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
@open-tomato/packages
|
|
2
|
+
Copyright 2026 Marcos Tomatti and the open-tomato contributors
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (see LICENSE).
|
|
5
|
+
|
|
6
|
+
The configs, rules and gates in this repository began in other
|
|
7
|
+
open-tomato repositories and are developed here since: the ESLint and
|
|
8
|
+
TypeScript configs and the two ESLint rules in rafa, the control-byte
|
|
9
|
+
gate in rafa, the privacy gate in components, and the pack and publish
|
|
10
|
+
scripts in service.
|
package/README.md
CHANGED
|
@@ -1,3 +1,115 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @open-tomato/gates
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Repository gates for the open-tomato projects. Each gate reads what git
|
|
4
|
+
tracks, reports by file and line, and exits with one of three codes:
|
|
5
|
+
|
|
6
|
+
| Exit | Meaning |
|
|
7
|
+
| --- | --- |
|
|
8
|
+
| `0` | clean |
|
|
9
|
+
| `1` | findings |
|
|
10
|
+
| `2` | the gate could not run |
|
|
11
|
+
|
|
12
|
+
A gate that cannot run never reports a pass. Both gates are TypeScript run
|
|
13
|
+
by bun, so they need bun 1.3 or newer.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add -d @open-tomato/gates
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## `ot-control-byte-gate`
|
|
20
|
+
|
|
21
|
+
Refuses raw control bytes and invisible characters in tracked text files.
|
|
22
|
+
A raw NUL makes git treat a file as binary, so its diffs stop rendering, and
|
|
23
|
+
makes grep miss text that is there. The gate also refuses the Trojan Source
|
|
24
|
+
BiDi overrides and the zero-width characters, the same set as
|
|
25
|
+
`@open-tomato/no-unsafe-unicode` in
|
|
26
|
+
[`@open-tomato/eslint-plugin`](../eslint-plugin/README.md). The gate covers
|
|
27
|
+
files ESLint never reads, such as YAML, shell scripts and lock files.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bunx ot-control-byte-gate # every tracked file
|
|
31
|
+
bunx ot-control-byte-gate --staged # the index, for a pre-commit hook
|
|
32
|
+
bunx ot-control-byte-gate --include-untracked # also files not yet added
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## `ot-privacy-gate`
|
|
36
|
+
|
|
37
|
+
Refuses personal and private data in what a public repository tracks:
|
|
38
|
+
|
|
39
|
+
- **Banned terms** from the project's own list, stored as sha256 hashes so
|
|
40
|
+
no file spells them: machine names, private domains, private
|
|
41
|
+
repositories, home paths.
|
|
42
|
+
- **Shapes** that need no list: a home directory with its account name, a
|
|
43
|
+
private or public IPv4 address, a LAN host name, a credential prefix, an
|
|
44
|
+
email outside the reserved example domains.
|
|
45
|
+
- **Sample data** in stories, fixtures, mocks and tests: a URL, a phone
|
|
46
|
+
number or a profile link must use a reserved example host or the
|
|
47
|
+
fictional 555-01xx range.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
bunx ot-privacy-gate # tracked files and untracked ones git would track
|
|
51
|
+
bunx ot-privacy-gate --staged # the index, for a pre-commit hook
|
|
52
|
+
bunx ot-privacy-gate --history # every blob and commit identity, before a first push
|
|
53
|
+
bunx ot-privacy-gate --root ../other-repo # a repository other than the current one
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Each mode reads what it judges from where a reviewer would see it:
|
|
57
|
+
|
|
58
|
+
- `--staged` reads the files and `.privacy-gate.json` from the index, so
|
|
59
|
+
an allowance edited locally but never staged cannot pass a commit.
|
|
60
|
+
- `--history` judges a blob under every path it was committed at,
|
|
61
|
+
because the sample rules and the allow list both depend on the path.
|
|
62
|
+
- An unknown flag, `--root` with no folder, or two modes at once stop the
|
|
63
|
+
gate with exit 2, so a typo never runs a different scan.
|
|
64
|
+
|
|
65
|
+
### The project's list
|
|
66
|
+
|
|
67
|
+
The package ships no banned terms. Each project keeps its own in
|
|
68
|
+
`.privacy-gate.json` at the repository root:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"banned": [
|
|
73
|
+
{ "sha256": "<sha256 of the lowercase term>", "label": "a private machine name" }
|
|
74
|
+
],
|
|
75
|
+
"allow": [
|
|
76
|
+
{ "path": "package.json", "rule": "email", "sha256": "<sha256 of the evidence>", "why": "The author field." }
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A label names the term without spelling it. Print a term's hash with:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
bun -e 'console.log(new Bun.CryptoHasher("sha256").update(process.argv[1].toLowerCase()).digest("hex"))' '<term>'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
An `allow` entry is a reviewed exception: the path glob, the rule, the
|
|
88
|
+
sha256 of the exact text the gate reported, and why it is meant. Without a
|
|
89
|
+
config file, the gate runs the shape rules alone and says so in its report.
|
|
90
|
+
A malformed config stops the gate with exit 2, and so does a key other than
|
|
91
|
+
`banned`, `allow` and `$comment`, or an `allow` rule the gate does not have:
|
|
92
|
+
a typo there would otherwise empty a list without a word.
|
|
93
|
+
|
|
94
|
+
## A pre-commit hook
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
#!/usr/bin/env bash
|
|
98
|
+
set -euo pipefail
|
|
99
|
+
bunx ot-control-byte-gate --staged
|
|
100
|
+
bunx ot-privacy-gate --staged
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Save it as `.githooks/pre-commit`, make it executable, and turn it on once
|
|
104
|
+
per clone with `git config core.hooksPath .githooks`.
|
|
105
|
+
|
|
106
|
+
## Exports
|
|
107
|
+
|
|
108
|
+
The gates are importable too, for a project that wraps them:
|
|
109
|
+
|
|
110
|
+
| Path | What it holds |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `./control-bytes` | `scanText`, `scanBuffer`, `scanFiles`, `listFiles` |
|
|
113
|
+
| `./privacy` | `judge`, `readSources`, `readConfig`, `parseArgs`, `run` |
|
|
114
|
+
| `./privacy/rules` | `scan`, `bannedIn`, `isSamplePath`, `sha256`, `RULE_NAMES` |
|
|
115
|
+
| `./privacy/config` | `readPrivacyConfig`, `parsePrivacyConfig`, `CONFIG_FILE`, `NO_CONFIG` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-tomato/gates",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Repository gates of the open-tomato projects: a control-byte gate and a privacy gate, run in CI and as pre-commit hooks.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Marcos Tomatti",
|
|
7
|
+
"openTomato": {
|
|
8
|
+
"team": "@open-tomato/maintainers"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/open-tomato/packages.git",
|
|
13
|
+
"directory": "packages/gates"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/open-tomato/packages/tree/main/packages/gates#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/open-tomato/packages/issues"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"gate",
|
|
21
|
+
"privacy",
|
|
22
|
+
"pre-commit",
|
|
23
|
+
"trojan-source",
|
|
24
|
+
"control-characters"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"ot-control-byte-gate": "./src/control-byte-gate.ts",
|
|
29
|
+
"ot-privacy-gate": "./src/privacy-gate.ts"
|
|
30
|
+
},
|
|
31
|
+
"exports": {
|
|
32
|
+
"./control-bytes": "./src/control-byte-gate.ts",
|
|
33
|
+
"./privacy": "./src/privacy-gate.ts",
|
|
34
|
+
"./privacy/rules": "./src/privacy-rules.ts",
|
|
35
|
+
"./privacy/config": "./src/privacy-config.ts",
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"src",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"NOTICE"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"registry": "https://registry.npmjs.org/"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"bun": ">=1.3.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"lint": "eslint .",
|
|
52
|
+
"check-types": "tsc --noEmit",
|
|
53
|
+
"test": "bun test"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@open-tomato/eslint-config": "^0.1.1",
|
|
57
|
+
"@open-tomato/eslint-plugin": "^0.1.1",
|
|
58
|
+
"@open-tomato/typescript-config": "^0.1.1",
|
|
59
|
+
"@types/bun": "^1.3.0",
|
|
60
|
+
"eslint": "^9.39.5",
|
|
61
|
+
"typescript": "^5.9.3"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Control-byte gate:
|
|
4
|
+
* A raw C0 control byte in a tracked text file breaks two tools at once,
|
|
5
|
+
* and both failures are silent false negatives:
|
|
6
|
+
*
|
|
7
|
+
* - git classifies the blob as binary, so every diff renders as
|
|
8
|
+
* `Bin N -> M bytes` and the file can never be reviewed again.
|
|
9
|
+
* - POSIX grep reports NO MATCH for strings that are present in the
|
|
10
|
+
* file, so any absence check built on grep passes vacuously.
|
|
11
|
+
*
|
|
12
|
+
* Source must spell control characters as language escapes (the
|
|
13
|
+
* six-character backslash-u form), never as the literal byte. Runtime
|
|
14
|
+
* behaviour is identical; tool visibility is not.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* ot-control-byte-gate # all tracked files
|
|
18
|
+
* ot-control-byte-gate --staged # staged files only
|
|
19
|
+
* ot-control-byte-gate --include-untracked # + files not yet git-added
|
|
20
|
+
* ot-control-byte-gate --root <repo-root>
|
|
21
|
+
*
|
|
22
|
+
* Exit codes: 0 clean, 1 findings, 2 the gate could not run.
|
|
23
|
+
* 2 is distinct from 0 on purpose — a gate that cannot run must never be
|
|
24
|
+
* mistaken for a gate that passed.
|
|
25
|
+
*/
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
27
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The NUL character and the escape spelling this gate recommends, built
|
|
32
|
+
* from code points rather than written as escapes. A backslash-u escape
|
|
33
|
+
* with four hex digits is valid JSON, so content carrying one through a
|
|
34
|
+
* JSON-decoding write path (an agent tool call) arrives with the escape
|
|
35
|
+
* already collapsed to the real byte — the exact accident this gate
|
|
36
|
+
* exists to catch. Built this way, this file can never seed it.
|
|
37
|
+
*/
|
|
38
|
+
const NUL = String.fromCharCode(0);
|
|
39
|
+
const BACKSLASH_U = String.fromCharCode(92) + 'u';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Bytes that must never appear raw in a tracked text file: every C0
|
|
43
|
+
* control character except TAB/LF/CR, plus DEL. NUL (0x00) is the one
|
|
44
|
+
* git and grep key their binary classification on; ESC (0x1b) does not
|
|
45
|
+
* trip the classifier but makes a file unreadable and unmatchable by
|
|
46
|
+
* edit tools.
|
|
47
|
+
*/
|
|
48
|
+
export const FORBIDDEN_BYTES: ReadonlySet<number> = new Set([
|
|
49
|
+
...Array.from({ length: 0x20 }, (_, b) => b).filter(
|
|
50
|
+
(b) => b !== 0x09 && b !== 0x0a && b !== 0x0d,
|
|
51
|
+
),
|
|
52
|
+
0x7f,
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Codepoints that are invisible or misleading rather than merely control
|
|
57
|
+
* bytes. These encode as bytes >= 0x80, so the byte scan above cannot
|
|
58
|
+
* see them — a Trojan Source payload (CVE-2021-42574) is entirely in
|
|
59
|
+
* this set.
|
|
60
|
+
*
|
|
61
|
+
* Kept deliberately in sync with `@open-tomato/no-unsafe-unicode` in
|
|
62
|
+
* `@open-tomato/eslint-plugin`; the gate's test suite asserts the two
|
|
63
|
+
* agree. The duplication is on
|
|
64
|
+
* purpose: this gate imports nothing, so it runs on a bare checkout with
|
|
65
|
+
* no install.
|
|
66
|
+
*/
|
|
67
|
+
export const FORBIDDEN_CODEPOINTS: ReadonlySet<number> = new Set([
|
|
68
|
+
// Bidirectional overrides and isolates — Trojan Source.
|
|
69
|
+
0x202a, 0x202b, 0x202c, 0x202d, 0x202e,
|
|
70
|
+
0x2066, 0x2067, 0x2068, 0x2069,
|
|
71
|
+
// Invisible / default-ignorable.
|
|
72
|
+
0x00ad, 0x200b, 0x200e, 0x200f, 0x2060, 0xfeff,
|
|
73
|
+
// Parser-splitting separators.
|
|
74
|
+
0x2028, 0x2029,
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/** Legitimate between two emoji, suspicious anywhere else. */
|
|
78
|
+
export const EMOJI_JOINERS: ReadonlySet<number> = new Set([0x200c, 0x200d]);
|
|
79
|
+
|
|
80
|
+
const EMOJI_ADJACENT =
|
|
81
|
+
/[\p{Extended_Pictographic}\p{Emoji_Modifier}\u{FE0F}]/u;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extensions whose files are legitimately binary. This is an ALLOWLIST,
|
|
85
|
+
* and it is the gate's only escape hatch: anything not listed here is
|
|
86
|
+
* scanned. The inverse design — skipping unknown extensions — is what
|
|
87
|
+
* lets a control byte slip through unnoticed, so do not invert it.
|
|
88
|
+
*/
|
|
89
|
+
export const BINARY_EXTENSIONS: ReadonlySet<string> = new Set([
|
|
90
|
+
'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico', 'icns', 'bmp', 'tiff',
|
|
91
|
+
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
|
92
|
+
'pdf', 'zip', 'gz', 'tgz', 'br', 'xz', '7z',
|
|
93
|
+
'mp3', 'mp4', 'webm', 'wav', 'mov', 'ogg',
|
|
94
|
+
'wasm', 'bin', 'lockb', 'node', 'dylib', 'so', 'class', 'jar',
|
|
95
|
+
'p12', 'der', 'keystore', 'jks',
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
/** Explicit per-path exemptions. Keep empty unless a file truly cannot comply. */
|
|
99
|
+
export const ALLOWLISTED_PATHS: ReadonlySet<string> = new Set<string>([]);
|
|
100
|
+
|
|
101
|
+
/** Per-file reporting cap: a truly binary file would otherwise flood the log. */
|
|
102
|
+
export const MAX_REPORTED_PER_FILE = 10;
|
|
103
|
+
|
|
104
|
+
export interface Finding {
|
|
105
|
+
file: string;
|
|
106
|
+
line: number;
|
|
107
|
+
column: number;
|
|
108
|
+
byte: number;
|
|
109
|
+
snippet: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isScannable(relPath: string): boolean {
|
|
113
|
+
if (ALLOWLISTED_PATHS.has(relPath)) return false;
|
|
114
|
+
const base = relPath.slice(relPath.lastIndexOf('/') + 1);
|
|
115
|
+
const dot = base.lastIndexOf('.');
|
|
116
|
+
if (dot <= 0) return true; // extensionless and dotfiles are scanned
|
|
117
|
+
return !BINARY_EXTENSIONS.has(base.slice(dot + 1).toLowerCase());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Render a line for display with every forbidden byte replaced by
|
|
122
|
+
* `<0xNN>`, so the gate's own output can never carry the byte into a
|
|
123
|
+
* terminal or CI log. Segments between control bytes are decoded as
|
|
124
|
+
* UTF-8 so multi-byte characters survive intact.
|
|
125
|
+
*/
|
|
126
|
+
export function renderSafe(buf: Buffer): string {
|
|
127
|
+
const parts: string[] = [];
|
|
128
|
+
let start = 0;
|
|
129
|
+
for (let i = 0; i < buf.length; i++) {
|
|
130
|
+
if (!FORBIDDEN_BYTES.has(buf[i]!)) continue;
|
|
131
|
+
parts.push(buf.subarray(start, i).toString('utf8'));
|
|
132
|
+
parts.push(`<0x${buf[i]!.toString(16).padStart(2, '0')}>`);
|
|
133
|
+
start = i + 1;
|
|
134
|
+
}
|
|
135
|
+
parts.push(buf.subarray(start).toString('utf8'));
|
|
136
|
+
return parts.join('').trim()
|
|
137
|
+
.slice(0, 160);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Every forbidden CODEPOINT in the decoded text, with 1-based
|
|
142
|
+
* line/column. Runs alongside the byte scan: bytes catch NUL/ESC,
|
|
143
|
+
* codepoints catch BiDi and the invisible set.
|
|
144
|
+
*/
|
|
145
|
+
export function scanText(file: string, text: string): Finding[] {
|
|
146
|
+
const findings: Finding[] = [];
|
|
147
|
+
let line = 1;
|
|
148
|
+
let lineStart = 0;
|
|
149
|
+
for (let i = 0; i < text.length; i++) {
|
|
150
|
+
const ch = text[i]!;
|
|
151
|
+
if (ch === '\n') {
|
|
152
|
+
line += 1;
|
|
153
|
+
lineStart = i + 1;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const code = text.codePointAt(i)!;
|
|
157
|
+
if (code === 0xfeff && i === 0) continue; // leading BOM is an encoding artifact
|
|
158
|
+
|
|
159
|
+
if (EMOJI_JOINERS.has(code)) {
|
|
160
|
+
const prev = codePointBefore(text, i);
|
|
161
|
+
const next = i + 1 < text.length
|
|
162
|
+
? text.codePointAt(i + 1)!
|
|
163
|
+
: -1;
|
|
164
|
+
const ok =
|
|
165
|
+
prev >= 0 && next >= 0 &&
|
|
166
|
+
EMOJI_ADJACENT.test(String.fromCodePoint(prev)) &&
|
|
167
|
+
EMOJI_ADJACENT.test(String.fromCodePoint(next));
|
|
168
|
+
if (ok) continue;
|
|
169
|
+
} else if (!FORBIDDEN_CODEPOINTS.has(code)) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let lineEnd = text.indexOf('\n', i);
|
|
174
|
+
if (lineEnd === -1) lineEnd = text.length;
|
|
175
|
+
findings.push({
|
|
176
|
+
file,
|
|
177
|
+
line,
|
|
178
|
+
column: i - lineStart + 1,
|
|
179
|
+
byte: code,
|
|
180
|
+
snippet: renderSafeText(text.slice(lineStart, lineEnd)),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return findings;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Code point ending at `i`, walking back over a surrogate pair. Reading
|
|
188
|
+
* `text[i - 1]` yields a lone low surrogate for any astral emoji, which
|
|
189
|
+
* never matches a Unicode property escape — that reads every real emoji
|
|
190
|
+
* ZWJ sequence as a violation.
|
|
191
|
+
*/
|
|
192
|
+
function codePointBefore(text: string, i: number): number {
|
|
193
|
+
if (i <= 0) return -1;
|
|
194
|
+
const lo = text.charCodeAt(i - 1);
|
|
195
|
+
if (lo >= 0xdc00 && lo <= 0xdfff && i >= 2) {
|
|
196
|
+
const hi = text.charCodeAt(i - 2);
|
|
197
|
+
if (hi >= 0xd800 && hi <= 0xdbff) {
|
|
198
|
+
return (hi - 0xd800) * 0x400 + (lo - 0xdc00) + 0x10000;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return lo;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Like `renderSafe`, for text: replaces flagged codepoints with `<U+XXXX>`. */
|
|
205
|
+
export function renderSafeText(text: string): string {
|
|
206
|
+
let out = '';
|
|
207
|
+
for (let i = 0; i < text.length; i++) {
|
|
208
|
+
const code = text.codePointAt(i)!;
|
|
209
|
+
const flagged =
|
|
210
|
+
FORBIDDEN_CODEPOINTS.has(code) ||
|
|
211
|
+
EMOJI_JOINERS.has(code) ||
|
|
212
|
+
FORBIDDEN_BYTES.has(code);
|
|
213
|
+
out += flagged
|
|
214
|
+
? `<U+${code.toString(16).toUpperCase()
|
|
215
|
+
.padStart(4, '0')}>`
|
|
216
|
+
: text[i];
|
|
217
|
+
}
|
|
218
|
+
return out.trim().slice(0, 160);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Every forbidden byte in `buf`, with 1-based line/column. */
|
|
222
|
+
export function scanBuffer(file: string, buf: Buffer): Finding[] {
|
|
223
|
+
const findings: Finding[] = [];
|
|
224
|
+
let line = 1;
|
|
225
|
+
let lineStart = 0;
|
|
226
|
+
for (let i = 0; i < buf.length; i++) {
|
|
227
|
+
const byte = buf[i]!;
|
|
228
|
+
if (byte === 0x0a) {
|
|
229
|
+
line += 1;
|
|
230
|
+
lineStart = i + 1;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!FORBIDDEN_BYTES.has(byte)) continue;
|
|
234
|
+
let lineEnd = buf.indexOf(0x0a, i);
|
|
235
|
+
if (lineEnd === -1) lineEnd = buf.length;
|
|
236
|
+
findings.push({
|
|
237
|
+
file,
|
|
238
|
+
line,
|
|
239
|
+
column: i - lineStart + 1,
|
|
240
|
+
byte,
|
|
241
|
+
snippet: renderSafe(buf.subarray(lineStart, lineEnd)),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return findings;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Paths git knows about, NUL-delimited so filenames with newlines cannot
|
|
249
|
+
* split a record. Throws when git cannot answer — callers must exit 2,
|
|
250
|
+
* not 0.
|
|
251
|
+
*/
|
|
252
|
+
export function listFiles(
|
|
253
|
+
root: string,
|
|
254
|
+
staged: boolean,
|
|
255
|
+
includeUntracked = false,
|
|
256
|
+
): string[] {
|
|
257
|
+
const run = (args: string[]): string[] => execFileSync('git', args, { cwd: root, maxBuffer: 64 * 1024 * 1024 })
|
|
258
|
+
.toString('utf8')
|
|
259
|
+
.split(NUL)
|
|
260
|
+
.filter(Boolean);
|
|
261
|
+
|
|
262
|
+
if (staged) {
|
|
263
|
+
return run(['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR']);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const tracked = run(['ls-files', '-z']);
|
|
267
|
+
if (!includeUntracked) return tracked;
|
|
268
|
+
|
|
269
|
+
// New skill/agent/context files are usually written before they are
|
|
270
|
+
// added. `--exclude-standard` keeps .gitignore honoured, so
|
|
271
|
+
// node_modules and build output stay out.
|
|
272
|
+
const untracked = run(['ls-files', '--others', '--exclude-standard', '-z']);
|
|
273
|
+
return [...new Set([...tracked, ...untracked])];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Worktree content, or null when the path is not a readable regular file. */
|
|
277
|
+
export function readWorktree(root: string, rel: string): Buffer | null {
|
|
278
|
+
const full = join(root, rel);
|
|
279
|
+
try {
|
|
280
|
+
if (!statSync(full).isFile()) return null; // submodule / gone
|
|
281
|
+
} catch {
|
|
282
|
+
return null; // staged deletion, or a path removed from the worktree
|
|
283
|
+
}
|
|
284
|
+
return readFileSync(full);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Content of the STAGED (index) version. The pre-commit hook must judge
|
|
289
|
+
* what the commit will actually contain: a byte staged and then cleaned
|
|
290
|
+
* up in the worktree would otherwise sail through.
|
|
291
|
+
*/
|
|
292
|
+
export function readStaged(root: string, rel: string): Buffer | null {
|
|
293
|
+
try {
|
|
294
|
+
return execFileSync('git', ['cat-file', 'blob', `:${rel}`], {
|
|
295
|
+
cwd: root,
|
|
296
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
297
|
+
});
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function scanFiles(
|
|
304
|
+
root: string,
|
|
305
|
+
relPaths: string[],
|
|
306
|
+
read: (rel: string) => Buffer | null = (rel) => readWorktree(root, rel),
|
|
307
|
+
): Finding[] {
|
|
308
|
+
const findings: Finding[] = [];
|
|
309
|
+
for (const rel of relPaths) {
|
|
310
|
+
if (!isScannable(rel)) continue;
|
|
311
|
+
const content = read(rel);
|
|
312
|
+
if (content === null) continue;
|
|
313
|
+
findings.push(...scanBuffer(rel, content));
|
|
314
|
+
// Byte scan first (NUL/ESC), then the decoded scan (BiDi/invisible).
|
|
315
|
+
// A file that is not valid UTF-8 still gets the byte scan, which is
|
|
316
|
+
// the one that matters for git's binary classification.
|
|
317
|
+
findings.push(...scanText(rel, content.toString('utf8')));
|
|
318
|
+
}
|
|
319
|
+
return findings;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (import.meta.main) {
|
|
323
|
+
const argv = process.argv.slice(2);
|
|
324
|
+
const staged = argv.includes('--staged');
|
|
325
|
+
const includeUntracked = argv.includes('--include-untracked');
|
|
326
|
+
const rootFlag = argv.indexOf('--root');
|
|
327
|
+
const root = rootFlag > -1
|
|
328
|
+
? argv[rootFlag + 1]!
|
|
329
|
+
: process.cwd();
|
|
330
|
+
|
|
331
|
+
let files: string[] = [];
|
|
332
|
+
try {
|
|
333
|
+
files = listFiles(root, staged, includeUntracked);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
const message = err instanceof Error
|
|
336
|
+
? err.message
|
|
337
|
+
: String(err);
|
|
338
|
+
console.error(`[control-byte-gate] FAIL — git could not list files: ${message}`);
|
|
339
|
+
process.exit(2);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const scannable = files.filter(isScannable);
|
|
343
|
+
|
|
344
|
+
// Vacuity guard: in full-repo mode an empty file list means the gate
|
|
345
|
+
// was pointed somewhere wrong, not that the repo is clean. Reporting
|
|
346
|
+
// OK here would be exactly the false all-clear this gate exists to
|
|
347
|
+
// prevent.
|
|
348
|
+
if (!staged && scannable.length === 0) {
|
|
349
|
+
console.error(
|
|
350
|
+
'[control-byte-gate] FAIL — 0 scannable files found. Refusing to report' +
|
|
351
|
+
' a pass; check --root points at the repository.',
|
|
352
|
+
);
|
|
353
|
+
process.exit(2);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Say plainly when there was nothing to look at. "OK - 0 files
|
|
357
|
+
// scanned" reads like a verification that happened; it is the absence
|
|
358
|
+
// of one.
|
|
359
|
+
if (staged && scannable.length === 0) {
|
|
360
|
+
console.log('[control-byte-gate] nothing staged to scan.');
|
|
361
|
+
process.exit(0);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const findings = scanFiles(
|
|
365
|
+
root,
|
|
366
|
+
scannable,
|
|
367
|
+
staged
|
|
368
|
+
? (rel) => readStaged(root, rel)
|
|
369
|
+
: undefined,
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
if (findings.length === 0) {
|
|
373
|
+
console.log(
|
|
374
|
+
`[control-byte-gate] OK — ${scannable.length} file(s) scanned, no unsafe characters.`,
|
|
375
|
+
);
|
|
376
|
+
process.exit(0);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
console.error(
|
|
380
|
+
`[control-byte-gate] FAIL — ${findings.length} unsafe character(s) in ` +
|
|
381
|
+
`${new Set(findings.map((f) => f.file)).size} file(s):`,
|
|
382
|
+
);
|
|
383
|
+
const byFile = new Map<string, Finding[]>();
|
|
384
|
+
for (const f of findings) {
|
|
385
|
+
const list = byFile.get(f.file) ?? [];
|
|
386
|
+
list.push(f);
|
|
387
|
+
byFile.set(f.file, list);
|
|
388
|
+
}
|
|
389
|
+
for (const [file, list] of byFile) {
|
|
390
|
+
for (const f of list.slice(0, MAX_REPORTED_PER_FILE)) {
|
|
391
|
+
const hexByte = `0x${f.byte.toString(16).padStart(2, '0')}`;
|
|
392
|
+
console.error(` ${file}:${f.line}:${f.column} ${hexByte}`);
|
|
393
|
+
console.error(` ${f.snippet}`);
|
|
394
|
+
}
|
|
395
|
+
if (list.length > MAX_REPORTED_PER_FILE) {
|
|
396
|
+
console.error(
|
|
397
|
+
` ${file}: ... and ${list.length - MAX_REPORTED_PER_FILE} more in this file`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
console.error(
|
|
402
|
+
`\n Write the character as a language escape instead (e.g. ${BACKSLASH_U}0000).`,
|
|
403
|
+
);
|
|
404
|
+
console.error(
|
|
405
|
+
' Runtime behaviour is unchanged; a literal byte makes git render the',
|
|
406
|
+
);
|
|
407
|
+
console.error(
|
|
408
|
+
' file as `Bin` and makes grep report no match for text that is present.',
|
|
409
|
+
);
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's privacy-gate config: `.privacy-gate.json` at the
|
|
3
|
+
* repository root. It holds what only the project knows:
|
|
4
|
+
*
|
|
5
|
+
* {
|
|
6
|
+
* "banned": [{ "sha256": "<hash of the lowercase term>", "label": "a private machine name" }],
|
|
7
|
+
* "allow": [{ "path": "package.json", "rule": "email", "sha256": "<hash of the evidence>", "why": "..." }]
|
|
8
|
+
* }
|
|
9
|
+
*
|
|
10
|
+
* Both lists store hashes, never values, so the config can sit in a
|
|
11
|
+
* public repository. A project with no file gets the shape rules alone,
|
|
12
|
+
* and the gate says so in its report.
|
|
13
|
+
*
|
|
14
|
+
* A malformed file throws: a gate that silently dropped a broken list
|
|
15
|
+
* would pass the very terms the list exists to stop.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { type BannedTerms, RULE_NAMES, type RuleName } from './privacy-rules';
|
|
22
|
+
|
|
23
|
+
/** The config file name, at the repository root. */
|
|
24
|
+
export const CONFIG_FILE = '.privacy-gate.json';
|
|
25
|
+
|
|
26
|
+
/** The top-level keys a config may hold; any other is a typo that would empty a list. */
|
|
27
|
+
const KNOWN_KEYS: ReadonlySet<string> = new Set(['$comment', 'banned', 'allow']);
|
|
28
|
+
|
|
29
|
+
/** A sha256 in lowercase hex. */
|
|
30
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
31
|
+
|
|
32
|
+
/** One reviewed exception: a path glob, a rule, and the sha256 of the exact evidence. */
|
|
33
|
+
export interface Allowance {
|
|
34
|
+
readonly path: string;
|
|
35
|
+
readonly rule: RuleName;
|
|
36
|
+
readonly sha256: string;
|
|
37
|
+
readonly why: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The parsed config. */
|
|
41
|
+
export interface PrivacyConfig {
|
|
42
|
+
/** Whether the project has a config file at all. */
|
|
43
|
+
readonly found: boolean;
|
|
44
|
+
readonly banned: BannedTerms;
|
|
45
|
+
readonly allow: readonly Allowance[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether `value` is a non-empty string. */
|
|
49
|
+
function isText(value: unknown): value is string {
|
|
50
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether `value` names one of the gate's rules. */
|
|
54
|
+
function isRuleName(value: unknown): value is RuleName {
|
|
55
|
+
return (RULE_NAMES as readonly unknown[]).includes(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One banned entry, checked, as a map pair. */
|
|
59
|
+
function parseBanned(entry: unknown, index: number): [string, string] {
|
|
60
|
+
const { sha256, label } = (entry ?? {}) as Record<string, unknown>;
|
|
61
|
+
if (!isText(sha256) || !SHA256.test(sha256)) throw new Error(`${CONFIG_FILE}: banned[${index}].sha256 must be a lowercase sha256`);
|
|
62
|
+
if (!isText(label)) throw new Error(`${CONFIG_FILE}: banned[${index}].label must name the term without spelling it`);
|
|
63
|
+
return [sha256, label];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One allow entry, checked. */
|
|
67
|
+
function parseAllowance(entry: unknown, index: number): Allowance {
|
|
68
|
+
const { path, rule, sha256, why } = (entry ?? {}) as Record<string, unknown>;
|
|
69
|
+
if (!isText(path)) throw new Error(`${CONFIG_FILE}: allow[${index}].path must be a glob`);
|
|
70
|
+
if (!isRuleName(rule)) throw new Error(`${CONFIG_FILE}: allow[${index}].rule must be one of ${RULE_NAMES.join(', ')}`);
|
|
71
|
+
if (!isText(sha256) || !SHA256.test(sha256)) throw new Error(`${CONFIG_FILE}: allow[${index}].sha256 must be a lowercase sha256`);
|
|
72
|
+
if (!isText(why)) throw new Error(`${CONFIG_FILE}: allow[${index}].why must say why the value is meant`);
|
|
73
|
+
return { path, rule, sha256, why };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A list field of the config, or an empty list when it is absent. */
|
|
77
|
+
function listField(config: Record<string, unknown>, field: string): unknown[] {
|
|
78
|
+
const value = config[field];
|
|
79
|
+
if (value === undefined) return [];
|
|
80
|
+
if (!Array.isArray(value)) throw new Error(`${CONFIG_FILE}: ${field} must be a list`);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Parses the text of a config file. */
|
|
85
|
+
export function parsePrivacyConfig(text: string): PrivacyConfig {
|
|
86
|
+
const parsed = JSON.parse(text) as unknown;
|
|
87
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${CONFIG_FILE}: must hold a JSON object`);
|
|
88
|
+
const config = parsed as Record<string, unknown>;
|
|
89
|
+
const unknown = Object.keys(config).filter((key) => !KNOWN_KEYS.has(key));
|
|
90
|
+
if (unknown.length > 0) throw new Error(`${CONFIG_FILE}: unknown key ${unknown.join(', ')}; it holds only banned, allow and $comment`);
|
|
91
|
+
return {
|
|
92
|
+
found: true,
|
|
93
|
+
banned: new Map(listField(config, 'banned').map(parseBanned)),
|
|
94
|
+
allow: listField(config, 'allow').map(parseAllowance),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The config of a project with no config file. */
|
|
99
|
+
export const NO_CONFIG: PrivacyConfig = { found: false, banned: new Map(), allow: [] };
|
|
100
|
+
|
|
101
|
+
/** Reads `.privacy-gate.json` from `root`, or an empty config when there is none. */
|
|
102
|
+
export function readPrivacyConfig(root: string): PrivacyConfig {
|
|
103
|
+
const path = join(root, CONFIG_FILE);
|
|
104
|
+
if (!existsSync(path)) return NO_CONFIG;
|
|
105
|
+
return parsePrivacyConfig(readFileSync(path, 'utf8'));
|
|
106
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* The privacy gate: refuses personal and private data in what a public
|
|
4
|
+
* repository tracks. The rules live in `./privacy-rules.ts`, the
|
|
5
|
+
* project's banned terms and reviewed exceptions in its
|
|
6
|
+
* `.privacy-gate.json` (`./privacy-config.ts`); this file picks what to
|
|
7
|
+
* read and reports.
|
|
8
|
+
*
|
|
9
|
+
* ot-privacy-gate tracked and untracked-not-ignored files
|
|
10
|
+
* ot-privacy-gate --staged the index blobs (the pre-commit hook)
|
|
11
|
+
* ot-privacy-gate --history every blob and commit identity in every ref
|
|
12
|
+
* ot-privacy-gate --root <dir> a repository other than the current one
|
|
13
|
+
*
|
|
14
|
+
* `--staged` judges the index, not the worktree, so a value staged and
|
|
15
|
+
* then cleaned up locally still blocks; it reads the config from the
|
|
16
|
+
* index too, so an unstaged allowance cannot pass a commit. `--history`
|
|
17
|
+
* is the check before a first push: a value removed by a later commit is
|
|
18
|
+
* still published by the earlier one, and a blob is judged under every
|
|
19
|
+
* path it was committed at.
|
|
20
|
+
*
|
|
21
|
+
* A finding that is meant (the author's address in `package.json`, say)
|
|
22
|
+
* goes in the config's `allow` list with its path glob, rule and the
|
|
23
|
+
* sha256 of its evidence, so every exception is written down and reviewed
|
|
24
|
+
* in a diff without the config spelling the value.
|
|
25
|
+
*
|
|
26
|
+
* Exit codes: 0 clean, 1 findings, 2 the gate could not run. A gate that
|
|
27
|
+
* cannot run must never read as a gate that passed.
|
|
28
|
+
*/
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
|
|
31
|
+
import { type Allowance, CONFIG_FILE, NO_CONFIG, parsePrivacyConfig, type PrivacyConfig, readPrivacyConfig } from './privacy-config';
|
|
32
|
+
import { type Finding, scan, sha256 } from './privacy-rules';
|
|
33
|
+
|
|
34
|
+
/** A file to judge: where it is named from, and its text. */
|
|
35
|
+
export interface Source {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly path: string;
|
|
38
|
+
readonly text: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The modes the gate reads sources in. */
|
|
42
|
+
export type Mode = 'tree' | 'staged' | 'history';
|
|
43
|
+
|
|
44
|
+
/** What the command line asks for. */
|
|
45
|
+
export interface GateArgs {
|
|
46
|
+
readonly mode: Mode;
|
|
47
|
+
/** The folder to start from; the current one when absent. */
|
|
48
|
+
readonly root: string | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A NUL in the first bytes marks a binary file, which is skipped. */
|
|
52
|
+
const BINARY_PROBE = 8000;
|
|
53
|
+
|
|
54
|
+
/** The flags that pick a mode other than the tree. */
|
|
55
|
+
const MODE_FLAGS: Readonly<Record<string, Mode>> = { '--staged': 'staged', '--history': 'history' };
|
|
56
|
+
|
|
57
|
+
/** Run git and answer its stdout, failing loudly on a nonzero exit. */
|
|
58
|
+
function git(args: string[], cwd: string): Buffer {
|
|
59
|
+
const result = Bun.spawnSync(['git', ...args], { cwd, maxBuffer: 512 * 1024 * 1024 });
|
|
60
|
+
if (result.exitCode !== 0) throw new Error(`git ${args.join(' ')} failed: ${result.stderr.toString()}`);
|
|
61
|
+
return result.stdout;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The whole text of a blob, or null for a binary one. */
|
|
65
|
+
function textOf(bytes: Uint8Array): string | null {
|
|
66
|
+
if (bytes.subarray(0, BINARY_PROBE).includes(0)) return null;
|
|
67
|
+
return new TextDecoder().decode(bytes);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Split NUL-separated git output into its entries. */
|
|
71
|
+
function entries(output: Buffer): string[] {
|
|
72
|
+
return output.toString().split('\0')
|
|
73
|
+
.filter((entry) => entry !== '');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The tracked files and the untracked ones git would track. */
|
|
77
|
+
async function treeSources(root: string): Promise<Source[]> {
|
|
78
|
+
const sources: Source[] = [];
|
|
79
|
+
for (const path of entries(git(['ls-files', '-z', '--cached', '--others', '--exclude-standard'], root))) {
|
|
80
|
+
const file = Bun.file(join(root, path));
|
|
81
|
+
if (!(await file.exists())) continue;
|
|
82
|
+
const text = textOf(await file.bytes());
|
|
83
|
+
if (text !== null) sources.push({ name: path, path, text });
|
|
84
|
+
}
|
|
85
|
+
return sources;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The index blobs of the files a commit would add or change. */
|
|
89
|
+
function stagedSources(root: string): Source[] {
|
|
90
|
+
const sources: Source[] = [];
|
|
91
|
+
for (const path of entries(git(['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR'], root))) {
|
|
92
|
+
const text = textOf(git(['show', `:${path}`], root));
|
|
93
|
+
if (text !== null) sources.push({ name: `${path} (staged)`, path, text });
|
|
94
|
+
}
|
|
95
|
+
return sources;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The text of the blob `sha`, read once and kept in `cache`. */
|
|
99
|
+
function blobText(sha: string, root: string, cache: Map<string, string | null>): string | null {
|
|
100
|
+
const known = cache.get(sha);
|
|
101
|
+
if (known !== undefined) return known;
|
|
102
|
+
const text = textOf(git(['cat-file', 'blob', sha], root));
|
|
103
|
+
cache.set(sha, text);
|
|
104
|
+
return text;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Every blob in the tree of every commit reachable from any ref, once per
|
|
109
|
+
* path it was committed at, plus commit identities and messages. The
|
|
110
|
+
* rules and the allow list both depend on the path, so a blob copied to a
|
|
111
|
+
* second path is judged again there.
|
|
112
|
+
*/
|
|
113
|
+
function historySources(root: string): Source[] {
|
|
114
|
+
const sources: Source[] = [];
|
|
115
|
+
const seen = new Set<string>();
|
|
116
|
+
const cache = new Map<string, string | null>();
|
|
117
|
+
for (const commit of git(['rev-list', '--all'], root).toString()
|
|
118
|
+
.split('\n')
|
|
119
|
+
.filter((line) => line !== '')) {
|
|
120
|
+
for (const entry of entries(git(['ls-tree', '-r', '-z', commit], root))) {
|
|
121
|
+
const [meta = '', path = ''] = entry.split('\t');
|
|
122
|
+
const [, type, sha] = meta.split(' ');
|
|
123
|
+
if (type !== 'blob' || sha === undefined || path === '' || seen.has(`${sha} ${path}`)) continue;
|
|
124
|
+
seen.add(`${sha} ${path}`);
|
|
125
|
+
const text = blobText(sha, root, cache);
|
|
126
|
+
if (text !== null) sources.push({ name: `${path} @ ${sha.slice(0, 8)}`, path, text });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const log = git(['log', '--all', '--format=%h%x00%an <%ae>%x00%cn <%ce>%x00%B%x00%x01'], root).toString();
|
|
130
|
+
for (const record of log.split('\x01')) {
|
|
131
|
+
const [hash, author, committer, body] = record.replace(/^\n/, '').split('\0');
|
|
132
|
+
if (hash === undefined || hash === '') continue;
|
|
133
|
+
sources.push({ name: `commit ${hash}`, path: '.git/COMMIT', text: [author, committer, body].join('\n') });
|
|
134
|
+
}
|
|
135
|
+
return sources;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Whether a finding in `path` is one of the reviewed exceptions. */
|
|
139
|
+
function isAllowed(path: string, finding: Finding, allowed: readonly Allowance[]): boolean {
|
|
140
|
+
return allowed.some((allowance) => allowance.rule === finding.rule
|
|
141
|
+
&& allowance.sha256 === sha256(finding.evidence)
|
|
142
|
+
&& new Bun.Glob(allowance.path).match(path));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Judges the sources under `config` and answers the report lines. */
|
|
146
|
+
export function judge(sources: readonly Source[], config: PrivacyConfig): string[] {
|
|
147
|
+
const report: string[] = [];
|
|
148
|
+
for (const source of sources) {
|
|
149
|
+
for (const finding of scan(source.path, source.text, config.banned)) {
|
|
150
|
+
if (isAllowed(source.path, finding, config.allow)) continue;
|
|
151
|
+
report.push(`${source.name}:${finding.line} ${finding.rule} ${finding.evidence}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return report;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The sources one mode reads from the repository at `root`. */
|
|
158
|
+
export async function readSources(root: string, mode: Mode): Promise<Source[]> {
|
|
159
|
+
if (mode === 'staged') return stagedSources(root);
|
|
160
|
+
if (mode === 'history') return historySources(root);
|
|
161
|
+
return treeSources(root);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The config a mode judges by: the index copy for `--staged`, the worktree copy otherwise. */
|
|
165
|
+
export function readConfig(root: string, mode: Mode): PrivacyConfig {
|
|
166
|
+
if (mode !== 'staged') return readPrivacyConfig(root);
|
|
167
|
+
if (entries(git(['ls-files', '-z', '--cached', '--', CONFIG_FILE], root)).length === 0) return NO_CONFIG;
|
|
168
|
+
return parsePrivacyConfig(git(['show', `:${CONFIG_FILE}`], root).toString());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parses the command line strictly: an unknown argument, `--root` with
|
|
173
|
+
* no folder, or two mode flags throw, so a typo never runs a different
|
|
174
|
+
* scan than the one asked for.
|
|
175
|
+
*/
|
|
176
|
+
export function parseArgs(argv: readonly string[]): GateArgs {
|
|
177
|
+
let mode: Mode | undefined;
|
|
178
|
+
let root: string | undefined;
|
|
179
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
180
|
+
const arg = argv[index] ?? '';
|
|
181
|
+
if (arg === '--root') {
|
|
182
|
+
const value = argv[index + 1];
|
|
183
|
+
if (value === undefined || value.startsWith('--')) throw new Error('--root needs a folder after it');
|
|
184
|
+
if (root !== undefined) throw new Error('--root is given twice');
|
|
185
|
+
root = value;
|
|
186
|
+
index += 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const flagMode = MODE_FLAGS[arg];
|
|
190
|
+
if (flagMode === undefined) throw new Error(`unknown argument ${arg}; the gate takes --staged, --history and --root <dir>`);
|
|
191
|
+
if (mode !== undefined) throw new Error(`--${mode} and ${arg} cannot run together`);
|
|
192
|
+
mode = flagMode;
|
|
193
|
+
}
|
|
194
|
+
return { mode: mode ?? 'tree', root };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The exit code for "the gate could not run". */
|
|
198
|
+
const EXIT_FAILED = 2;
|
|
199
|
+
|
|
200
|
+
/** Runs the gate over the repository the arguments name and answers its exit code. */
|
|
201
|
+
export async function run(argv: readonly string[]): Promise<number> {
|
|
202
|
+
const { mode, root: start = process.cwd() } = parseArgs(argv);
|
|
203
|
+
const root = git(['rev-parse', '--show-toplevel'], start).toString()
|
|
204
|
+
.trim();
|
|
205
|
+
const config = readConfig(root, mode);
|
|
206
|
+
const sources = await readSources(root, mode);
|
|
207
|
+
if (mode !== 'staged' && sources.length === 0) {
|
|
208
|
+
console.error(`privacy-gate: FAIL, 0 ${mode} sources under ${root}. Refusing to report a pass.`);
|
|
209
|
+
return EXIT_FAILED;
|
|
210
|
+
}
|
|
211
|
+
const listNote = config.found
|
|
212
|
+
? `${config.banned.size} banned terms`
|
|
213
|
+
: `no ${CONFIG_FILE}, so no banned terms`;
|
|
214
|
+
const report = judge(sources, config);
|
|
215
|
+
if (report.length === 0) {
|
|
216
|
+
console.log(`privacy-gate: ${sources.length} ${mode} sources, ${listNote}, no findings`);
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
console.error(`privacy-gate: ${report.length} findings in ${mode} sources (${listNote})\n${report.join('\n')}`);
|
|
220
|
+
console.error(`\nRemove each value, replace it with a reserved example, or add a reviewed entry to the allow list of ${CONFIG_FILE}\n(its evidence as sha256: bun -e 'console.log(new Bun.CryptoHasher("sha256").update(process.argv[1]).digest("hex"))' '<evidence>').`);
|
|
221
|
+
return 1;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (import.meta.main) {
|
|
225
|
+
try {
|
|
226
|
+
process.exit(await run(process.argv.slice(2)));
|
|
227
|
+
} catch (error) {
|
|
228
|
+
const message = error instanceof Error
|
|
229
|
+
? error.message
|
|
230
|
+
: String(error);
|
|
231
|
+
console.error(`privacy-gate: FAIL, the gate could not run: ${message}`);
|
|
232
|
+
process.exit(EXIT_FAILED);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The privacy rules: what a tracked file may not carry in a public
|
|
3
|
+
* repository. Each rule reads one string and answers findings with a
|
|
4
|
+
* line number, so the same rules judge a staged blob, a tracked file and
|
|
5
|
+
* every blob in the history.
|
|
6
|
+
*
|
|
7
|
+
* Three families:
|
|
8
|
+
*
|
|
9
|
+
* - **Banned terms**, stored as sha256 hashes of their lowercase form so
|
|
10
|
+
* no file spells them: machine names, private domains, private
|
|
11
|
+
* repositories, home paths and mail handles. This package ships no
|
|
12
|
+
* list; each project keeps its own in `.privacy-gate.json`
|
|
13
|
+
* (`./privacy-config.ts`).
|
|
14
|
+
* - **Shapes**, which need no list: a home directory path, a private or
|
|
15
|
+
* public IPv4 address, a LAN host name, a credential prefix.
|
|
16
|
+
* - **Sample data**, the gap other projects found: mock people in
|
|
17
|
+
* stories, fixtures and tests. There an email, a URL, a phone number
|
|
18
|
+
* or a profile link must use a reserved example domain or number, so a
|
|
19
|
+
* real person's address can never pass as a sample.
|
|
20
|
+
*
|
|
21
|
+
* To ban a term, print its hash and add an entry to the project's
|
|
22
|
+
* `.privacy-gate.json` with a label that does not spell it:
|
|
23
|
+
*
|
|
24
|
+
* bun -e 'console.log(new Bun.CryptoHasher("sha256").update(process.argv[1].toLowerCase()).digest("hex"))' '<term>'
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** A rule's answer: the line it fired on and what it found. */
|
|
28
|
+
export interface Finding {
|
|
29
|
+
readonly line: number;
|
|
30
|
+
readonly rule: RuleName;
|
|
31
|
+
/** What matched. A banned term is shown by its label only. */
|
|
32
|
+
readonly evidence: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The rule names, as the report and the allowlist spell them. */
|
|
36
|
+
export const RULE_NAMES = [
|
|
37
|
+
'banned-term',
|
|
38
|
+
'home-path',
|
|
39
|
+
'ip-address',
|
|
40
|
+
'lan-host',
|
|
41
|
+
'credential',
|
|
42
|
+
'email',
|
|
43
|
+
'sample-url',
|
|
44
|
+
'sample-phone',
|
|
45
|
+
'profile-link',
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
/** One rule name. */
|
|
49
|
+
export type RuleName = typeof RULE_NAMES[number];
|
|
50
|
+
|
|
51
|
+
/** sha256 of a banned lowercase term, mapped to a label that does not spell it. */
|
|
52
|
+
export type BannedTerms = ReadonlyMap<string, string>;
|
|
53
|
+
|
|
54
|
+
/** No banned terms: the default when a project lists none. */
|
|
55
|
+
export const NO_BANNED_TERMS: BannedTerms = new Map();
|
|
56
|
+
|
|
57
|
+
/** Characters a token is made of, for the banned-term rule. */
|
|
58
|
+
const TOKEN = /[a-z0-9._/~@-]+/g;
|
|
59
|
+
|
|
60
|
+
/** The separators that split a token into words. */
|
|
61
|
+
const SEPARATOR = /[._/~@-]/;
|
|
62
|
+
|
|
63
|
+
/** Tokens longer than this are cut into words from their first part only. */
|
|
64
|
+
const MAX_WORDS = 24;
|
|
65
|
+
|
|
66
|
+
/** Mail and web domains reserved for examples (RFC 2606, RFC 6761). */
|
|
67
|
+
const RESERVED_DOMAIN = /(^|\.)(example\.(com|net|org)|example|test|invalid|localhost)$/;
|
|
68
|
+
|
|
69
|
+
/** IPv4 addresses kept for documentation, loopback and wildcards. */
|
|
70
|
+
const ALLOWED_IP = /^(127\.0\.0\.1|0\.0\.0\.0|255\.255\.255\.255|192\.0\.2\.\d+|198\.51\.100\.\d+|203\.0\.113\.\d+)$/;
|
|
71
|
+
|
|
72
|
+
/** Hosts a sample may link to without naming anyone. */
|
|
73
|
+
const ALLOWED_SAMPLE_HOST = /(^|\.)(example\.(com|net|org)|example|test|invalid|localhost|www\.w3\.org|w3\.org)$/;
|
|
74
|
+
|
|
75
|
+
/** Credential prefixes a vendor issues keys under, followed by a key-like body. */
|
|
76
|
+
const CREDENTIAL = /\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|(AKIA|ASIA)[A-Z0-9]{16}|sk-(ant-)?[A-Za-z0-9_-]{20,}|xox[abprs]-[A-Za-z0-9-]{10,}|AIza[A-Za-z0-9_-]{30,}|-----BEGIN [A-Z ]*PRIVATE KEY-----)/;
|
|
77
|
+
|
|
78
|
+
/** A home directory with its account segment, on macOS, Linux or Windows. */
|
|
79
|
+
const HOME_PATH = /(^|[^\w.-])(\/Users\/[A-Za-z0-9._-]+|\/home\/[A-Za-z0-9._-]+|[A-Za-z]:\\Users\\[A-Za-z0-9._-]+)/;
|
|
80
|
+
|
|
81
|
+
/** A dotted quad, checked for range after the match. */
|
|
82
|
+
const IPV4 = /(?<![\w.])(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?![\w.]*\d)/g;
|
|
83
|
+
|
|
84
|
+
/** An SVG path or points attribute, whose numbers read like dotted quads. */
|
|
85
|
+
const SVG_PATH = /(^|\s)(d|points)=["{]/;
|
|
86
|
+
|
|
87
|
+
/** A host name on a LAN-only suffix, not a property chain like `node.local.name`. */
|
|
88
|
+
const LAN_HOST = /\b[a-z0-9-]+\.(local|lan|internal|home\.arpa|localdomain)\b(?![.(\w])/i;
|
|
89
|
+
|
|
90
|
+
/** An email address; the domain is checked after the match. */
|
|
91
|
+
const EMAIL = /(?<![\w/@.-])[A-Za-z0-9._%+-]+@((?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,})(?![\w-])/g;
|
|
92
|
+
|
|
93
|
+
/** An http(s) URL's host. */
|
|
94
|
+
const URL_HOST = /https?:\/\/([A-Za-z0-9.-]+)/g;
|
|
95
|
+
|
|
96
|
+
/** A phone number shape: optional country code, then 9 to 13 digits in groups. */
|
|
97
|
+
const PHONE = /(?<![\w.])\+?\d{1,3}?[ (.-]*\d{2,4}[ ).-]+\d{3,4}[ .-]+\d{3,4}(?![\w.])/g;
|
|
98
|
+
|
|
99
|
+
/** The fictional range: 555-0100 to 555-0199. */
|
|
100
|
+
const FICTIONAL_PHONE = /555[ .-]?01\d\d/;
|
|
101
|
+
|
|
102
|
+
/** A link to a person's profile on a public network. */
|
|
103
|
+
const PROFILE = /\b(github\.com|gitlab\.com|twitter\.com|x\.com|linkedin\.com\/in|instagram\.com|facebook\.com)\/([A-Za-z0-9_.-]+)/gi;
|
|
104
|
+
|
|
105
|
+
/** Paths that hold sample data: stories, fixtures, mocks, tests. */
|
|
106
|
+
const SAMPLE_PATH = /(\.stories\.[jt]sx?$|\.test\.[jt]sx?$|\.spec\.[jt]sx?$|(^|\/)(stories|fixtures?|mocks?|__mocks__|testdata|samples?)\/|fixtures?\.[jt]sx?$|mocks?\.[jt]sx?$)/;
|
|
107
|
+
|
|
108
|
+
/** Whether `path` holds sample data. */
|
|
109
|
+
export function isSamplePath(path: string): boolean {
|
|
110
|
+
return SAMPLE_PATH.test(path);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The sha256 of `text`, as hex. */
|
|
114
|
+
export function sha256(text: string): string {
|
|
115
|
+
return new Bun.CryptoHasher('sha256').update(text)
|
|
116
|
+
.digest('hex');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Every run of whole words in `token`, each with and without the separator before it. */
|
|
120
|
+
function candidates(token: string): string[] {
|
|
121
|
+
const words: { start: number; end: number }[] = [];
|
|
122
|
+
let start = -1;
|
|
123
|
+
for (let index = 0; index <= token.length; index += 1) {
|
|
124
|
+
const isWord = index < token.length && !SEPARATOR.test(token.charAt(index));
|
|
125
|
+
if (isWord && start === -1) start = index;
|
|
126
|
+
if (!isWord && start !== -1) {
|
|
127
|
+
words.push({ start, end: index });
|
|
128
|
+
start = -1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const runs: string[] = [];
|
|
132
|
+
const kept = words.slice(0, MAX_WORDS);
|
|
133
|
+
for (let first = 0; first < kept.length; first += 1) {
|
|
134
|
+
const from = kept[first]?.start ?? 0;
|
|
135
|
+
for (let last = first; last < kept.length; last += 1) {
|
|
136
|
+
const to = kept[last]?.end ?? 0;
|
|
137
|
+
runs.push(token.slice(from, to));
|
|
138
|
+
if (from > 0) runs.push(token.slice(from - 1, to));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return runs;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The labels of the banned terms one line holds. */
|
|
145
|
+
export function bannedIn(text: string, banned: BannedTerms = NO_BANNED_TERMS): string[] {
|
|
146
|
+
const found = new Set<string>();
|
|
147
|
+
for (const token of text.toLowerCase().match(TOKEN) ?? []) {
|
|
148
|
+
for (const candidate of candidates(token)) {
|
|
149
|
+
const label = banned.get(sha256(candidate));
|
|
150
|
+
if (label !== undefined) found.add(label);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return [...found];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Whether an IPv4 match is a real address outside the allowed set. */
|
|
157
|
+
function isReportableIp(parts: string[], text: string): boolean {
|
|
158
|
+
if (parts.some((part) => Number(part) > 255)) return false;
|
|
159
|
+
return !ALLOWED_IP.test(text);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The findings of the rules that apply to every file, for one line. */
|
|
163
|
+
function lineFindings(text: string, line: number, banned: BannedTerms): Finding[] {
|
|
164
|
+
const findings: Finding[] = [];
|
|
165
|
+
for (const label of bannedIn(text, banned)) findings.push({ line, rule: 'banned-term', evidence: label });
|
|
166
|
+
const home = HOME_PATH.exec(text);
|
|
167
|
+
if (home?.[2] !== undefined) findings.push({ line, rule: 'home-path', evidence: home[2] });
|
|
168
|
+
const svgPath = SVG_PATH.test(text);
|
|
169
|
+
for (const match of svgPath
|
|
170
|
+
? []
|
|
171
|
+
: text.matchAll(IPV4)) {
|
|
172
|
+
if (isReportableIp(match.slice(1, 5), match[0])) findings.push({ line, rule: 'ip-address', evidence: match[0] });
|
|
173
|
+
}
|
|
174
|
+
const lan = LAN_HOST.exec(text);
|
|
175
|
+
if (lan !== null) findings.push({ line, rule: 'lan-host', evidence: lan[0] });
|
|
176
|
+
const credential = CREDENTIAL.exec(text);
|
|
177
|
+
if (credential?.[1] !== undefined) findings.push({ line, rule: 'credential', evidence: `${credential[1].slice(0, 6)}…` });
|
|
178
|
+
for (const match of text.matchAll(EMAIL)) {
|
|
179
|
+
const domain = (match[1] ?? '').toLowerCase();
|
|
180
|
+
if (!RESERVED_DOMAIN.test(domain)) findings.push({ line, rule: 'email', evidence: match[0] });
|
|
181
|
+
}
|
|
182
|
+
return findings;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The findings of the sample-data rules, for one line of a sample file. */
|
|
186
|
+
function sampleFindings(text: string, line: number): Finding[] {
|
|
187
|
+
const findings: Finding[] = [];
|
|
188
|
+
for (const match of text.matchAll(URL_HOST)) {
|
|
189
|
+
const host = (match[1] ?? '').toLowerCase();
|
|
190
|
+
if (!ALLOWED_SAMPLE_HOST.test(host)) findings.push({ line, rule: 'sample-url', evidence: match[0] });
|
|
191
|
+
}
|
|
192
|
+
for (const match of text.matchAll(PHONE)) {
|
|
193
|
+
const digits = match[0].replace(/\D/g, '');
|
|
194
|
+
if (digits.length >= 9 && !FICTIONAL_PHONE.test(match[0])) findings.push({ line, rule: 'sample-phone', evidence: match[0].trim() });
|
|
195
|
+
}
|
|
196
|
+
for (const match of text.matchAll(PROFILE)) findings.push({ line, rule: 'profile-link', evidence: match[0] });
|
|
197
|
+
return findings;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Every finding in one file's text. Line numbers count from one. */
|
|
201
|
+
export function scan(path: string, text: string, banned: BannedTerms = NO_BANNED_TERMS): Finding[] {
|
|
202
|
+
const sample = isSamplePath(path);
|
|
203
|
+
const findings: Finding[] = [];
|
|
204
|
+
text.split('\n').forEach((content, index) => {
|
|
205
|
+
findings.push(...lineFindings(content, index + 1, banned));
|
|
206
|
+
if (sample) findings.push(...sampleFindings(content, index + 1));
|
|
207
|
+
});
|
|
208
|
+
return findings;
|
|
209
|
+
}
|