Skip to content

feat(audit): team-scoped audit read for team_admin (M-3)#368

Merged
haksungjang merged 1 commit into
mainfrom
fix/m3-team-scoped-audit-read
Jun 10, 2026
Merged

feat(audit): team-scoped audit read for team_admin (M-3)#368
haksungjang merged 1 commit into
mainfrom
fix/m3-team-scoped-audit-read

Conversation

@haksungjang

Copy link
Copy Markdown
Contributor

배경

검증팀(bug-hunter) 리포트 M-3 (Medium, 진짜 버그). H-1과 마찬가지로 70건 조치 중 트래커·마스터 플랜에서 누락돼 미조치로 남아 있던 항목 — origin/main 커밋 본문 전수 대조로 적발했다.

가이드는 감사 로그 읽기를 2단 RBAC(super_admin 전체 / team_admin 팀 범위)로 기술하나, 구현은 super-admin 전용 /v1/admin/audit 하나뿐이라 team_admin은 감사를 전혀 읽지 못했다(404). 출처 케이스: TC-AUDIT-03-002, 03-003, 09-003.

변경

  • 새 엔드포인트 GET /v1/audit (team_admin 이상):
    • super_admin → 전체(스코프 제한 없음).
    • team_admin → 본인이 team_admin 역할인 팀들의 행만.
    • developer → 403, 미인증 → 401.
  • 팀 스코프는 CurrentUser.team_roles(팀별 역할)에서 도출한다. 최고 역할(role)을 쓰지 않는 이유: team A의 team_admin이면서 team B의 developer인 사용자가 B의 감사를 보면 cross-team 권한 상승(OWASP A01 / CWE-863)이 된다.
  • 스코프는 서버측 WHERE audit_logs.team_id IN (...)로 강제하며, 쿼리 파라미터로 넓힐 수 없다(필터는 허용 팀 내에서 좁히기만). 빈 스코프는 0행(fail-closed).
  • search_audit/_apply_filtersallowed_team_ids 인자 추가. super-admin 전용 CSV export(/v1/admin/audit/export.csv)는 그대로 유지 — 이 경로는 team_admin의 포렌식 조회용이지 대량 export 표면이 아니다.

검증

  • 신규 integration 4건 PASS (실 Postgres): 미인증 401 · developer 403 · team_admin은 자기 팀만(B 누수 가드) · super_admin 전체.
  • admin audit 서비스/엔드포인트 회귀 + OpenAPI contract 70건 PASS.
  • OpenAPI 스냅샷 재생성(/v1/audit GET 반영).
  • ruff·mypy 클린.

The guide describes a two-tier audit RBAC — super_admin reads everything,
team_admin reads its own teams — but the only read path was the
super-admin-only /v1/admin/audit, so a team_admin got a 404 and could not
read audit at all.

Add GET /v1/audit (team_admin+). super_admin is unrestricted; team_admin is
scoped to the teams where it holds team_admin, derived from
CurrentUser.team_roles (per-team), never the coarse highest role — a
team_admin in team A who is only a developer in team B must not see B's audit
(CWE-863). The scope is a server-side WHERE on audit_logs.team_id the caller
cannot widen via query params; an empty scope matches zero rows (fail-closed).
search_audit/_apply_filters gain an allowed_team_ids arg; the
super-admin-only CSV export is unchanged.

Tests: anonymous 401, developer 403, team_admin sees only its team (B leak
guarded), super_admin sees all. OpenAPI snapshot regenerated.
from services.admin_audit_service import search_audit

router = APIRouter(prefix="/v1/audit", tags=["audit"])
log = structlog.get_logger("audit.api")
@haksungjang

Copy link
Copy Markdown
Contributor Author

security-reviewer (Producer-Reviewer) PASS — Critical 0 / High 0 / Medium 0 / Low 1 / Info 2.

네 공격 클래스 모두 정확히 닫힘:

  • IDOR/BOLA: _apply_filters가 인가 술어(team_id IN allowed)를 무조건 먼저 AND로 적용 — 쿼리 파라미터로 넓힐 수 없음. count/rows 양쪽 동일 적용.
  • cross-team 권한상승 (A01/CWE-863): 스코프를 coarse role이 아닌 team_roles(팀별)에서 도출. team A=team_admin·B=developer면 {A}만.
  • fail-closed: 빈 set은 team_id IN (NULL) AND (1!=1) → 0행(SQLAlchemy 2.0.36 컴파일 하네스로 확인). NULL team_id 시스템 행은 super_admin만.
  • auth 우회 불가: super_admin 판정은 토큰 role claim 무시, DB membership 재계산. HS256 고정(alg=none 거부).

Low(비차단): team_admin이 자기 팀 감사 diff JSONB 열람 — in-scope(cross-team 누수 아님), password/token은 listener가 이미 마스킹. team_admin은 팀 운영 관리자라 자기 팀 변경내역 열람이 권한 모델상 적절 → 수용 + 본 코멘트로 문서화. diff listener 마스킹 범위 점검은 별도 follow-up.

@haksungjang haksungjang merged commit b1f017e into main Jun 10, 2026
19 checks passed
haksungjang added a commit that referenced this pull request Jul 11, 2026
…agging (BomLens parity #15-#17) (#482)

* feat(scan): filter cdxgen SBOMs to the runtime scope before persist and matching

cdxgen keeps every resolved node, so a deployed app's SBOM also carried its
test/provided/dev toolchain (junit, lombok, devDependencies) as if it shipped
— inflating CVE counts, license obligations and NOTICE content. Mirror the
BomLens build-prep post-filters (#331/#335/#341) as a worker-side stage:

- integrations/sbom_scope_filter.py: pure post-filter. Maven drops cdxgen
  scope optional/excluded behind a hasScopes guard (an unscoped SBOM is left
  whole so recall never regresses). Node diverges from BomLens deliberately:
  instead of an npm re-resolve subprocess, drop pkg:npm components the
  already-parsed package-lock.json positively classifies dev (hasDev guard,
  keep-if-unknown for purls the root lockfile does not cover). Shared tail
  prunes dependencies[] to kept refs and always preserves the root ref.
- Pipeline wiring runs copy-then-commit BEFORE artifact persist, cosign
  signing, SCANOSS and Trivy so every consumer sees one consistent filtered
  document; a failed disk rewrite degrades to filter-skipped with memory and
  disk still identical. Telemetry lands in scan_metadata.scope_filter and a
  trusca:scope_filter SBOM property; no new pipeline stage (sub-second step).
- Ingest path deliberately unfiltered: an uploaded SBOM is the supplier's
  declared truth (pinned by an AST regression test).
- Config: SCAN_SCOPE_FILTER_ENABLED / _MAVEN_ENABLED / _NODE_ENABLED, all
  default ON; only exact falsy tokens disable, so a typo fails open to the
  correct-by-default behaviour (inverse of SCANOSS_ENABLED, no egress here).
- Fixtures are real captured cdxgen 12.3.3 output (Spring Boot 3.2 Maven app:
  108 components with required/optional/excluded; Express app: 487 components
  + its 502-package lockfile, 377 dev-flagged).
- Docs EN/KO: runtime-scope filtering section, env vars, stage description;
  also refresh the stale WebSocket slug list (dt_upload/dt_findings era).

* feat(fe): surface the runtime-scope filter's excluded-components count

The scope filter (previous commit) drops dev/test components server-side —
without a visible trace, a re-scan that loses 300 components reads as data
loss. Surface the worker telemetry in the Components tab:

- useScanScopeFilter reads scan_metadata.scope_filter off the ordinary scan
  detail response (ScanPublic.metadata already carries the JSONB verbatim —
  no backend change). Scan resolution mirrors the tab's anchoring: pinned
  ?scan= wins, else the latest succeeded scan from the overview. The parser
  treats the blob as untrusted at every level and collapses all nothing-to-
  show shapes to null.
- ComponentsTab summary band gains an additive 'excluded N' note
  (data-testid components-scope-filter-note, per-ecosystem breakdown on the
  title attribute); EN/KO keys in the same PR.
- Seeds (seed_demo + seed_e2e_user) stamp deterministic scope_filter
  telemetry on their succeeded scans so the e2e spec asserts without a live
  worker run.

* feat(scan): survive Podfile repos and fill pods in from Podfile.lock

With a Podfile present and no pod CLI (the worker ships no Ruby/CocoaPods
toolchain), cdxgen's cocoapods cataloger throws a TypeError on the undefined
pod stdout and aborts the whole SBOM stage — a terminal CdxgenFailed for any
iOS repo. Mirror BomLens #347 in two halves:

- Crash guard: pass --exclude-type cocoapods when a Podfile exists within
  depth 3 (Pods/ copies ignored; bounded glob, not BomLens' unbounded find).
  Applied in both the in-process adapter (integrations/cdxgen.py) and the
  sidecar script (build_prep_source.sh), cross-referenced to stay in sync.
- Fill-in: integrations/cocoapods_lockfile.py hand-parses Podfile.lock's
  PODS: block (npm_lockfile discipline: never raises, MAX_PODS cap,
  name-to-ref guard so edges never point at phantom refs, self-edges
  dropped) and merges components + dependency graph into the SBOM before
  the scope filter, copy-then-commit like the filter so disk and memory
  never diverge. Deliberate divergence from BomLens: no syft — the PODS:
  block already carries names, pinned versions and edges. Subspecs
  (Moya/Core) percent-encode into the purl; dependency_scope stays NULL
  (Podfile.lock has no runtime/test signal).
- SPM: repos committing only Package.resolved now detect as swift, and the
  sidecar skips swift package resolve over a committed lockfile (it is the
  resolved truth; re-resolving hits the network). Verified offline: cdxgen
  12.3.3 without a swift toolchain parses Package.resolved directly (the
  captured real_cyclonedx_swift.json fixture is that verification run).
- Fixtures: BomLens' real pod-install Podfile.lock (Moya -> Moya/Core ->
  Alamofire — subspecs, trailing-colon parents, constraint-only entries).

* feat(scan): flag end-of-life components from a vendored endoflife.date snapshot

An EOL runtime or framework gets no upstream fixes — a later Critical has no
patch. That is a supply-chain risk axis distinct from CVEs, and TRUSCA had no
signal for it. Mirror BomLens #368 (enrich-eol.sh) as a catalog-layer
enrichment:

- EOL lives on component_versions (0038: six nullable columns + a partial
  index WHERE eol_state='eol') — the KEV shape: a fact about product+cycle
  shared across scans/projects, re-stampable when the snapshot moves. NULL =
  not a tracked product; the whitelist philosophy never guesses.
- services/eol: the purl->product map vendored VERBATIM from BomLens
  (byte-identity contract test against a captured copy; the %40 npm-scope
  encoding divergence is absorbed in the matcher, keeping the map exact) +
  a compact endoflife.date snapshot vendored into the repo (16 KB, 10
  products) so Docker builds stay network-free and air-gapped installs work
  out of the box. scripts/refresh_eol_snapshot.py (port of BomLens
  build-eol-index.py: 15s/request, 60s budget, failed products skipped)
  refreshes it per release; a map<->snapshot product-set contract test trips
  when the map changes without a refresh.
- Evaluation is the BomLens semantics table-tested: cycle derivation
  (leading numeric segments, major vs major.minor), bool eol as-is, ISO
  date vs today, everything else unknown. express-session must not match
  pkg:npm/express@ (over-match guard test).
- Stamping happens inside persist_sbom_components right after the catalog
  row upsert — one evaluator per persist call, changed-value-guarded
  (idempotent re-scan), covering the source-scan AND sbom-ingest paths for
  free. Never fatal: corrupt/missing dataset degrades to no stamping with
  one aggregate log line.
- Config: EOL_ENABLED default ON (offline, zero egress — contrast SCANOSS),
  EOL_SNAPSHOT_PATH for air-gapped snapshot mounts.
- Verified: alembic upgrade head applies 0038 on live PostgreSQL 17
  (columns + partial index present); full unit suite green.

API/FE surfacing and the refresh beat land in the next two changes.

* feat(fe,api): surface end-of-life verdicts — column, badge, filter, KPI

Expose the catalog-layer EOL data (previous commit) end to end:

- API: ComponentSummary carries eol_state/eol_date and the detail response
  the full verdict (product/cycle/date/source) — free columns on the
  existing ComponentVersion joins, no extra query. New ?eol=true|false
  facet rides the partial index; the overview gains eol_count (one
  count query gathered with the existing aggregation). OpenAPI snapshot
  regenerated for the new param.
- FE: EolBadge (KevBadge structural mirror — High tone, renders only for
  state 'eol', absence is the signal), EOL column in the components table,
  an 'EOL only' toolbar toggle mirrored to ?eol=true, an always-rendered
  drawer row that distinguishes untracked (blank) from unknown (tracked,
  cycle undecidable), and an Overview chip deep-linking to the filtered
  list. EN/KO keys in the same change.
- Contracts: the closed eol_state vocabulary now lives in three places
  (catalog tuple, wire Literals, FE mirror) — set-equality tests on both
  sides (test_catalog_contracts.py + catalogMirrors.test.ts) plus EN/KO
  label coverage.
- e2e: components_eol.spec.ts (badge / filter+URL / drawer / overview
  chip); the seed stamps a far-past EOL verdict on exactly the first
  seeded component so assertions never depend on the run date or the
  vendored snapshot's live contents.
- Docs EN/KO: end-of-life section in the components guide (blank-vs-unknown
  semantics), endoflife.date row in data-sources, EOL_* env vars.

* feat(ops): weekly EOL re-stamp beat + admin snapshot health panel

Close out the EOL feature's ops layer (KEV subsystem structural mirror):

- eol_sync_state (0039): the kev_sync_state single-row pattern plus one
  addition — the fetched dataset itself rides the row (JSONB, a few KB) so
  the re-stamp pass can prefer the freshest data without network.
- tasks/eol_catalog_refresh: two-half tick. The fetch half is env-gated
  (EOL_REFRESH_ENABLED, DEFAULT OFF — new third-party egress follows the
  SCANOSS fail-closed posture, not the KEV one; EOL dates churn quarterly
  and the per-release vendored snapshot bounds staleness) with a
  proportional sanity floor (at least half the mapped products) so a gutted
  sweep never displaces a good dataset. The re-stamp half ALWAYS runs —
  pure local: effective dataset = newer of (vendored, fetched), bounded
  whitelist-prefix SELECT, changed-value-guarded stamps, and a clear pass
  for rows the whitelist no longer covers (the per-scan hook enriches but
  never reconciles). Weekly beat Sunday 02:15 UTC, between the KEV and
  Trivy ticks.
- integrations/eol_feed: kev_feed discipline scaled to many small
  documents — per-product byte ceiling + skip-and-count, 60s whole-run
  budget, EolFeedUnavailable only when NOTHING fetched, host-only logging.
- Admin surface: GET /v1/admin/eol/health (super-admin, existence-hide)
  assembled by eol_health_service (kev_health mirror + effective-snapshot
  resolution); FE EolPanel with a 180-day stale escalation, stamped/cleared
  KPIs and an origin/refresh footer; EN/KO keys; panel unit tests.
- Docs EN/KO: admin health guide section (docs-uat marker on the endpoint),
  EOL_REFRESH_* env rows. OpenAPI snapshot regenerated.
- Also: mypy-strict cleanups in the Phase K/L test files surfaced by the
  full 'mypy .' run.

* docs(parity): close gaps #15-#17 (Phase K/L/M) and move the baseline to 5a62094

7th review cycle over BomLens 9cc37e0..5a62094 (v1.7.0 + 27 commits, 80
total): the runtime-scope filter series, EOL flagging and the iOS lockfile
work were judged as gaps #15-#17 and implemented in this same branch — all
closed. Baseline, local-path correction (~/projects/bomlens) and the review
history row updated. Follow-up backlog recorded: Android release-classpath
scope filter (needs an Android SDK in the worker image), surfacing Trivy's
image-level eosl flag for container scans.

Also carries two doc changes that were finished earlier but stranded
(docs-only PRs are BLOCKED by the ci.yml paths-ignore required checks):
the reachability-analysis coordination row and
docs/commercial-parity-candidates.md.

* fix(security): harden the K/L/M parsers per security review

Producer-Reviewer findings (Medium 3 / Low 3 / Info 2), all addressed:

- CocoaPods ReDoS (M): the PODS-block regexes backtrack quadratically on a
  crafted line (measured 16s at 80k chars). Guards, in order: 2 MiB file
  cap before reading, 4 KiB per-line cap before the regex, and the work
  bound now counts EXAMINED lines (a never-matching block cannot bypass
  MAX_PODS). Regression test pins the pathological line under 1s.
- eol_cycle VARCHAR(32) overflow (M): a 33+-digit crafted version segment
  used to overflow the column at flush time — OUTSIDE the per-component
  best-effort guard — aborting the whole persist. derive_cycle now treats
  over-long numeric segments (>15 chars) as underivable -> 'unknown'.
- eol_sync_state.snapshot bloat (M): per-field 256-char bound in the feed
  compactor (drop, never truncate — a truncated version would match the
  wrong cycle) plus a 256 KiB size gate on the assembled dataset before
  the JSONB UPSERT (skip as 'snapshot_too_large', last-good survives).
- EOL feed redirects (L): max_redirects=3 with the trust-extension
  rationale documented (endoflife.date fronts a redirecting CDN, so
  redirects can't be off entirely).
- Scope-filter audit trail (L): the purls of dropped components are now
  recorded (bounded sample of 200 + exact totals) in ScopeFilterResult and
  scan_metadata.scope_filter.dropped_refs — counts alone are not
  reviewable when the npm predicate trusts an attacker-controlled lockfile
  classification.
- Symlink guards (L): Podfile.lock and the Podfile-detection glob reject
  symlinks (cosign-module precedent).
- LIKE metacharacter escape + assert->explicit guard (Info).

* chore(security): apply the two non-blocking re-review residuals

- Cap each recorded dropped ref at 512 chars so 200 hostile mega-purls
  cannot inflate the scan_metadata JSONB row.
- Correct the _podfile_present comment: the symlink guard covers the leaf
  only (glob traverses directory symlinks) — acceptable because the flag
  decides a CLI argument, not a read path; the read guard lives in
  read_podfile_lock.

* fix(scan,api): findings from full-stack verification of Phases K/L/M

Live-stack verification (docker-compose dev + Playwright + real worker
scans of public repos) surfaced three defects the unit suites structurally
could not see:

- Hand-built responses dropped the new fields (third instance of this
  class): the component drawer built ComponentDetailResponse without the
  eol_* block and the overview built ProjectOverviewResponse without
  eol_count — Pydantic silently defaulted both, so the DB and the list
  carried the verdict while the drawer/overview read null (caught by
  components_eol e2e E3/E4). Fixed, and a new AST completeness contract
  (test_handbuilt_response_completeness.py) requires a keyword for every
  model field in both constructions so the fourth instance fails in CI.
- Root-keyed consumers missed the git-clone subdirectory: _fetch_source
  returns workspace/source while the git path clones into source/repo, so
  the prep lockfile generators, the scope filter's and CocoaPods fill-in's
  lockfile reads, persist's W4-D npm scope enrichment and language
  detection all silently no-opped on every git scan (zip extractions,
  which land at source/ itself, were fine — which is why the fixture e2e
  never saw it). _resolve_project_root now hands them the repo root.
  Verified live: a chalk.git scan now drops 648 lockfile-dev components
  (47 kept) with the audit refs recorded; before the fix the same scan
  persisted all 695 with no telemetry.
- components_scope_filter.spec.ts seeded with the default component
  prefix, which collides with earlier runs' catalog purls (component-mode
  purls are not run-suffixed) — the seed failed on every run after the
  first. Now mints a per-run prefix like the sibling EOL spec.

Also verified live: maven-simple (all test-scope, no required) is left
whole by the hasScopes guard exactly as designed, with Usage=Optional
visible; the iOS guard survives a real cdxgen 12.3.3 run against a
Podfile tree in the worker and fills 3 pods + the subspec edge back in.
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