gitsieve 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +147 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +161 -0
- package/dist/config.js.map +1 -0
- package/dist/controls.d.ts +16 -0
- package/dist/controls.js +161 -0
- package/dist/controls.js.map +1 -0
- package/dist/corpus.d.ts +34 -0
- package/dist/corpus.js +121 -0
- package/dist/corpus.js.map +1 -0
- package/dist/git.d.ts +66 -0
- package/dist/git.js +214 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/matcher.d.ts +34 -0
- package/dist/matcher.js +75 -0
- package/dist/matcher.js.map +1 -0
- package/dist/preflight.d.ts +21 -0
- package/dist/preflight.js +52 -0
- package/dist/preflight.js.map +1 -0
- package/dist/report.d.ts +8 -0
- package/dist/report.js +0 -0
- package/dist/report.js.map +1 -0
- package/dist/scan.d.ts +17 -0
- package/dist/scan.js +134 -0
- package/dist/scan.js.map +1 -0
- package/dist/secrets.d.ts +13 -0
- package/dist/secrets.js +48 -0
- package/dist/secrets.js.map +1 -0
- package/dist/types.d.ts +104 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Yoon
|
|
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
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# gitsieve
|
|
2
|
+
|
|
3
|
+
Audit a private repository's **entire git history** before you open-source it,
|
|
4
|
+
and get a go/no-go.
|
|
5
|
+
|
|
6
|
+
It scans every commit on every ref — file contents, path names, commit
|
|
7
|
+
messages, author and committer identities, and ref names — against a list of
|
|
8
|
+
words you supply: your organisation's name, your products, your clients. Then
|
|
9
|
+
it exits non-zero if it finds any of them.
|
|
10
|
+
|
|
11
|
+
The feature that matters most is the one that sounds like overhead: **it proves
|
|
12
|
+
it can find something before it tells you it found nothing.**
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
$ gitsieve --repo . --terms ~/private/terms.json
|
|
16
|
+
|
|
17
|
+
SCANNER VERIFICATION
|
|
18
|
+
ok contents "createReadStream" 1 match(es), derived
|
|
19
|
+
ok paths "decisions" 1 match(es), derived
|
|
20
|
+
ok messages "verification" 1 match(es), derived
|
|
21
|
+
ok identities "someone@example.com" 1 match(es), derived
|
|
22
|
+
ok refs "heads" 1 match(es), derived
|
|
23
|
+
|
|
24
|
+
FINDINGS (3 across 2 term/channel pairs)
|
|
25
|
+
[term] client-name -- paths -- 2 match(es)
|
|
26
|
+
vendor/<redacted>/adapter.ts
|
|
27
|
+
[term] client-name -- identities -- 1 match(es)
|
|
28
|
+
commit 8f31a04
|
|
29
|
+
|
|
30
|
+
RESULT: NO-GO -- 3 finding(s). Do not publish this repository.
|
|
31
|
+
$ echo $?
|
|
32
|
+
1
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Why this exists
|
|
38
|
+
|
|
39
|
+
This tool is not a hypothetical. Earlier this year, publishing a private
|
|
40
|
+
repository as open source went wrong in three distinct ways, and every rule in
|
|
41
|
+
here comes from one of them.
|
|
42
|
+
|
|
43
|
+
**1. The confidentiality check ran against a `--depth 1` clone.**
|
|
44
|
+
A shallow clone holds one commit of one branch. `git grep` over it is a
|
|
45
|
+
perfectly correct command answering a question nobody asked, and it reported
|
|
46
|
+
clean.
|
|
47
|
+
|
|
48
|
+
**2. A full-history scan later found what was actually there.**
|
|
49
|
+
37 files under a directory named after an employer's product, roughly 17,900
|
|
50
|
+
content matches, employer package names, and commits authored from an employer
|
|
51
|
+
email address. Every current file was clean. The history was not, and history
|
|
52
|
+
is what `git push` sends.
|
|
53
|
+
|
|
54
|
+
**3. Worse: a second scan of the *current* files also returned zero, falsely.**
|
|
55
|
+
In that shell `grep` was not `/usr/bin/grep`. It was a shell function shimming
|
|
56
|
+
[ugrep](https://github.com/Genivia/ugrep) with `--ignore-files`, so it honoured
|
|
57
|
+
`.gitignore`. The repository's `.gitignore` was a deny-everything whitelist —
|
|
58
|
+
`*` followed by a handful of `!allowed` lines — so the scanner silently skipped
|
|
59
|
+
almost every file and exited 0. `git grep` over the same directory returned
|
|
60
|
+
three matches.
|
|
61
|
+
|
|
62
|
+
The employer's product name was public for about fifteen minutes.
|
|
63
|
+
|
|
64
|
+
None of those three runs produced an error. Two of them produced an empty
|
|
65
|
+
result set, which is exactly what a person about to publish wants to see.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## What it checks
|
|
70
|
+
|
|
71
|
+
### Five channels
|
|
72
|
+
|
|
73
|
+
The worst finding in the real incident was a **directory name**, and the second
|
|
74
|
+
worst was an **email address in an author field**. A scanner that only reads
|
|
75
|
+
file contents sees neither.
|
|
76
|
+
|
|
77
|
+
| channel | what it is | why it is in the list |
|
|
78
|
+
| ------------ | --------------------------------------- | -------------------------------------------------------------- |
|
|
79
|
+
| `contents` | every text blob in every commit | the obvious one, and the only one most tools do |
|
|
80
|
+
| `paths` | every path that ever existed | `vendor/<client-name>/…` leaks the client with no file content |
|
|
81
|
+
| `messages` | every commit message, and tag messages | "integrate with <product> v2" ships with the repo |
|
|
82
|
+
| `identities` | every author and committer name/email | `you@employer.example` in `%ae` on every commit you ever made |
|
|
83
|
+
| `refs` | every branch and tag name | `feature/<client>-migration` is on the GitHub branch dropdown |
|
|
84
|
+
|
|
85
|
+
### Scanner verification (the important one)
|
|
86
|
+
|
|
87
|
+
Before printing a result, the tool derives a control string per channel from
|
|
88
|
+
the repository itself — a real word from a real file, a real path segment, a
|
|
89
|
+
real author address — and pushes each through the *same* matcher a real term
|
|
90
|
+
uses. Any control that matches zero times means that channel is not being read,
|
|
91
|
+
and the whole run is reported as **not trustworthy**, exit code 2, with the
|
|
92
|
+
finding count explicitly disclaimed.
|
|
93
|
+
|
|
94
|
+
You can add your own controls to the terms file: strings you know are in the
|
|
95
|
+
repository. They are checked identically.
|
|
96
|
+
|
|
97
|
+
This is not a flag. A verification you can turn off is a verification that will
|
|
98
|
+
be off in the run that matters. Rationale and evidence:
|
|
99
|
+
[docs/decisions/0002](docs/decisions/0002-control-strings-are-mandatory.md).
|
|
100
|
+
|
|
101
|
+
### Preflight
|
|
102
|
+
|
|
103
|
+
- **Shallow clone** → blocker. `git rev-parse --is-shallow-repository`. There is
|
|
104
|
+
no scan that can rescue a corpus of one commit. `--allow-shallow` downgrades
|
|
105
|
+
it to a warning if you know what you are doing.
|
|
106
|
+
- **Single-branch clone** → warning, when `remote.origin.fetch` does not cover
|
|
107
|
+
`refs/heads/*`. Branches outside that refspec were never fetched.
|
|
108
|
+
- **Deny-all `.gitignore`** → warning. A `*` + `!allowed` whitelist is a normal
|
|
109
|
+
thing to write and an auditing hazard: every tool that honours ignore files
|
|
110
|
+
will skip most of the repository and report it clean. `gitsieve` itself
|
|
111
|
+
reads objects from git, not files from disk, so it is unaffected — but the
|
|
112
|
+
next person to run ripgrep in that directory will be.
|
|
113
|
+
|
|
114
|
+
### Non-ASCII terms
|
|
115
|
+
|
|
116
|
+
Terms are literal strings, never regexes, and word boundaries are applied only
|
|
117
|
+
when the term itself starts and ends with an ASCII word character.
|
|
118
|
+
|
|
119
|
+
`\b` in a regex is defined against `[A-Za-z0-9_]`. It never fires next to a
|
|
120
|
+
Hangul syllable, a kana, or a Han character. A term list built on
|
|
121
|
+
`\b<term>\b` therefore matches **nothing at all** for exactly the terms most
|
|
122
|
+
likely to be the organisation's real internal vocabulary. In the incident this
|
|
123
|
+
tool comes from, the terms included Korean spellings of the same names.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
// tests/matcher.test.ts
|
|
127
|
+
findMatches(compileTerm({ id: "ko", value: "노르헤이븐" }).regex, "노르헤이븐 연동 메모");
|
|
128
|
+
// => 1 match
|
|
129
|
+
|
|
130
|
+
findMatches(compileTerm({ id: "ko", value: "노르헤이븐", wholeWord: "always" }).regex,
|
|
131
|
+
"노르헤이븐 연동 메모");
|
|
132
|
+
// => 0 matches — the bug this design avoids
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Secret patterns
|
|
136
|
+
|
|
137
|
+
A short list of prefix-anchored credential patterns (AWS key ids, GitHub
|
|
138
|
+
tokens, PEM private key blocks, Slack, Stripe, Google API keys, npm tokens,
|
|
139
|
+
JWTs) runs as a **secondary** check. It is not the headline. See below.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## "gitleaks and trufflehog exist. Why this?"
|
|
144
|
+
|
|
145
|
+
They are better than this tool at what they do, and you should run one of them
|
|
146
|
+
too. They are not substitutes for each other.
|
|
147
|
+
|
|
148
|
+
**gitleaks and trufflehog find secrets.** Keys, tokens, credentials — things
|
|
149
|
+
with a recognisable shape, often verified live against the issuing service.
|
|
150
|
+
Hundreds of rules, years of tuning. `gitsieve` ships nine patterns and no
|
|
151
|
+
verification, and will not catch what they catch.
|
|
152
|
+
|
|
153
|
+
**They do not find organisation-owned vocabulary,** because it has no shape. A
|
|
154
|
+
product name is a word. A client name is a word. No entropy heuristic and no
|
|
155
|
+
regex library knows that one particular ordinary-looking noun is the one you
|
|
156
|
+
are contractually forbidden from publishing. Only you know that, which is why
|
|
157
|
+
this tool's core input is a list *you* write.
|
|
158
|
+
|
|
159
|
+
Concretely, against the real incident:
|
|
160
|
+
|
|
161
|
+
| what actually leaked | a secret scanner finds it? |
|
|
162
|
+
| ------------------------------------------------ | -------------------------- |
|
|
163
|
+
| a directory named after the employer's product | no — it is a path, not a secret, and most secret scanners scan blob contents |
|
|
164
|
+
| an employer email address in the commit author field | no — `%ae` is not part of the scanned corpus |
|
|
165
|
+
| the product name in 17,900 lines of past commits | no — it is an ordinary word with no entropy signature |
|
|
166
|
+
| Korean spellings of the same names | no — non-ASCII names are not in any rule set |
|
|
167
|
+
| an API key | **yes** — but there wasn't one |
|
|
168
|
+
|
|
169
|
+
The leak was not an API key. It was a client's name in a directory path and an
|
|
170
|
+
employer's address in an author field.
|
|
171
|
+
|
|
172
|
+
**And neither of them tells you when they read nothing.** That is the specific
|
|
173
|
+
failure that put a product name in public: a scanner that skipped almost every
|
|
174
|
+
file, exited 0, and printed nothing. Every tool in this category is one shell
|
|
175
|
+
alias away from that. This one refuses to report a zero it has not just
|
|
176
|
+
verified.
|
|
177
|
+
|
|
178
|
+
Run gitleaks for secrets. Run this for the words only you know are dangerous,
|
|
179
|
+
and for the reassurance that the scan happened at all.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## False positives, and the suppression list
|
|
184
|
+
|
|
185
|
+
**When a term is also a common word.** It happens: an organisation called Delta
|
|
186
|
+
or Nova or Prism will match the geometry code, the CSS, and the changelog.
|
|
187
|
+
There is no clever fix, so the tool does three unclever things instead:
|
|
188
|
+
|
|
189
|
+
1. **Word boundaries by default for ASCII terms.** `Zephyr` matches
|
|
190
|
+
`vendor/Zephyr-adapter/x.ts` and does not match `Zephyrline`.
|
|
191
|
+
`wholeWord: "never"` widens it, `"always"` narrows it.
|
|
192
|
+
2. **`channels` per term.** A term that is only dangerous in a path
|
|
193
|
+
(`{ "value": "Delta", "channels": ["paths", "refs"] }`) stops generating
|
|
194
|
+
noise from prose.
|
|
195
|
+
3. **`suppress` entries, each with a mandatory `reason`.** A suppression
|
|
196
|
+
without a stated reason is rejected by the config parser. An unexplained
|
|
197
|
+
hole in an audit is worse than a noisy audit.
|
|
198
|
+
|
|
199
|
+
```json
|
|
200
|
+
{
|
|
201
|
+
"suppress": [
|
|
202
|
+
{
|
|
203
|
+
"termId": "delta",
|
|
204
|
+
"channel": "contents",
|
|
205
|
+
"pathPrefix": "src/geometry/",
|
|
206
|
+
"reason": "delta as in difference; unrelated to the organisation name"
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**How the suppression list avoids becoming a leak vector.** Two rules:
|
|
213
|
+
|
|
214
|
+
- **Suppressions reference a term by `id`, never by value.** Passing a `value`
|
|
215
|
+
or `term` field is a hard config error, with that reason in the message. A
|
|
216
|
+
suppression file that repeated the sensitive string would be the leak it was
|
|
217
|
+
written to prevent.
|
|
218
|
+
- **The terms file itself must live outside the repository being scanned.**
|
|
219
|
+
The tool refuses a `--terms` path inside the repo and exits 3. That file is a
|
|
220
|
+
list of exactly the strings you are trying not to publish; committing it into
|
|
221
|
+
the repository you are about to publish is the whole problem in miniature.
|
|
222
|
+
`--allow-terms-in-repo` exists for terms that are genuinely not sensitive.
|
|
223
|
+
|
|
224
|
+
So a repository can carry `gitsieve.config.json` with its suppressions and
|
|
225
|
+
its `id`s, and the file that maps those ids to real words stays in your home
|
|
226
|
+
directory, your password manager, or your CI secrets.
|
|
227
|
+
|
|
228
|
+
**Reports redact by default.** Findings print the term id and location, not the
|
|
229
|
+
matched text. `--show-matches` reveals context for terms; secret matches are
|
|
230
|
+
never printed, with or without the flag. A report pasted into a ticket or a CI
|
|
231
|
+
log must not become the leak.
|
|
232
|
+
|
|
233
|
+
**Known false negatives**, stated plainly:
|
|
234
|
+
|
|
235
|
+
- An ASCII term next to an underscore. `Zephyrline` does not match
|
|
236
|
+
`ZEPHYRLINE_QUEUE_URL`, because `_` is a word character and the boundary does
|
|
237
|
+
not fire. Use `wholeWord: "never"` if that matters to you.
|
|
238
|
+
- Terms split across lines, obfuscated, or base64-encoded in history.
|
|
239
|
+
- Content inside binary blobs, which are skipped after a NUL-byte check, and
|
|
240
|
+
blobs over `--max-blob-bytes` (2 MiB), which are reported as a warning rather
|
|
241
|
+
than skipped silently.
|
|
242
|
+
- A term that is not on your list. This tool cannot tell you what you forgot.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Install and use
|
|
247
|
+
|
|
248
|
+
```bash
|
|
249
|
+
git clone https://github.com/sparkYJO1/gitsieve
|
|
250
|
+
cd gitsieve
|
|
251
|
+
npm install && npm run build
|
|
252
|
+
node dist/cli.js --repo /path/to/repo --terms ~/private/terms.json
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Node 20+. No runtime dependencies — it shells out to `git` and nothing else.
|
|
256
|
+
|
|
257
|
+
### Terms file
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
{
|
|
261
|
+
"terms": [
|
|
262
|
+
{ "id": "org", "value": "Norhaven" },
|
|
263
|
+
{ "id": "org-ko", "value": "노르헤이븐" },
|
|
264
|
+
{ "id": "product", "value": "Zephyrline", "channels": ["contents", "paths", "refs"] },
|
|
265
|
+
{ "id": "employer-mail", "value": "zephyrline-internal.example" },
|
|
266
|
+
"AnotherName"
|
|
267
|
+
],
|
|
268
|
+
"controls": [
|
|
269
|
+
{ "value": "a string you know is in this repository", "channel": "contents" }
|
|
270
|
+
],
|
|
271
|
+
"suppress": [
|
|
272
|
+
{ "termId": "org", "channel": "contents", "pathPrefix": "docs/", "reason": "…" }
|
|
273
|
+
],
|
|
274
|
+
"secrets": true
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
A bare string is shorthand for `{ "id": s, "value": s }`. See
|
|
279
|
+
[`gitsieve.config.example.json`](gitsieve.config.example.json).
|
|
280
|
+
|
|
281
|
+
### Options
|
|
282
|
+
|
|
283
|
+
```
|
|
284
|
+
--repo <path> repository to audit (default: .)
|
|
285
|
+
--terms <file.json> terms, suppressions and controls
|
|
286
|
+
--json machine-readable report on stdout
|
|
287
|
+
--show-matches print matched text instead of a redaction placeholder
|
|
288
|
+
--allow-shallow scan a shallow clone anyway, as a warning not a blocker
|
|
289
|
+
--allow-terms-in-repo permit a terms file stored inside the audited repo
|
|
290
|
+
--no-reflog scan refs only, ignoring the reflog
|
|
291
|
+
--no-secrets skip the secondary credential patterns
|
|
292
|
+
--max-blob-bytes <n> do not read blobs larger than this (default 2097152)
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Exit codes
|
|
296
|
+
|
|
297
|
+
| code | meaning |
|
|
298
|
+
| ---- | ------------------------------------------------------------------ |
|
|
299
|
+
| 0 | go — controls verified and no findings |
|
|
300
|
+
| 1 | no-go — findings |
|
|
301
|
+
| 2 | no-go — the scan could not be trusted; the number it printed means nothing |
|
|
302
|
+
| 3 | usage or configuration error |
|
|
303
|
+
|
|
304
|
+
`0` and `2` are the pair that matters. Most tools collapse them.
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## What this does not do
|
|
309
|
+
|
|
310
|
+
- **It does not rewrite history.** It tells you where a finding is. Removing it
|
|
311
|
+
means `git filter-repo`, or publishing a fresh repository with no history.
|
|
312
|
+
- **It is not a secret scanner.** Nine patterns, no live verification. Run
|
|
313
|
+
gitleaks or trufflehog alongside it.
|
|
314
|
+
- **It does not read submodules, LFS objects, or `.git/objects` that no ref or
|
|
315
|
+
reflog entry reaches.** Unreachable objects are not pushed, but they are in a
|
|
316
|
+
`.git` directory you hand over by copying.
|
|
317
|
+
- **It does not know your terms.** Everything depends on the list you write.
|
|
318
|
+
It cannot tell you the word you forgot, and a clean report against a short
|
|
319
|
+
list means very little.
|
|
320
|
+
- **It does not scan GitHub.** Issues, pull request titles, release notes,
|
|
321
|
+
wiki, Actions logs and repository description are all outside a git
|
|
322
|
+
repository and all publishable. Check them by hand.
|
|
323
|
+
- **It does not detect a leak that is a paraphrase.** A sentence that
|
|
324
|
+
identifies an organisation without naming it -- "our largest customer's
|
|
325
|
+
internal platform" -- is not a string match.
|
|
326
|
+
- **It is not legal advice about what you may publish.**
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## Development
|
|
331
|
+
|
|
332
|
+
```bash
|
|
333
|
+
npm run typecheck
|
|
334
|
+
npm test # 46 tests; each builds real git repositories in a temp dir
|
|
335
|
+
npm run selfcheck # build, then audit this repository with itself
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
The tests do not mock git. Every fixture is a real repository with planted
|
|
339
|
+
findings across history, branches, path names, commit messages and author
|
|
340
|
+
fields, and the suite asserts each one is found. It also includes a test that
|
|
341
|
+
hands the scanner a deliberately broken git — one whose object reader silently
|
|
342
|
+
returns nothing, reproducing the shimmed-`grep` failure — and proves the
|
|
343
|
+
control check fails loudly instead of reporting the repository clean.
|
|
344
|
+
|
|
345
|
+
Every term in the test fixtures is invented. Nothing in this repository names a
|
|
346
|
+
real employer, client or product, which is the failure it exists to catch.
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## Provenance
|
|
351
|
+
|
|
352
|
+
The incident is real and the decisions are mine. The code was written with
|
|
353
|
+
[Claude](https://claude.com/claude-code) against those decisions, and I
|
|
354
|
+
reviewed every line of it. The two architecture decision records in
|
|
355
|
+
[`docs/decisions/`](docs/decisions/) carry the reasoning and the terminal
|
|
356
|
+
transcripts behind the two choices that were not obvious — including an
|
|
357
|
+
approach that looked right, produced a plausible result, and turned out to miss
|
|
358
|
+
the exact category of finding that caused the incident.
|
|
359
|
+
|
|
360
|
+
## Licence
|
|
361
|
+
|
|
362
|
+
MIT. See [LICENSE](LICENSE).
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export interface CliArgs {
|
|
3
|
+
repo: string;
|
|
4
|
+
terms?: string;
|
|
5
|
+
json: boolean;
|
|
6
|
+
showMatches: boolean;
|
|
7
|
+
allowShallow: boolean;
|
|
8
|
+
allowTermsInRepo: boolean;
|
|
9
|
+
reflog: boolean;
|
|
10
|
+
secrets: boolean;
|
|
11
|
+
maxBlobBytes?: number;
|
|
12
|
+
help: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function parseArgs(argv: string[]): CliArgs;
|
|
15
|
+
export declare function main(argv: string[], out?: NodeJS.WriteStream & {
|
|
16
|
+
fd: 1;
|
|
17
|
+
}, err?: NodeJS.WriteStream & {
|
|
18
|
+
fd: 2;
|
|
19
|
+
}): number;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { assertTermsFileOutsideRepo, ConfigError, EMPTY_CONFIG, loadConfig, } from "./config.js";
|
|
6
|
+
import { GitError, RealGitRepo } from "./git.js";
|
|
7
|
+
import { exitCodeFor, formatJson, formatReport, EXIT_USAGE } from "./report.js";
|
|
8
|
+
import { scan } from "./scan.js";
|
|
9
|
+
const USAGE = `gitsieve -- audit a repository's entire history before open-sourcing it
|
|
10
|
+
|
|
11
|
+
gitsieve [options]
|
|
12
|
+
|
|
13
|
+
Options
|
|
14
|
+
--repo <path> repository to audit (default: .)
|
|
15
|
+
--terms <file.json> terms, suppressions and controls (see README)
|
|
16
|
+
--json machine-readable report on stdout
|
|
17
|
+
--show-matches print matched text instead of a redaction placeholder
|
|
18
|
+
(never applies to secret matches)
|
|
19
|
+
--allow-shallow scan a shallow clone anyway, as a warning not a blocker
|
|
20
|
+
--allow-terms-in-repo permit a terms file stored inside the audited repo
|
|
21
|
+
--no-reflog scan refs only, ignoring the reflog
|
|
22
|
+
--no-secrets skip the secondary credential patterns
|
|
23
|
+
--max-blob-bytes <n> do not read blobs larger than this (default 2097152)
|
|
24
|
+
-h, --help this text
|
|
25
|
+
|
|
26
|
+
Exit codes
|
|
27
|
+
0 go controls verified, no findings
|
|
28
|
+
1 no-go findings
|
|
29
|
+
2 no-go the scan could not be trusted; the number it printed means nothing
|
|
30
|
+
3 usage error
|
|
31
|
+
`;
|
|
32
|
+
export function parseArgs(argv) {
|
|
33
|
+
const args = {
|
|
34
|
+
repo: ".",
|
|
35
|
+
json: false,
|
|
36
|
+
showMatches: false,
|
|
37
|
+
allowShallow: false,
|
|
38
|
+
allowTermsInRepo: false,
|
|
39
|
+
reflog: true,
|
|
40
|
+
secrets: true,
|
|
41
|
+
help: false,
|
|
42
|
+
};
|
|
43
|
+
const need = (flag, value) => {
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
throw new ConfigError(`${flag} needs a value`);
|
|
46
|
+
return value;
|
|
47
|
+
};
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
const flag = argv[i];
|
|
50
|
+
switch (flag) {
|
|
51
|
+
case "--repo":
|
|
52
|
+
args.repo = need(flag, argv[++i]);
|
|
53
|
+
break;
|
|
54
|
+
case "--terms":
|
|
55
|
+
args.terms = need(flag, argv[++i]);
|
|
56
|
+
break;
|
|
57
|
+
case "--json":
|
|
58
|
+
args.json = true;
|
|
59
|
+
break;
|
|
60
|
+
case "--show-matches":
|
|
61
|
+
args.showMatches = true;
|
|
62
|
+
break;
|
|
63
|
+
case "--allow-shallow":
|
|
64
|
+
args.allowShallow = true;
|
|
65
|
+
break;
|
|
66
|
+
case "--allow-terms-in-repo":
|
|
67
|
+
args.allowTermsInRepo = true;
|
|
68
|
+
break;
|
|
69
|
+
case "--no-reflog":
|
|
70
|
+
args.reflog = false;
|
|
71
|
+
break;
|
|
72
|
+
case "--no-secrets":
|
|
73
|
+
args.secrets = false;
|
|
74
|
+
break;
|
|
75
|
+
case "--max-blob-bytes":
|
|
76
|
+
args.maxBlobBytes = Number(need(flag, argv[++i]));
|
|
77
|
+
if (!Number.isFinite(args.maxBlobBytes) || args.maxBlobBytes <= 0) {
|
|
78
|
+
throw new ConfigError("--max-blob-bytes must be a positive number");
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
case "-h":
|
|
82
|
+
case "--help":
|
|
83
|
+
args.help = true;
|
|
84
|
+
break;
|
|
85
|
+
default:
|
|
86
|
+
throw new ConfigError(`unknown option ${flag}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return args;
|
|
90
|
+
}
|
|
91
|
+
export function main(argv, out = process.stdout, err = process.stderr) {
|
|
92
|
+
let args;
|
|
93
|
+
try {
|
|
94
|
+
args = parseArgs(argv);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
err.write(`${error.message}\n\n${USAGE}`);
|
|
98
|
+
return EXIT_USAGE;
|
|
99
|
+
}
|
|
100
|
+
if (args.help) {
|
|
101
|
+
out.write(USAGE);
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
const repoRoot = resolve(args.repo);
|
|
105
|
+
try {
|
|
106
|
+
let config = EMPTY_CONFIG;
|
|
107
|
+
if (args.terms) {
|
|
108
|
+
if (!args.allowTermsInRepo)
|
|
109
|
+
assertTermsFileOutsideRepo(args.terms, repoRoot);
|
|
110
|
+
config = loadConfig(args.terms);
|
|
111
|
+
}
|
|
112
|
+
if (!args.secrets)
|
|
113
|
+
config = { ...config, secrets: false };
|
|
114
|
+
const repo = new RealGitRepo(repoRoot, { includeReflog: args.reflog });
|
|
115
|
+
const report = scan({
|
|
116
|
+
repo,
|
|
117
|
+
config,
|
|
118
|
+
allowShallow: args.allowShallow,
|
|
119
|
+
showMatches: args.showMatches,
|
|
120
|
+
...(args.maxBlobBytes ? { maxBlobBytes: args.maxBlobBytes } : {}),
|
|
121
|
+
});
|
|
122
|
+
out.write(args.json ? formatJson(report) : formatReport(report));
|
|
123
|
+
return exitCodeFor(report);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error instanceof ConfigError || error instanceof GitError) {
|
|
127
|
+
err.write(`${error.message}\n`);
|
|
128
|
+
return EXIT_USAGE;
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function invokedDirectly() {
|
|
134
|
+
const entry = process.argv[1];
|
|
135
|
+
if (!entry)
|
|
136
|
+
return false;
|
|
137
|
+
try {
|
|
138
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (invokedDirectly()) {
|
|
145
|
+
process.exitCode = main(process.argv.slice(2));
|
|
146
|
+
}
|
|
147
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Config } from "./types.js";
|
|
2
|
+
export declare class ConfigError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* The terms file lists the exact strings you are trying to keep out of public
|
|
6
|
+
* view. Committing it into the repository being published would publish them.
|
|
7
|
+
* Keep it outside the repo, or pass --allow-terms-in-repo and accept that.
|
|
8
|
+
*/
|
|
9
|
+
export declare function assertTermsFileOutsideRepo(configPath: string, repoRoot: string): void;
|
|
10
|
+
export declare function parseConfig(json: unknown): Config;
|
|
11
|
+
export declare function loadConfig(path: string): Config;
|
|
12
|
+
export declare const EMPTY_CONFIG: Config;
|