@link-assistant/hive-mind 2.12.3 → 2.12.4
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
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.12.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5659a7d: Sanitize credentials that only appear encoded. A GitHub token leaked through a published log because GHCR's token endpoint echoes the supplied credential back base64-encoded inside a JSON body, and every sanitization layer compared surface bytes: the base64 spelling shares no substring with the token, contains no `gho_`, and the structured-assignment rules that would have masked it on the key alone could not read through the backslash-escaped quotes that agent tool results wrap stdout in. Bounded base64 (at all three byte alignments), base64url, hex, percent, byte-escape and HTML-entity runs are now decoded, sanitized with the existing rules, and re-encoded with the round trip verified before substitution — so the `abc…xyz` mask survives the encoding and a run that cannot be rebuilt exactly is redacted whole. Line-wrapped blobs are scanned as groups of base64-only lines, since every common wrapper folds at 76 or 64 columns and leaves no single-line run to match, and the stream sanitizer holds such a group back rather than releasing byte-misaligned slices — together with the line the blob starts on, which is not base64-only and so joins no group, and which released on its own would leave a credential straddling the first fold in neither half. That line is recognised by whether its trailing base64 run decodes to text rather than by its length, so a URL, a commit SHA or a content digest still passes through without delay. Secretlint now runs over the decoded payloads too, sharing the same decoder walk as the synchronous core, which is the redundancy that actually helps: measured against the incident log, its recommended preset finds nothing at all in the surface text. Also fixes the `SENSITIVE_KEY` affixes, whose unbounded backtracking turned a 553 KB base64 blob into a hang at a fail-closed publication boundary, and `postKillRecoveryNotice()`, the one publication path that never sanitized — together with the `require-sanitized-output` rule's blindness to argv-array `gh` invocations that let it stay that way.
|
|
8
|
+
|
|
3
9
|
## 2.12.3
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { StringDecoder } from 'node:string_decoder';
|
|
10
|
+
import { sanitizeEncodedCredentials, wrappedBase64HoldStart } from './encoded-credential-detection.lib.mjs';
|
|
10
11
|
|
|
11
12
|
export const CREDENTIAL_SANITIZATION_ERROR_CODE = 'ERR_CREDENTIAL_SANITIZATION';
|
|
12
13
|
export const CREDENTIAL_SANITIZATION_FAILURE_MESSAGE = 'Credential sanitization failed; publication was blocked.';
|
|
@@ -70,7 +71,17 @@ const VENDOR_PATTERNS = Object.freeze([
|
|
|
70
71
|
/\b(?:[A-Z0-9]+[_-])+(?:TOKEN|SECRET|PASSWORD|CREDENTIAL|API[_-]?KEY)(?:[_-][A-Z0-9]+)+\b/g,
|
|
71
72
|
]);
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
// The affixes around the sensitive word are bounded rather than `*`. Unbounded
|
|
75
|
+
// they made every assignment rule quadratic: at each of the N starting offsets
|
|
76
|
+
// in a long identifier-shaped run the engine consumed to the end and then
|
|
77
|
+
// backtracked one character at a time looking for the sensitive word. A single
|
|
78
|
+
// 64 KB base64 blob — routine in a published log — took 19 s per rule and a
|
|
79
|
+
// 256 KB one never finished, which turns the fail-closed publication path into
|
|
80
|
+
// a hang. Bounding costs nothing in coverage: a key whose affix is longer than
|
|
81
|
+
// this simply matches from a later offset, and the affix is preserved text
|
|
82
|
+
// rather than masked content, so the sanitized output is identical.
|
|
83
|
+
const MAX_KEY_AFFIX = 64;
|
|
84
|
+
const SENSITIVE_KEY = String.raw`(?:[A-Za-z0-9_.-]{0,${MAX_KEY_AFFIX}}(?:api[-_]?key|account[-_]?key|client[-_]?secret|consumer[-_]?secret|webhook[-_]?secret|access[-_]?token|refresh[-_]?token|auth[-_]?token|password|passwd|pwd|private[-_]?key|secret|token|session[-_]?key|session[-_]?token|cookie|docker[-_]?auth|registry[-_]?auth|shared[-_]?access[-_]?signature|sas[-_]?token)[A-Za-z0-9_.-]{0,${MAX_KEY_AFFIX}}|auth|authorization)`;
|
|
74
85
|
|
|
75
86
|
// Issue #2119: token *accounting* is not a credential. Every AI provider SDK
|
|
76
87
|
// spells usage telemetry with the plural "tokens" (`tokens`, `inputTokens`,
|
|
@@ -90,14 +101,38 @@ const normalizeAssignmentKey = prefix =>
|
|
|
90
101
|
.toLowerCase();
|
|
91
102
|
|
|
92
103
|
const isTokenCounterAssignment = (prefix, value) => NUMERIC_VALUE.test(String(value ?? '').trim()) && TOKEN_COUNTER_KEY.test(normalizeAssignmentKey(prefix));
|
|
104
|
+
|
|
105
|
+
// Issue #2156: `SENSITIVE_KEY` matches any key *containing* `secret`, `token`
|
|
106
|
+
// or `auth`, which includes real npm package names — `secretlint`,
|
|
107
|
+
// `@secretlint/secretlint-rule-preset-recommend`, `next-auth`. Their manifest
|
|
108
|
+
// values are version ranges, and masking those rewrote dependency versions to
|
|
109
|
+
// `[REDACTED]` in every published `package.json` excerpt. A credential is
|
|
110
|
+
// never a bare semver range, so exempting that exact value shape costs no
|
|
111
|
+
// protection. The guard is on the value alone; `token: "1.2.3"` in a real
|
|
112
|
+
// credential field would be a 5-character value that `maskToken` reduces to
|
|
113
|
+
// `[REDACTED]` anyway.
|
|
114
|
+
// One comparator (`^1.2.3`, `>=4.0.0`, `v2.1`), optionally repeated as a range
|
|
115
|
+
// joined by whitespace, `-` or `||` — the full npm/semver range grammar.
|
|
116
|
+
const VERSION_COMPARATOR = String.raw`(?:\^|~|[><]=?|=)?\s*v?\d+(?:\.\d+){1,2}(?:[-+][0-9A-Za-z.-]+)?`;
|
|
117
|
+
const VERSION_RANGE_VALUE = new RegExp(`^${VERSION_COMPARATOR}(?:(?:\\s*(?:\\|\\||-)\\s*|\\s+)${VERSION_COMPARATOR})*$`);
|
|
118
|
+
const isVersionRangeAssignment = value => VERSION_RANGE_VALUE.test(String(value ?? '').trim());
|
|
93
119
|
const SENSITIVE_ENV_NAME = /(?:API_?KEY|ACCOUNT_?KEY|CLIENT_?SECRET|CONSUMER_?SECRET|WEBHOOK_?SECRET|ACCESS_?TOKEN|REFRESH_?TOKEN|AUTH_?TOKEN|PASSWORD|PASSWD|PRIVATE_?KEY|SECRET|TOKEN|COOKIE|AUTH)$/i;
|
|
94
|
-
|
|
120
|
+
// Issue #2156: structured output is routinely nested inside another JSON
|
|
121
|
+
// document — an agent tool result embeds the command's stdout as a JSON
|
|
122
|
+
// *string*, so a response body reaches the log as `{\"token\":\"...\"}` with
|
|
123
|
+
// every quote backslash-escaped. Anchoring on a bare `"` missed all of those,
|
|
124
|
+
// which meant the generic `token` / `password` / `api_key` rules silently did
|
|
125
|
+
// nothing for the single most common shape in our own logs. A quote delimiter
|
|
126
|
+
// is therefore any run of backslashes followed by a quote character, and the
|
|
127
|
+
// value is matched lazily so it stops at the escape rather than swallowing it.
|
|
128
|
+
const QUOTE = String.raw`\\*["']`;
|
|
129
|
+
const QUOTED_ASSIGNMENT = new RegExp(`((?:${QUOTE})?${SENSITIVE_KEY}(?:${QUOTE})?\\s*(?:=>|[:=])\\s*)(${QUOTE})([^"'\\r\\n]*?)(${QUOTE})`, 'gi');
|
|
95
130
|
// Issue #2119: a value that *opens* a JSON/JS structure is punctuation, not a
|
|
96
131
|
// secret. Without this guard `"tokens": {` was rewritten to `"tokens": [REDACTED]`,
|
|
97
132
|
// which silently truncated the object and made the whole record unparseable.
|
|
98
133
|
// The guard only rejects a structural character in first position, so a
|
|
99
134
|
// credential that merely contains a brace (`password=ab{cd`) is still masked whole.
|
|
100
|
-
const UNQUOTED_ASSIGNMENT = new RegExp(`((
|
|
135
|
+
const UNQUOTED_ASSIGNMENT = new RegExp(`((?:${QUOTE})?${SENSITIVE_KEY}(?:${QUOTE})?\\s*(?:=>|[:=])\\s*)(?!${QUOTE}|[{[]|(?:Bearer|Basic|SharedAccessSignature)\\s)([^\\s,;}&'"\\r\\n]+)`, 'gi');
|
|
101
136
|
const XML_CREDENTIAL = new RegExp(`(<(${SENSITIVE_KEY})\\b[^>]*>)([\\s\\S]*?)(<\\/\\2\\s*>)`, 'gi');
|
|
102
137
|
const CLI_CREDENTIAL_QUOTED = new RegExp(`(--${SENSITIVE_KEY}(?:\\s+|=))(["'])([^"'\\r\\n]*)(\\2)`, 'gi');
|
|
103
138
|
const CLI_CREDENTIAL = new RegExp(`(--${SENSITIVE_KEY}(?:\\s+|=))(?!["'])([^\\s"'\\r\\n]+)`, 'gi');
|
|
@@ -121,9 +156,13 @@ const replaceCookieHeader = (_match, prefix, cookieText) => {
|
|
|
121
156
|
|
|
122
157
|
/**
|
|
123
158
|
* Synchronously sanitize known vendor credentials and credential-like
|
|
124
|
-
* structured values
|
|
159
|
+
* structured values in their plaintext representation.
|
|
160
|
+
*
|
|
161
|
+
* Encoded representations are handled by {@link sanitizeCredentialText}, which
|
|
162
|
+
* wraps this function; keeping the plaintext rules separate is what lets the
|
|
163
|
+
* encoded layer call back into them without recursing forever.
|
|
125
164
|
*/
|
|
126
|
-
|
|
165
|
+
const sanitizePlaintextCredentials = (input, options = {}) => {
|
|
127
166
|
let output = String(input ?? '');
|
|
128
167
|
|
|
129
168
|
// Known active credentials are the strongest signal and are replaced before
|
|
@@ -160,8 +199,11 @@ export const sanitizeCredentialText = (input, options = {}) => {
|
|
|
160
199
|
|
|
161
200
|
// XML and JSON/YAML/TOML/INI/shell-style assignments.
|
|
162
201
|
output = output.replace(XML_CREDENTIAL, (_match, start, _key, value, end) => `${start}${maskValue(value.trim())}${end}`);
|
|
163
|
-
|
|
164
|
-
|
|
202
|
+
// The opening and closing delimiters are captured independently because an
|
|
203
|
+
// escaped payload may not balance them symmetrically; each is preserved as
|
|
204
|
+
// written so the surrounding document stays byte-for-byte parseable.
|
|
205
|
+
output = output.replace(QUOTED_ASSIGNMENT, (match, prefix, openQuote, value, closeQuote) => (isTokenCounterAssignment(prefix, value) || isVersionRangeAssignment(value) ? match : `${prefix}${openQuote}${maskValue(value)}${closeQuote}`));
|
|
206
|
+
output = output.replace(UNQUOTED_ASSIGNMENT, (match, prefix, value) => (isTokenCounterAssignment(prefix, value) || isVersionRangeAssignment(value) ? match : `${prefix}${maskValue(value)}`));
|
|
165
207
|
|
|
166
208
|
// CLI arguments and sensitive query parameters.
|
|
167
209
|
output = output.replace(CLI_CREDENTIAL_QUOTED, (_match, prefix, quote, value) => `${prefix}${quote}${maskValue(value)}${quote}`);
|
|
@@ -176,6 +218,50 @@ export const sanitizeCredentialText = (input, options = {}) => {
|
|
|
176
218
|
return output;
|
|
177
219
|
};
|
|
178
220
|
|
|
221
|
+
/**
|
|
222
|
+
* How many nested encoding layers to peel. A credential wrapped in base64 of
|
|
223
|
+
* base64 is still a credential; beyond three layers the payload is no longer
|
|
224
|
+
* something any real service produces.
|
|
225
|
+
*/
|
|
226
|
+
const MAX_ENCODED_SANITIZATION_DEPTH = 3;
|
|
227
|
+
|
|
228
|
+
const collectKnownTokenValues = options => {
|
|
229
|
+
const environmentTokens =
|
|
230
|
+
options.includeEnvironmentCredentials === false
|
|
231
|
+
? []
|
|
232
|
+
: Object.entries(process.env)
|
|
233
|
+
.filter(([name, value]) => SENSITIVE_ENV_NAME.test(name) && typeof value === 'string' && value.length > 0)
|
|
234
|
+
.map(([, value]) => value);
|
|
235
|
+
const explicit = (options.knownTokens || []).map(token => (typeof token === 'string' ? token : token?.value)).filter(Boolean);
|
|
236
|
+
return [...environmentTokens, ...explicit];
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Synchronously sanitize known vendor credentials and credential-like
|
|
241
|
+
* structured values, in both plaintext **and encoded** representations. The
|
|
242
|
+
* operation is deterministic and idempotent.
|
|
243
|
+
*
|
|
244
|
+
* Issue #2156: a credential that reaches a log only as base64 (or hex, percent,
|
|
245
|
+
* escape or entity encoding) has no plaintext substring for the rules above to
|
|
246
|
+
* match, yet remains fully recoverable — and GitHub's secret scanning does
|
|
247
|
+
* recover it. Encoded runs are therefore decoded, sanitized with the same
|
|
248
|
+
* rules, and re-encoded in place.
|
|
249
|
+
*/
|
|
250
|
+
export const sanitizeCredentialText = (input, options = {}) => {
|
|
251
|
+
const knownTokens = collectKnownTokenValues(options);
|
|
252
|
+
|
|
253
|
+
const sanitizeAtDepth = (text, depth) => {
|
|
254
|
+
const plain = sanitizePlaintextCredentials(text, options);
|
|
255
|
+
if (depth >= MAX_ENCODED_SANITIZATION_DEPTH || options.skipEncodedCredentials === true) return plain;
|
|
256
|
+
return sanitizeEncodedCredentials(plain, {
|
|
257
|
+
knownTokens,
|
|
258
|
+
sanitizePlaintext: nested => sanitizeAtDepth(nested, depth + 1),
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return sanitizeAtDepth(String(input ?? ''), 0);
|
|
263
|
+
};
|
|
264
|
+
|
|
179
265
|
/**
|
|
180
266
|
* Return a non-sensitive indication that another sanitizer pass would change
|
|
181
267
|
* the text. Callers never receive the matching value.
|
|
@@ -232,8 +318,17 @@ export const createCredentialStreamSanitizer = options => {
|
|
|
232
318
|
|
|
233
319
|
const boundary = Math.max(pending.lastIndexOf('\n'), pending.lastIndexOf('\r'));
|
|
234
320
|
if (boundary < 0) break;
|
|
235
|
-
|
|
236
|
-
|
|
321
|
+
|
|
322
|
+
// Issue #2156: a base64 blob wrapped across lines is one credential
|
|
323
|
+
// carrier spread over many records. Releasing those records one at a time
|
|
324
|
+
// hands the sanitizer a byte-misaligned slice that decodes to noise, so
|
|
325
|
+
// hold a trailing group of base64-only lines back until a line that
|
|
326
|
+
// cannot belong to the blob arrives (or `flush` forces the issue), the
|
|
327
|
+
// same way an unterminated PEM block is held above.
|
|
328
|
+
const releaseEnd = wrappedBase64HoldStart(pending, boundary + 1) ?? boundary + 1;
|
|
329
|
+
if (releaseEnd <= 0) break;
|
|
330
|
+
output += sanitizeCredentialText(pending.slice(0, releaseEnd), options);
|
|
331
|
+
pending = pending.slice(releaseEnd);
|
|
237
332
|
}
|
|
238
333
|
|
|
239
334
|
return output;
|