leak-mojibake-audit.js 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const ROOT = path.resolve(__dirname, '..');
  5. const DEFAULT_TARGETS = [
  6. 'package.json',
  7. 'README.md',
  8. 'install.js',
  9. 'bin',
  10. 'skill-package-manifest.json',
  11. '.mcp.json',
  12. '.claude-plugin',
  13. 'memory-templates',
  14. 'docs',
  15. 'scripts',
  16. path.join('mcp', 'src'),
  17. 'skills',
  18. 'fixtures'
  19. ];
  20. const SECRET_PATTERNS = [
  21. { name: 'openai-style-key', regex: new RegExp('sk-' + '[A-Za-z0-9_-]{20,}', 'g') },
  22. { name: 'parse-session-token', regex: new RegExp('r:' + '[A-Za-z0-9]{20,}', 'g') },
  23. { name: 'authorization-bearer', regex: new RegExp('Authorization\\s*[:=]\\s*Bearer\\s+[A-Za-z0-9._-]{16,}', 'gi') }
  24. ];
  25. const MOJIBAKE_PATTERNS = [
  26. { name: 'replacement-char', regex: /\uFFFD/g },
  27. { name: 'common-mojibake-1', regex: /é”|鈥|涓|乣/g },
  28. { name: 'common-mojibake-2', regex: /缂栧彿|鎺掑悕|鍗氫富|瀹㈡埛|褰掑洜|杞欢|寮烘帹/g },
  29. { name: 'utf8-read-as-gbk-short', regex: /[鎻绛鏍鍙瑙璇鐩杈閫浼缂鏂鍟氫涔乣]{3,}/g },
  30. { name: 'utf8-read-as-gbk-punctuation', regex: /[銆乣鈥滐紝]/g }
  31. ];
  32. const SELF = path.normalize(__filename);
  33. function main() {
  34. const args = parseArgs(process.argv.slice(2));
  35. const targets = args.targets ? String(args.targets).split(',').filter(Boolean) : DEFAULT_TARGETS;
  36. const files = targets.flatMap(target => listFiles(path.resolve(ROOT, target)));
  37. const hits = [];
  38. for (const file of files) {
  39. if (shouldSkip(file)) continue;
  40. const text = fs.readFileSync(file, 'utf8');
  41. for (const pattern of [...SECRET_PATTERNS, ...MOJIBAKE_PATTERNS]) {
  42. const matches = [...text.matchAll(pattern.regex)];
  43. for (const match of matches) hits.push(toHit(file, text, pattern.name, match));
  44. }
  45. }
  46. const summary = {
  47. root: ROOT,
  48. scannedFiles: files.filter(file => !shouldSkip(file)).length,
  49. hitCount: hits.length,
  50. hits
  51. };
  52. if (args.output) {
  53. fs.mkdirSync(path.dirname(path.resolve(args.output)), { recursive: true });
  54. fs.writeFileSync(path.resolve(args.output), JSON.stringify(summary, null, 2), 'utf8');
  55. }
  56. console.log(JSON.stringify({
  57. scannedFiles: summary.scannedFiles,
  58. hitCount: summary.hitCount,
  59. hits: summary.hits.slice(0, 20)
  60. }, null, 2));
  61. if (summary.hitCount) process.exitCode = 1;
  62. }
  63. function shouldSkip(file) {
  64. const normalized = path.normalize(file);
  65. return normalized === SELF ||
  66. /[\\/]node_modules[\\/]/.test(normalized) ||
  67. /[\\/]\.claude[\\/]/.test(normalized) ||
  68. /[\\/]\.npm-cache/.test(normalized);
  69. }
  70. function toHit(file, text, pattern, match) {
  71. const index = match.index || 0;
  72. const lineNumber = text.slice(0, index).split(/\r?\n/).length;
  73. const line = text.split(/\r?\n/)[lineNumber - 1] || '';
  74. return {
  75. file: path.relative(ROOT, file).replace(/\\/g, '/'),
  76. lineNumber,
  77. pattern,
  78. sample: maskSecret(match[0]),
  79. line: maskSecret(line).slice(0, 240)
  80. };
  81. }
  82. function maskSecret(value) {
  83. return String(value || '')
  84. .replace(new RegExp('sk-' + '([A-Za-z0-9_-]{4})[A-Za-z0-9_-]{8,}([A-Za-z0-9_-]{4})', 'g'), 'sk-$1...$2')
  85. .replace(new RegExp('r:' + '([A-Za-z0-9]{4})[A-Za-z0-9]{8,}([A-Za-z0-9]{4})', 'g'), 'r:$1...$2')
  86. .replace(/Bearer\s+([A-Za-z0-9._-]{4})[A-Za-z0-9._-]{8,}([A-Za-z0-9._-]{4})/gi, 'Bearer $1...$2');
  87. }
  88. function listFiles(target) {
  89. if (!fs.existsSync(target)) return [];
  90. const stat = fs.statSync(target);
  91. if (stat.isFile()) return [target];
  92. return fs.readdirSync(target, { withFileTypes: true }).flatMap(entry => {
  93. const full = path.join(target, entry.name);
  94. return entry.isDirectory() ? listFiles(full) : [full];
  95. });
  96. }
  97. function parseArgs(argv) {
  98. const args = {};
  99. for (let index = 0; index < argv.length; index += 1) {
  100. const raw = argv[index];
  101. if (!raw.startsWith('--')) continue;
  102. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  103. const next = argv[index + 1];
  104. if (!next || next.startsWith('--')) args[key] = true;
  105. else {
  106. args[key] = next;
  107. index += 1;
  108. }
  109. }
  110. return args;
  111. }
  112. if (require.main === module) main();
  113. module.exports = {
  114. listFiles,
  115. maskSecret
  116. };