@link-assistant/hive-mind 1.64.1 → 1.64.3

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.
@@ -28,6 +28,61 @@ const getFsModule = async () => (await import('fs')).promises;
28
28
  let secretlintCore = null;
29
29
  let secretlintConfig = null;
30
30
 
31
+ // Issue #1745: process-wide counters for how many tokens were masked. The
32
+ // final-summary path (solve.mjs / hive.mjs) reads these to print a one-line
33
+ // "we masked N secrets — pass --dangerously-skip-output-sanitization to skip"
34
+ // note when N > 0. Counters are intentionally simple (numbers, not arrays of
35
+ // values) so we never accidentally retain raw tokens for any longer than the
36
+ // masking pass itself.
37
+ const sanitizationStats = {
38
+ totalMasked: 0,
39
+ knownTokenMasks: 0,
40
+ patternMasks: 0,
41
+ hexMasks: 0,
42
+ excluded: 0,
43
+ };
44
+
45
+ /**
46
+ * Read process-wide sanitization counters. Pure read; never mutates.
47
+ * @returns {{totalMasked:number, knownTokenMasks:number, patternMasks:number, hexMasks:number, excluded:number}}
48
+ */
49
+ export const getSanitizationStats = () => ({ ...sanitizationStats });
50
+
51
+ /**
52
+ * Reset process-wide counters. Tests use this between cases. Production code
53
+ * has no reason to reset mid-run.
54
+ */
55
+ export const resetSanitizationStats = () => {
56
+ sanitizationStats.totalMasked = 0;
57
+ sanitizationStats.knownTokenMasks = 0;
58
+ sanitizationStats.patternMasks = 0;
59
+ sanitizationStats.hexMasks = 0;
60
+ sanitizationStats.excluded = 0;
61
+ };
62
+
63
+ /**
64
+ * Format a one-line operator-facing summary describing how many tokens were
65
+ * masked during this run, plus the dangerously-skip note required by the
66
+ * issue when the count is > 0. Returns an empty string if nothing was masked,
67
+ * so the caller can simply check truthiness before logging.
68
+ *
69
+ * @param {Object} [stats] override stats (defaults to module counters)
70
+ * @returns {string}
71
+ */
72
+ export const formatSanitizationSummary = (stats = sanitizationStats) => {
73
+ const { totalMasked = 0, knownTokenMasks = 0, patternMasks = 0, hexMasks = 0, excluded = 0 } = stats;
74
+ if (totalMasked <= 0 && excluded <= 0) return '';
75
+ const breakdown = [`known-local: ${knownTokenMasks}`, `pattern: ${patternMasks}`, `hex: ${hexMasks}`].join(', ');
76
+ const lines = [`🔒 Output sanitization: masked ${totalMasked} token(s) (${breakdown}) before publishing.`];
77
+ if (excluded > 0) {
78
+ lines.push(` ↳ left ${excluded} pre-existing token(s) untouched (user-provided content carve-out).`);
79
+ }
80
+ if (totalMasked > 0) {
81
+ lines.push(' ↳ Pass --dangerously-skip-output-sanitization if this blocks your workflow (active local tokens stay masked unless --dangerously-skip-active-tokens-output-sanitization is also set).');
82
+ }
83
+ return lines.join('\n');
84
+ };
85
+
31
86
  /**
32
87
  * Initialize secretlint modules lazily
33
88
  * @returns {Promise<boolean>} True if secretlint is available
@@ -399,20 +454,25 @@ const compareDetectionResults = async (secretlintSecrets, customSecrets) => {
399
454
  };
400
455
 
401
456
  /**
402
- * Sanitize log content by masking sensitive tokens while avoiding false positives
457
+ * Sanitize arbitrary outbound output by masking sensitive tokens while avoiding false positives
403
458
  * Uses DUAL APPROACH: Both secretlint AND custom patterns run independently
404
459
  *
405
460
  * If only secretlint detects a secret (but our custom patterns miss it),
406
461
  * a warning is logged so we can improve our patterns.
407
462
  *
408
- * @param {string} logContent - The log content to sanitize
463
+ * @param {string} output - The output to sanitize
409
464
  * @param {Object} options - Optional configuration
410
465
  * @param {boolean} options.warnOnMismatch - Log warnings when detection approaches differ (default: true in verbose mode)
411
- * @returns {Promise<string>} Sanitized log content with tokens masked
466
+ * @param {boolean} options.skipOutputSanitization - Skip pattern-based output sanitization. Does not skip known active-token masking.
467
+ * @param {boolean} options.skipActiveTokensOutputSanitization - Also skip known active-token masking. Dangerous; intended only for explicit debugging.
468
+ * @param {Array<string>} options.excludeTokens - Issue #1745 carve-out: token VALUES that were already in user-provided content (issue body, non-bot comments, pre-existing code). These will be left untouched and counted in `excluded` stats so we don't shock users by mangling tokens they typed themselves.
469
+ * @returns {Promise<string>} Sanitized output with tokens masked
412
470
  */
413
- export const sanitizeLogContent = async (logContent, options = {}) => {
414
- let sanitized = logContent;
415
- const { warnOnMismatch = global.verboseMode } = options;
471
+ export const sanitizeOutput = async (output, options = {}) => {
472
+ let sanitized = output;
473
+ const { warnOnMismatch = global.verboseMode, skipOutputSanitization = false, skipActiveTokensOutputSanitization = false, excludeTokens = [] } = options;
474
+ const excludedSet = new Set((excludeTokens || []).filter(t => typeof t === 'string' && t.length > 0));
475
+ const isExcluded = token => excludedSet.has(token);
416
476
 
417
477
  // Statistics for dual approach
418
478
  const stats = {
@@ -424,20 +484,34 @@ export const sanitizeLogContent = async (logContent, options = {}) => {
424
484
  };
425
485
 
426
486
  try {
427
- // Step 1: Get known tokens from files and commands
428
- const fileTokens = await getGitHubTokensFromFiles();
429
- const commandTokens = await getGitHubTokensFromCommand();
430
- const allKnownTokens = [...new Set([...fileTokens, ...commandTokens])];
431
-
432
- // Mask known tokens first
433
- for (const token of allKnownTokens) {
434
- if (token && token.length >= 12) {
435
- const maskedToken = maskToken(token);
436
- sanitized = sanitized.split(token).join(maskedToken);
437
- stats.knownTokens++;
487
+ if (!skipActiveTokensOutputSanitization) {
488
+ // Step 1: Get known tokens from files and commands
489
+ const fileTokens = await getGitHubTokensFromFiles();
490
+ const commandTokens = await getGitHubTokensFromCommand();
491
+ const allKnownTokens = [...new Set([...fileTokens, ...commandTokens])];
492
+
493
+ // Mask known tokens first
494
+ for (const token of allKnownTokens) {
495
+ if (token && token.length >= 12) {
496
+ if (isExcluded(token)) {
497
+ sanitizationStats.excluded++;
498
+ continue;
499
+ }
500
+ if (sanitized.includes(token)) {
501
+ const maskedToken = maskToken(token);
502
+ sanitized = sanitized.split(token).join(maskedToken);
503
+ stats.knownTokens++;
504
+ sanitizationStats.knownTokenMasks++;
505
+ sanitizationStats.totalMasked++;
506
+ }
507
+ }
438
508
  }
439
509
  }
440
510
 
511
+ if (skipOutputSanitization) {
512
+ return sanitized;
513
+ }
514
+
441
515
  // Step 2: DUAL APPROACH - Run both detection methods independently
442
516
  const [secretlintSecrets, customSecrets] = await Promise.all([detectSecretsWithSecretlint(sanitized), Promise.resolve(detectSecretsWithCustomPatterns(sanitized))]);
443
517
 
@@ -491,8 +565,14 @@ export const sanitizeLogContent = async (logContent, options = {}) => {
491
565
  // Verify the token is still in the content at the expected position
492
566
  const currentToken = sanitized.substring(start, end);
493
567
  if (currentToken === token) {
568
+ if (isExcluded(token)) {
569
+ sanitizationStats.excluded++;
570
+ continue;
571
+ }
494
572
  const masked = maskToken(token);
495
573
  sanitized = sanitized.substring(0, start) + masked + sanitized.substring(end);
574
+ sanitizationStats.patternMasks++;
575
+ sanitizationStats.totalMasked++;
496
576
  }
497
577
  }
498
578
 
@@ -516,13 +596,21 @@ export const sanitizeLogContent = async (logContent, options = {}) => {
516
596
 
517
597
  // Only mask if NOT in a safe git/gist context
518
598
  if (!isHexInSafeContext(tempContent, token, position)) {
599
+ if (isExcluded(token)) {
600
+ sanitizationStats.excluded++;
601
+ continue;
602
+ }
519
603
  hexReplacements.push({ token, masked: maskToken(token) });
520
604
  }
521
605
  }
522
606
 
523
607
  // Second pass: apply replacements
524
608
  for (const { token, masked } of hexReplacements) {
525
- sanitized = sanitized.split(token).join(masked);
609
+ if (sanitized.includes(token)) {
610
+ sanitized = sanitized.split(token).join(masked);
611
+ sanitizationStats.hexMasks++;
612
+ sanitizationStats.totalMasked++;
613
+ }
526
614
  }
527
615
 
528
616
  // Summary logging
@@ -558,14 +646,263 @@ export const sanitizeLogContent = async (logContent, options = {}) => {
558
646
  // Export detection functions for testing and visibility
559
647
  export { detectSecretsWithSecretlint, detectSecretsWithCustomPatterns, compareDetectionResults };
560
648
 
649
+ /**
650
+ * Backward-compatible alias for older log-specific call sites.
651
+ * New output paths should call sanitizeOutput().
652
+ */
653
+ export const sanitizeLogContent = sanitizeOutput;
654
+
655
+ // ============================================================================
656
+ // Issue #1745 — known-local-token registry
657
+ // ============================================================================
658
+ // We mask all known LOCAL tokens (env vars + tokens we discovered via gh/etc.)
659
+ // even when our regex/secretlint patterns miss them. This is the
660
+ // "defense-in-depth" layer for the leak documented in case-studies/issue-1745.
661
+ // ============================================================================
662
+
663
+ /**
664
+ * Names of environment variables that hold local tokens. Order is irrelevant
665
+ * but we list AI-CLI tools first since those are the most common leak vectors
666
+ * (claude, codex, opencode, gemini, qwen + telegram + gh).
667
+ *
668
+ * Adding a name here means: any process.env value at this key will be masked
669
+ * in every comment body / log line the bridge emits.
670
+ */
671
+ export const KNOWN_LOCAL_TOKEN_ENV_VARS = Object.freeze([
672
+ // Telegram bridge
673
+ 'TELEGRAM_BOT_TOKEN',
674
+ 'TELEGRAM_OWNER_CHAT_ID',
675
+ // GitHub CLI / API
676
+ 'GH_TOKEN',
677
+ 'GITHUB_TOKEN',
678
+ 'GITHUB_PAT',
679
+ // Claude / Anthropic
680
+ 'ANTHROPIC_API_KEY',
681
+ 'CLAUDE_API_KEY',
682
+ 'CLAUDE_CODE_OAUTH_TOKEN',
683
+ // OpenAI / Codex
684
+ 'OPENAI_API_KEY',
685
+ 'CODEX_API_KEY',
686
+ // Open-source agent CLIs
687
+ 'OPENCODE_API_KEY',
688
+ 'AGENT_CLI_TOKEN',
689
+ // Google Gemini / Qwen
690
+ 'GEMINI_API_KEY',
691
+ 'GOOGLE_API_KEY',
692
+ 'QWEN_API_KEY',
693
+ 'DASHSCOPE_API_KEY',
694
+ // Misc
695
+ 'HUGGINGFACE_TOKEN',
696
+ 'HF_TOKEN',
697
+ ]);
698
+
699
+ /**
700
+ * Read every known local-token env var that is currently set.
701
+ *
702
+ * @returns {Array<{name: string, value: string}>} entries with non-empty values
703
+ */
704
+ export const getEnvironmentTokens = () => {
705
+ const out = [];
706
+ for (const name of KNOWN_LOCAL_TOKEN_ENV_VARS) {
707
+ const value = process.env[name];
708
+ if (typeof value === 'string' && value.length >= 12) {
709
+ out.push({ name, value });
710
+ }
711
+ }
712
+ return out;
713
+ };
714
+
715
+ /**
716
+ * Build the union of every known-local token: env vars + GitHub tokens we
717
+ * already discover via `gh auth status` / hosts.yml (existing helpers).
718
+ *
719
+ * Each entry is `{ source, name, value }` where `source` is 'env' | 'gh-files'
720
+ * | 'gh-command'. The `name` field is human-readable for debug logs but is
721
+ * NEVER printed alongside the token to avoid creating a secondary leak.
722
+ *
723
+ * @returns {Promise<Array<{source: string, name: string, value: string}>>}
724
+ */
725
+ export const getAllKnownLocalTokens = async () => {
726
+ const tokens = [];
727
+
728
+ for (const { name, value } of getEnvironmentTokens()) {
729
+ tokens.push({ source: 'env', name, value });
730
+ }
731
+
732
+ try {
733
+ const fileTokens = await getGitHubTokensFromFiles();
734
+ for (const value of fileTokens) {
735
+ tokens.push({ source: 'gh-files', name: 'github', value });
736
+ }
737
+ } catch {
738
+ /* swallow — best-effort */
739
+ }
740
+
741
+ try {
742
+ const commandTokens = await getGitHubTokensFromCommand();
743
+ for (const value of commandTokens) {
744
+ tokens.push({ source: 'gh-command', name: 'github', value });
745
+ }
746
+ } catch {
747
+ /* swallow — best-effort */
748
+ }
749
+
750
+ // Deduplicate by exact value
751
+ const seen = new Set();
752
+ return tokens.filter(({ value }) => {
753
+ if (seen.has(value)) return false;
754
+ seen.add(value);
755
+ return true;
756
+ });
757
+ };
758
+
759
+ /**
760
+ * Test whether `text` contains any known-local token verbatim.
761
+ * Used to decide whether to fire the Telegram leak-warning DM.
762
+ *
763
+ * @param {string} text
764
+ * @param {Array<{value: string, name?: string, source?: string}>} [tokens]
765
+ * Pre-fetched token list (if you already called getAllKnownLocalTokens).
766
+ * Pass an explicit list to avoid re-running `gh auth status` per check.
767
+ * @returns {Promise<Array<{name: string, source: string}>>} list of token
768
+ * identifiers that were found in the text (NOT the values themselves).
769
+ */
770
+ export const containsKnownToken = async (text, tokens) => {
771
+ if (typeof text !== 'string' || text.length === 0) return [];
772
+ const list = tokens || (await getAllKnownLocalTokens());
773
+ const hits = [];
774
+ for (const t of list) {
775
+ if (t.value && text.includes(t.value)) {
776
+ hits.push({ name: t.name, source: t.source });
777
+ }
778
+ }
779
+ return hits;
780
+ };
781
+
782
+ /**
783
+ * Mask every known-local token inside `body` and then run `sanitizeOutput`
784
+ * for the regex/secretlint sweep. This is the wrapper that comment-posting
785
+ * paths must call before publishing anything to GitHub.
786
+ *
787
+ * Env-token masking runs FIRST so that even if our regex misses the shape
788
+ * (custom token formats from new AI tools, etc.) the local secret never
789
+ * leaves the process. The regex/secretlint pass then catches anything else.
790
+ *
791
+ * @param {string} body
792
+ * @param {Object} [options]
793
+ * @param {Array<{value: string}>} [options.knownTokens] pre-fetched token list
794
+ * @returns {Promise<string>} sanitized body
795
+ */
796
+ export const sanitizeCommentBody = async (body, options = {}) => {
797
+ if (typeof body !== 'string' || body.length === 0) return body;
798
+
799
+ let sanitized = body;
800
+ const excludedSet = new Set((options.excludeTokens || []).filter(t => typeof t === 'string' && t.length > 0));
801
+
802
+ // Pass 1: mask known-local tokens verbatim. This is the defense-in-depth
803
+ // layer that closes the gap from issue #1745.
804
+ if (!options.skipActiveTokensOutputSanitization) {
805
+ const knownTokens = options.knownTokens || (await getAllKnownLocalTokens());
806
+ for (const { value } of knownTokens) {
807
+ if (value && value.length >= 12 && sanitized.includes(value)) {
808
+ if (excludedSet.has(value)) {
809
+ sanitizationStats.excluded++;
810
+ continue;
811
+ }
812
+ sanitized = sanitized.split(value).join(maskToken(value));
813
+ sanitizationStats.knownTokenMasks++;
814
+ sanitizationStats.totalMasked++;
815
+ }
816
+ }
817
+ }
818
+
819
+ // Pass 2: regex + secretlint sweep for anything else.
820
+ sanitized = await sanitizeOutput(sanitized, {
821
+ warnOnMismatch: false,
822
+ skipOutputSanitization: options.skipOutputSanitization,
823
+ skipActiveTokensOutputSanitization: true,
824
+ excludeTokens: options.excludeTokens || [],
825
+ });
826
+
827
+ return sanitized;
828
+ };
829
+
830
+ /**
831
+ * Issue #1745 user-content carve-out helper.
832
+ *
833
+ * Comment #4364642786: "if issue description/comment/pull request comment from
834
+ * other users than our bot, contained access token, meaning access token was
835
+ * explicitly given, or access token was existing in code before, we don't
836
+ * touch it. That is not our responsibility by default."
837
+ *
838
+ * Given concatenated user-provided text (issue body, non-bot issue/PR
839
+ * comments, original code), this helper returns the token-shaped strings
840
+ * already present in that text. Callers pass this list as `excludeTokens`
841
+ * to `sanitizeOutput` / `sanitizeCommentBody` so the sanitizer leaves those
842
+ * tokens untouched.
843
+ *
844
+ * Active local tokens (env vars, gh CLI tokens) are NEVER returned even if
845
+ * they appear in user-provided content — the user couldn't have intended for
846
+ * us to leak our own bot tokens, so the carve-out doesn't apply to them.
847
+ *
848
+ * @param {string} text concatenated user-provided text
849
+ * @param {Object} [options]
850
+ * @param {Array<{value: string}>} [options.knownTokens] active local tokens to
851
+ * filter out of the carve-out (so the bot's own tokens still get masked
852
+ * even if the user pasted one verbatim).
853
+ * @returns {Promise<Array<string>>} token VALUES to exclude from sanitization
854
+ */
855
+ export const extractTokensFromUserContent = async (text, options = {}) => {
856
+ if (typeof text !== 'string' || text.length === 0) return [];
857
+
858
+ const customSecrets = detectSecretsWithCustomPatterns(text);
859
+ const secretlintSecrets = await detectSecretsWithSecretlint(text);
860
+
861
+ const tokens = new Set();
862
+ for (const s of [...customSecrets, ...secretlintSecrets]) {
863
+ if (s.token && s.token.length >= 12) {
864
+ tokens.add(s.token);
865
+ }
866
+ }
867
+
868
+ // 40-char hex in user-provided text — only exclude when not in a safe
869
+ // git/gist context. We're conservative here: the carve-out only applies
870
+ // to things our regex would otherwise mask.
871
+ const hexPattern = /(?:^|[\s:=])([a-f0-9]{40})(?=[\s\n]|$)/gm;
872
+ hexPattern.lastIndex = 0;
873
+ let m;
874
+ while ((m = hexPattern.exec(text)) !== null) {
875
+ const token = m[1];
876
+ if (!isHexInSafeContext(text, token, m.index)) {
877
+ tokens.add(token);
878
+ }
879
+ }
880
+
881
+ // Filter out our own active local tokens. The user pasting our token in
882
+ // their issue body doesn't mean we should leak it — that's still our bot's
883
+ // secret and it stays masked.
884
+ const knownActive = new Set((options.knownTokens || []).map(t => t.value).filter(Boolean));
885
+ return [...tokens].filter(value => !knownActive.has(value));
886
+ };
887
+
561
888
  // Default export for convenience
562
889
  export default {
563
890
  isSafeToken,
564
891
  isHexInSafeContext,
565
892
  getGitHubTokensFromFiles,
566
893
  getGitHubTokensFromCommand,
894
+ sanitizeOutput,
567
895
  sanitizeLogContent,
568
896
  detectSecretsWithSecretlint,
569
897
  detectSecretsWithCustomPatterns,
570
898
  compareDetectionResults,
899
+ getEnvironmentTokens,
900
+ getAllKnownLocalTokens,
901
+ containsKnownToken,
902
+ sanitizeCommentBody,
903
+ getSanitizationStats,
904
+ resetSanitizationStats,
905
+ formatSanitizationSummary,
906
+ extractTokensFromUserContent,
907
+ KNOWN_LOCAL_TOKEN_ENV_VARS,
571
908
  };
@@ -198,7 +198,7 @@ export const resetTrackedToolCommentIds = () => {
198
198
  * @param {string} options.body
199
199
  * @returns {Promise<{ok: boolean, commentId: string|null, stderr?: string}>}
200
200
  */
201
- export const postTrackedComment = async ({ $, owner, repo, targetNumber, body }) => {
201
+ export const postTrackedComment = async ({ $, owner, repo, targetNumber, body, sanitizationOptions }) => {
202
202
  if (!$) {
203
203
  throw new Error('postTrackedComment requires a command-stream $ helper');
204
204
  }
@@ -208,7 +208,11 @@ export const postTrackedComment = async ({ $, owner, repo, targetNumber, body })
208
208
  // We use the /issues/<n>/comments endpoint because it works identically
209
209
  // for both PRs and issues (a PR is an issue at this endpoint).
210
210
  const apiPath = `repos/${owner}/${repo}/issues/${targetNumber}/comments`;
211
- const payload = JSON.stringify({ body });
211
+ const { sanitizeOutput } = await import('./token-sanitization.lib.mjs');
212
+ // Issue #1745: caller may pass dangerous-skip flags + carve-out tokens.
213
+ // Defaults preserve fail-closed behavior: full sanitization.
214
+ const sanitizedBody = await sanitizeOutput(body, sanitizationOptions || {});
215
+ const payload = JSON.stringify({ body: sanitizedBody });
212
216
 
213
217
  // command-stream's options key is `stdin`, not `input` — unknown keys are
214
218
  // silently ignored, which previously left stdin inherited from the parent