record.mjs 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. #!/usr/bin/env node
  2. // Record a HAR of an interactive login, for learning a vendor API shape.
  3. //
  4. // node tools/har/record.mjs <url> [outputHarPath]
  5. //
  6. // Opens a real browser window. You log in and browse by hand. The script polls
  7. // for a sentinel file and saves the HAR when it appears:
  8. //
  9. // touch <sentinel> # when you are done browsing
  10. //
  11. // The raw HAR contains your session cookie and bearer token in clear text, so
  12. // the script writes it 0600 and immediately produces a redacted twin that is
  13. // safe to read, diff and quote from. Both live outside the repository — never
  14. // commit either one.
  15. //
  16. // What to look at while browsing: the zone/domain list request, and the request
  17. // that fetches one zone's records. That pair is enough to learn the read API.
  18. // Do NOT save any DNS change you make while recording: the gateway applies it
  19. // publicly within seconds.
  20. import { chromium } from "playwright"
  21. import { writeFileSync, existsSync, chmodSync, readFileSync } from "node:fs"
  22. import { join } from "node:path"
  23. const URL_TO_OPEN = process.argv[2] ?? "https://cloud.schlundtech.com/portfolio/domains/"
  24. const OUT = process.argv[3] ?? "/var/folders/cz/75zr419d1fq1ptvqp1bwgmw80000gp/T/opencode/har/schlundtech.har"
  25. const SENTINEL = `${OUT}.done`
  26. const SENSITIVE_HEADERS = new Set([
  27. "cookie",
  28. "set-cookie",
  29. "authorization",
  30. "proxy-authorization",
  31. "x-api-key",
  32. "x-auth-token",
  33. "x-csrf-token",
  34. "x-xsrf-token",
  35. ])
  36. const SENSITIVE_KEYS = /^(?:.*_)?(?:password|passwd|secret|token|access_token|refresh_token|session|sessionid|csrf|xsrf|apikey|api_key|auth)$/i
  37. // Anything that looks like a long opaque bearer/JWT blob.
  38. const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b/g
  39. const redactValue = (v) => (typeof v === "string" ? v.replace(JWT, "<redacted-jwt>") : v)
  40. function scrubEntry(entry) {
  41. for (const h of entry.request?.headers ?? []) {
  42. if (SENSITIVE_HEADERS.has(h.name.toLowerCase())) h.value = "<redacted>"
  43. }
  44. for (const h of entry.response?.headers ?? []) {
  45. if (SENSITIVE_HEADERS.has(h.name.toLowerCase())) h.value = "<redacted>"
  46. }
  47. const url = entry.request?.url
  48. if (url) {
  49. entry.request.url = url.replace(/([?&](?:token|access_token|session|sessionid|auth|key)=)[^&]*/gi, "$1<redacted>")
  50. }
  51. for (const side of [entry.request, entry.response]) {
  52. const text = side?.postData?.text
  53. if (typeof text === "string") {
  54. side.postData.text = text.replace(JWT, "<redacted-jwt>")
  55. try {
  56. const json = JSON.parse(text)
  57. const walk = (node) => {
  58. if (Array.isArray(node)) return node.forEach(walk)
  59. if (node && typeof node === "object") {
  60. for (const [k, val] of Object.entries(node)) {
  61. if (SENSITIVE_KEYS.test(k)) node[k] = "<redacted>"
  62. else walk(val)
  63. }
  64. }
  65. }
  66. walk(json)
  67. side.postData.text = JSON.stringify(json, null, 2)
  68. } catch {
  69. /* not JSON — the JWT scrub above already ran */
  70. }
  71. }
  72. if (Array.isArray(side?.postData?.params)) {
  73. for (const p of side.postData.params) if (SENSITIVE_KEYS.test(p.name)) p.value = "<redacted>"
  74. }
  75. }
  76. }
  77. console.log(`opening ${URL_TO_OPEN}`)
  78. console.log(`har ${OUT}`)
  79. console.log(`done touch ${SENTINEL}`)
  80. console.log("browsing…")
  81. // Prefer the real Chrome the user already has; fall back to Playwright's
  82. // bundled build if it is missing (or out of date relative to this Playwright).
  83. async function launch() {
  84. for (const opts of [{ channel: "chrome" }, {}]) {
  85. try {
  86. return await chromium.launch({ headless: false, ...opts })
  87. } catch (err) {
  88. if (opts.channel) console.error(` ${opts.channel}: ${String(err).split("\n")[0]}`)
  89. }
  90. }
  91. throw new Error("no chromium available — run: npx playwright install chromium")
  92. }
  93. const browser = await launch()
  94. const context = await browser.newContext({
  95. recordHar: { path: OUT, content: "embed" },
  96. ignoreHTTPSErrors: false,
  97. })
  98. const page = await context.newPage()
  99. await page.goto(URL_TO_OPEN, { waitUntil: "domcontentloaded" })
  100. const deadline = Date.now() + 30 * 60 * 1000
  101. while (!existsSync(SENTINEL)) {
  102. if (Date.now() > deadline) {
  103. console.error("timeout after 30 min — closing")
  104. break
  105. }
  106. await new Promise((r) => setTimeout(r, 1000))
  107. }
  108. await context.close()
  109. await browser.close()
  110. if (!existsSync(OUT)) {
  111. console.error("no HAR written")
  112. process.exit(1)
  113. }
  114. chmodSync(OUT, 0o600)
  115. const har = JSON.parse(readFileSync(OUT, "utf8"))
  116. har.log.entries.forEach(scrubEntry)
  117. const redacted = OUT.replace(/\.har$/, ".redacted.har")
  118. writeFileSync(redacted, JSON.stringify(har, null, 2))
  119. chmodSync(redacted, 0o600)
  120. console.log(`\nsaved ${OUT} (raw, 0600)`)
  121. console.log(`saved ${redacted} (redacted — this is the one to read)`)
  122. console.log(`entries ${har.log.entries.length}`)