Skip to content

feat: phase 3 pr #13 — obligations tab + NOTICE generator#6

Merged
haksungjang merged 3 commits into
mainfrom
feature/phase3-pr13-obligations
May 7, 2026
Merged

feat: phase 3 pr #13 — obligations tab + NOTICE generator#6
haksungjang merged 3 commits into
mainfrom
feature/phase3-pr13-obligations

Conversation

@haksungjang

Copy link
Copy Markdown
Contributor

Summary

  • Adds the fifth project-detail tab (Obligations) — read-only per-license catalog with kind/category multi-filter, search, sort, virtualized list, drawer with cross-link to ComponentDrawer, and per-kind distribution chips.
  • Adds a NOTICE attribution generator (/v1/projects/{id}/notice) that produces text/plain or text/markdown bodies with sanitized Content-Disposition filenames; the toolbar exposes a one-click download.
  • Schema check confirmed Obligation has no status column → read-only domain (no PATCH, no transitions, no audit_log writes), mirroring PR docs: chore PR #6 + #7 session handoffs + Phase 4 PR #14 prompt #12.

Test plan

  • ruff check apps/backend clean
  • mypy apps/backend clean (117 source files)
  • backend pure-unit pytest tests/unit/test_obligation_service.py -k 'not integration' — 22/22 pass
  • npm run lint 0 errors (14 pre-existing fast-refresh warnings)
  • npm run typecheck clean
  • npm run test -- --run (full vitest) — 241/241 pass (+18 new)
  • npx playwright test --list --grep '@obligations' — 4 collect
  • EN/KO i18n parity 264/264
  • bandit clean (887 LOC, 0 issues)
  • security-reviewer: PASS-with-follow-ups (Low 1 + Info 3, all defense-in-depth — size caps, nosniff header, RFC 6266 filename* — tracked for the next chore PR)
  • CI green on main merge
  • Manual smoke: --with-obligations seed → tab → distribution → row → drawer → cross-link to component → NOTICE download

Follow-up backlog (separate PR)

🤖 Generated with Claude Code

haksungjang and others added 3 commits May 7, 2026 01:00
…nerator)

Adds a fifth project-detail tab that surfaces the per-license obligation
catalog (attribution, source-disclosure, copyleft, etc.) and exposes a
NOTICE attribution download for the project's latest scan.

Backend (new):
  - GET /v1/projects/{id}/obligations — kind/category multi-filter, search
    (4 ILIKE columns escaped), sort + paging; returns items, per-kind
    distribution (known kinds first, unknown alphabetical), total.
  - GET /v1/projects/{id}/obligations/{obligation_id} — drawer detail,
    project-scoped URL, existence-hide (404) on cross-team, presence check
    against latest_scan_id.
  - GET /v1/projects/{id}/notice — text/plain or text/markdown body with
    optional Content-Disposition: attachment + sanitized filename token;
    X-Notice-{Generated-At, License-Count, Obligation-Count} headers.
  - read-only domain — no PATCH, no transition matrix, no audit_log writes.
  - schema check confirmed Obligation has no status workflow column.

Frontend (new):
  - ObligationsTab + ObligationsToolbar + ObligationDrawer mirroring the
    PR #12 Licenses pattern. URL search-params (?obligation, ?kind,
    ?license_category) chosen to not collide with prior tabs' drawer keys.
  - useNotice hook drives an imperative Blob download.
  - http(s) URL scheme guard on obligation.link + license_reference_url.

Carry-overs from PR #11/#12:
  - _escape_like ILIKE escape, cross-team authz logging emitted before
    raise, structlog "obligation.service", category ENUM cast for filters,
    query-time aggregation only.

Tests:
  - backend: 49 unit cases (22 pure-helper pass + 27 DB-gated integration),
    10 HTTP integration cases.
  - frontend: 18 vitest cases (9 ObligationsTab + 9 ObligationDrawer
    including a non-http(s) link XSS guard).
  - e2e: 4 @obligations scenarios + 5 PortalPage harness verbs.
  - seed CLI: --with-obligations flag attaches a 7-row catalog to the four
    e2e seed-licenses (forbidden 2 / conditional 2 / allowed 2 / unknown 1).

i18n: 49 EN+KO keys (264/264 parity); 9 glossary entries.

DoD: ruff/mypy/typecheck/lint clean; vitest 241/241; 22 backend pure-unit
pass; security review PASS-with-follow-ups (1 Low + 3 Info on size caps and
defense-in-depth headers, tracked as a separate hardening PR).

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

Records the read-only catalog domain decision (Obligation has no status
column → no mutation patterns), the project-scoped detail URL, the open
kind enum strategy, and the security review's PASS-with-follow-ups verdict.
Carries forward the size-cap defense-in-depth follow-up (PR #12 Info #1 +
PR #13 Low #1) as the leading candidate for the next chore PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`API-MIT` was hardcoded in both `test_licenses_api.py` and
`test_obligations_api.py`, causing a UniqueViolationError on the
shared `licenses.spdx_id` constraint when the suite runs against a
single Postgres instance. While here, applied the same uuid-suffix
treatment to the three remaining fixed SPDX literals so the suite is
robust to any future cross-module collision.
@haksungjang haksungjang merged commit a3634e3 into main May 7, 2026
@haksungjang haksungjang deleted the feature/phase3-pr13-obligations branch May 7, 2026 00:17
haksungjang added a commit that referenced this pull request May 7, 2026
…ects (chore PR #6) (#10)

* docs(sessions): archive completed chore PR #5 next-session prompt

Move docs/sessions/_next-session-prompt-chore-pr5.md to
docs/sessions/archive/next-session-prompts/. The chore PR #5 prompt
was fully consumed by PR #9 (merged in b23871b); keeping the file
in docs/sessions/ root would clutter the working-tree status with a
stale prompt.

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

* chore(integrations): promote subprocess env scrubbing to shared module

Extract the prep-only ``_PREP_ENV_ALLOWLIST`` + ``_scrubbed_env``
helper from ``tasks/scan_source.py`` into a new
``integrations/_subprocess_env.py`` module that exposes three
ecosystem-specific builders:

  * ``scrubbed_env_for_prep()``  — Ruby/Cargo/Go/.NET/Java prep
  * ``scrubbed_env_for_cdxgen()`` — Node/JVM/Python tooling for
    cdxgen, plus the ``npm_config_*`` / ``CDXGEN_*`` prefix bands
  * ``scrubbed_env_for_ort()``    — JVM tooling, plus the ``ORT_*``
    prefix band

All three share the same base allowlist (PATH/HOME/LANG/LC_ALL/TZ +
corporate CA / proxy hints from chore PR #5 L1) and resolve
``os.environ`` at call time per CLAUDE.md rule #11.

Prefix bands carry a credential heuristic: any allow-listed key whose
name matches ``auth|token|password|passphrase|secret|credential|
apikey|api_key|private(_)?key`` is dropped before forwarding. That
prevents npm's ``_authToken`` / ``_password`` / ``_auth`` and
``ORT_*_TOKEN`` style keys from sneaking through the prefix gates.

``tasks.scan_source._scrubbed_env`` is preserved as an alias so the
existing prep call site and its unit tests keep working unchanged.
The cdxgen / ORT integrations adopt the new helpers in follow-up
commits.

Refs: security-reviewer Medium #1 v2 (chore PR #5 handoff §1.2).

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

* chore(integrations): scrub worker secrets from cdxgen subprocess env

cdxgen runs as a Node binary under the worker. Hostile clones can
load attacker-controlled ``package.json`` plugin hooks /
``cdxgen.config.json`` rules, and the existing ``env = dict(os.environ)``
inheritance hands them ``DT_API_KEY`` / ``SECRET_KEY`` /
``DATABASE_URL`` / ``*_WEBHOOK_URL`` for free — exfiltrable through
plugin telemetry, crash reports, or DNS lookups in error paths
(security-reviewer Medium #1 v2, chore PR #5 handoff §1.2).

``_build_cdxgen_env`` now starts from ``scrubbed_env_for_cdxgen()``
instead of ``os.environ``. The Gradle 8 compat-shim path from chore
PR #5 Part C is preserved verbatim and a unit test is added pinning
that operator-set ``CDXGEN_GRADLE_ARGS`` still wins. Three new tests
in ``test_cdxgen_gradle_compat.py`` verify the scrubbed env strips
worker secrets, forwards ``NODE_EXTRA_CA_CERTS`` / ``HTTPS_PROXY``,
and keeps the existing operator-override semantics intact.

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

* chore(integrations): scrub worker secrets from ORT subprocess env

The ORT subprocess previously inherited the worker's full env (no
``env=`` argument to ``subprocess.run``). ORT is a JVM tool whose
``--rules-file`` and ``--ort-file`` inputs originate from the worker
image / scan workspace, so the inputs themselves are not attacker-
controlled — but a malicious ORT plugin or a JVM CVE in the rule
evaluator would still see ``DT_API_KEY`` / ``SECRET_KEY`` /
``DATABASE_URL`` and could tunnel them through telemetry, crash
reports, or DNS lookups in error paths (security-reviewer Medium #1
v2).

``run_ort`` now passes ``env=scrubbed_env_for_ort()``. The new env
forwards JVM toolchain hints (``JAVA_HOME`` / ``JAVA_OPTS`` /
``GRADLE_USER_HOME`` / ``MAVEN_OPTS``) and the operator-override
``ORT_*`` band, while stripping worker secrets and credential-named
``ORT_*`` keys (``ORT_GITHUB_TOKEN`` / ``ORT_NEXUS_PASSWORD`` etc.).

A new ``test_ort_env_scrub.py`` exercises the real-binary code path
by monkeypatching ``shutil.which`` + ``subprocess.run`` and pinning
two regressions: secrets are stripped, and credential-named ``ORT_*``
keys are dropped even though the prefix band is allow-listed.

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

* chore(license-fetcher): disable redirect following on registry clients

All four ecosystem fetchers (Maven Central, PyPI, crates.io,
pkg.go.dev) now build their default ``httpx.Client`` with
``follow_redirects=False`` (security-reviewer L4, chore PR #5 handoff
§1.2). Each registry serves the licence metadata from a single
authoritative origin, so a 3xx response is never a legitimate signal
— a phishing host inserted via MITM, a misconfigured mirror, or an
attacker-poisoned DNS response would otherwise be silently chased.

``base.request_with_retry`` grows an explicit 3xx branch that emits
``license_fetch_unexpected_redirect`` (warning-level, includes the
truncated ``Location`` header) and returns ``None`` so the caller
registers a negative cache entry. The attacker host is never
contacted.

Each fetcher gains two unit tests:
  * a behaviour pin — a 3xx response with a ``Location: attacker.example``
    header returns ``None`` and the second URL is never requested;
  * a contract pin — the production-default ``_client(...)`` factory
    has ``follow_redirects is False``.

Test injections are unaffected: callers can still pass an
``httpx.Client(follow_redirects=True)`` for live integration tests
that legitimately need to chase redirects.

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

* chore(integrations): widen credential deny heuristic with session/bearer/cookie

Add ``"session"`` / ``"bearer"`` / ``"cookie"`` to
``_CREDENTIAL_DENY_SUBSTRINGS`` so the prefix-band forwarders drop
``*_SESSION`` / ``*_SESSIONID`` / ``*_BEARER`` / ``*_COOKIE`` env
variables that cdxgen / ORT plugins occasionally accept for registry
login.

Bare ``"key"`` is intentionally not added: it would match
``npm_package_keywords`` (npm script context, benign). The existing
``"private_key"`` / ``"privatekey"`` / ``"apikey"`` / ``"api_key"``
substrings already cover the real threat shapes for crypto material.

Refs: security-reviewer L1 (chore PR #6 review).

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 7, 2026
Land the session housekeeping for the chore PR #6 + #7 work
(merged in 1d41a94 + 53be9ba):

  - 2026-05-07-chore-pr6-cdxgen-ort-env-scrub.md
  - 2026-05-07-chore-pr7-maven-url-uat-revalidation.md
  - _next-session-prompt-phase4-pr14.md (next session start prompt)

Archive the now-completed _next-session-prompt-chore-pr6-pr7.md
under docs/sessions/archive/next-session-prompts/.

The chore PR #6 + #7 work landed two security-reviewer Mediums
(M1 v2 cdxgen/ORT subprocess env scrub, M2 reference_url phishing)
plus L1/L4 follow-ups, surfaced and fixed three real chore PR #5
fetcher gaps (OR/WITH compounds, Maven aliases, pkg.go.dev 2026
HTML), and discovered + patched a chore-PR-#5-latent High recursion
DoS + Medium charset flaw in normalize_spdx_id during the security
review of chore PR #7. Phase 4 entry is now safe — Medium-or-higher
backlog at 0.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang added a commit that referenced this pull request May 26, 2026
CI typecheck (frontend) flagged 9 test fixtures that construct
ScanPublic / ApprovalOut literals without the freshly-added display
fields. Local tsc passed only because we ran it from a stale incremental
cache that didn't include the tests directory.

The fields are semantically optional anyway — list endpoints populate
them, but single-row endpoints and test fixtures don't need to — so
declare them with `?:` instead of `T | null`. UI call sites already use
truthy guards (`item.component_name ? ... : fallback`) so the runtime
behaviour is unchanged.

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

* feat(P1 #5/#6): surface project/component names in Scans + Approvals lists

The Scans queue and Approvals queue used to render the first 8 chars of a
raw UUID in their Project / Component columns because the list endpoints
shipped only the foreign-key columns (`ScanPublic.project_id`,
`ApprovalOut.component_id`/`project_id`). Users in real triage couldn't
tell rows apart and couldn't click through.

BE — Scans (#5):
- ScanPublic gains optional `project_name` / `project_slug`.
- `list_scans_for_actor` and `list_scans_for_project` now
  `selectinload(Scan.project)` so the relationship is batch-loaded in one
  IN(...) lookup; no per-row round-trip.
- `ScanPublic.from_scan(scan)` factory splices the two fields off the
  eagerly-loaded relationship; single-row endpoints keep
  `model_validate(scan)` and fall back to `null` (callers don't need them
  yet).

BE — Approvals (#6):
- ApprovalOut gains optional `component_name`, `component_purl`,
  `project_name`, `project_slug`.
- ComponentApproval intentionally does NOT carry ORM relationships across
  the auth / scan domains (see model docstring), so the service resolves
  the display fields with two batched IN(...) lookups against components
  and projects and returns each row inside a new `ApprovalListRow`
  dataclass envelope. `ApprovalOut.from_list_row(row)` splices them onto
  the model-validated approval row.

FE:
- ScansPage Project column links to `/projects/{id}` with the project name
  when present, falls back to the legacy 8-char UUID otherwise.
- ApprovalsPage Component column shows `component_name` + the purl in a
  monospace sub-line; Project column links to `/projects/{id}`. Both
  fall back to the UUID prefix when the BE field is null. The Project
  link stops click propagation so it doesn't also trip the row's
  drawer-open handler.
- TypeScript interfaces updated in `projectsApi.ts` / `approvalsApi.ts`.

Tests:
- New integration test `test_list_includes_project_name_and_slug` covers
  the Scans list response shape.
- New unit test `test_list_returns_component_and_project_display_fields`
  covers the ApprovalListRow envelope.
- Existing list_approvals tests updated to read fields via `.approval.*`
  through the new envelope.

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

* fix(P1 #5/#6): make new display fields optional to keep tests building

CI typecheck (frontend) flagged 9 test fixtures that construct
ScanPublic / ApprovalOut literals without the freshly-added display
fields. Local tsc passed only because we ran it from a stale incremental
cache that didn't include the tests directory.

The fields are semantically optional anyway — list endpoints populate
them, but single-row endpoints and test fixtures don't need to — so
declare them with `?:` instead of `T | null`. UI call sites already use
truthy guards (`item.component_name ? ... : fallback`) so the runtime
behaviour is 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 Jul 3, 2026
…(Phase F) (#452)

The scan-compare (release diff) feature landed with #28 (project_diff_service +
ComparePage + Releases-tab entry) but had no end-to-end coverage. Close that gap.

Seed: new `--scan-count {1|2}` on seed_e2e_user.py. `--scan-count 2` seeds a
second succeeded scan on the first project whose SCA posture differs from the
first by an exact, deterministic delta — 1 added / 1 removed / 1 version-changed
component and 1 introduced / 1 resolved finding (the delta components are
suffix-unique so they never collide). Requires --component-count >= 4; implies
--with-scan. The newer scan id is returned as `second_scan_id`; the base stays
`scan_ids[0]`. `--scan-count 1` (default) is byte-for-byte the old behavior.
Verified against a throwaway Postgres 17: diff_release_snapshots reports exactly
1/1/1 components and 1/1 vulnerabilities.

Harness: seed.ts gains `scanCount` + `second_scan_id`; PortalPage gains
`selectReleasesTab()`.

E2E: compare.spec.ts (@compare) — S1 single release keeps the Compare button
disabled; S2 two releases enable Compare and the diff renders the injected
change sets (added/removed/changed = 1, introduced/resolved >= 1) with base/target
pinned to the two seeded scans; S3 the swap control reverses base/target. Each
bootstrap uses a test-unique component prefix so the globally-unique component
purl never collides across the file's seeds on a persistent dev DB.

Docs: user-guide Releases/Compare paragraph (EN/KO) expanded to describe the
full diff (changed components, introduced/resolved vulns, risk/gate deltas, swap,
shareable ?base=&target= URL). Parity #6 moves to in-progress.
haksungjang added a commit that referenced this pull request Jul 3, 2026
Fulfil the CLAUDE.md "Excel / PDF reports" commitment, which previously shipped
PDF only. The project vulnerability report is now downloadable as an .xlsx
workbook alongside the PDF.

Backend:
- `services/report_xlsx_service.build_report_xlsx` — a PURE function (like
  build_report_html) that turns the SAME read-service dict shapes the PDF path
  uses into an openpyxl workbook (bytes), three sheets: Overview (risk score,
  severity + license distribution), Components, Vulnerabilities (CVE, CVSS,
  EPSS, KEV + due date, affected component). Document metadata is pinned to
  `generated_at` so identical inputs produce identical bytes.
- Formula-injection defence (CWE-1236 / OWASP): every string cell starting with
  `= + - @` (or a leading tab/CR) is prefixed with `'`, mirroring
  admin_audit_service._csv_cell. Component names / summaries come from scanned
  third-party metadata, so a hostile package can't ship a live formula.
- `GET /projects/{id}/vulnerability-report.xlsx` — mirrors the PDF endpoint
  (IDOR / existence-hide 404, team gate, disconnect guard, run_in_threadpool),
  records a `vuln_xlsx` export-history row.
- `report_type_enum += vuln_xlsx` (migration 0037; REPORT_TYPE_VALUES updated).
  `format` is a free VARCHAR, so `fmt="xlsx"` needs no migration.
- openpyxl==3.1.5 pinned (pure-Python, no native deps; latest stable, no CVEs).

Frontend:
- `fetchVulnerabilityReportXlsx` + `useVulnReportXlsx` (shares the PDF hook's
  download logic) + an "Download Excel" button on the Reports-tab vulnerability
  card. EN/KO strings.

Tests:
- Unit: workbook shape, sheet headers, native-typed number/bool cells, KEV date
  as ISO, determinism, and the injection defence (=cmd / +SUM / @ neutralised).
- Integration: xlsx happy-path (content-type + PK magic + .xlsx disposition),
  401 / 404-existence-hide / super-admin bypass, and one `vuln_xlsx` history row.
  None gated on weasyprint — openpyxl always available.
- E2E: reports.spec S2 clicks Excel and asserts a vulnerability-report-*.xlsx
  download. OpenAPI golden updated for the new path.

Docs: vulnerabilities guide "Download a report (PDF or Excel)" (EN/KO) + CHANGELOG.
Parity #8 → in-progress; #6 (Phase F) → closed.
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