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')
+ })
+})