@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
|
-
*
|
|
933
|
-
*
|
|
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
|
|
941
|
-
|
|
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.
|