@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.
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { spawn } from 'child_process';
26
26
  import { describeChildExit } from './child-exit.lib.mjs';
27
+ import { sanitizeForPublication } from './token-sanitization.lib.mjs'; // issue #2156: this body is published to a pull request
27
28
  import { KILL_CAUSE_DISK_FULL, KILL_CAUSE_FORCED_KILL, KILL_CAUSE_OUT_OF_MEMORY } from './session-kill-diagnostics.lib.mjs';
28
29
  import { ON_SESSION_KILL_RESUME } from './session-kill-policy.lib.mjs';
29
30
 
@@ -161,6 +162,13 @@ const defaultUnlink = async filePath => {
161
162
  * `--body-file` (not `--body`) is used deliberately: the notice contains
162
163
  * backticks and newlines that would otherwise have to survive shell quoting.
163
164
  *
165
+ * Issue #2156: the body is sanitized here rather than by the caller. It carries
166
+ * kill diagnostics and a resume command, both assembled from process and log
167
+ * data, so it is a publication boundary like any other and must fail closed.
168
+ * The array-argument `gh` invocation below is invisible to the
169
+ * `require-sanitized-output` ESLint rule, which is exactly how this path stayed
170
+ * unsanitized; the rule now understands this shape too.
171
+ *
164
172
  * @param {Object} options
165
173
  * @param {string} options.pullRequestUrl
166
174
  * @param {string} options.body
@@ -178,7 +186,7 @@ export async function postKillRecoveryNotice({ pullRequestUrl, body, runCommand
178
186
 
179
187
  const bodyFile = `${tempDir.replace(/\/$/, '')}/hive-mind-kill-notice-${fileSuffix}.md`;
180
188
  try {
181
- await writeFile(bodyFile, body);
189
+ await writeFile(bodyFile, await sanitizeForPublication(body));
182
190
  const result = await runCommand('gh', ['pr', 'comment', pullRequestUrl, '--body-file', bodyFile]);
183
191
  if (result?.code === 0) {
184
192
  const url = String(result.stdout || '').trim() || null;
@@ -19,6 +19,7 @@ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
19
19
  // lib.mjs, so it must not depend on this asynchronous Secretlint layer.
20
20
  import { log, isENOSPC } from './lib.mjs';
21
21
  import { CREDENTIAL_SANITIZATION_ERROR_CODE, CREDENTIAL_SANITIZATION_FAILURE_MESSAGE, createCredentialStreamSanitizer, findCredentialResiduals, maskToken, sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
22
+ import { findDecodableRuns, findEncodedKnownTokenRuns, sanitizeEncodedCredentials } from './encoded-credential-detection.lib.mjs'; // issue #2156: credentials that only appear re-encoded
22
23
  import { reportError } from './sentry.lib.mjs';
23
24
 
24
25
  export { createCredentialStreamSanitizer };
@@ -518,6 +519,130 @@ const sanitizeCredentialTextPreservingExclusions = (input, excludedSet) => {
518
519
  return output;
519
520
  };
520
521
 
522
+ // ---------------------------------------------------------------------------
523
+ // Issue #2156 — known-local tokens that only appear in an encoded form
524
+ // ---------------------------------------------------------------------------
525
+ // The leak in this issue was a `gho_` token that the GHCR token endpoint echoed
526
+ // back base64-encoded inside a JSON body. Every masking layer we had compared
527
+ // bytes literally, so the encoded copy walked straight through. These helpers
528
+ // mask the *encoded* occurrences of tokens we already hold locally.
529
+ // ---------------------------------------------------------------------------
530
+
531
+ /** Encoded-scan recursion limit: base64-of-base64-of-base64 and no deeper. */
532
+ const MAX_ENCODED_KNOWN_TOKEN_DEPTH = 2;
533
+
534
+ /**
535
+ * Replace every verbatim occurrence of the supplied token values.
536
+ *
537
+ * @param {string} text
538
+ * @param {Array<string>} values already filtered and de-duplicated
539
+ * @returns {string}
540
+ */
541
+ const maskKnownTokenValues = (text, values) => {
542
+ let output = text;
543
+ for (const value of values) {
544
+ if (output.includes(value)) output = output.split(value).join(maskToken(value));
545
+ }
546
+ return output;
547
+ };
548
+
549
+ /**
550
+ * Narrow a raw token list to the values worth searching for.
551
+ *
552
+ * @param {Array<string|{value: string}>} tokens
553
+ * @param {Set<string>} [excludedSet] issue #1745 user-content carve-out
554
+ * @returns {Array<string>}
555
+ */
556
+ const usableTokenValues = (tokens, excludedSet) => [...new Set((tokens || []).map(t => (typeof t === 'string' ? t : t?.value)).filter(value => typeof value === 'string' && value.length >= 12))].filter(value => !excludedSet?.has(value));
557
+
558
+ /**
559
+ * Mask encoded occurrences of known-local tokens.
560
+ *
561
+ * Decoded payloads are rebuilt rather than dropped: a base64 blob that merely
562
+ * *contains* the token keeps its other fields and stays parseable, and the
563
+ * masked token retains its first/last characters for debugging — the same
564
+ * contract plaintext masking has always offered.
565
+ *
566
+ * @param {string} text
567
+ * @param {Array<string>} values from {@link usableTokenValues}
568
+ * @param {number} [depth] internal recursion counter
569
+ * @returns {string}
570
+ */
571
+ const maskEncodedKnownTokens = (text, values, depth = 0) => {
572
+ if (values.length === 0) return text;
573
+ return sanitizeEncodedCredentials(text, {
574
+ knownTokens: values,
575
+ sanitizePlaintext: decoded => {
576
+ const masked = maskKnownTokenValues(decoded, values);
577
+ // Peel nested encodings so base64-of-base64 is covered too.
578
+ return depth >= MAX_ENCODED_KNOWN_TOKEN_DEPTH ? masked : maskEncodedKnownTokens(masked, values, depth + 1);
579
+ },
580
+ });
581
+ };
582
+
583
+ /**
584
+ * Mask encoded runs whose *decoded* payload Secretlint recognises.
585
+ *
586
+ * This is the redundancy the issue asks for, aimed at where it actually helps.
587
+ * Secretlint is blind to encoding: its GitHub rule flags a bare `gho_…` but
588
+ * reports nothing for the same token base64-encoded, and neither does any other
589
+ * pattern scanner, because a pattern scanner matches the bytes it is given.
590
+ * Adding a third scanner alongside the first two would therefore have changed
591
+ * nothing about this incident. Decoding first and *then* asking both detectors
592
+ * is what closes the gap, so the external rule set is applied to the decoded
593
+ * payload exactly as the maintained core already is.
594
+ *
595
+ * The two detectors stay independent: this runs whether or not the core found
596
+ * anything, so a credential format Secretlint knows and we do not is still
597
+ * caught once it is decoded.
598
+ *
599
+ * @param {string} text
600
+ * @param {Set<string>} [excludedSet] issue #1745 user-content carve-out
601
+ * @returns {Promise<{text: string, masked: number, ruleIds: Array<string>}>}
602
+ */
603
+ const maskEncodedSecretsWithSecretlint = async (text, excludedSet) => {
604
+ const runs = findDecodableRuns(text);
605
+ if (runs.length === 0) return { text, masked: 0, ruleIds: [] };
606
+
607
+ // Each payload is scanned on its own rather than as one joined document: a
608
+ // rule that matched across a join boundary would blame a run that is
609
+ // innocent, and masking an innocent run destroys log content.
610
+ const verdicts = await Promise.all(runs.map(run => detectSecretsWithSecretlint(run.decoded)));
611
+
612
+ // Keyed by decoded content, because that is what the sync layer hands back
613
+ // when it re-walks the same runs below. Two runs that decode identically are
614
+ // masked identically, which is what we want.
615
+ const maskedPayloads = new Map();
616
+ const ruleIds = new Set();
617
+ for (const [index, findings] of verdicts.entries()) {
618
+ const usable = findings.filter(finding => !excludedSet?.has(finding.token));
619
+ if (usable.length === 0) continue;
620
+ const { decoded } = runs[index];
621
+
622
+ // Mask inside the decoded payload so the surrounding structure survives.
623
+ // Ranges are spliced from the end so earlier offsets stay valid.
624
+ let payload = decoded;
625
+ for (const finding of [...usable].sort((a, b) => b.start - a.start)) {
626
+ if (payload.substring(finding.start, finding.end) !== finding.token) continue;
627
+ payload = payload.substring(0, finding.start) + maskToken(finding.token) + payload.substring(finding.end);
628
+ ruleIds.add(finding.ruleId);
629
+ }
630
+ if (payload === decoded) continue;
631
+ maskedPayloads.set(decoded, payload);
632
+ }
633
+
634
+ if (maskedPayloads.size === 0) return { text, masked: 0, ruleIds: [] };
635
+
636
+ // Re-encoding, round-trip verification and overlap merging are the sync
637
+ // layer's job. Driving it with a lookup of payloads we have already masked
638
+ // means the two paths cannot disagree about what a masked run looks like.
639
+ const output = sanitizeEncodedCredentials(text, {
640
+ sanitizePlaintext: decoded => maskedPayloads.get(decoded) ?? decoded,
641
+ });
642
+
643
+ return { text: output, masked: maskedPayloads.size, ruleIds: [...ruleIds] };
644
+ };
645
+
521
646
  /**
522
647
  * Sanitize arbitrary outbound output by masking sensitive tokens while avoiding false positives
523
648
  * Uses DUAL APPROACH: Both secretlint AND custom patterns run independently
@@ -543,6 +668,8 @@ export const sanitizeOutput = async (output, options = {}) => {
543
668
  const stats = {
544
669
  knownTokens: 0,
545
670
  secretlintDetections: 0,
671
+ encodedSecretlintDetections: 0,
672
+ encodedSecretlintRuleIds: [],
546
673
  customDetections: 0,
547
674
  secretlintOnlyWarnings: [],
548
675
  customOnlyDetections: [],
@@ -571,6 +698,17 @@ export const sanitizeOutput = async (output, options = {}) => {
571
698
  }
572
699
  }
573
700
  }
701
+
702
+ // Issue #2156: the same tokens, base64/hex/percent-encoded. Byte-for-byte
703
+ // comparison above cannot see those copies.
704
+ const encodableTokens = usableTokenValues(allKnownTokens, excludedSet);
705
+ const beforeEncoded = sanitized;
706
+ sanitized = maskEncodedKnownTokens(sanitized, encodableTokens);
707
+ if (sanitized !== beforeEncoded) {
708
+ stats.knownTokens++;
709
+ sanitizationStats.knownTokenMasks++;
710
+ sanitizationStats.totalMasked++;
711
+ }
574
712
  }
575
713
 
576
714
  if (skipOutputSanitization) {
@@ -663,6 +801,21 @@ export const sanitizeOutput = async (output, options = {}) => {
663
801
  }
664
802
  }
665
803
 
804
+ // Step 3b (issue #2156): everything above compares against the *surface*
805
+ // text, so a credential that only ever appears encoded is invisible to it —
806
+ // that is exactly how the leaked token survived. The maintained core
807
+ // already reads decoded payloads; run the external rule set over them too,
808
+ // so the two layers cover the same ground and either one can be the catch.
809
+ const beforeEncodedScan = sanitized;
810
+ const encodedScan = await maskEncodedSecretsWithSecretlint(sanitized, excludedSet);
811
+ if (encodedScan.text !== beforeEncodedScan) {
812
+ sanitized = encodedScan.text;
813
+ stats.encodedSecretlintDetections += encodedScan.masked;
814
+ stats.encodedSecretlintRuleIds = encodedScan.ruleIds;
815
+ sanitizationStats.patternMasks += encodedScan.masked;
816
+ sanitizationStats.totalMasked += encodedScan.masked;
817
+ }
818
+
666
819
  // Step 4: Handle 40-char hex tokens specially - only mask if NOT in safe context
667
820
  // These could be GitHub tokens OR git commit hashes/gist IDs
668
821
  const hexPattern = /(?:^|[\s:=])([a-f0-9]{40})(?=[\s\n]|$)/gm;
@@ -701,11 +854,14 @@ export const sanitizeOutput = async (output, options = {}) => {
701
854
  }
702
855
 
703
856
  // Summary logging
704
- const totalMasked = allSecrets.size + hexReplacements.length + stats.knownTokens;
857
+ const totalMasked = allSecrets.size + hexReplacements.length + stats.knownTokens + stats.encodedSecretlintDetections;
705
858
  if (global.verboseMode && totalMasked > 0) {
706
859
  await log(` 🔒 Sanitized ${totalMasked} secrets using dual approach:`, { verbose: true });
707
860
  await log(` • Known tokens: ${stats.knownTokens}`, { verbose: true });
708
861
  await log(` • Secretlint: ${stats.secretlintDetections} detections`, { verbose: true });
862
+ if (stats.encodedSecretlintDetections > 0) {
863
+ await log(` • Secretlint (encoded payloads): ${stats.encodedSecretlintDetections} run(s) [${stats.encodedSecretlintRuleIds.join(', ')}]`, { verbose: true });
864
+ }
709
865
  await log(` • Custom patterns: ${stats.customDetections} detections`, { verbose: true });
710
866
  await log(` • Hex tokens: ${hexReplacements.length}`, { verbose: true });
711
867
  if (stats.secretlintOnlyWarnings.length > 0) {
@@ -929,16 +1085,29 @@ export const getAllKnownLocalTokens = async () => {
929
1085
  * @param {Array<{value: string, name?: string, source?: string}>} [tokens]
930
1086
  * Pre-fetched token list (if you already called getAllKnownLocalTokens).
931
1087
  * Pass an explicit list to avoid re-running `gh auth status` per check.
932
- * @returns {Promise<Array<{name: string, source: string}>>} list of token
933
- * identifiers that were found in the text (NOT the values themselves).
1088
+ * Issue #2156: a token that appears only base64/hex/percent-encoded is a leak
1089
+ * just the same GitHub's own secret scanning decodes before matching, which
1090
+ * is exactly how the revocation in that issue was triggered. Encoded hits are
1091
+ * reported with the encoding that matched so operators can tell the two cases
1092
+ * apart in the fail-closed publication error path.
1093
+ *
1094
+ * @returns {Promise<Array<{name: string, source: string, encoding: string}>>}
1095
+ * list of token identifiers that were found in the text (NOT the values
1096
+ * themselves).
934
1097
  */
935
1098
  export const containsKnownToken = async (text, tokens) => {
936
1099
  if (typeof text !== 'string' || text.length === 0) return [];
937
1100
  const list = tokens || (await getAllKnownLocalTokens());
938
1101
  const hits = [];
939
1102
  for (const t of list) {
940
- if (t.value && text.includes(t.value)) {
941
- hits.push({ name: t.name, source: t.source });
1103
+ if (!t.value) continue;
1104
+ if (text.includes(t.value)) {
1105
+ hits.push({ name: t.name, source: t.source, encoding: 'plaintext' });
1106
+ continue;
1107
+ }
1108
+ const encodedRuns = findEncodedKnownTokenRuns(text, [t.value]);
1109
+ if (encodedRuns.length > 0) {
1110
+ hits.push({ name: t.name, source: t.source, encoding: encodedRuns[0].encoding });
942
1111
  }
943
1112
  }
944
1113
  return hits;
@@ -979,6 +1148,14 @@ export const sanitizeCommentBody = async (body, options = {}) => {
979
1148
  sanitizationStats.totalMasked++;
980
1149
  }
981
1150
  }
1151
+
1152
+ // Issue #2156: the same tokens, re-encoded (base64/hex/percent/escapes).
1153
+ const beforeEncoded = sanitized;
1154
+ sanitized = maskEncodedKnownTokens(sanitized, usableTokenValues(knownTokens, excludedSet));
1155
+ if (sanitized !== beforeEncoded) {
1156
+ sanitizationStats.knownTokenMasks++;
1157
+ sanitizationStats.totalMasked++;
1158
+ }
982
1159
  }
983
1160
 
984
1161
  // Pass 2: regex + secretlint sweep for anything else.