diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 648838a..5057782 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,3 @@ ---- version: 2 updates: - package-ecosystem: github-actions @@ -6,27 +5,42 @@ updates: schedule: interval: weekly day: monday + open-pull-requests-limit: 10 commit-message: - prefix: "ci" + prefix: ci groups: github-actions: - patterns: - - "*" + patterns: ["*"] labels: - dependencies - github-actions + - package-ecosystem: npm directory: / schedule: interval: weekly day: monday + open-pull-requests-limit: 10 commit-message: - prefix: "chore" + prefix: chore + prefix-development: chore + include: scope groups: - npm-minor-patch: - update-types: - - minor - - patch + # Dev-only minor/patch — auto-merge candidates (vitest, typescript, + # vite plugins). One PR keeps churn down. + dev-deps-minor: + dependency-type: development + update-types: [minor, patch] + # Runtime minor/patch — small surface (js-yaml only) but still group. + prod-deps-minor: + dependency-type: production + update-types: [minor, patch] + # All majors get their own PR per package — no grouping — so the + # human review queue can evaluate them individually. labels: - dependencies - npm + ignore: + # Stay on a stable Node major; bump deliberately, not via dependabot. + - dependency-name: "@types/node" + update-types: [version-update:semver-major] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5317f10..d31aa05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: push: branches: [main] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..ec224eb --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,33 @@ +name: Dependabot auto-merge + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: write + pull-requests: write + +jobs: + automerge: + if: github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Fetch Dependabot metadata + id: meta + uses: dependabot/fetch-metadata@v2.4.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + # Auto-merge minor and patch updates only — major updates go to a + # human review queue. github-actions and dev-only npm minor/patch are + # the safest categories and historically clean every time CI passes. + - name: Enable auto-merge for safe updates + if: | + steps.meta.outputs.update-type == 'version-update:semver-minor' || + steps.meta.outputs.update-type == 'version-update:semver-patch' + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh pr merge --auto --squash "$PR_URL" diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 3de5131..0ec5ae6 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -3,6 +3,18 @@ name: Pre-release on: push: branches: [main] + # Skip pre-release for changes that don't affect the built artifact. + paths-ignore: + - '**.md' + - '.github/dependabot.yml' + - '.github/ISSUE_TEMPLATE/**' + - '.github/PULL_REQUEST_TEMPLATE/**' + - '.coderabbit.yaml' + - '.gitleaks.toml' + - '.pre-commit-config.yaml' + - '.yamllint.yml' + - '.gitignore' + - 'LICENSE' permissions: contents: write diff --git a/README.md b/README.md index 32a1bf8..797abd4 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,41 @@ Browser-based tool that turns messy Docker Compose output into clean, readable d ## Features -### Service Cards +### Three views -Parsed per-service view showing image, ports, volumes, networks, environment, and extras (restart policy, hostname, depends_on, resource limits). Empty sections are omitted. Switch between YAML and Cards views with the tab bar. +- **Table** *(default)* — service overview + User/Group comparison + Volume comparison, all in one place. Best for quickly spotting UID/GID mismatches or which services share which host paths. +- **Cards** — per-service view showing image, ports, volumes, networks, environment, and extras (user, restart policy, hostname, depends_on, resource limits). Empty sections are omitted. +- **YAML** — full sanitized YAML output, ready to paste into a gist. -### Markdown Table +### Copy as Markdown — GitHub or Discord -One-click "Copy as Markdown Table" generates a table with columns for Service, Image, Ports, Volumes, and Networks — paste directly into Discord or GitHub issues. +Two dedicated buttons: + +- **Copy MD (GitHub)** — `### heading` + bare pipe-table markdown. Renders as a real table on GitHub. +- **Copy MD (Discord)** — `**bold**` labels + each table wrapped in a fenced code block. Discord doesn't render pipe tables, so the fence preserves alignment in monospace and prevents `_underscore_` / `*asterisk*` characters in volume paths from triggering inline formatting. + +Both formats include the Services overview, User/Group comparison, and Volume comparison sections. + +### User / Group merging + +The "User" column merges three sources of identity into a single value so you can spot mismatches at a glance: + +- explicit `user: :` directive +- `PUID` / `PGID` env vars (linuxserver convention) +- `group_add` and `UMASK` in the comparison table + +Lookups are case-insensitive (so a typo'd `Puid` still surfaces). When the directive matches `PUID:PGID`, only one value is shown; when they conflict, the directive is shown with the env values annotated. ### Redaction | What | Example | Result | |------|---------|--------| -| Sensitive env values | `RADARR__POSTGRES__HOST: db.example.com` | `RADARR__POSTGRES__HOST: **REDACTED**` | +| Sensitive env keys | `MYSQL_PASSWORD`, `API_KEY`, `DATABASE_URL`, `AWS_SECRET_ACCESS_KEY`, `*_FILE` variants | value replaced with `**REDACTED**` | +| Inline credentials in URLs | `postgres://:@db/app` | redacted regardless of the env-var name | +| Vendor token formats | GitHub PATs (`ghp_…`), AWS access keys (`AKIA…`), Tailscale auth keys (`tskey-…-…`), Discord/Slack webhooks, JWTs | redacted regardless of the env-var name | | Email addresses | `NOTIFY: user@example.com` | `NOTIFY: **REDACTED**` | | Home directory paths | `/home/john/media:/tv` | `~/media:/tv` | -Detected patterns: `password`, `secret`, `token`, `api_key`, `auth`, `credential`, `private_key`, `vpn_user`, and more. - Safe-listed keys (kept as-is): `PUID`, `PGID`, `TZ`, `UMASK`, `LOG_LEVEL`, `WEBUI_PORT`, etc. ### Noise Stripping @@ -73,19 +90,21 @@ Single-page app built with Vite + vanilla TypeScript. The build produces one sel ``` src/ - dom.ts # Shared el() DOM helper (no innerHTML) - patterns.ts # Type guards, regex patterns, utility functions - extract.ts # Extracts YAML from mixed console output - redact.ts # Redacts sensitive values, anonymizes paths - noise.ts # Strips auto-generated noise fields - advisories.ts # Detects misconfigurations (hardlinks, etc.) - services.ts # Parses compose object into ServiceInfo[] - markdown.ts # Generates markdown table from ServiceInfo[] - cards.ts # Renders per-service card DOM - config.ts # Customizable patterns, localStorage persistence - clipboard.ts # Copy, PrivateBin, and Gist sharing - disclaimer.ts # PII warnings and legal disclaimers - main.ts # UI assembly, tabs, and event wiring + dom.ts # Shared el() DOM helper (no innerHTML) + patterns.ts # Key + value regex patterns, type guards, helpers + extract.ts # Extracts YAML from mixed console output + redact.ts # Redacts sensitive values, anonymizes paths + noise.ts # Strips auto-generated noise fields + advisories.ts # Detects misconfigurations (hardlinks, etc.) + services.ts # Parses compose object into ServiceInfo[] + UserGroupInfo + markdown.ts # GitHub + Discord markdown generators + cards.ts # Renders per-service card DOM + volume-table.ts # Service / User-Group / Volume comparison tables + volume-utils.ts # Volume parsing + matrix builder + config.ts # Customizable patterns, localStorage persistence + clipboard.ts # Copy (with execCommand fallback), PrivateBin, Gist + disclaimer.ts # PII warnings and legal disclaimers + main.ts # UI assembly, tabs, and event wiring ``` ### Testing diff --git a/package-lock.json b/package-lock.json index f2ddc2e..3ce8be8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "docker-compose-debugger", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docker-compose-debugger", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "js-yaml": "^4.1.1" diff --git a/package.json b/package.json index a41fc8a..5e4101f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docker-compose-debugger", - "version": "0.1.0", + "version": "0.2.0", "description": "Browser-based Docker Compose debugger — redacts secrets, shows service cards, and generates markdown tables for support channels", "type": "module", "scripts": { diff --git a/src/clipboard.ts b/src/clipboard.ts index 39dff08..cb16872 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -1,10 +1,55 @@ -export async function copyToClipboard(text: string): Promise { +function legacyCopy(text: string): boolean { + if (typeof document === 'undefined' || document.body === null) return false + + const previouslyFocused = + document.activeElement instanceof HTMLElement ? document.activeElement : null + + const textarea = document.createElement('textarea') + textarea.value = text + textarea.setAttribute('readonly', '') + textarea.style.position = 'fixed' + textarea.style.top = '0' + textarea.style.left = '0' + textarea.style.width = '1px' + textarea.style.height = '1px' + textarea.style.opacity = '0' + textarea.style.pointerEvents = 'none' + document.body.appendChild(textarea) + + let success = false try { - await navigator.clipboard.writeText(text) - return true + textarea.focus() + textarea.select() + textarea.setSelectionRange(0, text.length) + success = document.execCommand('copy') } catch { - return false + success = false + } finally { + textarea.remove() + if (previouslyFocused) previouslyFocused.focus() + } + + return success +} + +function isSecureClipboardAvailable(): boolean { + if (typeof navigator === 'undefined') return false + if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') return false + // navigator.clipboard.writeText only works in secure contexts. Some browsers + // expose the API but throw at call time; we still try and fall through on error. + return true +} + +export async function copyToClipboard(text: string): Promise { + if (isSecureClipboardAvailable()) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // fall through to legacy path + } } + return legacyCopy(text) } export function openPrivateBin(): void { diff --git a/src/config.ts b/src/config.ts index d5431f3..6ac3656 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,15 @@ export const DEFAULT_CONFIG: SanitizerConfig = { 'credential', 'private[_\\-.]?key', 'vpn[_\\-.]?user', + '[_.\\-](url|uri|dsn|conn(?:ection)?(?:_string)?)$', + '^(database|redis|mongo|amqp|rabbit|celery|postgres|mysql|elastic)[_.\\-]?(url|uri|dsn)?$', + 'aws[_\\-.]?(access|secret)[_\\-.]?key', + 'tailscale[_\\-.]?(auth)?[_\\-.]?key', + 'webhook', + 'pat$', + '^gh[_\\-.]?(token|pat)', + '^(discord|slack|telegram|matrix|teams)[_\\-.]', + '\\b(guild|channel|server|workspace|tenant|application|bot|client)[_\\-.]?id$', ], safeKeys: [ 'PUID', 'PGID', 'TZ', 'UMASK', 'UMASK_SET', diff --git a/src/extract.ts b/src/extract.ts index dac4963..06c77fb 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -6,6 +6,50 @@ export interface ExtractResult { readonly error: string | null } +const HTML_ENTITY_PATTERN = /&(amp|lt|gt|quot|#39|apos|nbsp|#x?[0-9a-f]+);/i +const PERCENT_ENCODED_PATTERN = /%[0-9a-fA-F]{2}/ + +// When users paste from a rendered HTML page (forum thread, wiki, GitHub diff +// preview, autocompose web demo), the input arrives with HTML entities and/or +// percent-encoded sequences instead of literal characters. YAML will reject +// these. Decode them up front so the rest of the pipeline sees plain text. +function decodeHtmlEntities(input: string): string { + if (typeof document === 'undefined') return input + // The textarea innerHTML trick handles named entities (&), decimal + // ("), and hex (") without exposing us to script injection — the + // value is read back as text, never inserted into the live DOM. + const ta = document.createElement('textarea') + ta.innerHTML = input + return ta.value +} + +function decodePercentEncoding(input: string): string { + // decodeURIComponent throws on malformed sequences (e.g. lone %). Decode + // each match individually so a single bad sequence doesn't drop the whole + // input. + return input.replace(/%[0-9a-fA-F]{2}/g, match => { + try { + return decodeURIComponent(match) + } catch { + return match + } + }) +} + +export function normalizeEncodedInput(raw: string): string { + let out = raw + if (HTML_ENTITY_PATTERN.test(out)) { + out = decodeHtmlEntities(out) + } + // Only apply percent-decoding when there are at least two encoded sequences + // so a stray "%2" or "%20" inside a literal string doesn't get mangled. + const matches = out.match(/%[0-9a-fA-F]{2}/g) + if (matches && matches.length >= 2 && PERCENT_ENCODED_PATTERN.test(out)) { + out = decodePercentEncoding(out) + } + return out +} + const YAML_START_KEYS = /^(version|services|name|networks|volumes|x-)[\s:]/ const SHELL_PREFIX = /^[$#>]\s|^(sudo\s|docker\s|podman\s)/ @@ -36,7 +80,8 @@ function trimTrailingPrompt(lines: readonly string[]): readonly string[] { } export function extractYaml(raw: string): ExtractResult { - const trimmed = raw.trim() + const decoded = normalizeEncodedInput(raw) + const trimmed = decoded.trim() if (trimmed === '') { return { yaml: null, error: 'No input provided. Paste your Docker Compose YAML or console output.' } } diff --git a/src/main.ts b/src/main.ts index a672cf1..025f11f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,9 +9,9 @@ import { copyToClipboard, openPrivateBin, openGist } from './clipboard' import { createShortNotice, createPiiWarning, createFullDisclaimer } from './disclaimer' import { el } from './dom' import { parseServices } from './services' -import { generateMarkdownTable, generateVolumeComparisonMarkdown } from './markdown' +import { buildCombinedMarkdown, formatForDiscord, formatForGitHub } from './markdown' import { renderCards } from './cards' -import { renderServiceTable, renderVolumeTable } from './volume-table' +import { renderServiceTable, renderUserGroupTable, renderVolumeTable } from './volume-table' const MAX_INPUT_BYTES = 512 * 1024 @@ -263,20 +263,29 @@ function init(): void { piiWarning.classList.add('hidden') app.appendChild(piiWarning) - // Tab bar (hidden until output) + // Tab bar (hidden until output). Table is default — most users want the + // service overview as a quick read; YAML view is the full sanitized output. const tabBar = el('div', { className: 'tab-bar hidden' }) - const yamlTab = el('button', { className: 'tab-btn active' }) - yamlTab.textContent = 'YAML' - tabBar.appendChild(yamlTab) + const volumesTab = el('button', { className: 'tab-btn active' }) + volumesTab.textContent = 'Table' + tabBar.appendChild(volumesTab) const cardsTab = el('button', { className: 'tab-btn' }) cardsTab.textContent = 'Cards' tabBar.appendChild(cardsTab) - const volumesTab = el('button', { className: 'tab-btn' }) - volumesTab.textContent = 'Table' - tabBar.appendChild(volumesTab) + const yamlTab = el('button', { className: 'tab-btn' }) + yamlTab.textContent = 'YAML' + tabBar.appendChild(yamlTab) app.appendChild(tabBar) - // Output textarea (YAML view) + // Volumes container (default visible after sanitize) + const volumesContainer = el('div', { id: 'volumes', className: 'hidden' }) + app.appendChild(volumesContainer) + + // Cards container (hidden by default) + const cardsContainer = el('div', { id: 'cards', className: 'cards-container hidden' }) + app.appendChild(cardsContainer) + + // Output textarea (YAML view, hidden by default) const output = el('textarea', { id: 'output', className: 'code-textarea hidden', @@ -286,14 +295,6 @@ function init(): void { }) app.appendChild(output) - // Cards container (hidden by default) - const cardsContainer = el('div', { id: 'cards', className: 'cards-container hidden' }) - app.appendChild(cardsContainer) - - // Volumes container (hidden by default) - const volumesContainer = el('div', { id: 'volumes', className: 'hidden' }) - app.appendChild(volumesContainer) - // Track current parsed object for markdown generation let currentParsed: Record | null = null @@ -332,54 +333,51 @@ function init(): void { }) actions.appendChild(copyBtn) - const mdBtn = el('button', { className: 'btn btn-secondary' }) - mdBtn.textContent = 'Copy as Markdown' - mdBtn.addEventListener('click', async () => { - if (!currentParsed) { - mdBtn.textContent = 'No data' - setTimeout(() => { mdBtn.textContent = 'Copy as Markdown' }, 1500) - return - } - const services = parseServices(currentParsed) - const parts: string[] = [] - const serviceTable = generateMarkdownTable(services) - if (serviceTable) { - parts.push('### Services\n\n' + serviceTable) - } - const volTable = generateVolumeComparisonMarkdown(services) - if (volTable) { - parts.push('### Volume Comparison\n\n' + volTable) - } - const md = parts.join('\n\n') - const ok = await copyToClipboard(md || 'No services found') - mdBtn.textContent = ok ? 'Copied!' : 'Copy failed' - setTimeout(() => { mdBtn.textContent = 'Copy as Markdown' }, 1500) - }) - actions.appendChild(mdBtn) - - const pbBtn = el('button', { className: 'btn btn-secondary', title: 'Tip: Set expiry to 1 week or longer so support can review it' }) - pbBtn.textContent = 'Open PrivateBin' - pbBtn.addEventListener('click', async () => { - await copyToClipboard(output.value) - openPrivateBin() - }) - actions.appendChild(pbBtn) + function makeMarkdownButton(label: string, format: (p: ReturnType) => string): HTMLButtonElement { + const btn = el('button', { className: 'btn btn-secondary' }) + btn.textContent = label + btn.addEventListener('click', async () => { + if (!currentParsed) { + btn.textContent = 'No data' + setTimeout(() => { btn.textContent = label }, 1500) + return + } + const services = parseServices(currentParsed) + const md = format(buildCombinedMarkdown(services)) + const ok = await copyToClipboard(md || 'No services found') + btn.textContent = ok ? 'Copied!' : 'Copy failed' + setTimeout(() => { btn.textContent = label }, 1500) + }) + return btn + } - const logsBtn = el('button', { className: 'btn btn-secondary' }) - logsBtn.textContent = 'Open logs.notifiarr.com' - logsBtn.addEventListener('click', async () => { - await copyToClipboard(output.value) - window.open('https://logs.notifiarr.com/', '_blank', 'noopener,noreferrer') - }) - actions.appendChild(logsBtn) + actions.appendChild(makeMarkdownButton('Copy MD (GitHub)', formatForGitHub)) + actions.appendChild(makeMarkdownButton('Copy MD (Discord)', formatForDiscord)) + + // Open* buttons must call window.open synchronously inside the click handler + // before any await — otherwise Safari (and strict popup blockers) drop the + // user-activation token and the popup is blocked. + function makeOpenButton(label: string, url: string, title?: string): HTMLButtonElement { + const btn = el('button', { className: 'btn btn-secondary' }) + btn.textContent = label + if (title) btn.setAttribute('title', title) + btn.addEventListener('click', () => { + window.open(url, '_blank', 'noopener,noreferrer') + copyToClipboard(output.value).then(ok => { + btn.textContent = ok ? 'Copied! → opened tab' : 'Tab opened (copy failed)' + setTimeout(() => { btn.textContent = label }, 1800) + }) + }) + return btn + } - const gistBtn = el('button', { className: 'btn btn-secondary' }) - gistBtn.textContent = 'Open GitHub Gist' - gistBtn.addEventListener('click', async () => { - await copyToClipboard(output.value) - openGist() - }) - actions.appendChild(gistBtn) + actions.appendChild(makeOpenButton( + 'Open PrivateBin', + 'https://privatebin.net/', + 'Tip: Set expiry to 1 week or longer so support can review it', + )) + actions.appendChild(makeOpenButton('Open logs.notifiarr.com', 'https://logs.notifiarr.com/')) + actions.appendChild(makeOpenButton('Open GitHub Gist', 'https://gist.github.com/')) app.appendChild(actions) @@ -445,8 +443,8 @@ function init(): void { output.value = result.output ?? '' currentParsed = result.parsed - // Reset to YAML tab - switchTab(yamlTab) + // Reset to Table tab — best default for quick scanning + switchTab(volumesTab) // Render cards + volume table cardsContainer.replaceChildren() @@ -463,29 +461,41 @@ function init(): void { const svcTable = renderServiceTable(services) volumesContainer.appendChild(svcTable) + // Render user/group comparison table (only if at least one service has data) + const ugTable = renderUserGroupTable(services) + if (ugTable.firstChild) { + const ugLabel = el('label') + ugLabel.textContent = 'User / Group comparison:' + ugLabel.style.marginTop = '0.75rem' + volumesContainer.appendChild(ugLabel) + volumesContainer.appendChild(ugTable) + } + // Render volume comparison table const volTable = renderVolumeTable(services) - volumesContainer.appendChild(volTable) - - // Markdown preview textarea - const svcMd = generateMarkdownTable(services) - const volMd = generateVolumeComparisonMarkdown(services) - const mdParts: string[] = [] - if (svcMd) mdParts.push(svcMd) - if (volMd) mdParts.push(volMd) - if (mdParts.length > 0) { - const combinedMd = mdParts.join('\n\n') + if (volTable.firstChild) { + const volLabel = el('label') + volLabel.textContent = 'Volume comparison:' + volLabel.style.marginTop = '0.75rem' + volumesContainer.appendChild(volLabel) + volumesContainer.appendChild(volTable) + } + + // Markdown preview — share the exact pipeline used by the copy + // buttons so what's previewed is identical to what gets copied. + const previewMd = formatForGitHub(buildCombinedMarkdown(services)) + if (previewMd) { const mdLabel = el('label') - mdLabel.textContent = 'Markdown (for pasting into Discord / GitHub):' + mdLabel.textContent = 'Markdown preview (GitHub format) — use the buttons above to copy GitHub or Discord variants:' mdLabel.style.marginTop = '0.75rem' volumesContainer.appendChild(mdLabel) const mdPreview = el('textarea', { className: 'code-textarea', - rows: String(Math.min(combinedMd.split('\n').length + 1, 18)), + rows: String(Math.min(previewMd.split('\n').length + 1, 18)), readonly: 'true', spellcheck: 'false', }) - mdPreview.value = combinedMd + mdPreview.value = previewMd volumesContainer.appendChild(mdPreview) } } diff --git a/src/markdown.ts b/src/markdown.ts index 4335812..8670a3f 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -9,6 +9,31 @@ function joinField(values: readonly string[]): string { return values.join(', ') } +export function generateUserGroupComparisonMarkdown(services: readonly ServiceInfo[]): string { + if (services.length === 0) return '' + + const dash = '—' + type Row = { label: string; cells: string[] } + const rows: Row[] = [ + { label: 'user:', cells: services.map(s => s.userGroup.user || dash) }, + { label: 'PUID', cells: services.map(s => s.userGroup.puid || dash) }, + { label: 'PGID', cells: services.map(s => s.userGroup.pgid || dash) }, + { + label: 'group_add', + cells: services.map(s => (s.userGroup.groupAdd.length > 0 ? s.userGroup.groupAdd.join(', ') : dash)), + }, + { label: 'UMASK', cells: services.map(s => s.userGroup.umask || dash) }, + ] + const visible = rows.filter(r => r.cells.some(c => c !== dash)) + if (visible.length === 0) return '' + + const header = `| User / Group | ${services.map(s => escapeCell(s.name)).join(' | ')} |` + const separator = `| --- | ${services.map(() => '---').join(' | ')} |` + const body = visible.map(r => `| ${r.label} | ${r.cells.map(escapeCell).join(' | ')} |`) + + return [header, separator, ...body].join('\n') +} + export function generateVolumeComparisonMarkdown(services: readonly ServiceInfo[]): string { if (services.length === 0) return '' @@ -32,6 +57,45 @@ export function generateVolumeComparisonMarkdown(services: readonly ServiceInfo[ return [header, separator, ...rows].join('\n') } +export interface CombinedMarkdown { + readonly serviceTable: string + readonly userGroupTable: string + readonly volumeTable: string +} + +export function buildCombinedMarkdown(services: readonly ServiceInfo[]): CombinedMarkdown { + return { + serviceTable: generateMarkdownTable(services), + userGroupTable: generateUserGroupComparisonMarkdown(services), + volumeTable: generateVolumeComparisonMarkdown(services), + } +} + +export function formatForGitHub(parts: CombinedMarkdown): string { + const out: string[] = [] + if (parts.serviceTable) out.push('### Services\n\n' + parts.serviceTable) + if (parts.userGroupTable) out.push('### User / Group\n\n' + parts.userGroupTable) + if (parts.volumeTable) out.push('### Volume Comparison\n\n' + parts.volumeTable) + return out.join('\n\n') +} + +// Discord renders pipe-table markdown as literal text and parses _underscores_, +// **asterisks**, and ~~tildes~~ inside paths. Wrapping each table in a fenced +// code block preserves alignment and blocks Discord's inline formatting. +export function formatForDiscord(parts: CombinedMarkdown): string { + const out: string[] = [] + if (parts.serviceTable) { + out.push('**Services**\n```\n' + parts.serviceTable + '\n```') + } + if (parts.userGroupTable) { + out.push('**User / Group**\n```\n' + parts.userGroupTable + '\n```') + } + if (parts.volumeTable) { + out.push('**Volume Comparison**\n```\n' + parts.volumeTable + '\n```') + } + return out.join('\n\n') +} + export function generateMarkdownTable(services: readonly ServiceInfo[]): string { if (services.length === 0) return '' diff --git a/src/patterns.ts b/src/patterns.ts index 8951121..b91a84f 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -14,6 +14,20 @@ export const DEFAULT_SENSITIVE_PATTERNS: readonly RegExp[] = [ /credential/i, /private[_\-.]?key/i, /vpn[_\-.]?user/i, + // Connection strings & DSN-style keys often contain inline credentials. + /[_.\-](url|uri|dsn|conn(?:ection)?(?:_string)?)$/i, + /^(database|redis|mongo|amqp|rabbit|celery|postgres|mysql|elastic)[_.\-]?(url|uri|dsn)?$/i, + // Cloud / vendor keys. + /aws[_\-.]?(access|secret)[_\-.]?key/i, + /tailscale[_\-.]?(auth)?[_\-.]?key/i, + /webhook/i, + /pat$/i, + /^gh[_\-.]?(token|pat)/i, + // Discord / Slack / generic chat-platform identifiers. Snowflake IDs and + // channel/guild identifiers leak who the user is and which servers they + // are in; treat them as sensitive. (Issue #10, requested by TRaSH.) + /^(discord|slack|telegram|matrix|teams)[_\-.]/i, + /\b(guild|channel|server|workspace|tenant|application|bot|client)[_\-.]?id$/i, ] export const DEFAULT_SAFE_KEYS: ReadonlySet = new Set([ @@ -26,6 +40,35 @@ export const EMAIL_PATTERN = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/ export const HOME_DIR_PATTERN = /^(\/home\/[^/]+|~|\/root)\// +// Value-side patterns: trigger redaction even when the key looks innocent. +// Catches credentials embedded in URLs and provider-specific token formats. +// pragma: allowlist secret +export const SENSITIVE_VALUE_PATTERNS: readonly RegExp[] = [ + // Basic-auth credentials embedded in any URL (scheme then user:pass@host). + /[a-z][a-z0-9+\-.]{1,20}:\/\/[^\s/@:]{1,200}:[^\s/@]{1,200}@/i, // pragma: allowlist secret + // GitHub classic PATs: ghp_, gho_, ghu_, ghs_, ghr_ + /\bgh[pousr]_[A-Za-z0-9]{30,}\b/, + // GitHub fine-grained PATs: github_pat_ + /\bgithub_pat_[A-Za-z0-9_]{60,}\b/, + // AWS access key IDs (AKIA, ASIA, AROA, AIPA, AGPA, AIDA prefixes) + /\b(?:AKIA|ASIA|AROA|AIPA|AGPA|AIDA)[A-Z0-9]{16}\b/, + // Tailscale auth keys + /\btskey-[a-z]+-[A-Za-z0-9-]+\b/, + // Discord webhook URLs + /https:\/\/(?:discord(?:app)?\.com|ptb\.discord\.com|canary\.discord\.com)\/api\/webhooks\/\d+\/[\w-]+/i, + // Slack incoming webhooks + /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/, + // JWT (three base64url segments separated by dots, "ey…"-prefixed first segment) + /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/, +] + +// Strip a trailing _FILE suffix (Docker-secrets convention) so that keys like +// DATABASE_URL_FILE or POSTGRES_PASSWORD_FILE match the same patterns as their +// non-_FILE counterparts. +function stripFileSuffix(key: string): string { + return key.replace(/_FILE$/i, '') +} + export function isSensitiveKey( key: string, sensitivePatterns?: readonly RegExp[], @@ -33,14 +76,19 @@ export function isSensitiveKey( ): boolean { const safe = safeKeys ?? DEFAULT_SAFE_KEYS const sensitive = sensitivePatterns ?? DEFAULT_SENSITIVE_PATTERNS - if (safe.has(key.toUpperCase())) return false - return sensitive.some(p => p.test(key)) + const stripped = stripFileSuffix(key) + if (safe.has(stripped.toUpperCase())) return false + return sensitive.some(p => p.test(stripped)) } export function containsEmail(value: string): boolean { return EMAIL_PATTERN.test(value) } +export function containsSensitiveValue(value: string): boolean { + return SENSITIVE_VALUE_PATTERNS.some(p => p.test(value)) +} + export function anonymizeHomePath(volumeStr: string): string { return volumeStr.replace(HOME_DIR_PATTERN, '~/') } diff --git a/src/redact.ts b/src/redact.ts index 29a09a1..cb56a34 100644 --- a/src/redact.ts +++ b/src/redact.ts @@ -1,5 +1,5 @@ import { load, dump } from 'js-yaml' -import { isRecord, isSensitiveKey, containsEmail, anonymizeHomePath } from './patterns' +import { anonymizeHomePath, containsEmail, containsSensitiveValue, isRecord, isSensitiveKey } from './patterns' const REDACTED = '**REDACTED**' @@ -35,6 +35,10 @@ function redactEnvDict( stats.redactedEnvVars++ stats.redactedKeys.push(key) } + } else if (containsSensitiveValue(strValue)) { + result[key] = REDACTED + stats.redactedEnvVars++ + stats.redactedKeys.push(key) } else if (containsEmail(strValue)) { result[key] = REDACTED stats.redactedEmails++ @@ -64,6 +68,11 @@ function redactEnvArray( stats.redactedKeys.push(key) return `${key}=${REDACTED}` } + if (containsSensitiveValue(value)) { + stats.redactedEnvVars++ + stats.redactedKeys.push(key) + return `${key}=${REDACTED}` + } if (containsEmail(value)) { stats.redactedEmails++ stats.redactedKeys.push(key) diff --git a/src/services.ts b/src/services.ts index 775a356..9991e3e 100644 --- a/src/services.ts +++ b/src/services.ts @@ -6,6 +6,14 @@ export interface NetworkInfo { readonly ipv4Address: string } +export interface UserGroupInfo { + readonly user: string // explicit user: directive (UID[:GID]) or empty + readonly puid: string // PUID env value or empty + readonly pgid: string // PGID env value or empty + readonly groupAdd: readonly string[] // group_add entries + readonly umask: string // UMASK env value or empty +} + export interface ServiceInfo { readonly name: string readonly image: string @@ -14,6 +22,7 @@ export interface ServiceInfo { readonly networks: readonly NetworkInfo[] readonly environment: ReadonlyMap readonly extras: ReadonlyMap + readonly userGroup: UserGroupInfo } function normalizePort(entry: unknown): string { @@ -109,9 +118,67 @@ function formatResourceLimits(resources: Record): string { return parts.join('; ') } -function extractExtras(service: Record): ReadonlyMap { +// Linuxserver env conventions are uppercase, but we look up case-insensitively +// so a typo'd `Puid` or `pgid` still surfaces in the User/Group comparison. +function envLookupCI(env: ReadonlyMap, name: string): string { + const direct = env.get(name) + if (direct !== undefined) return direct.trim() + const upper = name.toUpperCase() + for (const [k, v] of env) { + if (k.toUpperCase() === upper) return v.trim() + } + return '' +} + +// Compose accepts user as either a quoted string ("1000:1000") or a bare YAML +// scalar (1000). js-yaml parses the bare form to a number, so coerce both. +function readUserDirective(service: Record): string { + const v = service['user'] + if (typeof v === 'string') return v.trim() + if (typeof v === 'number') return String(v) + return '' +} + +function extractUserGroup(service: Record, env: ReadonlyMap): UserGroupInfo { + const groupAddRaw = service['group_add'] + const groupAdd = Array.isArray(groupAddRaw) ? groupAddRaw.map(String) : [] + return { + user: readUserDirective(service), + puid: envLookupCI(env, 'PUID'), + pgid: envLookupCI(env, 'PGID'), + groupAdd, + umask: envLookupCI(env, 'UMASK'), + } +} + +function deriveUser(service: Record, env: ReadonlyMap): string { + const directive = readUserDirective(service) + const puid = envLookupCI(env, 'PUID') + const pgid = envLookupCI(env, 'PGID') + + // Prefer the explicit user: directive (it takes effect at runtime; PUID/PGID + // are linuxserver convention only). + if (directive && (puid || pgid)) { + const envPart = puid && pgid ? `PUID=${puid} PGID=${pgid}` : puid ? `PUID=${puid}` : `PGID=${pgid}` + // If directive matches PUID:PGID, surface a single value. + if (directive === `${puid}:${pgid}`) return directive + return `${directive} (${envPart})` + } + if (directive) return directive + if (puid && pgid) return `${puid}:${pgid}` + if (puid) return `PUID=${puid}` + if (pgid) return `PGID=${pgid}` + return '' +} + +function extractExtras(service: Record, env: ReadonlyMap): ReadonlyMap { const extras = new Map() + const userField = deriveUser(service, env) + if (userField) { + extras.set('user', userField) + } + const simpleKeys = ['restart', 'hostname', 'container_name'] as const for (const key of simpleKeys) { const value = service[key] @@ -142,14 +209,16 @@ function extractExtras(service: Record): ReadonlyMap): ServiceInfo { + const environment = extractEnvironment(service['environment']) return { name, image: typeof service['image'] === 'string' ? service['image'] : '', ports: normalizePorts(service['ports']), volumes: normalizeVolumes(service['volumes']), networks: extractNetworks(service['networks']), - environment: extractEnvironment(service['environment']), - extras: extractExtras(service), + environment, + extras: extractExtras(service, environment), + userGroup: extractUserGroup(service, environment), } } diff --git a/src/volume-table.ts b/src/volume-table.ts index 1d8fea6..d186a7a 100644 --- a/src/volume-table.ts +++ b/src/volume-table.ts @@ -1,6 +1,72 @@ import { el } from './dom' import { buildVolumeMatrix, type VolumeMapping } from './volume-utils' -import type { ServiceInfo } from './services' +import type { ServiceInfo, UserGroupInfo } from './services' + +const EM_DASH = '—' + +interface UserGroupRow { + readonly label: string + readonly cells: readonly string[] +} + +function userGroupRows(services: readonly ServiceInfo[]): readonly UserGroupRow[] { + const cell = (svc: ServiceInfo, fn: (ug: UserGroupInfo) => string): string => { + const v = fn(svc.userGroup) + return v === '' ? EM_DASH : v + } + const rows: UserGroupRow[] = [ + { label: 'user:', cells: services.map(s => cell(s, ug => ug.user)) }, + { label: 'PUID', cells: services.map(s => cell(s, ug => ug.puid)) }, + { label: 'PGID', cells: services.map(s => cell(s, ug => ug.pgid)) }, + { label: 'group_add', cells: services.map(s => cell(s, ug => ug.groupAdd.join(', '))) }, + { label: 'UMASK', cells: services.map(s => cell(s, ug => ug.umask)) }, + ] + // Hide rows where every service has em-dash (nothing to compare). + return rows.filter(r => r.cells.some(c => c !== EM_DASH)) +} + +export function renderUserGroupTable(services: readonly ServiceInfo[]): HTMLElement { + const wrap = el('div', { className: 'volume-table-wrap' }) + if (services.length === 0) return wrap + + const rows = userGroupRows(services) + if (rows.length === 0) return wrap + + const table = el('table', { className: 'volume-table' }) + + const thead = el('thead') + const headerRow = el('tr') + const propTh = el('th') + propTh.textContent = 'User / Group' + headerRow.appendChild(propTh) + for (const svc of services) { + const th = el('th') + th.textContent = svc.name + headerRow.appendChild(th) + } + thead.appendChild(headerRow) + table.appendChild(thead) + + const tbody = el('tbody') + for (const row of rows) { + const tr = el('tr') + const labelTd = el('td') + labelTd.textContent = row.label + labelTd.style.fontWeight = '600' + tr.appendChild(labelTd) + for (const value of row.cells) { + const td = el('td') + td.textContent = value + if (value === EM_DASH) td.className = 'vol-empty' + tr.appendChild(td) + } + tbody.appendChild(tr) + } + table.appendChild(tbody) + + wrap.appendChild(table) + return wrap +} function formatCell(mapping: VolumeMapping): string { return mapping.mode ? `${mapping.target} (${mapping.mode})` : mapping.target diff --git a/tests/cards.test.ts b/tests/cards.test.ts index c697eb4..0852577 100644 --- a/tests/cards.test.ts +++ b/tests/cards.test.ts @@ -14,6 +14,7 @@ function makeService(overrides: Partial & { name: string }): Servic networks: [], environment: new Map(), extras: new Map(), + userGroup: { user: '', puid: '', pgid: '', groupAdd: [], umask: '' }, ...overrides, } } diff --git a/tests/extract.test.ts b/tests/extract.test.ts index 404ae5d..1a144ae 100644 --- a/tests/extract.test.ts +++ b/tests/extract.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { extractYaml } from '../src/extract' +import { extractYaml, normalizeEncodedInput } from '../src/extract' describe('extractYaml', () => { it('returns pure YAML as-is', () => { @@ -86,4 +86,66 @@ describe('extractYaml', () => { expect(result.yaml).toContain('services:') expect(result.error).toBeNull() }) + + it('decodes common HTML entities in pasted input', () => { + const input = + 'services:\n app:\n image: nginx\n environment:\n - FOO="bar"\n - BAZ=a&b\n' + const result = extractYaml(input) + expect(result.error).toBeNull() + expect(result.yaml).toContain('FOO="bar"') + expect(result.yaml).toContain('BAZ=a&b') + }) + + it('decodes percent-encoded paths in pasted input', () => { + const input = + 'services:\n app:\n image: nginx\n volumes:\n - /mnt/My%20Files:/data\n - /opt/foo%2Fbar:/x\n' + const result = extractYaml(input) + expect(result.error).toBeNull() + expect(result.yaml).toContain('/mnt/My Files:/data') + expect(result.yaml).toContain('/opt/foo/bar:/x') + }) + + it('leaves a single literal % alone (not a misread encoding)', () => { + const input = 'services:\n app:\n image: nginx\n environment:\n - GREET=hi 100% done\n' + const result = extractYaml(input) + expect(result.error).toBeNull() + expect(result.yaml).toContain('100% done') + }) +}) + +describe('normalizeEncodedInput', () => { + it('passes plain text through unchanged', () => { + expect(normalizeEncodedInput('plain text')).toBe('plain text') + }) + + it('decodes named HTML entities', () => { + expect(normalizeEncodedInput('a & b < c')).toBe('a & b < c') + }) + + it('decodes numeric HTML entities', () => { + expect(normalizeEncodedInput('"quote"')).toBe('"quote"') + }) + + it('decodes hex HTML entities', () => { + expect(normalizeEncodedInput('"quote"')).toBe('"quote"') + }) + + it('decodes multiple percent sequences together', () => { + expect(normalizeEncodedInput('/path/with%20spaces/and%2Fslashes')).toBe('/path/with spaces/and/slashes') + }) + + it('leaves a lone % sign alone', () => { + // Only one %-sequence and the test is conservative — keep literal. + expect(normalizeEncodedInput('battery 100% full')).toBe('battery 100% full') + }) + + it('handles malformed percent sequences gracefully', () => { + // %ZZ is not valid hex; should be left as-is rather than throwing. + expect(() => normalizeEncodedInput('value with %ZZ and %20 here %2F')).not.toThrow() + }) + + it('handles mixed HTML and percent encoding', () => { + expect(normalizeEncodedInput('foo & /a%20b')).toBe('foo & /a%20b') // percent stays — only one %20 + expect(normalizeEncodedInput('foo & /a%20b/c%20d')).toBe('foo & /a b/c d') // two %20 → decoded + }) }) diff --git a/tests/markdown.test.ts b/tests/markdown.test.ts index 38fedb8..771ca20 100644 --- a/tests/markdown.test.ts +++ b/tests/markdown.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest' -import { generateMarkdownTable, generateVolumeComparisonMarkdown } from '../src/markdown' +import { + buildCombinedMarkdown, + formatForDiscord, + formatForGitHub, + generateMarkdownTable, + generateVolumeComparisonMarkdown, +} from '../src/markdown' import type { ServiceInfo, NetworkInfo } from '../src/services' function net(name: string, opts?: { aliases?: string[]; ipv4Address?: string }): NetworkInfo { @@ -14,6 +20,7 @@ function makeService(overrides: Partial & { name: string }): Servic networks: [], environment: new Map(), extras: new Map(), + userGroup: { user: '', puid: '', pgid: '', groupAdd: [], umask: '' }, ...overrides, } } @@ -186,3 +193,100 @@ describe('generateVolumeComparisonMarkdown', () => { expect(result).toContain('/a\\|b') }) }) + +describe('formatForGitHub', () => { + it('returns empty string when no services', () => { + expect(formatForGitHub(buildCombinedMarkdown([]))).toBe('') + }) + + it('renders headings as ### and bare markdown tables', () => { + const services = [ + makeService({ name: 'app', image: 'nginx', volumes: ['/data:/data'] }), + ] + const result = formatForGitHub(buildCombinedMarkdown(services)) + expect(result).toMatch(/^### Services\n\n\| Service \|/) + expect(result).toContain('### Volume Comparison') + expect(result).not.toContain('```') + }) + + it('omits a section when its source table is empty', () => { + const services = [makeService({ name: 'app', image: 'nginx' })] // no volumes + const result = formatForGitHub(buildCombinedMarkdown(services)) + expect(result).toContain('### Services') + expect(result).not.toContain('### Volume Comparison') + }) + + it('includes User / Group section when userGroup data is present', () => { + const services = [ + makeService({ + name: 'app', + image: 'nginx', + userGroup: { user: '1000:1000', puid: '1000', pgid: '1000', groupAdd: ['video'], umask: '022' }, + }), + ] + const result = formatForGitHub(buildCombinedMarkdown(services)) + expect(result).toContain('### User / Group') + expect(result).toContain('| User / Group | app |') + expect(result).toContain('| user: | 1000:1000 |') + expect(result).toContain('| PUID | 1000 |') + expect(result).toContain('| group_add | video |') + expect(result).toContain('| UMASK | 022 |') + }) +}) + +describe('formatForDiscord', () => { + it('returns empty string when no services', () => { + expect(formatForDiscord(buildCombinedMarkdown([]))).toBe('') + }) + + it('wraps each table in a fenced code block', () => { + const services = [ + makeService({ name: 'app', image: 'nginx', volumes: ['/data:/data'] }), + ] + const result = formatForDiscord(buildCombinedMarkdown(services)) + // fenced blocks open and close on their own lines + expect(result).toContain('**Services**\n```\n') + expect(result).toContain('\n```') + expect(result).toContain('**Volume Comparison**\n```\n') + // exactly two opening fences (services + volume) and two closing fences + const fences = (result.match(/```/g) ?? []).length + expect(fences).toBe(4) + }) + + it('uses bold labels not ### so old Discord clients render', () => { + const services = [makeService({ name: 'app', image: 'nginx' })] + const result = formatForDiscord(buildCombinedMarkdown(services)) + expect(result).not.toContain('### ') + expect(result).toContain('**Services**') + }) + + it('preserves the raw pipe-table content inside the fences', () => { + const services = [makeService({ name: 'app', image: 'nginx' })] + const result = formatForDiscord(buildCombinedMarkdown(services)) + expect(result).toContain('| Service | Image |') + expect(result).toContain('| app | nginx |') + }) + + it('omits a section when its source table is empty', () => { + const services = [makeService({ name: 'app', image: 'nginx' })] // no volumes + const result = formatForDiscord(buildCombinedMarkdown(services)) + expect(result).toContain('**Services**') + expect(result).not.toContain('**Volume Comparison**') + }) + + it('includes User / Group section wrapped in fenced code', () => { + const services = [ + makeService({ + name: 'app', + image: 'nginx', + userGroup: { user: '1000:1000', puid: '', pgid: '', groupAdd: [], umask: '' }, + }), + ] + const result = formatForDiscord(buildCombinedMarkdown(services)) + expect(result).toContain('**User / Group**\n```\n') + expect(result).toContain('| user: | 1000:1000 |') + // Three sections expected: Services + User/Group (volumes omitted, no volumes data) + const fences = (result.match(/```/g) ?? []).length + expect(fences).toBe(4) + }) +}) diff --git a/tests/patterns.test.ts b/tests/patterns.test.ts index 64a9116..dcc21f4 100644 --- a/tests/patterns.test.ts +++ b/tests/patterns.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { isSensitiveKey, containsEmail, anonymizeHomePath } from '../src/patterns' +import { anonymizeHomePath, containsEmail, containsSensitiveValue, isSensitiveKey } from '../src/patterns' describe('isSensitiveKey', () => { it.each([ @@ -49,6 +49,97 @@ describe('isSensitiveKey', () => { const safeKeys = new Set(['AUTH_TOKEN']) expect(isSensitiveKey('AUTH_TOKEN', undefined, safeKeys)).toBe(false) }) + + it.each([ + ['DATABASE_URL', true], + ['REDIS_URL', true], + ['MONGO_URI', true], + ['POSTGRES_DSN', true], + ['CELERY_BROKER_URL', true], + ['DB_CONNECTION_STRING', true], + ['AWS_ACCESS_KEY_ID', true], + ['AWS_SECRET_ACCESS_KEY', true], + ['TAILSCALE_AUTHKEY', true], + ['TAILSCALE_AUTH_KEY', true], + ['DISCORD_WEBHOOK', true], + ['GH_TOKEN', true], + ['GITHUB_PAT', true], + ])('catches connection-string / vendor-key conventions: %s', (key, expected) => { + expect(isSensitiveKey(key)).toBe(expected) + }) + + it('strips _FILE suffix before matching (Docker secrets)', () => { + expect(isSensitiveKey('POSTGRES_PASSWORD_FILE')).toBe(true) + expect(isSensitiveKey('DATABASE_URL_FILE')).toBe(true) + expect(isSensitiveKey('PUID_FILE')).toBe(false) + }) + + // Issue #10 (TRaSH): chat-platform IDs leak who/where you are. + it.each([ + ['GUILD_ID', true], + ['DISCORD_GUILD_ID', true], + ['DISCORD_CHANNEL_ID', true], + ['SLACK_WORKSPACE_ID', true], + ['DISCORD_BOT_TOKEN', true], + ['SLACK_TOKEN', true], + ['DISCORD_APPLICATION_ID', true], + ['BOT_ID', true], + ['DISCORD_USER_ID', true], + ])('catches chat-platform identifiers: %s', (key, expected) => { + expect(isSensitiveKey(key)).toBe(expected) + }) + + it('does not over-match bare ID-suffixed keys that are not chat platforms', () => { + expect(isSensitiveKey('CONTAINER_ID')).toBe(false) + expect(isSensitiveKey('IMAGE_ID')).toBe(false) + expect(isSensitiveKey('USER_ID')).toBe(false) + expect(isSensitiveKey('PROCESS_ID')).toBe(false) + }) +}) + +describe('containsSensitiveValue', () => { + it('detects basic-auth in URLs', () => { + expect(containsSensitiveValue('postgres://user:hunter2@db.example.com:5432/app')).toBe(true) // pragma: allowlist secret + expect(containsSensitiveValue('mongodb://admin:s3cret@mongo:27017/?authSource=admin')).toBe(true) // pragma: allowlist secret + expect(containsSensitiveValue('https://service:p@ss@example.com')).toBe(true) // pragma: allowlist secret + }) + + it('detects GitHub PATs', () => { + expect(containsSensitiveValue('ghp_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789')).toBe(true) // pragma: allowlist secret + expect(containsSensitiveValue('gho_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789')).toBe(true) // pragma: allowlist secret + expect(containsSensitiveValue('GHP_AbCdEf')).toBe(false) // too short + }) + + it('detects AWS access key IDs', () => { + expect(containsSensitiveValue('AKIAIOSFODNN7EXAMPLE')).toBe(true) // pragma: allowlist secret + expect(containsSensitiveValue('ASIATESTKEYABCDEFGHI')).toBe(true) // pragma: allowlist secret + }) + + it('detects Tailscale auth keys', () => { + expect(containsSensitiveValue('tskey-auth-kAbCd1EfG2-XyZAbcDef123456789')).toBe(true) // pragma: allowlist secret + }) + + it('detects Discord webhooks', () => { + // pragma: allowlist nextline secret + expect(containsSensitiveValue('https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKl_MnOpQrSt-uVwXyZ')).toBe(true) + }) + + it('detects Slack webhooks', () => { + // pragma: allowlist nextline secret + expect(containsSensitiveValue('https://hooks.slack.com/services/T01ABCDEFGH/B01ABCDEFGH/abcdEFGHijklMNOP1234')).toBe(true) + }) + + it('detects JWT-like tokens', () => { + // pragma: allowlist nextline secret + expect(containsSensitiveValue('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c')).toBe(true) + }) + + it('does not flag normal values', () => { + expect(containsSensitiveValue('linuxserver/sonarr:latest')).toBe(false) + expect(containsSensitiveValue('America/Chicago')).toBe(false) + expect(containsSensitiveValue('1000:1000')).toBe(false) + expect(containsSensitiveValue('https://media.example.com/')).toBe(false) + }) }) describe('containsEmail', () => { diff --git a/tests/services.test.ts b/tests/services.test.ts index 026b123..2c83a7a 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -378,6 +378,147 @@ describe('parseServices', () => { const result = parseServices(compose) expect(result[0].extras).toEqual(new Map()) }) + + it('derives user from explicit user: directive only', () => { + const compose = { + services: { app: { image: 'nginx', user: '1000:1000' } }, + } + const result = parseServices(compose) + expect(result[0].extras.get('user')).toBe('1000:1000') + }) + + it('derives user from PUID/PGID env when no directive', () => { + const compose = { + services: { + app: { + image: 'lscr.io/linuxserver/sonarr', + environment: { PUID: '1000', PGID: '1000' }, + }, + }, + } + const result = parseServices(compose) + expect(result[0].extras.get('user')).toBe('1000:1000') + }) + + it('collapses to single value when user: matches PUID:PGID', () => { + const compose = { + services: { + app: { + image: 'app', + user: '1000:1000', + environment: { PUID: '1000', PGID: '1000' }, + }, + }, + } + const result = parseServices(compose) + expect(result[0].extras.get('user')).toBe('1000:1000') + }) + + it('shows directive + env annotation when they differ', () => { + const compose = { + services: { + app: { + image: 'app', + user: '0:0', + environment: { PUID: '1000', PGID: '1000' }, + }, + }, + } + const result = parseServices(compose) + expect(result[0].extras.get('user')).toBe('0:0 (PUID=1000 PGID=1000)') + }) + + it('handles PUID alone', () => { + const compose = { + services: { app: { image: 'app', environment: { PUID: '1000' } } }, + } + const result = parseServices(compose) + expect(result[0].extras.get('user')).toBe('PUID=1000') + }) + + it('omits user extra when no user info present', () => { + const compose = { + services: { app: { image: 'app' } }, + } + const result = parseServices(compose) + expect(result[0].extras.has('user')).toBe(false) + }) + + it('orders user before other extras', () => { + const compose = { + services: { + app: { + image: 'app', + user: '1000:1000', + restart: 'unless-stopped', + hostname: 'myhost', + }, + }, + } + const result = parseServices(compose) + const keys = Array.from(result[0].extras.keys()) + expect(keys[0]).toBe('user') + }) + + it('looks up PUID/PGID/UMASK case-insensitively', () => { + const compose = { + services: { + app: { + image: 'app', + environment: { puid: '1000', Pgid: '1000', umask: '022' }, + }, + }, + } + const result = parseServices(compose) + expect(result[0].userGroup.puid).toBe('1000') + expect(result[0].userGroup.pgid).toBe('1000') + expect(result[0].userGroup.umask).toBe('022') + expect(result[0].extras.get('user')).toBe('1000:1000') + }) + }) + + describe('userGroup extraction', () => { + it('captures explicit user, PUID, PGID, group_add, UMASK', () => { + const compose = { + services: { + app: { + image: 'app', + user: '1000:1001', + group_add: ['video', 'render'], + environment: { PUID: '1000', PGID: '1001', UMASK: '002' }, + }, + }, + } + const result = parseServices(compose) + expect(result[0].userGroup).toEqual({ + user: '1000:1001', + puid: '1000', + pgid: '1001', + groupAdd: ['video', 'render'], + umask: '002', + }) + }) + + it('returns empty values when nothing is set', () => { + const compose = { services: { app: { image: 'app' } } } + const result = parseServices(compose) + expect(result[0].userGroup).toEqual({ + user: '', + puid: '', + pgid: '', + groupAdd: [], + umask: '', + }) + }) + + it('coerces numeric user: scalars (unquoted YAML) to string', () => { + // Unquoted `user: 1000` parses as a number; the directive should still + // surface in the user field rather than being silently dropped. + const compose = { services: { app: { image: 'app', user: 1000 } } } + const result = parseServices(compose) + expect(result[0].userGroup.user).toBe('1000') + expect(result[0].extras.get('user')).toBe('1000') + }) }) // Immutability diff --git a/tests/volume-table.test.ts b/tests/volume-table.test.ts index 6542dd0..d0f1c23 100644 --- a/tests/volume-table.test.ts +++ b/tests/volume-table.test.ts @@ -14,6 +14,7 @@ function makeService(overrides: Partial & { name: string }): Servic networks: [], environment: new Map(), extras: new Map(), + userGroup: { user: '', puid: '', pgid: '', groupAdd: [], umask: '' }, ...overrides, } }