From 8ba8711dedcc2d85ee8fca451f53fd1fa16783a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:15:23 +0900 Subject: [PATCH 1/7] test(supply-chain): expose Docker secret-context gap --- src/dockerBuildContextSecurity.test.ts | 90 ++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/dockerBuildContextSecurity.test.ts diff --git a/src/dockerBuildContextSecurity.test.ts b/src/dockerBuildContextSecurity.test.ts new file mode 100644 index 00000000..d86735f7 --- /dev/null +++ b/src/dockerBuildContextSecurity.test.ts @@ -0,0 +1,90 @@ +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 escapeRegexCharacter(character: string): string { + return /[\\^$.*+?()[\]{}|]/u.test(character) ? `\\${character}` : character; +} + +function dockerPatternToRegex(pattern: string): RegExp { + let source = '^'; + let offset = 0; + while (offset < pattern.length) { + if (pattern.startsWith('**/', offset)) { + source += '(?:.*/)?'; + offset += 3; + continue; + } + if (pattern.startsWith('**', offset)) { + source += '.*'; + offset += 2; + continue; + } + if (pattern[offset] === '*') { + source += '[^/]*'; + offset += 1; + continue; + } + source += escapeRegexCharacter(pattern[offset] ?? ''); + offset += 1; + } + return new RegExp(`${source}$`, 'u'); +} + +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 (dockerPatternToRegex(pattern).test(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); + } + }); +}); From 6e3d1722b5e44470d6b249cb4b1cbad8578f1396 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 09:27:30 +0900 Subject: [PATCH 2/7] fix(supply-chain): exclude Docker build-context secrets --- .dockerignore | 14 +++++ src/dockerBuildContextSecurity.test.ts | 84 ++++++++++++++++++-------- 2 files changed, 74 insertions(+), 24 deletions(-) diff --git a/.dockerignore b/.dockerignore index 7181b19c..2aa55cfa 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,17 @@ 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* +**/.netrc +**/*.pem +**/*.key +**/*.p12 +**/*.pfx diff --git a/src/dockerBuildContextSecurity.test.ts b/src/dockerBuildContextSecurity.test.ts index d86735f7..e11c3d76 100644 --- a/src/dockerBuildContextSecurity.test.ts +++ b/src/dockerBuildContextSecurity.test.ts @@ -8,33 +8,69 @@ const dockerIgnoreRules = readFileSync(resolve(process.cwd(), '.dockerignore'), .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith('#')); -function escapeRegexCharacter(character: string): string { - return /[\\^$.*+?()[\]{}|]/u.test(character) ? `\\${character}` : character; -} +function matchesPathSegment(pattern: string, value: string): boolean { + let previous = Array.from({ length: value.length + 1 }, (_, index) => index === 0); -function dockerPatternToRegex(pattern: string): RegExp { - let source = '^'; - let offset = 0; - while (offset < pattern.length) { - if (pattern.startsWith('**/', offset)) { - source += '(?:.*/)?'; - offset += 3; - continue; - } - if (pattern.startsWith('**', offset)) { - source += '.*'; - offset += 2; - continue; + 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]); + } } - if (pattern[offset] === '*') { - source += '[^/]*'; - offset += 1; - continue; + 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); } - source += escapeRegexCharacter(pattern[offset] ?? ''); - offset += 1; + + memo.set(key, matched); + return matched; } - return new RegExp(`${source}$`, 'u'); + + return visit(0, 0); } function isExcludedFromDockerContext(path: string): boolean { @@ -42,7 +78,7 @@ function isExcludedFromDockerContext(path: string): boolean { for (const rule of dockerIgnoreRules) { const negated = rule.startsWith('!'); const pattern = negated ? rule.slice(1) : rule; - if (dockerPatternToRegex(pattern).test(path)) { + if (matchesDockerPattern(pattern, path)) { excluded = !negated; } } From c475a69057c3f19595bc6014329b68583169a399 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 09:14:49 +0900 Subject: [PATCH 3/7] test(supply-chain): require Python credential build-context exclusions --- src/dockerPythonCredentialContextSecurity.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/dockerPythonCredentialContextSecurity.test.ts 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'); + }); +}); From 810c5f9c086aa676668c8ce9f6ebeec43b19f629 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 09:18:43 +0900 Subject: [PATCH 4/7] fix(supply-chain): exclude Python build credentials --- .dockerignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.dockerignore b/.dockerignore index 2aa55cfa..792fe906 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,6 +13,8 @@ coverage **/.npmrc **/.pnpmrc **/.yarnrc* +**/.pypirc +**/pip.conf **/.netrc **/*.pem **/*.key From eec296e52af0c6b05ef6033ee1d1c994c7c610a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:26:47 +0900 Subject: [PATCH 5/7] test(ci): cover event-specific Python matrix Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 7104fd66..209f4845 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,10 +50,14 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) + matrix_match = re.search(r"python-version:\s*(.+)", office_job) assert matrix_match is not None - matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) - assert matrix_versions == SUPPORTED_PYTHON_VERSIONS + pull_request_versions, push_versions = ( + tuple(re.findall(r'"(3\.\d+)"', versions)) + for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + ) + assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) + assert push_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: From c16dafa0c93e5c5c2544aabe2d152b50db6f4544 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:32:54 +0900 Subject: [PATCH 6/7] test(ci): bind Python matrix to event Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 209f4845..a52ddec3 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,11 +50,16 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r"python-version:\s*(.+)", office_job) + matrix_match = re.search( + r"python-version:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'" + r"\s*&&\s*fromJSON\('(\[[^']+\])'\)\s*\|\|\s*" + r"fromJSON\('(\[[^']+\])'\)\s*\}\}", + office_job, + ) assert matrix_match is not None pull_request_versions, push_versions = ( tuple(re.findall(r'"(3\.\d+)"', versions)) - for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + for versions in matrix_match.groups() ) assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) assert push_versions == SUPPORTED_PYTHON_VERSIONS From c2b88adf4b7353ed52a899de1938b9492e093326 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:36:23 +0900 Subject: [PATCH 7/7] revert(ci): restore Office contract owner Remove the duplicated Python support contract changes from this Docker security branch. PR #405 remains the single writer while this branch keeps its build-context credential exclusion delta. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) --- office/tests/test_python_support_contract.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index a52ddec3..7104fd66 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,19 +50,10 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search( - r"python-version:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'" - r"\s*&&\s*fromJSON\('(\[[^']+\])'\)\s*\|\|\s*" - r"fromJSON\('(\[[^']+\])'\)\s*\}\}", - office_job, - ) + matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) assert matrix_match is not None - pull_request_versions, push_versions = ( - tuple(re.findall(r'"(3\.\d+)"', versions)) - for versions in matrix_match.groups() - ) - assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) - assert push_versions == SUPPORTED_PYTHON_VERSIONS + matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) + assert matrix_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: