From 5fdbba0949ad8d4c9b454e870fd6e48878f481f4 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:55:58 -0600 Subject: [PATCH 01/18] feat: add pattern matching for sensitive keys, emails, home paths --- src/patterns.ts | 42 +++++++++++++++++++ tests/patterns.test.ts | 91 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 src/patterns.ts create mode 100644 tests/patterns.test.ts diff --git a/src/patterns.ts b/src/patterns.ts new file mode 100644 index 0000000..db22bf9 --- /dev/null +++ b/src/patterns.ts @@ -0,0 +1,42 @@ +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/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') + }) +}) From aa2a95806a26fe03d9b8cc78f2864582819e0d9b Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:56:51 -0600 Subject: [PATCH 02/18] feat: implement core compose redaction logic --- src/redact.ts | 147 ++++++++++++++++++++++++++++++++++++ tests/redact.test.ts | 173 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 src/redact.ts create mode 100644 tests/redact.test.ts diff --git a/src/redact.ts b/src/redact.ts new file mode 100644 index 0000000..4e2d7dc --- /dev/null +++ b/src/redact.ts @@ -0,0 +1,147 @@ +import { load, dump } from 'js-yaml' +import { 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 +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function redactEnvDict( + env: Record, + stats: { redactedEnvVars: number; redactedEmails: number }, +): Record { + const result: Record = {} + for (const [key, value] of Object.entries(env)) { + const strValue = value == null ? '' : String(value) + if (isSensitiveKey(key)) { + 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 }, +): 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)) { + 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 }, +): Record { + const result: Record = { ...service } + + const env = service['environment'] + if (Array.isArray(env)) { + result['environment'] = redactEnvArray(env, stats) + } else if (isRecord(env)) { + result['environment'] = redactEnvDict(env, stats) + } + + const volumes = service['volumes'] + if (Array.isArray(volumes)) { + result['volumes'] = anonymizeVolumes(volumes, stats) + } + + return result +} + +export function redactCompose(raw: string): 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) : svc + } + compose['services'] = newServices + } + + return { + output: dump(compose, { lineWidth: -1, noRefs: true, quotingType: "'", forceQuotes: false }), + error: null, + stats: { ...stats }, + } +} diff --git a/tests/redact.test.ts b/tests/redact.test.ts new file mode 100644 index 0000000..25b51fe --- /dev/null +++ b/tests/redact.test.ts @@ -0,0 +1,173 @@ +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('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') + }) +}) From 2373efb52c38895afd779b6555fbbe95fbc95f4a Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:57:47 -0600 Subject: [PATCH 03/18] feat: add noise stripping for generated compose output --- src/noise.ts | 97 +++++++++++++++++++++++++++ tests/noise.test.ts | 159 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 src/noise.ts create mode 100644 tests/noise.test.ts diff --git a/src/noise.ts b/src/noise.ts new file mode 100644 index 0000000..f4b2653 --- /dev/null +++ b/src/noise.ts @@ -0,0 +1,97 @@ +const COMPOSE_LABEL_PREFIX = 'com.docker.compose.' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +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 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 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 } + + const labels = service['labels'] + if (isRecord(labels)) { + const cleaned = stripComposeLabelsDict(labels) + result = { ...result, labels: cleaned } + } else if (Array.isArray(labels)) { + const cleaned = stripComposeLabelsArray(labels) + result = { ...result, labels: cleaned } + } + + 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/tests/noise.test.ts b/tests/noise.test.ts new file mode 100644 index 0000000..9eaddba --- /dev/null +++ b/tests/noise.test.ts @@ -0,0 +1,159 @@ +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') + }) +}) From e0057a770e3918e6a7b3f42b122544829b4db139 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:59:58 -0600 Subject: [PATCH 04/18] feat: detect media volume mounts and show hardlinks advisory --- src/advisories.ts | 63 ++++++++++++++++++ tests/advisories.test.ts | 137 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/advisories.ts create mode 100644 tests/advisories.test.ts diff --git a/src/advisories.ts b/src/advisories.ts new file mode 100644 index 0000000..7645cf8 --- /dev/null +++ b/src/advisories.ts @@ -0,0 +1,63 @@ +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 isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +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/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') + }) +}) From 02f0bcd68ecdbe249ff15ae2bd2fdd608bca12fa Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:00:52 -0600 Subject: [PATCH 05/18] feat: extract YAML from pasted console output --- src/extract.ts | 81 +++++++++++++++++++++++++++++++++++++++ tests/extract.test.ts | 89 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 src/extract.ts create mode 100644 tests/extract.test.ts diff --git a/src/extract.ts b/src/extract.ts new file mode 100644 index 0000000..a46d7d5 --- /dev/null +++ b/src/extract.ts @@ -0,0 +1,81 @@ +import { load } from 'js-yaml' + +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 isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +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/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() + }) +}) From cd779092752e163c1b718f89fc2135984b5f8b85 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:01:40 -0600 Subject: [PATCH 06/18] feat: configurable redaction patterns with localStorage persistence --- src/config.ts | 61 +++++++++++++++++++++++++++++++++++++ tests/config.test.ts | 71 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/config.ts create mode 100644 tests/config.test.ts diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..95feac8 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,61 @@ +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 isValidConfig(value: unknown): value is Partial { + if (typeof value !== 'object' || value === null) return false + const obj = value as Record + if (obj['sensitivePatterns'] !== undefined && !Array.isArray(obj['sensitivePatterns'])) return false + if (obj['safeKeys'] !== undefined && !Array.isArray(obj['safeKeys'])) return false + return true +} + +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/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..57f6dc8 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { loadConfig, saveConfig, resetConfig, 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') + }) +}) From 2fc52a2205ca61fd144da1bb42a826bc714bd170 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:03:18 -0600 Subject: [PATCH 07/18] =?UTF-8?q?feat:=20strip=20autocompose=20noise=20?= =?UTF-8?q?=E2=80=94=20default=20fields,=20internal=20env=20vars,=20empty?= =?UTF-8?q?=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/noise.ts | 91 +++++++++++++++++++++++++++++++-- tests/noise.test.ts | 119 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+), 4 deletions(-) diff --git a/src/noise.ts b/src/noise.ts index f4b2653..139c8bd 100644 --- a/src/noise.ts +++ b/src/noise.ts @@ -1,5 +1,26 @@ 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 isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -12,6 +33,16 @@ function isEmpty(value: unknown): boolean { 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)) { @@ -29,6 +60,37 @@ function stripComposeLabelsArray(labels: readonly unknown[]): readonly unknown[] }) } +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 @@ -52,13 +114,34 @@ function cleanFields(obj: Record): Record { function stripServiceNoise(service: Record): Record { let result: Record = { ...service } + // Strip compose labels const labels = service['labels'] if (isRecord(labels)) { - const cleaned = stripComposeLabelsDict(labels) - result = { ...result, labels: cleaned } + result = { ...result, labels: stripComposeLabelsDict(labels) } } else if (Array.isArray(labels)) { - const cleaned = stripComposeLabelsArray(labels) - result = { ...result, labels: cleaned } + 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) diff --git a/tests/noise.test.ts b/tests/noise.test.ts index 9eaddba..964916a 100644 --- a/tests/noise.test.ts +++ b/tests/noise.test.ts @@ -156,4 +156,123 @@ describe('stripNoise', () => { 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') + }) }) From cd58419bebe095548252da8fbaa9ba612ca24324 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:04:28 -0600 Subject: [PATCH 08/18] feat: clipboard copy and paste service redirect --- src/clipboard.ts | 16 ++++++++++++ tests/clipboard.test.ts | 58 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/clipboard.ts create mode 100644 tests/clipboard.test.ts 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/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() + }) +}) From 2f4e57c360b5cf18981b8ec047f7866183b8f62b Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:08:52 -0600 Subject: [PATCH 09/18] feat: assemble UI with sanitize pipeline, settings, and disclaimers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire all modules into main.ts — input extraction, redaction, noise stripping, advisory detection, and YAML dump. Add settings panel, action buttons (copy/PrivateBin/Gist), stats display, and disclaimers. Dark/light theme CSS with responsive layout. Zero innerHTML usage. --- index.html | 259 ++++++++++++++++++++++++++++++++++++++ src/disclaimer.ts | 56 +++++++++ src/main.ts | 312 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 625 insertions(+), 2 deletions(-) create mode 100644 src/disclaimer.ts diff --git a/index.html b/index.html index 585dbdf..287c4e0 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,265 @@ Docker Compose Sanitizer +
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/main.ts b/src/main.ts index dbaca96..cf52d7c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,2 +1,310 @@ -// Entry point — will be wired up in Task 8 -console.log('compose-sanitizer loaded') +import { load, dump } from 'js-yaml' +import { extractYaml } from './extract' +import { redactCompose } from './redact' +import { stripNoise } from './noise' +import { detectAdvisories, type Advisory } from './advisories' +import { loadConfig, saveConfig, resetConfig, type SanitizerConfig } from './config' +import { copyToClipboard, openPrivateBin, openGist } from './clipboard' +import { createShortNotice, createPiiWarning, createFullDisclaimer } from './disclaimer' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +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): { + 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 result = redactCompose(extracted.yaml) + 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 + } + + sanitizeBtn.disabled = true + sanitizeBtn.textContent = 'Sanitizing...' + + try { + const result = sanitize(raw) + + 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() From fee28f1525e240ae324fd0c23422822567736755 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:16:18 -0600 Subject: [PATCH 10/18] ci: auto pre-release on main push, manual stable release - prerelease.yml: auto-tags v*-pre.N on every push to main - stable-release.yml: manual workflow_dispatch for stable releases with version validation, test gate, and version bump - release.yml: marks pre-releases automatically via tag format --- .github/workflows/prerelease.yml | 59 ++++++++++++++++++++++++ .github/workflows/release.yml | 1 + .github/workflows/stable-release.yml | 69 ++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) create mode 100644 .github/workflows/prerelease.yml create mode 100644 .github/workflows/stable-release.yml 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 From 8d604f163572aea85a71520e329549d60d07c774 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:18:09 -0600 Subject: [PATCH 11/18] chore: add Renovate, CodeRabbit config, branch protection - renovate.json: auto-merge minor/digest, label PRs, rebase stale - .coderabbit.yaml: assertive reviews with security-focused path instructions - Branch protection: CI required, rebase/squash only, no force push --- .coderabbit.yaml | 24 ++++++++++++++++++++++++ renovate.json | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 .coderabbit.yaml create mode 100644 renovate.json 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/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" + } + ] +} From 5607c4d69ad71e9c0f7c9c3cd26ea151d7a7585f Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:19:28 -0600 Subject: [PATCH 12/18] chore: add issue and PR templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug report and feature request templates only — no blank issues, no support requests. PR template with test checklist. --- .github/ISSUE_TEMPLATE/bug_report.yml | 39 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 2 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 27 +++++++++++++++ .github/pull_request_template.md | 15 +++++++++ 4 files changed, 83 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..7529c87 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,39 @@ +name: Bug Report +description: Report a bug in the sanitizer +labels: [bug] +body: + - type: markdown + attributes: + value: | + **This tracker is for bugs only.** For support questions, visit the relevant community channels (e.g., Discord, forums). + - 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: 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..742f5f3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,27 @@ +name: Feature Request +description: Suggest an improvement or new feature +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + **This tracker is for feature requests only.** For support questions, visit the relevant community channels. + - 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? What problem does it solve? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Have you considered any workarounds? 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`) From b60241a7dc6e6d78a2c22cc5ac65f2fdd71f0ce0 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:20:38 -0600 Subject: [PATCH 13/18] chore: optimize issue templates for compose sanitizer domain Add category dropdowns specific to redaction, noise stripping, advisories, and input types. Clarify not for Docker support. --- .github/ISSUE_TEMPLATE/bug_report.yml | 29 ++++++++++++++++++++-- .github/ISSUE_TEMPLATE/feature_request.yml | 25 +++++++++++++------ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7529c87..935b719 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,11 +1,25 @@ name: Bug Report -description: Report a bug in the sanitizer +description: Report a bug in the compose sanitizer labels: [bug] body: - type: markdown attributes: value: | - **This tracker is for bugs only.** For support questions, visit the relevant community channels (e.g., Discord, forums). + **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: @@ -20,6 +34,17 @@ body: 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: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 742f5f3..5ad3db5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,11 +1,25 @@ name: Feature Request -description: Suggest an improvement or new feature +description: Suggest an improvement to the compose sanitizer labels: [enhancement] body: - type: markdown attributes: value: | - **This tracker is for feature requests only.** For support questions, visit the relevant community channels. + **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: @@ -17,11 +31,6 @@ body: id: use-case attributes: label: Use case - description: Why is this needed? What problem does it solve? + description: Why is this needed? Include example YAML if relevant. validations: required: true - - type: textarea - id: alternatives - attributes: - label: Alternatives considered - description: Have you considered any workarounds? From 6a448b9f90ac05723f1230028e666a59b022f023 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:28:03 -0600 Subject: [PATCH 14/18] fix: thread custom config through sanitize pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings panel saved patterns to localStorage but never passed them to the redaction functions. Added PatternConfig parameter through redactCompose → redactService → redactEnvDict/redactEnvArray → isSensitiveKey. Added compileConfig() to convert string patterns to RegExp at call time. Strengthened isValidConfig to reject non-string array elements. 102 tests passing, 91.39% statement coverage. --- src/config.ts | 18 ++++++++++++-- src/main.ts | 9 +++---- src/redact.ts | 20 +++++++++++----- tests/config.test.ts | 27 ++++++++++++++++++++- tests/redact.test.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 13 deletions(-) diff --git a/src/config.ts b/src/config.ts index 95feac8..15294db 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,14 +26,28 @@ export const DEFAULT_CONFIG: SanitizerConfig = { ], } +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 && !Array.isArray(obj['sensitivePatterns'])) return false - if (obj['safeKeys'] !== undefined && !Array.isArray(obj['safeKeys'])) return false + 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 +} { + return { + sensitivePatterns: config.sensitivePatterns.map(p => new RegExp(p, 'i')), + safeKeys: new Set(config.safeKeys), + } +} + export function loadConfig(): SanitizerConfig { try { const raw = localStorage.getItem(STORAGE_KEY) diff --git a/src/main.ts b/src/main.ts index cf52d7c..95450c3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,7 +3,7 @@ import { extractYaml } from './extract' import { redactCompose } from './redact' import { stripNoise } from './noise' import { detectAdvisories, type Advisory } from './advisories' -import { loadConfig, saveConfig, resetConfig, type SanitizerConfig } from './config' +import { loadConfig, saveConfig, resetConfig, compileConfig, type SanitizerConfig } from './config' import { copyToClipboard, openPrivateBin, openGist } from './clipboard' import { createShortNotice, createPiiWarning, createFullDisclaimer } from './disclaimer' @@ -38,7 +38,7 @@ function el( return element } -function sanitize(raw: string): { +function sanitize(raw: string, config: SanitizerConfig): { output: string | null error: string | null stats: { redactedEnvVars: number; redactedEmails: number; anonymizedPaths: number } @@ -51,7 +51,8 @@ function sanitize(raw: string): { return { output: null, error: extracted.error, stats: emptyStats, advisories: [] } } - const result = redactCompose(extracted.yaml) + const compiled = compileConfig(config) + const result = redactCompose(extracted.yaml, compiled) if (result.error !== null) { return { output: null, error: result.error, stats: emptyStats, advisories: [] } } @@ -276,7 +277,7 @@ function init(): void { sanitizeBtn.textContent = 'Sanitizing...' try { - const result = sanitize(raw) + const result = sanitize(raw, currentConfig) if (result.error !== null) { errorDiv.textContent = result.error diff --git a/src/redact.ts b/src/redact.ts index 4e2d7dc..613c256 100644 --- a/src/redact.ts +++ b/src/redact.ts @@ -19,14 +19,20 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +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)) { + if (isSensitiveKey(key, config.sensitivePatterns, config.safeKeys)) { result[key] = strValue === '' ? '' : REDACTED if (strValue !== '') stats.redactedEnvVars++ } else if (containsEmail(strValue)) { @@ -42,6 +48,7 @@ function redactEnvDict( function redactEnvArray( env: readonly unknown[], stats: { redactedEnvVars: number; redactedEmails: number }, + config: PatternConfig, ): readonly string[] { return env.map(item => { const str = String(item) @@ -51,7 +58,7 @@ function redactEnvArray( const key = str.slice(0, eqIdx) const value = str.slice(eqIdx + 1) - if (isSensitiveKey(key)) { + if (isSensitiveKey(key, config.sensitivePatterns, config.safeKeys)) { stats.redactedEnvVars++ return `${key}=${REDACTED}` } @@ -87,14 +94,15 @@ function anonymizeVolumes( 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) + result['environment'] = redactEnvArray(env, stats, config) } else if (isRecord(env)) { - result['environment'] = redactEnvDict(env, stats) + result['environment'] = redactEnvDict(env, stats, config) } const volumes = service['volumes'] @@ -105,7 +113,7 @@ function redactService( return result } -export function redactCompose(raw: string): RedactResult { +export function redactCompose(raw: string, config: PatternConfig = {}): RedactResult { const emptyStats: RedactStats = { redactedEnvVars: 0, redactedEmails: 0, anonymizedPaths: 0 } let parsed: unknown @@ -134,7 +142,7 @@ export function redactCompose(raw: string): RedactResult { if (isRecord(services)) { const newServices: Record = {} for (const [name, svc] of Object.entries(services)) { - newServices[name] = isRecord(svc) ? redactService(svc, stats) : svc + newServices[name] = isRecord(svc) ? redactService(svc, stats, config) : svc } compose['services'] = newServices } diff --git a/tests/config.test.ts b/tests/config.test.ts index 57f6dc8..84e8770 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { loadConfig, saveConfig, resetConfig, DEFAULT_CONFIG } from '../src/config' +import { loadConfig, saveConfig, resetConfig, compileConfig, DEFAULT_CONFIG } from '../src/config' describe('config', () => { beforeEach(() => { @@ -68,4 +68,29 @@ describe('config', () => { 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('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/redact.test.ts b/tests/redact.test.ts index 25b51fe..8d2950b 100644 --- a/tests/redact.test.ts +++ b/tests/redact.test.ts @@ -170,4 +170,60 @@ services: 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') + }) }) From 115b8175411a8b610ce6d1f968f6f25edf32f7c7 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:30:05 -0600 Subject: [PATCH 15/18] refactor: deduplicate isRecord into patterns.ts, add long-form volume test Consolidated isRecord() from 5 duplicate definitions into a single export in patterns.ts. Added test for long-form volume object anonymization (type: bind with source field). 103 tests, 93.47% statement coverage. --- src/advisories.ts | 6 ++---- src/extract.ts | 5 +---- src/main.ts | 5 +---- src/noise.ts | 6 ++---- src/patterns.ts | 4 ++++ src/redact.ts | 6 +----- tests/redact.test.ts | 20 ++++++++++++++++++++ 7 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/advisories.ts b/src/advisories.ts index 7645cf8..b1f0fc2 100644 --- a/src/advisories.ts +++ b/src/advisories.ts @@ -1,3 +1,5 @@ +import { isRecord } from './patterns' + export interface Advisory { readonly type: 'hardlinks' readonly message: string @@ -9,10 +11,6 @@ const MEDIA_CONTAINER_PATHS = new Set([ '/tv', '/movies', '/series', '/music', '/books', '/anime', ]) -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - function getContainerPath(volumeStr: string): string { const parts = volumeStr.split(':') if (parts.length >= 2) { diff --git a/src/extract.ts b/src/extract.ts index a46d7d5..dac4963 100644 --- a/src/extract.ts +++ b/src/extract.ts @@ -1,4 +1,5 @@ import { load } from 'js-yaml' +import { isRecord } from './patterns' export interface ExtractResult { readonly yaml: string | null @@ -11,10 +12,6 @@ const SHELL_PREFIX = /^[$#>]\s|^(sudo\s|docker\s|podman\s)/ const TERMINAL_PROMPT = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+[:\s~$#]/ -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - function findYamlStart(lines: readonly string[]): number { for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? '' diff --git a/src/main.ts b/src/main.ts index 95450c3..a6e066c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import { load, dump } from 'js-yaml' +import { isRecord } from './patterns' import { extractYaml } from './extract' import { redactCompose } from './redact' import { stripNoise } from './noise' @@ -7,10 +8,6 @@ import { loadConfig, saveConfig, resetConfig, compileConfig, type SanitizerConfi import { copyToClipboard, openPrivateBin, openGist } from './clipboard' import { createShortNotice, createPiiWarning, createFullDisclaimer } from './disclaimer' -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - function el( tag: K, attrs?: Record, diff --git a/src/noise.ts b/src/noise.ts index 139c8bd..e204124 100644 --- a/src/noise.ts +++ b/src/noise.ts @@ -1,3 +1,5 @@ +import { isRecord } from './patterns' + const COMPOSE_LABEL_PREFIX = 'com.docker.compose.' const NOISE_ENV_PATTERNS: readonly RegExp[] = [ @@ -21,10 +23,6 @@ const DEFAULT_SERVICE_FIELDS: ReadonlyMap = new Map([ const DEFAULT_ENTRYPOINTS = new Set(['/init']) -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - function isEmpty(value: unknown): boolean { if (value == null) return true if (value === '') return true diff --git a/src/patterns.ts b/src/patterns.ts index db22bf9..8951121 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -1,3 +1,7 @@ +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, diff --git a/src/redact.ts b/src/redact.ts index 613c256..8a44e5a 100644 --- a/src/redact.ts +++ b/src/redact.ts @@ -1,5 +1,5 @@ import { load, dump } from 'js-yaml' -import { isSensitiveKey, containsEmail, anonymizeHomePath } from './patterns' +import { isRecord, isSensitiveKey, containsEmail, anonymizeHomePath } from './patterns' const REDACTED = '**REDACTED**' @@ -15,10 +15,6 @@ export interface RedactResult { readonly stats: RedactStats } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - export interface PatternConfig { readonly sensitivePatterns?: readonly RegExp[] readonly safeKeys?: ReadonlySet diff --git a/tests/redact.test.ts b/tests/redact.test.ts index 8d2950b..9ce6019 100644 --- a/tests/redact.test.ts +++ b/tests/redact.test.ts @@ -72,6 +72,26 @@ services: 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: From c05ebaa13581be64c189551d8cb59272b9b858fe Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:31:49 -0600 Subject: [PATCH 16/18] fix: handle invalid regex in compileConfig, harden CSP compileConfig now skips invalid regex patterns instead of crashing. Added base-uri 'none' to CSP meta tag to prevent base-href injection. 104 tests passing. --- index.html | 2 +- src/config.ts | 10 +++++++++- tests/config.test.ts | 11 +++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index 287c4e0..84a59da 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ + content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self'; font-src 'self'; base-uri 'none'"> Docker Compose Sanitizer