diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..2bdc8b5 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,24 @@ +language: en-US +reviews: + profile: assertive + request_changes_workflow: true + high_level_summary: true + poem: false + review_status: true + collapse_walkthrough: false + path_instructions: + - path: "src/**" + instructions: | + Review for XSS vulnerabilities — never use innerHTML, always textContent. + Check for immutability violations — never mutate input objects. + Verify regex patterns are not susceptible to ReDoS. + - path: "tests/**" + instructions: | + Verify tests cover edge cases and error paths. + Check test isolation — no shared mutable state. + - path: ".github/workflows/**" + instructions: | + Check for command injection via untrusted GitHub context variables. + Verify secrets are not exposed in logs. +chat: + auto_reply: true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..935b719 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,64 @@ +name: Bug Report +description: Report a bug in the compose sanitizer +labels: [bug] +body: + - type: markdown + attributes: + value: | + **This tracker is for bugs only.** Not for general Docker/compose support. + - type: dropdown + id: category + attributes: + label: Category + options: + - Redaction (sensitive value not caught or safe value wrongly redacted) + - Noise stripping (field not removed or wrong field removed) + - Advisory (false positive or missed detection) + - Input extraction (YAML not parsed from pasted output) + - UI / display issue + - Build / deployment + - Other + validations: + required: true + - type: textarea + id: description + attributes: + label: Description + description: What happened? + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should have happened? + validations: + required: true + - type: dropdown + id: input-type + attributes: + label: Input type + options: + - docker-autocompose output + - docker compose config output + - Raw docker-compose.yml + - Other + validations: + required: true + - type: textarea + id: input + attributes: + label: Input YAML (sanitized) + description: Paste a minimal example that reproduces the issue. Redact any real secrets first. + render: yaml + - type: textarea + id: output + attributes: + label: Actual output + description: What the sanitizer produced + render: yaml + - type: input + id: browser + attributes: + label: Browser + description: e.g., Chrome 120, Firefox 121, Safari 17 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8005e32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: false +contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..5ad3db5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,36 @@ +name: Feature Request +description: Suggest an improvement to the compose sanitizer +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + **This tracker is for feature requests only.** Not for general Docker/compose support. + - type: dropdown + id: category + attributes: + label: Category + options: + - New redaction pattern (detect additional sensitive values) + - New noise filter (strip additional generated fields) + - New advisory (detect additional misconfigurations) + - Input format support (new input types) + - Output / sharing (clipboard, export options) + - UI / UX improvement + - Other + validations: + required: true + - type: textarea + id: description + attributes: + label: Description + description: What would you like to see added or changed? + validations: + required: true + - type: textarea + id: use-case + attributes: + label: Use case + description: Why is this needed? Include example YAML if relevant. + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..964bfaa --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## Summary + + + +## Changes + + + +## Test plan + + + +- [ ] Tests pass (`npm test`) +- [ ] TypeScript compiles (`npx tsc --noEmit`) +- [ ] Build succeeds (`npm run build`) diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 0000000..f4f00bd --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,59 @@ +name: Pre-release + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + prerelease: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Get current version + id: version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Get next pre-release number + id: prerelease + env: + BASE_VERSION: ${{ steps.version.outputs.version }} + run: | + LATEST=$(git tag -l "v${BASE_VERSION}-pre.*" --sort=-version:refname | head -n1) + if [ -z "$LATEST" ]; then + NUM=1 + else + NUM=$(echo "$LATEST" | sed "s/v${BASE_VERSION}-pre\.\([0-9]*\)/\1/") + NUM=$((NUM + 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 + + - name: Create pre-release tag + env: + TAG: ${{ steps.prerelease.outputs.tag }} + run: | + git tag "$TAG" + git push origin "$TAG" + + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.prerelease.outputs.tag }} + files: compose-sanitizer.html + generate_release_notes: true + prerelease: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e0fcc4..a69a5c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,3 +23,4 @@ jobs: with: files: compose-sanitizer.html generate_release_notes: true + prerelease: ${{ contains(github.ref, '-') }} diff --git a/.github/workflows/stable-release.yml b/.github/workflows/stable-release.yml new file mode 100644 index 0000000..b9c48b5 --- /dev/null +++ b/.github/workflows/stable-release.yml @@ -0,0 +1,69 @@ +name: Stable Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g., 0.2.0). Will be tagged as v0.2.0' + required: true + type: string + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Validate version format + env: + VERSION: ${{ inputs.version }} + run: | + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Invalid version format. Use semver (e.g., 1.2.3)" + exit 1 + fi + + - name: Check tag does not exist + env: + VERSION: ${{ inputs.version }} + run: | + if git rev-parse "v${VERSION}" >/dev/null 2>&1; then + echo "::error::Tag v${VERSION} already exists" + exit 1 + fi + + - name: Update package.json version + env: + VERSION: ${{ inputs.version }} + run: npm version "$VERSION" --no-git-tag-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 + + - name: Commit version bump and tag + env: + VERSION: ${{ inputs.version }} + run: | + 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}" + git tag "v${VERSION}" + git push origin main --follow-tags + + - uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ inputs.version }} + files: compose-sanitizer.html + generate_release_notes: true + prerelease: false diff --git a/README.md b/README.md index dc1a076..a2707d0 100644 --- a/README.md +++ b/README.md @@ -4,32 +4,93 @@ Browser-based tool that redacts sensitive values from Docker Compose YAML while **Live:** [bakerboy448.github.io/compose-sanitizer](https://bakerboy448.github.io/compose-sanitizer/) -## What Gets Redacted +## Features -- **Sensitive env var values** matching password, secret, token, api_key, auth, credential patterns -- **Email addresses** detected in any value -- **Home directory paths** in volume mounts (`/home/user/...` becomes `~/...`) +### Redaction -## What Gets Kept +| What | Example | Result | +|------|---------|--------| +| Sensitive env values | `MYSQL_PASSWORD: supersecret` | `MYSQL_PASSWORD: **REDACTED**` | +| Email addresses | `NOTIFY: user@example.com` | `NOTIFY: **REDACTED**` | +| Home directory paths | `/home/john/media:/tv` | `~/media:/tv` | -- Container names, images, labels, networks, ports -- Volume mounts (with anonymized home paths) -- Environment variable **names** (only values redacted) -- PUID, PGID, TZ, UMASK values (explicitly safelisted) +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 + +Removes auto-generated fields that clutter compose output: + +- `com.docker.compose.*` labels +- S6-overlay env vars (`S6_*`) +- Default runtime values (`ipc: private`, `entrypoint: /init`) +- Locale/path env vars (`PATH`, `LANG`, `XDG_*`) +- Empty maps and arrays + +### Advisories + +Detects common misconfigurations and shows warnings with links to documentation: + +- **Hardlinks advisory**: Warns when separate `/tv`, `/movies`, etc. mounts prevent hardlinks and instant moves + +### Input Handling + +Accepts multiple input formats: + +- Raw `docker-compose.yml` content +- Output from `docker compose config` +- Output from [`docker-autocompose`](https://github.com/Red5d/docker-autocompose) (strips shell prompts and non-YAML lines) + +### Customizable Patterns + +The Settings panel allows custom sensitive patterns (regex) and safe key lists. Configuration persists in `localStorage`. ## Self-Hosting -Download `compose-sanitizer.html` from the [latest release](https://github.com/bakerboy448/compose-sanitizer/releases) and open it in any browser. Everything runs client-side — no server required. +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. ## Development ```bash npm install -npm run dev # Start dev server -npm run test # Run tests -npm run build # Build single-file output +npm run dev # Start Vite dev server +npm test # Run tests (vitest) +npm run build # Build single-file dist/index.html ``` +### Architecture + +Single-page app built with Vite + vanilla TypeScript. The build produces one self-contained HTML file via `vite-plugin-singlefile`. + +``` +src/ + patterns.ts # Shared 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.) + config.ts # Customizable patterns, localStorage persistence + clipboard.ts # Copy, PrivateBin, and Gist sharing + disclaimer.ts # PII warnings and legal disclaimers + main.ts # UI assembly and event wiring +``` + +### Testing + +104 tests across 7 test files with >93% statement coverage: + +```bash +npm test # Run tests +npx vitest run --coverage # Run with coverage report +``` + +## Privacy + +- All processing happens in your browser — no data is sent anywhere +- 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 + ## License MIT diff --git a/index.html b/index.html index 585dbdf..d902dbc 100644 --- a/index.html +++ b/index.html @@ -4,8 +4,267 @@ + content="default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; base-uri 'none'"> Docker Compose Sanitizer +
diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..698b984 --- /dev/null +++ b/renovate.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":rebaseStalePrs", + ":semanticCommits", + ":automergeMinor", + ":automergeDigest" + ], + "labels": ["dependencies"], + "rangeStrategy": "bump", + "packageRules": [ + { + "matchUpdateTypes": ["major"], + "automerge": false + }, + { + "matchDepTypes": ["devDependencies"], + "automerge": true, + "automergeType": "pr" + } + ] +} diff --git a/src/advisories.ts b/src/advisories.ts new file mode 100644 index 0000000..b1f0fc2 --- /dev/null +++ b/src/advisories.ts @@ -0,0 +1,61 @@ +import { isRecord } from './patterns' + +export interface Advisory { + readonly type: 'hardlinks' + readonly message: string + readonly link: string + readonly services: readonly string[] +} + +const MEDIA_CONTAINER_PATHS = new Set([ + '/tv', '/movies', '/series', '/music', '/books', '/anime', +]) + +function getContainerPath(volumeStr: string): string { + const parts = volumeStr.split(':') + if (parts.length >= 2) { + const containerPart = parts[1] ?? '' + return containerPart.replace(/:.*$/, '') + } + return '' +} + +function hasMediaMount(volumes: readonly unknown[]): boolean { + return volumes.some(vol => { + if (typeof vol === 'string') { + return MEDIA_CONTAINER_PATHS.has(getContainerPath(vol)) + } + if (isRecord(vol) && typeof vol['target'] === 'string') { + return MEDIA_CONTAINER_PATHS.has(vol['target']) + } + return false + }) +} + +export function detectAdvisories(compose: Record): readonly Advisory[] { + const services = compose['services'] + if (!isRecord(services)) return [] + + const affectedServices: string[] = [] + + for (const [name, svc] of Object.entries(services)) { + if (!isRecord(svc)) continue + const volumes = svc['volumes'] + if (!Array.isArray(volumes)) continue + if (hasMediaMount(volumes)) { + affectedServices.push(name) + } + } + + if (affectedServices.length === 0) return [] + + return [ + { + type: 'hardlinks', + message: + 'Separate /tv, /movies etc. mounts prevent hardlinks. Consider a unified media root mount.', + link: 'https://trash-guides.info/Hardlinks/Hardlinks-and-Instant-Moves/', + services: [...affectedServices], + }, + ] +} diff --git a/src/clipboard.ts b/src/clipboard.ts new file mode 100644 index 0000000..39dff08 --- /dev/null +++ b/src/clipboard.ts @@ -0,0 +1,16 @@ +export async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + return false + } +} + +export function openPrivateBin(): void { + window.open('https://privatebin.net/', '_blank', 'noopener,noreferrer') +} + +export function openGist(): void { + window.open('https://gist.github.com/', '_blank', 'noopener,noreferrer') +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d5431f3 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,83 @@ +export interface SanitizerConfig { + readonly sensitivePatterns: readonly string[] + readonly safeKeys: readonly string[] +} + +const STORAGE_KEY = 'compose-sanitizer-config' + +export const DEFAULT_CONFIG: SanitizerConfig = { + sensitivePatterns: [ + 'passw(or)?d', + '^pw$', + '[_.]pass(w)?$', + '^pass[_.]?', + 'secret', + 'token', + 'api[_\\-.:]?key', + 'auth', + 'credential', + 'private[_\\-.]?key', + 'vpn[_\\-.]?user', + ], + safeKeys: [ + 'PUID', 'PGID', 'TZ', 'UMASK', 'UMASK_SET', + 'HOME', 'PATH', 'LANG', 'LC_ALL', + 'LOG_LEVEL', 'WEBUI_PORT', + ], +} + +function isStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every(x => typeof x === 'string') +} + +function isValidConfig(value: unknown): value is Partial { + if (typeof value !== 'object' || value === null) return false + const obj = value as Record + if (obj['sensitivePatterns'] !== undefined && !isStringArray(obj['sensitivePatterns'])) return false + if (obj['safeKeys'] !== undefined && !isStringArray(obj['safeKeys'])) return false + return true +} + +export function compileConfig(config: SanitizerConfig): { + readonly sensitivePatterns: readonly RegExp[] + readonly safeKeys: ReadonlySet +} { + const compiled: RegExp[] = [] + for (const p of config.sensitivePatterns) { + try { + compiled.push(new RegExp(p, 'i')) + } catch { + // Skip invalid regex patterns — user entered bad syntax in settings + } + } + return { + sensitivePatterns: compiled, + safeKeys: new Set(config.safeKeys), + } +} + +export function loadConfig(): SanitizerConfig { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw === null) return DEFAULT_CONFIG + + const parsed: unknown = JSON.parse(raw) + if (!isValidConfig(parsed)) return DEFAULT_CONFIG + + return { + sensitivePatterns: parsed.sensitivePatterns ?? DEFAULT_CONFIG.sensitivePatterns, + safeKeys: parsed.safeKeys ?? DEFAULT_CONFIG.safeKeys, + } + } catch { + return DEFAULT_CONFIG + } +} + +export function saveConfig(config: SanitizerConfig): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)) +} + +export function resetConfig(): SanitizerConfig { + localStorage.removeItem(STORAGE_KEY) + return DEFAULT_CONFIG +} diff --git a/src/disclaimer.ts b/src/disclaimer.ts new file mode 100644 index 0000000..6fb8146 --- /dev/null +++ b/src/disclaimer.ts @@ -0,0 +1,56 @@ +export const SHORT_NOTICE = + 'All processing happens locally in your browser. No data is ever sent to any server.\n\n' + + 'This tool is provided as a best-effort aid. Always review the output yourself before sharing \u2014 ' + + 'automated redaction cannot guarantee every sensitive value is caught.' + +export const PII_WARNING = + 'Review the output below for any remaining personal information before sharing.' + +export const FULL_DISCLAIMER = + 'NO WARRANTY: This software is provided "as is", without warranty of any kind, express or implied, ' + + 'including but not limited to the warranties of merchantability, fitness for a particular purpose, ' + + 'and noninfringement.\n\n' + + 'NO GUARANTEE OF COMPLETE REDACTION: While this tool attempts to identify and redact sensitive ' + + 'values using pattern matching, it cannot guarantee that all sensitive information will be caught. ' + + 'New or unusual patterns, custom variable names, or non-standard formats may not be detected.\n\n' + + 'USER RESPONSIBILITY: You are solely responsible for reviewing the sanitized output before sharing ' + + 'it publicly or with third parties. The authors and contributors of this tool accept no liability ' + + 'for any sensitive information that may remain in the output.\n\n' + + 'NO DATA COLLECTION: All processing is performed entirely within your browser. No data is transmitted ' + + 'to any server, collected, stored, or shared by this tool.\n\n' + + 'NOT LEGAL OR SECURITY ADVICE: This tool does not constitute legal advice, security advice, or a ' + + 'professional security audit. It is a community utility intended to assist with a common task.\n\n' + + 'LIMITATION OF LIABILITY: In no event shall the authors or contributors be liable for any claim, ' + + 'damages, or other liability arising from the use of this tool.\n\n' + + 'This is a community tool built to help, not a contract. Use it as one layer in your review process, ' + + 'not the final word.' + +export function createShortNotice(): HTMLElement { + const div = document.createElement('div') + div.className = 'notice' + div.textContent = SHORT_NOTICE + return div +} + +export function createPiiWarning(): HTMLElement { + const div = document.createElement('div') + div.className = 'pii-warning' + div.textContent = PII_WARNING + return div +} + +export function createFullDisclaimer(): HTMLElement { + const details = document.createElement('details') + details.className = 'disclaimer' + + const summary = document.createElement('summary') + summary.textContent = 'Full Disclaimer' + details.appendChild(summary) + + const content = document.createElement('p') + content.textContent = FULL_DISCLAIMER + content.style.whiteSpace = 'pre-wrap' + details.appendChild(content) + + return details +} diff --git a/src/extract.ts b/src/extract.ts new file mode 100644 index 0000000..dac4963 --- /dev/null +++ b/src/extract.ts @@ -0,0 +1,78 @@ +import { load } from 'js-yaml' +import { isRecord } from './patterns' + +export interface ExtractResult { + readonly yaml: string | null + readonly error: string | null +} + +const YAML_START_KEYS = /^(version|services|name|networks|volumes|x-)[\s:]/ + +const SHELL_PREFIX = /^[$#>]\s|^(sudo\s|docker\s|podman\s)/ + +const TERMINAL_PROMPT = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+[:\s~$#]/ + +function findYamlStart(lines: readonly string[]): number { + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? '' + if (YAML_START_KEYS.test(line)) return i + if (line.startsWith('---')) return i + } + return -1 +} + +function trimTrailingPrompt(lines: readonly string[]): readonly string[] { + let end = lines.length + while (end > 0) { + const line = lines[end - 1] ?? '' + const trimmed = line.trim() + if (trimmed === '' || TERMINAL_PROMPT.test(trimmed) || SHELL_PREFIX.test(trimmed)) { + end-- + } else { + break + } + } + return lines.slice(0, end) +} + +export function extractYaml(raw: string): ExtractResult { + const trimmed = raw.trim() + if (trimmed === '') { + return { yaml: null, error: 'No input provided. Paste your Docker Compose YAML or console output.' } + } + + const lines = trimmed.split('\n') + + const yamlStartIdx = findYamlStart(lines) + + let yamlLines: readonly string[] + if (yamlStartIdx >= 0) { + yamlLines = lines.slice(yamlStartIdx) + } else { + // Try the whole thing — maybe it's YAML without a recognizable start key + yamlLines = lines + } + + yamlLines = trimTrailingPrompt(yamlLines) + + if (yamlLines.length === 0) { + return { yaml: null, error: 'No valid YAML found. Make sure you copied the full output.' } + } + + const yamlStr = yamlLines.join('\n') + + try { + const parsed = load(yamlStr) + if (!isRecord(parsed)) { + return { yaml: null, error: 'Input does not appear to be a Docker Compose file. Expected a YAML mapping at root level.' } + } + return { yaml: yamlStr, error: null } + } catch (e) { + // If we skipped lines at the start, the failure may be due to truncation + const msg = e instanceof Error ? e.message : String(e) + const hint = yamlStartIdx > 0 + ? ' Make sure you copied the full output.' + : ' Did you copy the full output?' + return { yaml: null, error: `Invalid YAML: ${msg}.${hint}` } + } +} diff --git a/src/main.ts b/src/main.ts index dbaca96..aeb986e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,2 +1,321 @@ -// Entry point — will be wired up in Task 8 -console.log('compose-sanitizer loaded') +import { load, dump } from 'js-yaml' +import { isRecord } from './patterns' +import { extractYaml } from './extract' +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 { createShortNotice, createPiiWarning, createFullDisclaimer } from './disclaimer' + +const MAX_INPUT_BYTES = 512 * 1024 + +function el( + tag: K, + attrs?: Record, + children?: (HTMLElement | string)[], +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag) + if (attrs) { + for (const [key, value] of Object.entries(attrs)) { + if (key === 'className') { + element.className = value + } else { + element.setAttribute(key, value) + } + } + } + if (children) { + for (const child of children) { + if (typeof child === 'string') { + element.appendChild(document.createTextNode(child)) + } else { + element.appendChild(child) + } + } + } + return element +} + +function sanitize(raw: string, config: SanitizerConfig): { + output: string | null + error: string | null + stats: { redactedEnvVars: number; redactedEmails: number; anonymizedPaths: number } + advisories: readonly Advisory[] +} { + const emptyStats = { redactedEnvVars: 0, redactedEmails: 0, anonymizedPaths: 0 } + + const extracted = extractYaml(raw) + if (extracted.error !== null || extracted.yaml === null) { + return { output: null, error: extracted.error, stats: emptyStats, advisories: [] } + } + + const compiled = compileConfig(config) + const result = redactCompose(extracted.yaml, compiled) + if (result.error !== null) { + return { output: null, error: result.error, stats: emptyStats, advisories: [] } + } + + let parsed: unknown + try { + parsed = load(result.output) + } catch { + return { output: result.output, error: null, stats: result.stats, advisories: [] } + } + + if (isRecord(parsed)) { + const stripped = stripNoise(parsed) + const advisories = detectAdvisories(stripped) + const finalOutput = dump(stripped, { lineWidth: -1, noRefs: true, quotingType: "'", forceQuotes: false }) + return { output: finalOutput, error: null, stats: result.stats, advisories } + } + + return { output: result.output, error: null, stats: result.stats, advisories: [] } +} + +function renderAdvisories(advisories: readonly Advisory[]): HTMLElement { + const container = el('div', { className: 'advisories' }) + for (const advisory of advisories) { + const div = el('div', { className: 'advisory' }) + + const icon = el('span', { className: 'advisory-icon' }) + icon.textContent = '\u26A0\uFE0F' + div.appendChild(icon) + + const text = el('span') + text.textContent = advisory.message + ' ' + div.appendChild(text) + + const link = el('a', { href: advisory.link, target: '_blank', rel: 'noopener noreferrer' }) + link.textContent = 'Learn more' + div.appendChild(link) + + const services = el('span', { className: 'advisory-services' }) + services.textContent = ' (Services: ' + advisory.services.join(', ') + ')' + div.appendChild(services) + + container.appendChild(div) + } + return container +} + +function renderStats(stats: { redactedEnvVars: number; redactedEmails: number; anonymizedPaths: number }): string { + const parts: string[] = [] + if (stats.redactedEnvVars > 0) parts.push(`${stats.redactedEnvVars} env var${stats.redactedEnvVars > 1 ? 's' : ''} redacted`) + 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' +} + +function buildSettingsPanel(config: SanitizerConfig, onSave: (c: SanitizerConfig) => void): HTMLElement { + const details = el('details', { className: 'settings' }) + const summary = el('summary') + summary.textContent = 'Settings' + details.appendChild(summary) + + const form = el('div', { className: 'settings-form' }) + + const sensLabel = el('label') + sensLabel.textContent = 'Sensitive patterns (one regex per line):' + form.appendChild(sensLabel) + const sensInput = el('textarea', { className: 'settings-textarea', rows: '6', spellcheck: 'false' }) + sensInput.value = config.sensitivePatterns.join('\n') + form.appendChild(sensInput) + + const safeLabel = el('label') + safeLabel.textContent = 'Safe keys (one per line):' + form.appendChild(safeLabel) + const safeInput = el('textarea', { className: 'settings-textarea', rows: '4', spellcheck: 'false' }) + safeInput.value = config.safeKeys.join('\n') + form.appendChild(safeInput) + + const btnRow = el('div', { className: 'settings-buttons' }) + + const saveBtn = el('button', { className: 'btn btn-secondary' }) + saveBtn.textContent = 'Save Settings' + saveBtn.addEventListener('click', () => { + const newConfig: SanitizerConfig = { + sensitivePatterns: sensInput.value.split('\n').map(s => s.trim()).filter(Boolean), + safeKeys: safeInput.value.split('\n').map(s => s.trim()).filter(Boolean), + } + onSave(newConfig) + saveConfig(newConfig) + saveBtn.textContent = 'Saved!' + setTimeout(() => { saveBtn.textContent = 'Save Settings' }, 1500) + }) + btnRow.appendChild(saveBtn) + + const resetBtn = el('button', { className: 'btn btn-secondary' }) + resetBtn.textContent = 'Reset to Defaults' + resetBtn.addEventListener('click', () => { + const defaults = resetConfig() + sensInput.value = defaults.sensitivePatterns.join('\n') + safeInput.value = defaults.safeKeys.join('\n') + onSave(defaults) + }) + btnRow.appendChild(resetBtn) + + form.appendChild(btnRow) + details.appendChild(form) + return details +} + +function init(): void { + const app = document.getElementById('app') + if (!app) return + + let currentConfig = loadConfig() + + // Header + const header = el('header') + const h1 = el('h1') + h1.textContent = 'Docker Compose Sanitizer' + header.appendChild(h1) + app.appendChild(header) + + // Short notice + app.appendChild(createShortNotice()) + + // Input + const inputLabel = el('label', { for: 'input' }) + inputLabel.textContent = 'Paste your Docker Compose YAML or console output:' + app.appendChild(inputLabel) + const input = el('textarea', { + id: 'input', + className: 'code-textarea', + 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 + const sanitizeBtn = el('button', { id: 'sanitize', className: 'btn btn-primary' }) + sanitizeBtn.textContent = 'Sanitize' + app.appendChild(sanitizeBtn) + + // Error display + const errorDiv = el('div', { id: 'error', className: 'error hidden' }) + app.appendChild(errorDiv) + + // Stats display + const statsDiv = el('div', { id: 'stats', className: 'stats hidden' }) + app.appendChild(statsDiv) + + // Advisories container + const advisoriesDiv = el('div', { id: 'advisories' }) + app.appendChild(advisoriesDiv) + + // PII warning (hidden until output) + const piiWarning = createPiiWarning() + piiWarning.classList.add('hidden') + app.appendChild(piiWarning) + + // Output + const output = el('textarea', { + id: 'output', + className: 'code-textarea hidden', + rows: '18', + readonly: 'true', + spellcheck: 'false', + }) + app.appendChild(output) + + // Action buttons + const actions = el('div', { id: 'actions', className: 'actions hidden' }) + + const copyBtn = el('button', { className: 'btn btn-secondary' }) + copyBtn.textContent = 'Copy to Clipboard' + copyBtn.addEventListener('click', async () => { + const ok = await copyToClipboard(output.value) + copyBtn.textContent = ok ? 'Copied!' : 'Copy failed' + setTimeout(() => { copyBtn.textContent = 'Copy to Clipboard' }, 1500) + }) + actions.appendChild(copyBtn) + + const pbBtn = el('button', { className: 'btn btn-secondary' }) + pbBtn.textContent = 'Open PrivateBin' + pbBtn.addEventListener('click', async () => { + await copyToClipboard(output.value) + openPrivateBin() + }) + actions.appendChild(pbBtn) + + 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) + + app.appendChild(actions) + + // Settings panel + const settings = buildSettingsPanel(currentConfig, (c) => { currentConfig = c }) + app.appendChild(settings) + + // Full disclaimer + app.appendChild(createFullDisclaimer()) + + // Sanitize handler + sanitizeBtn.addEventListener('click', () => { + const raw = input.value + if (!raw.trim()) { + errorDiv.textContent = 'Please paste some Docker Compose YAML first.' + errorDiv.classList.remove('hidden') + output.classList.add('hidden') + piiWarning.classList.add('hidden') + actions.classList.add('hidden') + statsDiv.classList.add('hidden') + advisoriesDiv.replaceChildren() + return + } + + if (new Blob([raw]).size > MAX_INPUT_BYTES) { + errorDiv.textContent = 'Input too large. Maximum 512 KB.' + errorDiv.classList.remove('hidden') + output.classList.add('hidden') + piiWarning.classList.add('hidden') + actions.classList.add('hidden') + statsDiv.classList.add('hidden') + advisoriesDiv.replaceChildren() + return + } + + sanitizeBtn.disabled = true + sanitizeBtn.textContent = 'Sanitizing...' + + try { + const result = sanitize(raw, currentConfig) + + if (result.error !== null) { + errorDiv.textContent = result.error + errorDiv.classList.remove('hidden') + output.classList.add('hidden') + piiWarning.classList.add('hidden') + actions.classList.add('hidden') + statsDiv.classList.add('hidden') + advisoriesDiv.replaceChildren() + } else { + errorDiv.classList.add('hidden') + output.value = result.output ?? '' + output.classList.remove('hidden') + piiWarning.classList.remove('hidden') + actions.classList.remove('hidden') + statsDiv.textContent = renderStats(result.stats) + statsDiv.classList.remove('hidden') + + advisoriesDiv.replaceChildren() + if (result.advisories.length > 0) { + advisoriesDiv.appendChild(renderAdvisories(result.advisories)) + } + } + } finally { + sanitizeBtn.disabled = false + sanitizeBtn.textContent = 'Sanitize' + } + }) +} + +init() diff --git a/src/noise.ts b/src/noise.ts new file mode 100644 index 0000000..e204124 --- /dev/null +++ b/src/noise.ts @@ -0,0 +1,178 @@ +import { isRecord } from './patterns' + +const COMPOSE_LABEL_PREFIX = 'com.docker.compose.' + +const NOISE_ENV_PATTERNS: readonly RegExp[] = [ + /^S6_/i, + /^IMAGE_STATS$/i, + /^APP_DIR$/i, + /^CONFIG_DIR$/i, + /^XDG_/i, + /^LANG$/i, + /^LANGUAGE$/i, + /^LC_ALL$/i, + /^PATH$/i, + /^PRIVOXY_ENABLED$/i, + /^UNBOUND_ENABLED$/i, +] + +const DEFAULT_SERVICE_FIELDS: ReadonlyMap = new Map([ + ['ipc', 'private'], + ['working_dir', '/'], +]) + +const DEFAULT_ENTRYPOINTS = new Set(['/init']) + +function isEmpty(value: unknown): boolean { + if (value == null) return true + if (value === '') return true + if (Array.isArray(value) && value.length === 0) return true + if (isRecord(value) && Object.keys(value).length === 0) return true + return false +} + +function isNoiseEnvKey(key: string): boolean { + return NOISE_ENV_PATTERNS.some(p => p.test(key)) +} + +function isEmptyEnvValue(entry: string): boolean { + const eqIdx = entry.indexOf('=') + if (eqIdx === -1) return false + return entry.slice(eqIdx + 1) === '' +} + +function stripComposeLabelsDict(labels: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(labels)) { + if (!key.startsWith(COMPOSE_LABEL_PREFIX)) { + result[key] = value + } + } + return result +} + +function stripComposeLabelsArray(labels: readonly unknown[]): readonly unknown[] { + return labels.filter(item => { + const str = String(item) + return !str.startsWith(COMPOSE_LABEL_PREFIX) + }) +} + +function stripNoiseEnvDict(env: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(env)) { + if (!isNoiseEnvKey(key)) { + result[key] = value + } + } + return result +} + +function stripNoiseEnvArray(env: readonly unknown[]): readonly unknown[] { + return env.filter(item => { + const str = String(item) + const eqIdx = str.indexOf('=') + const key = eqIdx >= 0 ? str.slice(0, eqIdx) : str + if (isNoiseEnvKey(key)) return false + if (isEmptyEnvValue(str)) return false + return true + }) +} + +function isDefaultEntrypoint(entrypoint: unknown): boolean { + if (Array.isArray(entrypoint) && entrypoint.length === 1) { + return DEFAULT_ENTRYPOINTS.has(String(entrypoint[0])) + } + if (typeof entrypoint === 'string') { + return DEFAULT_ENTRYPOINTS.has(entrypoint) + } + return false +} + +function isDefaultOnlyNetwork(networks: Record): boolean { + const keys = Object.keys(networks) + if (keys.length !== 1 || keys[0] !== 'default') return false + const defaultNet = networks['default'] + if (defaultNet == null) return true + if (!isRecord(defaultNet)) return false + const entries = Object.entries(defaultNet).filter(([, v]) => v != null && v !== false) + return entries.length === 0 +} + +function cleanFields(obj: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + if (!isEmpty(value)) { + result[key] = value + } + } + return result +} + +function stripServiceNoise(service: Record): Record { + let result: Record = { ...service } + + // Strip compose labels + const labels = service['labels'] + if (isRecord(labels)) { + result = { ...result, labels: stripComposeLabelsDict(labels) } + } else if (Array.isArray(labels)) { + result = { ...result, labels: stripComposeLabelsArray(labels) } + } + + // Strip noise env vars + const env = service['environment'] + if (isRecord(env)) { + result = { ...result, environment: stripNoiseEnvDict(env) } + } else if (Array.isArray(env)) { + result = { ...result, environment: stripNoiseEnvArray(env) } + } + + // Strip default Docker fields + for (const [field, defaultValue] of DEFAULT_SERVICE_FIELDS) { + if (result[field] === defaultValue) { + const { [field]: _, ...rest } = result + result = rest + } + } + + // Strip default entrypoint + if (isDefaultEntrypoint(result['entrypoint'])) { + const { entrypoint: _, ...rest } = result + result = rest + } + + return cleanFields(result) +} + +export function stripNoise(compose: Record): Record { + let result: Record = {} + + for (const [key, value] of Object.entries(compose)) { + if (key === 'version' || key === 'name') continue + result[key] = value + } + + const services = result['services'] + if (isRecord(services)) { + const newServices: Record = {} + for (const [name, svc] of Object.entries(services)) { + newServices[name] = isRecord(svc) ? stripServiceNoise(svc) : svc + } + result = { ...result, services: newServices } + } + + const networks = result['networks'] + if (isRecord(networks) && isDefaultOnlyNetwork(networks)) { + const { networks: _, ...rest } = result + result = rest + } + + const volumes = result['volumes'] + if (isRecord(volumes) && Object.keys(volumes).length === 0) { + const { volumes: _, ...rest } = result + result = rest + } + + return result +} diff --git a/src/patterns.ts b/src/patterns.ts new file mode 100644 index 0000000..8951121 --- /dev/null +++ b/src/patterns.ts @@ -0,0 +1,46 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export const DEFAULT_SENSITIVE_PATTERNS: readonly RegExp[] = [ + /passw(or)?d/i, + /^pw$/i, + /[_.]pass(w)?$/i, + /^pass[_.]?/i, + /secret/i, + /token/i, + /api[_\-.:]?key/i, + /auth/i, + /credential/i, + /private[_\-.]?key/i, + /vpn[_\-.]?user/i, +] + +export const DEFAULT_SAFE_KEYS: ReadonlySet = new Set([ + 'PUID', 'PGID', 'TZ', 'UMASK', 'UMASK_SET', + 'HOME', 'PATH', 'LANG', 'LC_ALL', + 'LOG_LEVEL', 'WEBUI_PORT', +]) + +export const EMAIL_PATTERN = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/ + +export const HOME_DIR_PATTERN = /^(\/home\/[^/]+|~|\/root)\// + +export function isSensitiveKey( + key: string, + sensitivePatterns?: readonly RegExp[], + safeKeys?: ReadonlySet, +): 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)) +} + +export function containsEmail(value: string): boolean { + return EMAIL_PATTERN.test(value) +} + +export function anonymizeHomePath(volumeStr: string): string { + return volumeStr.replace(HOME_DIR_PATTERN, '~/') +} diff --git a/src/redact.ts b/src/redact.ts new file mode 100644 index 0000000..8a44e5a --- /dev/null +++ b/src/redact.ts @@ -0,0 +1,151 @@ +import { load, dump } from 'js-yaml' +import { isRecord, isSensitiveKey, containsEmail, anonymizeHomePath } from './patterns' + +const REDACTED = '**REDACTED**' + +export interface RedactStats { + readonly redactedEnvVars: number + readonly redactedEmails: number + readonly anonymizedPaths: number +} + +export interface RedactResult { + readonly output: string + readonly error: string | null + readonly stats: RedactStats +} + +export interface PatternConfig { + readonly sensitivePatterns?: readonly RegExp[] + readonly safeKeys?: ReadonlySet +} + +function redactEnvDict( + env: Record, + stats: { redactedEnvVars: number; redactedEmails: number }, + config: PatternConfig, +): Record { + const result: Record = {} + for (const [key, value] of Object.entries(env)) { + const strValue = value == null ? '' : String(value) + if (isSensitiveKey(key, config.sensitivePatterns, config.safeKeys)) { + result[key] = strValue === '' ? '' : REDACTED + if (strValue !== '') stats.redactedEnvVars++ + } else if (containsEmail(strValue)) { + result[key] = REDACTED + stats.redactedEmails++ + } else { + result[key] = value + } + } + return result +} + +function redactEnvArray( + env: readonly unknown[], + stats: { redactedEnvVars: number; redactedEmails: number }, + config: PatternConfig, +): readonly string[] { + return env.map(item => { + const str = String(item) + const eqIdx = str.indexOf('=') + if (eqIdx === -1) return str + + const key = str.slice(0, eqIdx) + const value = str.slice(eqIdx + 1) + + if (isSensitiveKey(key, config.sensitivePatterns, config.safeKeys)) { + stats.redactedEnvVars++ + return `${key}=${REDACTED}` + } + if (containsEmail(value)) { + stats.redactedEmails++ + return `${key}=${REDACTED}` + } + return str + }) +} + +function anonymizeVolumes( + volumes: readonly unknown[], + stats: { anonymizedPaths: number }, +): readonly unknown[] { + return volumes.map(vol => { + if (typeof vol === 'string') { + const anonymized = anonymizeHomePath(vol) + if (anonymized !== vol) stats.anonymizedPaths++ + return anonymized + } + if (isRecord(vol) && typeof vol['source'] === 'string') { + const anonymized = anonymizeHomePath(vol['source']) + if (anonymized !== vol['source']) { + stats.anonymizedPaths++ + return { ...vol, source: anonymized } + } + } + return vol + }) +} + +function redactService( + service: Record, + stats: { redactedEnvVars: number; redactedEmails: number; anonymizedPaths: number }, + config: PatternConfig, +): Record { + const result: Record = { ...service } + + const env = service['environment'] + if (Array.isArray(env)) { + result['environment'] = redactEnvArray(env, stats, config) + } else if (isRecord(env)) { + result['environment'] = redactEnvDict(env, stats, config) + } + + const volumes = service['volumes'] + if (Array.isArray(volumes)) { + result['volumes'] = anonymizeVolumes(volumes, stats) + } + + return result +} + +export function redactCompose(raw: string, config: PatternConfig = {}): RedactResult { + const emptyStats: RedactStats = { redactedEnvVars: 0, redactedEmails: 0, anonymizedPaths: 0 } + + let parsed: unknown + try { + parsed = load(raw) + } catch (e) { + return { + output: '', + error: `Invalid YAML: ${e instanceof Error ? e.message : String(e)}`, + stats: emptyStats, + } + } + + if (!isRecord(parsed)) { + return { + output: '', + error: 'Input is not a valid Docker Compose file (expected a YAML mapping at root level)', + stats: emptyStats, + } + } + + const stats = { redactedEnvVars: 0, redactedEmails: 0, anonymizedPaths: 0 } + const compose: Record = { ...parsed } + + const services = parsed['services'] + if (isRecord(services)) { + const newServices: Record = {} + for (const [name, svc] of Object.entries(services)) { + newServices[name] = isRecord(svc) ? redactService(svc, stats, config) : svc + } + compose['services'] = newServices + } + + return { + output: dump(compose, { lineWidth: -1, noRefs: true, quotingType: "'", forceQuotes: false }), + error: null, + stats: { ...stats }, + } +} diff --git a/tests/advisories.test.ts b/tests/advisories.test.ts new file mode 100644 index 0000000..5280a4b --- /dev/null +++ b/tests/advisories.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest' +import { detectAdvisories } from '../src/advisories' + +describe('detectAdvisories', () => { + it('detects separate /tv mount', () => { + const compose = { + services: { + sonarr: { + image: 'linuxserver/sonarr', + volumes: ['/mnt/data/tv:/tv'], + }, + }, + } + const advisories = detectAdvisories(compose) + expect(advisories).toHaveLength(1) + expect(advisories[0]?.type).toBe('hardlinks') + expect(advisories[0]?.services).toContain('sonarr') + }) + + it('detects separate /movies mount', () => { + const compose = { + services: { + radarr: { + volumes: ['/mnt/data/movies:/movies'], + }, + }, + } + const advisories = detectAdvisories(compose) + expect(advisories).toHaveLength(1) + expect(advisories[0]?.type).toBe('hardlinks') + }) + + it('detects /series, /music, /books, /anime mounts', () => { + const compose = { + services: { + app: { + volumes: [ + '/data/series:/series', + '/data/music:/music', + '/data/books:/books', + '/data/anime:/anime', + ], + }, + }, + } + const advisories = detectAdvisories(compose) + expect(advisories).toHaveLength(1) + expect(advisories[0]?.services).toContain('app') + }) + + it('does not trigger for /config', () => { + const compose = { + services: { + app: { + volumes: ['/home/user/.config:/config'], + }, + }, + } + const advisories = detectAdvisories(compose) + expect(advisories).toHaveLength(0) + }) + + it('does not trigger for unified root like /data/media/tv', () => { + const compose = { + services: { + sonarr: { + volumes: ['/mnt/data:/data'], + }, + }, + } + const advisories = detectAdvisories(compose) + expect(advisories).toHaveLength(0) + }) + + it('returns single advisory for multiple services with separate media mounts', () => { + const compose = { + services: { + sonarr: { + volumes: ['/mnt/tv:/tv'], + }, + radarr: { + volumes: ['/mnt/movies:/movies'], + }, + }, + } + const advisories = detectAdvisories(compose) + expect(advisories).toHaveLength(1) + expect(advisories[0]?.services).toContain('sonarr') + expect(advisories[0]?.services).toContain('radarr') + }) + + it('returns no advisories when no media mounts', () => { + const compose = { + services: { + nginx: { + volumes: ['/etc/nginx:/etc/nginx:ro'], + }, + }, + } + const advisories = detectAdvisories(compose) + expect(advisories).toHaveLength(0) + }) + + it('handles services without volumes', () => { + const compose = { + services: { + app: { + image: 'nginx', + }, + }, + } + const advisories = detectAdvisories(compose) + expect(advisories).toHaveLength(0) + }) + + it('handles empty services', () => { + const compose = { services: {} } + const advisories = detectAdvisories(compose) + expect(advisories).toHaveLength(0) + }) + + it('handles compose without services key', () => { + const compose = { networks: {} } + const advisories = detectAdvisories(compose) + expect(advisories).toHaveLength(0) + }) + + it('includes a link to TRaSH Guides', () => { + const compose = { + services: { + sonarr: { volumes: ['/tv:/tv'] }, + }, + } + const advisories = detectAdvisories(compose) + expect(advisories[0]?.link).toContain('trash-guides.info') + }) +}) diff --git a/tests/clipboard.test.ts b/tests/clipboard.test.ts new file mode 100644 index 0000000..7f106cf --- /dev/null +++ b/tests/clipboard.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { copyToClipboard, openPrivateBin, openGist } from '../src/clipboard' + +describe('copyToClipboard', () => { + beforeEach(() => { + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }) + }) + + it('calls navigator.clipboard.writeText with the text', async () => { + await copyToClipboard('test text') + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test text') + }) + + it('returns true on success', async () => { + const result = await copyToClipboard('test') + expect(result).toBe(true) + }) + + it('returns false on failure', async () => { + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockRejectedValue(new Error('denied')), + }, + }) + const result = await copyToClipboard('test') + expect(result).toBe(false) + }) +}) + +describe('openPrivateBin', () => { + it('opens PrivateBin in a new tab', () => { + const spy = vi.spyOn(window, 'open').mockImplementation(() => null) + openPrivateBin() + expect(spy).toHaveBeenCalledWith( + 'https://privatebin.net/', + '_blank', + 'noopener,noreferrer', + ) + spy.mockRestore() + }) +}) + +describe('openGist', () => { + it('opens GitHub Gist in a new tab', () => { + const spy = vi.spyOn(window, 'open').mockImplementation(() => null) + openGist() + expect(spy).toHaveBeenCalledWith( + 'https://gist.github.com/', + '_blank', + 'noopener,noreferrer', + ) + spy.mockRestore() + }) +}) diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..3d0acdd --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { loadConfig, saveConfig, resetConfig, compileConfig, DEFAULT_CONFIG } from '../src/config' + +describe('config', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('loadConfig returns defaults with empty localStorage', () => { + const config = loadConfig() + expect(config.sensitivePatterns).toEqual(DEFAULT_CONFIG.sensitivePatterns) + expect(config.safeKeys).toEqual(DEFAULT_CONFIG.safeKeys) + }) + + it('saveConfig persists to localStorage', () => { + const custom = { + sensitivePatterns: ['custom_pattern'], + safeKeys: ['CUSTOM_SAFE'], + } + saveConfig(custom) + const stored = localStorage.getItem('compose-sanitizer-config') + expect(stored).toBeTruthy() + expect(JSON.parse(stored!)).toEqual(custom) + }) + + it('loadConfig returns saved patterns after save', () => { + const custom = { + sensitivePatterns: ['my_secret'], + safeKeys: ['MY_SAFE_KEY'], + } + saveConfig(custom) + const loaded = loadConfig() + expect(loaded.sensitivePatterns).toEqual(['my_secret']) + expect(loaded.safeKeys).toEqual(['MY_SAFE_KEY']) + }) + + it('loadConfig falls back to defaults on invalid JSON', () => { + localStorage.setItem('compose-sanitizer-config', 'not valid json!!!') + const config = loadConfig() + expect(config.sensitivePatterns).toEqual(DEFAULT_CONFIG.sensitivePatterns) + expect(config.safeKeys).toEqual(DEFAULT_CONFIG.safeKeys) + }) + + it('loadConfig falls back to defaults on missing fields', () => { + localStorage.setItem('compose-sanitizer-config', '{"sensitivePatterns": ["foo"]}') + const config = loadConfig() + expect(config.sensitivePatterns).toEqual(['foo']) + expect(config.safeKeys).toEqual(DEFAULT_CONFIG.safeKeys) + }) + + it('resetConfig clears localStorage and returns defaults', () => { + saveConfig({ sensitivePatterns: ['custom'], safeKeys: ['CUSTOM'] }) + const config = resetConfig() + expect(config.sensitivePatterns).toEqual(DEFAULT_CONFIG.sensitivePatterns) + expect(config.safeKeys).toEqual(DEFAULT_CONFIG.safeKeys) + expect(localStorage.getItem('compose-sanitizer-config')).toBeNull() + }) + + it('DEFAULT_CONFIG has expected sensitive patterns', () => { + expect(DEFAULT_CONFIG.sensitivePatterns.length).toBeGreaterThan(0) + expect(DEFAULT_CONFIG.sensitivePatterns).toContain('passw(or)?d') + expect(DEFAULT_CONFIG.sensitivePatterns).toContain('secret') + expect(DEFAULT_CONFIG.sensitivePatterns).toContain('token') + }) + + it('DEFAULT_CONFIG has expected safe keys', () => { + expect(DEFAULT_CONFIG.safeKeys).toContain('PUID') + expect(DEFAULT_CONFIG.safeKeys).toContain('PGID') + expect(DEFAULT_CONFIG.safeKeys).toContain('TZ') + }) + + it('compileConfig converts string patterns to RegExp and keys to Set', () => { + const config = { + sensitivePatterns: ['passw(or)?d', 'secret'], + safeKeys: ['PUID', 'TZ'], + } + const compiled = compileConfig(config) + expect(compiled.sensitivePatterns).toHaveLength(2) + expect(compiled.sensitivePatterns[0]).toBeInstanceOf(RegExp) + expect(compiled.sensitivePatterns[0].test('MY_PASSWORD')).toBe(true) + expect(compiled.sensitivePatterns[0].flags).toBe('i') + expect(compiled.safeKeys).toBeInstanceOf(Set) + expect(compiled.safeKeys.has('PUID')).toBe(true) + expect(compiled.safeKeys.has('TZ')).toBe(true) + expect(compiled.safeKeys.has('OTHER')).toBe(false) + }) + + it('compileConfig skips invalid regex patterns gracefully', () => { + const config = { + sensitivePatterns: ['valid', '[invalid', 'also_valid'], + safeKeys: ['PUID'], + } + const compiled = compileConfig(config) + expect(compiled.sensitivePatterns).toHaveLength(2) + expect(compiled.sensitivePatterns[0].source).toBe('valid') + expect(compiled.sensitivePatterns[1].source).toBe('also_valid') + }) + + it('loadConfig rejects non-string array elements in sensitivePatterns', () => { + localStorage.setItem('compose-sanitizer-config', JSON.stringify({ + sensitivePatterns: ['valid', 123], + safeKeys: ['PUID'], + })) + const config = loadConfig() + expect(config.sensitivePatterns).toEqual(DEFAULT_CONFIG.sensitivePatterns) + }) +}) diff --git a/tests/extract.test.ts b/tests/extract.test.ts new file mode 100644 index 0000000..404ae5d --- /dev/null +++ b/tests/extract.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' +import { extractYaml } from '../src/extract' + +describe('extractYaml', () => { + it('returns pure YAML as-is', () => { + const yaml = 'services:\n app:\n image: nginx\n' + const result = extractYaml(yaml) + expect(result.yaml).toBe(yaml.trim()) + expect(result.error).toBeNull() + }) + + it('strips leading shell command', () => { + const input = '$ sudo docker compose config\nservices:\n app:\n image: nginx\n' + const result = extractYaml(input) + expect(result.yaml).toContain('services:') + expect(result.yaml).not.toContain('$ sudo') + expect(result.error).toBeNull() + }) + + it('strips leading blank lines', () => { + const input = '\n\n\n \nservices:\n app:\n image: nginx\n' + const result = extractYaml(input) + expect(result.yaml).toContain('services:') + expect(result.error).toBeNull() + }) + + it('strips trailing terminal prompt', () => { + const input = 'services:\n app:\n image: nginx\nuser@host:~$ ' + const result = extractYaml(input) + expect(result.yaml).toContain('services:') + expect(result.yaml).not.toContain('user@host') + expect(result.error).toBeNull() + }) + + it('returns error for pure garbage text', () => { + const result = extractYaml('this is just some random text with no yaml') + expect(result.error).toBeTruthy() + expect(result.yaml).toBeNull() + }) + + it('returns error for empty input', () => { + const result = extractYaml('') + expect(result.error).toBeTruthy() + expect(result.yaml).toBeNull() + }) + + it('returns error for whitespace-only input', () => { + const result = extractYaml(' \n \n ') + expect(result.error).toBeTruthy() + expect(result.yaml).toBeNull() + }) + + it('handles docker run command before YAML', () => { + const input = '$ docker run --rm ghcr.io/red5d/docker-autocompose sonarr\nservices:\n sonarr:\n image: linuxserver/sonarr\n' + const result = extractYaml(input) + expect(result.yaml).toContain('services:') + expect(result.yaml).not.toContain('docker run') + expect(result.error).toBeNull() + }) + + it('handles comment lines before YAML', () => { + const input = '# Generated by docker-autocompose\nservices:\n app:\n image: nginx\n' + const result = extractYaml(input) + expect(result.yaml).toContain('services:') + expect(result.error).toBeNull() + }) + + it('suggests copy issue for truncated YAML', () => { + const input = 'services:\n app:\n image: nginx\n environment:\n - FOO=' + const result = extractYaml(input) + // Should still try to parse — truncated YAML may or may not be valid + // The key is we don't crash + expect(result.error === null || typeof result.error === 'string').toBe(true) + }) + + it('handles version key at start', () => { + const input = 'version: "3.6"\nservices:\n app:\n image: nginx\n' + const result = extractYaml(input) + expect(result.yaml).toContain('services:') + expect(result.error).toBeNull() + }) + + it('handles name key at start (docker compose config style)', () => { + const input = 'name: myproject\nservices:\n app:\n image: nginx\n' + const result = extractYaml(input) + expect(result.yaml).toContain('services:') + expect(result.error).toBeNull() + }) +}) diff --git a/tests/noise.test.ts b/tests/noise.test.ts new file mode 100644 index 0000000..964916a --- /dev/null +++ b/tests/noise.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect } from 'vitest' +import { stripNoise } from '../src/noise' + +describe('stripNoise', () => { + it('removes com.docker.compose.* labels from services', () => { + const input = { + services: { + app: { + image: 'nginx', + labels: { + 'com.docker.compose.project': 'myapp', + 'com.docker.compose.service': 'app', + 'traefik.enable': 'true', + }, + }, + }, + } + const result = stripNoise(input) + const labels = (result['services'] as Record>)['app']?.['labels'] as Record + expect(labels).not.toHaveProperty('com.docker.compose.project') + expect(labels).not.toHaveProperty('com.docker.compose.service') + expect(labels).toHaveProperty('traefik.enable', 'true') + }) + + it('removes default network with no custom config', () => { + const input = { + services: { app: { image: 'nginx' } }, + networks: { + default: { + external: false, + }, + }, + } + const result = stripNoise(input) + expect(result).not.toHaveProperty('networks') + }) + + it('keeps networks with custom config', () => { + const input = { + services: { app: { image: 'nginx' } }, + networks: { + proxy: { + external: true, + }, + }, + } + const result = stripNoise(input) + expect(result).toHaveProperty('networks') + }) + + it('removes top-level empty volumes', () => { + const input = { + services: { app: { image: 'nginx' } }, + volumes: {}, + } + const result = stripNoise(input) + expect(result).not.toHaveProperty('volumes') + }) + + it('keeps top-level volumes with entries', () => { + const input = { + services: { app: { image: 'nginx' } }, + volumes: { data: { driver: 'local' } }, + } + const result = stripNoise(input) + expect(result).toHaveProperty('volumes') + }) + + it('removes version key', () => { + const input = { + version: '3.6', + services: { app: { image: 'nginx' } }, + } + const result = stripNoise(input) + expect(result).not.toHaveProperty('version') + }) + + it('removes null/empty fields recursively', () => { + const input = { + services: { + app: { + image: 'nginx', + entrypoint: null, + command: '', + labels: {}, + }, + }, + } + const result = stripNoise(input) + const app = (result['services'] as Record>)['app'] + expect(app).not.toHaveProperty('entrypoint') + expect(app).not.toHaveProperty('command') + expect(app).not.toHaveProperty('labels') + expect(app).toHaveProperty('image', 'nginx') + }) + + it('does not mutate the input', () => { + const input = { + version: '3.6', + services: { + app: { + image: 'nginx', + labels: { + 'com.docker.compose.project': 'myapp', + }, + }, + }, + } + const inputCopy = JSON.parse(JSON.stringify(input)) + stripNoise(input) + expect(input).toEqual(inputCopy) + }) + + it('handles services with only noise fields gracefully', () => { + const input = { + services: { + app: { + image: 'nginx', + labels: { + 'com.docker.compose.project': 'myapp', + }, + entrypoint: null, + }, + }, + } + const result = stripNoise(input) + const app = (result['services'] as Record>)['app'] + expect(app).toHaveProperty('image', 'nginx') + expect(app).not.toHaveProperty('labels') + expect(app).not.toHaveProperty('entrypoint') + }) + + it('handles array-style labels (removes compose labels)', () => { + const input = { + services: { + app: { + image: 'nginx', + labels: [ + 'com.docker.compose.project=myapp', + 'com.docker.compose.service=app', + 'traefik.enable=true', + ], + }, + }, + } + const result = stripNoise(input) + const labels = (result['services'] as Record>)['app']?.['labels'] as string[] + expect(labels).toEqual(['traefik.enable=true']) + }) + + it('removes name key (auto-generated by docker compose config)', () => { + const input = { + name: 'myproject', + services: { app: { image: 'nginx' } }, + } + const result = stripNoise(input) + expect(result).not.toHaveProperty('name') + }) + + it('removes default Docker fields from autocompose output', () => { + const input = { + services: { + app: { + image: 'nginx', + ipc: 'private', + working_dir: '/', + entrypoint: ['/init'], + hostname: 'myhost', + }, + }, + } + const result = stripNoise(input) + const app = (result['services'] as Record>)['app'] + expect(app).not.toHaveProperty('ipc') + expect(app).not.toHaveProperty('working_dir') + expect(app).not.toHaveProperty('entrypoint') + expect(app).toHaveProperty('hostname', 'myhost') + }) + + it('keeps non-default entrypoint', () => { + const input = { + services: { + app: { + image: 'nginx', + entrypoint: ['/custom-entrypoint.sh'], + }, + }, + } + const result = stripNoise(input) + const app = (result['services'] as Record>)['app'] + expect(app).toHaveProperty('entrypoint') + }) + + it('strips container-internal env vars from image defaults (dict style)', () => { + const input = { + services: { + app: { + image: 'linuxserver/sonarr', + environment: { + S6_BEHAVIOUR_IF_STAGE2_FAILS: '2', + S6_CMD_WAIT_FOR_SERVICES_MAXTIME: '0', + IMAGE_STATS: 'base64data', + APP_DIR: '/app', + CONFIG_DIR: '/config', + XDG_CONFIG_HOME: '/config/.config', + XDG_CACHE_HOME: '/config/.cache', + XDG_DATA_HOME: '/config/.local/share', + PUID: '1000', + TZ: 'America/New_York', + API_KEY: 'secret123', + }, + }, + }, + } + const result = stripNoise(input) + const env = (result['services'] as Record>)['app']?.['environment'] as Record + expect(env).not.toHaveProperty('S6_BEHAVIOUR_IF_STAGE2_FAILS') + expect(env).not.toHaveProperty('S6_CMD_WAIT_FOR_SERVICES_MAXTIME') + expect(env).not.toHaveProperty('IMAGE_STATS') + expect(env).not.toHaveProperty('APP_DIR') + expect(env).not.toHaveProperty('CONFIG_DIR') + expect(env).not.toHaveProperty('XDG_CONFIG_HOME') + expect(env).not.toHaveProperty('XDG_CACHE_HOME') + expect(env).not.toHaveProperty('XDG_DATA_HOME') + expect(env).toHaveProperty('PUID', '1000') + expect(env).toHaveProperty('TZ', 'America/New_York') + expect(env).toHaveProperty('API_KEY', 'secret123') + }) + + it('strips container-internal env vars from image defaults (array style)', () => { + const input = { + services: { + app: { + image: 'linuxserver/sonarr', + environment: [ + 'S6_BEHAVIOUR_IF_STAGE2_FAILS=2', + 'IMAGE_STATS=base64data', + 'APP_DIR=/app', + 'XDG_CONFIG_HOME=/config/.config', + 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + 'PUID=1000', + 'TZ=America/New_York', + ], + }, + }, + } + const result = stripNoise(input) + const env = (result['services'] as Record>)['app']?.['environment'] as string[] + expect(env).not.toContain(expect.stringContaining('S6_BEHAVIOUR_IF_STAGE2_FAILS')) + expect(env).not.toContain(expect.stringContaining('IMAGE_STATS')) + expect(env).not.toContain(expect.stringContaining('APP_DIR')) + expect(env).not.toContain(expect.stringContaining('XDG_CONFIG_HOME')) + expect(env).not.toContain(expect.stringContaining('PATH=')) + expect(env).toContain('PUID=1000') + expect(env).toContain('TZ=America/New_York') + }) + + it('strips empty env values in array 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 string[] + expect(env).not.toContain('VPN_PIA_USER=') + expect(env).not.toContain('VPN_LAN_NETWORK=') + expect(env).not.toContain('UNBOUND_NAMESERVERS=') + expect(env).toContain('PUID=1000') + }) +}) diff --git a/tests/patterns.test.ts b/tests/patterns.test.ts new file mode 100644 index 0000000..64a9116 --- /dev/null +++ b/tests/patterns.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest' +import { isSensitiveKey, containsEmail, anonymizeHomePath } from '../src/patterns' + +describe('isSensitiveKey', () => { + it.each([ + ['MYSQL_PASSWORD', true], + ['DB_PASS', true], + ['API_KEY', true], + ['AUTH_TOKEN', true], + ['VPN_USER', true], + ['SECRET_KEY', true], + ['PRIVATE_KEY', true], + ['CREDENTIAL', true], + ['OAUTH_SECRET', true], + ['JWT_TOKEN', true], + ['PW', true], + ])('returns %s for %s', (key, expected) => { + expect(isSensitiveKey(key)).toBe(expected) + }) + + it.each([ + ['PUID', false], + ['PGID', false], + ['TZ', false], + ['UMASK', false], + ['UMASK_SET', false], + ['WEBUI_PORT', false], + ['LOG_LEVEL', false], + ['HOME', false], + ['PATH', false], + ['LANG', false], + ['LC_ALL', false], + ])('returns %s for safelisted key %s', (key, expected) => { + expect(isSensitiveKey(key)).toBe(expected) + }) + + it('handles lowercase keys', () => { + expect(isSensitiveKey('mysql_password')).toBe(true) + expect(isSensitiveKey('api_key')).toBe(true) + }) + + it('respects custom sensitive patterns', () => { + const custom = [/^MY_CUSTOM$/i] + expect(isSensitiveKey('MY_CUSTOM', custom)).toBe(true) + expect(isSensitiveKey('SOMETHING_ELSE', custom)).toBe(false) + }) + + it('respects custom safe keys', () => { + const safeKeys = new Set(['AUTH_TOKEN']) + expect(isSensitiveKey('AUTH_TOKEN', undefined, safeKeys)).toBe(false) + }) +}) + +describe('containsEmail', () => { + it('detects standard emails', () => { + expect(containsEmail('user@example.com')).toBe(true) + expect(containsEmail('admin@mail.server.org')).toBe(true) + }) + + it('detects emails within longer strings', () => { + expect(containsEmail('Send to user@example.com please')).toBe(true) + }) + + it('rejects non-emails', () => { + expect(containsEmail('no-email-here')).toBe(false) + expect(containsEmail('just-a-string')).toBe(false) + expect(containsEmail('@')).toBe(false) + }) +}) + +describe('anonymizeHomePath', () => { + it('replaces /home// with ~/', () => { + expect(anonymizeHomePath('/home/john/media:/tv')).toBe('~/media:/tv') + }) + + it('leaves ~/ paths unchanged', () => { + expect(anonymizeHomePath('~/config:/config')).toBe('~/config:/config') + }) + + it('leaves non-home paths unchanged', () => { + expect(anonymizeHomePath('/mnt/data/media:/tv')).toBe('/mnt/data/media:/tv') + }) + + it('replaces /root/ with ~/', () => { + expect(anonymizeHomePath('/root/.config:/config')).toBe('~/.config:/config') + }) + + it('handles paths without container mount', () => { + expect(anonymizeHomePath('/home/user/data')).toBe('~/data') + }) +}) diff --git a/tests/redact.test.ts b/tests/redact.test.ts new file mode 100644 index 0000000..9ce6019 --- /dev/null +++ b/tests/redact.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect } from 'vitest' +import { redactCompose } from '../src/redact' + +describe('redactCompose', () => { + it('redacts sensitive env vars in dict style', () => { + const input = ` +services: + app: + image: linuxserver/sonarr + environment: + MYSQL_PASSWORD: supersecret + PUID: "1000" + PGID: "1000" + TZ: America/New_York +` + const result = redactCompose(input) + expect(result.error).toBeNull() + expect(result.output).toContain('MYSQL_PASSWORD') + expect(result.output).toContain('**REDACTED**') + expect(result.output).not.toContain('supersecret') + expect(result.output).toContain("'1000'") + expect(result.output).toContain('America/New_York') + }) + + it('redacts sensitive env vars in array style', () => { + const input = ` +services: + app: + image: linuxserver/sonarr + environment: + - 'MYSQL_PASSWORD=supersecret' + - 'PUID=1000' + - 'TZ=America/New_York' +` + const result = redactCompose(input) + expect(result.error).toBeNull() + expect(result.output).toContain('MYSQL_PASSWORD=**REDACTED**') + expect(result.output).not.toContain('supersecret') + expect(result.output).toContain('PUID=1000') + expect(result.output).toContain('TZ=America/New_York') + }) + + it('redacts emails in env values', () => { + const input = ` +services: + app: + environment: + NOTIFY_EMAIL: user@example.com + PUID: "1000" +` + const result = redactCompose(input) + expect(result.error).toBeNull() + expect(result.output).not.toContain('user@example.com') + expect(result.output).toContain('**REDACTED**') + }) + + it('anonymizes home paths in volumes', () => { + const input = ` +services: + app: + volumes: + - /home/john/media:/tv + - /mnt/data/media:/movies + - /root/.config:/config +` + const result = redactCompose(input) + expect(result.error).toBeNull() + expect(result.output).toContain('~/media:/tv') + expect(result.output).toContain('/mnt/data/media:/movies') + expect(result.output).toContain('~/.config:/config') + expect(result.output).not.toContain('/home/john') + expect(result.output).not.toContain('/root/') + }) + + it('anonymizes home paths in long-form volume objects', () => { + const input = ` +services: + app: + volumes: + - type: bind + source: /home/john/config + target: /config + - type: bind + source: /mnt/data/media + target: /media +` + const result = redactCompose(input) + expect(result.error).toBeNull() + expect(result.output).toContain('~/config') + expect(result.output).not.toContain('/home/john') + expect(result.output).toContain('/mnt/data/media') + expect(result.stats.anonymizedPaths).toBe(1) + }) + + it('keeps container names, labels, networks, ports', () => { + const input = ` +services: + app: + container_name: sonarr + image: linuxserver/sonarr + labels: + - "traefik.enable=true" + networks: + - proxy + ports: + - "8989:8989" +` + const result = redactCompose(input) + expect(result.error).toBeNull() + expect(result.output).toContain('sonarr') + expect(result.output).toContain('linuxserver/sonarr') + expect(result.output).toContain('traefik.enable=true') + expect(result.output).toContain('proxy') + expect(result.output).toContain('8989:8989') + }) + + it('handles multiple services', () => { + const input = ` +services: + sonarr: + image: linuxserver/sonarr + environment: + API_KEY: abc123 + radarr: + image: linuxserver/radarr + environment: + API_KEY: def456 +` + const result = redactCompose(input) + expect(result.error).toBeNull() + expect(result.output).not.toContain('abc123') + expect(result.output).not.toContain('def456') + expect(result.stats.redactedEnvVars).toBe(2) + }) + + it('handles empty/minimal compose input', () => { + const result = redactCompose('services: {}') + expect(result.error).toBeNull() + expect(result.output).toBeTruthy() + }) + + it('returns error for invalid YAML', () => { + const result = redactCompose('this is: not: valid: yaml: [') + expect(result.error).toBeTruthy() + }) + + it('returns error for non-object YAML', () => { + const result = redactCompose('just a string') + expect(result.error).toBeTruthy() + }) + + it('tracks redaction stats', () => { + const input = ` +services: + app: + environment: + SECRET: value1 + TOKEN: value2 + EMAIL_FIELD: user@example.com + volumes: + - /home/user/config:/config +` + const result = redactCompose(input) + expect(result.stats.redactedEnvVars).toBe(2) + expect(result.stats.redactedEmails).toBe(1) + expect(result.stats.anonymizedPaths).toBe(1) + }) + + it('handles env vars without values in dict style', () => { + const input = ` +services: + app: + environment: + SECRET: + PUID: "1000" +` + const result = redactCompose(input) + expect(result.error).toBeNull() + expect(result.output).toContain('PUID') + }) + + it('redacts emails in array-style env values', () => { + const input = ` +services: + app: + environment: + - 'NOTIFY=user@example.com' +` + const result = redactCompose(input) + expect(result.error).toBeNull() + expect(result.output).not.toContain('user@example.com') + }) + + it('uses custom sensitive patterns when provided', () => { + const input = ` +services: + app: + environment: + MY_CUSTOM_THING: should-be-redacted + NORMAL_VAR: keep-this +` + const customConfig = { + sensitivePatterns: [/custom_thing/i], + safeKeys: new Set(), + } + const result = redactCompose(input, customConfig) + expect(result.error).toBeNull() + expect(result.output).not.toContain('should-be-redacted') + expect(result.output).toContain('keep-this') + expect(result.stats.redactedEnvVars).toBe(1) + }) + + it('respects custom safe keys to skip redaction', () => { + const input = ` +services: + app: + environment: + AUTH_TOKEN: should-be-safe + SECRET: should-be-redacted +` + const customConfig = { + sensitivePatterns: [/secret/i, /auth/i, /token/i], + safeKeys: new Set(['AUTH_TOKEN']), + } + const result = redactCompose(input, customConfig) + 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: + app: + environment: + - 'CUSTOM_KEY=secret-value' + - 'NORMAL=keep-this' +` + const customConfig = { + sensitivePatterns: [/custom_key/i], + safeKeys: new Set(), + } + const result = redactCompose(input, customConfig) + expect(result.error).toBeNull() + expect(result.output).toContain('CUSTOM_KEY=**REDACTED**') + expect(result.output).toContain('NORMAL=keep-this') + }) +})