Skip to content

feat: phase 3 pr #10 — project detail (overview + components tabs)#3

Merged
haksungjang merged 4 commits into
mainfrom
feature/phase3-pr10-project-detail
May 6, 2026
Merged

feat: phase 3 pr #10 — project detail (overview + components tabs)#3
haksungjang merged 4 commits into
mainfrom
feature/phase3-pr10-project-detail

Conversation

@haksungjang

Copy link
Copy Markdown
Contributor

Summary

Phase 3 PR #10 — Project Detail (Overview + Components 탭). docs/v2-execution-plan.md §3.4 표 3.1 / 3.2 / 3.3 산출.

3개 신규 backend 엔드포인트 + ProjectDetailPage UI + 4개 탭 (Overview / Components 활성, Vulnerabilities / Licenses 는 PR #11/#12 placeholder) + 1만 행 가상 스크롤 드로어 + EN/KO 번역 + e2e 6 시나리오.

Backend (apps/backend/)

3 신규 엔드포인트, 모두 Depends(get_current_user) + IDOR 가드 + RFC 7807 problem+json:

  • GET /v1/projects/{id}/overview — risk score (0~100), severity_distribution, license_distribution, recent_scans (5). asyncio.gather 로 집계 + 스캔 이력 병렬 SELECT. p95 < 200ms 달성 (db-designer EXPLAIN 검증, 신규 마이그레이션 불필요).
  • GET /v1/projects/{id}/components — 1만 행 페이지네이션 (limit ≤ 500). search ILIKE / severity multi-filter / license_category multi-filter / sort by name|severity|license × asc|desc. cross-team component existence 는 404 (hide-existence — 의도적).
  • GET /v1/components/{id} — 드로어 페이로드 (vulnerabilities + raw_data + license).

중요 schema 결정: 실제 DB 모델은 severity_max / license_category 컬럼이 components 또는 scan_components 에 없음. project → latest_scan_id → scan → scan_components → component_versions → components → vulnerability_findings/license_findings join 으로 query-time aggregation. 현 1만 행 cap 에서 적합. 100k+ 행 도달 시 denormalized cache 또는 materialized view 검토 — Phase 3+ follow-up.

37 신규 테스트 (7 pure-unit + 19 service-DB + 11 HTTP-integration). ruff + mypy --strict clean.

Frontend (apps/frontend/)

  • ProjectDetailPage — 4 탭 + breadcrumb + 헤더 RiskGauge.
  • OverviewTab — Risk gauge (semicircular SVG) + severity / license stacked bars + recent-5 scans 테이블. recharts 의도적 미사용 (bundle 200kB + v1 XSS surface 회피).
  • ComponentsTab — react-virtuoso TableVirtuoso 1만 행 가상 스크롤 + 인라인 toolbar (search 디바운스 300ms / severity / license / sort) + URL search-params 동기화 (deep-linking) + 우측 슬라이드 Sheet 드로어.
  • ComponentDrawer — vuln 리스트 + raw_data accordion (<pre> + JSON.stringify text node, dangerouslySetInnerHTML 미사용).
  • shadcn-style Tabs primitive 자체 구현 (@radix-ui/react-tabs 미추가 — 동일 shape 라 미래 swap 무중단).
  • 라우터: /projects/:id 추가, ProjectListPage row name → <Link>.

41 신규 vitest 케이스. coverage 93.41% lines / 85.29% branches / 87.09% funcs (≥ 80% 게이트 통과).

i18n (EN + KO)

project_detail namespace, 84 키 양쪽 완성. 도메인 용어는 기존 common.risk (치명/높음/중간/낮음/정보) + projects.status (대기 중/실행 중/성공/실패) 와 정합.

신규 도메인 용어 8개 — docs/glossary.md 정식 등재 후보:

  • Severity = "심각도"
  • Forbidden / Conditional / Allowed = "금지 / 조건부 / 허용" (CLAUDE.md 라이선스 분류 표 일치)
  • Cancelled = "취소됨", Source / Container = "소스 / 컨테이너", Fixed in = "수정 버전", Breadcrumb = "탐색 경로", Unknown (license) = "미상"

E2E

PortalPage 하네스에 8 verb 추가 (6 사용자 요청 + 2 ergonomic). 모두 event-driven wait — page.waitForTimeout 미사용.

project_detail.spec.ts — 6 시나리오:

  1. Open detail → Overview default 렌더 (4 카드 + risk-gauge).
  2. Components endReached 페이지네이션 성장 (250 rows).
  3. Drawer open + URL 동기화.
  4. Search 디바운스 + clear 시 total 복원.
  5. Severity multi-filter + URL 동기화.
  6. Sort key + order toggle (severity desc → asc).

시나리오 2 의 250 rows 는 CI 30s/test 예산 준수. seed CLI 는 --component-count 10000 도 지원 (ad-hoc perf 테스트).

seed 스크립트 확장: --with-scan / --component-count / --component-prefix 신규 플래그 (배치 100 rows, severity / license_category round-robin 분포).

Security review (security-reviewer)

평결: PASS (블로커 0, Medium 1, Low 4, Info 3).

등급 항목 본 PR 머지 차단?
Medium raw_data redaction 누락 — cdxgen/DT 원본의 잠재적 시크릿 (npm _auth, git tokens) 가 same-team developer 에게 노출 No — 256 KiB JSONB 가드 (PR #8) 가 mitigation. Phase 8 follow-up 으로 redaction 레이어 추가
Low severity / license_category enum router-level 검증 부재 (서비스 레이어가 화이트리스트 dedup 으로 mitigation) No — follow-up
Low offset 상한 부재 (10k 행 cap 데이터 모델에서 영향 미미) No — Phase 8 keyset 전환 시 자연 해소
Low IDOR 시도 미로깅 (기존 service_project.get_project 와 동일 패턴) No — 별도 PR 일괄 적용
Low search ILIKE %/_ 미escape (UX 이슈, SQL 주입 아님 — bind parameter) No — UX polish
Info 통합 테스트 cross-team component-detail 케이스 누락 (단위 커버리지로 충분) No
Info seed --component-count 상한 부재 (운영자 CLI 전용) No
Info dynamic 스캔 (bandit/semgrep/gitleaks/pip-audit) 본 라운드 실행 불가 — venv 부재 No — CI 결과로 보강

Test plan

  • CI 전체 잡 (lint × 2 / typecheck × 2 / test × 2 / image-scan (hard-fail) / frontend-bundle-audit / e2e) 모두 success.
  • image-scan hard-fail 통과 (PR chore: harden worker image + restore image-scan hard-fail #2.trivyignore 정책 + npm 11.13 fix 가 본 PR 에서도 유효 — 본 PR 은 이미지 변경 없음).
  • e2e 6 시나리오 green (@project-detail 태그). 시나리오 2 의 endReached 트리거 확인.
  • backend coverage report 에서 신규 코드 ≥ 80% line.
  • frontend coverage report 93.41% 유지.

Follow-up backlog (별도 PR)

  1. Phase 8 hardening — raw_data redaction 레이어 — security-reviewer Medium chore(deps): bump fastapi/starlette/multipart/pyasn1/jose for HIGH CVEs + restore image-scan hard-fail #1.
  2. Phase 8 hardening — 신규 read API rate limit (60 req/min/user).
  3. 별도 PR — service-layer IDOR 시도 일괄 로깅 — security-reviewer Low feat: phase 3 pr #10 — project detail (overview + components tabs) #3 + 기존 service_project 도 동일 변경.
  4. 별도 PR — bandit / semgrep / gitleaks / pip-audit GitHub Actions 잡 추가 — security-reviewer Info.
  5. PR chore: drop fetcher reference_url + UAT v2 fixes (chore PR #7) #11 — Vulnerabilities 탭 (현재 disabled placeholder).
  6. PR docs: chore PR #6 + #7 session handoffs + Phase 4 PR #14 prompt #12 — Licenses 탭 (현재 disabled placeholder).
  7. docs/glossary.md 등재 — i18n-specialist 가 식별한 신규 도메인 용어 8개.

Refs: docs/v2-execution-plan.md §3.4 + docs/sessions/2026-05-06-chore-worker-image-hardening.md (chore PR #2 핸드오프, 본 브랜치 첫 commit 4e886d9 로 동행).

🤖 Generated with Claude Code

haksungjang and others added 4 commits May 6, 2026 18:38
…ignore policy

Records the 3-round (8dca1faa1e2e14ac39bf0) Trivy 33 → 0 path:

  Round 1: apt upgrade + cdxgen 11→12 → 33 → 27 (FAIL, security-reviewer)
  Round 2: npm 11.13 self-upgrade + .trivyignore for ORT 3 jars → 27 → 12
           (FAIL — cdxgen-plugins-bin postinstall defeats --omit=optional)
  Round 3: .trivyignore policy expanded to category (3) "fixed-upstream-
           but-bundled-in-dev-tooling-runtime-unreached" with 12 cdxgen-
           plugins-bin gobinary entries (CRITICAL grpc CVE-2026-33186 +
           11 HIGH) → 0 findings, image-scan hard-fail green.

Documents the empirical finding that `npm install --omit=optional` is
ineffective on cdxgen 12.x (postinstall script downloads plugins-bin
independently of the optionalDependencies key) so future contributors
don't repeat the same investigation cycle.

Phase 8 hardening backlog logged: carve plugins-bin out at image build
once cdxgen self-test compatibility is verified, base digest pin,
non-root USER, NodeSource curl|bash → signed-by, npm audit signatures.

Routed onto the same branch (feature/phase3-pr10-project-detail) as the
Phase 3 work that follows so the handoff lands on main with that PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 PR #10 — Project Detail API + UI per docs/v2-execution-plan.md
§3.4 tables 3.1 / 3.2 / 3.3.

Backend (apps/backend/):
  3 new endpoints under /v1, all auth-gated (developer role) with
  IDOR guard + RFC 7807 problem+json on 4xx/5xx:
    - GET /v1/projects/{id}/overview        — risk score, severity +
        license distributions, recent 5 scans (concurrent SELECTs via
        asyncio.gather; uses ix_scans_project_created_at + existing
        scan_components / vulnerability_findings / license_findings
        indexes per db-designer's EXPLAIN review — no migration needed).
    - GET /v1/projects/{id}/components      — 1만-row paginated list
        (limit ≤ 500, offset ≥ 0); search (ILIKE), severity / license_
        category multi-filter, sort by name|severity|license × asc|desc.
        Items + count run concurrently. Cross-team component existence
        is hidden (404, not 403) — deliberate hide-existence.
    - GET /v1/components/{id}               — drawer payload (vuln list
        + raw_data jsonb + license).

  schemas/project_detail.py (185 LOC) — ProjectOverviewResponse,
  ScanSummary, ComponentSummary, ComponentListResponse, VulnerabilityRef,
  ComponentDetailResponse. Severity / license category as Literal so
  OpenAPI shows allowed values exactly.

  services/project_detail_service.py (723 LOC) — query-time aggregation
  off project.latest_scan_id (the actual schema does not denormalize
  severity_max / license_category onto components — both come from
  vulnerability_findings → vulnerabilities and license_findings →
  licenses joins). _can_access_team mirrors services/project_service.

  scripts/seed_e2e_user.py — new flags --with-scan, --component-count,
  --component-prefix for e2e fixture data.

  Tests (37 new, ruff + mypy --strict clean):
    - unit/test_risk_score.py (7 pure-unit)
    - unit/test_project_detail_service.py (19 service-DB integration)
    - integration/test_project_detail_api.py (11 HTTP + RFC 7807)

Frontend (apps/frontend/):
  ProjectDetailPage with shadcn-style Tabs (4 tabs; Vulnerabilities /
  Licenses are disabled placeholders for PR #11/#12). Hand-rolled
  minimal Tabs primitive (no @radix-ui/react-tabs dep) — same shape
  so a future swap is non-breaking.

  OverviewTab — Risk gauge (semicircular SVG, no recharts) + severity
  & license stacked bars + recent-5 scans table. All charts are pure
  SVG / Tailwind divs (recharts deliberately not added — bundle size
  + v1 XSS surface avoidance).

  ComponentsTab — react-virtuoso TableVirtuoso for 10k-row virtual
  scroll, infinite-scroll via TanStack Query useInfiniteQuery; inline
  toolbar (search debounced 300ms, severity / license multi-select
  filters, sort key + order); URL search-params sync (deep-linking
  for ?tab, ?search, ?severity, ?license_category, ?sort, ?order,
  ?drawer). Row click opens right-slide Sheet drawer; drawer fetches
  detail lazily on open.

  ComponentDrawer — vuln list (CVE id + severity badge + CVSS +
  fixed_in), raw_data accordion (collapsible JSON viewer via <pre>
  + JSON.stringify; React text-node escapes — no innerHTML).

  Tests (41 new, vitest):
    - 9 spec files; coverage 93.41% lines / 85.29% branches / 87.09%
      funcs (above 80% gate).

  i18n: project_detail namespace with 84 keys, both EN + KO complete
  (i18n-specialist mapped against existing common.risk + projects.status
  conventions; new domain terms — Severity, Forbidden/Conditional/
  Allowed, Cancelled, Source/Container, Fixed in, Breadcrumb, Unknown
  — logged for docs/glossary.md follow-up).

  Router: /projects/:id added (RequireAuth-guarded) between /projects
  and the catch-all. ProjectListPage row name now navigates via Link.

E2E (apps/frontend/tests/):
  PortalPage harness extended with 8 verbs: openProjectDetail, selectTab,
  filterComponentsBySeverity, searchComponents, openComponentDrawer,
  assertRiskScore + 2 ergonomic helpers. All event-driven waits — no
  page.waitForTimeout.

  project_detail.spec.ts — 6 scenarios:
    1. Open detail → Overview default render (4 cards + risk-gauge)
    2. Components endReached pagination growth (250 rows)
    3. Drawer open + URL sync
    4. Search debounce + clear restores total
    5. Severity multi-filter + URL sync
    6. Sort key + order toggle (severity desc/asc)

  Scenario 2 seeds 250 components (not 10k — keeps suite under 30s/test
  CI budget). The CLI still supports --component-count 10000 for ad-hoc
  perf testing; 60fps validation deferred to Lighthouse measurement
  pre-GA.

Security review (security-reviewer): PASS, 0 blockers, 1 Medium
(raw_data redaction — same-team exposure surface introduced; mitigated
by 256 KiB JSONB size guard from PR #8; logged as Phase 8 follow-up
PR), 4 Low + 3 Info — all logged as separate follow-up PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…functions

CI run 25430060584 backend test job failed with:
  asyncpg.exceptions.UndefinedFunctionError:
    operator does not exist: vuln_severity = character varying
  HINT: No operator matches the given name and argument types.
        You might need to add explicit type casts.

Postgres ENUM types do not implicitly compare with varchar. The CASE
expression form `case({literal("foo"): N, ...}, value=Column)` compiles
to `CASE Column WHEN 'foo' THEN N END`, where 'foo' is bound as varchar
— which Postgres refuses to compare with the vuln_severity / license_
category ENUM columns. The error surfaces only at execute time
(asyncpg prepare phase), so unit + integration suites both went red.

Fix: cast the LHS column to text inside the CASE so dict-key string
literals compare cleanly. No schema change — purely a query rewrite.

  case({literal("critical"): 5, ...}, value=Vulnerability.severity)
  → case({literal("critical"): 5, ...},
         value=cast(Vulnerability.severity, String))

Same fix applied to _license_rank_case() (LicenseModel.category is
the license_category ENUM).

Both functions ship inline comments explaining the cast so future
contributors don't strip it.

Refs: PR #10 (feature/phase3-pr10-project-detail), CI run 25430060584,
job 74593651095.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI run 25430459893 backend test job failed with:
  asyncpg.exceptions.UniqueViolationError: duplicate key value violates
  unique constraint "uq_licenses_spdx_id"

  FAILED tests/unit/test_project_detail_service.py::
    test_component_detail_returns_drawer_payload_with_vulns

The unit-test DB fixture is module-scoped (`_migrate_once` runs alembic
upgrade head exactly once per module, then commits from each test
survive into the next). Multiple tests in this file legitimately ask
for the same hardcoded spdx_id (MIT for both `test_overview_aggregates_
severity_and_license_distributions` and `test_component_detail_returns_
drawer_payload_with_vulns`), so the second INSERT hits
`uq_licenses_spdx_id`.

Two paths considered:

  A. Make every test use unique spdx_ids (`MIT-{suffix}` etc).
     Rejected — the readability of `MIT`/`GPL-3.0`/`AGPL-3.0`/
     `Apache-2.0` in test code is intentional (they exercise category
     classification at the spdx-id level), and any future test adding
     a new hardcoded spdx_id would silently break too.

  B. Make `_make_license` idempotent (SELECT first, INSERT when absent).
     Adopted. The fixture now treats `spdx_id` as a stable handle —
     the first call inserts, subsequent calls in any test reuse the
     existing row. `category` of an existing row wins; callers that
     need a different category must pick a different spdx_id (a
     constraint documented in the new docstring).

Refs: PR #10 (feature/phase3-pr10-project-detail), CI run 25430459893,
job 74595050764.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@haksungjang haksungjang merged commit 7d6f66d into main May 6, 2026
haksungjang added a commit that referenced this pull request May 7, 2026
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang added a commit that referenced this pull request May 7, 2026
…_like import

Two CI failures from the previous chore-pr3 commit:

1. typecheck (backend) — `tests/unit/test_license_service.py` imported
   `_escape_like` from `services.vulnerability_service`. After chore PR
   #3 promoted the helper to `core.sql_safety`, the vulnerability_service
   re-export became a renamed alias which mypy strict refuses to count
   as "explicitly exported." The test now imports `escape_like as
   _escape_like` directly from `core.sql_safety` — same call site
   alias, no in-tree underscore re-export.

2. test (backend) — the four `/notice` integration tests (text inline,
   markdown, download, UTF-8 round-trip) all returned 500 because
   slowapi's `@limiter.limit("10/minute")` wrapper inherits slowapi's
   own module as its `__globals__`. With `from __future__ import
   annotations` enabled, FastAPI calls `typing.get_type_hints()` on the
   wrapper and Pydantic raises "TypeAdapter is not fully defined" for
   the `uuid.UUID` path parameter. Mirror the workaround already in
   `apps/backend/api/v1/auth.py` for the login endpoint: patch the
   names the wrapper needs (`uuid`, `AsyncSession`, `Request`,
   `Response`, `Depends`, `Query`, `CurrentUser`) into the wrapper's
   `__globals__` after the decorator applies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang added a commit that referenced this pull request May 7, 2026
CI image build (run 25475555213, jobs `e2e (scan-flow)` + `image-scan
(worker)`) failed at worker stage 12/15 with:

    sha256sum: 'standard input': no properly formatted checksum lines found

Root cause: `https://go.dev/dl/<file>.sha256` returns an HTML refresh
page (the human download UI), not the raw 64-hex string. The previous
`expected_sha="$(curl ...)"` captured the HTML and fed it to
`sha256sum -c -`, which then choked on `<!DOCTYPE html>` as a
non-checksum line. The tarball curl on the same path worked because
it follows the redirect to dl.google.com transparently with `-L`.

Fix:
* Switch the Go tarball URL to `https://dl.google.com/go/<file>`
  (where Go's CDN serves both the tarball and a raw `.sha256`).
* Pin the per-arch Go SHA-256 as a build-time `ENV` constant
  (`GO_SHA256_AMD64` / `GO_SHA256_ARM64`) — same pattern Debian's
  `golang` image uses. Bumping the version now requires bumping the
  hash deliberately. This also addresses chore PR #4
  security-reviewer Medium #3 (TOFU same-origin hash → pinned
  constant), so we collapse both items into one fix instead of
  carrying it to chore PR #5.
* Same treatment for Gradle: pin `GRADLE_SHA256` as ENV constant
  rather than fetching the `.sha256` companion at build time. The
  Gradle URL did work (Gradle's CDN serves a raw checksum), but the
  consistency win + the security-reviewer recommendation outweigh
  the "fetch dynamically" ergonomics.

Verified by manually fetching the published hashes against pinned
versions:
    curl https://dl.google.com/go/go1.22.7.linux-amd64.tar.gz.sha256
    curl https://dl.google.com/go/go1.22.7.linux-arm64.tar.gz.sha256
    curl https://services.gradle.org/distributions/gradle-8.10.2-bin.zip.sha256

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang added a commit that referenced this pull request May 7, 2026
…ation (chore PR #5) (#9)

* chore(scan): scrub worker secrets from prep subprocesses + dt_resync source guard

Part A — security-reviewer Medium #1 follow-up. `_run_prep` previously
inherited the full worker env (DT_API_KEY / SECRET_KEY / DATABASE_URL
credentials / *_WEBHOOK_URL) into `bundle lock` / `cargo generate-lockfile` /
`go mod tidy` / `dotnet restore`. A hostile clone (malicious NuGet feed,
Go `replace` directive) could exfiltrate those through resolver
telemetry. Now we hand each prep subprocess a `_scrubbed_env` built from
a documented allowlist (PATH/HOME/LANG + per-language config vars only)
and seed `.NET` telemetry-opt-out by default.

Part E.1 — `_upsert_vulnerability` previously called `raw.get("source", {})
.get("name")` to derive `external_id`, which raises AttributeError on
DT 4.13's plain-string `source` shape. The fallback through `vulnId`
masked it most of the time, but the asymmetry with the line 110-118
`source` resolution was inconsistent. Both reads now share the same
isinstance-guarded shape.

Tests:
- `test_run_prep_passes_only_allowlisted_env` pins that DT_API_KEY /
  SECRET_KEY / DATABASE_URL / SLACK_WEBHOOK_URL are NOT inherited.
- `test_run_prep_seeds_dotnet_telemetry_optout` pins the default
  telemetry-opt-out injection.
- 37 prep tests stay green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(settings): formalize git push + gh pr merge allow rules

Carry-over from chore PR #3. The user's policy update (2026-05-06)
authorized Claude to run `git push` and `gh pr merge` directly; the
working-tree change had been sitting on main since then. This is the
user's edit verbatim — no further modification by Claude.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(deps,trivy): bump python-multipart 0.0.27 + ignore 4 Maven UNREACHED HIGHs

chore PR #4 introduced a Maven 3.9 bundle in the worker image so we
can run `mvn dependency:tree` for Java pre-cdxgen prep. Trivy's next
scan flagged five new HIGH CVEs that did not affect chore PR #4 itself
(soft-fail). This commit triages all five:

- python-multipart 0.0.22 → 0.0.27 — direct dep, picks up the
  CVE-2026-42561 boundary-parsing DoS fix.
- 4 Maven JARs (netty-codec, netty-codec-http, bcpg-jdk18on,
  plexus-utils) — vendored alongside `mvn`. We only ever invoke
  `mvn -B -q -DskipTests dependency:tree`; none of the vulnerable
  code paths (HTTP server frames, OpenPGP packet parsing, untrusted
  XML XXE) are reached by a resolver-only invocation. Added to
  .trivyignore as Category (3) UNREACHED with per-CVE reach analysis
  matching the policy header.

Trivy stays soft-fail until Phase 8 GA hardening — this commit is
about keeping the residual list explicit, not about gating the build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(docs): archive completed chore PR #3 / #4 next-session prompts

Both prompts were used to start their respective sessions and are now
historical record only. Moving them out of the live `docs/sessions/`
root keeps `git status` clean for subsequent sessions and preserves
the original briefings under `docs/sessions/archive/next-session-prompts/`
for traceability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(authz): migrate project / project_detail / vulnerability services to assert_team_access

Part D of chore PR #5 (carry-over from chore PR #3). Replaces 11 remaining
`if not _can_access_team(...): raise XxxForbidden/NotFound(...)` call sites
across project_service (3), project_detail_service (3), and
vulnerability_service (3 + 2 redundant log.warning blocks) with the
centralized `assert_team_access(actor, team_id, log=..., resource=...,
resource_id=..., deny=lambda: ExistingException(...))` helper.

Behavior preserved: same exception class, same message, same 403/404
existence-hide policy. The `authz.cross_team_attempt` warning event now
flows through a single shape, matching license_service / obligation_service
which were migrated in chore PR #3. The vulnerability_service duplicate
inline `log.warning("authz.cross_team_attempt", ...)` blocks before the
raise are removed (the helper emits the same event).

scan_service.py keeps its local `_can_access_team` for now and is tracked
as a separate carry-over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(scan): silence S108 on _scrubbed_env HOME default with rationale

Inline noqa + comment per security-reviewer Medium #1 implementation
review. The `/tmp` value is a HOME hint for resolver config caches
(`~/.cargo`, `~/.dotnet`), not a tempfile-creation site — S108's
collision / symlink-race rationale doesn't apply, and the workspace is
wiped after every scan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(ui): swap stand-in tabs primitive for @radix-ui/react-tabs

Replace the hand-rolled Tabs stand-in (PR #10) with the canonical shadcn/ui
component built on @radix-ui/react-tabs@1.1.0. The exported API
(Tabs / TabsList / TabsTrigger / TabsContent) and the visual styling
(border-b strip, h-9 trigger, primary underline on the active tab) are
preserved 1:1 — existing call sites and Playwright harness selectors
(role="tab", data-state, data-testid) keep working unchanged. Active-state
styling now uses radix's data-[state=active] selector instead of a
conditional className, matching the upstream shadcn pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(integrations): add multi-ecosystem license fetcher (Maven/PyPI/crates/pkg.go.dev)

The 2026-05-07 UAT matrix flagged 91/39/164/29 unknown licenses on the
pilot-java-maven / pilot-python / pilot-rust / pilot-go scans because
cdxgen does not always extract declared licences for transitive deps.
This adds a post-process fetcher that hits each ecosystem's authoritative
registry to fill those gaps with SPDX ids, caches the answer (positive
or negative) in a new license_fetch_cache table with a 24h TTL, and
emits the result as a "concluded" LicenseFinding so the downstream UI
can tell registry-derived licences apart from cdxgen's "declared" ones.

Module layout:
  integrations/license_fetcher/
    base.py    — LicenseFetchResult, request_with_retry helper,
                 SPDX normalization (free-text → canonical id), per-host
                 throttle (crates.io 1 req/sec, others 0.1-0.25s).
    maven.py   — repo1.maven.org/<g>/<a>/<v>/<a>-<v>.pom XML parser.
    pypi.py    — pypi.org/pypi/<n>/<v>/json, prefers Trove classifiers.
    crates.py  — crates.io/api/v1/crates/<n>/<v>, SPDX expression
                 (compound expressions yield None — same policy as
                 _extract_spdx_ids).
    pkggo.py   — pkg.go.dev HTML scrape of the UnitMeta-license block.
    __init__.py — dispatch by purl prefix + cache layer (UPSERT keyed
                 on purl; positive + negative cache share TTL).

Schema (forward-only, Alembic 0004): license_fetch_cache
  purl TEXT PK, spdx_id TEXT NULL, reference_url TEXT NULL,
  source TEXT NOT NULL, is_negative BOOL NOT NULL, fetched_at TIMESTAMPTZ.

scan_source._persist_component_licenses now falls back to the fetcher
when cdxgen returned zero SPDX ids for the component; cdxgen wins when
it has metadata so the cost-saver path is preserved.

Tests (104 new):
  unit/integrations/license_fetcher/
    test_base.py       — normalize_spdx_id (10 happy + 7 reject),
                         request_with_retry (8 cases — 2xx/404/5xx-with-
                         retry/429/persistent-5xx/4xx-no-retry/transport-
                         error/no-retry-on-403).
    test_maven.py      — POM parsing, 404, no-licences, unmapped
                         free-text, 5xx retry, foreign purl prefix.
    test_pypi.py       — Trove classifier preferred, free-text fallback,
                         404, invalid JSON, URL shape pin.
    test_crates.py     — SPDX expression direct, compound rejected,
                         missing field, 404, 429 retry.
    test_pkggo.py      — primary anchor, fallback anchor, missing block,
                         404, fallback-to-normalized free-text.
    test_dispatch.py   — prefix routing, cache miss/hit positive/hit
                         negative/expired/unsupported-ecosystem/empty-purl,
                         TTL env override.
  integration/scan/test_license_fetcher_integration.py — real
    license_fetch_cache UPSERT, TTL refresh, "cdxgen has license → fetcher
    not called" pin.

Coverage: 88% lines on changed integrations/license_fetcher/* code.
ruff + mypy clean. 697 unit + 141 integration tests green.

Refs: docs/sessions/_next-session-prompt-chore-pr5.md Part B
      docs/sessions/2026-05-07-uat-multi-ecosystem-matrix.md §4.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(integrations): cdxgen Gradle 8 init.gradle compatibility

The 2026-05-07 UAT found pilot-java-gradle returned 0 components: cdxgen
v11.x's bundled init.gradle calls allprojects { ... } at root scope,
which Gradle 8 removed (the build aborts with "Could not get unknown
property 'allprojects' for root project" before cdxgen can enumerate
configurations).

Fix: when run_cdxgen detects a Gradle build root (build.gradle{,.kts} /
settings.gradle{,.kts}) it writes a small Gradle 8 compat init script
into the cdxgen output dir and exposes it via CDXGEN_GRADLE_ARGS=
"--init-script <path>". cdxgen passes the args through to gradle, the
shim runs first, re-binds the missing allprojects closure to
rootProject.allprojects, and cdxgen's component enumeration completes
normally. The shim is a no-op when an operator already set
CDXGEN_GRADLE_ARGS in the worker env (explicit override wins).

Strategy choice (per the prompt):
  Option 1 (--no-init-gradle CLI flag) — not available across all
    cdxgen 11.x point releases shipped in the worker image. Skipped to
    avoid version-coupling.
  Option 2 (env-var-driven init script) — chosen. Works on every cdxgen
    11.x build that honours CDXGEN_GRADLE_ARGS, which is the canonical
    knob in the cdxgen 11 line. Trivially reverted (delete the helper)
    when cdxgen 12 lands with native Gradle 8 support.
  Option 3 (separate cdxgen-java11 image) — heavyweight, deferred.

Tests (15 cases):
  test_cdxgen_gradle_compat.py
    - _is_gradle_project: each marker file (build.gradle / .kts /
      settings.gradle / .kts) flips the detector True; package.json /
      empty dir → False.
    - _write_gradle_compat_init: file exists, lives under output_dir,
      content matches _GRADLE8_COMPAT_INIT, parent dir is created
      lazily.
    - _build_cdxgen_env: Gradle root → CDXGEN_GRADLE_ARGS injected and
      script written; non-Gradle root → no env var, no script;
      operator-supplied CDXGEN_GRADLE_ARGS wins (no clobber).

Pilot validation is out of scope for CI — actual scan-result re-check
(component count ≥ 30 on pilot-java-gradle) lands in the next UAT round.

Refs: docs/sessions/_next-session-prompt-chore-pr5.md Part C
      docs/sessions/2026-05-07-uat-multi-ecosystem-matrix.md §4.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(scan,settings): adopt security-reviewer L1 + I1 follow-ups

Two findings from the chore PR #5 Producer-Reviewer pass that are
small enough to land inline:

- L1 (Low) — `_PREP_ENV_ALLOWLIST` was dropping corporate CA / proxy
  hints (SSL_CERT_FILE / SSL_CERT_DIR / REQUESTS_CA_BUNDLE /
  NODE_EXTRA_CA_CERTS / HTTP[S]_PROXY / NO_PROXY in both upper- and
  lower-case). On-prem evaluators behind a TLS-intercepting proxy
  saw `go mod tidy` / `dotnet restore` fail silently with x509
  errors, surfacing only in `prep_failed` log lines. These vars are
  operator-chosen — a hostile clone cannot influence them — so they
  are safe to forward.

- I1 (Info) — `Bash(git push *)` allow rule (commit 2965283) accepts
  destructive push variants. Per memory `feedback_push_pr_authorized`
  the user authorized push/PR-create direct execution but reserved
  force-push for explicit approval. Adding deny rules for
  `--force` / `-f` / `--force-with-lease` / `--delete` / `--mirror`
  to align settings.json with documented policy.

Two reviewer Mediums (M1 cdxgen/ORT scope gap, M2 Maven reference_url
phishing surface) are flagged as immediate follow-up chore PRs in the
session handoff — neither is a regression of the in-scope work and
both deserve their own focused Producer-Reviewer pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(sessions): chore PR #5 security follow-up + license fetcher handoff

11 commits, 35 files (+4199 / -275 LoC), security-reviewer PASS with
conditions (0 Critical / 0 High / 2 Medium / 4 Low / 3 Info). L1 + I1
absorbed inline (4d2c619). M1 (cdxgen/ORT env scope gap) and M2 (Maven
reference_url phishing) deferred to focused follow-up PRs with their
own Producer-Reviewer pass.

Three options for the next session:
  A. Phase 4 entry — notification system PR #14 (model + API + RBAC).
  B. Medium #1 v2 + L4 — cdxgen/ORT subprocess env scrub +
     license-fetcher follow_redirects=False.
  C. Medium #2 + UAT re-validation — drop fetcher-derived reference_url
     + re-run 5-pilot ecosystem matrix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(tests): apply ruff import-order fix to license_fetcher conftest

Auto-fix from `ruff check --fix` that was generated locally during the
chore PR #5 verification but missed inclusion in the original 9a00b86
commit. CI lint job caught it. Pure import reorder, no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@haksungjang haksungjang deleted the feature/phase3-pr10-project-detail branch May 10, 2026 13:04
haksungjang added a commit that referenced this pull request May 11, 2026
Marathon bundle 8 (L1). Splits PostgreSQL into a migration-owning role
(``trustedoss`` — DDL) and a runtime DML-only role (``trustedoss_app``)
so a runtime RCE cannot DROP the audit_logs immutable trigger and
mutate audit_logs. The audit-log append-only contract is now enforced
at three layers:

  1. Trigger (migration 0012) — row-level DDL guard.
  2. REVOKE UPDATE/DELETE/TRUNCATE on audit_logs (migration 0014) —
     even if the trigger is dropped, the runtime cannot mutate.
  3. Trigger ownership barrier — the runtime can't drop the trigger
     because it doesn't own the table.

Backend:
- alembic/versions/0014_app_role_grants.py — conditional GRANT block.
  No-op when trustedoss_app role doesn't exist (legacy / dev / single-
  role deployments). Also REVOKEs CREATE on schema public for
  defence-in-depth on PG <15.
- alembic/env.py — uses database_url_owner_sync() so DDL has the
  necessary privileges. Falls back to DATABASE_URL when
  DATABASE_URL_OWNER unset.
- core/config.py — splits database_url() (runtime, reads
  DATABASE_URL_APP first) from database_url_owner() (migrations).
- main.py lifespan — logs the connected role at boot. In APP_ENV=prod
  with DATABASE_URL_APP set, refuses to start when the runtime ended
  up connecting as a non-app role (security-reviewer Medium #2 —
  fail loud on env-wiring drift instead of silent regression).

Infra:
- docker-compose.yml — backend / worker / beat ONLY see the runtime
  DSN (DATABASE_URL_APP fallback to DATABASE_URL). Owner DSN never
  flows into long-running containers (security-reviewer Critical fix).
- scripts/install.sh — alembic exec uses an explicit
  ``-e DATABASE_URL=$DATABASE_URL_OWNER`` override so the DDL run
  uses the owner role exactly once. Generates app password +
  POSTGRES_APP_PASSWORD env.
- scripts/postgres-init.sh — first-boot psql script that creates the
  trustedoss_app role from POSTGRES_APP_PASSWORD. Uses psql
  ``--variable`` interpolation (security-reviewer Medium M4 —
  password injection safety; single-quote / backslash in password no
  longer breaks SQL parse).
- .env.example — documents DATABASE_URL_OWNER / DATABASE_URL_APP /
  POSTGRES_APP_PASSWORD with the legacy single-role fallback noted.

Tests added (7 new):
- tests/integration/test_role_separation.py — fixture creates a
  randomized trustedoss_app_test_<hex> role, applies the same grants
  as migration 0014, then verifies:
  * INSERT on audit_logs allowed.
  * UPDATE / DELETE / TRUNCATE on audit_logs denied.
  * DROP TRIGGER audit_logs_immutable_trigger denied (ownership).
  * Normal-table DML (projects) still allowed.
  * CREATE TABLE denied on PG 15+.
- Fixture returns (conn, role_name) tuple explicitly (security-reviewer
  High #2: avoids reaching into asyncpg's _params internal which a
  future driver bump could rename).
- test_alembic_upgrade head bumped to 0014.

Verification:
- pytest 12/12 green (7 role_separation + 5 alembic_upgrade).
- Manual end-to-end: created trustedoss_app + applied GRANTs →
  audit_logs has INSERT=t / UPDATE=f / DELETE=f / TRUNCATE=f, while
  projects keeps full DML.
- ruff + mypy clean. shellcheck clean (no warnings).

security-reviewer Producer-Reviewer = CHANGES REQUESTED, fixes
applied:
- Critical: docker-compose env wiring (compose anchor + install.sh
  alembic exec) → fixed.
- High #2: test fixture asyncpg internal → fixed (tuple return).
- Medium M4: postgres-init.sh password injection → fixed (psql
  --variable).
- Medium #2: silent env-mismatch in prod → fixed (lifespan refuses).
- Low #1: audit_logs ACL clean shape → fixed (REVOKE ALL + GRANT
  SELECT, INSERT).
- High #3: deny-by-default for FUTURE tables → applied (ALTER
  DEFAULT PRIVILEGES grants only SELECT, INSERT — future audit-style
  tables auto-inherit append-only posture).

Backlog (deferred to follow-up chore — security-reviewer findings):
- Existing-deployment upgrade path (upgrade.sh hook + admin health
  card + runbook) — Medium #5 below.
- Strengthen test for default-privileges idempotency across grantor
  rotation — High #3 partial.
- Migration that creates the literal trustedoss_app role and runs
  0014 against it (currently the integration test mirrors the GRANTs
  rather than exercising 0014 directly) — Low #2.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang added a commit that referenced this pull request May 22, 2026
Addresses the residual security-reviewer findings on the scan-pipeline-gap
surface (Medium #1/#2 were already fixed in #107/#109).

- Low #3: report_service._safe_href rejects URLs with embedded control chars
  (CR/LF/TAB) before scheme parsing — closes a latent CRLF-injection vector if
  the helper is reused in a header context.
- Low #4: obligation list + NOTICE endpoints now existence-hide cross-team
  reads as 404 (were 403), uniform with the detail / report / source-tree /
  SBOM endpoints — a non-member can no longer enumerate project existence.
- Low #5: the inline HTML NOTICE response carries a restrictive
  Content-Security-Policy (default-src 'none'; style-src 'unsafe-inline') so a
  future escaping regression cannot execute against the API origin.

Tests: _safe_href CR/LF/NUL cases; obligation list/notice cross-team 404
(unit + integration); NOTICE CSP present for html / absent for text.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang added a commit that referenced this pull request May 24, 2026
…-b1) (#157)

* feat(backend): GitHub App credential storage + token-minting foundation (v2.2-b1)

Lay down the credential STORAGE + token-minting foundation for the D1 GitHub
App flow (b2/b3 build PR creation on top — not implemented here).

- core/crypto.py: reversible Fernet encryption (encrypt_secret/decrypt_secret)
  with runtime key resolution — GITHUB_APP_ENCRYPTION_KEY, else deterministically
  derived from SECRET_KEY with a structured WARNING. Pin cryptography explicitly.
- models/github_app.py: github_app_credentials (team-scoped, Fernet-encrypted PEM
  private key + webhook secret, soft-delete) and github_app_installations
  (per-project opt-in). Migration 0019 (expand-only, forward-only).
- core/audit.py: mask private_key_encrypted / webhook_secret_encrypted.
- schemas/github_app.py: create/out/list + installation shapes; key plaintext
  accepted only inbound, never returned; adversarial PEM/app_id/repo validation.
- services/github_app_service.py: register/list/get/revoke + installation
  link/list/unlink (cross-team opt-in blocked) + mint_installation_token
  (RS256 App JWT, iat clock-skew clamp, exp ≤10min, injectable httpx client so
  tests mock GitHub). Never logs the PEM, JWT, or installation token.
- api/v1/github_app.py: /v1/github-app-credentials CRUD + installations, JWT
  auth, team_admin/member RBAC in the service, RFC 7807 errors, no key leak.
- Tests: crypto (both key paths, rotation), schema adversarial, service
  (encrypt-at-rest, masked audit, RBAC, mint w/ real RSA + mocked GitHub),
  endpoint RBAC/audit/RFC7807. New-code coverage 94%.
- Docs: admin-guide/github-app (EN + KO); .env.example entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(github-app): security-reviewer follow-ups — redirect-disable, GITHUB_API_URL allow-list, installation_id revalidation, prod fail-closed key, FK-vs-dup status

Medium #1: mint_installation_token POST now passes follow_redirects=False so
the App JWT can never follow a 3xx to an attacker host.

Medium #2: core.config.github_api_url() validates the value at call time
(rule #11) — in prod it requires https:// and rejects loopback, link-local
(incl. 169.254.169.254 metadata), and RFC-1918 hosts via urlparse + ipaddress.
Non-prod stays permissive for tests / local GHES. New GitHubAppConfigError.

Medium #3: mint_installation_token re-validates installation_id against
^[0-9]{1,32}$ at the trust boundary before any DB load or URL interpolation;
non-numeric / CRLF / path-traversal values raise before any HTTP attempt.

Low #1: core.crypto fails closed in prod when GITHUB_APP_ENCRYPTION_KEY is
unset/blank (raises SecretEncryptionError instead of deriving from SECRET_KEY).
Derive-from-secret remains the non-prod fallback with its WARNING.

Low #4: register_credential distinguishes the FK violation (non-existent
team_id → 404) from the unique violation (genuine duplicate → 409) via
SQLSTATE 23503 with a message-substring fallback.

Low #2/#3: docs/comments — .env.example documents the GITHUB_API_URL prod
policy and that encryption-key rotation requires re-registration (MultiFernet
rolling rotation tracked as follow-up); App-JWT builder notes the NTP-sync
clock assumption.

Tests: new test_github_api_url_config.py (prod allow-list + non-prod
permissive); crypto prod fail-closed cases; service tests for bad
installation_id (never reaches HTTP), follow_redirects=False, no-3xx-follow,
and FK-vs-duplicate status. Endpoint surface (OpenAPI) unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang added a commit that referenced this pull request May 26, 2026
Sweep the user and admin guides (EN + KO) so the prose matches what
shipped to main between W1 and the current ops-triage batch. The PR
is doc-only — no apps/* changes.

User guide
- new user-guide/dashboard.md (EN + KO) — covers the post-login `/`
  landing page introduced by W1; sidebar entry as position 0.
- projects.md: project-list meta row (`scan_count`/`release_count`/
  `last_scan_at` from #30), detail-page tab table refreshed to the
  post-v2.3 layout (11 tabs incl. Releases/Reports/Source moved
  between Reports and Remediation per P2 #3), new Project info card
  section (P2 #1), new "Releases tab" section incl. ?scan= snapshot
  pin behaviour, new "Reports tab" section (#32 — generate cards +
  export history table), risk-score rewritten as the two-axis model
  (#34, Security + License); KO mirror in parallel.
- scans.md: "only one scan at a time per project" callout +
  troubleshooting entry (P1 #10), per-stage log panel description
  (P2 #8b), Project-name column note on the global queue (P1 #5),
  Finalizing-spinner regression troubleshooting (P1 #11); KO mirror.
- vulnerabilities.md: "Vulnerability data unavailable" banner
  section (#35), bulk-transition section with the `:bulk-transition`
  endpoint contract (#33b); KO mirror.
- components-and-licenses.md: Components table columns updated to
  include Type (Direct/Transitive) and Usage (Required/Optional)
  with the matching toolbar filters and drawer meta (W2 #31); KO
  mirror.
- approvals.md: list-row columns updated to the resolved component /
  project names + click-through links (P1 #6), batched IN(...)
  lookups note; KO mirror.

Admin guide
- users-and-teams.md: new "Recovering a deactivated super-admin"
  section that documents the ensure-active recovery path in
  scripts/create_super_admin.py (#14); KO mirror.

Docs site
- sidebars.ts: added user-guide/dashboard ahead of user-guide/projects.
- KO projects.md: explicit anchor `{#build-gate-verdict-overview-tab}`
  added so the dashboard's KO link to the build-gate section resolves
  (the auto-derived Korean slug was the previous breakage point).
- KO vulnerabilities.md: fixed pre-existing stale anchor target
  `#epss--악용-가능성` → `#epss--악용-확률`.

Stale assets flagged (not removed)
- Walkthrough video on projects.md still captures the pre-post-v2.3
  four-tab layout. Marked in a `<!-- -->` comment; refresh after
  merge.
- Projects-list and global-scans-queue screenshots predate the
  scan-meta row and Project-name column. Marked in `<!-- -->`
  comments; refresh after merge.

Not covered (deferred until on main)
- P2 #4 (Dashboard KPI deep links), P2 #3/#1/#2 (visible in feature
  branches but not yet merged to main).

Verification
- `npm run build` under docs-site exits 0 with no warnings on either
  locale.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang added a commit that referenced this pull request May 26, 2026
…s→Components / file input i18n (#3/#1/#2/#9) (#177)

* feat(P2): UI consistency pass — #3 / #1 / #2 / #9 bundle

Four small UI defects reported in 2026-05-26 운영 트리아지 grouped into
one PR because they all touch the project detail surface and are best
verified together by a walkthrough.

#3 — Source tab order
The Source (raw artifact) tab is the cognitive predecessor of Licenses
(classified output of that artifact), so Source now sits to the left of
Licenses. Tab key / route is unchanged — deep links and tests keep
working.

#1 — Overview "Project info" card
Description, repository URL (with external-link affordance), default
branch, and visibility are now surfaced at the top of the Overview tab.
Data comes from the already-fetched ProjectPublic (parent threads it
through OverviewTab), so this is a zero-extra-fetch addition. EN + KO
translations added under `overview.info_card.*`.

#2 — Releases row → Components tab
Clicking a row in the Releases table used to pin the snapshot and land
on Overview; "a release IS a component snapshot," so the natural
landing surface is Components. The header ReleaseSwitcher keeps the
original Overview target — that affordance is about a quick header-
level snapshot pin, not a deep-dive. Implemented by splitting
`handleViewSnapshot` into a parameterised `pinSnapshotAndGoToTab` core
with two thin wrappers.

#9 — File input localisation
`<input type="file">` renders its own button label in the OS locale
(macOS Korean = "파일 선택") regardless of app i18n. Both the source-
upload + folder-pick inputs in SourceSelectDialog and the VEX import
input in VexImportDialog now hide the native control (sr-only) and
expose a translated Button + filename strip we own.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(P2 #9): add missing i18n keys for file/folder picker labels

CI i18n drift check failed: the new translated Button labels in
SourceSelectDialog + VexImportDialog referenced keys that didn't exist
in locale json:
  - en/ko scans.json:        source.upload.{choose_file,no_file_chosen}
                              source.folder.{choose_folder,no_folder_chosen,files_count}
  - en/ko project_detail.json: vulnerabilities.vex.{choose_file,no_file_chosen}

defaultValue kept the UI rendering during dev (i18next falls through)
but i18n:check scans every t() call site and requires both EN + KO
entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang added a commit that referenced this pull request May 26, 2026
Add a tool-log streaming side-channel on the existing /ws/scans/<id>
WebSocket so the scan drawer renders cdxgen / scancode stdout + stderr
lines as they arrive, instead of only progress events.

Backend:
- New `integrations/_line_streamer.py` wraps `subprocess.Popen` with two
  drain threads that decode each line and forward to an optional callback.
  Fallback to `subprocess.run` when no callback is set, preserving the
  prior contract for callers that don't need streaming.
- cdxgen / scancode adapters accept a `line_callback` kwarg and route to
  the new helper. Full captured stdout/stderr is preserved on the
  returned CompletedProcess so existing error inspection still works.
- `tasks/_progress.publish_log(scan_id, stage, stream, line)` publishes
  log frames on the same Redis channel as progress frames. Per-scan line
  budget (SCAN_LOG_MAX_LINES_PER_SCAN, default 5000) + per-line truncation
  (SCAN_LOG_LINE_MAX_LEN, default 2000) cap broker volume + memory.
- Progress frames now carry `type: "progress"` discriminator; log frames
  carry `type: "log"`. Older clients that ignore `type` are unaffected
  (missing-field interpretation is "progress").
- `scan_source.py` passes `_make_line_callback(scan_uuid, stage=...)` to
  cdxgen + scancode call sites. Per-scan log counter resets at task entry
  so a Celery re-execution starts with a fresh budget.

Frontend:
- `useScanWebSocket` discriminates frames by `type` and surfaces a new
  `logMessages: ScanLogMessage[]` (5000-cap ring buffer) alongside the
  existing `messages` progress buffer. Buffer clears on scanId change.
- `ScanProgress` log panel renders tool stdout / stderr color-coded by
  stage (cdxgen=blue, scancode=amber) with stderr tinted critical-red
  and an `err` badge. Falls back to the prior per-step trace when no
  tool lines have arrived (older worker / mock backend).
- EN/KO `progress.tool_log_toggle` keys added.

Tests:
- 9 unit tests for `_line_streamer` (no-callback fallback, in-order
  stdout/stderr delivery, callback-exception isolation, timeout with
  partial output, non-zero exit, blank-line drop, invalid utf-8).
- 10 unit tests for `publish_log` + the discriminator on `publish_progress`
  (channel, schema, stream normalisation, line truncation, per-scan
  budget, kill switch, Redis-error isolation, scan isolation).
- 3 unit tests for `scan_source._make_line_callback` (stage curry,
  publisher-error isolation, multi-stage independence).
- 2 unit tests for `build_log_frame`.
- 1 integration test (`test_ws_forwards_published_tool_log_line`) and
  `type: "progress"` assertion added to the existing progress forwarding test.
- 5 useScanWebSocket tests for log-frame parsing + dispatch.
- 4 ScanProgress tests for the new tool-log panel rendering.

CLAUDE.md compliance: env reads via getters at call time (rule #11);
PostgreSQL is unaffected (the publisher cap is in-process); Celery
async pipeline preserved (rule #3); DT breaker untouched (rule #4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang added a commit that referenced this pull request May 27, 2026
#185)

* feat(P2 #8c): stream cdxgen + scancode stdout to scan drawer log panel

Add a tool-log streaming side-channel on the existing /ws/scans/<id>
WebSocket so the scan drawer renders cdxgen / scancode stdout + stderr
lines as they arrive, instead of only progress events.

Backend:
- New `integrations/_line_streamer.py` wraps `subprocess.Popen` with two
  drain threads that decode each line and forward to an optional callback.
  Fallback to `subprocess.run` when no callback is set, preserving the
  prior contract for callers that don't need streaming.
- cdxgen / scancode adapters accept a `line_callback` kwarg and route to
  the new helper. Full captured stdout/stderr is preserved on the
  returned CompletedProcess so existing error inspection still works.
- `tasks/_progress.publish_log(scan_id, stage, stream, line)` publishes
  log frames on the same Redis channel as progress frames. Per-scan line
  budget (SCAN_LOG_MAX_LINES_PER_SCAN, default 5000) + per-line truncation
  (SCAN_LOG_LINE_MAX_LEN, default 2000) cap broker volume + memory.
- Progress frames now carry `type: "progress"` discriminator; log frames
  carry `type: "log"`. Older clients that ignore `type` are unaffected
  (missing-field interpretation is "progress").
- `scan_source.py` passes `_make_line_callback(scan_uuid, stage=...)` to
  cdxgen + scancode call sites. Per-scan log counter resets at task entry
  so a Celery re-execution starts with a fresh budget.

Frontend:
- `useScanWebSocket` discriminates frames by `type` and surfaces a new
  `logMessages: ScanLogMessage[]` (5000-cap ring buffer) alongside the
  existing `messages` progress buffer. Buffer clears on scanId change.
- `ScanProgress` log panel renders tool stdout / stderr color-coded by
  stage (cdxgen=blue, scancode=amber) with stderr tinted critical-red
  and an `err` badge. Falls back to the prior per-step trace when no
  tool lines have arrived (older worker / mock backend).
- EN/KO `progress.tool_log_toggle` keys added.

Tests:
- 9 unit tests for `_line_streamer` (no-callback fallback, in-order
  stdout/stderr delivery, callback-exception isolation, timeout with
  partial output, non-zero exit, blank-line drop, invalid utf-8).
- 10 unit tests for `publish_log` + the discriminator on `publish_progress`
  (channel, schema, stream normalisation, line truncation, per-scan
  budget, kill switch, Redis-error isolation, scan isolation).
- 3 unit tests for `scan_source._make_line_callback` (stage curry,
  publisher-error isolation, multi-stage independence).
- 2 unit tests for `build_log_frame`.
- 1 integration test (`test_ws_forwards_published_tool_log_line`) and
  `type: "progress"` assertion added to the existing progress forwarding test.
- 5 useScanWebSocket tests for log-frame parsing + dispatch.
- 4 ScanProgress tests for the new tool-log panel rendering.

CLAUDE.md compliance: env reads via getters at call time (rule #11);
PostgreSQL is unaffected (the publisher cap is in-process); Celery
async pipeline preserved (rule #3); DT breaker untouched (rule #4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(P2 #8c): restore line_callback / _make_line_callback after main merge

The prior merge commit (94313c0) accidentally reset scan_source.py to the
main version, dropping the four PR #185 changes:
  - import publish_log + reset_log_counter
  - reset_log_counter(scan_uuid) at task entry
  - line_callback=_make_line_callback(stage="cdxgen") on cdxgen call
  - line_callback=_make_line_callback(stage="scancode") on scancode call
  - _make_line_callback helper

This commit re-applies all five changes on top of the main merge so the
P2 #8c tests (test_scan_source_log_callback) and the rest of the WS log
streaming chain compile and run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(P2 #8c): fake_run_cdxgen accepts **kwargs for line_callback

W6 integration tests (test_scan_source_pipeline_mock /
test_scan_source_dependency_graph) define _fake_run_cdxgen with a
strict (source_dir, output_dir) signature. PR #185 added a
line_callback kwarg to the real cdxgen_adapter.run_cdxgen signature,
which makes scan_source.py pass that kwarg into the patched fake —
TypeError: unexpected keyword argument 'line_callback'.

Accept **_kwargs in both fakes so the W6 tests survive the P2 #8c
signature extension without coupling the fixtures to the streaming
callback contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(P2 #8c): more fake cdxgen/scancode patches accept line_callback

The earlier 012f080 only fixed the two named _fake_run_cdxgen functions;
this round-trip caught four more fixtures that the same TypeError chain
breaks:
  - test_scan_source_dependency_graph._skip_scancode (scancode adapter
    patch) needs **_kwargs for line_callback parity with cdxgen.
  - test_scan_source_trivy three lambda cdxgen patches (lines 281, 358,
    414) each take a strict (source_dir, output_dir) signature; widen
    them with **_kwargs.

The W6 scan_source pipeline now passes line_callback into both adapters
on every dispatch, so every monkeypatch on those names must accept the
kwarg even when the test does not exercise the streaming path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang added a commit that referenced this pull request Jul 2, 2026
…ines (#443)

BomLens parity item #3 (Phase B). The obligation catalog has always
declared license_text_inclusion_required, but the NOTICE we generate
only linked to spdx.org — the product did not satisfy its own
obligation. It now does.

License texts:
- services/license_texts/ vendors the full text for all 32 catalog
  licenses (21 from BomLens, Apache-2.0; 11 from the official SPDX
  license-list-data repo) with a loader that allowlists the id shape,
  resolves only against the real file-stem set, and caches by file
  snapshot. A catalog contract test locks catalog_spdx_ids() ==
  bundled_spdx_ids().
- All three NOTICE formats append a 'License Texts' section: unique
  compound-expression operands (obligation-catalog splitter, so the
  section shares vocabulary with the obligation rows), spdx asc,
  verbatim text — html escaped in <pre>, markdown wrapped in a dynamic
  fence longer than any backtick run in the text. Unbundled ids render
  a pointer line (own 500 cap); bundled texts cap at 200 with the
  established omitted footer.

Copyright:
- Component lines now carry Copyright: <value> from the scanned SBOM,
  clamped to 500 chars IN SQL (func.left inside jsonb_agg — an
  authenticated hostile SBOM cannot materialize multi-hundred-MB
  aggregate rows), control-char sanitized, and escaped per format.
  Missing holders fall back to 'holders not captured in SBOM — see
  source (<registry url>)' — never blank. _purl_source_url ports the
  BomLens purl_src registry map (9 types, https only) and rejects
  '..'/'%' in the purl body (traversal-shaped links).

Security: Producer-Reviewer gate — CHANGES REQUESTED (Medium: SQL-side
clamp; Low: plaintext divider forging via scanned spdx_id; two Infos)
then PASS on re-review. The ingest-side spdx_id sanitize root fix is
tracked with the existing persist-NUL follow-up.

Docs: SBOM user guide gains 'Copyright lines' / 'License texts'
sections (EN/KO), obligations page cross-link, CHANGELOG entry. Parity
tracker moves #3 to in-progress and records the third BomLens baseline
review (522735a — G7 registry v2 sync follow-up registered).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant