From ff115a147ce982b852f81d0c6149cc305c974b6b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 13:25:07 +0000 Subject: [PATCH] fix: normalize safe keys and strip empty dict-style env values - Uppercase safe keys in compileConfig so Advanced Settings safe keys work regardless of input casing (matches isSensitiveKey lookup) - Strip empty values from dict-style environment blocks in stripNoise, matching existing array-style behavior - Remove unused openPrivateBin/openGist imports from main.ts - Add regression tests for both fixes --- .github/dependabot.yml | 46 ++++ .github/workflows/ci.yml | 38 +++- .github/workflows/dependabot-automerge.yml | 33 +++ .github/workflows/deploy.yml | 7 +- .github/workflows/pre-commit.yml | 18 ++ .github/workflows/prerelease.yml | 52 +++-- .github/workflows/release.yml | 18 +- .github/workflows/stable-release.yml | 32 ++- .gitleaks.toml | 13 ++ .pre-commit-config.yaml | 42 ++++ .secrets.baseline | 134 +++++++++++ .vscode/extensions.json | 8 + .yamllint.yml | 10 + CLAUDE.md | 2 +- README.md | 77 +++++-- package-lock.json | 225 +++++++++++-------- package.json | 10 +- renovate.json | 35 ++- src/clipboard.ts | 53 ++++- src/config.ts | 11 +- src/extract.ts | 47 +++- src/main.ts | 249 ++++++++++++++------- src/markdown.ts | 64 ++++++ src/noise.ts | 5 +- src/patterns.ts | 52 ++++- src/redact.ts | 30 ++- src/services.ts | 75 ++++++- src/volume-table.ts | 68 +++++- tests/cards.test.ts | 1 + tests/config.test.ts | 11 + tests/extract.test.ts | 64 +++++- tests/markdown.test.ts | 106 ++++++++- tests/noise.test.ts | 23 +- tests/patterns.test.ts | 93 +++++++- tests/redact.test.ts | 53 +++++ tests/services.test.ts | 141 ++++++++++++ tests/volume-table.test.ts | 1 + 37 files changed, 1670 insertions(+), 277 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/dependabot-automerge.yml create mode 100644 .github/workflows/pre-commit.yml create mode 100644 .gitleaks.toml create mode 100644 .pre-commit-config.yaml create mode 100644 .secrets.baseline create mode 100644 .vscode/extensions.json create mode 100644 .yamllint.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5057782 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,46 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 10 + commit-message: + prefix: ci + groups: + github-actions: + patterns: ["*"] + labels: + - dependencies + - github-actions + + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 10 + commit-message: + prefix: chore + prefix-development: chore + include: scope + groups: + # 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 a268112..d31aa05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,15 +6,49 @@ on: push: branches: [main] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: 22.22.0 + node-version: 24.14.0 cache: npm - run: npm ci - run: npx tsc --noEmit - run: npx vitest run --coverage --passWithNoTests + + security: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: 24.14.0 + cache: npm + + - run: npm ci + + # Dependency audit — fail on high/critical vulnerabilities + - name: npm audit + run: npm audit --audit-level=high + + # Build output size gate — single-file HTML should stay under 150 KB + - name: Build and check output size + run: | + npm run build + size=$(stat -c%s dist/index.html) + echo "Build output: ${size} bytes" + if [ "$size" -gt 153600 ]; then + echo "::error::Build output exceeds 150 KB (${size} bytes) — unexpected bloat" + exit 1 + fi 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/deploy.yml b/.github/workflows/deploy.yml index e3ad3c9..57eccad 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -16,19 +16,20 @@ concurrency: jobs: deploy: runs-on: ubuntu-latest + timeout-minutes: 10 environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: 22.22.0 + node-version: 24.14.0 cache: npm - run: npm ci - run: npm run build - uses: actions/configure-pages@v5 - - uses: actions/upload-pages-artifact@v3 + - uses: actions/upload-pages-artifact@v4 with: path: dist - id: deployment diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..99069e8 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,18 @@ +name: Pre-commit + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 1c29084..30b8106 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 @@ -10,13 +22,14 @@ permissions: jobs: prerelease: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: 22.22.0 + node-version: 24.14.0 cache: npm - name: Get current version @@ -29,31 +42,44 @@ jobs: id: prerelease env: BASE_VERSION: ${{ steps.version.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} run: | - LATEST=$(git tag -l "v${BASE_VERSION}-pre.*" --sort=-version:refname | head -n1) + # Derive the next number from existing RELEASES (incl. drafts + already + # published immutable releases), NOT git tags. Under immutable releases a + # pre-release tag can be permanently reserved in the release ledger while + # never materializing as a git ref, so git-tag-based numbering collides on + # the same number every run. The releases list always reflects burned tags. + LATEST=$(gh api "repos/${REPO}/releases" --paginate \ + --jq ".[].tag_name | select(startswith(\"v${BASE_VERSION}-pre.\"))" \ + | sed "s|^v${BASE_VERSION}-pre.||" | sort -n | tail -n1) if [ -z "$LATEST" ]; then NUM=1 else - NUM=$(echo "$LATEST" | sed "s/v${BASE_VERSION}-pre\.\([0-9]*\)/\1/") - NUM=$((NUM + 1)) + NUM=$((LATEST + 1)) fi TAG="v${BASE_VERSION}-pre.${NUM}" echo "tag=$TAG" >> "$GITHUB_OUTPUT" - run: npm ci - run: npm run build - - run: cp dist/index.html compose-sanitizer.html + - run: cp dist/index.html docker-compose-debugger.html - - name: Create pre-release tag - env: - TAG: ${{ steps.prerelease.outputs.tag }} - run: | - git tag "$TAG" - git push origin "$TAG" + # The repo has immutable releases enabled. softprops/action-gh-release + # creates and publishes in one step, which means the asset upload races + # against the immutable lockdown and fails. Create as draft, upload the + # asset, then publish in a follow-up step. - uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.prerelease.outputs.tag }} - files: compose-sanitizer.html + files: docker-compose-debugger.html generate_release_notes: true prerelease: true + draft: true + + - name: Publish pre-release + env: + TAG: ${{ steps.prerelease.outputs.tag }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release edit "$TAG" --repo "${{ github.repository }}" --draft=false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36d98a8..728b4d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,17 +10,27 @@ permissions: jobs: release: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: 22.22.0 + node-version: 24.14.0 cache: npm - run: npm ci - run: npm run build - - run: cp dist/index.html compose-sanitizer.html + - run: cp dist/index.html docker-compose-debugger.html + # Immutable releases require draft-first so assets can be attached + # before the release is locked. - uses: softprops/action-gh-release@v2 with: - files: compose-sanitizer.html + files: docker-compose-debugger.html generate_release_notes: true prerelease: ${{ contains(github.ref, '-') }} + draft: true + + - name: Publish release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.ref_name }} + run: gh release edit "$TAG" --repo "${{ github.repository }}" --draft=false diff --git a/.github/workflows/stable-release.yml b/.github/workflows/stable-release.yml index 33c9029..f82d06b 100644 --- a/.github/workflows/stable-release.yml +++ b/.github/workflows/stable-release.yml @@ -14,11 +14,12 @@ permissions: jobs: release: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: 22.22.0 + node-version: 24.14.0 cache: npm - name: Validate version format @@ -42,13 +43,16 @@ jobs: - name: Update package.json version env: VERSION: ${{ inputs.version }} - run: npm version "$VERSION" --no-git-tag-version + # `npm version` errors when the requested version equals the current + # (which happens when the version was bumped in a PR before the + # release workflow runs). `npm pkg set` is idempotent. + run: npm pkg set "version=$VERSION" - run: npm ci - run: npx tsc --noEmit - run: npx vitest run --passWithNoTests - run: npm run build - - run: cp dist/index.html compose-sanitizer.html + - run: cp dist/index.html docker-compose-debugger.html - name: Commit version bump and tag env: @@ -57,13 +61,29 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add package.json package-lock.json - git commit -m "chore: release v${VERSION}" + # If the version was already bumped in a PR (so package.json/lock + # are unchanged here) skip the commit. The tag and push still need + # to run. + if ! git diff --cached --quiet; then + git commit -m "chore: release v${VERSION}" + else + echo "package.json already at v${VERSION}; skipping bump commit" + fi git tag "v${VERSION}" git push origin main --follow-tags + # Immutable releases require draft-first so assets can be attached + # before the release is locked. - uses: softprops/action-gh-release@v2 with: tag_name: v${{ inputs.version }} - files: compose-sanitizer.html + files: docker-compose-debugger.html generate_release_notes: true prerelease: false + draft: true + + - name: Publish release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: v${{ inputs.version }} + run: gh release edit "$TAG" --repo "${{ github.repository }}" --draft=false diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..f3024cb --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,13 @@ +# Gitleaks configuration +# https://github.com/gitleaks/gitleaks + +title = "docker-compose-debugger gitleaks config" + +[allowlist] + description = "Global allowlist" + paths = [ + '''node_modules/''', + '''dist/''', + '''coverage/''', + '''package-lock\.json''', + ] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d4dafbe --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,42 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-yaml + - id: check-json + name: check json + files: \.json$ + - id: check-merge-conflict + name: check for merge conflicts + - id: end-of-file-fixer + name: fix end of files + - id: trailing-whitespace + name: trim trailing whitespace + - id: no-commit-to-branch + args: ['--branch', 'master'] + - id: check-added-large-files + name: check for added large files + - id: detect-private-key + name: detect private key + - id: mixed-line-ending + name: mixed line ending + args: ['--fix=lf'] + + - repo: https://github.com/rhysd/actionlint + rev: v1.7.7 + hooks: + - id: actionlint + + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + name: detect secrets + args: ['--baseline', '.secrets.baseline'] + exclude: 'package-lock\.json$' + + - repo: https://github.com/adrienverge/yamllint + rev: v1.35.1 + hooks: + - id: yamllint + args: [--config-file, .yamllint.yml] diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 0000000..4660e7f --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,134 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + }, + { + "path": "detect_secrets.filters.regex.should_exclude_file", + "pattern": [ + "package-lock\\.json$", + "node_modules/" + ] + } + ], + "results": {}, + "generated_at": "2026-03-07T02:37:05Z" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..e7ed6c3 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "eamodio.gitlens", + "editorconfig.editorconfig", + "esbenp.prettier-vscode" + ] +} diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..75173ca --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,10 @@ +extends: default + +rules: + line-length: + max: 200 + truthy: + check-keys: false + comments: + min-spaces-from-content: 1 + document-start: disable diff --git a/CLAUDE.md b/CLAUDE.md index 0d0465d..3f294d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# Compose Debugger +# Docker Compose Debugger ## Build npm install && npm run build diff --git a/README.md b/README.md index 29d5b19..797abd4 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,46 @@ -# Compose Debugger +# Docker Compose Debugger -Browser-based tool for parsing Docker Compose output into structured debugging views. Paste output from `docker-autocompose`, `docker compose config`, or raw `docker-compose.yml` — get sanitized YAML, per-service cards, and a markdown table ready for Discord or GitHub support channels. +Browser-based tool that turns messy Docker Compose output into clean, readable debugging views. Paste output from `docker-autocompose`, `docker compose config`, or raw `docker-compose.yml` — get sanitized YAML with sensitive values redacted, per-service cards, volume comparison tables, and a markdown table ready for Discord or GitHub support channels. -**Live:** [bakerboy448.github.io/compose-sanitizer](https://bakerboy448.github.io/compose-sanitizer/) +**Live:** [baker-scripts.github.io/docker-compose-debugger](https://baker-scripts.github.io/docker-compose-debugger/) ## 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 @@ -56,7 +73,7 @@ The Advanced Settings panel allows custom sensitive patterns (regex) and safe ke ## Self-Hosting -Download `compose-sanitizer.html` from the [latest release](https://github.com/bakerboy448/compose-sanitizer/releases/latest) and open it in any browser. Everything runs client-side in a single HTML file — no server, no network requests, no data leaves your browser. +Download `docker-compose-debugger.html` from the [latest release](https://github.com/baker-scripts/docker-compose-debugger/releases/latest) and open it in any browser. Everything runs client-side in a single HTML file — no server, no network requests, no data leaves your browser. ## Development @@ -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 @@ -101,6 +120,16 @@ npx vitest run --coverage # Run with coverage report - No analytics, tracking, or external requests - The "Open PrivateBin" and "Open GitHub Gist" buttons copy to clipboard and open a new tab — you paste manually +## Contributors + + + Contributors + + +## Disclaimer + +This tool is provided as-is with no warranty. While all processing happens client-side, always verify redaction output before sharing. The authors are not responsible for any accidentally exposed secrets. + ## License MIT diff --git a/package-lock.json b/package-lock.json index 8dd69f3..8b1ddc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "compose-sanitizer", - "version": "0.1.0", + "name": "docker-compose-debugger", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "compose-sanitizer", - "version": "0.1.0", + "name": "docker-compose-debugger", + "version": "0.2.0", "license": "MIT", "dependencies": { "js-yaml": "^4.1.1" @@ -1187,29 +1187,29 @@ "license": "MIT" }, "node_modules/@vitest/coverage-v8": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", - "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.0.18", - "ast-v8-to-istanbul": "^0.3.10", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", - "magicast": "^0.5.1", + "magicast": "^0.5.2", "obug": "^2.1.1", - "std-env": "^3.10.0", - "tinyrainbow": "^3.0.3" + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.0.18", - "vitest": "4.0.18" + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1218,31 +1218,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1251,7 +1251,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1263,26 +1263,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -1290,13 +1290,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1305,9 +1306,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -1315,14 +1316,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1355,9 +1357,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", - "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", "dev": true, "license": "MIT", "dependencies": { @@ -1399,6 +1401,13 @@ "node": ">=18" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/css-tree": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", @@ -1482,9 +1491,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -1718,9 +1727,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1853,9 +1872,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -1930,9 +1949,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -1943,9 +1962,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "dev": true, "funding": [ { @@ -2087,9 +2106,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -2148,9 +2167,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -2231,9 +2250,9 @@ } }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -2248,9 +2267,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, "license": "MIT", "dependencies": { @@ -2340,31 +2359,31 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -2380,12 +2399,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -2406,6 +2428,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -2414,6 +2442,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/package.json b/package.json index d1efe35..346fdc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "compose-sanitizer", - "version": "0.1.0", + "name": "docker-compose-debugger", + "version": "1.0.2", "description": "Browser-based Docker Compose debugger — redacts secrets, shows service cards, and generates markdown tables for support channels", "type": "module", "scripts": { @@ -13,7 +13,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/bakerboy448/compose-sanitizer.git" + "url": "git+https://github.com/baker-scripts/docker-compose-debugger.git" }, "keywords": [ "docker", @@ -25,9 +25,9 @@ "author": "bakerboy448", "license": "MIT", "bugs": { - "url": "https://github.com/bakerboy448/compose-sanitizer/issues" + "url": "https://github.com/baker-scripts/docker-compose-debugger/issues" }, - "homepage": "https://bakerboy448.github.io/compose-sanitizer/", + "homepage": "https://baker-scripts.github.io/docker-compose-debugger/", "dependencies": { "js-yaml": "^4.1.1" }, diff --git a/renovate.json b/renovate.json index 698b984..8915e40 100644 --- a/renovate.json +++ b/renovate.json @@ -1,23 +1,20 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended", - ":rebaseStalePrs", - ":semanticCommits", - ":automergeMinor", - ":automergeDigest" - ], - "labels": ["dependencies"], - "rangeStrategy": "bump", + "description": "Inlined house Renovate config — self-contained, public presets only.", + "extends": ["config:best-practices", ":dependencyDashboard", ":semanticCommits", ":enableVulnerabilityAlertsWithLabel(security)"], + "schedule": ["before 6am on sunday"], + "timezone": "America/Chicago", + "prHourlyLimit": 2, "prConcurrentLimit": 4, + "labels": ["dependencies", "renovate"], + "rangeStrategy": "bump", "separateMajorMinor": true, "separateMinorPatch": false, + "automerge": false, "platformAutomerge": false, "rebaseWhen": "behind-base-branch", + "branchPrefix": "renovate/", "ignoreDeps": [], + "vulnerabilityAlerts": {"labels": ["security"], "schedule": ["at any time"], "automerge": true}, "packageRules": [ - { - "matchUpdateTypes": ["major"], - "automerge": false - }, - { - "matchDepTypes": ["devDependencies"], - "automerge": true, - "automergeType": "pr" - } - ] + {"description": "Group GHA minor/patch/digest — automerge when CI green", "matchManagers": ["github-actions"], "matchUpdateTypes": ["minor","patch","digest","pin","pinDigest"], "groupName": "github-actions", "groupSlug": "github-actions", "semanticCommitType": "chore", "automerge": true}, + {"description": "GitHub Actions major — hold for review", "matchManagers": ["github-actions"], "matchUpdateTypes": ["major"], "automerge": false, "addLabels": ["review-required"]}, + {"description": "Any major — hold for review", "matchUpdateTypes": ["major"], "automerge": false, "addLabels": ["major","review-required"]} + ], + "lockFileMaintenance": {"enabled": true, "schedule": ["before 6am on sunday"], "automerge": true, "automergeType": "branch"}, + "recreateWhen": "auto" } 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..3ebfed5 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', @@ -52,7 +61,7 @@ export function compileConfig(config: SanitizerConfig): { } return { sensitivePatterns: compiled, - safeKeys: new Set(config.safeKeys), + safeKeys: new Set(config.safeKeys.map(k => k.toUpperCase())), } } 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 6d4d3a2..2289a0a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,13 +5,13 @@ import { redactCompose } from './redact' import { stripNoise } from './noise' import { detectAdvisories, type Advisory } from './advisories' import { loadConfig, saveConfig, resetConfig, compileConfig, type SanitizerConfig } from './config' -import { copyToClipboard, openPrivateBin, openGist } from './clipboard' +import { copyToClipboard } 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 @@ -19,10 +19,10 @@ function sanitize(raw: string, config: SanitizerConfig): { output: string | null parsed: Record | null error: string | null - stats: { redactedEnvVars: number; redactedEmails: number; anonymizedPaths: number } + stats: { redactedEnvVars: number; redactedEmails: number; anonymizedPaths: number; redactedKeys: readonly string[] } advisories: readonly Advisory[] } { - const emptyStats = { redactedEnvVars: 0, redactedEmails: 0, anonymizedPaths: 0 } + const emptyStats = { redactedEnvVars: 0, redactedEmails: 0, anonymizedPaths: 0, redactedKeys: [] as string[] } const extracted = extractYaml(raw) if (extracted.error !== null || extracted.yaml === null) { @@ -78,9 +78,12 @@ function renderAdvisories(advisories: readonly Advisory[]): HTMLElement { return container } -function renderStats(stats: { redactedEnvVars: number; redactedEmails: number; anonymizedPaths: number }): string { +function renderStats(stats: { redactedEnvVars: number; redactedEmails: number; anonymizedPaths: number; redactedKeys: readonly string[] }): string { const parts: string[] = [] - if (stats.redactedEnvVars > 0) parts.push(`${stats.redactedEnvVars} env var${stats.redactedEnvVars > 1 ? 's' : ''} redacted`) + if (stats.redactedEnvVars > 0) { + const keyList = stats.redactedKeys.length > 0 ? ` (${[...new Set(stats.redactedKeys)].join(', ')})` : '' + parts.push(`${stats.redactedEnvVars} env var${stats.redactedEnvVars > 1 ? 's' : ''} redacted${keyList}`) + } if (stats.redactedEmails > 0) parts.push(`${stats.redactedEmails} email${stats.redactedEmails > 1 ? 's' : ''} redacted`) if (stats.anonymizedPaths > 0) parts.push(`${stats.anonymizedPaths} path${stats.anonymizedPaths > 1 ? 's' : ''} anonymized`) return parts.length > 0 ? parts.join(', ') : 'No sensitive values detected' @@ -148,13 +151,84 @@ function init(): void { // Header const header = el('header') const h1 = el('h1') - h1.textContent = 'Compose Debugger' + h1.textContent = 'Docker Compose Debugger' header.appendChild(h1) + const subtitle = el('p') + subtitle.textContent = 'Paste your Docker Compose output to get a clean, readable breakdown — sensitive values redacted, noise stripped, misconfigurations flagged.' + subtitle.style.color = 'var(--text-muted)' + subtitle.style.fontSize = '0.9rem' + subtitle.style.marginTop = '0.25rem' + header.appendChild(subtitle) app.appendChild(header) // Short notice app.appendChild(createShortNotice()) + // Help commands (copyable) + const helpBox = el('div', { className: 'notice' }) + helpBox.style.whiteSpace = 'normal' + const helpIntro = el('p') + helpIntro.textContent = 'Run one of these commands, then paste the output below:' + helpIntro.style.marginBottom = '0.5rem' + helpIntro.style.fontWeight = '500' + helpIntro.style.color = 'var(--text)' + helpBox.appendChild(helpIntro) + + const commands = [ + { label: 'docker-autocompose (recommended)', cmd: 'docker run --rm -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/red5d/docker-autocompose ' }, + ] + for (const { label, cmd } of commands) { + const row = el('div') + row.style.marginBottom = '0.4rem' + const labelSpan = el('span') + labelSpan.textContent = label + ':' + labelSpan.style.fontSize = '0.8rem' + labelSpan.style.color = 'var(--text-muted)' + labelSpan.style.display = 'block' + row.appendChild(labelSpan) + + const cmdWrap = el('div') + cmdWrap.style.display = 'flex' + cmdWrap.style.alignItems = 'center' + cmdWrap.style.gap = '0.5rem' + + const code = el('code') + code.textContent = cmd + code.style.fontFamily = 'var(--mono)' + code.style.fontSize = '0.8rem' + code.style.background = 'var(--bg)' + code.style.padding = '0.3rem 0.5rem' + code.style.borderRadius = '4px' + code.style.display = 'block' + code.style.overflowX = 'auto' + code.style.userSelect = 'all' + code.style.cursor = 'pointer' + cmdWrap.appendChild(code) + + const copyBtn = el('button', { className: 'btn btn-secondary' }) + copyBtn.textContent = 'Copy' + copyBtn.style.padding = '0.2rem 0.5rem' + copyBtn.style.fontSize = '0.75rem' + copyBtn.style.flexShrink = '0' + copyBtn.addEventListener('click', async () => { + const ok = await copyToClipboard(cmd) + copyBtn.textContent = ok ? 'Copied!' : 'Failed' + setTimeout(() => { copyBtn.textContent = 'Copy' }, 1500) + }) + cmdWrap.appendChild(copyBtn) + + row.appendChild(cmdWrap) + helpBox.appendChild(row) + } + + const helpNote = el('p') + helpNote.textContent = 'You can also paste raw docker-compose.yml content directly.' + helpNote.style.marginTop = '0.4rem' + helpNote.style.fontSize = '0.8rem' + helpNote.style.color = 'var(--text-muted)' + helpBox.appendChild(helpNote) + app.appendChild(helpBox) + // Input const inputLabel = el('label', { for: 'input' }) inputLabel.textContent = 'Paste your Docker Compose YAML or console output:' @@ -165,7 +239,6 @@ function init(): void { rows: '18', spellcheck: 'false', }) - input.placeholder = 'Paste output from:\n docker run --rm -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/red5d/docker-autocompose \n docker compose config\n or raw docker-compose.yml content' app.appendChild(input) // Sanitize button @@ -190,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', @@ -213,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 @@ -259,46 +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) + 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 pbBtn = el('button', { className: 'btn btn-secondary' }) - pbBtn.textContent = 'Open PrivateBin' - pbBtn.addEventListener('click', async () => { - await copyToClipboard(output.value) - openPrivateBin() - }) - actions.appendChild(pbBtn) + 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) @@ -312,7 +391,7 @@ function init(): void { // Source code link const footer = el('div', { className: 'footer' }) const sourceLink = el('a', { - href: 'https://github.com/bakerboy448/compose-sanitizer', + href: 'https://github.com/baker-scripts/docker-compose-debugger', target: '_blank', rel: 'noopener noreferrer', }) @@ -364,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() @@ -382,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/noise.ts b/src/noise.ts index e204124..7a58fe0 100644 --- a/src/noise.ts +++ b/src/noise.ts @@ -62,7 +62,10 @@ function stripNoiseEnvDict(env: Record): Record = {} for (const [key, value] of Object.entries(env)) { if (!isNoiseEnvKey(key)) { - result[key] = value + const strValue = value == null ? '' : String(value) + if (strValue !== '') { + result[key] = value + } } } return result 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 8a44e5a..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**' @@ -7,6 +7,7 @@ export interface RedactStats { readonly redactedEnvVars: number readonly redactedEmails: number readonly anonymizedPaths: number + readonly redactedKeys: readonly string[] } export interface RedactResult { @@ -22,7 +23,7 @@ export interface PatternConfig { function redactEnvDict( env: Record, - stats: { redactedEnvVars: number; redactedEmails: number }, + stats: { redactedEnvVars: number; redactedEmails: number; redactedKeys: string[] }, config: PatternConfig, ): Record { const result: Record = {} @@ -30,10 +31,18 @@ function redactEnvDict( const strValue = value == null ? '' : String(value) if (isSensitiveKey(key, config.sensitivePatterns, config.safeKeys)) { result[key] = strValue === '' ? '' : REDACTED - if (strValue !== '') stats.redactedEnvVars++ + if (strValue !== '') { + 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++ + stats.redactedKeys.push(key) } else { result[key] = value } @@ -43,7 +52,7 @@ function redactEnvDict( function redactEnvArray( env: readonly unknown[], - stats: { redactedEnvVars: number; redactedEmails: number }, + stats: { redactedEnvVars: number; redactedEmails: number; redactedKeys: string[] }, config: PatternConfig, ): readonly string[] { return env.map(item => { @@ -56,10 +65,17 @@ function redactEnvArray( if (isSensitiveKey(key, config.sensitivePatterns, config.safeKeys)) { stats.redactedEnvVars++ + 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) return `${key}=${REDACTED}` } return str @@ -89,7 +105,7 @@ function anonymizeVolumes( function redactService( service: Record, - stats: { redactedEnvVars: number; redactedEmails: number; anonymizedPaths: number }, + stats: { redactedEnvVars: number; redactedEmails: number; anonymizedPaths: number; redactedKeys: string[] }, config: PatternConfig, ): Record { const result: Record = { ...service } @@ -110,7 +126,7 @@ function redactService( } export function redactCompose(raw: string, config: PatternConfig = {}): RedactResult { - const emptyStats: RedactStats = { redactedEnvVars: 0, redactedEmails: 0, anonymizedPaths: 0 } + const emptyStats: RedactStats = { redactedEnvVars: 0, redactedEmails: 0, anonymizedPaths: 0, redactedKeys: [] } let parsed: unknown try { @@ -131,7 +147,7 @@ export function redactCompose(raw: string, config: PatternConfig = {}): RedactRe } } - const stats = { redactedEnvVars: 0, redactedEmails: 0, anonymizedPaths: 0 } + const stats = { redactedEnvVars: 0, redactedEmails: 0, anonymizedPaths: 0, redactedKeys: [] as string[] } const compose: Record = { ...parsed } const services = parsed['services'] 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/config.test.ts b/tests/config.test.ts index 3d0acdd..0ce1e6f 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -85,6 +85,17 @@ describe('config', () => { expect(compiled.safeKeys.has('OTHER')).toBe(false) }) + it('compileConfig normalizes safe keys to uppercase for case-insensitive lookup', () => { + const config = { + sensitivePatterns: ['secret'], + safeKeys: ['auth_token', 'MY_KEY'], + } + const compiled = compileConfig(config) + expect(compiled.safeKeys.has('AUTH_TOKEN')).toBe(true) + expect(compiled.safeKeys.has('MY_KEY')).toBe(true) + expect(compiled.safeKeys.has('auth_token')).toBe(false) + }) + it('compileConfig skips invalid regex patterns gracefully', () => { const config = { sensitivePatterns: ['valid', '[invalid', 'also_valid'], 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/noise.test.ts b/tests/noise.test.ts index 964916a..7d08f59 100644 --- a/tests/noise.test.ts +++ b/tests/noise.test.ts @@ -207,7 +207,7 @@ describe('stripNoise', () => { XDG_DATA_HOME: '/config/.local/share', PUID: '1000', TZ: 'America/New_York', - API_KEY: 'secret123', + API_KEY: 'secret123', // pragma: allowlist secret }, }, }, @@ -275,4 +275,25 @@ describe('stripNoise', () => { expect(env).not.toContain('UNBOUND_NAMESERVERS=') expect(env).toContain('PUID=1000') }) + + it('strips empty env values in dict style', () => { + const input = { + services: { + app: { + environment: { + VPN_PIA_USER: '', + VPN_LAN_NETWORK: '', + UNBOUND_NAMESERVERS: '', + PUID: '1000', + }, + }, + }, + } + const result = stripNoise(input) + const env = (result['services'] as Record>)['app']?.['environment'] as Record + expect(env).not.toHaveProperty('VPN_PIA_USER') + expect(env).not.toHaveProperty('VPN_LAN_NETWORK') + expect(env).not.toHaveProperty('UNBOUND_NAMESERVERS') + expect(env).toHaveProperty('PUID', '1000') + }) }) 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/redact.test.ts b/tests/redact.test.ts index 9ce6019..18c091f 100644 --- a/tests/redact.test.ts +++ b/tests/redact.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { redactCompose } from '../src/redact' +import { compileConfig } from '../src/config' describe('redactCompose', () => { it('redacts sensitive env vars in dict style', () => { @@ -166,6 +167,39 @@ services: expect(result.stats.anonymizedPaths).toBe(1) }) + it('tracks which keys were redacted', () => { + const input = ` +services: + sonarr: + environment: + API_KEY: abc123 + MYSQL_PASSWORD: secret + PUID: "1000" + radarr: + environment: + API_KEY: def456 + NOTIFY: user@example.com +` + const result = redactCompose(input) + expect(result.stats.redactedKeys).toContain('API_KEY') + expect(result.stats.redactedKeys).toContain('MYSQL_PASSWORD') + expect(result.stats.redactedKeys).toContain('NOTIFY') + expect(result.stats.redactedKeys).toHaveLength(4) + }) + + it('tracks redacted keys for array-style env vars', () => { + const input = ` +services: + app: + environment: + - 'SECRET_TOKEN=myvalue' + - 'PUID=1000' +` + const result = redactCompose(input) + expect(result.stats.redactedKeys).toContain('SECRET_TOKEN') + expect(result.stats.redactedKeys).toHaveLength(1) + }) + it('handles env vars without values in dict style', () => { const input = ` services: @@ -229,6 +263,25 @@ services: expect(result.stats.redactedEnvVars).toBe(1) }) + it('respects lowercase safe keys via compileConfig (Advanced Settings path)', () => { + const input = ` +services: + app: + environment: + AUTH_TOKEN: should-be-safe + SECRET: should-be-redacted +` + const compiled = compileConfig({ + sensitivePatterns: ['secret', 'auth', 'token'], + safeKeys: ['auth_token'], + }) + const result = redactCompose(input, compiled) + expect(result.error).toBeNull() + expect(result.output).toContain('should-be-safe') + expect(result.output).not.toContain('should-be-redacted') + expect(result.stats.redactedEnvVars).toBe(1) + }) + it('uses custom config with array-style env vars', () => { const input = ` services: 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, } }