diff --git a/docs/github-account-suspension-appeal-tracker.md b/docs/github-account-suspension-appeal-tracker.md index b85e6b09..edaa2f5c 100644 --- a/docs/github-account-suspension-appeal-tracker.md +++ b/docs/github-account-suspension-appeal-tracker.md @@ -15,9 +15,18 @@ can be filed one by one later. | `hfdery` | Suspended/disabled | User confirmed this account was also disabled during relogin triage | Not submitted in repo | Recheck exact GitHub support page text before drafting. | | `ljhyugg` | Suspended/disabled | User confirmed this account was gone/disabled during relogin triage | Not submitted in repo | Recheck exact GitHub support page text before drafting. | | `hfdryhy` | Suspended/disabled | User confirmed this account was also disabled during relogin triage | Not submitted in repo | Recheck exact GitHub support page text before drafting. | -| `hfdvbgt` | Abnormal during relogin | User reported it was not normal during relogin triage | Not submitted in repo | Verify whether GitHub labels it suspended, disabled, locked, or another restriction before appeal wording. | -| `hfdegh` | Abnormal during relogin | User reported it was not normal during relogin triage | Not submitted in repo | Verify whether GitHub labels it suspended, disabled, locked, or another restriction before appeal wording. | -| `zjhferw` | Abnormal during relogin | User reported it was not normal during relogin triage | Not submitted in repo | Verify whether GitHub labels it suspended, disabled, locked, or another restriction before appeal wording. | +| `hfdvbgt` | Suspended/disabled | 2026-07-04 GitHub relogin retry did not complete; user confirmed unfinished continuation attempts are suspended | Not submitted in repo | Use suspended-account appeal wording; recheck exact GitHub support page text before final submission. | +| `hfdegh` | Suspended/disabled | 2026-07-04 GitHub relogin retry did not complete; user confirmed unfinished continuation attempts are suspended | Not submitted in repo | Use suspended-account appeal wording; recheck exact GitHub support page text before final submission. | +| `zjhferw` | Suspended/disabled | User confirmed the GitHub account was suspended after a relogin attempt was blocked | Not submitted in repo | Use suspended-account appeal wording; recheck exact GitHub support page text before final submission. | +| `ffgthn` | Suspended/disabled | 2026-07-04 GitHub relogin attempt was stopped after user confirmed the account was disabled | Not submitted in repo | Use suspended-account appeal wording; recheck exact GitHub support page text before final submission. | +| `ghrdds` | Suspended/disabled | 2026-07-04 GitHub relogin retry did not complete; user confirmed unfinished continuation attempts are suspended | Not submitted in repo | Use suspended-account appeal wording; recheck exact GitHub support page text before final submission. | +| `sefgyjh` | Suspended/disabled | 2026-07-04 GitHub relogin retry did not complete; user confirmed unfinished continuation attempts are suspended | Not submitted in repo | Use suspended-account appeal wording; recheck exact GitHub support page text before final submission. | +| `hfdegv` | Suspended/disabled | 2026-07-04 GitHub relogin retry did not complete; user confirmed unfinished continuation attempts are suspended | Not submitted in repo | Use suspended-account appeal wording; recheck exact GitHub support page text before final submission. | +| `fgddefc` | Suspended/disabled | 2026-07-04 GitHub relogin retry did not complete; user confirmed unfinished continuation attempts are suspended | Not submitted in repo | Use suspended-account appeal wording; recheck exact GitHub support page text before final submission. | +| `khsytgb` | Suspended/disabled | 2026-07-04 GitHub relogin retry did not complete; user confirmed unfinished continuation attempts are suspended | Not submitted in repo | Use suspended-account appeal wording; recheck exact GitHub support page text before final submission. | +| `gddegn` | Suspended/disabled | 2026-07-04 GitHub relogin retry did not complete; user confirmed unfinished continuation attempts are suspended | Not submitted in repo | Use suspended-account appeal wording; recheck exact GitHub support page text before final submission. | +| `hgfdsw` | Suspended/disabled | 2026-07-04 GitHub relogin retry did not complete; user confirmed unfinished continuation attempts are suspended | Not submitted in repo | Use suspended-account appeal wording; recheck exact GitHub support page text before final submission. | +| `jhfded` | Suspended/disabled | 2026-07-04 GitHub relogin retry did not complete; user confirmed unfinished continuation attempts are suspended | Not submitted in repo | Use suspended-account appeal wording; recheck exact GitHub support page text before final submission. | ## Already Recovered diff --git a/skills/github-password-rotator/SKILL.md b/skills/github-password-rotator/SKILL.md new file mode 100644 index 00000000..9c332666 --- /dev/null +++ b/skills/github-password-rotator/SKILL.md @@ -0,0 +1,89 @@ +--- +name: github-password-rotator +description: Use when a GitHub account password needs to be changed or rotated with browser automation, especially when Codex should prefill username/password fields while the user manually handles 2FA, passkeys, device checks, CAPTCHA, suspended-account pages, or other GitHub verification steps. +--- + +# GitHub Password Rotator + +## Boundaries + +- Never pass GitHub passwords on the command line. +- Never print, store, commit, or summarize old passwords, new passwords, 2FA codes, cookies, or session tokens. +- Never print, store, commit, or summarize TOTP/2FA secrets from `2fa.fun`. +- Use an isolated Chrome profile and the HTTP proxy for the whole browser flow. Default proxy: `http://127.0.0.1:11111`. +- Treat 2FA, passkeys, CAPTCHA, unusual verification, and suspended-account pages as manual user steps. +- Do not claim the password changed unless the script reports completion or the user confirms success in the browser. + +## Standard Flow + +Use environment variables or hidden TTY prompts for both passwords: + +```bash +read -rsp 'Current GitHub password: ' GITHUB_CURRENT_PASSWORD; echo +read -rsp 'New GitHub password: ' GITHUB_NEW_PASSWORD; echo +read -rsp 'GitHub TOTP secret for 2fa.fun: ' GITHUB_TOTP_SECRET; echo +export GITHUB_CURRENT_PASSWORD GITHUB_NEW_PASSWORD GITHUB_TOTP_SECRET +python3 skills/github-password-rotator/scripts/rotate_github_password.py \ + --github-login username \ + --manual-timeout-seconds 900 \ + --auto-2fa-fun \ + --create-learning-repo +unset GITHUB_CURRENT_PASSWORD GITHUB_NEW_PASSWORD GITHUB_TOTP_SECRET +``` + +The helper: + +1. Opens `https://github.com/settings/security` in an isolated Chrome profile through the proxy. +2. Fills the GitHub login page with `--github-login` and the current password. +3. If `--auto-2fa-fun` is set and GitHub shows an app-code 2FA prompt, opens or reuses `https://2fa.fun/`, enters the TOTP secret, reads the generated code from `input.faotp.value`, and submits it to GitHub without printing the code. +4. Waits for the user to complete passkey, device verification, CAPTCHA, suspended-account inspection, or any 2FA step that cannot be handled from `2fa.fun`. +5. Handles GitHub sudo password confirmation with the current password when detected. +6. Fills the password-change form with current password, new password, and confirmation. +7. Exits successfully after GitHub reports success, or after a submitted password form collapses back to the `Change password` state without an explicit success message. +8. Navigates back to `https://github.com/settings/security` after a completed submit so browser refresh will not resubmit the password form. +9. When `--create-learning-repo` is set, waits a random 3-10 seconds after the password change before creating the repository, then creates `hello-world-from-` and writes a beginner-friendly English `README.md`. + +## Useful Options + +- `--github-login USER`: required GitHub username or email. +- `--current-password-env NAME`: defaults to `GITHUB_CURRENT_PASSWORD`. +- `--new-password-env NAME`: defaults to `GITHUB_NEW_PASSWORD`. +- `--totp-secret-env NAME`: defaults to `GITHUB_TOTP_SECRET`; only read when `--auto-2fa-fun` is set. +- `--proxy http://127.0.0.1:11111`: override login proxy. +- `--settings-url URL`: override GitHub password settings URL. +- `--manual-timeout-seconds 900`: time allowed for manual verification. +- `--keep-browser`: keep the isolated browser open after the helper exits. +- `--auto-2fa-fun`: use the hidden TOTP secret with `2fa.fun` to fill GitHub app-code 2FA prompts. +- `--create-learning-repo`: after password rotation, create a public beginner learning repository named `hello-world-from-`. +- `--dry-run`: print redacted plan and verify script wiring without launching a browser or requiring passwords. + +## Learning Repository + +- Repository name is deterministic: `hello-world-from-`, where the slug lowercases the GitHub login and replaces non-alphanumeric runs with `-`. +- README content must be English, beginner-oriented, and generated from multiple randomized sections at runtime. Do not make it a fixed template keyed only by account name. +- The README should still include the account name in the heading so the repository looks account-specific. +- If GitHub reports the repository already exists or the editor cannot be found, stop with a clear failure instead of silently skipping the repository. + +## Failure Handling + +- If GitHub shows a suspended/disabled account page, stop the script and record the account in the appeal tracker instead of retrying. +- If the helper times out while GitHub is logged in, inspect the visible browser. Do not scrape or print cookies/tokens. +- If `2fa.fun` is used, read generated codes only from `input.faotp.value`; do not parse arbitrary page text or secret fields as codes. +- If GitHub changed the settings DOM, rerun with `--keep-browser`, inspect visible labels/selectors, then patch `drive_github_password_change.mjs`. +- GitHub may not show a password success flash. After a submit, treat the collapsed password form plus visible `Change password` entry as a completed no-flash state, then force a GET navigation back to the settings URL to avoid refresh resubmission. +- If the password-change form remains visible after submit, do not assume success. Check visible validation text or ask the user to confirm. + +## Verification + +Dry-run and syntax checks are safe: + +```bash +python3 skills/github-password-rotator/scripts/rotate_github_password.py \ + --github-login username \ + --dry-run + +python3 -m py_compile skills/github-password-rotator/scripts/rotate_github_password.py +node --check skills/github-password-rotator/scripts/drive_github_password_change.mjs +``` + +For live verification, rely on GitHub's success page, the completed no-flash collapsed form state, or a user-confirmed successful login with the new password. Do not log the password itself. diff --git a/skills/github-password-rotator/agents/openai.yaml b/skills/github-password-rotator/agents/openai.yaml new file mode 100644 index 00000000..15bdcd7e --- /dev/null +++ b/skills/github-password-rotator/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "GitHub Password Rotator" + short_description: "Rotate GitHub passwords and optionally create a beginner learning repository." + default_prompt: "Use $github-password-rotator to change a GitHub account password with 2fa.fun app-code support and create the hello-world learning repository." diff --git a/skills/github-password-rotator/scripts/drive_github_password_change.mjs b/skills/github-password-rotator/scripts/drive_github_password_change.mjs new file mode 100755 index 00000000..59fa2956 --- /dev/null +++ b/skills/github-password-rotator/scripts/drive_github_password_change.mjs @@ -0,0 +1,864 @@ +#!/usr/bin/env node + +import { randomInt } from "node:crypto"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const port = process.env.GITHUB_DEVTOOLS_PORT; +const githubLogin = process.env.GITHUB_LOGIN || ""; +const currentPassword = process.env.GITHUB_CURRENT_PASSWORD || ""; +const newPassword = process.env.GITHUB_NEW_PASSWORD || ""; +const settingsUrl = process.env.GITHUB_SETTINGS_URL || "https://github.com/settings/security"; +const timeoutSeconds = Number(process.env.GITHUB_MANUAL_TIMEOUT_SECONDS || "900"); +const createLearningRepo = process.env.GITHUB_CREATE_LEARNING_REPO === "1"; +const learningRepoOwner = process.env.GITHUB_LEARNING_REPO_OWNER || githubLogin; +const auto2faFun = process.env.GITHUB_AUTO_2FA_FUN === "1"; +const totpSecret = process.env.GITHUB_TOTP_SECRET || ""; +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function connectPage() { + const deadline = Date.now() + 25_000; + while (Date.now() < deadline) { + try { + const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + const page = pages.find((item) => item.type === "page"); + if (page?.webSocketDebuggerUrl) { + return page; + } + } catch { + // Chrome may still be starting. + } + await sleep(250); + } + throw new Error("Chrome DevTools page target not found"); +} + +let ws; +let nextId = 0; +const pending = new Map(); + +async function openWebsocket() { + const page = await connectPage(); + ws = new WebSocket(page.webSocketDebuggerUrl); + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.id && pending.has(message.id)) { + pending.get(message.id)(message); + pending.delete(message.id); + } + }; + + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = reject; + }); +} + +function send(method, params = {}) { + return new Promise((resolve) => { + if (!ws) { + throw new Error("Chrome DevTools websocket is not connected"); + } + const id = ++nextId; + pending.set(id, resolve); + ws.send(JSON.stringify({ id, method, params })); + }); +} + +async function evalJs(expression) { + const response = await send("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + }); + if (response.exceptionDetails) { + throw new Error(JSON.stringify(response.exceptionDetails)); + } + return response.result?.result?.value; +} + +function jsString(value) { + return JSON.stringify(value); +} + +async function navigate(url) { + await send("Page.navigate", { url }); +} + +async function waitForPageSettle(ms = 1500) { + await sleep(ms); +} + +async function state() { + return await evalJs(`(() => ({ + title: document.title, + url: location.href, + text: document.body ? document.body.innerText.slice(0, 3000) : "", + hasLoginInput: !!document.querySelector('#login_field,input[name="login"],input[name="user_login"],input[type="email"]'), + passwordInputs: [...document.querySelectorAll('input[type="password"]')] + .map((e) => ({ + id: e.id || "", + name: e.name || "", + autocomplete: e.autocomplete || "", + placeholder: e.placeholder || "", + visible: !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length), + })), + buttons: [...document.querySelectorAll('button,a,[role="button"],input[type="submit"]')] + .map((e) => (e.innerText || e.value || e.getAttribute('aria-label') || '').trim()) + .filter(Boolean) + .slice(0, 100), + }))()`); +} + +async function clickText(label) { + return await evalJs(`(() => { + const target = ${jsString(label)}; + const primary = [...document.querySelectorAll('button,a,[role="button"],input[type="submit"]')]; + const el = primary.find((e) => (e.innerText || e.value || e.getAttribute('aria-label') || '').trim() === target); + if (!el) return false; + el.click(); + return true; + })()`); +} + +async function clickTextContaining(fragment) { + return await evalJs(`(() => { + const target = ${jsString(fragment)}.toLowerCase(); + const primary = [...document.querySelectorAll('button,a,[role="button"],input[type="submit"]')]; + const el = primary.find((e) => ((e.innerText || e.value || e.getAttribute('aria-label') || '').trim().toLowerCase()).includes(target)); + if (!el) return false; + el.click(); + return true; + })()`); +} + +async function clickEnabledTextContaining(fragment) { + return await evalJs(`(() => { + const target = ${jsString(fragment)}.toLowerCase(); + const primary = [...document.querySelectorAll('button,a,[role="button"],input[type="submit"]')]; + const el = primary.find((e) => { + const text = ((e.innerText || e.value || e.getAttribute('aria-label') || '').trim().toLowerCase()); + return text.includes(target) && !e.disabled && e.getAttribute('aria-disabled') !== 'true'; + }); + if (!el) return false; + el.scrollIntoView({ block: 'center' }); + el.click(); + return true; + })()`); +} + +async function browserPageTargets() { + return await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); +} + +async function targetForUrl(fragment) { + const targets = await browserPageTargets(); + return targets.find((item) => item.type === "page" && item.url.includes(fragment)); +} + +async function openOrFindPage(fragment, url) { + const existing = await targetForUrl(fragment); + if (existing?.webSocketDebuggerUrl) { + return existing; + } + await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(url)}`, { method: "PUT" }); + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + const target = await targetForUrl(fragment); + if (target?.webSocketDebuggerUrl) { + return target; + } + await sleep(500); + } + throw new Error(`Browser page did not open: ${url}`); +} + +async function evaluateInTarget(target, expression) { + const targetWs = new WebSocket(target.webSocketDebuggerUrl); + let targetId = 0; + const targetPending = new Map(); + targetWs.onmessage = (event) => { + const message = JSON.parse(event.data); + if (targetPending.has(message.id)) { + targetPending.get(message.id)(message); + targetPending.delete(message.id); + } + }; + await new Promise((resolve, reject) => { + targetWs.onopen = resolve; + targetWs.onerror = reject; + }); + const targetSend = (method, params = {}) => + new Promise((resolve) => { + const id = ++targetId; + targetPending.set(id, resolve); + targetWs.send(JSON.stringify({ id, method, params })); + }); + await targetSend("Runtime.enable"); + const response = await targetSend("Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + }); + targetWs.close(); + if (response.exceptionDetails) { + throw new Error(JSON.stringify(response.exceptionDetails)); + } + return response.result?.result?.value; +} + +async function setInput(selector, value) { + return await evalJs(`(() => { + const e = document.querySelector(${jsString(selector)}); + if (!e) return false; + e.scrollIntoView({ block: 'center' }); + e.focus(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; + setter.call(e, ${jsString(value)}); + e.dispatchEvent(new Event('input', { bubbles: true })); + e.dispatchEvent(new Event('change', { bubbles: true })); + return true; + })()`); +} + +async function setFileEditorContent(content) { + const mode = await evalJs(`(() => { + const value = ${jsString(content)}; + const setTextControl = (e) => { + e.scrollIntoView({ block: 'center' }); + e.focus(); + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; + if (setter) { + setter.call(e, value); + } else { + e.value = value; + } + e.dispatchEvent(new Event('input', { bubbles: true })); + e.dispatchEvent(new Event('change', { bubbles: true })); + return 'text-control'; + }; + const textarea = document.querySelector('textarea[name="value"],textarea[aria-label*="file"],textarea[aria-label*="File"],textarea'); + if (textarea) return setTextControl(textarea); + if (window.monaco?.editor?.getModels?.()?.length) { + window.monaco.editor.getModels()[0].setValue(value); + return 'monaco'; + } + const cm = document.querySelector('.cm-content[contenteditable="true"],[role="textbox"][contenteditable="true"]'); + if (cm) { + cm.scrollIntoView({ block: 'center' }); + const rect = cm.getBoundingClientRect(); + return { + mode: 'contenteditable', + x: rect.x + 20, + y: rect.y + 20, + }; + } + return { mode: '' }; + })()`); + if (mode?.mode === "contenteditable") { + await send("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: mode.x, + y: mode.y, + button: "none", + }); + await send("Input.dispatchMouseEvent", { + type: "mousePressed", + x: mode.x, + y: mode.y, + button: "left", + clickCount: 1, + }); + await send("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: mode.x, + y: mode.y, + button: "left", + clickCount: 1, + }); + await sleep(300); + await send("Input.insertText", { text: content }); + await sleep(500); + return true; + } + if (typeof mode === "string") { + return !!mode; + } + return !!mode?.mode; +} + +async function setPasswordByIndex(index, value) { + return await evalJs(`(() => { + const visible = [...document.querySelectorAll('input[type="password"]')] + .filter((e) => !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length)); + const e = visible[${index}]; + if (!e) return false; + e.focus(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; + setter.call(e, ${jsString(value)}); + e.dispatchEvent(new Event('input', { bubbles: true })); + e.dispatchEvent(new Event('change', { bubbles: true })); + return true; + })()`); +} + +async function submitNearestPasswordForm() { + return await evalJs(`(() => { + const password = document.querySelector('input[type="password"]'); + const form = password ? password.closest('form') : null; + if (form) { + const submit = form.querySelector('button[type="submit"],input[type="submit"],button'); + if (submit) { + submit.click(); + return true; + } + form.requestSubmit(); + return true; + } + return false; + })()`); +} + +function isGithubLoginPage(url, lower) { + return ( + url.includes("github.com") && + (url.includes("/login") || + lower.includes("sign in to github") || + lower.includes("username or email address")) + ); +} + +function isGithubSettingsPage(url) { + try { + const parsed = new URL(url); + return parsed.hostname === "github.com" && parsed.pathname.startsWith("/settings/"); + } catch { + return url.includes("github.com/settings/"); + } +} + +function requiresManualGithubStep(url, lower) { + const accountRestriction = + lower.includes("account suspended") || + lower.includes("account has been suspended") || + lower.includes("account disabled") || + lower.includes("account has been disabled") || + lower.includes("your account has been disabled"); + if (accountRestriction) { + return true; + } + if (isGithubSettingsPage(url)) { + return false; + } + return ( + url.includes("github.com/sessions/two-factor") || + url.includes("github.com/sessions/verified-device") || + url.includes("github.com/sessions/webauthn") || + url.includes("github.com/login/device") || + lower.includes("authentication code") || + lower.includes("verify your identity") || + lower.includes("verify your account") || + lower.includes("device verification") || + lower.includes("enter the code") || + (lower.includes("code") && lower.includes("we sent")) || + ((lower.includes("passkey") || lower.includes("security key")) && + (lower.includes("authenticate") || lower.includes("sign in") || lower.includes("verify"))) + ); +} + +function isGithubTwoFactorPrompt(url, lower) { + return ( + url.includes("github.com/sessions/two-factor") || + url.includes("github.com/settings/two_factor_checkup") || + lower.includes("authentication code") || + lower.includes("two-factor authentication") || + lower.includes("verify your two-factor authentication") || + lower.includes("verify your recently configured two-factor authentication method") + ); +} + +function extractTotpCodeFrom2faFunValues(values) { + for (const value of values) { + const match = String(value || "").match(/^\s*(\d{6})\s*$/) || String(value || "").match(/\b(\d{6})\b/); + if (match) { + return match[1]; + } + } + return ""; +} + +async function codeFrom2faFun() { + if (!totpSecret) { + return ""; + } + const target = await openOrFindPage("2fa.fun", "https://2fa.fun/"); + const inputOk = await evaluateInTarget( + target, + `(() => { + const textarea = document.querySelector('#SECRET2FA,textarea[name="SECRET2FA"],textarea'); + if (!textarea) return false; + textarea.focus(); + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; + setter.call(textarea, ${jsString(totpSecret)}); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.dispatchEvent(new Event('change', { bubbles: true })); + const button = [...document.querySelectorAll('button,input[type=submit],[role=button]')] + .find((e) => /获取验证码|验证码|get|code/i.test((e.innerText || e.value || e.getAttribute('aria-label') || '').trim())); + if (!button) return false; + button.click(); + return true; + })()` + ); + if (!inputOk) { + return ""; + } + + const deadline = Date.now() + 12_000; + while (Date.now() < deadline) { + await sleep(500); + const values = await evaluateInTarget( + target, + `(() => [...document.querySelectorAll('input.faotp')].map((e) => e.value || ''))()` + ); + const code = extractTotpCodeFrom2faFunValues(values || []); + if (code) { + return code; + } + } + return ""; +} + +async function submitGithubTotpCode(code) { + if (!code) { + return false; + } + return await evalJs(`(() => { + const code = ${jsString(code)}; + const input = document.querySelector('input[name="app_otp"],input#app_totp,input[name="otp"],input[name="two_factor_otp"],input[autocomplete="one-time-code"],input[type="text"],input[type="tel"]'); + if (!input) return false; + input.focus(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; + setter.call(input, code); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + const form = input.closest('form'); + const button = form?.querySelector('button[type="submit"],input[type="submit"],button') || + [...document.querySelectorAll('button,input[type=submit]')] + .find((e) => /verify|submit|continue/i.test((e.innerText || e.value || '').trim())); + if (button) { + button.click(); + return true; + } + if (form) { + form.requestSubmit(); + return true; + } + return false; + })()`); +} + +function hasPasswordChangeForm(current) { + const inputs = current.passwordInputs || []; + const names = inputs.map((item) => `${item.id} ${item.name} ${item.autocomplete}`.toLowerCase()); + return ( + inputs.length >= 3 || + names.some((text) => text.includes("old_password")) || + names.some((text) => text.includes("password_confirmation")) + ); +} + +function looksLikeSuccess(lower) { + return ( + lower.includes("password was successfully updated") || + lower.includes("password has been updated") || + lower.includes("your password was changed") || + lower.includes("password changed successfully") + ); +} + +function accountSlug(accountName) { + const slug = (accountName || "github-user") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return slug || "github-user"; +} + +function learningRepoNameForAccount(accountName) { + return `hello-world-from-${accountSlug(accountName)}`; +} + +function randomPostPasswordChangeDelayMs(pickInt = randomInt) { + return 3000 + pickInt(7001); +} + +function pickVariant(variants, pickIndex = randomInt) { + return variants[pickIndex(variants.length)]; +} + +function pickDistinct(variants, count, pickIndex = randomInt) { + const remaining = [...variants]; + const picked = []; + while (remaining.length > 0 && picked.length < count) { + const index = pickIndex(remaining.length); + picked.push(remaining.splice(index, 1)[0]); + } + return picked; +} + +function learningRepoDescriptionForAccount(accountName, pickIndex = randomInt) { + const displayName = accountName || "this GitHub account"; + const variants = [ + `A small beginner-friendly practice repository for ${displayName}.`, + `A first GitHub learning space for ${displayName}.`, + `A simple starter project for practicing GitHub basics as ${displayName}.`, + `A lightweight hello-world repository for ${displayName}'s GitHub practice.`, + ]; + return pickVariant(variants, pickIndex); +} + +function learningRepoContentForAccount(accountName, pickIndex = randomInt) { + const displayName = accountName || "this account"; + const intro = pickVariant([ + `This repository is a small learning space for ${displayName}. It keeps the first GitHub project simple: a README, a few notes, and room to practice commits, branches, and pull requests.`, + `${displayName} can use this repository as a gentle starting point for GitHub. The goal is to learn how a project is organized, how changes are saved, and how simple documentation helps other people understand the work.`, + `This is a beginner practice repository for ${displayName}. It is intentionally small so the focus stays on learning the basics: editing files, writing clear commit messages, and getting comfortable with the GitHub workflow.`, + `Welcome to ${displayName}'s first practice repository. This project is for learning by doing, starting with a simple README and growing through small, easy-to-review updates.`, + ], pickIndex); + const learningGoals = pickDistinct([ + "Practice editing files in a repository.", + "Learn how commits record project history.", + "Use branches and pull requests for small changes.", + "Keep project notes clear enough for another beginner to follow.", + "Try Markdown headings, lists, and links.", + "Review changes before merging them.", + ], 4, pickIndex); + const exercise = pickVariant([ + "Add one short note about something learned today, commit it, and review the change on GitHub.", + "Create a new branch, edit this README, and open a pull request with a short explanation.", + "Add a tiny checklist for the next practice session, then commit it with a clear message.", + "Write a short paragraph about what a repository is and save it as the next commit.", + ], pickIndex); + return [ + `# Hello World from ${displayName}`, + "", + intro, + "", + "## Learning goals", + "", + ...learningGoals.map((goal) => `- ${goal}`), + "", + "## First exercise", + "", + exercise, + "", + ].join("\n"); +} + +async function waitForState(predicate, timeoutMs, label) { + const deadline = Date.now() + timeoutMs; + let current; + while (Date.now() < deadline) { + current = await state(); + if (predicate(current)) { + return current; + } + await sleep(1000); + } + throw new Error(`Timed out waiting for ${label}; last url=${current?.url}; title=${current?.title}`); +} + +async function createLearningRepository() { + const repoName = learningRepoNameForAccount(learningRepoOwner); + const description = learningRepoDescriptionForAccount(learningRepoOwner); + const readme = learningRepoContentForAccount(learningRepoOwner); + const ownerSlug = accountSlug(learningRepoOwner); + + await navigate("https://github.com/new"); + await waitForPageSettle(3000); + let current = await state(); + const currentLower = (current.text || "").toLowerCase(); + if (isGithubTwoFactorPrompt(current.url, currentLower)) { + if (auto2faFun && totpSecret) { + await clickEnabledTextContaining("verify 2fa now"); + await sleep(3000); + const code = await codeFrom2faFun(); + if (await submitGithubTotpCode(code)) { + console.log("Browser helper: submitted GitHub 2FA code from 2fa.fun"); + await sleep(3500); + } + await navigate("https://github.com/new"); + await waitForPageSettle(3000); + } else if (await clickEnabledTextContaining("skip 2fa verification")) { + await waitForPageSettle(3000); + } + } + + const nameSet = await setInput('#repository-name-input,input[aria-label="Repository name"],input[id*="repository-name"]', repoName); + const descriptionSet = await setInput('input[name="Description"],input[aria-label="Description"],input[id*="description"]', description); + if (!nameSet) { + throw new Error("GitHub repository name field was not found"); + } + if (!descriptionSet) { + throw new Error("GitHub repository description field was not found"); + } + + await sleep(2500); + const createClicked = await clickEnabledTextContaining("create repository"); + if (!createClicked) { + throw new Error("GitHub create repository button was not available"); + } + + await waitForState( + (current) => + current.url.toLowerCase().includes(`/${ownerSlug}/${repoName}`) || + current.text.toLowerCase().includes("quick setup"), + 45_000, + `repository ${repoName} to be created` + ); + + await navigate(`https://github.com/${learningRepoOwner}/${repoName}/new/main?filename=README.md`); + await waitForPageSettle(3500); + await setInput('input[name="filename"],input[aria-label*="file name"],input[placeholder*="Name your file"]', "README.md"); + const contentSet = await setFileEditorContent(readme); + if (!contentSet) { + throw new Error("GitHub file editor was not found for README.md"); + } + await sleep(1000); + const firstCommitClicked = await clickEnabledTextContaining("commit changes"); + if (!firstCommitClicked) { + throw new Error("GitHub commit button was not available for README.md"); + } + await sleep(1500); + await clickEnabledTextContaining("commit changes"); + + const finalState = await waitForState( + (current) => learningRepoReadmeCommitted(current, ownerSlug, repoName), + 45_000, + `README.md commit in ${repoName}` + ); + return { repoName, url: finalState.url }; +} + +function learningRepoReadmeCommitted(current, ownerSlug, repoName) { + const url = (current.url || "").toLowerCase(); + const text = (current.text || "").toLowerCase(); + return ( + url.includes(`/${ownerSlug}/${repoName}`) && + !url.includes(`/${repoName}/new/`) && + !url.includes("/new/") && + text.includes("hello world from") + ); +} + +function passwordChangeSettledAfterSubmit(current) { + const url = current.url || ""; + const visiblePasswordInputs = (current.passwordInputs || []).filter((item) => item.visible); + const hasChangePasswordButton = (current.buttons || []).some((button) => + button.toLowerCase().includes("change password") + ); + return ( + url.includes("github.com/settings/security") && + visiblePasswordInputs.length === 0 && + hasChangePasswordButton + ); +} + +export { + isGithubLoginPage, + isGithubTwoFactorPrompt, + requiresManualGithubStep, + hasPasswordChangeForm, + looksLikeSuccess, + extractTotpCodeFrom2faFunValues, + learningRepoContentForAccount, + learningRepoDescriptionForAccount, + learningRepoNameForAccount, + learningRepoReadmeCommitted, + passwordChangeSettledAfterSubmit, + randomPostPasswordChangeDelayMs, +}; + +async function main() { + if (!port) { + console.error("GITHUB_DEVTOOLS_PORT is required"); + process.exit(2); + } + if (!githubLogin || !currentPassword || !newPassword) { + console.error("GitHub login, current password, and new password are required"); + process.exit(2); + } + + await openWebsocket(); + await send("Runtime.enable"); + await send("Page.enable"); + + const deadline = Date.now() + timeoutSeconds * 1000; + let lastAction = "started"; + let lastManualNoticeAt = 0; + let submittedGithubCredentials = false; + let submittedSudoPassword = false; + let submittedPasswordChange = false; + + async function finishPasswordChange(message) { + await navigate(settingsUrl); + await sleep(1500); + console.log(message); + if (createLearningRepo) { + const delayMs = randomPostPasswordChangeDelayMs(); + console.log(`Browser helper: waiting ${delayMs}ms before creating learning repository`); + await sleep(delayMs); + const repo = await createLearningRepository(); + console.log(`Browser helper: created learning repository ${repo.url}`); + } + ws.close(); + process.exit(0); + } + + while (Date.now() < deadline) { + const current = await state(); + const text = current.text || ""; + const lower = text.toLowerCase(); + const url = current.url || ""; + const passwordInputs = (current.passwordInputs || []).filter((item) => item.visible); + + if (looksLikeSuccess(lower)) { + await finishPasswordChange("Browser helper: GitHub password change completed"); + } + + if (submittedPasswordChange && passwordChangeSettledAfterSubmit(current)) { + await finishPasswordChange( + "Browser helper: GitHub password change completed without explicit success message" + ); + } + + if (!url.includes("github.com")) { + await navigate(settingsUrl); + lastAction = "navigated to GitHub settings"; + await sleep(1500); + continue; + } + + if ( + !submittedGithubCredentials && + isGithubLoginPage(url, lower) && + current.hasLoginInput && + passwordInputs.length >= 1 + ) { + await setInput('#login_field,input[name="login"],input[name="user_login"],input[type="email"]', githubLogin); + await setInput('#password,input[name="password"],input[type="password"]', currentPassword); + await sleep(250); + const clicked = (await clickText("Sign in")) || (await clickTextContaining("sign in")); + submittedGithubCredentials = true; + lastAction = `submitted GitHub credentials clicked=${clicked}`; + console.log("Browser helper: submitted GitHub credentials"); + await sleep(3500); + continue; + } + + if (requiresManualGithubStep(url, lower)) { + if (auto2faFun && totpSecret && isGithubTwoFactorPrompt(url, lower)) { + const code = await codeFrom2faFun(); + if (await submitGithubTotpCode(code)) { + console.log("Browser helper: submitted GitHub 2FA code from 2fa.fun"); + lastAction = "submitted GitHub 2FA code from 2fa.fun"; + await sleep(3500); + continue; + } + } + if (Date.now() - lastManualNoticeAt > 10_000) { + console.log("Browser helper: GitHub verification/restriction detected; complete or inspect it manually"); + lastManualNoticeAt = Date.now(); + } + lastAction = "waiting for manual GitHub verification"; + await sleep(2000); + continue; + } + + if (url.includes("github.com") && !url.includes("/settings/security")) { + await navigate(settingsUrl); + lastAction = "navigated to password settings"; + await sleep(1500); + continue; + } + + if ( + !submittedSudoPassword && + passwordInputs.length === 1 && + (lower.includes("confirm access") || + lower.includes("confirm password") || + lower.includes("sudo") || + lower.includes("verify your password")) + ) { + await setPasswordByIndex(0, currentPassword); + await sleep(250); + const submitted = + (await clickText("Confirm")) || + (await clickTextContaining("confirm")) || + (await submitNearestPasswordForm()); + submittedSudoPassword = true; + lastAction = `submitted sudo password submitted=${submitted}`; + console.log("Browser helper: submitted GitHub sudo password"); + await sleep(2500); + continue; + } + + if (!submittedPasswordChange && hasPasswordChangeForm(current)) { + const oldSet = + (await setInput('#user_old_password_sign_in_methods,#user_old_password,input[name="user[old_password]"],input[autocomplete="current-password"]', currentPassword)) || + (await setPasswordByIndex(0, currentPassword)); + const newSet = + (await setInput('#user_new_password_sign_in_methods,#user_password,input[name="user[password]"],input[autocomplete="new-password"]', newPassword)) || + (await setPasswordByIndex(1, newPassword)); + const confirmSet = + (await setInput('#user_confirm_new_password_sign_in_methods,#user_password_confirmation,input[name="user[password_confirmation]"]', newPassword)) || + (await setPasswordByIndex(2, newPassword)); + if (!oldSet || !newSet || !confirmSet) { + lastAction = `password form detected but not all fields were set old=${oldSet} new=${newSet} confirm=${confirmSet}`; + await sleep(1000); + continue; + } + await sleep(250); + const submitted = + (await clickText("Update password")) || + (await clickText("Change password")) || + (await clickTextContaining("update password")) || + (await clickTextContaining("change password")) || + (await submitNearestPasswordForm()); + submittedPasswordChange = true; + lastAction = `submitted password change old=${oldSet} new=${newSet} confirm=${confirmSet} submitted=${submitted}`; + console.log("Browser helper: submitted GitHub password change form"); + await sleep(5000); + continue; + } + + if ( + !submittedPasswordChange && + current.buttons?.some((button) => button.toLowerCase().includes("change password")) + ) { + await clickTextContaining("change password"); + lastAction = "clicked change password"; + await sleep(1500); + continue; + } + + await sleep(1000); +} + + const finalState = await state(); + ws.close(); + console.error( + `Browser helper timed out; lastAction=${lastAction}; title=${finalState.title}; url=${finalState.url}; text=${JSON.stringify((finalState.text || "").slice(0, 500))}` + ); + process.exit(1); +} + +if (isMain) { + main().catch((error) => { + console.error(error?.stack || error?.message || String(error)); + process.exit(1); + }); +} diff --git a/skills/github-password-rotator/scripts/rotate_github_password.py b/skills/github-password-rotator/scripts/rotate_github_password.py new file mode 100755 index 00000000..3b01eb2a --- /dev/null +++ b/skills/github-password-rotator/scripts/rotate_github_password.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Assist GitHub password rotation in an isolated browser. + +Passwords are accepted only through environment variables or hidden TTY +prompts. They are passed to the DevTools helper through environment variables, +never command-line arguments, and are not printed. +""" + +from __future__ import annotations + +import argparse +import getpass +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.request +from pathlib import Path +from typing import Any + + +DEFAULT_PROXY = "http://127.0.0.1:11111" +DEFAULT_SETTINGS_URL = "https://github.com/settings/security" +NODE_DRIVER = Path(__file__).with_name("drive_github_password_change.mjs") + + +def log(message: str) -> None: + print(message, flush=True) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--github-login", required=True, help="GitHub username or email") + parser.add_argument("--proxy", default=DEFAULT_PROXY) + parser.add_argument("--settings-url", default=DEFAULT_SETTINGS_URL) + parser.add_argument("--current-password-env", default="GITHUB_CURRENT_PASSWORD") + parser.add_argument("--new-password-env", default="GITHUB_NEW_PASSWORD") + parser.add_argument("--totp-secret-env", default="GITHUB_TOTP_SECRET") + parser.add_argument("--manual-timeout-seconds", type=int, default=900) + parser.add_argument("--chrome-bin") + parser.add_argument("--chrome-profile") + parser.add_argument("--debug-port", type=int) + parser.add_argument("--keep-browser", action="store_true") + parser.add_argument( + "--create-learning-repo", + action="store_true", + help="Create a beginner learning repository after the password change completes", + ) + parser.add_argument( + "--auto-2fa-fun", + action="store_true", + help="Use a TOTP secret with 2fa.fun to fill GitHub 2FA prompts automatically", + ) + parser.add_argument("--dry-run", action="store_true") + return parser.parse_args(argv) + + +def resolve_secret(env_name: str, prompt: str) -> str: + value = os.environ.get(env_name) + if value: + return value + if sys.stdin.isatty(): + value = getpass.getpass(prompt) + if value: + return value + raise SystemExit(f"{env_name} is required") + + +def chrome_binary(explicit: str | None) -> str: + if explicit: + return explicit + for name in ("google-chrome", "chromium", "chromium-browser"): + found = shutil.which(name) + if found: + return found + raise RuntimeError("Chrome/Chromium binary not found") + + +def find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def wait_http_json(url: str, timeout: float = 20.0) -> Any: + deadline = time.monotonic() + timeout + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + while time.monotonic() < deadline: + try: + with opener.open(url, timeout=2) as resp: + return json.loads(resp.read().decode("utf-8")) + except Exception: + time.sleep(0.25) + raise RuntimeError(f"timed out waiting for {url}") + + +def wait_for_page_target(port: int) -> None: + pages = wait_http_json(f"http://127.0.0.1:{port}/json/list", timeout=25) + page = next((item for item in pages if item.get("type") == "page"), None) + if not page: + raise RuntimeError("Chrome DevTools page target not found") + + +def launch_chrome(args: argparse.Namespace) -> tuple[subprocess.Popen[Any], int, str]: + port = args.debug_port or find_free_port() + profile_dir = args.chrome_profile or tempfile.mkdtemp(prefix="github-password-rotator-") + cmd = [ + chrome_binary(args.chrome_bin), + f"--user-data-dir={profile_dir}", + f"--proxy-server={args.proxy}", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-gpu", + "--disable-software-rasterizer", + "--remote-debugging-address=127.0.0.1", + f"--remote-debugging-port={port}", + args.settings_url, + ] + proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=args.keep_browser, + ) + return proc, port, profile_dir + + +def start_browser_helper( + args: argparse.Namespace, + port: int, + *, + current_password: str, + new_password: str, + totp_secret: str = "", +) -> subprocess.Popen[Any]: + if not NODE_DRIVER.is_file(): + raise FileNotFoundError(f"Node DevTools driver not found: {NODE_DRIVER}") + if not shutil.which("node"): + raise RuntimeError("node is required for browser automation") + env = os.environ.copy() + env.update( + { + "GITHUB_DEVTOOLS_PORT": str(port), + "GITHUB_LOGIN": args.github_login, + "GITHUB_CURRENT_PASSWORD": current_password, + "GITHUB_NEW_PASSWORD": new_password, + "GITHUB_MANUAL_TIMEOUT_SECONDS": str(args.manual_timeout_seconds), + "GITHUB_SETTINGS_URL": args.settings_url, + "GITHUB_CREATE_LEARNING_REPO": "1" if args.create_learning_repo else "0", + "GITHUB_LEARNING_REPO_OWNER": args.github_login, + "GITHUB_AUTO_2FA_FUN": "1" if args.auto_2fa_fun else "0", + "GITHUB_TOTP_SECRET": totp_secret, + } + ) + return subprocess.Popen(["node", str(NODE_DRIVER)], env=env) + + +def dry_run_summary(args: argparse.Namespace) -> dict[str, Any]: + return { + "dry_run": True, + "github_login": args.github_login, + "proxy": args.proxy, + "settings_url": args.settings_url, + "current_password_env": args.current_password_env, + "new_password_env": args.new_password_env, + "totp_secret_env": args.totp_secret_env, + "manual_timeout_seconds": args.manual_timeout_seconds, + "create_learning_repo": args.create_learning_repo, + "auto_2fa_fun": args.auto_2fa_fun, + "driver": str(NODE_DRIVER), + } + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + if args.dry_run: + log(json.dumps(dry_run_summary(args), ensure_ascii=False, indent=2)) + return 0 + + current_password = resolve_secret( + args.current_password_env, + f"Current GitHub password for {args.github_login}: ", + ) + new_password = resolve_secret( + args.new_password_env, + f"New GitHub password for {args.github_login}: ", + ) + if current_password == new_password: + raise SystemExit("new password must differ from current password") + totp_secret = "" + if args.auto_2fa_fun: + totp_secret = resolve_secret( + args.totp_secret_env, + f"TOTP secret for {args.github_login}: ", + ) + + proc: subprocess.Popen[Any] | None = None + helper: subprocess.Popen[Any] | None = None + profile_dir: str | None = None + try: + proc, port, profile_dir = launch_chrome(args) + wait_for_page_target(port) + helper = start_browser_helper( + args, + port, + current_password=current_password, + new_password=new_password, + totp_secret=totp_secret, + ) + code = helper.wait() + if code != 0: + raise RuntimeError(f"browser helper failed with code {code}") + log(json.dumps({"status": "password_change_completed", "github_login": args.github_login})) + return 0 + finally: + if helper and helper.poll() is None: + helper.terminate() + try: + helper.wait(timeout=5) + except subprocess.TimeoutExpired: + helper.kill() + if proc and not args.keep_browser: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + if profile_dir and not args.keep_browser and not args.chrome_profile: + shutil.rmtree(profile_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/skills/github-password-rotator/tests/drive_github_password_change.test.mjs b/skills/github-password-rotator/tests/drive_github_password_change.test.mjs new file mode 100644 index 00000000..2988a1f4 --- /dev/null +++ b/skills/github-password-rotator/tests/drive_github_password_change.test.mjs @@ -0,0 +1,128 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + extractTotpCodeFrom2faFunValues, + isGithubTwoFactorPrompt, + learningRepoContentForAccount, + learningRepoReadmeCommitted, + learningRepoNameForAccount, + passwordChangeSettledAfterSubmit, + randomPostPasswordChangeDelayMs, + requiresManualGithubStep, +} from "../scripts/drive_github_password_change.mjs"; + +test("does not treat the account security settings page as manual verification", () => { + const settingsText = [ + "Account security", + "Password", + "Two-factor authentication", + "Passkeys", + "Security keys", + ].join("\n").toLowerCase(); + + assert.equal( + requiresManualGithubStep("https://github.com/settings/security", settingsText), + false, + ); +}); + +test("still detects GitHub two-factor challenge pages", () => { + assert.equal( + requiresManualGithubStep( + "https://github.com/sessions/two-factor", + "two-factor authentication code", + ), + true, + ); +}); + +test("detects GitHub two-factor checkup pages", () => { + assert.equal( + isGithubTwoFactorPrompt( + "https://github.com/settings/two_factor_checkup?", + "verify your recently configured two-factor authentication method", + ), + true, + ); +}); + +test("treats collapsed password form after submit as settled without a success flash", () => { + assert.equal( + passwordChangeSettledAfterSubmit({ + url: "https://github.com/settings/security", + passwordInputs: [], + buttons: ["Change password"], + }), + true, + ); +}); + +test("does not treat a visible password form as settled after submit", () => { + assert.equal( + passwordChangeSettledAfterSubmit({ + url: "https://github.com/settings/security", + passwordInputs: [ + { visible: true }, + { visible: true }, + { visible: true }, + ], + buttons: ["Update password"], + }), + false, + ); +}); + +test("builds a deterministic learning repository name from the account name", () => { + assert.equal(learningRepoNameForAccount("Thompsonx"), "hello-world-from-thompsonx"); + assert.equal(learningRepoNameForAccount("Jane_Doe.42"), "hello-world-from-jane-doe-42"); +}); + +test("waits 3 to 10 seconds after password change before repository creation", () => { + assert.equal(randomPostPasswordChangeDelayMs(() => 0), 3000); + assert.equal(randomPostPasswordChangeDelayMs((max) => max - 1), 10000); +}); + +test("builds varied beginner README content for the same account", () => { + const first = learningRepoContentForAccount("alice", () => 0); + const second = learningRepoContentForAccount("alice", (length) => Math.min(1, length - 1)); + + assert.match(first, /^# Hello World from alice/m); + assert.match(first, /beginner|practice|learning/i); + assert.notEqual(first, second); +}); + +test("does not treat the new-file editor as a committed learning README", () => { + assert.equal( + learningRepoReadmeCommitted( + { + url: "https://github.com/alice/hello-world-from-alice/new/main?filename=README.md", + text: "# Hello World from alice", + }, + "alice", + "hello-world-from-alice", + ), + false, + ); +}); + +test("treats repository file view as a committed learning README", () => { + assert.equal( + learningRepoReadmeCommitted( + { + url: "https://github.com/alice/hello-world-from-alice/tree/main", + text: "README Hello World from alice", + }, + "alice", + "hello-world-from-alice", + ), + true, + ); +}); + +test("extracts 2fa.fun code from OTP input values", () => { + assert.equal( + extractTotpCodeFrom2faFunValues(["", "123456"]), + "123456", + ); +}); diff --git a/skills/github-password-rotator/tests/test_rotate_github_password.py b/skills/github-password-rotator/tests/test_rotate_github_password.py new file mode 100644 index 00000000..d5a1a8b1 --- /dev/null +++ b/skills/github-password-rotator/tests/test_rotate_github_password.py @@ -0,0 +1,194 @@ +import importlib.util +import os +import subprocess +import sys +import unittest +from pathlib import Path + + +SCRIPT = ( + Path(__file__).resolve().parents[1] + / "scripts" + / "rotate_github_password.py" +) +SPEC = importlib.util.spec_from_file_location("rotate_github_password", SCRIPT) +module = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = module +SPEC.loader.exec_module(module) + + +class GithubPasswordRotatorTest(unittest.TestCase): + def test_parse_args_uses_environment_password_sources(self): + args = module.parse_args( + [ + "--github-login", + "octo", + "--current-password-env", + "OLD_ENV", + "--new-password-env", + "NEW_ENV", + ] + ) + + self.assertEqual(args.github_login, "octo") + self.assertEqual(args.current_password_env, "OLD_ENV") + self.assertEqual(args.new_password_env, "NEW_ENV") + self.assertFalse(hasattr(args, "current_password")) + self.assertFalse(hasattr(args, "new_password")) + + def test_resolve_secret_reads_environment_without_printing_value(self): + old_value = os.environ.get("GH_PASSWORD_TEST") + os.environ["GH_PASSWORD_TEST"] = "secret-value" + try: + value = module.resolve_secret("GH_PASSWORD_TEST", "ignored") + finally: + if old_value is None: + os.environ.pop("GH_PASSWORD_TEST", None) + else: + os.environ["GH_PASSWORD_TEST"] = old_value + + self.assertEqual(value, "secret-value") + + def test_start_browser_helper_passes_passwords_only_via_environment(self): + calls = [] + + class FakeProcess: + pass + + def fake_which(name): + if name == "node": + return "/usr/bin/node" + return None + + def fake_popen(cmd, env, **kwargs): + calls.append((cmd, env, kwargs)) + return FakeProcess() + + args = module.parse_args(["--github-login", "octo"]) + original_which = module.shutil.which + original_popen = module.subprocess.Popen + module.shutil.which = fake_which + module.subprocess.Popen = fake_popen + try: + process = module.start_browser_helper( + args, + 9222, + current_password="old-secret", + new_password="new-secret", + ) + finally: + module.shutil.which = original_which + module.subprocess.Popen = original_popen + + self.assertIsInstance(process, FakeProcess) + self.assertEqual(len(calls), 1) + cmd, env, _kwargs = calls[0] + self.assertEqual(cmd, ["node", str(module.NODE_DRIVER)]) + self.assertNotIn("old-secret", cmd) + self.assertNotIn("new-secret", cmd) + self.assertEqual(env["GITHUB_LOGIN"], "octo") + self.assertEqual(env["GITHUB_CURRENT_PASSWORD"], "old-secret") + self.assertEqual(env["GITHUB_NEW_PASSWORD"], "new-secret") + self.assertEqual(env["GITHUB_CREATE_LEARNING_REPO"], "0") + self.assertEqual(env["GITHUB_AUTO_2FA_FUN"], "0") + self.assertEqual(env["GITHUB_TOTP_SECRET"], "") + + def test_create_learning_repo_flag_is_passed_to_browser_helper(self): + calls = [] + + class FakeProcess: + pass + + def fake_which(name): + if name == "node": + return "/usr/bin/node" + return None + + def fake_popen(cmd, env, **kwargs): + calls.append((cmd, env, kwargs)) + return FakeProcess() + + args = module.parse_args(["--github-login", "octo", "--create-learning-repo"]) + original_which = module.shutil.which + original_popen = module.subprocess.Popen + module.shutil.which = fake_which + module.subprocess.Popen = fake_popen + try: + module.start_browser_helper( + args, + 9222, + current_password="old-secret", + new_password="new-secret", + ) + finally: + module.shutil.which = original_which + module.subprocess.Popen = original_popen + + self.assertEqual(len(calls), 1) + _cmd, env, _kwargs = calls[0] + self.assertEqual(env["GITHUB_CREATE_LEARNING_REPO"], "1") + self.assertEqual(env["GITHUB_LEARNING_REPO_OWNER"], "octo") + + def test_auto_2fa_fun_secret_is_passed_only_via_environment(self): + calls = [] + + class FakeProcess: + pass + + def fake_which(name): + if name == "node": + return "/usr/bin/node" + return None + + def fake_popen(cmd, env, **kwargs): + calls.append((cmd, env, kwargs)) + return FakeProcess() + + args = module.parse_args(["--github-login", "octo", "--auto-2fa-fun"]) + original_which = module.shutil.which + original_popen = module.subprocess.Popen + module.shutil.which = fake_which + module.subprocess.Popen = fake_popen + try: + module.start_browser_helper( + args, + 9222, + current_password="old-secret", + new_password="new-secret", + totp_secret="totp-secret", + ) + finally: + module.shutil.which = original_which + module.subprocess.Popen = original_popen + + self.assertEqual(len(calls), 1) + cmd, env, _kwargs = calls[0] + self.assertNotIn("totp-secret", cmd) + self.assertEqual(env["GITHUB_AUTO_2FA_FUN"], "1") + self.assertEqual(env["GITHUB_TOTP_SECRET"], "totp-secret") + + def test_dry_run_does_not_launch_browser_or_require_secrets(self): + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--github-login", + "octo", + "--dry-run", + "--manual-timeout-seconds", + "1", + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stdout) + self.assertIn("dry_run", result.stdout) + self.assertIn("octo", result.stdout) + + +if __name__ == "__main__": + unittest.main()