Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/scripts/collect-dependency-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ export function parseResolvedDependencies(content, source = "") {
const dependencies = [];
const matcher = /([A-Za-z0-9_.-]+):([A-Za-z0-9_.-]+):([^\s()]+)(?:\s+->\s+([^\s()]+))?/g;
for (const line of String(content).split(/\r?\n/)) {
if (/\(c\)\s*$/.test(line.trim())) {
if (/(?:\((?:c|n)\)|\bFAILED)\s*$/.test(line.trim())) {
continue;
}
for (const match of line.matchAll(matcher)) {
Expand All @@ -440,6 +440,12 @@ export function parseResolvedDependencies(content, source = "") {
return dependencies;
}

export function parseResolutionFailures(content, source = "") {
return String(content).split(/\r?\n/)
.filter((line) => /^[|\\+\s-]*[A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+:\S+.*\bFAILED\s*$/.test(line))
.map((line) => `Gradle 미해석 의존성 (${source}): ${line.trim()}`);
}

function mergeResolvedDependencies(entries) {
const merged = new Map();
for (const entry of entries) {
Expand Down Expand Up @@ -952,10 +958,12 @@ async function main() {

const resolvedRaw = [];
const missingResolvedReports = [];
const unresolvedDependencies = [];
for (const reportPath of options.resolvedReports) {
try {
const content = await fs.readFile(reportPath, "utf8");
resolvedRaw.push(...parseResolvedDependencies(content, path.basename(reportPath)));
unresolvedDependencies.push(...parseResolutionFailures(content, path.basename(reportPath)));
} catch (error) {
if (error?.code === "ENOENT") {
missingResolvedReports.push(reportPath);
Expand Down Expand Up @@ -1015,6 +1023,7 @@ async function main() {
(entry) => entry.currentVersion && !entry.metadata?.url && !entry.metadata?.offline,
);
const gaps = [
...unresolvedDependencies,
...missingResolvedReports.map((report) => `Gradle 해석 보고서 누락: ${report}`),
...Object.entries(resolutionStatus)
.filter(([, exitCode]) => exitCode !== 0)
Expand Down
17 changes: 17 additions & 0 deletions .github/scripts/collect-dependency-audit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
detectCatalogUsage,
parseBomPom,
parseResolvedDependencies,
parseResolutionFailures,
parseVersionCatalog,
renderSummary,
firstPatchedVersion,
Expand Down Expand Up @@ -85,6 +86,8 @@ test("extracts selected Gradle versions after conflict resolution", () => {
const dependencies = parseResolvedDependencies(
"+--- androidx.compose.runtime:runtime:1.10.6 -> 1.11.4\n" +
"+--- unused.constraint:only:9.9.9 (c)\n" +
"+--- unresolved.declaration:only:0.1.0 (n)\n" +
"+--- failed.resolution:only:0.2.0 FAILED\n" +
"\\--- com.squareup.okhttp3:okhttp:5.4.0",
"app-runtime.txt",
);
Expand Down Expand Up @@ -266,3 +269,17 @@ test("summarizes whether a security finding has a stable version to move to", ()
});
assert.match(fixed, /정식 패치판 `2\.4\.20`/);
});

test("keeps failed dependency resolution visible without treating declarations as selected versions", () => {
const report = [
"+--- failed.resolution:only:0.2.0 FAILED",
"| \\--- failed.other:only:0.3.0 -> 0.4.0 FAILED",
"+--- unresolved.declaration:only:0.1.0 (n)",
"BUILD FAILED in 1s",
].join("\n");
assert.deepEqual(parseResolutionFailures(report, "app-configurations.txt"), [
"Gradle 미해석 의존성 (app-configurations.txt): +--- failed.resolution:only:0.2.0 FAILED",
"Gradle 미해석 의존성 (app-configurations.txt): | \\--- failed.other:only:0.3.0 -> 0.4.0 FAILED",
]);
assert.deepEqual(parseResolvedDependencies(report), []);
});
12 changes: 6 additions & 6 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,14 @@ jobs:
persist-credentials: false

- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
languages: actions
build-mode: none

- name: Analyze and upload CodeQL results
id: analyze
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
category: /language:actions

Expand Down Expand Up @@ -174,14 +174,14 @@ jobs:
persist-credentials: false

- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
languages: javascript-typescript
build-mode: none

- name: Analyze and upload CodeQL results
id: analyze
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
category: /language:javascript-typescript

Expand Down Expand Up @@ -231,7 +231,7 @@ jobs:
uses: ./.github/actions/setup-ci-config

- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
languages: java-kotlin
build-mode: manual
Expand All @@ -249,7 +249,7 @@ jobs:

- name: Analyze and upload CodeQL results
id: analyze
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
category: /language:java-kotlin

Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/dependency-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,19 @@ jobs:
./gradlew "$@" --console=plain > "$REPORT_DIR/resolved/$name.txt" 2>&1
status=$?
set -e
# dependencies 보고서는 일부 해석 실패에도 exit 0 을 반환한다.
if [ "$status" -eq 0 ] && grep -q ' FAILED' "$REPORT_DIR/resolved/$name.txt"; then
status=1
fi
printf '%s=%s\n' "$name" "$status" >> "$REPORT_DIR/resolution-status.txt"
}

# :app 의 debug runtime classpath 에 core/feature 모듈의 런타임 의존이 모두 들어온다.
# 앱 런타임뿐 아니라 별도 해석되는 App Distribution, ktlint, UTP 및 included build도 감사한다.
run_report root-build-environment buildEnvironment
run_report app-build-environment :app:buildEnvironment
run_report app-debug-runtime :app:dependencies --configuration debugRuntimeClasspath
run_report app-configurations :app:dependencies
run_report convention-compile -p build-logic :convention:dependencies --configuration compileClasspath

- name: Run current SDK and JVM compatibility gate
id: compatibility
Expand All @@ -90,7 +97,10 @@ jobs:
node .github/scripts/collect-dependency-audit.mjs \
--root . \
--resolved-report "$REPORT_DIR/resolved/root-build-environment.txt" \
--resolved-report "$REPORT_DIR/resolved/app-build-environment.txt" \
--resolved-report "$REPORT_DIR/resolved/app-debug-runtime.txt" \
--resolved-report "$REPORT_DIR/resolved/app-configurations.txt" \
--resolved-report "$REPORT_DIR/resolved/convention-compile.txt" \
--resolution-status "$REPORT_DIR/resolution-status.txt" \
--compatibility-status "$REPORT_DIR/compatibility-status.json" \
--output "$REPORT_DIR/dependency-audit.json" \
Expand Down
10 changes: 10 additions & 0 deletions build-logic/convention/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ dependencies {
compileOnly(libs.compose.compiler.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.hilt.gradlePlugin)
// included build 는 루트 프로젝트의 constraints 를 상속하지 않는다.
constraints {
compileOnly(libs.security.jdom)
compileOnly(libs.security.jose)
compileOnly(libs.security.commons.lang)
compileOnly(libs.security.httpclient)
compileOnly(libs.security.bouncycastle.provider)
compileOnly(libs.security.bouncycastle.pkix)
compileOnly(libs.security.bouncycastle.util)
}
}

gradlePlugin {
Expand Down
62 changes: 54 additions & 8 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ buildscript {
// https://github.com/advisories/GHSA-574f-3g2m-x479 (patched 1.80.2), https://repo1.maven.org/maven2/org/bouncycastle/
configurations.classpath {
resolutionStrategy.force(
"org.bouncycastle:bcprov-jdk18on:1.85",
"org.bouncycastle:bcpkix-jdk18on:1.85",
"org.bouncycastle:bcutil-jdk18on:1.85",
libs.security.bouncycastle.provider.get().toString(),
libs.security.bouncycastle.pkix.get().toString(),
libs.security.bouncycastle.util.get().toString(),
)
}
dependencies {
// AGP 9 내장 Kotlin 은 KGP 2.2.10 을 런타임 의존으로 갖는다. 더 높은 KGP 를 쓰려면 classpath 에 직접 올린다
// (developer.android.com/build/releases/agp-9-0-0-release-notes 의 "Upgrade KGP/KSP").
classpath(libs.kotlin.gradlePlugin)
constraints {
classpath(libs.security.jdom)
classpath(libs.security.jose)
classpath(libs.security.commons.lang)
classpath(libs.security.httpclient)
}
// Hilt Gradle 플러그인(hiltAggregateDepsDebug)이 호출하는 JavaPoet ClassName.canonicalName() 보장 — 버전 충돌(NoSuchMethodError) 방지
classpath("com.squareup:javapoet:1.13.0")
}
Expand All @@ -36,13 +42,14 @@ plugins {
// 끌어온다. buildscript classpath 만 고정하면 dependency-review 가 그 경로의 1.79 를 새 취약 의존으로 잡는다.
// 앱 산출물에는 들어가지 않는 도구 의존이므로 모든 프로젝트 설정에서 패치본으로 맞춘다.
// https://github.com/advisories/GHSA-574f-3g2m-x479
val bouncyCastlePatches = arrayOf(
libs.security.bouncycastle.provider.get().toString(),
libs.security.bouncycastle.pkix.get().toString(),
libs.security.bouncycastle.util.get().toString(),
)
allprojects {
configurations.configureEach {
resolutionStrategy.force(
"org.bouncycastle:bcprov-jdk18on:1.85",
"org.bouncycastle:bcpkix-jdk18on:1.85",
"org.bouncycastle:bcutil-jdk18on:1.85",
)
resolutionStrategy.force(*bouncyCastlePatches)
}
}

Expand All @@ -57,3 +64,42 @@ subprojects {
add("ktlintRuleset", composeRules)
}
}

// 빌드 플러그인과 UTP/ktlint 는 앱 런타임 그래프와 별도로 해석된다.
// constraints 는 이미 존재하는 모듈만 올리고, Netty BOM 은 4.1 패치 계열을 정렬한다.
// https://docs.gradle.org/current/userguide/dependency_constraints.html
val toolingPatches = listOf(
libs.security.jdom,
libs.security.jose,
libs.security.commons.lang,
libs.security.httpclient,
libs.security.logback.classic,
libs.security.logback.core,
)
val nettyBom = libs.security.netty.bom
val guavaPatch = libs.security.guava
subprojects {
// 루트 classpath 는 이 시점에 이미 해석되었으므로 위 buildscript 블록에서 처리한다.
buildscript.dependencies.apply {
if (path == ":app") add("classpath", platform(nettyBom))
toolingPatches.forEach { constraints.add("classpath", it) }
}
val toolingSecurityConstraints = configurations.dependencyScope("toolingSecurityConstraints")
dependencies {
add(toolingSecurityConstraints.name, platform(nettyBom))
constraints {
toolingPatches.forEach { add(toolingSecurityConstraints.name, it) }
}
}
listOf("com.android.application", "com.android.library").forEach { pluginId ->
pluginManager.withPlugin(pluginId) {
dependencies.constraints.add("implementation", guavaPatch)
}
}
configurations.configureEach {
when {
name.startsWith("_internal-unified-test-platform-") || name == "ktlint" ->
extendsFrom(toolingSecurityConstraints.get())
}
}
}
50 changes: 50 additions & 0 deletions docs/testing/dependency-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 의존성 보안 패치 검증

앱 실행 의존성과 빌드·테스트 도구의 의존성은 별도로 해석된다. `Android Dependency Audit`은 루트와 앱의 빌드 클래스패스, 앱의 전체 구성(컴파일·실행·ktlint·UTP), included build의 컴파일 구성을 함께 수집한다. 기존 debug 실행 그래프도 별도 파일로 유지하여 비교할 수 있다. 선언 전용 `(n)` 항목은 실제 선택 버전으로 세지 않고, 해석 실패는 감사의 미확인 항목으로 남긴다.

## 현재 적용한 최소 버전

`gradle/libs.versions.toml`의 `security-*` 별칭은 기존 전이 의존성에 대한 최소 제약이다. 기존 Bouncy Castle 강제 정렬은 유지하며 값은 같은 카탈로그에서 읽는다. 나머지 제약은 상위 버전 선택을 막지 않고 앱에 도구 라이브러리를 추가하지 않는다. Netty는 BOM으로 같은 4.1 패치 계열을 맞춘다.

| 좌표 | 최소 버전 | 적용 경로·근거 |
| --- | --- | --- |
| `io.netty:netty-bom` | `4.1.137.Final` | App Distribution·UTP. [공식 패치 릴리스](https://netty.io/news/2026/08/06/4-1-137-Final.html) |
| `org.jdom:jdom2` | `2.0.6.1` | AGP Jetifier. [릴리스 안내](https://www.jdom.org/news/) |
| `org.bitbucket.b_c:jose4j` | `0.9.6` | AGP bundletool. [공식 수정](https://bitbucket.org/b_c/jose4j/commits/19a90a64c47bb07c4aa5462f1316d5c293d81fcf) |
| `org.apache.commons:commons-lang3` | `3.18.0` | AGP·UTP. [릴리스 기록](https://commons.apache.org/proper/commons-lang/changes.html) |
| `org.apache.httpcomponents:httpclient` | `4.5.14` | UTP의 구버전 요청을 기존 빌드 도구 버전과 정렬. [공식 배포 기록](https://archive.apache.org/dist/httpcomponents/httpclient/RELEASE_NOTES-4.5.x.txt) |
| `com.google.guava:guava` | `32.1.3-android` | 앱 컴파일 의존성을 기존 실행 버전과 정렬. [릴리스 기록](https://github.com/google/guava/releases/tag/v32.1.3) |
| `ch.qos.logback:logback-classic`, `ch.qos.logback:logback-core` | `1.6.3` | ktlint 도구에만 적용. [릴리스·호환성 안내](https://logback.qos.ch/news.html) |
| `org.bouncycastle:bcprov-jdk18on` | `1.85.2` | 기존 루트·UTP 패치를 included build에도 적용하고 provider 패치를 반영. [공식 릴리스](https://www.bouncycastle.org/download/bouncy-castle-java/) |
| `org.bouncycastle:bcpkix-jdk18on`, `org.bouncycastle:bcutil-jdk18on` | `1.85` | provider 전용 1.85.2 패치에는 새 pkix/util 배포가 없으므로 기존 배포 버전 유지. [공식 릴리스](https://www.bouncycastle.org/resources/new-release-bouncy-castle-java-1-85/) |

Logback 1.3은 유지보수가 종료되어 1.6 계열로 옮겼다. ktlint가 호출하는 Logger/Level API와 실제 ktlint 실행을 확인한다. 이 도구는 JDK 21에서 실행하며 앱의 JVM 11 설정을 바꾸지 않는다. [Logback 요구 사항](https://logback.qos.ch/dependencies.html)

## 재검증

JDK 21에서 다음을 실행한다. `:app:dependencies`에서 UTP 및 ktlint의 선택 버전도 확인한다. 버전 변경 후 빌드·lint·단위 테스트와 UTP 연결 테스트를 함께 실행해야 한다.

```sh
./gradlew buildEnvironment :app:buildEnvironment :app:dependencies --max-workers=2
./gradlew -p build-logic :convention:dependencies --configuration compileClasspath
./gradlew assembleDebug testDebugUnitTest lintDebug ktlintCheck --max-workers=2
ANDROID_SERIAL=<전용-에뮬레이터> ./gradlew :app:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.example.slowclock.ExampleInstrumentedTest,com.example.slowclock.AlarmCommandIdentityTest \
--max-workers=2
node --test .github/scripts/*.test.mjs
```

연결 테스트는 폐기 가능한 에뮬레이터에서 수행한다. 이 검증은 실제 App Distribution 업로드나 운영 서비스 호출을 포함하지 않는다. 배포 시 서비스 인증·권한 검증은 [배포 절차](../release/distribution.md)를 따른다.

## 남아 있는 경계

2026-09-13 기준 `org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.10`의 GHSA-r937-wjx7-w2jp는 유지한다. 수정 안정판 2.4.20은 배포되었고 로컬 빌드·단위 테스트 322개·lint·스크린샷 검증은 통과했으나, CodeQL CLI 2.26.4와 최신 2.27.0 모두 `Kotlin version 2.4.20 is too recent`로 실제 소스 추출에 실패했다. 공식 지원표와 실행 결과가 달라, 보안 분석을 유지하기 위해 Kotlin·Compose compiler·serialization을 기존 2.4.10에 둔다. CodeQL Action은 v4.38.0으로 갱신했다. 지원되는 추출기 배포 후 세 플러그인을 함께 갱신하고 실제 Kotlin 분석까지 검증한다.

- [안정판 릴리스](https://github.com/JetBrains/kotlin/releases/tag/v2.4.20)
- [실패한 최신 CodeQL 실행](https://github.com/1hyok/SlowClock/actions/runs/34745788351)
- [CodeQL Action 릴리스](https://github.com/github/codeql-action/releases/tag/v4.38.0)
- [공식 지원표](https://codeql.github.com/docs/codeql-overview/supported-languages-and-frameworks/)

해당 취약점은 KAPT 캐시 역직렬화 경로이며 현재 프로젝트는 KSP를 사용한다. 이 사실을 경고 해제나 전체 빌드 도구의 안전 보장으로 확대하지 않는다. [공식 수정](https://github.com/JetBrains/kotlin/commit/bf51df665b458fda7c3eaf436c4d88dc119d7ec6).

npm의 Functions 실행 의존성과 Firestore 테스트 도구는 #225에서 별도 검증한다. GitHub 경고 감소는 기본 브랜치 snapshot 반영 후 확인한다.
24 changes: 24 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,31 @@ kotlinxSerialization = "1.11.0"
mockk = "1.14.11"
kotlinxCoroutinesTest = "1.11.0"

# 기존 도구의 전이 의존성에 적용하는 최소 패치 버전. 상위 버전 선택은 막지 않는다.
securityJdom = "2.0.6.1"
securityJose = "0.9.6"
securityCommonsLang = "3.18.0"
securityHttpclient = "4.5.14"
securityGuava = "32.1.3-android"
securityNetty = "4.1.137.Final"
securityLogback = "1.6.3"
securityBouncyCastle = "1.85"
# 1.85.2 는 provider 만 배포된 패치다. pkix/util 최신 배포는 1.85.
securityBouncyCastleProvider = "1.85.2"

[libraries]
security-jdom = { module = "org.jdom:jdom2", version.ref = "securityJdom" }
security-jose = { module = "org.bitbucket.b_c:jose4j", version.ref = "securityJose" }
security-commons-lang = { module = "org.apache.commons:commons-lang3", version.ref = "securityCommonsLang" }
security-httpclient = { module = "org.apache.httpcomponents:httpclient", version.ref = "securityHttpclient" }
security-guava = { module = "com.google.guava:guava", version.ref = "securityGuava" }
security-netty-bom = { module = "io.netty:netty-bom", version.ref = "securityNetty" }
security-logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "securityLogback" }
security-logback-core = { module = "ch.qos.logback:logback-core", version.ref = "securityLogback" }
security-bouncycastle-provider = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "securityBouncyCastleProvider" }
security-bouncycastle-pkix = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "securityBouncyCastle" }
security-bouncycastle-util = { module = "org.bouncycastle:bcutil-jdk18on", version.ref = "securityBouncyCastle" }

androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
Expand Down
Loading