diff --git a/.dockerignore b/.dockerignore index 7181b19c..792fe906 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,19 @@ dist-demo coverage .git *.log + +# The standalone demo build is credential-free. Keep local configuration, +# registry credentials, and private key material out of recursive build context. +**/.env +**/.env.* +!**/.env.example +**/.npmrc +**/.pnpmrc +**/.yarnrc* +**/.pypirc +**/pip.conf +**/.netrc +**/*.pem +**/*.key +**/*.p12 +**/*.pfx diff --git a/src/dockerBuildContextSecurity.test.ts b/src/dockerBuildContextSecurity.test.ts new file mode 100644 index 00000000..e11c3d76 --- /dev/null +++ b/src/dockerBuildContextSecurity.test.ts @@ -0,0 +1,126 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const dockerIgnoreRules = readFileSync(resolve(process.cwd(), '.dockerignore'), 'utf8') + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); + +function matchesPathSegment(pattern: string, value: string): boolean { + let previous = Array.from({ length: value.length + 1 }, (_, index) => index === 0); + + for (const token of pattern) { + const current = Array.from({ length: value.length + 1 }, () => false); + if (token === '*') { + current[0] = previous[0] ?? false; + for (let index = 1; index <= value.length; index += 1) { + current[index] = + (previous[index] ?? false) || (current[index - 1] ?? false); + } + } else { + for (let index = 1; index <= value.length; index += 1) { + current[index] = + (previous[index - 1] ?? false) && + (token === '?' || token === value[index - 1]); + } + } + previous = current; + } + + return previous[value.length] ?? false; +} + +function matchesDockerPattern(pattern: string, path: string): boolean { + const patternSegments = pattern.split('/').filter(Boolean); + const pathSegments = path.split('/').filter(Boolean); + + if (patternSegments.length === 1) { + const [singlePattern = ''] = patternSegments; + return pathSegments.some((segment) => + matchesPathSegment(singlePattern, segment), + ); + } + + const memo = new Map(); + function visit(patternIndex: number, pathIndex: number): boolean { + const key = `${patternIndex}:${pathIndex}`; + const cached = memo.get(key); + if (cached !== undefined) return cached; + + let matched: boolean; + if (patternIndex === patternSegments.length) { + matched = pathIndex === pathSegments.length; + } else if (patternSegments[patternIndex] === '**') { + matched = + visit(patternIndex + 1, pathIndex) || + (pathIndex < pathSegments.length && visit(patternIndex, pathIndex + 1)); + } else { + matched = + pathIndex < pathSegments.length && + matchesPathSegment( + patternSegments[patternIndex] ?? '', + pathSegments[pathIndex] ?? '', + ) && + visit(patternIndex + 1, pathIndex + 1); + } + + memo.set(key, matched); + return matched; + } + + return visit(0, 0); +} + +function isExcludedFromDockerContext(path: string): boolean { + let excluded = false; + for (const rule of dockerIgnoreRules) { + const negated = rule.startsWith('!'); + const pattern = negated ? rule.slice(1) : rule; + if (matchesDockerPattern(pattern, path)) { + excluded = !negated; + } + } + return excluded; +} + +describe('Docker build-context secret boundary', () => { + it('excludes local environment and package-registry credentials recursively', () => { + for (const privatePath of [ + '.env', + '.env.production', + 'demo/.env.local', + '.npmrc', + 'packages/editor/.pnpmrc', + '.yarnrc.yml', + 'nested/.netrc', + ]) { + expect(isExcludedFromDockerContext(privatePath), privatePath).toBe(true); + } + }); + + it('excludes common private-key and credential-container files recursively', () => { + for (const privatePath of [ + 'certificate.pem', + 'secrets/signing.key', + 'credentials/client.p12', + 'credentials/client.pfx', + ]) { + expect(isExcludedFromDockerContext(privatePath), privatePath).toBe(true); + } + }); + + it('keeps explicit public examples and required build inputs in context', () => { + for (const publicPath of [ + '.env.example', + 'demo/.env.example', + 'package.json', + 'pnpm-lock.yaml', + 'src/styles.css', + 'demo/App.tsx', + ]) { + expect(isExcludedFromDockerContext(publicPath), publicPath).toBe(false); + } + }); +}); diff --git a/src/dockerPythonCredentialContextSecurity.test.ts b/src/dockerPythonCredentialContextSecurity.test.ts new file mode 100644 index 00000000..ee60240a --- /dev/null +++ b/src/dockerPythonCredentialContextSecurity.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const dockerIgnore = readFileSync(resolve(process.cwd(), '.dockerignore'), 'utf8'); + +describe('Docker build-context Python credential boundary', () => { + it('excludes Python registry and installer credential files recursively', () => { + expect(dockerIgnore).toContain('**/.pypirc'); + expect(dockerIgnore).toContain('**/pip.conf'); + }); +});