rvlib-shipkit 0.35.8 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Fleet.ts +116 -0
- package/src/cli/cmd_Prose.ts +65 -0
- package/src/cli/cmd_Smoke.ts +42 -0
- package/src/cli/cmd_Start.ts +1 -1
- package/src/cli/cmd_Units.ts +82 -0
- package/src/cli.ts +8 -0
- package/src/core/check-suite.ts +28 -5
- package/src/core/run-check.ts +12 -1
- package/src/core/run-ci.ts +12 -2
- package/src/core/write-status.ts +5 -1
- package/src/fleet/scan-fleet.ts +142 -0
- package/src/git-bootstrap.ts +11 -5
- package/src/hooks/leak-guard.ts +313 -38
- package/src/hooks/run-hooks.ts +55 -7
- package/src/ledger/gate-units.ts +80 -27
- package/src/ledger/store.ts +7 -2
- package/src/ledger/unit-table.ts +105 -0
- package/src/prose/run-prose.ts +146 -0
- package/src/prose/ste.ts +436 -0
- package/src/repo-config.ts +42 -1
- package/src/rules/SK014-prose-ste.ts +50 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/rules/untrack-generated.ts +4 -1
- package/src/smoke/run-smoke.ts +403 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The fleet: every repo on this disk that depends on the toolkit, and how far
|
|
2
|
+
// behind each one is.
|
|
3
|
+
//
|
|
4
|
+
// Why a scan and not a registry: `local-projects.md` only holds repos whose
|
|
5
|
+
// `docsBase` points at a wiki, which is 4 of the 10 that actually install the
|
|
6
|
+
// libs. A repo that never reported is exactly the repo most likely to be
|
|
7
|
+
// stale, so a registry is the wrong source for this question.
|
|
8
|
+
//
|
|
9
|
+
// This module DISCOVERS and REPORTS. It never updates: rule SK005 owns the
|
|
10
|
+
// update, with the lease, the dirty-tree guard and the frozen guard that took
|
|
11
|
+
// three bugs to get right. `--update` runs each repo's own `shipkit check`,
|
|
12
|
+
// so the fix travels through that one code path.
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
14
|
+
import { join } from 'path'
|
|
15
|
+
|
|
16
|
+
export type Pkg = { name?: string; version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
17
|
+
|
|
18
|
+
export type FleetDep = {
|
|
19
|
+
name: string
|
|
20
|
+
/** the range in package.json, or `link:…` for a source-linked dep */
|
|
21
|
+
pin: string
|
|
22
|
+
/** what node_modules actually carries, null when nothing is installed */
|
|
23
|
+
installed: string | null
|
|
24
|
+
/** npm latest, null when the registry was not consulted or not reachable */
|
|
25
|
+
latest: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** a jump that changes the MAJOR, which is a porting job rather than a bump.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately NOT the semver rule. On a `0.x` package npm treats every
|
|
31
|
+
* minor as breaking, and by that rule every repo here is breaking, including
|
|
32
|
+
* the `0.24 → 0.35` shipkit jumps that rule SK005 already performs by itself
|
|
33
|
+
* every day. A flag that fires on everything is a flag nobody reads. What
|
|
34
|
+
* survives is the case a human has to own: `rvlib-pb-mobx 0.2.0 → 2.0.0`. */
|
|
35
|
+
export function isBreaking(installed: string, latest: string): boolean {
|
|
36
|
+
const major = (v: string) => Number(v.split('-')[0]!.split('.')[0]) || 0
|
|
37
|
+
return major(installed) !== major(latest)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type FleetRepo = {
|
|
41
|
+
name: string
|
|
42
|
+
dir: string
|
|
43
|
+
deps: FleetDep[]
|
|
44
|
+
/** a repo pointing at a checkout rather than a published version. Never
|
|
45
|
+
* updated: the source IS the version, and that is a deliberate choice. */
|
|
46
|
+
linked: boolean
|
|
47
|
+
/** whether this repo can run the update itself (it has a shipkit binary) */
|
|
48
|
+
hasShipkit: boolean
|
|
49
|
+
behind: FleetDep[]
|
|
50
|
+
/** the subset of `behind` whose jump crosses a breaking boundary */
|
|
51
|
+
breaking: FleetDep[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const readPkg = (dir: string): Pkg | null => {
|
|
55
|
+
const p = join(dir, 'package.json')
|
|
56
|
+
if (!existsSync(p)) return null
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(p, 'utf8')) as Pkg
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const installedVersion = (dir: string, name: string): string | null => readPkg(join(dir, 'node_modules', name))?.version ?? null
|
|
65
|
+
|
|
66
|
+
/** semver-ish compare, enough to answer "is this one behind that one". A
|
|
67
|
+
* prerelease tag sorts before its release, which is the safe direction: it
|
|
68
|
+
* reports behind rather than pretending parity. */
|
|
69
|
+
export function isBehind(installed: string, latest: string): boolean {
|
|
70
|
+
const part = (v: string) => v.split('-')[0]!.split('.').map((n) => Number(n) || 0)
|
|
71
|
+
const [a, b] = [part(installed), part(latest)]
|
|
72
|
+
for (let i = 0; i < 3; i++) {
|
|
73
|
+
if ((a[i] ?? 0) < (b[i] ?? 0)) return true
|
|
74
|
+
if ((a[i] ?? 0) > (b[i] ?? 0)) return false
|
|
75
|
+
}
|
|
76
|
+
return installed.includes('-') && !latest.includes('-')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function scanFleet(root: string, o: { prefix?: string } = {}): FleetRepo[] {
|
|
80
|
+
const prefix = o.prefix ?? 'rvlib-'
|
|
81
|
+
const out: FleetRepo[] = []
|
|
82
|
+
let entries: string[]
|
|
83
|
+
try {
|
|
84
|
+
entries = readdirSync(root)
|
|
85
|
+
} catch {
|
|
86
|
+
return out
|
|
87
|
+
}
|
|
88
|
+
for (const e of entries.sort()) {
|
|
89
|
+
if (e.startsWith('.')) continue
|
|
90
|
+
const dir = join(root, e)
|
|
91
|
+
try {
|
|
92
|
+
if (!statSync(dir).isDirectory()) continue
|
|
93
|
+
} catch {
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
const pkg = readPkg(dir)
|
|
97
|
+
if (pkg == null) continue
|
|
98
|
+
const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
|
99
|
+
const names = Object.keys(all).filter((n) => n.startsWith(prefix))
|
|
100
|
+
if (names.length === 0) continue
|
|
101
|
+
const deps: FleetDep[] = names.sort().map((name) => ({ name, pin: all[name]!, installed: installedVersion(dir, name), latest: null }))
|
|
102
|
+
out.push({
|
|
103
|
+
name: e,
|
|
104
|
+
dir,
|
|
105
|
+
deps,
|
|
106
|
+
linked: deps.some((d) => d.pin.startsWith('link:') || d.pin.startsWith('file:')),
|
|
107
|
+
hasShipkit: existsSync(join(dir, 'node_modules', 'rvlib-shipkit', 'src', 'cli.ts')),
|
|
108
|
+
behind: [],
|
|
109
|
+
breaking: [],
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** npm latest per PACKAGE, fetched once and shared across every repo that
|
|
116
|
+
* depends on it. Ten repos on one lib is one request, not ten. */
|
|
117
|
+
export async function resolveLatest(repos: FleetRepo[], fetchLatest: (name: string) => Promise<string | null>): Promise<Map<string, string | null>> {
|
|
118
|
+
const names = new Set<string>()
|
|
119
|
+
for (const r of repos) for (const d of r.deps) if (!d.pin.startsWith('link:') && !d.pin.startsWith('file:')) names.add(d.name)
|
|
120
|
+
const latest = new Map<string, string | null>()
|
|
121
|
+
await Promise.all(
|
|
122
|
+
[...names].map(async (n) => {
|
|
123
|
+
try {
|
|
124
|
+
latest.set(n, await fetchLatest(n))
|
|
125
|
+
} catch {
|
|
126
|
+
latest.set(n, null)
|
|
127
|
+
}
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
for (const r of repos) {
|
|
131
|
+
for (const d of r.deps) d.latest = latest.get(d.name) ?? null
|
|
132
|
+
r.behind = r.deps.filter((d) => d.installed != null && d.latest != null && isBehind(d.installed, d.latest))
|
|
133
|
+
r.breaking = r.behind.filter((d) => isBreaking(d.installed!, d.latest!))
|
|
134
|
+
}
|
|
135
|
+
return latest
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function npmLatest(name: string): Promise<string | null> {
|
|
139
|
+
const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(10_000) })
|
|
140
|
+
if (!res.ok) return null
|
|
141
|
+
return ((await res.json()) as Pkg).version ?? null
|
|
142
|
+
}
|
package/src/git-bootstrap.ts
CHANGED
|
@@ -101,7 +101,7 @@ const samePath = (a: string, b: string): boolean => {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
type RepoView = { nameWithOwner?: string; isPrivate?: boolean;
|
|
104
|
+
type RepoView = { nameWithOwner?: string; isPrivate?: boolean; sshUrl?: string }
|
|
105
105
|
|
|
106
106
|
/** phase one: this dir IS a git work tree root. false means the caller must
|
|
107
107
|
* not publish anything (a subdirectory of another repo, or git refused), and
|
|
@@ -194,7 +194,7 @@ export async function publishRepo(root: string, opts: PublishOpts = {}): Promise
|
|
|
194
194
|
const login = firstGhLine(user.out)
|
|
195
195
|
if (!NAME_RE.test(login)) return void p(`WARN no remote wired: gh did not answer with a login (${login})`)
|
|
196
196
|
const target = `${login}/${name}`
|
|
197
|
-
const viewArgs = ['repo', 'view', target, '--json', 'nameWithOwner,isPrivate,
|
|
197
|
+
const viewArgs = ['repo', 'view', target, '--json', 'nameWithOwner,isPrivate,sshUrl']
|
|
198
198
|
|
|
199
199
|
let view = await gh(viewArgs)
|
|
200
200
|
let created = false
|
|
@@ -217,9 +217,15 @@ export async function publishRepo(root: string, opts: PublishOpts = {}): Promise
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
const info = carveGhJson<RepoView>(view.out)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
// ALWAYS the ssh url gh gave, never the https one and never `gh config
|
|
221
|
+
// get git_protocol`: an https origin re-asks for a credential on every
|
|
222
|
+
// push, and a machine answering `https` there wires a remote that reports
|
|
223
|
+
// success and fails at the first push. An answer carrying no sshUrl is
|
|
224
|
+
// unreadable, not an invitation to build one: this module knows no host,
|
|
225
|
+
// and a guessed `github.com` remote against an enterprise gh is the same
|
|
226
|
+
// silent failure one host over
|
|
227
|
+
const url = info?.sshUrl
|
|
228
|
+
if (url == null) return void p(`WARN no remote wired: gh answered without an ssh url (${firstGhLine(view.out)})`)
|
|
223
229
|
|
|
224
230
|
const add = await git(['remote', 'add', 'origin', url])
|
|
225
231
|
if (add.code !== 0) return void p(`WARN no remote wired: git remote add failed (${firstGhLine(add.out)})`)
|
package/src/hooks/leak-guard.ts
CHANGED
|
@@ -4,10 +4,23 @@
|
|
|
4
4
|
//
|
|
5
5
|
// the keyword file is deliberately untracked (a list of things to grep for
|
|
6
6
|
// is itself private). absent file → loud skip, never a silent pass.
|
|
7
|
+
//
|
|
8
|
+
// a keyword matches as a SUBSTRING, which is what catches a private name
|
|
9
|
+
// glued into a hostname or an identifier. The cost is a false positive a repo
|
|
10
|
+
// cannot escape: a four letter row lands inside a dependency name or inside a
|
|
11
|
+
// base64 integrity hash in a lockfile, and that lockfile is uncommittable
|
|
12
|
+
// forever. A guard that cries wolf is a guard people answer with --no-verify,
|
|
13
|
+
// which protects nothing. Two answers, in this order. `w:<word>` rows match on
|
|
14
|
+
// word boundaries, which makes the PATTERN smarter rather than the guard
|
|
15
|
+
// looser. An ALLOWLIST waives a known-good match in a named context, and every
|
|
16
|
+
// waiver that fires is PRINTED with its reason, so a reader sees what was let
|
|
17
|
+
// through and why.
|
|
18
|
+
import { createHash } from 'crypto'
|
|
7
19
|
import { existsSync, readFileSync } from 'fs'
|
|
8
|
-
import { join } from 'path'
|
|
20
|
+
import { basename, join } from 'path'
|
|
9
21
|
|
|
10
|
-
export type LeakHit = { where: string; pattern: string }
|
|
22
|
+
export type LeakHit = { where: string; pattern: string; file?: string }
|
|
23
|
+
export type LeakWaiver = LeakHit & { reason: string; source: string }
|
|
11
24
|
|
|
12
25
|
/** what a hit REPORTS. The matched string is private by construction, and a
|
|
13
26
|
* gate that prints it verbatim puts it in a terminal, a CI log and any paste
|
|
@@ -15,16 +28,66 @@ export type LeakHit = { where: string; pattern: string }
|
|
|
15
28
|
export const maskPattern = (p: string): string =>
|
|
16
29
|
p.startsWith('re:') || p.length <= 6 ? p : `${p.slice(0, 3)}…${p.slice(-2)} (${p.length} chars)`
|
|
17
30
|
|
|
31
|
+
/** a stable name for a pattern that does not CARRY it, so a tracked allowlist
|
|
32
|
+
* points at a private keyword without becoming a copy of the private keyword
|
|
33
|
+
* list. Printed beside every hit, which is how a waiver gets written without
|
|
34
|
+
* anyone typing the word.
|
|
35
|
+
*
|
|
36
|
+
* Honest scope: this stops the list being READ off a tracked file. It is no
|
|
37
|
+
* defence against a targeted guess, because a short keyword has a small
|
|
38
|
+
* search space and whoever already suspects the word can confirm it. */
|
|
39
|
+
export const patternFingerprint = (pattern: string): string =>
|
|
40
|
+
'fp:' + createHash('sha256').update(pattern.toLowerCase()).digest('hex').slice(0, 12)
|
|
41
|
+
|
|
42
|
+
/** one known-good match a repo (or this machine) waives.
|
|
43
|
+
*
|
|
44
|
+
* `paths` alone waives every pattern in those files. `patterns` alone waives
|
|
45
|
+
* those patterns everywhere. The PAIR is the shape to reach for: this
|
|
46
|
+
* pattern, in these files, while the same pattern still blocks anywhere else.
|
|
47
|
+
* `inHashes` narrows further, to a match that lands inside a content hash. */
|
|
48
|
+
export type LeakAllowEntry = {
|
|
49
|
+
/** why this match is known good. Printed every time the waiver fires, so
|
|
50
|
+
* a reader months later can judge it. Required. */
|
|
51
|
+
reason: string
|
|
52
|
+
/** staged paths this entry covers: a repo-relative path, a bare basename
|
|
53
|
+
* ('bun.lock'), or a 'dir/' prefix. Absent = every target, the commit
|
|
54
|
+
* message included. */
|
|
55
|
+
paths?: string[]
|
|
56
|
+
/** the patterns this entry waives, named by FINGERPRINT (`fp:` + 12 hex,
|
|
57
|
+
* printed beside every hit). Absent = every pattern, on `paths` alone.
|
|
58
|
+
* A TRACKED source (repo.config.ts) takes the fingerprint form only: a
|
|
59
|
+
* literal there puts the private keyword into git. */
|
|
60
|
+
patterns?: string[]
|
|
61
|
+
/** waive only a match that falls inside a content hash (an npm `sha512-…`
|
|
62
|
+
* integrity field, a 40+ char base64 run). The bytes of a content address
|
|
63
|
+
* are arithmetic, not prose, so a keyword inside one is noise. The same
|
|
64
|
+
* keyword on a normal line of the same file still blocks. */
|
|
65
|
+
inHashes?: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
18
68
|
export type LeakGuardResult =
|
|
19
69
|
| { status: 'skipped'; reason: string }
|
|
20
|
-
| { status: 'clean'; patterns: number }
|
|
21
|
-
| { status: 'hit'; hits: LeakHit[]; patterns: number }
|
|
70
|
+
| { status: 'clean'; patterns: number; waived: LeakWaiver[]; notes: string[] }
|
|
71
|
+
| { status: 'hit'; hits: LeakHit[]; patterns: number; waived: LeakWaiver[]; notes: string[] }
|
|
72
|
+
|
|
73
|
+
export type ParsedPatterns = {
|
|
74
|
+
substrings: string[]
|
|
75
|
+
words: { word: string; re: RegExp }[]
|
|
76
|
+
regexes: { row: string; re: RegExp }[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
22
80
|
|
|
23
81
|
/** Parse the keyword file: blank lines and `#` comments dropped, `re:<pattern>`
|
|
24
|
-
* rows become case-insensitive regexes,
|
|
25
|
-
* substring.
|
|
26
|
-
|
|
82
|
+
* rows become case-insensitive regexes, `w:<word>` rows match on word
|
|
83
|
+
* boundaries, everything else a case-insensitive substring.
|
|
84
|
+
*
|
|
85
|
+
* A `w:` row REPORTS as the bare word, so its mask and its fingerprint are
|
|
86
|
+
* the same with or without the prefix: adding the prefix to a row never
|
|
87
|
+
* invalidates a waiver already written against it. */
|
|
88
|
+
export function parsePatterns(text: string): ParsedPatterns {
|
|
27
89
|
const substrings: string[] = []
|
|
90
|
+
const words: { word: string; re: RegExp }[] = []
|
|
28
91
|
const regexes: { row: string; re: RegExp }[] = []
|
|
29
92
|
for (const raw of text.split('\n')) {
|
|
30
93
|
const row = raw.trim()
|
|
@@ -32,24 +95,134 @@ export function parsePatterns(text: string): { substrings: string[]; regexes: {
|
|
|
32
95
|
if (row.startsWith('re:')) {
|
|
33
96
|
try { regexes.push({ row, re: new RegExp(row.slice(3), 'i') }) }
|
|
34
97
|
catch { /* a malformed row must not disable the whole guard */ }
|
|
98
|
+
} else if (row.startsWith('w:')) {
|
|
99
|
+
const word = row.slice(2).trim().toLowerCase()
|
|
100
|
+
// \b is ascii-only in JS, which lets an accented name match inside
|
|
101
|
+
// a longer accented word: the boundary is spelled in unicode
|
|
102
|
+
if (word !== '') words.push({ word, re: new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(word)}(?![\\p{L}\\p{N}_])`, 'giu') })
|
|
35
103
|
} else substrings.push(row.toLowerCase())
|
|
36
104
|
}
|
|
37
|
-
return { substrings, regexes }
|
|
105
|
+
return { substrings, words, regexes }
|
|
38
106
|
}
|
|
39
107
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
108
|
+
type Span = [start: number, end: number]
|
|
109
|
+
|
|
110
|
+
// a substring row is matched by a case-insensitive REGEX rather than by
|
|
111
|
+
// indexOf over a lowercased copy: one unicode character (İ) lowercases into
|
|
112
|
+
// two, which slides every offset after it, and the offsets are what decide
|
|
113
|
+
// whether a match sits inside a content hash. Compiled once per keyword.
|
|
114
|
+
const subRe = new Map<string, RegExp>()
|
|
115
|
+
const substringRegex = (s: string): RegExp => {
|
|
116
|
+
const hit = subRe.get(s)
|
|
117
|
+
if (hit != null) return hit
|
|
118
|
+
const re = new RegExp(escapeRegex(s), 'gi')
|
|
119
|
+
subRe.set(s, re)
|
|
120
|
+
return re
|
|
44
121
|
}
|
|
45
122
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
123
|
+
/** every occurrence of every pattern, keyed by the id a hit reports. */
|
|
124
|
+
function occurrences(haystack: string, p: ParsedPatterns): Map<string, Span[]> {
|
|
125
|
+
const found = new Map<string, Span[]>()
|
|
126
|
+
const push = (id: string, span: Span): void => {
|
|
127
|
+
const prev = found.get(id)
|
|
128
|
+
if (prev == null) found.set(id, [span])
|
|
129
|
+
else prev.push(span)
|
|
130
|
+
}
|
|
131
|
+
for (const s of p.substrings) for (const m of haystack.matchAll(substringRegex(s))) push(s, [m.index, m.index + m[0].length])
|
|
132
|
+
for (const w of p.words) for (const m of haystack.matchAll(w.re)) push(w.word, [m.index, m.index + m[0].length])
|
|
133
|
+
for (const r of p.regexes) {
|
|
134
|
+
const g = new RegExp(r.re.source, r.re.flags.includes('g') ? r.re.flags : r.re.flags + 'g')
|
|
135
|
+
for (const m of haystack.matchAll(g)) push(r.row, [m.index, m.index + m[0].length])
|
|
136
|
+
}
|
|
137
|
+
return found
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// an npm/yarn integrity field, and any long base64 run. ONLY an entry that
|
|
141
|
+
// asked for `inHashes` consults this: an api token is also a long base64 run,
|
|
142
|
+
// so skipping these by default would be the guard going quiet on the exact
|
|
143
|
+
// shape it exists to catch.
|
|
144
|
+
const HASH_SPAN = /(?:sha\d{1,3}|md5)-[A-Za-z0-9+/=]{16,}|[A-Za-z0-9+/]{40,}={0,2}/gi
|
|
145
|
+
|
|
146
|
+
const hashSpans = (text: string): Span[] => [...text.matchAll(HASH_SPAN)].map((m): Span => [m.index, m.index + m[0].length])
|
|
147
|
+
|
|
148
|
+
const inside = (span: Span, spans: Span[]): boolean => spans.some(([a, b]) => span[0] >= a && span[1] <= b)
|
|
149
|
+
|
|
150
|
+
/** a path selector: a repo-relative path, a bare basename, or a 'dir/' prefix. */
|
|
151
|
+
function pathCovered(file: string | undefined, selectors: string[]): boolean {
|
|
152
|
+
if (file == null) return false
|
|
153
|
+
return selectors.some((s) => s === file || (s.endsWith('/') && file.startsWith(s)) || (!s.includes('/') && basename(file) === s))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type LoadedAllow = LeakAllowEntry & { source: string }
|
|
157
|
+
|
|
158
|
+
/** what the guard reads: one file's added lines, one staged path, or a text
|
|
159
|
+
* the caller handed in (a commit message). */
|
|
160
|
+
export type ScanTarget = { where: string; content: string; file?: string }
|
|
161
|
+
|
|
162
|
+
/** the whole decision, with no git and no disk in it: which matches block,
|
|
163
|
+
* which are waived, by whom, and why. */
|
|
164
|
+
export function evaluateLeak(targets: ScanTarget[], p: ParsedPatterns, allow: LoadedAllow[]): { hits: LeakHit[]; waived: LeakWaiver[] } {
|
|
165
|
+
const hits: LeakHit[] = []
|
|
166
|
+
const waived: LeakWaiver[] = []
|
|
167
|
+
// one row per distinct (where, file, pattern): a keyword hit 40 times in
|
|
168
|
+
// one file is one finding
|
|
169
|
+
const seen = new Set<string>()
|
|
170
|
+
for (const t of targets) {
|
|
171
|
+
const found = occurrences(t.content, p)
|
|
172
|
+
if (found.size === 0) continue
|
|
173
|
+
let hashes: Span[] | null = null
|
|
174
|
+
for (const [id, spans] of found) {
|
|
175
|
+
const key = `${t.where} ${t.file ?? ''} ${id}`
|
|
176
|
+
if (seen.has(key)) continue
|
|
177
|
+
seen.add(key)
|
|
178
|
+
const fp = patternFingerprint(id)
|
|
179
|
+
const entry = allow.find((a) => {
|
|
180
|
+
if (a.paths != null && !pathCovered(t.file, a.paths)) return false
|
|
181
|
+
if (a.patterns != null && !a.patterns.some((x) => x === fp || x.toLowerCase() === id)) return false
|
|
182
|
+
if (a.inHashes === true) {
|
|
183
|
+
hashes ??= hashSpans(t.content)
|
|
184
|
+
if (!spans.every((s) => inside(s, hashes!))) return false
|
|
185
|
+
}
|
|
186
|
+
return true
|
|
187
|
+
})
|
|
188
|
+
if (entry == null) hits.push({ where: t.where, pattern: id, file: t.file })
|
|
189
|
+
else waived.push({ where: t.where, pattern: id, file: t.file, reason: entry.reason, source: entry.source })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { hits, waived }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const ALLOW_KEYS = ['reason', 'paths', 'patterns', 'inHashes']
|
|
196
|
+
const FINGERPRINT = /^fp:[0-9a-f]{12}$/
|
|
197
|
+
|
|
198
|
+
/** why an entry cannot be used, or null. `tracked` = the source is committed
|
|
199
|
+
* (repo.config.ts), where a literal pattern publishes the private word the
|
|
200
|
+
* guard exists to keep out of git. */
|
|
201
|
+
export function leakAllowProblem(entry: unknown, where: string, tracked: boolean): string | null {
|
|
202
|
+
if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) return `${where}: every entry is an object`
|
|
203
|
+
const e = entry as Record<string, unknown>
|
|
204
|
+
for (const k of Object.keys(e)) if (!ALLOW_KEYS.includes(k)) return `${where}: unknown key '${k}' (known: ${ALLOW_KEYS.join(', ')})`
|
|
205
|
+
if (typeof e['reason'] !== 'string' || e['reason'].trim() === '') return `${where}: needs a 'reason' — a waiver nobody can judge later is a hole`
|
|
206
|
+
if (e['paths'] == null && e['patterns'] == null)
|
|
207
|
+
return `${where}: needs 'paths' or 'patterns' — an entry with neither waives every pattern in every file, which is the guard switched off`
|
|
208
|
+
for (const k of ['paths', 'patterns'] as const) {
|
|
209
|
+
const v = e[k]
|
|
210
|
+
if (v == null) continue
|
|
211
|
+
if (!Array.isArray(v) || v.length === 0 || v.some((x) => typeof x !== 'string' || x.trim() === ''))
|
|
212
|
+
return `${where}: '${k}' is a non-empty array of non-empty strings`
|
|
213
|
+
}
|
|
214
|
+
if (e['inHashes'] != null && typeof e['inHashes'] !== 'boolean') return `${where}: 'inHashes' is a boolean`
|
|
215
|
+
if (tracked)
|
|
216
|
+
for (const x of (e['patterns'] as string[] | undefined) ?? [])
|
|
217
|
+
if (!FINGERPRINT.test(x))
|
|
218
|
+
return `${where}: '${x}' — a tracked allowlist names a pattern by FINGERPRINT (fp:…, printed beside every hit), never by value, or the private keyword lands in git`
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** every problem in a declared allowlist. `defineRepo` throws on the first. */
|
|
223
|
+
export function leakAllowProblems(entries: unknown, where: string, tracked: boolean): string[] {
|
|
224
|
+
if (!Array.isArray(entries)) return [`${where}: leakAllow is an array of entries`]
|
|
225
|
+
return entries.map((e, i) => leakAllowProblem(e, `${where}[${i}]`, tracked)).filter((x): x is string => x != null)
|
|
53
226
|
}
|
|
54
227
|
|
|
55
228
|
/** the machine-global list every repo's guard also reads: one private list
|
|
@@ -62,36 +235,138 @@ export function globalKeywordFile(): string | null {
|
|
|
62
235
|
return existsSync(p) ? p : null
|
|
63
236
|
}
|
|
64
237
|
|
|
238
|
+
/** the machine-global allowlist, the twin of the keyword file. It is private,
|
|
239
|
+
* so it may name a pattern by value as well as by fingerprint. */
|
|
240
|
+
export function globalAllowFile(): string | null {
|
|
241
|
+
const home = process.env.HOME ?? ''
|
|
242
|
+
if (home === '') return null
|
|
243
|
+
const p = join(home, '.rv-private', 'leak-allow.json')
|
|
244
|
+
return existsSync(p) ? p : null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** read an allowlist file (`LeakAllowEntry[]` as JSON). A file that will not
|
|
248
|
+
* parse, or an entry that cannot be used, is a NOTE and never a failure: a
|
|
249
|
+
* waiver that does not load leaves the guard stricter, never looser. */
|
|
250
|
+
export function loadAllowFile(path: string, tracked: boolean): { entries: LoadedAllow[]; notes: string[] } {
|
|
251
|
+
let parsed: unknown
|
|
252
|
+
try { parsed = JSON.parse(readFileSync(path, 'utf8')) }
|
|
253
|
+
catch (e) { return { entries: [], notes: [`${path} is not readable JSON (${e instanceof Error ? e.message.split('\n')[0] : String(e)}) — its waivers are IGNORED`] } }
|
|
254
|
+
if (!Array.isArray(parsed)) return { entries: [], notes: [`${path} holds an array of allowlist entries — its waivers are IGNORED`] }
|
|
255
|
+
const entries: LoadedAllow[] = []
|
|
256
|
+
const notes: string[] = []
|
|
257
|
+
parsed.forEach((raw, i) => {
|
|
258
|
+
const problem = leakAllowProblem(raw, `${path}[${i}]`, tracked)
|
|
259
|
+
if (problem != null) notes.push(`${problem} — entry IGNORED`)
|
|
260
|
+
else entries.push({ ...(raw as LeakAllowEntry), source: `${basename(path)}[${i}]` })
|
|
261
|
+
})
|
|
262
|
+
return { entries, notes }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export type LeakGuardOpts = {
|
|
266
|
+
/** path to the keyword file, relative to root (default '.rv-private/banned-keywords.txt') */
|
|
267
|
+
keywordFile?: string
|
|
268
|
+
/** extra text to scan (a commit message, a release note) */
|
|
269
|
+
text?: { label: string; content: string }[]
|
|
270
|
+
/** scan the staged diff + staged paths (default true) */
|
|
271
|
+
staged?: boolean
|
|
272
|
+
/** the repo's declared waivers (repo.config.ts hooks.leakAllow). Tracked,
|
|
273
|
+
* so fingerprints only. */
|
|
274
|
+
allow?: LeakAllowEntry[]
|
|
275
|
+
/** override the machine-global allowlist path; null reads none */
|
|
276
|
+
allowFile?: string | null
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** git c-quotes a path that carries a space or a non-ascii byte. */
|
|
280
|
+
function unquotePath(p: string): string {
|
|
281
|
+
if (!p.startsWith('"')) return p
|
|
282
|
+
try { return JSON.parse(p) as string } catch { return p.slice(1, -1) }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** `@@ -old,n +new,m @@`, the only line that says how long a hunk body is. */
|
|
286
|
+
const HUNK_HEADER = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/
|
|
287
|
+
|
|
288
|
+
/** ADDED lines only, grouped by the file that receives them: what this commit
|
|
289
|
+
* puts INTO the object store. Removed and context lines are already in
|
|
290
|
+
* history, and a commit that DELETES private content must never be blocked by
|
|
291
|
+
* that content. The grouping is what lets a waiver name a file.
|
|
292
|
+
*
|
|
293
|
+
* A `+++ x` line is a file header only OUTSIDE a hunk body. Inside one it is
|
|
294
|
+
* an added line whose own text starts with `++`, and reading it as a header
|
|
295
|
+
* is a hole in both directions: `++ /dev/null` would drop every added line
|
|
296
|
+
* after it in that file, and `++ b/bun.lock` would file them under a name a
|
|
297
|
+
* waiver covers. So the hunk body is COUNTED, from the size its header
|
|
298
|
+
* declares. A header this does not recognise (a combined `@@@` diff) opens an
|
|
299
|
+
* unbounded body that only the next file closes, which is the safe direction:
|
|
300
|
+
* content is still scanned, and no line inside it can name a file. */
|
|
301
|
+
export function addedLinesByFile(diff: string): { file: string; content: string }[] {
|
|
302
|
+
const out: { file: string; content: string }[] = []
|
|
303
|
+
let current: { file: string; lines: string[] } | null = null
|
|
304
|
+
let oldLeft = 0
|
|
305
|
+
let newLeft = 0
|
|
306
|
+
const flush = (): void => {
|
|
307
|
+
if (current != null && current.lines.length > 0) out.push({ file: current.file, content: current.lines.join('\n') })
|
|
308
|
+
current = null
|
|
309
|
+
}
|
|
310
|
+
const add = (line: string): void => { if (current != null) current.lines.push(line.slice(1)) }
|
|
311
|
+
for (const line of diff.split('\n')) {
|
|
312
|
+
// a `diff --git ` at column 0 can never be diff CONTENT: a content line
|
|
313
|
+
// always carries its ' ', '+' or '-' prefix first
|
|
314
|
+
if (line.startsWith('diff --git ')) {
|
|
315
|
+
flush()
|
|
316
|
+
oldLeft = 0
|
|
317
|
+
newLeft = 0
|
|
318
|
+
} else if (oldLeft > 0 || newLeft > 0) {
|
|
319
|
+
if (line.startsWith('+')) { newLeft--; add(line) }
|
|
320
|
+
else if (line.startsWith('-')) oldLeft--
|
|
321
|
+
else if (!line.startsWith('\\')) { oldLeft--; newLeft-- } // '' counts for neither
|
|
322
|
+
} else if (line.startsWith('@@')) {
|
|
323
|
+
const h = HUNK_HEADER.exec(line)
|
|
324
|
+
oldLeft = h == null ? Infinity : h[1] == null ? 1 : Number(h[1])
|
|
325
|
+
newLeft = h == null ? Infinity : h[2] == null ? 1 : Number(h[2])
|
|
326
|
+
} else if (line.startsWith('+++ ')) {
|
|
327
|
+
flush()
|
|
328
|
+
const p = unquotePath(line.slice(4).trim())
|
|
329
|
+
current = p === '/dev/null' ? null : { file: p.startsWith('b/') ? p.slice(2) : p, lines: [] }
|
|
330
|
+
} else if (line.startsWith('+')) add(line) // a stray added line outside any hunk is still content
|
|
331
|
+
}
|
|
332
|
+
flush()
|
|
333
|
+
return out
|
|
334
|
+
}
|
|
335
|
+
|
|
65
336
|
export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResult {
|
|
66
337
|
const rel = o.keywordFile ?? '.rv-private/banned-keywords.txt'
|
|
67
338
|
const file = join(root, rel)
|
|
68
339
|
const sources = [existsSync(file) ? file : null, globalKeywordFile()].filter((f): f is string => f != null)
|
|
69
340
|
if (sources.length === 0) return { status: 'skipped', reason: `${rel} not found and no ~/.rv-private/banned-keywords.txt — no keyword list, no leak guard` }
|
|
70
341
|
const patterns = parsePatterns(sources.map((f) => readFileSync(f, 'utf8')).join('\n'))
|
|
71
|
-
const count = patterns.substrings.length + patterns.regexes.length
|
|
342
|
+
const count = patterns.substrings.length + patterns.words.length + patterns.regexes.length
|
|
72
343
|
if (count === 0) return { status: 'skipped', reason: `keyword file(s) have no patterns (${sources.join(', ')})` }
|
|
73
344
|
|
|
74
|
-
const
|
|
345
|
+
const notes: string[] = []
|
|
346
|
+
const allow: LoadedAllow[] = []
|
|
347
|
+
;(o.allow ?? []).forEach((e, i) => {
|
|
348
|
+
const problem = leakAllowProblem(e, `repo.config.ts hooks.leakAllow[${i}]`, true)
|
|
349
|
+
if (problem != null) notes.push(`${problem} — entry IGNORED`)
|
|
350
|
+
else allow.push({ ...e, source: `repo.config.ts hooks.leakAllow[${i}]` })
|
|
351
|
+
})
|
|
352
|
+
const globalAllow = o.allowFile === undefined ? globalAllowFile() : o.allowFile
|
|
353
|
+
if (globalAllow != null && existsSync(globalAllow)) {
|
|
354
|
+
const g = loadAllowFile(globalAllow, false)
|
|
355
|
+
allow.push(...g.entries)
|
|
356
|
+
notes.push(...g.notes)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const targets: ScanTarget[] = []
|
|
75
360
|
if (o.staged !== false) {
|
|
76
|
-
// staged CONTENT (what lands in the object store) and staged PATHS
|
|
77
|
-
// (a filename can leak as loudly as a line of code)
|
|
78
361
|
const diff = Bun.spawnSync(['git', 'diff', '--cached'], { cwd: root, maxBuffer: 256 * 1024 * 1024 })
|
|
79
|
-
if (diff.exitCode === 0)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// deletes private content must never be blocked by that content
|
|
83
|
-
const added = diff.stdout.toString().split('\n')
|
|
84
|
-
.filter((l) => l.startsWith('+') && !l.startsWith('+++'))
|
|
85
|
-
.join('\n')
|
|
86
|
-
scan(added, 'staged content', patterns, hits)
|
|
87
|
-
}
|
|
362
|
+
if (diff.exitCode === 0)
|
|
363
|
+
for (const f of addedLinesByFile(diff.stdout.toString())) targets.push({ where: 'staged content', file: f.file, content: f.content })
|
|
364
|
+
// a filename can leak as loudly as a line of code
|
|
88
365
|
const names = Bun.spawnSync(['git', 'diff', '--cached', '--name-only'], { cwd: root })
|
|
89
|
-
if (names.exitCode === 0)
|
|
366
|
+
if (names.exitCode === 0) for (const n of names.stdout.toString().split('\n')) if (n.trim() !== '') targets.push({ where: 'staged path', file: n, content: n })
|
|
90
367
|
}
|
|
91
|
-
for (const t of o.text ?? [])
|
|
368
|
+
for (const t of o.text ?? []) targets.push({ where: t.label, content: t.content })
|
|
92
369
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const unique = hits.filter((h) => { const k = h.where + ' ' + h.pattern; if (seen.has(k)) return false; seen.add(k); return true })
|
|
96
|
-
return unique.length > 0 ? { status: 'hit', hits: unique, patterns: count } : { status: 'clean', patterns: count }
|
|
370
|
+
const { hits, waived } = evaluateLeak(targets, patterns, allow)
|
|
371
|
+
return hits.length > 0 ? { status: 'hit', hits, patterns: count, waived, notes } : { status: 'clean', patterns: count, waived, notes }
|
|
97
372
|
}
|