feat(audit): team-scoped audit read for team_admin (M-3)#368
Merged
Conversation
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") |
Contributor
Author
|
security-reviewer (Producer-Reviewer) PASS — Critical 0 / High 0 / Medium 0 / Low 1 / Info 2. 네 공격 클래스 모두 정확히 닫힘:
Low(비차단): team_admin이 자기 팀 감사 |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
배경
검증팀(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_filters에allowed_team_ids인자 추가. super-admin 전용 CSV export(/v1/admin/audit/export.csv)는 그대로 유지 — 이 경로는 team_admin의 포렌식 조회용이지 대량 export 표면이 아니다.검증
/v1/auditGET 반영).