Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -1,32 +1,46 @@
---
version: 2
updates:
- package-ecosystem: github-actions
directory: /
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]
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
push:
branches: [main]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/dependabot-automerge.yml
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 12 additions & 0 deletions .github/workflows/prerelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 39 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <UID>:<GID>` 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://<user>:<pw>@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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
53 changes: 49 additions & 4 deletions src/clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,55 @@
export async function copyToClipboard(text: string): Promise<boolean> {
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<boolean> {
if (isSecureClipboardAvailable()) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// fall through to legacy path
}
}
return legacyCopy(text)
}

export function openPrivateBin(): void {
Expand Down
9 changes: 9 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
47 changes: 46 additions & 1 deletion src/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (&amp;), decimal
// (&#34;), and hex (&#x22;) 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)/
Expand Down Expand Up @@ -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.' }
}
Expand Down
Loading
Loading