check-public-tree.mjs 3.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
  1. import assert from 'node:assert/strict'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import { execFileSync } from 'node:child_process'
  5. import { fileURLToPath, pathToFileURL } from 'node:url'
  6. const rootFiles = new Set(['.gitattributes', '.gitignore', '.npmrc', '.node-version', 'LICENSE', 'README.md', 'CHANGELOG.md', 'CONTRIBUTING.md', 'SECURITY.md', 'CODE_OF_CONDUCT.md', 'dsh-baseline.json', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'tsconfig.base.json', 'vitest.config.ts', 'eslint.config.mjs'])
  7. export function checkPublicPath(file) {
  8. assert.ok(!file.includes('\\') && !file.includes(':') && !file.startsWith('/') && !file.split('/').includes('..'), `Unsafe public path: ${file}`)
  9. assert.ok(rootFiles.has(file) || /^(packages|scripts\/release|docs\/(user|maintenance)|examples|\.github)\//.test(file)
  10. || /^docs\/(development|book-format)\.md$/.test(file) || /^scripts\/(benchmark-(derive|impact|search)|strip-types-loader)\.mjs$/.test(file), `File outside public allowlist: ${file}`)
  11. assert.ok(!/(^|\/)(\.trellis|\.credentials[^/]*|session[^/]*\.jsonl|node_modules|\.env(?:\..*)?|\.webnovel)(\/|$)/.test(file), `Private content path: ${file}`)
  12. assert.notEqual(file, 'packages/bundle/dsh-local.yml', 'Local instance config is private')
  13. }
  14. export function checkPublicText(file, data) {
  15. const text = data.toString('utf8')
  16. assert.ok(!/-----BEGIN [A-Z ]*PRIVATE KEY-----|gh[pousr]_[A-Za-z0-9]{30,}|sk-[A-Za-z0-9_-]{30,}/.test(text), `Possible credential in ${file}; value withheld`)
  17. assert.ok(!/[A-Z]:[\\/](?:Users|wk)[\\/]/.test(text), `Personal absolute path in ${file}`)
  18. }
  19. export function checkMarkdownLinks(root, file, text) {
  20. const prose = text.replace(/```[\s\S]*?```/g, '')
  21. for (const match of prose.matchAll(/\]\(([^)]+)\)/g)) {
  22. const target = match[1].replace(/^<|>$/g, '').split('#')[0]
  23. if (!target || /^[a-z][a-z0-9+.-]*:/i.test(target)) continue
  24. const resolved = path.resolve(path.dirname(path.join(root, file)), decodeURIComponent(target))
  25. assert.ok(resolved === root || resolved.startsWith(root + path.sep), `Link escapes public root: ${file}`)
  26. assert.ok(fs.existsSync(resolved), `Broken public link in ${file}: ${target}`)
  27. }
  28. }
  29. export function checkTree(root, history = true) {
  30. const git = args => execFileSync('git', args, { cwd: root, encoding: 'utf8', windowsHide: true, maxBuffer: 16 * 1024 * 1024 })
  31. const files = [...new Set(git(['ls-files', '--cached', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean))]
  32. for (const file of files) {
  33. checkPublicPath(file)
  34. assert.ok(!fs.lstatSync(path.join(root, file)).isSymbolicLink(), `Public symlink: ${file}`)
  35. const data = fs.readFileSync(path.join(root, file))
  36. checkPublicText(file, data)
  37. if (file.endsWith('.md')) checkMarkdownLinks(root, file, data.toString('utf8'))
  38. }
  39. let commits = 0
  40. if (history) {
  41. const revisions = git(['rev-list', 'HEAD']).trim().split('\n').filter(Boolean)
  42. const checked = new Set()
  43. for (const revision of revisions) {
  44. commits++
  45. for (const line of git(['ls-tree', '-rz', revision]).split('\0').filter(Boolean)) {
  46. const [meta, file] = line.split('\t')
  47. const [mode, kind, oid] = meta.split(' ')
  48. checkPublicPath(file)
  49. assert.ok(kind === 'blob' && ['100644', '100755'].includes(mode), `Non-regular history entry: ${file}`)
  50. if (!checked.has(oid)) {
  51. checkPublicText(file, git(['cat-file', 'blob', oid]))
  52. checked.add(oid)
  53. }
  54. }
  55. }
  56. }
  57. return { ok: true, files: files.length, commits }
  58. }
  59. if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
  60. const root = path.resolve(fileURLToPath(new URL('../../', import.meta.url)))
  61. console.log(JSON.stringify(checkTree(root, !process.argv.includes('--no-history'))))
  62. }