@sibylline-labs/eris 0.0.0-stage → 0.3.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 +2 -0
- package/README.md +146 -2
- package/bin/eris.ts +42 -0
- package/docs/ARCHITECTURE.md +188 -0
- package/docs/COMPLETION.md +106 -0
- package/docs/ERRORS.md +632 -0
- package/docs/OUTPUT-CONTRACT.md +219 -0
- package/docs/README.md +30 -0
- package/docs/UPDATE.md +159 -0
- package/docs/YAK.md +395 -0
- package/docs/dev/ADDING-A-COMMAND.md +79 -0
- package/docs/dev/DEVELOPMENT.md +88 -0
- package/docs/dev/LAYOUT.md +74 -0
- package/docs/dev/PLUGINS.md +382 -0
- package/docs/dev/RELEASING.md +157 -0
- package/docs/dev/TESTING.md +84 -0
- package/docs/dev/surfaces/secrets.md +95 -0
- package/package.json +55 -4
- package/packages/yak-contract/package.json +28 -0
- package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
- package/packages/yak-contract/src/index.ts +1 -0
- package/packages/yak-contract/src/v2.ts +375 -0
- package/plugins/index.ts +12 -0
- package/plugins/yak/handlers.ts +128 -0
- package/plugins/yak/index.ts +51 -0
- package/plugins/yak/login.ts +300 -0
- package/plugins/yak/yak-context.ts +280 -0
- package/plugins/yak/yak-identity.ts +399 -0
- package/plugins/yak/yak-redact.ts +686 -0
- package/plugins/yak/yak-setup.ts +133 -0
- package/plugins/yak/yak-submit.ts +1166 -0
- package/plugins/yak/yak.ts +485 -0
- package/src/build.ts +248 -0
- package/src/cli/args.ts +434 -0
- package/src/cli/commands/completion/bash.ts +24 -0
- package/src/cli/commands/completion/domain.ts +203 -0
- package/src/cli/commands/completion/emit.ts +246 -0
- package/src/cli/commands/completion/shell.ts +24 -0
- package/src/cli/commands/completion/zsh.ts +24 -0
- package/src/cli/commands/completion.ts +48 -0
- package/src/cli/commands/update/github.ts +328 -0
- package/src/cli/commands/update/opt-in.ts +32 -0
- package/src/cli/commands/update/refusal.ts +21 -0
- package/src/cli/commands/update/release.ts +351 -0
- package/src/cli/commands/update/replace.ts +143 -0
- package/src/cli/commands/update/semver.ts +48 -0
- package/src/cli/commands/update.ts +47 -0
- package/src/cli/commands/usage.ts +68 -0
- package/src/cli/commands/version.ts +29 -0
- package/src/cli/compose.ts +273 -0
- package/src/cli/output.ts +81 -0
- package/src/cli/plugin.ts +85 -0
- package/src/cli/registry.ts +180 -0
- package/src/cli/router.ts +31 -0
- package/src/cli/ts-verb.ts +126 -0
- package/src/main.ts +223 -0
- package/src/runtime/env.ts +16 -0
- package/src/runtime/eris-assets.d.ts +5 -0
- package/src/runtime/home.ts +306 -0
- package/src/runtime/logging.ts +43 -0
- package/src/runtime/process.ts +388 -0
- package/src/runtime/settings.ts +191 -0
- package/src/sdk.ts +62 -0
- package/testing/build-child.ts +55 -0
- package/testing/fake-runner.ts +23 -0
- package/testing/index.ts +16 -0
- package/ts/lib/eris-fail.ts +180 -0
- package/ts/lib/pool.ts +63 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* yak-redact.ts — the Yak submission redaction pass.
|
|
3
|
+
*
|
|
4
|
+
* Agent-authored Yak bodies are free text persisted server-side and posted
|
|
5
|
+
* to an issue tracker. Redaction belongs in this CLI, which sees the material
|
|
6
|
+
* first: nothing on the server can un-see it. An agent reporting a broken
|
|
7
|
+
* harness may paste an env dump.
|
|
8
|
+
*
|
|
9
|
+
* PURE: no I/O, no filesystem, no network, no config load, no added latency.
|
|
10
|
+
* `redact()` runs on `summary` / `body` / `evidence` BEFORE key derivation
|
|
11
|
+
* so the Actor's re-derived key agrees with the
|
|
12
|
+
* client's — deriving from unredacted text would guarantee a mismatch on
|
|
13
|
+
* every report.
|
|
14
|
+
*
|
|
15
|
+
* THIRTEEN CLASSES, applied in a fixed pipeline order, most-specific first.
|
|
16
|
+
* Order is load-bearing for three reasons:
|
|
17
|
+
*
|
|
18
|
+
* 1. A JSON-embedded GCP private key ("private_key":"-----BEGIN...") is
|
|
19
|
+
* also a syntactic match for the generic PEM-block pattern (`[\s\S]*?`
|
|
20
|
+
* matches escaped `\n` sequences same as it matches real newlines). The
|
|
21
|
+
* GCP-specific pass runs first so that case is classified and redacted
|
|
22
|
+
* as `gcp-private-key`, not `pem`, and is never double-counted by the
|
|
23
|
+
* generic pass that follows.
|
|
24
|
+
* 2. `auth-header` runs before `assignment`, because "Authorization: Bearer
|
|
25
|
+
* …" is ALSO a syntactic match for the generic NAME:value scrub (`AUTH`
|
|
26
|
+
* is in the assignment name list and `Authorization` starts with it).
|
|
27
|
+
* Running auth-header first means the marker is in place before
|
|
28
|
+
* `assignment`'s pass ever sees that text, so it is classified once, as
|
|
29
|
+
* the more specific class.
|
|
30
|
+
* 3. `sib-token` and `sibdc-code` run right after `jwt`, before `aws-key`,
|
|
31
|
+
* `github-token` and `sk-key`. A token's base64url secret can itself contain `-sk-`
|
|
32
|
+
* (where `\bsk-` holds after the `-`) or `ghp_…`, so any of those classes
|
|
33
|
+
* running first would replace only the embedded run and leave
|
|
34
|
+
* `sib_<orgId>_<head>` in plaintext. It also runs before `assignment`, so
|
|
35
|
+
* `ERIS_TOKEN=sib_…` is classified once, as the more specific class.
|
|
36
|
+
*
|
|
37
|
+
* MARKER GUARD. Each pass runs sequentially over the STRING RESULT of the
|
|
38
|
+
* previous pass, and the `«redacted:<class>»` marker matches none of the
|
|
39
|
+
* patterns below by its own shape (verified in tests/yak-redact.test.ts —
|
|
40
|
+
* idempotence and "marker is never re-matched"). Three passes are the
|
|
41
|
+
* exception: each takes an arbitrary non-whitespace run as the value at a
|
|
42
|
+
* position — `assignment` (after the operator), `auth-header` (the credential
|
|
43
|
+
* after the scheme) and `conn-string` (the password in the userinfo) — and a
|
|
44
|
+
* marker an earlier pass left at that position is exactly such a run. A pass
|
|
45
|
+
* may decline such a value, so that the more specific class stays the only
|
|
46
|
+
* one classified and counted, only where declining leaves in place nothing
|
|
47
|
+
* but the marker and text that cannot be secret:
|
|
48
|
+
*
|
|
49
|
+
* - `assignment` declines a value that is EXACTLY one marker (`MARKER`):
|
|
50
|
+
* bare and ending where the value would end, or alone in matching quotes.
|
|
51
|
+
* The name stays, as it does for any redacted value.
|
|
52
|
+
* - `auth-header` declines a credential that is exactly one marker, ending
|
|
53
|
+
* at whitespace, after a known scheme word (`AUTH_DECLINED`). `assignment`
|
|
54
|
+
* must honour that too, or it would take `Authorization` as a governed
|
|
55
|
+
* name and `Bearer` as its value (`AUTH_DECLINED_RE`).
|
|
56
|
+
* - `conn-string` never declines: its scheme and host runs have no fixed
|
|
57
|
+
* edge, so with no separator around the span they can carry secret text.
|
|
58
|
+
*
|
|
59
|
+
* A value that runs on past a marker (`«redacted:sk-key»` followed by the tail
|
|
60
|
+
* of a key longer than that class's cap, or by `.`, `%21`, `:x-oauth-…`) is
|
|
61
|
+
* NOT declined: the run is redacted and both classes are counted. That
|
|
62
|
+
* over-counts, but declining on the marker alone would ship the tail.
|
|
63
|
+
* `assignment` also redacts every assignment that begins inside such a value
|
|
64
|
+
* (`runAssignmentPass`), so a tail cannot swallow an adjacent `"name": value`.
|
|
65
|
+
* No bespoke "already redacted" bookkeeping is needed.
|
|
66
|
+
*
|
|
67
|
+
* NO CATASTROPHIC BACKTRACKING. This runs on agent-authored text of
|
|
68
|
+
* arbitrary shape mid-task, so every pattern here is either a fixed-literal
|
|
69
|
+
* scan, a single unambiguous character class with a quantifier, or the
|
|
70
|
+
* standard `(?:[^"\\]|\\.)*` safe-JSON-string idiom (mutually exclusive
|
|
71
|
+
* alternatives, no ambiguity for the engine to search). None of them nest a
|
|
72
|
+
* quantified group inside another quantified group over an unbounded class —
|
|
73
|
+
* the shape that produces exponential blowup. `ASSIGNMENT_RE`'s NAME group is
|
|
74
|
+
* `[A-Za-z0-9_]{0,60}WORD[A-Za-z0-9_]{0,60}` — two BOUNDED, non-nested
|
|
75
|
+
* quantifiers either side of a fixed-literal alternation, not a lookahead —
|
|
76
|
+
* so a worst-case backtrack is O(60) per candidate `\b` position, never
|
|
77
|
+
* exponential in input length. See the "pathological input" tests for the
|
|
78
|
+
* exercised worst cases. No pattern carries a lookbehind either: the bun this
|
|
79
|
+
* repo pins cannot compile one to native code, so the whole pattern would run
|
|
80
|
+
* in the regex interpreter, about ten times slower on every input
|
|
81
|
+
* (`rules/regex-no-lookbehind.yml`).
|
|
82
|
+
*
|
|
83
|
+
* NAME MATCHING (`ASSIGNMENT_RE`): the governed word must appear ANYWHERE inside the identifier token —
|
|
84
|
+
* prefix, suffix, or infix, `_`-delimited or not — not just as the token's
|
|
85
|
+
* own prefix. `_` is a `\w` character, so `\bTOKEN` never matches the
|
|
86
|
+
* `TOKEN` in `GITHUB_TOKEN` (no boundary between `_` and `T`); the original
|
|
87
|
+
* prefix-only pattern therefore missed the dominant real-world shape,
|
|
88
|
+
* `*_TOKEN` / `*_SECRET` / `*_API_KEY` (`DATABASE_PASSWORD`, `CLERK_API_KEY`,
|
|
89
|
+
* `ERIS_YAKS_TOKEN` — this CLI's own bearer — among others). The fix wraps
|
|
90
|
+
* the alternation in a bounded `[A-Za-z0-9_]{0,60}` on each side instead of
|
|
91
|
+
* anchoring it to the start, and the surrounding `\b`s still require the
|
|
92
|
+
* WHOLE token to be an identifier (no partial match inside a longer
|
|
93
|
+
* non-identifier run) — outer boundaries are unaffected by *where* in the
|
|
94
|
+
* token the word sits. Case-insensitive (`gi`), which is what catches the
|
|
95
|
+
* lowercase `aws_session_token` shape.
|
|
96
|
+
*
|
|
97
|
+
* NAME MATCHING — HYPHENATED NAMES (fixed, the same class of miss one
|
|
98
|
+
* separator over): `[A-Za-z0-9_]` excludes `-`, so `x-api-key`,
|
|
99
|
+
* `api-key`, and `private-key` — the standard HTTP-header and YAML-config
|
|
100
|
+
* spelling — broke into non-governed halves at the hyphen (`api`/`key`,
|
|
101
|
+
* `private`/`key`; neither half alone is a governed word) and shipped in
|
|
102
|
+
* plaintext, even though the underscore-joined spelling (`API_KEY`,
|
|
103
|
+
* `PRIVATE_KEY`) already matched. The prefix/suffix class is now
|
|
104
|
+
* `[A-Za-z0-9_-]`, and the two-word alternatives (`PRIVATE_KEY`, `API_KEY`,
|
|
105
|
+
* `ACCESS_KEY`, `CLIENT_SECRET`) use `[_-]` in place of a literal `_` for
|
|
106
|
+
* their internal separator, so both spellings match. The single-word
|
|
107
|
+
* governed terms (SECRET, TOKEN, AUTH, …) already matched a hyphenated
|
|
108
|
+
* neighbor by accident before this fix — they stand alone as their own
|
|
109
|
+
* hyphen-bounded identifier regardless of what the surrounding class
|
|
110
|
+
* allows — which is why `client-secret`/`access-token` were never in the
|
|
111
|
+
* leak list, only the two-word phrases were.
|
|
112
|
+
*
|
|
113
|
+
* `.` is deliberately NOT added to this class, despite being another
|
|
114
|
+
* realistic dotted-config-key separator (`service.api.key`). Unlike `-`,
|
|
115
|
+
* `.` is also the sentence-ending and file-extension character, and this
|
|
116
|
+
* codebase's own convention — used throughout this file's comments and
|
|
117
|
+
* `docs/YAK.md` — is `word.ext: description` (`yak-redact.ts: the
|
|
118
|
+
* redaction pass`). Merging across `.` would turn every such reference
|
|
119
|
+
* whose filename happens to start with a governed word (`auth.md: …`,
|
|
120
|
+
* `token.ts: …`) into a false-positive `NAME: value` match, which is a
|
|
121
|
+
* common and realistic yak-report shape, not a contrived one. `-` doesn't
|
|
122
|
+
* carry that collision (English prose runs `word-word`, not
|
|
123
|
+
* `sentence.-next`), so it clears the bar `.` doesn't. If a dotted-config
|
|
124
|
+
* shape becomes a real, observed miss, it needs its own scoped pattern
|
|
125
|
+
* (e.g. anchored to a known key vocabulary) rather than widening this
|
|
126
|
+
* general-purpose class further.
|
|
127
|
+
*
|
|
128
|
+
* VALUE BOUND (fixed): the unquoted/quoted value alternatives are
|
|
129
|
+
* bounded to 20,000 chars — `YAK_EVIDENCE_MAX_LENGTH` in
|
|
130
|
+
* `@sibylline-labs/yak-contract`, the largest of the three redacted fields — not
|
|
131
|
+
* 4,096.
|
|
132
|
+
* A value longer than the capture bound is only PARTIALLY replaced: the
|
|
133
|
+
* captured prefix becomes a marker and everything past the bound survives as
|
|
134
|
+
* plaintext directly after it, which is worse than no redaction (it *looks*
|
|
135
|
+
* handled). This module deliberately does not import the schema constant —
|
|
136
|
+
* it stays dependency-free by design (file header, "PURE") — so the bound is
|
|
137
|
+
* a literal number here, with this comment as the cross-reference. If
|
|
138
|
+
* `YAK_EVIDENCE_MAX_LENGTH` changes, this bound is the sibling to update.
|
|
139
|
+
*
|
|
140
|
+
* MINIMUM UNQUOTED VALUE LENGTH (fixed, found while rebuilding the negative
|
|
141
|
+
* corpus): the unquoted value alternative requires >= 4 non-whitespace
|
|
142
|
+
* chars, not >= 1. No real credential this pass targets is 1-3 characters;
|
|
143
|
+
* requiring 4 is what keeps `Session: 3 of 4 workers failed to start` from
|
|
144
|
+
* matching as `Session: «redacted:assignment»` (captured value `"3"`) while
|
|
145
|
+
* costing nothing against any real secret shape — every positive fixture in
|
|
146
|
+
* the test file is one to three orders of magnitude longer than 4 chars.
|
|
147
|
+
* Quoted values keep no minimum: an explicit `"…"` or `'…'` is already a
|
|
148
|
+
* stronger structural signal of an intentional value than bare unquoted text
|
|
149
|
+
* is, so a short quoted value (`"token": "x"` in a JSON fixture) is left
|
|
150
|
+
* alone by this rule (it may still be caught by GCP's own JSON-string
|
|
151
|
+
* pattern where relevant).
|
|
152
|
+
*
|
|
153
|
+
* THE NAME: VALUE TRADE-OFF (deliberate, NOT "fixed" — a structural ambiguity,
|
|
154
|
+
* not a bug): `seed: 4815162342 (deterministic replay)` and
|
|
155
|
+
* `- SECRET_SCANNING: enabled` are syntactically indistinguishable from a
|
|
156
|
+
* real `SEED=<wallet-seed>` or `CLIENT_SECRET: <credential>` assignment —
|
|
157
|
+
* both are `NAME` `[:=]` `value`, and this pass has no semantic model of
|
|
158
|
+
* "this NAME is a log field, that NAME is a credential." The minimum-length
|
|
159
|
+
* fix above removes the cheapest false positives (single-digit/short
|
|
160
|
+
* values); it does NOT and cannot resolve this class, because a real seed
|
|
161
|
+
* or session value can legitimately be exactly as long as a fake one. The
|
|
162
|
+
* deliberate choice: keep matching. A false positive here costs one line's
|
|
163
|
+
* readability (the marker plus the surrounding sentence are still legible,
|
|
164
|
+
* and `--no-redact` exists for exactly this complaint); a miss costs a
|
|
165
|
+
* leaked credential. Those costs are not symmetric, so this pass is tuned
|
|
166
|
+
* toward over-redaction on ambiguous `NAME: value` shapes rather than
|
|
167
|
+
* under-redaction. See `tests/yak-redact.test.ts`'s "documented trade-off"
|
|
168
|
+
* block, which pins this as intended behavior so a future reader sees it was
|
|
169
|
+
* decided, not missed.
|
|
170
|
+
*
|
|
171
|
+
* AUTH SCHEME (fixed): `AUTH_HEADER_RE` matched only `Bearer|Token`, so
|
|
172
|
+
* `Authorization: Basic <base64>` fell through to the generic `assignment`
|
|
173
|
+
* pass, which eats only the scheme word (`AUTH` is a governed name) and
|
|
174
|
+
* ships the base64 credential in plaintext. The pattern now matches ANY
|
|
175
|
+
* RFC-7235-shaped scheme token (`Basic`, `Bearer`, `Digest`, `Token`,
|
|
176
|
+
* `AWS4-HMAC-SHA256`, …) — always anchored to a literal `Authorization:`
|
|
177
|
+
* prefix, so this cannot fire on ordinary prose that happens to contain the
|
|
178
|
+
* word "authorization".
|
|
179
|
+
*
|
|
180
|
+
* SK- WORD BOUNDARY (fixed): `SK_KEY_RE` had no boundary before
|
|
181
|
+
* `sk-`, so it matched mid-word inside `risk-assessment-methodology`,
|
|
182
|
+
* `disk-cache-manager-impl`, and any other hyphenated word with `sk-`
|
|
183
|
+
* inside it — a real path or a real sentence, mangled. A leading `\b` fixes
|
|
184
|
+
* this: `\b` between two `\w` characters (the `i` and `s` in `risk`) never
|
|
185
|
+
* holds, so `\bsk-` cannot start mid-identifier; it still matches happily
|
|
186
|
+
* after whitespace, `=`, a quote, or the start of the string, which is
|
|
187
|
+
* every real `sk-…` key's actual context.
|
|
188
|
+
*/
|
|
189
|
+
|
|
190
|
+
export type RedactionClass =
|
|
191
|
+
| "pem"
|
|
192
|
+
| "jwt"
|
|
193
|
+
| "sib-token"
|
|
194
|
+
| "sibdc-code"
|
|
195
|
+
| "aws-key"
|
|
196
|
+
| "github-token"
|
|
197
|
+
| "slack-token"
|
|
198
|
+
| "google-api-key"
|
|
199
|
+
| "sk-key"
|
|
200
|
+
| "auth-header"
|
|
201
|
+
| "gcp-private-key"
|
|
202
|
+
| "conn-string"
|
|
203
|
+
| "assignment";
|
|
204
|
+
|
|
205
|
+
export interface RedactionResult {
|
|
206
|
+
text: string;
|
|
207
|
+
counts: Record<RedactionClass, number>;
|
|
208
|
+
total: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Every class, in a fixed order — the single source of truth for
|
|
212
|
+
* what a fresh `counts` record looks like. */
|
|
213
|
+
const REDACTION_CLASSES: RedactionClass[] = [
|
|
214
|
+
"pem",
|
|
215
|
+
"jwt",
|
|
216
|
+
"sib-token",
|
|
217
|
+
"sibdc-code",
|
|
218
|
+
"aws-key",
|
|
219
|
+
"github-token",
|
|
220
|
+
"slack-token",
|
|
221
|
+
"google-api-key",
|
|
222
|
+
"sk-key",
|
|
223
|
+
"auth-header",
|
|
224
|
+
"gcp-private-key",
|
|
225
|
+
"conn-string",
|
|
226
|
+
"assignment",
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
function emptyCounts(): Record<RedactionClass, number> {
|
|
230
|
+
const counts = {} as Record<RedactionClass, number>;
|
|
231
|
+
for (const c of REDACTION_CLASSES) counts[c] = 0;
|
|
232
|
+
return counts;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function marker(cls: RedactionClass): string {
|
|
236
|
+
return `«redacted:${cls}»`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Regex source for exactly one `marker()` output: every RedactionClass name is
|
|
240
|
+
// lowercase words joined by `-`, at most 40 chars. The three guards in the file
|
|
241
|
+
// header's "MARKER GUARD" decline a value only when it is this and nothing
|
|
242
|
+
// more. tests/yak-redact.test.ts holds every class's marker to it.
|
|
243
|
+
const MARKER = "«redacted:[a-z-]{1,40}»";
|
|
244
|
+
|
|
245
|
+
// --- pass definitions ------------------------------------------------------
|
|
246
|
+
//
|
|
247
|
+
// Each pass is a (RedactionClass, RegExp) pair. `re` must carry the `g` flag.
|
|
248
|
+
// `replace(text, counts)` runs the pass once over `text`, incrementing
|
|
249
|
+
// `counts[cls]` per match, and returns the transformed text. Passes that need
|
|
250
|
+
// to preserve surrounding structure (a capture group they must keep) supply
|
|
251
|
+
// a `build` function instead of relying on whole-match replacement.
|
|
252
|
+
|
|
253
|
+
interface Pass {
|
|
254
|
+
cls: RedactionClass;
|
|
255
|
+
re: RegExp;
|
|
256
|
+
/** Given the match, return the literal replacement string. Defaults to the
|
|
257
|
+
* bare class marker (whole match replaced). */
|
|
258
|
+
build?: (m: RegExpExecArray) => string;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// PEM private-key blocks: BEGIN...END, non-greedy across the body so a file
|
|
262
|
+
// with multiple keys yields one match — and one count — per key.
|
|
263
|
+
const PEM_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
|
|
264
|
+
|
|
265
|
+
// JWTs: three dot-separated base64url segments, header always starts "eyJ"
|
|
266
|
+
// (base64 of `{"`). Bounded segment lengths — no unbounded nested repetition.
|
|
267
|
+
const JWT_RE = /eyJ[A-Za-z0-9_-]{10,500}\.[A-Za-z0-9_-]{10,500}\.[A-Za-z0-9_-]{10,500}/g;
|
|
268
|
+
|
|
269
|
+
// `sib_` service tokens: sib_<26-char Crockford ULID>_<43 base64url>.
|
|
270
|
+
// Contract-shaped on purpose, not a generic `sib_` + run: the issuer pins this
|
|
271
|
+
// shape, and a generic run would eat prose identifiers such as
|
|
272
|
+
// `sib_service_token_rotation_policy`. The ULID part accepts either case so a
|
|
273
|
+
// lower-cased paste still redacts (this pass is tuned toward over-redaction).
|
|
274
|
+
// The secret is exactly `{43}`: the issuer formats and parses only 32 random
|
|
275
|
+
// bytes as 43 base64url characters, so a longer run is a whole token with other
|
|
276
|
+
// text glued straight onto it (`<token>Authorization: …`, `<token>PASSWORD=…`).
|
|
277
|
+
// That text belongs to the later passes, which need its leading letters as
|
|
278
|
+
// their anchor; a wider run would swallow them and ship what follows. No
|
|
279
|
+
// leading `\b`: `x_sib_…` still redacts, and the fixed 26+1+43 structure is
|
|
280
|
+
// what blocks false positives. Ordered ahead of the classes whose shapes its
|
|
281
|
+
// secret can contain (file header, reason 3).
|
|
282
|
+
const SIB_TOKEN_RE = /sib_[0-9A-Za-z]{26}_[A-Za-z0-9_-]{43}/g;
|
|
283
|
+
|
|
284
|
+
// `sibdc_` device codes, what `eris login` polls with: sibdc_<8-char user
|
|
285
|
+
// code>_<43 base64url>. An approved code redeems a fresh `sib_` bearer for
|
|
286
|
+
// whoever polls first, so it is a credential for its ten-minute life. Shaped
|
|
287
|
+
// like SIB_TOKEN_RE and for the same reasons: contract-shaped, either case in
|
|
288
|
+
// the user code (the issuer's alphabet is 20 upper-case consonants), exactly 43
|
|
289
|
+
// in the secret, no leading `\b`, ordered ahead of the classes its secret can
|
|
290
|
+
// embed (file header, reason 3). Neither shape can occur inside the other.
|
|
291
|
+
const SIBDC_CODE_RE = /sibdc_[A-Za-z]{8}_[A-Za-z0-9_-]{43}/g;
|
|
292
|
+
|
|
293
|
+
// AWS: access-key ids (AKIA/ASIA + 16 alnum) and the aws_secret_access_key
|
|
294
|
+
// assignment, as two independent matches under one class.
|
|
295
|
+
const AWS_KEY_ID_RE = /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g;
|
|
296
|
+
const AWS_SECRET_RE =
|
|
297
|
+
/aws_secret_access_key\s*[:=]\s*['"]?[A-Za-z0-9/+=]{20,80}['"]?/gi;
|
|
298
|
+
|
|
299
|
+
// GitHub fine-grained/classic tokens: gh[pousr]_<alnum>{36,}.
|
|
300
|
+
const GITHUB_TOKEN_RE = /gh[pousr]_[A-Za-z0-9]{36,255}/g;
|
|
301
|
+
|
|
302
|
+
// Slack tokens: xox[baprs]-<alnum/hyphen>{10,}.
|
|
303
|
+
const SLACK_TOKEN_RE = /xox[baprs]-[A-Za-z0-9-]{10,128}/g;
|
|
304
|
+
|
|
305
|
+
// Google API keys: AIza + 35 fixed chars (39 total, per Google's own format).
|
|
306
|
+
const GOOGLE_API_KEY_RE = /AIza[0-9A-Za-z_-]{35}/g;
|
|
307
|
+
|
|
308
|
+
// OpenAI/Anthropic-style secret keys: sk-, sk-ant-, sk-proj-, etc. `\b`
|
|
309
|
+
// before `sk-` is load-bearing (see file header, "SK- WORD BOUNDARY") — it
|
|
310
|
+
// is what stops this from matching mid-word inside `risk-…`/`disk-…`/`task-…`.
|
|
311
|
+
const SK_KEY_RE = /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,120}/g;
|
|
312
|
+
|
|
313
|
+
// Authorization headers: any scheme token + bounded credential, applied
|
|
314
|
+
// before the generic `assignment` pass so "Authorization: <scheme> …" is
|
|
315
|
+
// classified once here (see file header, "AUTH SCHEME") — not just Bearer
|
|
316
|
+
// and Token, so `Authorization: Basic …` no longer falls through to the
|
|
317
|
+
// generic pass and ships its base64 credential unredacted.
|
|
318
|
+
//
|
|
319
|
+
// The guard declines a header whose scheme is a known scheme word and whose
|
|
320
|
+
// credential is exactly one EARLIER class's marker, ending at whitespace or the
|
|
321
|
+
// end of input (file header, "MARKER GUARD"): `Authorization: Bearer
|
|
322
|
+
// «redacted:github-token»` stays as it is, scheme visible, instead of
|
|
323
|
+
// collapsing to `«redacted:auth-header»` with both classes counted. Anything
|
|
324
|
+
// after the marker in the same run (`«redacted:sk-key»<tail of an over-long
|
|
325
|
+
// key>`) is not declined: the whole header is redacted. Nor is an unknown
|
|
326
|
+
// scheme word, because `AUTH_SCHEME` also matches a raw key sent with no
|
|
327
|
+
// scheme (`Authorization: sk_live_… «redacted:jwt»`), and declining would ship
|
|
328
|
+
// it. The lookahead is fixed literals plus bounded classes, so it adds no
|
|
329
|
+
// backtracking. `assignment` must leave that same declined shape alone too
|
|
330
|
+
// (`AUTH_DECLINED_RE`), so both build it from `AUTH_DECLINED` and cannot drift.
|
|
331
|
+
const AUTH_SCHEME = "[A-Za-z][A-Za-z0-9._-]{0,40}";
|
|
332
|
+
const AUTH_DECLINED = `(?:Bearer|Basic|Token|Digest|Negotiate|NTLM|OAuth|DPoP)\\s+${MARKER}(?!\\S)`;
|
|
333
|
+
const AUTH_HEADER_RE = new RegExp(
|
|
334
|
+
`Authorization:\\s*(?!${AUTH_DECLINED})${AUTH_SCHEME}\\s+[^\\s\\r\\n]{1,2048}`,
|
|
335
|
+
"gi",
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// GCP service-account "private_key" JSON field: the standard safe JSON-string
|
|
339
|
+
// idiom for the value, so the surrounding quotes and key name survive and
|
|
340
|
+
// only the material is replaced.
|
|
341
|
+
const GCP_PRIVATE_KEY_RE = /("private_key"\s*:\s*")((?:[^"\\]|\\.)*)(")/g;
|
|
342
|
+
|
|
343
|
+
// Connection strings with an inline password: proto://user:pass@host. Every
|
|
344
|
+
// character class excludes the next delimiter, so there is no ambiguity for
|
|
345
|
+
// the engine to backtrack through.
|
|
346
|
+
//
|
|
347
|
+
// No marker guard (file header, "MARKER GUARD"): a password that is an earlier
|
|
348
|
+
// class's marker is redacted again, with the scheme, user and host, and both
|
|
349
|
+
// classes are counted. Declining it would leave the span's own runs as they
|
|
350
|
+
// are, and with no separator around it those runs can carry secret text: the
|
|
351
|
+
// scheme run takes letters before `postgres`, and the host run takes a key
|
|
352
|
+
// pasted straight after `@db`.
|
|
353
|
+
const CONN_STRING_RE =
|
|
354
|
+
/[a-zA-Z][a-zA-Z0-9+.-]{0,30}:\/\/[^\s:@/]{1,255}:[^\s@/]{1,255}@[^\s/]{1,255}/g;
|
|
355
|
+
|
|
356
|
+
// Key-name assignments: NAME=value / NAME: value, case-insensitive, where
|
|
357
|
+
// NAME contains one of the governed words ANYWHERE in the identifier — see
|
|
358
|
+
// file header, "NAME MATCHING". The operator must follow only optional
|
|
359
|
+
// whitespace — prose like "the auth token was missing" never reaches an
|
|
360
|
+
// `=`/`:` and so never matches. The value is captured separately (quoted or
|
|
361
|
+
// a bounded non-whitespace run) so only the VALUE is replaced, keeping the
|
|
362
|
+
// variable name visible for triage.
|
|
363
|
+
//
|
|
364
|
+
// Group 1 is `[A-Za-z0-9_-]{0,60}` + the governed-word alternation +
|
|
365
|
+
// `[A-Za-z0-9_-]{0,60}` — bounded prefix AND suffix, so `SECRET_KEY`,
|
|
366
|
+
// `DATABASE_PASSWORD`, `ERIS_YAKS_TOKEN`, and `x-api-key` all capture their
|
|
367
|
+
// FULL identifier, not just the governed word itself; losing either bounded
|
|
368
|
+
// run from the capture would silently truncate the name in the rebuilt
|
|
369
|
+
// replacement. `-` is in the class (file header, "NAME MATCHING —
|
|
370
|
+
// HYPHENATED NAMES") for the standard HTTP-header/YAML-config spelling; the
|
|
371
|
+
// two-word alternatives (`PRIVATE[_-]KEY`, `API[_-]KEY`, `ACCESS[_-]KEY`,
|
|
372
|
+
// `CLIENT[_-]SECRET`) allow `_` OR `-` as their internal separator so
|
|
373
|
+
// `private-key` and `x-api-key` match the same as `PRIVATE_KEY` and
|
|
374
|
+
// `API_KEY` do — a literal underscore in the alternation would never match
|
|
375
|
+
// a hyphenated spelling no matter how permissive the surrounding class is.
|
|
376
|
+
//
|
|
377
|
+
// The value alternatives are bounded to 20,000 chars (file header, "VALUE
|
|
378
|
+
// BOUND") and the unquoted alternative requires >= 4 chars (file header,
|
|
379
|
+
// "MINIMUM UNQUOTED VALUE LENGTH").
|
|
380
|
+
//
|
|
381
|
+
// The guard directly after the operator stops this pass from treating an
|
|
382
|
+
// EARLIER pass's marker as its "value": without it, "credentials:
|
|
383
|
+
// «redacted:aws-key»" (the aws-key pass having already run) would match here
|
|
384
|
+
// too — CREDENTIAL is a governed name, the marker has no internal whitespace
|
|
385
|
+
// so it satisfies the unquoted-value alternative, and the text would be
|
|
386
|
+
// reclassified/reprocessed as `assignment`, breaking both idempotence and the
|
|
387
|
+
// count for the class that actually caught it. It declines only a value that
|
|
388
|
+
// is EXACTLY one marker (file header, "MARKER GUARD"): unquoted and ending
|
|
389
|
+
// where the unquoted value would end, or alone inside a matching pair of
|
|
390
|
+
// quotes. A value that runs on past the marker (`«redacted:sk-key»<tail>`) is
|
|
391
|
+
// redacted whole.
|
|
392
|
+
//
|
|
393
|
+
// `AUTH_DECLINED_RE` is the same guard for the one header `auth-header` owns.
|
|
394
|
+
// When `auth-header` declines `Authorization: Bearer «redacted:github-token»`
|
|
395
|
+
// (credential exactly a marker), this pass would otherwise read the `AUTH`
|
|
396
|
+
// infix in `Authorization` as a governed name and `Bearer` as its value,
|
|
397
|
+
// leaving `Authorization: «redacted:assignment» «redacted:github-token»`. It
|
|
398
|
+
// applies only to an unquoted name ending in `Authorization` (so
|
|
399
|
+
// `Proxy-Authorization` is covered, and an ordinary `password: hunter2
|
|
400
|
+
// «redacted:…»` is not), and it is `AUTH_HEADER_RE`'s declined shape exactly:
|
|
401
|
+
// colon directly after the name, then `AUTH_DECLINED`.
|
|
402
|
+
// The replace callback checks it, sticky, at the matched name: a lookbehind
|
|
403
|
+
// in `ASSIGNMENT_RE` would keep the pinned bun from compiling the pattern to
|
|
404
|
+
// native code, about ten times slower on every input (`regex-no-lookbehind`).
|
|
405
|
+
const AUTH_DECLINED_RE = new RegExp(`authorization:\\s*${AUTH_DECLINED}`, "iy");
|
|
406
|
+
const AUTHORIZATION = "authorization";
|
|
407
|
+
const GOVERNED_NAME =
|
|
408
|
+
"[A-Za-z0-9_-]{0,60}(?:SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]KEY|API[_-]KEY|CREDENTIAL|ACCESS[_-]KEY|CLIENT[_-]SECRET|MNEMONIC|SEED|SESSION|COOKIE|AUTH)[A-Za-z0-9_-]{0,60}";
|
|
409
|
+
|
|
410
|
+
// The NAME side is TWO alternatives, not one pattern with an optional
|
|
411
|
+
// quote — see file header, "JSON/QUOTED NAME MATCHING" for why a single
|
|
412
|
+
// `["']?` can't do this safely. `(?<q>["'])…\k<q>` requires the SAME quote
|
|
413
|
+
// character to close (so `"token'` never matches), and the unquoted
|
|
414
|
+
// alternative keeps its own `\b` pair — a quoted match doesn't need `\b`
|
|
415
|
+
// at all, because the quote character is itself an unambiguous delimiter
|
|
416
|
+
// no ordinary prose produces around a bare identifier.
|
|
417
|
+
const QUOTED_VALUE = `"(?:[^"\\\\\\r\\n]|\\\\.){0,20000}"|'(?:[^'\\\\\\r\\n]|\\\\.){0,20000}'`;
|
|
418
|
+
const assignmentRe = (valueGuard: string, ledValue = ""): RegExp => new RegExp(
|
|
419
|
+
`(?:(?<q>["'])(?<qname>${GOVERNED_NAME})\\k<q>|\\b(?<uname>${GOVERNED_NAME})\\b)` +
|
|
420
|
+
// The quoted exact-marker alternatives (not just a bare marker) are
|
|
421
|
+
// what make this guard work with the quoted-value alternative
|
|
422
|
+
// below: an EARLIER pass (sk-key, jwt, gcp-private-key, …) that fires
|
|
423
|
+
// on a value embedded in `"api_key": "sk-ant-…"` leaves that marker
|
|
424
|
+
// wrapped in its ORIGINAL quotes — `"api_key": "«redacted:sk-key»"` —
|
|
425
|
+
// untouched by that pass (it only ever replaces the material INSIDE
|
|
426
|
+
// the quotes, never the quotes themselves). Without the quoted
|
|
427
|
+
// alternatives, the guard only sees a `"` immediately after the operator
|
|
428
|
+
// (not the marker glyph itself), lets the quoted-value alternative consume
|
|
429
|
+
// the whole `"«redacted:sk-key»"` span, and reclassifies it as the
|
|
430
|
+
// generic `assignment` — corrupting a MORE specific classification
|
|
431
|
+
// into a less specific one, silently, for any class that can appear
|
|
432
|
+
// as a governed key's JSON value.
|
|
433
|
+
`(?<sep>\\s*[:=]\\s*)${valueGuard}` +
|
|
434
|
+
`(?<value>${ledValue}${QUOTED_VALUE}|[^\\s,}\\]]{4,20000})`,
|
|
435
|
+
"gi",
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
// Declines only a value that is exactly a marker; redacts one that runs on past it.
|
|
439
|
+
const EXACT_MARKER_GUARD = `(?!${MARKER}(?![^\\s,}\\]])|"${MARKER}"|'${MARKER}')`;
|
|
440
|
+
const ASSIGNMENT_RE = assignmentRe(EXACT_MARKER_GUARD);
|
|
441
|
+
// The same, except a value that starts with a marker matches only its opening
|
|
442
|
+
// quote and `«redacted:` (group `led`), so `runAssignmentPass` can work out its
|
|
443
|
+
// extent in code and keep scanning inside it.
|
|
444
|
+
const ASSIGNMENT_LED_RE = assignmentRe(EXACT_MARKER_GUARD, `(?<led>['"]?«redacted:)|`);
|
|
445
|
+
const QUOTED_VALUE_RE = new RegExp(QUOTED_VALUE, "y");
|
|
446
|
+
const MARKER_LED_RE = /['"]?«redacted:/y;
|
|
447
|
+
|
|
448
|
+
function runWholeMatchPass(
|
|
449
|
+
text: string,
|
|
450
|
+
cls: RedactionClass,
|
|
451
|
+
re: RegExp,
|
|
452
|
+
counts: Record<RedactionClass, number>,
|
|
453
|
+
): string {
|
|
454
|
+
re.lastIndex = 0;
|
|
455
|
+
return text.replace(re, () => {
|
|
456
|
+
counts[cls]++;
|
|
457
|
+
return marker(cls);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
interface AssignmentGroups {
|
|
462
|
+
q?: string;
|
|
463
|
+
qname?: string;
|
|
464
|
+
uname?: string;
|
|
465
|
+
sep: string;
|
|
466
|
+
value: string;
|
|
467
|
+
led?: string;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** True when the match is `Authorization: <scheme> <marker>`, a header
|
|
471
|
+
* auth-header declined: it is left whole and uncounted (AUTH_DECLINED_RE). */
|
|
472
|
+
function isDeclinedAuthHeader(text: string, m: RegExpExecArray, groups: AssignmentGroups): boolean {
|
|
473
|
+
if (groups.uname === undefined || !groups.uname.toLowerCase().endsWith(AUTHORIZATION)) return false;
|
|
474
|
+
AUTH_DECLINED_RE.lastIndex = m.index + groups.uname.length - AUTHORIZATION.length;
|
|
475
|
+
return AUTH_DECLINED_RE.test(text);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** For each index, the first index at or after it that ends an unquoted value
|
|
479
|
+
* (whitespace, `,`, `}`, `]`), or `text.length`. */
|
|
480
|
+
function unquotedValueEnds(text: string): Int32Array {
|
|
481
|
+
const ends = new Int32Array(text.length + 1);
|
|
482
|
+
ends[text.length] = text.length;
|
|
483
|
+
for (let i = text.length - 1; i >= 0; i--) ends[i] = /[\s,}\]]/.test(text[i]) ? i : ends[i + 1];
|
|
484
|
+
return ends;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The `assignment` pass. Only the VALUE is replaced; the name stays for triage.
|
|
489
|
+
*
|
|
490
|
+
* A value that starts with a marker and runs on past it
|
|
491
|
+
* (`«redacted:sk-key»<tail of an over-long key>`) is ambiguous: its tail may
|
|
492
|
+
* be the rest of a secret, or, with no whitespace in between, the start of the
|
|
493
|
+
* next assignment (`"authorization": "Basic …"`, `auth: '…'`). Redacting the
|
|
494
|
+
* run as one value covers the first reading but swallows the second one's
|
|
495
|
+
* name, so its value would ship. Declining it covers the second and ships the
|
|
496
|
+
* tail. So when the plain scan (`ASSIGNMENT_RE`) meets such a value, a second
|
|
497
|
+
* scan (`ASSIGNMENT_LED_RE`) records each marker-led value's full extent AND
|
|
498
|
+
* keeps scanning inside it, and the pass redacts the union of both scans'
|
|
499
|
+
* values: each overlapping or touching run becomes one marker and one count.
|
|
500
|
+
*
|
|
501
|
+
* Linear: a text with no marker-led value is scanned once. In the second scan
|
|
502
|
+
* a marker-led value's regex match stops after `«redacted:`; its unquoted
|
|
503
|
+
* extent is a table lookup, and its quoted extent ends at the next matching
|
|
504
|
+
* quote, so the extents cost O(n) in total.
|
|
505
|
+
*/
|
|
506
|
+
function runAssignmentPass(text: string, counts: Record<RedactionClass, number>): string {
|
|
507
|
+
const ranges: Array<[number, number]> = [];
|
|
508
|
+
let markerLed = false;
|
|
509
|
+
ASSIGNMENT_RE.lastIndex = 0;
|
|
510
|
+
for (let m = ASSIGNMENT_RE.exec(text); m !== null; m = ASSIGNMENT_RE.exec(text)) {
|
|
511
|
+
const groups = m.groups as unknown as AssignmentGroups;
|
|
512
|
+
if (isDeclinedAuthHeader(text, m, groups)) continue;
|
|
513
|
+
const end = m.index + m[0].length;
|
|
514
|
+
const valueStart = end - groups.value.length;
|
|
515
|
+
MARKER_LED_RE.lastIndex = valueStart;
|
|
516
|
+
if (MARKER_LED_RE.test(text)) markerLed = true;
|
|
517
|
+
ranges.push([valueStart, end]);
|
|
518
|
+
}
|
|
519
|
+
if (markerLed) {
|
|
520
|
+
const unquotedEnds = unquotedValueEnds(text);
|
|
521
|
+
ASSIGNMENT_LED_RE.lastIndex = 0;
|
|
522
|
+
for (let m = ASSIGNMENT_LED_RE.exec(text); m !== null; m = ASSIGNMENT_LED_RE.exec(text)) {
|
|
523
|
+
const groups = m.groups as unknown as AssignmentGroups;
|
|
524
|
+
if (isDeclinedAuthHeader(text, m, groups)) continue;
|
|
525
|
+
const end = m.index + m[0].length;
|
|
526
|
+
const valueStart = end - groups.value.length;
|
|
527
|
+
if (groups.led === undefined) {
|
|
528
|
+
ranges.push([valueStart, end]);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
// The extent ASSIGNMENT_RE's value alternatives would give it.
|
|
532
|
+
QUOTED_VALUE_RE.lastIndex = valueStart;
|
|
533
|
+
const extent = QUOTED_VALUE_RE.test(text)
|
|
534
|
+
? QUOTED_VALUE_RE.lastIndex
|
|
535
|
+
: Math.min(unquotedEnds[valueStart], valueStart + 20000);
|
|
536
|
+
ranges.push([valueStart, extent]);
|
|
537
|
+
}
|
|
538
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
539
|
+
}
|
|
540
|
+
let out = "";
|
|
541
|
+
let last = 0;
|
|
542
|
+
for (let i = 0; i < ranges.length; ) {
|
|
543
|
+
const start = ranges[i][0];
|
|
544
|
+
let end = ranges[i][1];
|
|
545
|
+
for (i++; i < ranges.length && ranges[i][0] <= end; i++) end = Math.max(end, ranges[i][1]);
|
|
546
|
+
counts.assignment++;
|
|
547
|
+
out += `${text.slice(last, start)}${marker("assignment")}`;
|
|
548
|
+
last = end;
|
|
549
|
+
}
|
|
550
|
+
return out + text.slice(last);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// --- the pipeline, most-specific class first (see file header) -----------------
|
|
554
|
+
|
|
555
|
+
function runPipeline(input: string): { text: string; counts: Record<RedactionClass, number> } {
|
|
556
|
+
const counts = emptyCounts();
|
|
557
|
+
let text = input;
|
|
558
|
+
|
|
559
|
+
// gcp-private-key BEFORE the generic pem pass (reason 1, file header).
|
|
560
|
+
GCP_PRIVATE_KEY_RE.lastIndex = 0;
|
|
561
|
+
text = text.replace(GCP_PRIVATE_KEY_RE, (_whole, prefix: string, _value: string, suffix: string) => {
|
|
562
|
+
counts["gcp-private-key"]++;
|
|
563
|
+
return `${prefix}${marker("gcp-private-key")}${suffix}`;
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
text = runWholeMatchPass(text, "pem", PEM_RE, counts);
|
|
567
|
+
text = runWholeMatchPass(text, "jwt", JWT_RE, counts);
|
|
568
|
+
|
|
569
|
+
// sib-token BEFORE aws-key / github-token / sk-key (reason 3, file header).
|
|
570
|
+
text = runWholeMatchPass(text, "sib-token", SIB_TOKEN_RE, counts);
|
|
571
|
+
text = runWholeMatchPass(text, "sibdc-code", SIBDC_CODE_RE, counts);
|
|
572
|
+
|
|
573
|
+
// aws-key: two independent regexes feeding one class.
|
|
574
|
+
text = runWholeMatchPass(text, "aws-key", AWS_KEY_ID_RE, counts);
|
|
575
|
+
text = runWholeMatchPass(text, "aws-key", AWS_SECRET_RE, counts);
|
|
576
|
+
|
|
577
|
+
text = runWholeMatchPass(text, "github-token", GITHUB_TOKEN_RE, counts);
|
|
578
|
+
text = runWholeMatchPass(text, "slack-token", SLACK_TOKEN_RE, counts);
|
|
579
|
+
text = runWholeMatchPass(text, "google-api-key", GOOGLE_API_KEY_RE, counts);
|
|
580
|
+
text = runWholeMatchPass(text, "sk-key", SK_KEY_RE, counts);
|
|
581
|
+
|
|
582
|
+
// auth-header BEFORE assignment (reason 2, file header).
|
|
583
|
+
text = runWholeMatchPass(text, "auth-header", AUTH_HEADER_RE, counts);
|
|
584
|
+
|
|
585
|
+
text = runWholeMatchPass(text, "conn-string", CONN_STRING_RE, counts);
|
|
586
|
+
|
|
587
|
+
// assignment last: the catch-all, most likely to overlap an already-handled
|
|
588
|
+
// class's surface text if run earlier.
|
|
589
|
+
//
|
|
590
|
+
// Named groups, not positional args: the NAME side is an alternation
|
|
591
|
+
// (quoted vs. unquoted — see ASSIGNMENT_RE's definition), so exactly one
|
|
592
|
+
// of `qname`/`uname` is defined per match and the other is `undefined`.
|
|
593
|
+
// A quoted match reconstructs `q + qname + q` (the quotes are part of the
|
|
594
|
+
// preserved NAME text, same as the original design preserved an unquoted
|
|
595
|
+
// name verbatim); an unquoted match reconstructs `uname` alone.
|
|
596
|
+
text = runAssignmentPass(text, counts);
|
|
597
|
+
|
|
598
|
+
return { text, counts };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Redact secret material from a single string. Pure, synchronous, no I/O.
|
|
603
|
+
*
|
|
604
|
+
* Each match is replaced with `«redacted:<class>»`. `counts` names every
|
|
605
|
+
* `RedactionClass` (zero when unmatched — never an absent key) and `total` is
|
|
606
|
+
* their total. Idempotent: redacting the output of `redact()` again returns the
|
|
607
|
+
* same text with every count at zero (tests/yak-redact.test.ts).
|
|
608
|
+
*/
|
|
609
|
+
export function redact(text: string): RedactionResult {
|
|
610
|
+
const { text: redacted, counts } = runPipeline(text);
|
|
611
|
+
const total = REDACTION_CLASSES.reduce((acc, c) => acc + counts[c], 0);
|
|
612
|
+
return { text: redacted, counts, total };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Render one concise stderr line for a redaction pass's count/class
|
|
617
|
+
* breakdown — `docs/YAK.md` promises this on stderr in text mode, not
|
|
618
|
+
* only in `--output json`'s `redactions` field. Pure formatting, no policy: the caller decides WHEN to emit this
|
|
619
|
+
* (`yak.ts` only calls it in text mode, and only when there is something to
|
|
620
|
+
* report) — this function does not gate on `count` itself, so it stays a
|
|
621
|
+
* formatter, not a decision.
|
|
622
|
+
*
|
|
623
|
+
* Takes the `{count, classes}` shape structurally (matches
|
|
624
|
+
* `yak-submit.ts`'s `RedactionSummary` without importing it — this module
|
|
625
|
+
* stays dependency-free by design, file header "PURE") rather than a full
|
|
626
|
+
* `RedactionResult`, since the caller already has the summarized shape and
|
|
627
|
+
* re-deriving classes from `counts` would be a second implementation of
|
|
628
|
+
* `summarizeRedactionCounts` living in the wrong module.
|
|
629
|
+
*/
|
|
630
|
+
export function formatRedactionSummary(summary: { count: number; classes: RedactionClass[] }): string {
|
|
631
|
+
const noun = summary.count === 1 ? "redaction" : "redactions";
|
|
632
|
+
return `${summary.count} ${noun}: ${summary.classes.join(", ")}\n`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Result shape for `redactDeep()` — same `counts`/`total` contract as
|
|
637
|
+
* `RedactionResult`, but `value` is the redacted structure rather than a
|
|
638
|
+
* single string.
|
|
639
|
+
*/
|
|
640
|
+
export interface DeepRedactionResult<T> {
|
|
641
|
+
value: T;
|
|
642
|
+
counts: Record<RedactionClass, number>;
|
|
643
|
+
total: number;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Redact every string leaf in an arbitrary JSON-ish value (objects, arrays,
|
|
648
|
+
* strings, and pass-through primitives), accumulating one combined
|
|
649
|
+
* `counts`/`total` across the whole tree.
|
|
650
|
+
*
|
|
651
|
+
* For provenance: `provenance.git.origin_url` can carry a token
|
|
652
|
+
* embedded in the URL, and this is the one function that needs to reach it
|
|
653
|
+
* without the caller hand-walking the shape.
|
|
654
|
+
*
|
|
655
|
+
* `undefined` leaves pass through as `undefined` rather than being coerced to
|
|
656
|
+
* a string — this is what keeps an absent `evidence` field absent rather than
|
|
657
|
+
* becoming the four-character string `"undefined"` (the absent-vs-empty
|
|
658
|
+
* discipline the submission core one layer up depends on).
|
|
659
|
+
*/
|
|
660
|
+
export function redactDeep<T>(value: T): DeepRedactionResult<T> {
|
|
661
|
+
const counts = emptyCounts();
|
|
662
|
+
|
|
663
|
+
function walk(v: unknown): unknown {
|
|
664
|
+
if (typeof v === "string") {
|
|
665
|
+
const r = redact(v);
|
|
666
|
+
for (const c of REDACTION_CLASSES) counts[c] += r.counts[c];
|
|
667
|
+
return r.text;
|
|
668
|
+
}
|
|
669
|
+
if (Array.isArray(v)) {
|
|
670
|
+
return v.map(walk);
|
|
671
|
+
}
|
|
672
|
+
if (v !== null && typeof v === "object") {
|
|
673
|
+
const out: Record<string, unknown> = {};
|
|
674
|
+
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
|
675
|
+
out[k] = walk(val);
|
|
676
|
+
}
|
|
677
|
+
return out;
|
|
678
|
+
}
|
|
679
|
+
// numbers, booleans, null, undefined — pass through unchanged.
|
|
680
|
+
return v;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const redactedValue = walk(value) as T;
|
|
684
|
+
const total = REDACTION_CLASSES.reduce((acc, c) => acc + counts[c], 0);
|
|
685
|
+
return { value: redactedValue, counts, total };
|
|
686
|
+
}
|