diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 237e6bf29..2fe5fa832 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,8 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 groups: github-actions: patterns: @@ -13,6 +15,8 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 groups: docker-base-images: patterns: @@ -22,6 +26,8 @@ updates: directory: "/frontend" schedule: interval: "weekly" + cooldown: + default-days: 7 groups: frontend-docker-base-images: patterns: @@ -31,6 +37,8 @@ updates: directory: "/backend" schedule: interval: "weekly" + cooldown: + default-days: 7 groups: backend-python: patterns: @@ -40,6 +48,8 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 groups: ci-python: patterns: @@ -49,6 +59,8 @@ updates: directory: "/frontend" schedule: interval: "weekly" + cooldown: + default-days: 7 groups: frontend-npm: patterns: @@ -58,13 +70,19 @@ updates: directory: /connector schedule: interval: daily + cooldown: + default-days: 7 - package-ecosystem: pip directory: /connector schedule: interval: daily + cooldown: + default-days: 7 - package-ecosystem: npm directory: / schedule: interval: daily + cooldown: + default-days: 7 diff --git a/.github/workflows/app-ci.yml b/.github/workflows/app-ci.yml index d6a17f49a..e8f445748 100644 --- a/.github/workflows/app-ci.yml +++ b/.github/workflows/app-ci.yml @@ -35,10 +35,12 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} cache: pip @@ -84,14 +86,15 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 - + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install pnpm run: corepack enable pnpm - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" cache: pnpm diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml index 58c486b5b..c5c613c08 100644 --- a/.github/workflows/bandit.yml +++ b/.github/workflows/bandit.yml @@ -22,10 +22,12 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" @@ -38,7 +40,7 @@ jobs: - name: Upload SARIF file if: ${{ always() }} - uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 with: sarif_file: bandit-results.sarif category: bandit diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 27fde0dc0..c303d1e61 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -29,7 +29,7 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7ea854dc1..8d0ca1ed8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,7 +20,7 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 54652af73..879b906ec 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -54,7 +54,9 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 @@ -178,7 +180,9 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Read release version id: version @@ -248,7 +252,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Log in to GHCR - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/.github/workflows/mail-smoke.yml b/.github/workflows/mail-smoke.yml index cfa94f5a0..6a3a3fdc9 100644 --- a/.github/workflows/mail-smoke.yml +++ b/.github/workflows/mail-smoke.yml @@ -30,10 +30,12 @@ jobs: ${{ vars.MAIL_SMOKE_ALLOWED_ENDPOINTS }} - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" cache: pip diff --git a/CHANGELOG.md b/CHANGELOG.md index a06003d8f..3e217792b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2718,3 +2718,13 @@ - **Note:** CI opencode-review 잡 실행 중 타임아웃 오류(The action 'Run OpenCode PR Review model pool' has timed out after 350 minutes)가 발생했습니다. 이는 외부 AI 검토 모델 서버(github-models 등)의 응답 지연에 기인한 일시적 인프라 문제로 판단되며, 코드 변경 자체의 결함은 아니므로 그대로 재제출하여 파이프라인 재실행을 시도합니다. - **Note:** CI opencode-review 잡 실행 중 타임아웃 오류(The action 'Run OpenCode PR Review model pool' has timed out after 350 minutes)가 발생했습니다. 반복되는 외부 인프라 타임아웃 문제를 해결하기 위해, 마지막으로 재제출을 시도합니다. - **Note:** 추가적인 코드 변경은 없으며, PR 내 자동 분석 커멘트에 대한 답변(CI 실패가 본 PR이 아닌 develop의 기존 이슈임을 인지함)을 남기고 현재 워크플로우를 완료합니다. + +- [UX 개선] 캘린더 회의 조율 화면 제안 버튼의 접근성 향상 (스크린 리더 사용자를 위한 sr-only 텍스트 추가 및 불필요한 중복 텍스트 숨김) + +### 변경 사항 (Changes) + +- `backend/tests/test_release_governance.py` 파일의 394번째 줄에서 `yaml.load` 함수 사용 시 발생하는 Bandit B506 오탐지를 억제하기 위해 `# nosec B506` 주석을 추가했습니다. 해당 코드는 `yaml.SafeLoader`를 상속받은 `UniqueKeyLoader`를 사용하므로 실제로는 안전합니다. 이 변경은 보안 취약점 픽스가 아닌, 정적 분석 툴의 오탐지를 처리하기 위한 조치입니다. + +### 문서 (Documentation) + +- `yaml.load()`와 관련해 발생한 Bandit B506 항목에 대해 규칙 한정적 오탐지(false-positive) 판정 및 처분 근거(disposition)를 담은 `docs/doctoring/bandit-b506-false-positive-disposition.md` 문서를 추가했습니다. 이는 제품의 실제 취약점 패치가 아니며, PyYAML의 `SafeLoader`를 명시적으로 사용하는 사용자 정의 로더에 대해 오탐지를 억제하는 조건과 롤백 기준을 테스트 증거와 함께 기록한 문서입니다. diff --git a/backend/tests/test_release_governance.py b/backend/tests/test_release_governance.py index 56ef2bff4..efe0acd0e 100644 --- a/backend/tests/test_release_governance.py +++ b/backend/tests/test_release_governance.py @@ -388,10 +388,28 @@ def construct_mapping( construct_mapping, ) + # Verify that UniqueKeyLoader is strictly a subclass of SafeLoader so that `# nosec B506` + # suppression is genuinely justified according to PyYAML safety contracts. + assert issubclass(UniqueKeyLoader, yaml.SafeLoader), ( + "UniqueKeyLoader must inherit from SafeLoader to suppress B506" + ) + # Ensure that Python object instantiation tags (like !!python/object) are safely + # rejected rather than executed. + with pytest.raises(yaml.constructor.ConstructorError): + yaml.load("!!python/object/apply:os.system ['echo pwned']", Loader=UniqueKeyLoader) # nosec B506 + # Ensure normal valid YAML loading still works + assert yaml.load("a: 1\nb: 2", Loader=UniqueKeyLoader) == {"a": 1, "b": 2} # nosec B506 + # Ensure the duplicate key prevention still works + with pytest.raises(AssertionError, match="duplicate mapping key 'a'"): + yaml.load("a: 1\na: 2", Loader=UniqueKeyLoader) # nosec B506 + duplicates: list[str] = [] for workflow_path in governed_workflows: try: - yaml.load(workflow_path.read_text(encoding="utf-8"), Loader=UniqueKeyLoader) + # We explicitly pass UniqueKeyLoader (which inherits from SafeLoader). + # Bandit B506 blindly flags yaml.load() regardless of the Loader argument. + # This is a verified false positive. + yaml.load(workflow_path.read_text(encoding="utf-8"), Loader=UniqueKeyLoader) # nosec B506 except AssertionError as exc: duplicates.append(f"{workflow_path.relative_to(REPO_ROOT)}: {exc}") @@ -716,7 +734,7 @@ def test_docker_publish_validates_pr_images_and_publishes_semver_images_only_on_ == 2 ) assert ( - "docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0" + "docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0" in workflow ) assert ( diff --git a/docs/doctoring/bandit-b506-false-positive-disposition.md b/docs/doctoring/bandit-b506-false-positive-disposition.md new file mode 100644 index 000000000..28d0e9099 --- /dev/null +++ b/docs/doctoring/bandit-b506-false-positive-disposition.md @@ -0,0 +1,24 @@ +# False Positive Disposition: Bandit B506 (`yaml.load`) + +## Context and Evidence +Bandit reports a Medium severity B506 issue on `yaml.load()` calls because using the default loader can permit the instantiation of arbitrary Python objects, posing a security risk (PyCQA, 2024). However, in `backend/tests/test_release_governance.py`, `yaml.load` is explicitly invoked with `Loader=UniqueKeyLoader`. + +The local implementation explicitly defines `UniqueKeyLoader` as a subclass of `yaml.SafeLoader`: +```python +class UniqueKeyLoader(yaml.SafeLoader): + pass +``` + +Because `UniqueKeyLoader` inherits from `yaml.SafeLoader`, it automatically inherits all safety constraints, explicitly rejecting unsafe tags (e.g., `!!python/object/apply`). Tests in `test_release_governance.py` verify that `issubclass(UniqueKeyLoader, yaml.SafeLoader)` is true and that malicious YAML payloads are correctly rejected via `yaml.constructor.ConstructorError` rather than being executed (PyYAML, 2024). + +Therefore, this finding is a verified false positive caused by a limitation in Bandit's static analysis, which triggers on the `yaml.load` function name without evaluating the inheritance chain of the provided `Loader` argument. + +## Resolution +The `yaml.load` call has been annotated with `# nosec B506` to suppress the false positive locally. We retain this suppression strictly under the condition that `UniqueKeyLoader` remains a subclass of `yaml.SafeLoader` and is explicitly provided to `yaml.load`. + +## Rollback Criteria +If the YAML loader implementation is modified to inherit from an unsafe loader, or if `yaml.load` is used without explicitly providing the safe custom loader, this disposition must be revoked and the `# nosec B506` annotation removed. + +## References +PyCQA. (2024). *B506: Test for use of yaml load*. Bandit Documentation. https://bandit.readthedocs.io/en/latest/plugins/b506_yaml_load.html +PyYAML. (2024). *PyYAML Documentation: Loading YAML safely*. https://pyyaml.org/wiki/PyYAMLDocumentation#loading-yaml-safely diff --git a/docs/doctoring/calendar-a11y.md b/docs/doctoring/calendar-a11y.md new file mode 100644 index 000000000..a78d17cb2 --- /dev/null +++ b/docs/doctoring/calendar-a11y.md @@ -0,0 +1,25 @@ +# Calendar Coordination View Accessibility + +`CalendarCoordinationView.tsx` presents numbered meeting proposals with date, time, attendance status, and a visible `제안하기` action label. + +## Accessibility problem + +A short `aria-label` on the button would replace the descendant-derived accessible name and could omit the date, time, or attendance information needed to distinguish the proposals. Purely visual repetition can also make screen-reader output unnecessarily noisy. + +## Implemented pattern + +Each button keeps its essential visible text in the accessibility tree and adds a visually hidden contextual prefix such as `1안 제안하기: `. The duplicated visual option badge and trailing action label use `aria-hidden="true"`. The native button role and existing `focus-visible` ring remain intact. + +This component pattern aligns with WCAG 2.2 Success Criterion 4.1.2, **Name, Role, Value**, and Success Criterion 2.4.7, **Focus Visible**. This scoped implementation statement does **not** establish conformance of the whole Naruon product. + +## Research note + +Lazar et al. (2007) studied 100 blind web users and identified confusing screen-reader feedback and poorly designed or unlabeled controls among the leading sources of frustration. The proposal-button pattern therefore preserves task-specific context in the computed accessible name instead of relying on visual grouping alone. + +## References + +Lazar, J., Allen, A., Kleinman, J., & Malarkey, C. (2007). What frustrates screen reader users on the web: A study of 100 blind users. *International Journal of Human–Computer Interaction, 22*(3), 247–269. https://doi.org/10.1080/10447310709336964 + +World Wide Web Consortium. (2023a). *Understanding Success Criterion 2.4.7: Focus visible*. https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html + +World Wide Web Consortium. (2023b). *Understanding Success Criterion 4.1.2: Name, role, value*. https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html diff --git a/frontend/src/components/calendar/CalendarCoordinationView.test.tsx b/frontend/src/components/calendar/CalendarCoordinationView.test.tsx new file mode 100644 index 000000000..97825f4c8 --- /dev/null +++ b/frontend/src/components/calendar/CalendarCoordinationView.test.tsx @@ -0,0 +1,64 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { CalendarCoordinationView } from "./CalendarCoordinationView"; + +describe("CalendarCoordinationView", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(() => { + if (root && container) { + act(() => { + root!.unmount(); + }); + container.remove(); + } + container = null; + root = null; + }); + + it("renders buttons with distinct accessible names including date and attendance", () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root!.render(); + }); + + const buttons = container.querySelectorAll("button"); + expect(buttons).toHaveLength(2); + + // Assert focus class + buttons.forEach((btn) => { + expect(btn.className).toContain("focus-visible:ring-2"); + }); + + // Check sr-only span content within buttons to ensure computed accessible name contains it + const button1 = buttons[0]; + const button2 = buttons[1]; + + expect(button1.textContent).toContain("1안 제안하기:"); + expect(button1.textContent).toContain("5월 23일 (목) 14:00 - 15:00"); + expect(button1.textContent).toContain("모든 참석자 참석 가능"); + + expect(button2.textContent).toContain("2안 제안하기:"); + expect(button2.textContent).toContain("5월 24일 (금) 10:00 - 11:00"); + expect(button2.textContent).toContain("1명(김개발) 불참 예상"); + + // Check aria-hidden on decorative elements + const ariaHiddenElements = container.querySelectorAll('[aria-hidden="true"]'); + // There are 2 option badges (1안, 2안) + 2 propose labels (제안하기) = 4 + expect(ariaHiddenElements).toHaveLength(4); + + // verify option labels are aria-hidden + expect(Array.from(ariaHiddenElements).some(el => el.textContent === '1안')).toBe(true); + expect(Array.from(ariaHiddenElements).some(el => el.textContent === '2안')).toBe(true); + + // verify propose labels are aria-hidden + const proposeLabels = Array.from(ariaHiddenElements).filter(el => el.textContent === '제안하기'); + expect(proposeLabels).toHaveLength(2); + }); +}); diff --git a/frontend/src/components/calendar/CalendarCoordinationView.tsx b/frontend/src/components/calendar/CalendarCoordinationView.tsx index 41bf3b893..014581449 100644 --- a/frontend/src/components/calendar/CalendarCoordinationView.tsx +++ b/frontend/src/components/calendar/CalendarCoordinationView.tsx @@ -7,23 +7,25 @@ export function CalendarCoordinationView() {