@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.
- package/CHANGELOG.md +23 -0
- package/package.json +1 -1
- package/src/agent-commander.lib.mjs +47 -5
- package/src/agent-token-usage.lib.mjs +15 -1
- package/src/claude.budget-stats.lib.mjs +72 -27
- package/src/claude.lib.mjs +12 -1
- package/src/codex.lib.mjs +22 -1
- package/src/context-fill.lib.mjs +71 -0
- package/src/gemini.lib.mjs +22 -7
- package/src/github.lib.mjs +2 -2
- package/src/interactive-mode.lib.mjs +104 -8
- package/src/lib.mjs +3 -3
- package/src/post-finish-sanitization-sweep.lib.mjs +201 -0
- package/src/qwen.lib.mjs +191 -9
- package/src/solve.config.lib.mjs +15 -0
- package/src/solve.results.lib.mjs +52 -0
- package/src/telegram-bot.mjs +40 -0
- package/src/telegram-leak-notifier.lib.mjs +79 -0
- package/src/telegram-tokens-command.lib.mjs +151 -0
- package/src/token-sanitization.lib.mjs +355 -18
- package/src/tool-comments.lib.mjs +6 -2
|
@@ -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
|
|
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}
|
|
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
|
-
* @
|
|
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
|
|
414
|
-
let sanitized =
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
|
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
|