| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135 |
- #!/usr/bin/env node
- // Record a HAR of an interactive login, for learning a vendor API shape.
- //
- // node tools/har/record.mjs <url> [outputHarPath]
- //
- // Opens a real browser window. You log in and browse by hand. The script polls
- // for a sentinel file and saves the HAR when it appears:
- //
- // touch <sentinel> # when you are done browsing
- //
- // The raw HAR contains your session cookie and bearer token in clear text, so
- // the script writes it 0600 and immediately produces a redacted twin that is
- // safe to read, diff and quote from. Both live outside the repository — never
- // commit either one.
- //
- // What to look at while browsing: the zone/domain list request, and the request
- // that fetches one zone's records. That pair is enough to learn the read API.
- // Do NOT save any DNS change you make while recording: the gateway applies it
- // publicly within seconds.
- import { chromium } from "playwright"
- import { writeFileSync, existsSync, chmodSync, readFileSync } from "node:fs"
- import { join } from "node:path"
- const URL_TO_OPEN = process.argv[2] ?? "https://cloud.schlundtech.com/portfolio/domains/"
- const OUT = process.argv[3] ?? "/var/folders/cz/75zr419d1fq1ptvqp1bwgmw80000gp/T/opencode/har/schlundtech.har"
- const SENTINEL = `${OUT}.done`
- const SENSITIVE_HEADERS = new Set([
- "cookie",
- "set-cookie",
- "authorization",
- "proxy-authorization",
- "x-api-key",
- "x-auth-token",
- "x-csrf-token",
- "x-xsrf-token",
- ])
- const SENSITIVE_KEYS = /^(?:.*_)?(?:password|passwd|secret|token|access_token|refresh_token|session|sessionid|csrf|xsrf|apikey|api_key|auth)$/i
- // Anything that looks like a long opaque bearer/JWT blob.
- const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b/g
- const redactValue = (v) => (typeof v === "string" ? v.replace(JWT, "<redacted-jwt>") : v)
- function scrubEntry(entry) {
- for (const h of entry.request?.headers ?? []) {
- if (SENSITIVE_HEADERS.has(h.name.toLowerCase())) h.value = "<redacted>"
- }
- for (const h of entry.response?.headers ?? []) {
- if (SENSITIVE_HEADERS.has(h.name.toLowerCase())) h.value = "<redacted>"
- }
- const url = entry.request?.url
- if (url) {
- entry.request.url = url.replace(/([?&](?:token|access_token|session|sessionid|auth|key)=)[^&]*/gi, "$1<redacted>")
- }
- for (const side of [entry.request, entry.response]) {
- const text = side?.postData?.text
- if (typeof text === "string") {
- side.postData.text = text.replace(JWT, "<redacted-jwt>")
- try {
- const json = JSON.parse(text)
- const walk = (node) => {
- if (Array.isArray(node)) return node.forEach(walk)
- if (node && typeof node === "object") {
- for (const [k, val] of Object.entries(node)) {
- if (SENSITIVE_KEYS.test(k)) node[k] = "<redacted>"
- else walk(val)
- }
- }
- }
- walk(json)
- side.postData.text = JSON.stringify(json, null, 2)
- } catch {
- /* not JSON — the JWT scrub above already ran */
- }
- }
- if (Array.isArray(side?.postData?.params)) {
- for (const p of side.postData.params) if (SENSITIVE_KEYS.test(p.name)) p.value = "<redacted>"
- }
- }
- }
- console.log(`opening ${URL_TO_OPEN}`)
- console.log(`har ${OUT}`)
- console.log(`done touch ${SENTINEL}`)
- console.log("browsing…")
- // Prefer the real Chrome the user already has; fall back to Playwright's
- // bundled build if it is missing (or out of date relative to this Playwright).
- async function launch() {
- for (const opts of [{ channel: "chrome" }, {}]) {
- try {
- return await chromium.launch({ headless: false, ...opts })
- } catch (err) {
- if (opts.channel) console.error(` ${opts.channel}: ${String(err).split("\n")[0]}`)
- }
- }
- throw new Error("no chromium available — run: npx playwright install chromium")
- }
- const browser = await launch()
- const context = await browser.newContext({
- recordHar: { path: OUT, content: "embed" },
- ignoreHTTPSErrors: false,
- })
- const page = await context.newPage()
- await page.goto(URL_TO_OPEN, { waitUntil: "domcontentloaded" })
- const deadline = Date.now() + 30 * 60 * 1000
- while (!existsSync(SENTINEL)) {
- if (Date.now() > deadline) {
- console.error("timeout after 30 min — closing")
- break
- }
- await new Promise((r) => setTimeout(r, 1000))
- }
- await context.close()
- await browser.close()
- if (!existsSync(OUT)) {
- console.error("no HAR written")
- process.exit(1)
- }
- chmodSync(OUT, 0o600)
- const har = JSON.parse(readFileSync(OUT, "utf8"))
- har.log.entries.forEach(scrubEntry)
- const redacted = OUT.replace(/\.har$/, ".redacted.har")
- writeFileSync(redacted, JSON.stringify(har, null, 2))
- chmodSync(redacted, 0o600)
- console.log(`\nsaved ${OUT} (raw, 0600)`)
- console.log(`saved ${redacted} (redacted — this is the one to read)`)
- console.log(`entries ${har.log.entries.length}`)
|