diff --git a/.cursor/commands/openspec-apply.md b/.cursor/commands/openspec-apply.md deleted file mode 100644 index fcdea3e7..00000000 --- a/.cursor/commands/openspec-apply.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: /openspec-apply -id: openspec-apply -category: OpenSpec -description: Implement an approved OpenSpec change and keep tasks in sync. ---- - -**Guardrails** -- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. -- Keep changes tightly scoped to the requested outcome. -- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. -- Refer to `AGENTS.md` (located in root directory) for general implementation guidelines. - -**Steps** -Track these steps as TODOs and complete them one by one. -1. Read `changes//proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria. -2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. -3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished. -4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality. -5. Reference `openspec list` or `openspec show ` when additional context is required. - -**Reference** -- Use `openspec show --json --deltas-only` if you need additional context from the proposal while implementing. - diff --git a/.cursor/commands/openspec-archive.md b/.cursor/commands/openspec-archive.md deleted file mode 100644 index 492b3ed6..00000000 --- a/.cursor/commands/openspec-archive.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: /openspec-archive -id: openspec-archive -category: OpenSpec -description: Archive a deployed OpenSpec change and update specs. ---- - -**Guardrails** -- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. -- Keep changes tightly scoped to the requested outcome. -- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. - -**Steps** -1. Determine the change ID to archive: - - If this prompt already includes a specific change ID (for example inside a `` block populated by slash-command arguments), use that value after trimming whitespace. - - If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends. - - Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding. - - If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet. -2. Validate the change ID by running `openspec list` (or `openspec show `) and stop if the change is missing, already archived, or otherwise not ready to archive. -3. Run `openspec archive --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work). -4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`. -5. Validate with `openspec validate --strict` and inspect with `openspec show ` if anything looks off. - -**Reference** -- Use `openspec list` to confirm change IDs before archiving. -- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off. - diff --git a/.cursor/commands/openspec-proposal.md b/.cursor/commands/openspec-proposal.md deleted file mode 100644 index 029329ff..00000000 --- a/.cursor/commands/openspec-proposal.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: /openspec-proposal -id: openspec-proposal -category: OpenSpec -description: Scaffold a new OpenSpec change and validate strictly. ---- - -**Guardrails** -- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. -- Keep changes tightly scoped to the requested outcome. -- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. -- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files. -- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval. -- Refer to `AGENTS.md` (located in root directory) for general implementation guidelines. - -**Steps** -1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification. -2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes//`. -3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. -4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs. -5. Draft spec deltas in `changes//specs//spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant. -6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work. -7. Validate with `openspec validate --strict` and resolve every issue before sharing the proposal. - -**Reference** -- Use `openspec show --json --deltas-only` or `openspec show --type spec` to inspect details when validation fails. -- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones. -- Explore the codebase with `rg `, `ls`, or direct file reads so proposals align with current implementation realities. - diff --git a/.gitignore b/.gitignore index 792fbe32..c0e1cfd7 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,5 @@ coverage/ # Temporary files *.tmp *.temp -.cache/ \ No newline at end of file +.cache/ +.cursor/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 509b6b78..cbb10311 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,3 @@ - -# OpenSpec Instructions - -These instructions are for AI assistants working in this project. - -Always open `@/openspec/AGENTS.md` when the request: -- Mentions planning or proposals (words like proposal, spec, change, plan) -- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work -- Sounds ambiguous and you need the authoritative spec before coding - -Use `@/openspec/AGENTS.md` to learn: -- How to create and apply change proposals -- Spec format and conventions -- Project structure and guidelines - -Keep this managed block so 'openspec update' can refresh the instructions. - - ## Core Principles @@ -43,7 +25,6 @@ Font sizes and layout dimensions (heights, widths, padding, margins) that affect ### VIII. Loading State Patterns Loading states MUST use PatternFly `Skeleton` components instead of `Spinner` components. Skeletons provide better UX by showing the structure and layout of content that will appear, reducing perceived load time and layout shift. Use `Skeleton` components to match the shape and size of the content being loaded. Spinners should only be used for small, inline loading indicators (e.g., button loading states) or when skeleton placeholders are not practical. - ## Technology Stack **Backend**: Quarkus, Java, MongoDB (via Panache), REST @@ -69,4 +50,3 @@ Loading states MUST use PatternFly `Skeleton` components instead of `Spinner` co - Access: `http://localhost:8080` Use `docs/development.md` for runtime development guidance. - diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md deleted file mode 100644 index 96ab0bb3..00000000 --- a/openspec/AGENTS.md +++ /dev/null @@ -1,456 +0,0 @@ -# OpenSpec Instructions - -Instructions for AI coding assistants using OpenSpec for spec-driven development. - -## TL;DR Quick Checklist - -- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) -- Decide scope: new capability vs modify existing capability -- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) -- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability -- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement -- Validate: `openspec validate [change-id] --strict` and fix issues -- Request approval: Do not start implementation until proposal is approved - -## Three-Stage Workflow - -### Stage 1: Creating Changes -Create proposal when you need to: -- Add features or functionality -- Make breaking changes (API, schema) -- Change architecture or patterns -- Optimize performance (changes behavior) -- Update security patterns - -Triggers (examples): -- "Help me create a change proposal" -- "Help me plan a change" -- "Help me create a proposal" -- "I want to create a spec proposal" -- "I want to create a spec" - -Loose matching guidance: -- Contains one of: `proposal`, `change`, `spec` -- With one of: `create`, `plan`, `make`, `start`, `help` - -Skip proposal for: -- Bug fixes (restore intended behavior) -- Typos, formatting, comments -- Dependency updates (non-breaking) -- Configuration changes -- Tests for existing behavior - -**Workflow** -1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. -2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. -3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. -4. Run `openspec validate --strict` and resolve any issues before sharing the proposal. - -### Stage 2: Implementing Changes -Track these steps as TODOs and complete them one by one. -1. **Read proposal.md** - Understand what's being built -2. **Read design.md** (if exists) - Review technical decisions -3. **Read tasks.md** - Get implementation checklist -4. **Implement tasks sequentially** - Complete in order -5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses -6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality -7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved - -### Stage 3: Archiving Changes -After deployment, create separate PR to: -- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` -- Update `specs/` if capabilities changed -- Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) -- Run `openspec validate --strict` to confirm the archived change passes checks - -## Before Any Task - -**Context Checklist:** -- [ ] Read relevant specs in `specs/[capability]/spec.md` -- [ ] Check pending changes in `changes/` for conflicts -- [ ] Read `openspec/project.md` for conventions -- [ ] Run `openspec list` to see active changes -- [ ] Run `openspec list --specs` to see existing capabilities - -**Before Creating Specs:** -- Always check if capability already exists -- Prefer modifying existing specs over creating duplicates -- Use `openspec show [spec]` to review current state -- If request is ambiguous, ask 1–2 clarifying questions before scaffolding - -### Search Guidance -- Enumerate specs: `openspec spec list --long` (or `--json` for scripts) -- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) -- Show details: - - Spec: `openspec show --type spec` (use `--json` for filters) - - Change: `openspec show --json --deltas-only` -- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` - -## Quick Start - -### CLI Commands - -```bash -# Essential commands -openspec list # List active changes -openspec list --specs # List specifications -openspec show [item] # Display change or spec -openspec validate [item] # Validate changes or specs -openspec archive [--yes|-y] # Archive after deployment (add --yes for non-interactive runs) - -# Project management -openspec init [path] # Initialize OpenSpec -openspec update [path] # Update instruction files - -# Interactive mode -openspec show # Prompts for selection -openspec validate # Bulk validation mode - -# Debugging -openspec show [change] --json --deltas-only -openspec validate [change] --strict -``` - -### Command Flags - -- `--json` - Machine-readable output -- `--type change|spec` - Disambiguate items -- `--strict` - Comprehensive validation -- `--no-interactive` - Disable prompts -- `--skip-specs` - Archive without spec updates -- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) - -## Directory Structure - -``` -openspec/ -├── project.md # Project conventions -├── specs/ # Current truth - what IS built -│ └── [capability]/ # Single focused capability -│ ├── spec.md # Requirements and scenarios -│ └── design.md # Technical patterns -├── changes/ # Proposals - what SHOULD change -│ ├── [change-name]/ -│ │ ├── proposal.md # Why, what, impact -│ │ ├── tasks.md # Implementation checklist -│ │ ├── design.md # Technical decisions (optional; see criteria) -│ │ └── specs/ # Delta changes -│ │ └── [capability]/ -│ │ └── spec.md # ADDED/MODIFIED/REMOVED -│ └── archive/ # Completed changes -``` - -## Creating Change Proposals - -### Decision Tree - -``` -New request? -├─ Bug fix restoring spec behavior? → Fix directly -├─ Typo/format/comment? → Fix directly -├─ New feature/capability? → Create proposal -├─ Breaking change? → Create proposal -├─ Architecture change? → Create proposal -└─ Unclear? → Create proposal (safer) -``` - -### Proposal Structure - -1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) - -2. **Write proposal.md:** -```markdown -# Change: [Brief description of change] - -## Why -[1-2 sentences on problem/opportunity] - -## What Changes -- [Bullet list of changes] -- [Mark breaking changes with **BREAKING**] - -## Impact -- Affected specs: [list capabilities] -- Affected code: [key files/systems] -``` - -3. **Create spec deltas:** `specs/[capability]/spec.md` -```markdown -## ADDED Requirements -### Requirement: New Feature -The system SHALL provide... - -#### Scenario: Success case -- **WHEN** user performs action -- **THEN** expected result - -## MODIFIED Requirements -### Requirement: Existing Feature -[Complete modified requirement] - -## REMOVED Requirements -### Requirement: Old Feature -**Reason**: [Why removing] -**Migration**: [How to handle] -``` -If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`—one per capability. - -4. **Create tasks.md:** -```markdown -## 1. Implementation -- [ ] 1.1 Create database schema -- [ ] 1.2 Implement API endpoint -- [ ] 1.3 Add frontend component -- [ ] 1.4 Write tests -``` - -5. **Create design.md when needed:** -Create `design.md` if any of the following apply; otherwise omit it: -- Cross-cutting change (multiple services/modules) or a new architectural pattern -- New external dependency or significant data model changes -- Security, performance, or migration complexity -- Ambiguity that benefits from technical decisions before coding - -Minimal `design.md` skeleton: -```markdown -## Context -[Background, constraints, stakeholders] - -## Goals / Non-Goals -- Goals: [...] -- Non-Goals: [...] - -## Decisions -- Decision: [What and why] -- Alternatives considered: [Options + rationale] - -## Risks / Trade-offs -- [Risk] → Mitigation - -## Migration Plan -[Steps, rollback] - -## Open Questions -- [...] -``` - -## Spec File Format - -### Critical: Scenario Formatting - -**CORRECT** (use #### headers): -```markdown -#### Scenario: User login success -- **WHEN** valid credentials provided -- **THEN** return JWT token -``` - -**WRONG** (don't use bullets or bold): -```markdown -- **Scenario: User login** ❌ -**Scenario**: User login ❌ -### Scenario: User login ❌ -``` - -Every requirement MUST have at least one scenario. - -### Requirement Wording -- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) - -### Delta Operations - -- `## ADDED Requirements` - New capabilities -- `## MODIFIED Requirements` - Changed behavior -- `## REMOVED Requirements` - Deprecated features -- `## RENAMED Requirements` - Name changes - -Headers matched with `trim(header)` - whitespace ignored. - -#### When to use ADDED vs MODIFIED -- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. -- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. -- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. - -Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. - -Authoring a MODIFIED requirement correctly: -1) Locate the existing requirement in `openspec/specs//spec.md`. -2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). -3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. -4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. - -Example for RENAMED: -```markdown -## RENAMED Requirements -- FROM: `### Requirement: Login` -- TO: `### Requirement: User Authentication` -``` - -## Troubleshooting - -### Common Errors - -**"Change must have at least one delta"** -- Check `changes/[name]/specs/` exists with .md files -- Verify files have operation prefixes (## ADDED Requirements) - -**"Requirement must have at least one scenario"** -- Check scenarios use `#### Scenario:` format (4 hashtags) -- Don't use bullet points or bold for scenario headers - -**Silent scenario parsing failures** -- Exact format required: `#### Scenario: Name` -- Debug with: `openspec show [change] --json --deltas-only` - -### Validation Tips - -```bash -# Always use strict mode for comprehensive checks -openspec validate [change] --strict - -# Debug delta parsing -openspec show [change] --json | jq '.deltas' - -# Check specific requirement -openspec show [spec] --json -r 1 -``` - -## Happy Path Script - -```bash -# 1) Explore current state -openspec spec list --long -openspec list -# Optional full-text search: -# rg -n "Requirement:|Scenario:" openspec/specs -# rg -n "^#|Requirement:" openspec/changes - -# 2) Choose change id and scaffold -CHANGE=add-two-factor-auth -mkdir -p openspec/changes/$CHANGE/{specs/auth} -printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md -printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md - -# 3) Add deltas (example) -cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' -## ADDED Requirements -### Requirement: Two-Factor Authentication -Users MUST provide a second factor during login. - -#### Scenario: OTP required -- **WHEN** valid credentials are provided -- **THEN** an OTP challenge is required -EOF - -# 4) Validate -openspec validate $CHANGE --strict -``` - -## Multi-Capability Example - -``` -openspec/changes/add-2fa-notify/ -├── proposal.md -├── tasks.md -└── specs/ - ├── auth/ - │ └── spec.md # ADDED: Two-Factor Authentication - └── notifications/ - └── spec.md # ADDED: OTP email notification -``` - -auth/spec.md -```markdown -## ADDED Requirements -### Requirement: Two-Factor Authentication -... -``` - -notifications/spec.md -```markdown -## ADDED Requirements -### Requirement: OTP Email Notification -... -``` - -## Best Practices - -### Simplicity First -- Default to <100 lines of new code -- Single-file implementations until proven insufficient -- Avoid frameworks without clear justification -- Choose boring, proven patterns - -### Complexity Triggers -Only add complexity with: -- Performance data showing current solution too slow -- Concrete scale requirements (>1000 users, >100MB data) -- Multiple proven use cases requiring abstraction - -### Clear References -- Use `file.ts:42` format for code locations -- Reference specs as `specs/auth/spec.md` -- Link related changes and PRs - -### Capability Naming -- Use verb-noun: `user-auth`, `payment-capture` -- Single purpose per capability -- 10-minute understandability rule -- Split if description needs "AND" - -### Change ID Naming -- Use kebab-case, short and descriptive: `add-two-factor-auth` -- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` -- Ensure uniqueness; if taken, append `-2`, `-3`, etc. - -## Tool Selection Guide - -| Task | Tool | Why | -|------|------|-----| -| Find files by pattern | Glob | Fast pattern matching | -| Search code content | Grep | Optimized regex search | -| Read specific files | Read | Direct file access | -| Explore unknown scope | Task | Multi-step investigation | - -## Error Recovery - -### Change Conflicts -1. Run `openspec list` to see active changes -2. Check for overlapping specs -3. Coordinate with change owners -4. Consider combining proposals - -### Validation Failures -1. Run with `--strict` flag -2. Check JSON output for details -3. Verify spec file format -4. Ensure scenarios properly formatted - -### Missing Context -1. Read project.md first -2. Check related specs -3. Review recent archives -4. Ask for clarification - -## Quick Reference - -### Stage Indicators -- `changes/` - Proposed, not yet built -- `specs/` - Built and deployed -- `archive/` - Completed changes - -### File Purposes -- `proposal.md` - Why and what -- `tasks.md` - Implementation steps -- `design.md` - Technical decisions -- `spec.md` - Requirements and behavior - -### CLI Essentials -```bash -openspec list # What's in progress? -openspec show [item] # View details -openspec validate --strict # Is it correct? -openspec archive [--yes|-y] # Mark complete (add --yes for automation) -``` - -Remember: Specs are truth. Changes are proposals. Keep them in sync. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 00000000..392946c6 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/openspec/schemas/research-first/schema.yaml b/openspec/schemas/research-first/schema.yaml new file mode 100644 index 00000000..b15502d0 --- /dev/null +++ b/openspec/schemas/research-first/schema.yaml @@ -0,0 +1,31 @@ +name: research-first +version: 1 +description: Custom workflow schema for research-first +artifacts: + - id: proposal + generates: proposal.md + description: High-level description of the change, its motivation, and scope + template: proposal.md + requires: [] + - id: specs + generates: specs/**/*.md + description: Detailed specifications with requirements and scenarios + template: specs/spec.md + requires: + - proposal + - id: design + generates: design.md + description: Technical design decisions and implementation approach + template: design.md + requires: + - specs + - id: tasks + generates: tasks.md + description: Implementation checklist with trackable tasks + template: tasks.md + requires: + - design +apply: + requires: + - tasks + tracks: tasks.md diff --git a/openspec/schemas/research-first/templates/design.md b/openspec/schemas/research-first/templates/design.md new file mode 100644 index 00000000..bc2ba9f6 --- /dev/null +++ b/openspec/schemas/research-first/templates/design.md @@ -0,0 +1,24 @@ +## Context + + + +## Goals / Non-Goals + +**Goals:** + + +**Non-Goals:** + + +## Decisions + +### 1. Decision Name + +Description and rationale. + +**Alternatives considered:** +- Alternative 1: Rejected because... + +## Risks / Trade-offs + + diff --git a/openspec/schemas/research-first/templates/proposal.md b/openspec/schemas/research-first/templates/proposal.md new file mode 100644 index 00000000..8bbebdb1 --- /dev/null +++ b/openspec/schemas/research-first/templates/proposal.md @@ -0,0 +1,19 @@ +## Why + + + +## What Changes + + + +## Capabilities + +### New Capabilities + + +### Modified Capabilities + + +## Impact + + diff --git a/openspec/schemas/research-first/templates/specs/spec.md b/openspec/schemas/research-first/templates/specs/spec.md new file mode 100644 index 00000000..0fb4dcd1 --- /dev/null +++ b/openspec/schemas/research-first/templates/specs/spec.md @@ -0,0 +1,9 @@ +## ADDED Requirements + +### Requirement: Example requirement + +Description of the requirement. + +#### Scenario: Example scenario +- **WHEN** some condition +- **THEN** some outcome diff --git a/openspec/schemas/research-first/templates/tasks.md b/openspec/schemas/research-first/templates/tasks.md new file mode 100644 index 00000000..18cee7fb --- /dev/null +++ b/openspec/schemas/research-first/templates/tasks.md @@ -0,0 +1,5 @@ +## Implementation Tasks + +- [ ] Task 1 +- [ ] Task 2 +- [ ] Task 3 diff --git a/openspec/specs/document-titles/spec.md b/openspec/specs/document-titles/spec.md index 56899e4e..c60886d4 100644 --- a/openspec/specs/document-titles/spec.md +++ b/openspec/specs/document-titles/spec.md @@ -2,9 +2,7 @@ ## Purpose Define how the web UI sets `document.title` so tabs show where the user is. Implementation: `src/main/webui/src/pages/pageTitles.ts` and `useDocumentTitle`. - ## Requirements - ### Requirement: Format and single source Tab titles SHALL be built only through `pageTitles.ts` helpers, applied with `useDocumentTitle`, and SHALL end with ` | Exploit Intelligence` (via `withAppTitle` / `DOCUMENT_TITLE_APP_NAME`). @@ -22,3 +20,16 @@ Titles SHALL follow the patterns implemented in `pageTitles.ts`, including: Home #### Scenario: CVE details load failure - **WHEN** CVE details fail to load for a route CVE id - **THEN** the document title includes that CVE id in the error segment with the app suffix + +### Requirement: RPM repository report document title segments + +When the repository report page loads successfully for an RPM package checker report (**`report.input.image.pipeline_mode`** is **`rpm_package_checker`**) and **`report.input.image.target_package`** is present, **`document.title`** SHALL include the CVE id and RPM package identity derived from **`target_package`**. The **name**, **version**, and **release`** portion **SHALL** be presented as **hyphenated N-V-R** (**`name-version-release`**) when all three are non-empty after trim (**not** as a space-separated triple). **Architecture** (**`arch`**) **SHALL** appear as a distinct segment in the title suffix (same delimiter pattern as **`pageTitles.ts`** uses for the non-RPM image **name**/**tag** suffix, e.g. em dash after CVE and separator between **N-V-R** and **arch** as implemented), so the tab title **aligns** with **repository-report-page** active-tail formatting. + +Titles MUST still follow **`document-titles`** global conventions (constructed via **`pageTitles.ts`** / **`useDocumentTitle`** and the **`Exploit Intelligence`** suffix). + +#### Scenario: Loaded RPM checker repository report title + +- **WHEN** the repository report page has resolved report data AND **`pipeline_mode`** is **`rpm_package_checker`** AND **`target_package`** has **name**, **version**, **release**, and **arch** populated after trim +- **THEN** **`document.title`** includes **CVE id** and a suffix where **package coordinates use `name-version-release`** (hyphens) **and** **architecture** is visible **without** collapsing **name version release arch** into one space-separated Nevra string +- **AND** the suffix follows **`pageTitles.ts`** conventions with the **Exploit Intelligence** app suffix + diff --git a/openspec/specs/new-rpm-report-api/spec.md b/openspec/specs/new-rpm-report-api/spec.md new file mode 100644 index 00000000..349be33e --- /dev/null +++ b/openspec/specs/new-rpm-report-api/spec.md @@ -0,0 +1,88 @@ +# new-rpm-report-api Specification + +## Purpose +Support RPM analysis +## Requirements +### Requirement: New RPM report REST endpoint +The system SHALL provide a REST endpoint at `POST /api/v1/reports/new-rpm-report` that accepts a JSON body with string fields `name`, `version`, `release`, `arch`, and **`cveId`** (matching the **`upload-spdx-api`** vulnerability ID property name). The endpoint SHALL validate that every listed field is present and non-empty (after trimming surrounding whitespace). The endpoint SHALL validate that **`arch`**, after trim, is exactly one of the allowed RPM architecture literals: **`x86_64`**, **`amd64`**, **`aarch64`**, **`arm64`**, **`ppc64le`**, **`s390x`** (same set as the Request Analysis RPM UI architecture control). The endpoint SHALL validate the CVE identifier using the official CVE regex pattern `^CVE-[0-9]{4}-[0-9]{4,19}$` (case handling SHALL match existing report upload endpoints). When validation fails, the endpoint SHALL return the same style of structured error response as **`POST /api/v1/products/upload-spdx`** for field-level failures: HTTP 400 with a JSON object whose top-level keys are **request field names** and whose values are **human-readable error strings** for each invalid or missing field (see the validation requirement below). The endpoint SHALL construct a Morpheus-compatible report `input` document, persist a new report record, and **always** enqueue or submit the report for Agent Morpheus analysis using the same mechanism as `POST /api/v1/reports/new` **with submission enabled**. The endpoint SHALL NOT accept a `submit` query parameter or support a persist-only mode. + +**Persistence rules:** +- The stored report SHALL set `input.image.pipeline_mode` to the string `rpm_package_checker`. +- The stored report SHALL set `input.image.target_package` to an object whose keys are `name`, `version`, `release`, `arch`, and `ecosystem`: the first four SHALL be the corresponding string values from the request (after validation), and `ecosystem` SHALL be the literal string `rpm` (server-defined, not a client-supplied field). +- The value of **`cveId`** SHALL be persisted as the CVE under `input.scan` (e.g. as `vuln_id` on the appropriate element of `input.scan.vulns`), as described in this requirement. + +#### Scenario: Successful RPM report creation and queue +- **WHEN** an authenticated client calls `POST /api/v1/reports/new-rpm-report` with a JSON body where `name`, `version`, `release`, `arch`, and `cveId` are all non-empty strings, `arch` is one of the allowed architecture literals, and `cveId` matches `^CVE-[0-9]{4}-[0-9]{4,19}$` +- **THEN** the system persists a new report document whose `input.image.pipeline_mode` is `rpm_package_checker` +- **AND** `input.image.target_package` equals `{ name, version, release, ecosystem: "rpm", arch }` with values derived from the request and `ecosystem` exactly `rpm` +- **AND** the CVE is present under `input.scan` as specified in this requirement +- **AND** the report is enqueued or submitted according to the configured concurrent request pool rules +- **AND** the API returns HTTP 202 (Accepted) with the created report data, consistent with `POST /api/v1/reports/new` success behavior + +### Requirement: Validation and error responses for RPM report requests (upload-spdx parity) +For validation failures **only** (`name`, `version`, `release`, `arch`, `cveId`), the endpoint SHALL match **`upload-spdx-api`** field-mapped errors: HTTP 400 (Bad Request) with a JSON object **mapping each offending request property name** to **one human-readable message string** per key (same pattern as **`POST /api/v1/products/upload-spdx`** scenarios “Missing vulnerability ID rejection with field mapping”, “Invalid vulnerability ID format rejection with field mapping”, and “Multiple field validation errors”). Keys SHALL include `name`, `version`, `release`, `arch`, and **`cveId`** for CVE-related failures. When `arch` is present and non-empty after trim but not one of the allowed architecture literals, the response SHALL include key **`arch`** with a message that the value is not allowed and identifies the permitted set (or equivalent clear wording). Responses SHALL NOT use a single top-level `"error"` string for purely field validation failures; each problem field SHALL appear as its own key in the JSON object. + +When the request queue limits are exceeded, the endpoint SHALL return HTTP 429 (Too Many Requests) with an error payload consistent with `POST /api/v1/reports/new` (this condition is not a per-field validation case). + +#### Scenario: Missing required field with field mapping (upload-spdx style) +- **WHEN** a client calls `POST /api/v1/reports/new-rpm-report` with a JSON body omitting one or more of `name`, `version`, `release`, `arch`, or `cveId`, or supplying only whitespace after trim for any of those properties +- **THEN** the API returns HTTP 400 (Bad Request) +- **AND** the response body is a JSON object mapping **field names to error messages**, in the same style as upload-spdx +- **AND** for each absent or whitespace-only required field, the corresponding key (`name`, `version`, `release`, `arch`, or `cveId`) is present with a message that describes the requirement (for example missing or blank) + +#### Scenario: Invalid CVE format with field mapping +- **WHEN** a client calls `POST /api/v1/reports/new-rpm-report` with all of `name`, `version`, `release`, and `arch` present and non-empty after trim, `arch` is an allowed architecture literal, but `cveId` does not match `^CVE-[0-9]{4}-[0-9]{4,19}$` +- **THEN** the API returns HTTP 400 (Bad Request) +- **AND** the response body is a JSON object mapping field names to error messages +- **AND** the response includes `"cveId": "error message"` indicating the CVE format is invalid (wording analogous to **`upload-spdx-api`** Invalid vulnerability ID scenario) + +#### Scenario: Invalid architecture with field mapping +- **WHEN** a client calls `POST /api/v1/reports/new-rpm-report` with `name`, `version`, `release`, and `cveId` valid per other rules but `arch` is a non-empty string after trim that is not one of **`x86_64`**, **`amd64`**, **`aarch64`**, **`arm64`**, **`ppc64le`**, **`s390x`** +- **THEN** the API returns HTTP 400 (Bad Request) +- **AND** the response body is a JSON object mapping field names to error messages +- **AND** the response includes **`"arch"`** with a message describing that the architecture is not allowed + +#### Scenario: Multiple field validation errors +- **WHEN** a client calls `POST /api/v1/reports/new-rpm-report` with more than one validation problem among `name`, `version`, `release`, `arch`, and `cveId` (for example blank `release` together with invalid `cveId`, or invalid `arch` together with invalid `cveId`) +- **THEN** the API returns HTTP 400 (Bad Request) +- **AND** the response body is a JSON object mapping field names to error messages +- **AND** the response includes an entry per failed field where applicable (e.g. both `"release": "..."` and `"cveId": "..."`) +- **AND** each key maps to the specific validation error for that property (same aggregation rule as **`upload-spdx-api`** “Multiple field validation errors”) + +#### Scenario: Queue limit exceeded +- **WHEN** a client submits a valid RPM report request but the internal pending queue is full (same condition as `POST /api/v1/reports/new`) +- **THEN** the API returns HTTP 429 (Too Many Requests) +- **AND** the error payload is consistent with `POST /api/v1/reports/new` + +### Requirement: OpenAPI documentation +The new endpoint SHALL be documented in the application OpenAPI/Swagger specification with the JSON request schema (`name`, `version`, `release`, `arch`, `cveId`), where **`arch`** is documented with an **`enum`** (or equivalent OpenAPI enumeration) listing exactly **`x86_64`**, **`amd64`**, **`aarch64`**, **`arm64`**, **`ppc64le`**, **`s390x`**, response schema for HTTP 202 (aligned with existing report creation responses), documented HTTP **400** response describing the **field-name-to-message object** matching upload-spdx-style validation errors, and HTTP **429** consistent with queued report submission. + +#### Scenario: Generated client contract +- **WHEN** OpenAPI code generation runs for the frontend client +- **THEN** the request and response types for `POST /api/v1/reports/new-rpm-report` are available to TypeScript consumers without manually duplicating DTO shapes +- **AND** the generated type for **`arch`** reflects the documented enumeration of allowed RPM architectures + +### Requirement: Morpheus `input.image` for rpm_package_checker + +When the system builds the Morpheus `input` document for **`POST /api/v1/reports/new-rpm-report`**, the persisted **`report.input.image`** for **`rpm_package_checker`** SHALL include these mandatory fields for Agent Morpheus **`ImageInfoInput`** (upstream **`ImageInfoInput`** in the agent codebase’s **`input.py`**): + +- **`pipeline_mode`**: literal **`rpm_package_checker`** +- **`analysis_type`**: literal **`source`** (corresponding to **`AnalysisType.SOURCE`**) +- **`target_package`**: populated as described in the **`New RPM report REST endpoint`** requirement ( **`name`**, **`version`**, **`release`**, **`arch`**, **`ecosystem`**: **`rpm`** ) + +Serialization MUST NOT omit **`analysis_type`** because of **`NON_EMPTY`**-style omission of empty strings or empty collections. + +The public request body for **`new-rpm-report`** SHALL remain limited to **`name`**, **`version`**, **`release`**, **`arch`**, and **`cveId`**; the server SHALL supply **`pipeline_mode`**, **`analysis_type`**, and **`target_package`**. + +#### Scenario: Stored RPM report JSON includes mandatory image fields + +- **WHEN** an authenticated client successfully calls **`POST /api/v1/reports/new-rpm-report`** with valid **`name`**, **`version`**, **`release`**, **`arch`**, and **`cveId`** +- **THEN** the persisted report’s **`report.input.image.pipeline_mode`** is **`rpm_package_checker`** +- **AND** **`report.input.image.analysis_type`** is **`source`** +- **AND** **`report.input.image.target_package`** equals **`{ name, version, release, ecosystem: "rpm", arch }`** as specified in this capability spec + +#### Scenario: Morpheus generate accepts rpm checker payload + +- **WHEN** the stored **`input`** for a successfully created RPM report is submitted to Agent Morpheus ingest / generate APIs that validate against **`AgentMorpheusInput`** / **`ImageInfoInput`** +- **THEN** validation errors SHALL NOT occur for missing mandatory **`pipeline_mode`**, **`analysis_type`**, or **`target_package`** on **`image`** + diff --git a/openspec/specs/reports-input-type/spec.md b/openspec/specs/reports-input-type/spec.md new file mode 100644 index 00000000..ac584518 --- /dev/null +++ b/openspec/specs/reports-input-type/spec.md @@ -0,0 +1,39 @@ +# reports-input-type Specification + +## Purpose +TBD - created by archiving change reports-filter-by-artifact-kind. Update Purpose after archive. +## Requirements +### Requirement: inputType on GET /api/v1/reports + +**`GET /api/v1/reports`** SHALL support an optional **`inputType`** query parameter with allowed values **`repository`** and **`rpm`** only. **`GET /api/v1/reports/product`** MUST NOT gain **`inputType`** or any parallel parameter as part of this capability. + +When **`inputType`** is **omitted**, the server MUST NOT apply **`inputType`**-based filtering (result set follows paging, sorting, and all other query parameters only, with **no** restriction on **`metadata.product_id`** or pipeline mode from **`inputType`**). + +When **`inputType`** is **`repository`** or **`rpm`**, every returned report SHALL have **`metadata.product_id`** absent (reports with a product id MUST be excluded). Additionally: **`inputType=repository`** SHALL return only reports whose **`input.image.pipeline_mode`** is **not** **`rpm_package_checker`**; **`inputType=rpm`** SHALL return only reports whose **`input.image.pipeline_mode`** **is** **`rpm_package_checker`**. + +Any other **`inputType`** value SHALL be rejected with **HTTP 400** and a documented error. + +Legacy **`withoutProduct`** and tab-only use of **`pipelineMode`** to distinguish RPM vs repository listings on **`GET /api/v1/reports`** SHALL be removed in favor of **`inputType`**. + +#### Scenario: Omit inputType does not filter by product or pipeline + +- **WHEN** a client calls **`GET /api/v1/reports`** without an **`inputType`** query parameter +- **THEN** the response MUST NOT be restricted by **`inputType`** rules (product-associated and standalone reports MAY both appear, subject to other filters) + +#### Scenario: Repository excludes product_id and RPM checker + +- **WHEN** a client calls **`GET /api/v1/reports`** with **`inputType=repository`** +- **THEN** every returned report SHALL have **`metadata.product_id`** absent +- **AND** **`input.image.pipeline_mode`** SHALL NOT be **`rpm_package_checker`** + +#### Scenario: RPM excludes product_id and requires checker pipeline + +- **WHEN** a client calls **`GET /api/v1/reports`** with **`inputType=rpm`** +- **THEN** every returned report SHALL have **`metadata.product_id`** absent +- **AND** **`input.image.pipeline_mode`** SHALL be **`rpm_package_checker`** + +#### Scenario: Invalid inputType + +- **WHEN** a client calls **`GET /api/v1/reports`** with **`inputType`** set to a value other than **`repository`** or **`rpm`** +- **THEN** the server responds with **HTTP 400** + diff --git a/openspec/specs/reports-table/spec.md b/openspec/specs/reports-table/spec.md index a4f1d2e4..ea1e173d 100644 --- a/openspec/specs/reports-table/spec.md +++ b/openspec/specs/reports-table/spec.md @@ -144,23 +144,34 @@ The reports table SHALL display a "Finding" column with one finding per product. - **AND** the count is shown in the same format as Vulnerable and Uncertain (e.g. "3 Excluded") ### Requirement: Reports Page Tabs and Single Repositories -The Reports page SHALL display two tabs: **SBOMs** (default) and **Single Repositories**. Selecting a tab SHALL switch content and update the URL: `/reports` for SBOMs, `/reports/single-repositories` for Single Repositories. Direct navigation to either URL SHALL show the corresponding tab. Spacing between tab bar and content SHALL use PatternFly Stack/StackItem with the standard spacer. + +The Reports page SHALL display three tabs in order: **SBOMs** (default), **Single Repositories**, **RPM**. Selecting a tab SHALL switch content and update the URL: **`/reports`** (**SBOMs**), **`/reports/single-repositories`** (**Single Repositories**), **`/reports/rpm`** (**RPM**). Direct navigation to any of those URLs SHALL show the matching tab selected. Spacing between tab bar and content SHALL use PatternFly Stack/StackItem with the standard spacer. #### Scenario: Tabs and URL + - **WHEN** a user is on the Reports page -- **THEN** two tabs are shown; SBOMs tab shows the product-level reports table (filtering, sorting, pagination as in Reports Table Display/Filtering); Single Repositories tab shows a table of reports without `report.metadata.product_id` +- **THEN** three tabs are shown; SBOMs tab shows the product-level reports table (filtering, sorting, pagination as in Reports Table Display/Filtering) using **`GET /api/v1/reports/product`** **without** new query parameters from **reports-input-type**; Single Repositories tab shows standalone repository workflow reports; RPM tab shows standalone RPM checker reports per RPM routing below - **AND** clicking a tab navigates to its URL; browser Back/Forward SHALL switch tabs correctly #### Scenario: Single Repositories table + - **WHEN** the Single Repositories tab is active -- **THEN** a table is shown that conforms to the **repository-reports-table** specification (columns, Finding column logic, shared InProgressStatus/FailedStatus, single Finding filter, pagination, loading and empty/error behavior) -- **AND** data is from `/api/v1/reports` with a parameter returning only reports where `report.metadata.product_id` is absent; pagination, sorting, and filtering are server-side -- **AND** "View" navigates to `/reports/component/:cveId/:reportId`; empty and error states are displayed when applicable +- **THEN** a table is shown that conforms to **repository-reports-table** (**Single Repositories variant**: Repository and Commit ID columns, Finding column logic, toolbar, pagination, loading and empty/error rules) +- **AND** data is from **`GET /api/v1/reports`** with **`inputType=repository`** (standalone only: **no** **`metadata.product_id`**); pagination, sorting, and filtering remain server-side unless delegated to **repository-reports-table** +- **AND** View navigates to **`/reports/component/:cveId/:reportId`**; empty and error states are displayed when applicable + +#### Scenario: RPM reports tab table -#### Scenario: Implementation and test data -- **WHEN** implementing the feature -- **THEN** Single Repositories tab content SHALL live in a dedicated component (e.g. `SingleRepositoriesTable.tsx`) reusing patterns from `RepositoryReportsTable` -- **AND** devservices/test data SHALL include at least one report without `report.metadata.product_id` (e.g. under `src/test/resources/devservices/reports/`) for verification +- **WHEN** the **RPM** tab is active +- **THEN** a table is shown that conforms to **repository-reports-table** **RPM Reports tab variant** (**Package** and **Architecture** columns in place of Repository and Commit ID); Finding column, timestamps, CVE filter visibility, pagination, skeleton and empty/error rules SHALL match Single Repositories except where **RPM Reports tab table variant** specifies otherwise +- **AND** listing SHALL use **`GET /api/v1/reports`** with **`inputType=rpm`** (**RPM checker**, standalone only: **no** **`metadata.product_id`** per **reports-input-type**); paging, sorting, and filtering remain server-side +- **AND** View navigation SHALL use **`/reports/component/:cveId/:reportId`** + +#### Scenario: Implementation notes + +- **WHEN** implementing this surface +- **THEN** RPM tab content MAY live alongside existing tab shells (for example **`RpmReportsTable.tsx`** or parameterized reuse of **`RepositoryReportsTable`** patterns) +- **AND** devservices fixture data SHALL cover at least one standalone RPM checker report (**`pipeline_mode`** **`rpm_package_checker`**, **`metadata.product_id`** absent) ### Requirement: Repos Analyzed column format The reports table SHALL display the "Repos Analyzed" column using the same calculation as the report page Details card: num completed from `statusCounts["completed"]` and num submitted from `product.data.submittedCount`. The display SHALL use the same shared formatter as the report details card, with the optional suffix "analyzed" (e.g. "5/10 analyzed"). @@ -199,3 +210,4 @@ The SBOMs tab product-level reports table SHALL list only products that correspo - **WHEN** a product row is returned for an accepted analysis batch where reports exist and the Finding rules classify the batch as still in progress (including edge cases where summary state is processing but individual status keys are not yet populated) - **THEN** the Finding column SHALL show **In progress** (or another defined label from the Finding rules), not a permanently blank cell + diff --git a/openspec/specs/repository-report-page/spec.md b/openspec/specs/repository-report-page/spec.md index e0e72cb4..f8873bf1 100644 --- a/openspec/specs/repository-report-page/spec.md +++ b/openspec/specs/repository-report-page/spec.md @@ -1,130 +1,160 @@ # repository-report-page Specification ## Description -A detailed view page that displays comprehensive information about a specific repository report, including vulnerability analysis results, analysis state, and download options for VEX and report data. + +Saved repository vulnerability report page: breadcrumbs, **CVE repository report details** (**`DetailsCard`**) with distinct **artifact details** for container vs **RPM checker** reports, supplementary RPM **Details** markdown, downloads, SSE. ## Purpose -View individual repository report details for a specific CVE, image, and tag combination within an SBOM report or as a standalone component report. -When the API report status is a **failing** state (`failed` or `expired` from the backend), the UI treats it as **Failed** everywhere: **Analysis State** and the CVE line use the same **Failed** label as repository findings tables. The `error.type` field is not shown; only **Failure reason** (`report.error.message`) explains the outcome. The **Analysis Q&A** card is omitted, and agent-only detail rows (CVSS Score, Intel Reliability Score, Reason, Summary) are not shown. +**CVE** everywhere on the page comes from the route parameter **`cveId`** (**Repository report page CVE identity**). **`DetailsCard`** omits **CVSS** for every API **`status`**. **`failed`**/**`expired`** strips analysis rows **after artifact details** and **ChecklistCard** (non-RPM); see **Failing API status**. ## Requirements + +### Requirement: Repository report page CVE identity + +The page **SHALL** treat the route parameter **`cveId`** as **Repository report page CVE identity**. **CVE** labels, **`DetailsCard`** CVE display and link targets, breadcrumb **CVE** fragments, page title **CVE** fragments, and **`output.analysis`** row selection **SHALL** use that route **`cveId`**. **`report.input.scan.vulns`** **SHALL NOT** override route **`cveId`** for those purposes. **`output.analysis[*].vuln_id`** **SHALL NOT** be used alone to determine which CVE the page is for. + +#### Scenario: Identity wires **`output`** and **`DetailsCard`** + +- **WHEN** the user opens a repository report URL with **`cveId`** +- **THEN** **`output.analysis`** lookups **SHALL** use the row whose **`vuln_id`** equals route **`cveId`** +- **AND** **`DetailsCard`** CVE **SHALL** display and link using route **`cveId`** + ### Requirement: Repository Report Page Routes -The repository report page SHALL support multiple route patterns. - -#### Scenario: SBOM report route pattern -- **WHEN** a user navigates to `/reports/product/:productId/:cveId/:reportId` -- **THEN** the repository report page displays with a SBOM report breadcrumb showing the SBOM report ID and CVE ID - -#### Scenario: Component route pattern -- **WHEN** a user navigates to `/reports/component/:cveId/:reportId` -- **THEN** the repository report page displays without an SBOM report breadcrumb - -#### Scenario: Legacy route pattern -- **WHEN** a user navigates to `/reports/:productId/:cveId/:reportId` -- **THEN** the repository report page displays (supported for backward compatibility) - -### Requirement: Repository Report Page Breadcrumb Navigation -The repository report page SHALL display a hierarchical breadcrumb navigation at the top of the page showing the navigation path from the reports list through the SBOM report/CVE report (if applicable) to the individual repository report. - -#### Scenario: Breadcrumb for SBOM report route -- **WHEN** a user views the repository report page at `/reports/product/:productId/:cveId/:reportId` -- **THEN** a breadcrumb navigation is displayed at the top of the page with three items: - - First item: "Reports" displayed as a clickable link that navigates to `/reports` (reports list page) - - Second item: SBOM Report ID and CVE ID (format: `/`) displayed as a clickable link that navigates to `/reports/product/:productId/:cveId` (SBOM report/CVE report page) - - Third item: Report identifier (format: ` | | `) displayed as non-clickable text indicating the current page - -#### Scenario: Breadcrumb for component route -- **WHEN** a user views the repository report page at `/reports/component/:cveId/:reportId` -- **THEN** a breadcrumb navigation is displayed at the top of the page with two items: - - First item: "Reports" displayed as a clickable link that navigates to `/reports` (reports list page) - - Second item: Report identifier (format: ` | | `) displayed as non-clickable text indicating the current page -- **AND** no SBOM report/CVE breadcrumb item is displayed - -#### Scenario: Breadcrumb SBOM report ID from report metadata -- **WHEN** a user views the repository report page with a SBOM report route -- **THEN** the SBOM report ID in the second breadcrumb item is extracted from `report.metadata.product_id` -- **AND** if `product_id` is not available in metadata, the SBOM report ID from route parameters is used - -#### Scenario: Breadcrumb CVE ID from route -- **WHEN** a user views the repository report page -- **THEN** the CVE ID in the second breadcrumb item (for SBOM report routes) is extracted from the `cveId` route parameter - -#### Scenario: Breadcrumb report identifier from report data -- **WHEN** a user views the repository report page with report data loaded -- **THEN** the report identifier breadcrumb item displays in the format ` | | ` -- **AND** the CVE ID is extracted from the vulnerability output matching the route `cveId` parameter (`vuln.vuln_id`) -- **AND** the image name and tag are extracted from `report.input.image.name` and `report.input.image.tag` respectively -- **AND** if image name or tag is missing, empty string is used - -#### Scenario: Breadcrumb navigation to reports list -- **WHEN** a user clicks the "Reports" breadcrumb item on the repository report page -- **THEN** the application navigates to `/reports` (reports list page) - -#### Scenario: Breadcrumb navigation to SBOM report/CVE report -- **WHEN** a user clicks the SBOM report ID/CVE ID breadcrumb item on the repository report page (SBOM report route only) -- **THEN** the application navigates to `/reports/product/:productId/:cveId` where `:productId` and `:cveId` are extracted from the route parameters - -### Requirement: Repository Report Page Content -The repository report page SHALL display report details in a structured layout with cards showing different aspects of the repository report, including the analysis state of the report. - -The repository report page SHALL display an inline warning alert with the title "AI usage notice" and the message "Always review AI generated content prior to use." The alert SHALL be positioned below the page title and above the report detail cards to remind users to review AI-generated content. - -The repository report page SHALL automatically refresh data by subscribing to SSE and re-fetching from `/api/v1/reports/{id}` when events arrive, but only when the report status is not "completed" or "failed". When the report status is "completed" or "failed", live refresh SHALL stop. - -The repository report page SHALL compare the report status between the previous and current data during live refresh. The page SHALL only trigger a rerender if the report status has changed. This optimization SHALL prevent unnecessary rerenders and UI jumps when the report status remains unchanged. Note: Only the status field is compared, not the entire report object. - -The repository report page SHALL display a Feedback card after the RepositoryAdditionalDetailsCard (Additional Details card) in the same grid only when the report status is "completed". - -#### Scenario: CVE repository report details card (DetailsCard) -- **WHEN** a user views the repository report page with report data loaded -- **THEN** the `CVE repository report details` card (DetailsCard) displays a description list whose first row is **Finding**, computed from the report API `status` field and the vulnerability analysis justification (same **Failed** presentation as repository findings tables when status is a failing state) -- **AND** when analysis is in a failing state, **Failure reason** appears next, showing `report.error.message` only -- **AND** **CVE** is shown as an internal app link to the CVE details route for the current report -- **AND** **Repository URL** is shown as follows: when the code entry in `report.input.image.source_info` includes both `git_repo` and `ref`, the field is an external link whose URL is the repository base (trim trailing `/`, strip a trailing `.git` suffix, then trim trailing `/` again) followed by `/commit/` and the `ref` value, so the link opens the code snapshot for that revision; the visible link text matches that URL; when only `git_repo` is present, the link targets and displays `git_repo`; when neither is usable, **Not available** is shown -- **AND** **Image** shows the pull reference (for example `registry/repository:tag`, or `registry/repository@sha256:…`) as plain text suitable for any OCI-compatible `pull` command when the report has a pullable container reference; when the report is source-based (for example `analysis_type` is `source` or the image `name` is an `http`/`https` URL that is not a registry reference), **Not available** is shown; the value is not a hyperlink -- **AND** when analysis is not in a failing state, additional rows include CVSS Score, Intel Reliability Score, Reason, and Summary -- **AND** the Analysis Q&A card (ChecklistCard) is not shown when analysis is in a failing state - -#### Scenario: Live refresh prevents unnecessary rerenders -- **WHEN** the repository report page SSE-driven refetch runs AND the report status has not changed -- **THEN** the page SHALL compare only the report status between the previous and current data -- **AND** the page SHALL skip the state update (prevent rerender) if the report status is unchanged -- **AND** the page SHALL trigger a rerender if the report status has changed -- **AND** this optimization SHALL prevent UI jumps and visual disruption when the report status remains unchanged -- **AND** note that only the status field is compared, not the entire report object - -#### Scenario: Feedback card displayed after Additional Details -- **WHEN** a user views the repository report page with report data loaded AND the report status is "completed"(Feedback card not shown when report not completed) -- **THEN** a Feedback card is displayed in the same grid after the RepositoryAdditionalDetailsCard (Additional Details card) -- **AND** the Feedback card has the title "Feedback" and the subtitle "Your feedback will be used to improve the accuracy of our AI models."(for more details see feedback-report spec ) - - -### Requirement: Download Feature -The repository report page SHALL provide a download button that allows users to download either the VEX (Vulnerability Exploitability eXchange) data or the complete report as JSON files. - -#### Scenario: Download dropdown menu -- **WHEN** a user clicks the **Download** button -- **THEN** a dropdown menu opens displaying two options: - - "VEX" option for downloading VEX data - - "Report" option for downloading the complete report data - -#### Scenario: VEX download availability -- **WHEN** a user views the repository report page -- **AND** the report contains VEX data (component is in a vulnerable status) -- **THEN** the "VEX" option in the download dropdown is enabled and clickable -- **AND** when the user clicks the "VEX" option, a JSON file is downloaded containing the VEX data from `report.output.vex` -- **AND** the downloaded file is named in the format `vex-{cveId}-{reportId}.json` - -#### Scenario: VEX download disabled -- **WHEN** a user views the repository report page -- **AND** the report does not contain VEX data (`report.output.vex` is null or undefined, indicating the component is not in a vulnerable status) -- **THEN** the "VEX" option in the download dropdown is disabled (not clickable) -- **AND** the disabled state visually indicates that VEX data is not available - -#### Scenario: Report download -- **WHEN** a user clicks the "Report" option in the download dropdown -- **THEN** a JSON file is downloaded containing the complete report data -- **AND** the downloaded file is named in the format `report-{cveId}-{reportId}.json` -- **AND** the report download option is always available regardless of VEX data availability +The page **SHALL** support **`/reports/product/:productId/:cveId/:reportId`**, **`/reports/component/:cveId/:reportId`**, and legacy **`/reports/:productId/:cveId/:reportId`**. + +#### Scenario: Path shapes + +- **WHEN** any supported URL **THEN** the page **SHALL** load **AND** product URLs **SHALL** include the extra SBOM breadcrumb segment that component URLs omit + +### Requirement: RPM package checker detection + +**RPM checker** branching on the repository report page SHALL follow **rpm-package-checker-report** (normative **`pipeline_mode`** rule). **repository-report-page** RPM behaviors (**breadcrumb tail**, **artifact details**, **Details** section, failing omissions) SHALL apply **only** for reports classified as RPM checker under that capability. Otherwise non-RPM **artifact** and **`DetailsCard`** rules SHALL apply. + +#### Scenario: Mode + +- **WHEN** a loaded report qualifies as **RPM checker** per **rpm-package-checker-report** +- **THEN** **RPM** branches **apply** + +- **WHEN** a loaded report does not qualify as RPM checker per **rpm-package-checker-report** +- **THEN** non-RPM **artifact** and **`DetailsCard`** rules SHALL apply + + +### Requirement: Breadcrumb — what appears and data sources + +The page **SHALL** render breadcrumb segments in this **order**: **Reports** (always) → **SBOM parent** (product URL only) → **active tail** (always last, not a link). + +1. **Reports:** static label, target **`/reports`**. +2. **SBOM parent** (only **`/reports/product/:productId/:cveId/:reportId`**): shows **`{product}/{CVE}`**, target **`/reports/product/:productId/:cveId`**. **`product`** **SHALL** be **`report.metadata.product_id`**, else route **`productId`**. **`CVE`** label **SHALL** be route **`cveId`** (**CVE identity**). +3. **Active tail:** **`{CVE} | {name} | {tag}`**. **`CVE`** **SHALL** be route **`cveId`** (**not** from **`output.analysis`** alone). **`name`**/**`tag`** **SHALL** be **`report.input.image.name`**/**`tag`**, **empty string** if missing. **RPM checker** with **`target_package`**: if **`name`**/**`tag`** would be empty placeholders, the active tail **SHALL NOT** join **`target_package`** **name**, **version**, **release**, and **arch** with spaces. Instead it **SHALL** use **`{CVE} | {N-V-R} | {arch}`** where **{N-V-R}** is **`name`**, **`version`**, and **`release`** joined by hyphen **U+002D** when all three are non-empty after trim (**same hyphenation rule as the DetailsCard Package row**), **{arch}** is **`target_package.arch`** trimmed or **empty string** if missing, preserving three **`|`-separated** visual slots (**CVE**, **N-V-R slot**, **architecture slot**). If **N-V-R** cannot be formed (any of **name**/**version**/**release** missing or blank after trim), **{N-V-R}** **SHALL** be **empty string**. The **same active-tail string** (**`{CVE} | … | …`**) **SHALL** drive the **CVE Repository Report** **`h1`** subtitle segment that follows the **CVE Repository Report:** prefix. + +**Clicks:** **Reports** → **`/reports`** SBOM middle → **`/reports/product/:productId/:cveId`**. + +#### Scenario: Product vs component + +- **WHEN** product URL **THEN** **SHALL** render segments 1–3 + +- **WHEN** component URL **THEN** **SHALL** render segments 1 and 3 only + +#### Scenario: RPM checker breadcrumb uses N-V-R, not space-separated Nevra + +- **WHEN** the loaded report is **RPM checker** and **`target_package`** has **name**, **version**, **release**, and **arch** present after trim +- **THEN** the active tail **SHALL** show **`{CVE} | {name-version-release} | {arch}`** with hyphens between **name**, **version**, and **release** +- **AND** **SHALL NOT** render **`{CVE} | name version release arch`** as a single spaced Nevra blob + +### Requirement: Unavailable and missing values + +**Default:** **`DetailsCard`** values **SHALL** show **`Not available`** when the backing field is missing, blank after trim, or unusable, unless this spec specifies otherwise (e.g. breadcrumb uses **empty string** for missing **`name`**/**`tag`** slots). **Feedback** hidden unless **`status`** **`completed`** is normal, not **`Not available`**. + +#### Scenario: **`DetailsCard`** gap + +- **WHEN** a **`DetailsCard`** source is absent **THEN** the page **SHALL** show **`Not available`** + +### Requirement: Primary details card — artifact details and fields + +The **`DetailsCard` SHALL** include an **artifact details** section **after** **CVE** and **before** the analysis rows (**Intel** onward). **Artifact details** **SHALL** group “which artifact” content: **non-RPM** includes **Repository URL** and **Image** inside this block (**no** standalone numbered **Image** row **outside** it); **RPM** includes **Package**, **Architecture**, and **RPM package URL**, in that order (**no** **Repository URL**, **no** **Image**). + +**Repository URL** (**non-RPM**): from first **`source_info`** with **`type === code`**. **`git_repo`** + **`ref`** **SHALL** → external link **`{normalizedBase}/commit/{ref}`** (trim **`/`**, strip **`.git`** from base, visible text = URL). **`git_repo`** only **SHALL** → link to **`git_repo`**. Else **`Not available`**. + +**Image** (**non-RPM**, inside **artifact details**): pull-style plaintext from **`report.input.image`** (registry vs **source**/URL rules); **`Not available`** when not pullable. + +**RPM artifact details:** **Package** **SHALL** be **`name`-`version`-`release`** (hyphen U+002D) **only if** all three on **`report.input.image.target_package`** are non-empty after trim **else** **`Not available`**. **Architecture** **SHALL** be **`target_package.arch`** or **`Not available`**. **RPM package URL** **SHALL** be **`report.info.checker_context.artifacts.source_url`** as an external hyperlink when that value is non-empty after trim **else** **`Not available`**. + +Row order for **`DetailsCard`** is items 1–8 below. When **Failing API status** applies, the page **SHALL** omit rows **≥** **5** in this list (**Intel** onward). **ChecklistCard** **SHALL NOT** appear when failing (non-RPM). For **RPM**, **ChecklistCard** **SHALL NOT** appear for any **`status`**. **`DetailsCard`** **SHALL NOT** render **CVSS Score**. + +1. **Finding** — API **`status`** + **`output.analysis.justification.status`** when row exists (**Failed** matches findings tables on **`failed`**/**`expired`**). +2. **Failure reason** — only on **`failed`**/**`expired`**: **`report.error.message`** (**`error.type`** not shown here). +3. **CVE** — route **`cveId`**, internal link (**CVE identity**). +4. **Artifact details** — **non-RPM**: **Repository URL** + **Image**; **RPM**: **Package** + **Architecture** + **RPM package URL** (per rules above). +5. **Intel Reliability Score** — **`output.analysis.intel_score`** if not failing. +6. **Justification** — **`output.analysis.justification.label`** if not failing. +7. **Reason** — **`output.analysis.justification.reason`** as markdown if not failing. +8. **Summary** — **`output.analysis.summary`** as markdown if not failing. + +#### Scenario: Non-RPM completed + +- **WHEN** not **RPM** and not failing **THEN** **`DetailsCard` SHALL** match the ordered list with **artifact details** containing **Repository URL** and **Image**, **then** analysis rows **5–8**, with **no** **CVSS** + +#### Scenario: RPM completed + +- **WHEN** **RPM** and not failing **THEN** **`DetailsCard` SHALL** use **artifact details** with **Package**, **Architecture**, and **RPM package URL** in that order, **then** rows **5–8**, with **no** **Repository URL** row **and no** **Image** row + +#### Scenario: RPM package URL absent + +- **WHEN** **`report.info.checker_context.artifacts.source_url`** is absent, empty, or blank after trim **THEN** **`DetailsCard` SHALL** show **`Not available`** for **RPM package URL** + +### Requirement: Failing API status + +On **`failed`**/**`expired`**, **`DetailsCard` SHALL** keep rows 1–4 only (**Finding** **Failed**, **Failure reason**, **CVE**, **artifact details**). For **non-RPM**, **artifact details** **SHALL** include **Repository URL** and **Image** therein. For **RPM**, **artifact details** **SHALL** include **Package**, **Architecture**, and **RPM package URL** in that order (**no** **Image**). **SHALL NOT** show rows **5–8** (**Intel** through **Summary**). **Non-RPM** **SHALL NOT** show **ChecklistCard**. **RPM** **SHALL NOT** show **Details** markdown section (**RPM** supplementary **Details**) or **RpmRelatedLinksSection**. **`RepositoryAdditionalDetailsCard` SHALL** still render (**including** **RPM** failures), subject to **`RepositoryAdditionalDetailsCard`** placement and CVSS omission. + +The page **SHALL** run SSE refetches **while** **`status`** is **neither** **`completed`** **nor** **`failed`**. **SHALL** stop SSE-driven refetch when **`status`** is **`completed`** or **`failed`**. **SHALL** apply state updates after refetch **only** when **`status`** changes. + +#### Scenario: Truncated **`DetailsCard`** (non-RPM) + +- **WHEN** **`failed`** or **`expired`** and **not** **RPM** **THEN** **`DetailsCard` SHALL** end after **artifact details** (including **Image** therein) (**no** **Intel**/**Justification**/**Reason**/**Summary**) + +#### Scenario: Truncated **`DetailsCard`** (RPM) + +- **WHEN** **`failed`** or **`expired`** and **RPM** **THEN** **`DetailsCard` SHALL** end after **artifact details** (**Package**, **Architecture**, **RPM package URL**) (**no** **Intel**/**Justification**/**Reason**/**Summary**) + +### Requirement: **`RepositoryAdditionalDetailsCard`** placement and CVSS omission + +The page **SHALL** render **`RepositoryAdditionalDetailsCard`** (expandable **Additional Details**) **after **`DetailsCard`** for **RPM checker** **and** **non-RPM** repository reports whenever the repository report grid is shown (**including** **Failing API status** (**`failed`**/**`expired`**)). **`RepositoryAdditionalDetailsCard`** **SHALL NOT** display **CVSS Vector String** (**`output.analysis.cvss.vector_string`** or equivalent) **or** any **`DescriptionList`** row labeled for **CVSS** vector strings, for either mode. + +#### Scenario: **`RepositoryAdditionalDetailsCard`** on **RPM** and **non-RPM** + +- **WHEN** the repository report grid is shown **and** **RPM checker** **or** **non-RPM** **THEN** **`RepositoryAdditionalDetailsCard` SHALL** render + +#### Scenario: **`RepositoryAdditionalDetailsCard`** when **Finding** truncated + +- **WHEN** **`failed`** **or** **`expired`** **THEN** **`RepositoryAdditionalDetailsCard` SHALL** still render + +#### Scenario: No **CVSS** vector row in **Additional Details** + +- **WHEN** **`RepositoryAdditionalDetailsCard`** is expanded +- **THEN** the page **SHALL NOT** show **CVSS Vector String** + +### Requirement: RPM-only supplementary content + +When **RPM checker** and **not** failing, the page **SHALL** render a **Details** section: **`output.analysis.details`** as markdown for the **`output.analysis`** row selected by **CVE identity** (route **`cveId`**). The page **SHALL** show **`Not available`** when **`details`** is absent or blank. The page **SHALL** set the page subtitle so **CVE** (route **`cveId`**) plus artifact label uses **`target_package`** formatted **as N-V-R and architecture segments consistent with** the **active tail** (hyphenated **name-version-release** and **architecture** slot, **not** space-separated **name version release arch**). + +#### Scenario: **`details`** populated + +- **WHEN** non-empty **`details`** **THEN** the page **SHALL** render markdown + +#### Scenario: **`details`** empty + +- **WHEN** absent or blank **THEN** the page **SHALL** show **`Not available`** + +### Requirement: Alert, downloads, feedback + +The repository report page **SHALL** show an **Alert** under **`h1`**, above cards: **AI usage notice** / **Always review AI generated content prior to use.** + +The page **SHALL** provide **Download**. **VEX** **SHALL** use filename **`vex-{cveId}-{reportId}.json`**, **SHALL** be enabled when **`report.output.vex`** is present, and **SHALL** be disabled when **`report.output.vex`** is absent. **Report** **SHALL** always be enabled with filename **`report-{cveId}-{reportId}.json`** (full JSON). + +The page **SHALL** show **Feedback** after **RepositoryAdditionalDetailsCard** only when **`status`** is **`completed`** (copy per feedback-report). + +#### Scenario: Feedback when done + +- **WHEN** **`completed`** **THEN** **Feedback** **SHALL** follow **RepositoryAdditionalDetailsCard** diff --git a/openspec/specs/repository-reports-table/spec.md b/openspec/specs/repository-reports-table/spec.md index b79cd421..001153bb 100644 --- a/openspec/specs/repository-reports-table/spec.md +++ b/openspec/specs/repository-reports-table/spec.md @@ -1,10 +1,33 @@ # repository-reports-table Specification ## Purpose -Defines the structure and behavior of the repository reports table. This table is used in two places: embedded on the report page (filtered by product and CVE) and as the Single Repositories tab table. Both SHALL conform to this specification. +Defines structure and behavior of the repository reports table for **embedded SBOM-linked** contexts (**report-page**), **Standalone** **`/reports/single-repositories`**, and **RPM** **`/reports/rpm`** (RPM Reports tab variant). ## Requirements +### Requirement: RPM Reports tab table variant + +The RPM tab (see **reports-table**, route **`/reports/rpm`**) SHALL list only standalone RPM checker reports via **`inputType=rpm`** on **`GET /api/v1/reports`** per **rpm-package-checker-report** (**`metadata.product_id`** MUST be absent for every row—see **reports-input-type**). + +Every **`GET /api/v1/reports`** from this tab SHALL include **`inputType=rpm`** alongside paging/sorting/other active filters. + +RPM tab layouts SHALL omit the **`git_repo`** (**Repository Name**) toolbar search/filter control shown on **`/reports/single-repositories`**. + +For RPM layouts, artifact-identifying columns before Finding SHALL header **Package** and **Architecture** instead of Repository and Commit ID. The **Package** cell SHALL format **`name`-`version`-`release`** (hyphens U+002D) from **`report.input.image.target_package`** fields **only when** **name**, **version**, **release** are each non-empty after trim; otherwise nothing. The **Architecture** cell SHALL display trimmed **target_package.arch** or nothing. + +#### Scenario: RPM tab query parameters + +- **WHEN** the RPM tab fetches paginated rows +- **THEN** **`inputType=rpm`** is included on each **`GET /api/v1/reports`** request + +#### Scenario: Package cell hyphen-delimited triple + +- **WHEN** **target_package** supplies non-empty trimmed **name**, **version**, **release** +- **THEN** **Package** shows hyphen-delimited NVR + ### Requirement: Table structure and Finding column -The repository reports table SHALL display columns: **ID** (first column, width 10%), Repository, Commit ID, Finding, **Date Requested**, and **Date Completed**. The ID column SHALL display `report.id` as a link to the report page (component route: `/reports/component/{cveId}/{report.id}`; product route: `/reports/product/{productId}/{cveId}/{report.id}`). The **Date Requested** column SHALL display `metadata.submitted_at` when present, in the format "DD Month YYYY, HH:MM:SS AM/PM"; when `metadata.submitted_at` is missing, the cell SHALL display "-". The **Date Completed** column SHALL display `report.completedAt` in the same format. All date fields SHALL use the format "DD Month YYYY, HH:MM:SS AM/PM" (e.g., "07 July 2025, 10:14:02 PM"). + +The repository reports table SHALL display columns: **ID** (first column, width 10%), Repository, Commit ID, Finding, **Date Requested**, and **Date Completed** when embedded on the SBOM report page or **`/reports/single-repositories`**. When **`/reports/rpm`** (RPM Reports tab) is shown, artifact-identifying columns before Finding SHALL omit Repository and Commit ID and SHALL display **Package** and **Architecture** per **RPM Reports tab table variant** above. + +The ID column SHALL display `report.id` as a link to the report page (component route: `/reports/component/{cveId}/{report.id}`; product route: `/reports/product/{productId}/{cveId}/{report.id}`). The **Date Requested** column SHALL display `metadata.submitted_at` when present, in the format "DD Month YYYY, HH:MM:SS AM/PM"; when `metadata.submitted_at` is missing, the cell SHALL display "-". The **Date Completed** column SHALL display `report.completedAt` in the same format. All date fields SHALL use the format "DD Month YYYY, HH:MM:SS AM/PM" (e.g., "07 July 2025, 10:14:02 PM"). The table SHALL display a single **Finding** column (no separate "Analysis state" or "ExploitIQ Status" column). The Finding cell SHALL show, per row: if the report's analysis state is **completed**, the ExploitIQ status (Vulnerable, Not vulnerable, or Uncertain) from the vulnerability justification; if the report's analysis state is **pending**, **queued**, or **sent**, "In progress" using the shared InProgressStatus component (grey outline label, InProgressIcon); if the report's analysis state is **expired** or **failed**, "Failed" using the shared FailedStatus component (grey filled label, ExclamationCircleIcon). Styling SHALL match the Finding column in the reports table for in-progress and failed states. @@ -18,7 +41,7 @@ The table SHALL display a single **Finding** column (no separate "Analysis state - **AND** In progress and Failed use the shared InProgressStatus and FailedStatus components so styling matches the reports table Finding column ### Requirement: Finding filter and toolbar -The repository reports table toolbar SHALL provide a single **Finding** filter (not separate Analysis state and ExploitIQ status filters). The Finding filter SHALL allow selecting exactly one finding value (e.g. Vulnerable, Not vulnerable, Uncertain, In progress, Failed), or none. When the user selects a Finding value, the table SHALL pass the corresponding backend parameter(s) to the reports API: "In progress" maps to status values for pending, queued, sent; "Failed" maps to status values for expired, failed; "Vulnerable", "Not vulnerable", and "Uncertain" map to exploitIqStatus (or equivalent API parameter) so that the backend returns only rows matching the selected finding. When no Finding value is selected, the table SHALL not apply a finding filter (no status/exploitIqStatus restriction from this filter). +The repository reports table toolbar SHALL provide a single **Finding** filter (not separate Analysis state and ExploitIQ Status filters); **RPM** **`/reports/rpm`** layouts SHALL NOT expose **`git_repo`** / Repository Name filter controls (see **RPM Reports tab table variant** above). The Finding filter SHALL allow selecting exactly one finding value (e.g. Vulnerable, Not vulnerable, Uncertain, In progress, Failed), or none. When the user selects a Finding value, the table SHALL pass the corresponding backend parameter(s) to the reports API: "In progress" maps to status values for pending, queued, sent; "Failed" maps to status values for expired, failed; "Vulnerable", "Not vulnerable", and "Uncertain" map to exploitIqStatus (or equivalent API parameter) so that the backend returns only rows matching the selected finding. When no Finding value is selected, the table SHALL not apply a finding filter (no status/exploitIqStatus restriction from this filter). #### Scenario: Finding filter and backend parameters - **WHEN** the repository reports table toolbar displays filters @@ -27,28 +50,70 @@ The repository reports table toolbar SHALL provide a single **Finding** filter ( - **AND** when the user selects a Finding value, the table SHALL pass the corresponding backend parameter(s) to the reports API so that the backend returns only rows matching the selected finding - **AND** when the user clears the Finding filter (no value selected), the table SHALL request data without finding-based status or exploitIqStatus filter parameters +### Requirement: RPM package substring filter + +The **`GET /api/v1/reports`** endpoint SHALL accept an optional query parameter **`rpmPackage`**. + +When **`rpmPackage`** is present and non-empty after trim, the result set SHALL include only report documents whose **`input.image.target_package`** has **name**, **version**, and **release** each non-empty after trim, **and** the case-insensitive **substring** condition holds on the concatenation **`name`** + **U+002D** + **`version`** + **U+002D** + **`release`** using the **same** logical joining as the **Package** column (no extra spaces). + +The match SHALL treat the **`rpmPackage`** parameter value as a **literal** substring (**not** a regular expression vocabulary): characters such as `.` (full stop) **U+002E** and **hyphen** **U+002D** MUST match themselves only (no regex metacharacter interpretation). + +Documents missing a complete **`name`**/**`version`**/**`release`** triple (after trim) SHALL NOT satisfy this filter, consistent with displaying an empty Package cell. + +The RPM Reports tab (**`/reports/rpm`**) SHALL expose a toolbar **Package** search control bound to **`rpmPackage`**. When the control is empty, **`rpmPackage`** SHALL be omitted from the request. **`/reports/single-repositories`** and embedded report-page layouts SHALL NOT show this Package search control. + +#### Scenario: Backend literal substring match + +- **WHEN** a caller invokes **`GET /api/v1/reports`** with **`rpmPackage`** set to **`3.1.2`** +- **THEN** responses include only reports where **`target_package`** has non-empty trimmed **name**, **version**, and **release** **and** the joined string contains **`3.1.2`** as a contiguous literal substring ignoring English letter case (**e.g.** matches **`LIBARCHIVE-3.1.2-14.el7_9.1`**) + +#### Scenario: Incomplete triple does not match + +- **WHEN** **`rpmPackage`** is non-empty **and** a document has a missing or blank **name**, **version**, or **release** field (after trim) on **`target_package`** +- **THEN** that document SHALL NOT appear in **`GET /api/v1/reports`** results filtered by **`rpmPackage`** + +#### Scenario: Substring spans name, version, and release boundaries + +- **WHEN** **`rpmPackage`** is a contiguous substring of the joined **`name`-`version`-`release`** string that is **not** wholly contained inside **exactly one** of **name**, **version**, or **release** alone (**e.g.** on **`libarchive-3.1.2-14.el7_9.1`** the value **`archive-3.1`** spans **name** into **version**; **`2-14.el7`** spans **version** into **release**) +- **THEN** reports whose full joined triple contains that literal substring SHALL match **because** filtering evaluates the **concatenated** display string **not** independent per-field substring tests + +#### Scenario: RPM tab toolbar + +- **WHEN** a user is on **`/reports/rpm`** +- **THEN** a Package search filter is visible **and** non-empty entries include **`rpmPackage`** on **`GET /api/v1/reports`** alongside **`inputType=rpm`** and other active filters + +#### Scenario: Non-RPM layouts omit Package toolbar control + +- **WHEN** a user views **`/reports/single-repositories`** **or** the embedded repository reports table on a report page +- **THEN** the Package (**`rpmPackage`**) toolbar control SHALL NOT appear + ### Requirement: Pagination and loading behavior -The repository reports table SHALL use backend pagination via `/api/v1/reports` with `page` and `pageSize` query parameters. The table SHALL display a loading skeleton only on the initial load when the table is first displayed. When users change sort order or filters, the existing table data SHALL remain visible while new data loads in the background; the table SHALL update with new data once the API call completes, without showing the skeleton. + +The repository reports table SHALL use backend pagination via `/api/v1/reports` with `page`, `pageSize`, and list parameters appropriate to layout (**`inputType=repository`** on **`/reports/single-repositories`**, **`inputType=rpm`** on **`/reports/rpm`**; both values imply **no** **`metadata.product_id`** per **reports-input-type**, plus **`gitRepo`** (repository tab), **`rpmPackage`** where used (RPM tab), **`vulnId`**, **aggregated Finding parameters** as implemented). The table SHALL display a loading skeleton only on the initial load when the table is first displayed. When users change sort order or filters, the existing table data SHALL remain visible while new data loads in the background; the table SHALL update with new data once the API call completes, without showing the skeleton. #### Scenario: Pagination + - **WHEN** a user views the repository reports table - **THEN** the table displays pagination controls - **AND** pagination uses backend pagination support via `/api/v1/reports` endpoint with `page` and `pageSize` query parameters - **AND** users can navigate between pages of repository reports #### Scenario: Loading skeleton on initial load + - **WHEN** the repository reports table is first displayed (initial load) - **THEN** a loading skeleton is displayed while the initial data is being fetched - **AND** the skeleton shows placeholder rows matching the table structure - **AND** once data is loaded, the skeleton is replaced with the actual table data #### Scenario: No skeleton on sort change + - **WHEN** a user changes the sort order in the repository reports table (e.g., clicks a column header to sort) - **THEN** the existing table data remains visible - **AND** the table updates with sorted data once the API call completes - **AND** no loading skeleton is displayed during the sort operation #### Scenario: No skeleton on filter change + - **WHEN** a user changes a filter value in the repository reports table (e.g., selects Finding filter or enters repository search text) - **THEN** the existing table data remains visible - **AND** the table updates with filtered data once the API call completes @@ -74,22 +139,30 @@ The repository reports table SHALL be sorted by default in descending order by s - **AND** the sort is performed by the reports API with the appropriate sort parameter(s) - **AND** the Date Requested column header reflects the active sort when applicable -### Requirement: CVE ID filter (Single Repositories only) -The repository reports table toolbar SHALL support an optional **CVE ID** filter. The CVE ID filter SHALL be displayed only when the table is used in the Single Repositories tab; it SHALL NOT be displayed when the table is embedded on the report page (product or component CVE context). When displayed, the user SHALL be able to enter or clear a CVE ID value (e.g. via a search or text input). When the user sets a CVE ID filter value, the table SHALL send that value to the reports API as the `vulnId` query parameter so that the backend returns only reports that include the specified vulnerability. When the filter is cleared, the table SHALL omit the parameter and show all reports (subject to other filters). +### Requirement: CVE ID filter (Standalone repository tables) + +The repository reports table toolbar SHALL support an optional **CVE ID** filter. The CVE filter SHALL surface on **`/reports/single-repositories`** and **`/reports/rpm`**; it SHALL stay hidden in embedded SBOM-linked contexts (**report-page** routing by product/component CVE). + +When visible, **`vulnId`** SHALL transmit entered values; clearing the CVE filter SHALL omit **`vulnId`** from the request while other standalone-tab parameters (**`Finding`**, **`inputType`**, repository **`gitRepo`** / RPM **`rpmPackage`** search text where applicable, etc.) remain applied per **`reports-input-type`** and this specification. + +#### Scenario: CVE toolbar on `/reports/single-repositories` + +- **WHEN** a user views the Single Repositories tab +- **THEN** the CVE ID filter SHALL be visible and wired to **`vulnId`** as implemented + +#### Scenario: CVE toolbar on `/reports/rpm` + +- **WHEN** a user views the RPM tab +- **THEN** the CVE ID filter SHALL use the same **`vulnId`** semantics as the Single Repositories tab + +#### Scenario: CVE toolbar hidden when embedded on report page -#### Scenario: CVE ID filter visible on Single Repositories tab -- **WHEN** a user views the repository reports table in the Single Repositories tab -- **THEN** the toolbar displays a CVE ID filter control (e.g. search or text input) -- **AND** the user can enter a CVE ID (e.g. CVE-2024-1234) to filter the table to reports that include that vulnerability -- **AND** the table requests data from the reports API with the `vulnId` query parameter set to the entered value +- **WHEN** the repository reports table is embedded in an SBOM report-linked context +- **THEN** the CVE manual filter SHALL NOT appear -#### Scenario: CVE ID filter not shown on report page -- **WHEN** a user views the repository reports table embedded on the report page (product or component CVE context) -- **THEN** the toolbar does NOT display the CVE ID filter -- **AND** the table continues to use the existing product/CVE context for filtering (no user-facing CVE ID filter) +#### Scenario: Clearing CVE filter on standalone tabs -#### Scenario: Clearing CVE ID filter -- **WHEN** a user clears the CVE ID filter in the Single Repositories table -- **THEN** the table requests data from the reports API without the `vulnId` parameter (or with it omitted) -- **AND** the table shows repository reports according to the other active filters only +- **WHEN** a user clears the CVE filter on **`/reports/single-repositories`** or **`/reports/rpm`** +- **THEN** **`vulnId`** SHALL be omitted from the **`GET /api/v1/reports`** request +- **AND** other filters (**Finding**, **`inputType`**, **`gitRepo`**, **`rpmPackage`** where applicable, etc.) SHALL remain unchanged diff --git a/openspec/specs/request-analysis-modal-rpm-fields/spec.md b/openspec/specs/request-analysis-modal-rpm-fields/spec.md new file mode 100644 index 00000000..b8cb9c50 --- /dev/null +++ b/openspec/specs/request-analysis-modal-rpm-fields/spec.md @@ -0,0 +1,79 @@ +# request-analysis-modal-rpm-fields Specification + +## Purpose + +**RPM** mode fields: hyphenated Package **N-V-R**, architecture selection, **`POST /api/v1/reports/new-rpm-report`** submit, success navigation and field-level **400** handling. Modal shell behaviors (CVE ID validation, keyboard **Enter**, mode switching, when private-repository UI applies) defer to **`request-analysis-modal`** and are not redefined here except where this spec states RPM-specific overrides. + +Sibling mode specs: [SBOM](../request-analysis-modal-sbom/spec.md), [Single Repository](../request-analysis-modal-single-repository/spec.md). Root modal: [`request-analysis-modal`](../request-analysis-modal/spec.md). + +## Implementation (non-normative) + +Form logic: `src/main/webui/src/hooks/useAnalysisRequestForm.ts`; validation helpers aligned with **`request-analysis-modal`** RPM handling; Presentation: `src/main/webui/src/components/request-analysis/` (RPM field subcomponents alongside existing mode fields). +## Requirements +### Requirement: RPM mode layout and controls + +RPM mode SHALL present the shared CVE ID field (per **`request-analysis-modal`**), a **`TextInput`** for the RPM package whose **`FormGroup` label text** is exactly **`Package N-V-R`** (no parenthetical suffix on the label). The **`TextInput` placeholder** SHALL be a **concrete example N-V-R** in **`name-version-release`** form (for example **`openssl-3.0.7-5.el9`**) so users see delimiter pattern and segment shape. Below the input, **`FormHelperText`** with **`HelperText`** SHALL include **at least one non-error `HelperTextItem`** that explains that the value is **hyphen-separated package name, version, and release** (brief wording acceptable). When client validation reports a package error, an error **`HelperTextItem`** SHALL also appear; the non-error explanatory helper SHOULD remain visible when an error is shown unless product accessibility copy rules require otherwise. An **Architecture** control SHALL provide the distinct selectable string values **`x86_64`**, **`amd64`**, **`aarch64`**, **`arm64`**, **`ppc64le`**, **`s390x`**, with **`x86_64`** as the initial default when RPM is selected or when the architecture is reset entering RPM mode. Those six values SHALL be the only architectures accepted by **`POST /api/v1/reports/new-rpm-report`** for field **`arch`** (per **`new-rpm-report-api`**). RPM mode SHALL NOT show SBOM `FileUpload` or Single Repository URL / Commit ID controls. + +#### Scenario: RPM shows only RPM-specific inputs + +- **WHEN** the user selects **RPM** on the mode toggle +- **THEN** CVE ID, the **Package N-V-R** field, and Architecture are visible per this requirement +- **AND** SBOM file and Single Repository fields are not shown + +#### Scenario: Package N-V-R field shows label, example placeholder, and helper text + +- **WHEN** the user views RPM mode with no package validation error active +- **THEN** the package field label reads **Package N-V-R** +- **AND** the **`TextInput` placeholder** shows a concrete **name-version-release** example (such as **`openssl-3.0.7-5.el9`**) +- **AND** non-error helper text below the field explains hyphen-separated **name**, **version**, and **release** + +### Requirement: Client NVR validation and parsing + +The client SHALL validate the trimmed Package value on **blur** and on **submit** (and **Enter** SHALL follow the same validation path as blur per parent modal **Keyboard Enter** requirement). Valid input SHALL decompose into three non-empty strings **`name`**, **`version`**, and **`release`** using RPM-style parsing: the substring after the **last** hyphen is **`release`**, the substring between the **last two** hyphens is **`version`**, and the leading remainder is **`name`** (the **`name`** segment MAY itself contain hyphens). If the trimmed value cannot be parsed into three non-empty segments (for example fewer than two hyphens in the trimmed value), the field SHALL show a human-readable error explaining that the value must be **`name-version-release`**. + +#### Scenario: Valid NVR accepted + +- **WHEN** the user enters a trimmed value such as **`openssl-3.0.7-5.el9`** +- **AND** blur or submit validation runs +- **THEN** no Package format error is shown +- **AND** the derived **`name`**, **`version`**, and **`release`** are **`openssl`**, **`3.0.7`**, and **`5.el9`** respectively + +#### Scenario: Too few hyphens rejected + +- **WHEN** the user enters **`openssl-3.0.7`** (only one hyphen) +- **AND** blur or submit validation runs +- **THEN** a Package error is shown and submit is blocked until fixed + +### Requirement: RPM submit via new-rpm-report + +On submit with valid client-side checks, the client SHALL call **`POST /api/v1/reports/new-rpm-report`** using the generated API client and **`useApi`**, with JSON body **`name`**, **`version`**, **`release`**, **`arch`**, and **`cveId`** where the first four come from parsed NVR and selected architecture (one of the six values defined for the Architecture control) and **`cveId`** is the trimmed CVE string. Field-level HTTP **400** responses whose body maps field names to messages SHALL surface messages on the corresponding controls when the key is **`name`**, **`version`**, **`release`**, **`arch`**, or **`cveId`**. On success (**HTTP 202**), the application SHALL navigate to **`/reports/component/:cveId/:reportId`** using identifiers from the response body in the same way Single Repository success uses **`POST /api/v1/reports/new`**, and SHALL close the modal. + +#### Scenario: Successful RPM request navigates to component report + +- **WHEN** the user submits valid CVE, NVR, and architecture +- **AND** the API returns **202** with report identifiers +- **THEN** the client navigates to the component report route for that CVE and report +- **AND** the modal closes + +#### Scenario: Field-mapped 400 shows per-field errors + +- **WHEN** the API returns **400** with a JSON object mapping for example **`release`** to an error string +- **THEN** that message is shown in context for the Package field or otherwise clearly associated with the failing segment per existing form error patterns +- **AND** the modal stays open with values preserved + +#### Scenario: Field-mapped 400 for invalid architecture + +- **WHEN** the API returns **400** with a JSON object mapping **`arch`** to an error string (for example after a contract mismatch or direct API use) +- **THEN** that message is shown on the Architecture control +- **AND** the modal stays open with values preserved + +### Requirement: RPM field state on mode changes + +When the user switches **from** RPM **to** another mode, RPM-specific field values (Package text, architecture other than default) and RPM-specific field errors SHALL be cleared or reset so the prior mode’s rules apply without stale RPM errors. When switching **to** RPM from another mode, Package SHALL start empty and Architecture SHALL be **`x86_64`** unless product rules preserve user preference elsewhere (default: reset as stated). + +#### Scenario: Leave RPM clears RPM inputs + +- **WHEN** the user had entered Package text in RPM mode +- **AND** the user switches to **SBOM** +- **THEN** Package value and any Package error are cleared + diff --git a/openspec/specs/request-analysis-modal/spec.md b/openspec/specs/request-analysis-modal/spec.md index 8875a390..bcb6dba7 100644 --- a/openspec/specs/request-analysis-modal/spec.md +++ b/openspec/specs/request-analysis-modal/spec.md @@ -1,28 +1,35 @@ # request-analysis-modal Specification ## Purpose -Shared Request Analysis modal behavior for both input modes ([SBOM](../request-analysis-modal-sbom/spec.md), [Single Repository](../request-analysis-modal-single-repository/spec.md)): mode toggle, CVE ID validation, optional private-repository credentials, field/generic error handling, submit UX, and **Enter** parity for TextInputs as defined in **Keyboard Enter on modal text fields** below. +Shared Request Analysis modal shell: mode toggle, CVE ID validation, optional private-repository credentials, field/generic error handling, submit UX, and **Enter** parity for TextInputs as defined in **Keyboard Enter on modal text fields** below. + +Mode-specific behaviors are specified in sibling capabilities: **[SBOM](../request-analysis-modal-sbom/spec.md)**, **[Single Repository](../request-analysis-modal-single-repository/spec.md)**, **[RPM fields](../request-analysis-modal-rpm-fields/spec.md)**. ## Implementation (non-normative) -Modal UI: `src/main/webui/src/components/request-analysis/`. Logic: `useAnalysisRequestForm.ts`; helpers: `requestAnalysisValidation.ts`, `requestAnalysisSbom.ts`, `requestAnalysisSubmit.ts`. +Modal UI: `src/main/webui/src/components/request-analysis/`. Logic: `useAnalysisRequestForm.ts`; helpers: `requestAnalysisValidation.ts`, `requestAnalysisRpm.ts`, `requestAnalysisSbom.ts`, `requestAnalysisSubmit.ts`. ## Requirements ### Requirement: Mode selector and shared layout -The modal SHALL expose **SBOM** and **Single Repository** via PatternFly `ToggleGroup`/`ToggleGroupItem` ("SBOM", "Single Repository"), exactly one selected, meaningful `aria-label`, SBOM selected by default. Switching modes SHALL clear generic Alert errors and **all** field-specific errors **except `cveId`**. CVE ID SHALL always appear. Private repository controls SHALL behave per **Private Repository Authentication**. SBOM mode shows file upload only; Single Repository shows Source Repo + Commit ID only. +The modal SHALL expose **SBOM**, **Single Repository**, and **RPM** via PatternFly `ToggleGroup`/`ToggleGroupItem` ("SBOM", "Single Repository", "RPM"), exactly one selected, meaningful `aria-label`, SBOM selected by default. Switching modes SHALL clear generic Alert errors and **all** field-specific errors **except `cveId`**. CVE ID SHALL always appear. Private repository controls SHALL follow **Private Repository Authentication** (including visibility rules when **RPM** is selected). SBOM mode shows file upload only; Single Repository shows Source Repo + Commit ID only; RPM mode shows Package NVR + Architecture only per **[RPM fields](../request-analysis-modal-rpm-fields/spec.md)**. #### Scenario: Mode switch clears non-CVE errors -- **WHEN** the user switches between SBOM and Single Repository via the toggle +- **WHEN** the user switches among SBOM, Single Repository, and RPM via the toggle - **THEN** generic submission error and non-CVE field errors are cleared; CVE ID value and CVE error persist; controls match the newly selected mode. ### Requirement: Private Repository Authentication -With **Private repository** ON: modal SHALL show required secret (`type="password"`) inside secondary `Card`; label-help popover with text *Provide an SSH private key or Personal Access Token to authenticate with the private repository.*; helper *Accepts SSH private keys… auto-detected.*; PAT vs SSH inferred from secret (starts with `-----BEGIN`/`-----END` pattern ⇒ SSH purple “SSH key” label, no username; otherwise PAT ⇒ teal “PAT” label + required username). With switch ON secret empty ⇒ submit disabled. Client-side: secret required when switch ON; when PAT ⇒ username required. Switch OFF ⇒ clear secret, username, and auth errors; editing secret/username clears **that** field’s error. SPDX/CycloneDX uploads MUST attach credentials when switch ON (`/upload-spdx`, `/upload-cyclonedx`); Single Repository attaches `credential` on `ReportRequest` per sibling specs. +When the selected analysis mode is **RPM**, private-repository credential controls SHALL NOT be shown and SHALL NOT affect submit enablement (**`new-rpm-report`** accepts no credential fields). When the selected mode is **SBOM** or **Single Repository**, the following SHALL apply: With **Private repository** ON: modal SHALL show required secret (`type="password"`) inside secondary `Card`; label-help popover with text *Provide an SSH private key or Personal Access Token to authenticate with the private repository.*; helper *Accepts SSH private keys… auto-detected.*; PAT vs SSH inferred from secret (starts with `-----BEGIN`/`-----END` pattern ⇒ SSH purple “SSH key” label, no username; otherwise PAT ⇒ teal “PAT” label + required username). With switch ON secret empty ⇒ submit disabled. Client-side: secret required when switch ON; when PAT ⇒ username required. Switch OFF ⇒ clear secret, username, and auth errors; editing secret/username clears **that** field’s error. SPDX/CycloneDX uploads MUST attach credentials when switch ON (`/upload-spdx`, `/upload-cyclonedx`); Single Repository attaches `credential` on `ReportRequest` per sibling specs. #### Scenario: PAT requires username on submit - **WHEN** Private repository ON, PAT detected, username empty - **AND** user submits - **THEN** *Username is required for Personal Access Token authentication* under username; API not called; modal stays open. +#### Scenario: RPM mode suppresses private repository UI +- **WHEN** the selected mode is **RPM** +- **THEN** private-repository switch, secret, username controls, and their labels are not shown +- **AND** submit eligibility does not depend on private-repository credential state + ### Requirement: CVE ID validation CVE ID MUST match `^CVE-[0-9]{4}-[0-9]{4,19}$` when validation runs for that field (including **blur** and **submit**). Invalid format ⇒ message explaining expected pattern ("CVE-YYYY…"). Empty required ⇒ *Required* on submit (aligned with tab specs). @@ -31,10 +38,10 @@ CVE ID MUST match `^CVE-[0-9]{4}-[0-9]{4,19}$` when validation runs for that fie - **THEN** CVE shows the format message and submission is blocked until resolved. ### Requirement: Keyboard Enter on modal text fields -For **every** modal **PatternFly TextInput** (CVE ID, Source Repository, Commit ID, Authentication secret, Username), pressing **`Enter`** while focused SHALL **`preventDefault`** and SHALL invoke the **identical client validation/update paths** implemented for **`blur`** on that same field—including CVE format, Source Repo URL checks, credential required/PAT username checks. **Excluded from this requirement:** **`FileUpload`**, **`ToggleGroup` / mode chooser**, **`Switch`**, other non-text controls. Fields with **no** blur validation rule today only receive **`preventDefault`** (currently **Commit ID**). +For **every** modal **PatternFly TextInput** (CVE ID, Source Repository, Commit ID, **Package NVR when RPM mode is active**, Authentication secret, Username), pressing **`Enter`** while focused SHALL **`preventDefault`** and SHALL invoke the **identical client validation/update paths** implemented for **`blur`** on that same field—including CVE format, Source Repo URL checks, RPM NVR parsing/validation **when RPM mode is active**, credential required/PAT username checks. **Excluded from this requirement:** **`FileUpload`**, **`ToggleGroup` / mode chooser**, **`Switch`**, **Architecture `Select`/dropdown**, other non-text controls. Fields with **no** blur validation rule today only receive **`preventDefault`** (currently **Commit ID**). #### Scenario: Enter matches blur for all modal TextInputs -- **WHEN** focus is in any CVE, Source Repo, Commit ID, Authentication secret, or Username TextInput listed above +- **WHEN** focus is in any CVE, Source Repo, Commit ID, Package NVR (RPM mode), Authentication secret, or Username TextInput listed above - **AND** the user presses Enter - **THEN** Enter is intercepted (no unintended control action) AND each field behaves exactly as documented for blur for **that field** wherever blur rules exist; Commit ID invokes only interception. @@ -46,4 +53,4 @@ Editing a control clears **that field’s** error. Non-validation API failures ( - **THEN** Alert shows a message; form values remain; loading ends. ### Requirement: Submit button -Submit enabled if not submitting, mode-specific required inputs satisfied ([SBOM](../request-analysis-modal-sbom/spec.md) / [Single Repository](../request-analysis-modal-single-repository/spec.md)), and not blocked by Private repository empty secret rule. +Submit enabled if not submitting, mode-specific required inputs satisfied ([SBOM](../request-analysis-modal-sbom/spec.md) / [Single Repository](../request-analysis-modal-single-repository/spec.md) / [**RPM fields**](../request-analysis-modal-rpm-fields/spec.md)), and not blocked by Private repository empty secret rule when the active mode is **SBOM** or **Single Repository**. diff --git a/openspec/specs/rpm-package-checker-report/spec.md b/openspec/specs/rpm-package-checker-report/spec.md new file mode 100644 index 00000000..670e211d --- /dev/null +++ b/openspec/specs/rpm-package-checker-report/spec.md @@ -0,0 +1,23 @@ +# rpm-package-checker-report Specification + +## Purpose + +Shared normative rule for treating a persisted analysis document as **RPM package checker** vs **repository (non-RPM)** for UI branching, layouts, and list filtering (**repository report detail**, RPM tab routing, breadcrumbs). + +## Requirements + +### Requirement: RPM package checker report classification + +A stored analysis report SHALL qualify as **RPM package checker report** (**RPM checker**) when **`report.input.image.pipeline_mode`** is exactly **`rpm_package_checker`**. Reports that do not satisfy this predicate SHALL not be classified as RPM checker for branching in UI (repository report detail layout, RPM tab listing, toolbar filters, etc.). + +Systems SHALL use this definition consistently wherever **RPM checker** branching is referenced in **repository-report-page**, **reports-table** (RPM tab), and **repository-reports-table** (RPM variant); other capabilities SHOULD align when referencing RPM checker analyses (for example **`document-titles`** rules that cite **`pipeline_mode`**). + +#### Scenario: RPM checker matches pipeline_mode + +- **WHEN** a report **input.image.pipeline_mode** equals **rpm_package_checker** +- **THEN** the report SHALL classify as RPM checker for spec-driven behavior. + +#### Scenario: Missing or alternate pipeline_mode + +- **WHEN** **pipeline_mode** is absent, null, empty, or any value other than **rpm_package_checker** +- **THEN** the report SHALL NOT classify as RPM checker solely from this field predicate. diff --git a/scripts/submit-rpm-report.sh b/scripts/submit-rpm-report.sh new file mode 100755 index 00000000..14ffda07 --- /dev/null +++ b/scripts/submit-rpm-report.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Submit RPM analysis (POST /api/v1/reports/new-rpm-report) for local dev use. +# +# Defaults match request.json target_package plus sample CVE CVE-2024-2511. +# +# Usage: +# ./scripts/submit-rpm-report.sh +# ./scripts/submit-rpm-report.sh -c CVE-2024-9102 +# ./scripts/submit-rpm-report.sh -n openssl -V 3.5.1 -r 7.el9_7 -a x86_64 -c CVE-2024-2511 +# +# Optional env: BASE_URL (default http://localhost:8080) +# +# Requires: curl; jq recommended for safely escaped JSON (falls back if absent) + +set -euo pipefail + +readonly BASE_URL="${BASE_URL:-http://localhost:8080}" +readonly ENDPOINT="${BASE_URL%/}/api/v1/reports/new-rpm-report" + +PKG_NAME="openssl" +PKG_VERSION="3.5.1" +PKG_RELEASE="7.el9_7" +PKG_ARCH="x86_64" +CVE_ID="CVE-2024-2511" + +usage() { + cat <<'USAGE' +Usage: submit-rpm-report.sh [-n name] [-V version] [-r release] [-a arch] [-c cveId] [-h] + + -n RPM package name (default: openssl) + -V RPM version (default: 3.5.1) + -r RPM release (default: 7.el9_7) + -a architecture (default: x86_64) + -c CVE id (default: CVE-2024-2511) + -h show this help + +Env: BASE_URL (default http://localhost:8080) +USAGE +} + +while getopts "n:V:r:a:c:h" opt; do + case "${opt}" in + n) PKG_NAME="$OPTARG" ;; + V) PKG_VERSION="$OPTARG" ;; + r) PKG_RELEASE="$OPTARG" ;; + a) PKG_ARCH="$OPTARG" ;; + c) CVE_ID="$OPTARG" ;; + h) + usage + exit 0 + ;; + *) + usage >&2 + exit 1 + ;; + esac +done + +tmp="$(mktemp)" +trap 'rm -f "${tmp}"' EXIT + +if command -v jq >/dev/null 2>&1; then + payload="$(jq -nc \ + --arg name "${PKG_NAME}" \ + --arg version "${PKG_VERSION}" \ + --arg release "${PKG_RELEASE}" \ + --arg arch "${PKG_ARCH}" \ + --arg cve "${CVE_ID}" \ + '{name:$name, version:$version, release:$release, arch:$arch, cveId:$cve}')" +else + payload="$(printf '{"name":"%s","version":"%s","release":"%s","arch":"%s","cveId":"%s"}' \ + "${PKG_NAME}" "${PKG_VERSION}" "${PKG_RELEASE}" "${PKG_ARCH}" "${CVE_ID}")" +fi + +echo "POST ${ENDPOINT}" >&2 +echo "Body: ${payload}" >&2 + +http_code="$(curl --globoff -sS -o "${tmp}" -w '%{http_code}' \ + -X POST \ + -H 'Content-Type: application/json' \ + -d "${payload}" \ + "${ENDPOINT}")" + +echo "HTTP ${http_code}" >&2 +cat "${tmp}" +echo "" + +[[ "${http_code}" == "202" ]] diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/dev/DatabaseInit.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/dev/DatabaseInit.java index 5f8776c1..70cb5ae3 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/dev/DatabaseInit.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/dev/DatabaseInit.java @@ -20,6 +20,7 @@ import java.nio.file.Files; import java.time.Instant; import java.util.ArrayList; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -152,13 +153,13 @@ private void loadReports() { try { var doc = Document.parse(Files.readString(f)); var metadata = doc.get("metadata", Document.class); - metadata.put("submitted_at", Instant.parse((String) metadata.get("submitted_at"))); + metadata.put("submitted_at", normalizeMetadataInstant(metadata.get("submitted_at"))); if (Objects.nonNull(metadata.get("sent_at"))) { - metadata.put("sent_at", Instant.parse((String) metadata.get("sent_at"))); + metadata.put("sent_at", normalizeMetadataInstant(metadata.get("sent_at"))); } docs.add(doc); } catch (Exception e) { - LOGGER.errorf("Ignoring invalid document: %s", f, e); + LOGGER.errorf("Ignoring invalid document: %s with error: %s", f, e.getMessage()); } }); mongoClient.getDatabase(dbName).getCollection("reports").insertMany(docs); @@ -167,4 +168,21 @@ private void loadReports() { LOGGER.error("Unable to load reports into database", e); } } + + /** Values from BSON extended JSON {@code {"$date": "..."}} parse as {@link Date}; ISO strings are also accepted. */ + private static Instant normalizeMetadataInstant(Object value) { + if (value == null) { + return null; + } + if (value instanceof Instant instant) { + return instant; + } + if (value instanceof Date date) { + return date.toInstant(); + } + if (value instanceof String string) { + return Instant.parse(string); + } + throw new IllegalArgumentException("Unsupported metadata timestamp type " + value.getClass().getName()); + } } diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/model/NewRpmReportRequest.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/NewRpmReportRequest.java new file mode 100644 index 00000000..c4c83b82 --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/NewRpmReportRequest.java @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.redhat.ecosystemappeng.morpheus.model; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** JSON body for {@code POST /api/v1/reports/new-rpm-report}. */ +@Schema(name = "NewRpmReportRequest", description = "RPM package plus CVE for new analysis request") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@RegisterForReflection +public record NewRpmReportRequest( + @Schema(required = true, description = "RPM package name") String name, + @Schema(required = true, description = "RPM package version") String version, + @Schema(required = true, description = "RPM package release") String release, + @Schema(required = true, description = "RPM architecture") String arch, + @Schema(required = true, description = "Vulnerability identifier (CVE-YYYY-NNNN+)", example = "CVE-2024-12345") String cveId) { + +} diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/model/Report.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/Report.java index be32c60f..4ea86073 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/model/Report.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/Report.java @@ -47,6 +47,10 @@ public record Report( @Schema(description = "Git reference (commit hash, tag, or branch) from source_info") String ref, @Schema(description = "Submitted at timestamp from metadata.submitted_at") - String submittedAt) { + String submittedAt, + @Schema(description = "RPM NVR hyphenated triple from target_package when present") + String rpmPackage, + @Schema(description = "RPM architecture from target_package.arch when present") + String rpmArchitecture) { } diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/model/TargetPackage.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/TargetPackage.java new file mode 100644 index 00000000..b15ca38e --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/TargetPackage.java @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.redhat.ecosystemappeng.morpheus.model; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** RPM package identity for Agent Morpheus RPM package checker pipeline. */ +@Schema(name = "TargetPackage", description = "RPM target package descriptor for rpm_package_checker pipeline") +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@RegisterForReflection +public record TargetPackage( + @Schema(required = true) String name, + @Schema(required = true) String version, + @Schema(required = true) String release, + @Schema(required = true) String arch, + @Schema(required = true, description = "Package ecosystem", example = "rpm") String ecosystem) { + +} diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/model/morpheus/Image.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/morpheus/Image.java index f2150f51..ff40f323 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/model/morpheus/Image.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/morpheus/Image.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; + import jakarta.annotation.Nullable; import io.quarkus.runtime.annotations.RegisterForReflection; @@ -26,6 +27,8 @@ import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import com.redhat.ecosystemappeng.morpheus.model.TargetPackage; + @Schema(name = "Image", description = "Image data") @RegisterForReflection @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -43,6 +46,12 @@ public record Image( @Schema(required = true, description = "Source code information", type = SchemaType.ARRAY, implementation = SourceInfo.class) @JsonProperty("source_info") Collection sourceInfo, @Schema(description = "SBOM information", type = SchemaType.OBJECT, implementation = Object.class) - @JsonProperty("sbom_info") @Nullable JsonNode sbomInfo + @JsonProperty("sbom_info") @Nullable JsonNode sbomInfo, + @Schema( + description = "Agent pipeline mode; omit when not applicable", + enumeration = {"full_pipeline", "rpm_package_checker"}) + @JsonProperty("pipeline_mode") @Nullable PipelineMode pipelineMode, + @Schema(description = "RPM target package when pipeline_mode is rpm_package_checker") + @JsonProperty("target_package") @Nullable TargetPackage targetPackage ) { } diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/model/morpheus/PipelineMode.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/morpheus/PipelineMode.java new file mode 100644 index 00000000..2a52bfc9 --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/model/morpheus/PipelineMode.java @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.redhat.ecosystemappeng.morpheus.model.morpheus; + +import com.fasterxml.jackson.annotation.JsonValue; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +@Schema(name = "PipelineMode", description = "Morpheus agent pipeline mode") +@RegisterForReflection +public enum PipelineMode { + FULL_PIPELINE("full_pipeline"), + RPM_PACKAGE_CHECKER("rpm_package_checker"); + + private final String wire; + + PipelineMode(String wire) { + this.wire = wire; + } + + @JsonValue + public String wire() { + return wire; + } + + @Override + public String toString() { + return wire; + } +} diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java index 3406aab8..42e47a88 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryService.java @@ -21,9 +21,11 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Set; import java.util.Map; import java.util.Objects; +import java.util.regex.Pattern; import com.mongodb.client.MongoCursor; import org.bson.Document; @@ -105,6 +107,55 @@ public MongoCollection getCollection() { return mongoClient.getDatabase(dbName).getCollection(COLLECTION); } + /** Returns trimmed non-empty {@code s}, or {@code null} if absent or blank. */ + static String trimmedNonEmpty(String s) { + if (Objects.isNull(s)) { + return null; + } + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + /** + * Regex for {@code $regexMatch} on a lowercased NVR concat: literal substring, case-insensitive via lowercased input. + * Package-private for unit tests. + */ + static String rpmPackageMatchRegexLiteral(String trimmedUserTerm) { + return ".*" + Pattern.quote(trimmedUserTerm.toLowerCase(Locale.ROOT)) + ".*"; + } + + private static Document rpmTargetPackageTrimmedField(String fieldSuffix) { + String path = "input.image.target_package." + fieldSuffix; + return new Document("$trim", new Document("input", + new Document("$ifNull", List.of("$" + path, "")))); + } + + private static Document rpmTargetPackageNonemptyField(String fieldSuffix) { + return new Document("$gt", + List.of(new Document("$strLenCP", rpmTargetPackageTrimmedField(fieldSuffix)), 0)); + } + + /** + * BSON filter: target_package has complete NVR (trimmed non-empty) and joined string contains {@code term} literally (case-insensitive). + */ + static Bson rpmPackageSubstringMatch(String trimmedNonEmptyTerm) { + String regex = rpmPackageMatchRegexLiteral(trimmedNonEmptyTerm); + Document joinedLower = new Document("$toLower", new Document("$concat", + List.of( + rpmTargetPackageTrimmedField("name"), + "-", + rpmTargetPackageTrimmedField("version"), + "-", + rpmTargetPackageTrimmedField("release")))); + Document match = new Document("$regexMatch", new Document("input", joinedLower).append("regex", regex)); + Document guard = new Document("$and", List.of( + rpmTargetPackageNonemptyField("name"), + rpmTargetPackageNonemptyField("version"), + rpmTargetPackageNonemptyField("release"), + match)); + return Filters.expr(guard); + } + @PostConstruct public void dbInit() { MongoCollection reportsCollection = getCollection(); @@ -178,6 +229,19 @@ public Report toReport(Document doc) { } } + String rpmPackageStr = null; + String rpmArchStr = null; + var targetPackageDoc = Objects.nonNull(image) ? image.get("target_package", Document.class) : null; + if (Objects.nonNull(targetPackageDoc)) { + String n = trimmedNonEmpty(targetPackageDoc.getString("name")); + String ver = trimmedNonEmpty(targetPackageDoc.getString("version")); + String rel = trimmedNonEmpty(targetPackageDoc.getString("release")); + if (Objects.nonNull(n) && Objects.nonNull(ver) && Objects.nonNull(rel)) { + rpmPackageStr = n + "-" + ver + "-" + rel; + } + rpmArchStr = trimmedNonEmpty(targetPackageDoc.getString("arch")); + } + String submittedAt = Objects.nonNull(metadata) ? metadata.get(SUBMITTED_AT) : null; String scanId = scan.getString(RepositoryConstants.SCAN_ID); return new Report(id, scanId, @@ -190,7 +254,9 @@ public Report toReport(Document doc) { metadata, gitRepo, ref, - submittedAt); + submittedAt, + rpmPackageStr, + rpmArchStr); } public String getStatus(Document doc, Map metadata) { @@ -336,7 +402,9 @@ public String findByScanId(String scanId) { "submittedAt", "metadata.submitted_at", "vuln_id", "output.analysis.vuln_id", "ref", "input.image.source_info.ref", - "gitRepo", "input.image.source_info.git_repo"); + "gitRepo", "input.image.source_info.git_repo", + "rpmPackage", "input.image.target_package.name", + "rpmArchitecture", "input.image.target_package.arch"); public PaginatedResult list(Map queryFilter, List sortFields, Pagination pagination) { @@ -716,11 +784,24 @@ private Bson buildQueryFilter(Map queryFilter) { handleMultipleValues(e.getValue(), (value) -> Filters.eq("metadata.product_id", value), filters); break; - case "withoutProduct": - if (Boolean.TRUE.toString().equalsIgnoreCase(e.getValue())) { - filters.add(Filters.exists("metadata." + PRODUCT_ID, false)); + case "inputType": { + String it = trimmedNonEmpty(e.getValue()); + if (Objects.nonNull(it)) { + switch (it.toLowerCase()) { + case "rpm": + filters.add(Filters.exists("metadata." + PRODUCT_ID, false)); + filters.add(Filters.eq("input.image.pipeline_mode", "rpm_package_checker")); + break; + case "repository": + filters.add(Filters.exists("metadata." + PRODUCT_ID, false)); + filters.add(Filters.ne("input.image.pipeline_mode", "rpm_package_checker")); + break; + default: + break; + } } break; + } case "gitRepo": var gitRepoValues = e.getValue().split(","); if (gitRepoValues.length == 1) { @@ -743,6 +824,23 @@ private Bson buildQueryFilter(Map queryFilter) { filters.add(Filters.or(gitRepoFilters)); } break; + case "rpmPackage": { + String rawRpmPkg = e.getValue(); + if (Objects.nonNull(rawRpmPkg) && !rawRpmPkg.isBlank()) { + String[] rpmTerms = rawRpmPkg.split(","); + List rpmPkgFilters = new ArrayList<>(); + for (String term : rpmTerms) { + String t = trimmedNonEmpty(term); + if (Objects.nonNull(t)) { + rpmPkgFilters.add(rpmPackageSubstringMatch(t)); + } + } + if (!rpmPkgFilters.isEmpty()) { + filters.add(rpmPkgFilters.size() == 1 ? rpmPkgFilters.get(0) : Filters.or(rpmPkgFilters)); + } + } + break; + } case "exploitIqStatus": break; default: diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/ReportEndpoint.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/ReportEndpoint.java index e218703f..f933ec72 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/ReportEndpoint.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/rest/ReportEndpoint.java @@ -14,6 +14,9 @@ package com.redhat.ecosystemappeng.morpheus.rest; +import java.io.IOException; + +import java.util.HashMap; import java.util.List; import java.util.Map.Entry; import java.util.Set; @@ -38,8 +41,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.redhat.ecosystemappeng.morpheus.model.InlineCredential; +import com.redhat.ecosystemappeng.morpheus.exception.ValidationException; import com.redhat.ecosystemappeng.morpheus.model.MarkReportFailedRequest; +import com.redhat.ecosystemappeng.morpheus.model.NewRpmReportRequest; import com.redhat.ecosystemappeng.morpheus.model.ReportData; import com.redhat.ecosystemappeng.morpheus.model.ReportRequest; import com.redhat.ecosystemappeng.morpheus.model.SortField; @@ -48,6 +52,7 @@ import com.redhat.ecosystemappeng.morpheus.service.PreProcessingService; import com.redhat.ecosystemappeng.morpheus.service.ProductService; import com.redhat.ecosystemappeng.morpheus.service.ReportService; +import com.redhat.ecosystemappeng.morpheus.service.RpmReportService; import com.redhat.ecosystemappeng.morpheus.service.RequestQueueExceededException; import com.redhat.ecosystemappeng.morpheus.service.UserService; import com.redhat.ecosystemappeng.morpheus.service.UtilitiesService; @@ -96,6 +101,9 @@ public class ReportEndpoint { @Inject ReportService reportService; + @Inject + RpmReportService rpmReportService; + @Inject PreProcessingService preProcessingService; @@ -201,7 +209,62 @@ public Response newRequest( } } - @POST + @POST + @Path("/new-rpm-report") + @Operation( + summary = "Create analysis request for an RPM package", + description = """ + Accepts RPM name, version, release, architecture, and a CVE id; builds a Morpheus input with \ + pipeline_mode rpm_package_checker and target_package, persists the report, and always submits \ + it for analysis (same queue path as POST /reports/new with submit=true). Validation errors use \ + the same field-mapped JSON shape as POST /products/upload-spdx (object \"errors\" mapping field names to messages).""") + @APIResponses({ + @APIResponse( + responseCode = "202", + description = "Analysis request accepted", + content = @Content( + schema = @Schema(implementation = ReportData.class) + ) + ), + @APIResponse( + responseCode = "400", + description = "Missing or invalid fields; response body has an \"errors\" object mapping field names (name, version, release, arch, cveId) to messages" + ), + @APIResponse( + responseCode = "429", + description = "Request queue exceeded" + ), + @APIResponse( + responseCode = "500", + description = "Internal server error" + ) + }) + public Response newRpmReport( + @RequestBody( + description = "RPM package coordinates and CVE identifier", + required = true, + content = @Content(schema = @Schema(implementation = NewRpmReportRequest.class)) + ) + NewRpmReportRequest request) { + try { + ReportData res = rpmReportService.persistAndSubmitNewRpmReport(request); + return Response.accepted(res).build(); + } catch (RequestQueueExceededException e) { + LOGGER.errorf("Too many requests, limit exceeded"); + return Response.status(Status.TOO_MANY_REQUESTS) + .entity(objectMapper.createObjectNode() + .put("error", "Too many requests, limit exceeded")) + .build(); + } catch (IOException e) { + LOGGER.error("Unable to persist or submit RPM analysis request", e); + return Response.serverError() + .entity(objectMapper.createObjectNode() + .put("error", e.getMessage())) + .build(); + } + } + + @POST @Path("/{id}/retry") @Operation( summary = "Retry analysis request", @@ -308,6 +371,10 @@ public Response receive( schema = @Schema(type = SchemaType.ARRAY, implementation = Report.class) ) ), + @APIResponse( + responseCode = "400", + description = "Invalid query parameters (for example unsupported inputType)" + ), @APIResponse( responseCode = "500", description = "Internal server error" @@ -352,22 +419,48 @@ public Response list( ) @QueryParam("productId") String productId, @Parameter( - description = "When true, return only reports that have no metadata.product_id (single repositories not part of a product)" + description = "Standalone Reports tab filter: \"repository\" (no product id, not rpm_package_checker), " + + "\"rpm\" (no product id, rpm_package_checker), or omit for no input-type filter" ) - @QueryParam("withoutProduct") @DefaultValue("false") String withoutProduct, + @QueryParam("inputType") String inputType, @Parameter( description = "Filter by ExploitIQ status. Valid values: TRUE, FALSE, UNKNOWN" ) - @QueryParam("exploitIqStatus") String exploitIqStatus) { + @QueryParam("exploitIqStatus") String exploitIqStatus, + @Parameter( + description = "Case-insensitive substring match on RPM NVR as displayed: " + + "trimmed non-empty input.image.target_package name, version, and release joined with hyphens " + + "(documents missing any of the three are excluded). Literal match only—not a regex vocabulary. " + + "Comma-separated values match if any term matches (OR)." + ) + @QueryParam("rpmPackage") String rpmPackage) { - var filter = uriInfo.getQueryParameters().entrySet().stream() + String inputTypeCanon = canonInputTypeOrNull(inputType); + if (inputType != null && !inputType.isBlank() && Objects.isNull(inputTypeCanon)) { + return Response.status(Status.BAD_REQUEST) + .entity(objectMapper.createObjectNode() + .put("error", "inputType must be repository or rpm if provided")) + .build(); + } + + var filter = new HashMap<>(uriInfo.getQueryParameters().entrySet().stream() .filter(e -> !FIXED_QUERY_PARAMS.contains(e.getKey())) .collect(Collectors.toMap( Entry::getKey, e -> e.getValue().size() > 1 ? String.join(",", e.getValue()) : e.getValue().getFirst() - )); + ))); + filter.remove("withoutProduct"); + filter.remove("pipelineMode"); + if (Objects.nonNull(inputTypeCanon)) { + filter.put("inputType", inputTypeCanon); + } + + if (LOGGER.isTraceEnabled()) { + LOGGER.tracef("list inputTypeEffective=%s", inputTypeCanon); + } + var result = reportService.list(filter, SortField.fromSortBy(sortBy), page, pageSize); return Response.ok(result.results) .header("X-Total-Pages", result.totalPages) @@ -772,6 +865,37 @@ public Response removeByProductId( return Response.accepted().build(); } + /** + * Canonical {@code inputType} query for {@link #list}, or null when omitted/blank ({@code null} means invalid if raw was non-blank outside handler). + */ + private static String canonInputTypeOrNull(String inputType) { + if (Objects.isNull(inputType)) { + return null; + } + String t = inputType.trim(); + if (t.isEmpty()) { + return null; + } + if ("repository".equalsIgnoreCase(t)) { + return "repository"; + } + if ("rpm".equalsIgnoreCase(t)) { + return "rpm"; + } + return null; + } + + @ServerExceptionMapper + public Response mapValidationException(ValidationException e) { + var errorNode = objectMapper.createObjectNode(); + var errorsNode = errorNode.putObject("errors"); + LOGGER.error(e.getMessage()); + if (e.getErrors() != null) { + e.getErrors().forEach(errorsNode::put); + } + return Response.status(Status.BAD_REQUEST).entity(errorNode).build(); + } + @ServerExceptionMapper public Response mapNotFoundException(NotFoundException e) { LOGGER.errorf("Not found: %s", e.getMessage()); diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ReportService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ReportService.java index eb8a760e..212cdd2c 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ReportService.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/ReportService.java @@ -42,6 +42,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.redhat.ecosystemappeng.morpheus.client.GitHubService; import com.redhat.ecosystemappeng.morpheus.config.AppConfig; import com.redhat.ecosystemappeng.morpheus.model.PaginatedResult; @@ -509,7 +510,7 @@ private Image buildImage(ReportRequest request) throws JsonProcessingException, new SourceInfo("code", sourceLocation, commitId, allIncludes, allExcludes), new SourceInfo("doc", sourceLocation, commitId, includes.get("Docs"), Collections.emptyList())); - return new Image(request.analysisType(), ecosystem, manifestPath, name, tag, srcInfo, sbomInfo); + return new Image(request.analysisType(), ecosystem, manifestPath, name, tag, srcInfo, sbomInfo, null, null); } private Set buildLanguagesExtensions(String ecosystem) { diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/RpmReportService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/RpmReportService.java new file mode 100644 index 00000000..885ca9aa --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/RpmReportService.java @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.redhat.ecosystemappeng.morpheus.service; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.redhat.ecosystemappeng.morpheus.exception.ValidationException; +import com.redhat.ecosystemappeng.morpheus.model.NewRpmReportRequest; +import com.redhat.ecosystemappeng.morpheus.model.ReportData; +import com.redhat.ecosystemappeng.morpheus.model.ReportRequestId; +import com.redhat.ecosystemappeng.morpheus.model.TargetPackage; +import com.redhat.ecosystemappeng.morpheus.model.morpheus.Image; +import com.redhat.ecosystemappeng.morpheus.model.morpheus.PipelineMode; +import com.redhat.ecosystemappeng.morpheus.model.morpheus.ReportInput; +import com.redhat.ecosystemappeng.morpheus.model.morpheus.Scan; +import com.redhat.ecosystemappeng.morpheus.model.morpheus.VulnId; +import com.redhat.ecosystemappeng.morpheus.validation.CveIdRules; +import com.redhat.ecosystemappeng.morpheus.validation.RpmArchitecture; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +/** + * Builds, persists, and submits Morpheus report requests for the RPM package checker pipeline. + */ +@ApplicationScoped +public class RpmReportService { + + @Inject + ReportService reportService; + + @Inject + ObjectMapper objectMapper; + + @Inject + UserService userService; + + private record ValidatedNewRpmReport(String name, String version, String release, String arch, String cveUppercase) { + } + + /** + * Validates RPM report fields (upload-spdx-style aggregated {@link ValidationException}), + * builds Morpheus input with {@link PipelineMode#RPM_PACKAGE_CHECKER}, persists, and submits to the queue. + */ + public ReportData persistAndSubmitNewRpmReport(NewRpmReportRequest request) + throws JsonProcessingException, IOException { + ValidatedNewRpmReport v = validateNewRpmReportRequest(request); + ReportData reportData = generateRpmPackageCheckerReport(v); + ReportData saved = reportService.saveReport(reportData); + reportService.submit(saved.reportRequestId().id(), saved.report()); + return saved; + } + + private ValidatedNewRpmReport validateNewRpmReportRequest(NewRpmReportRequest request) throws ValidationException { + Map errors = new HashMap<>(); + String name = trimmedOrNull(request.name()); + String version = trimmedOrNull(request.version()); + String release = trimmedOrNull(request.release()); + String arch = trimmedOrNull(request.arch()); + String rawCve = trimmedOrNull(request.cveId()); + putIfBlank(errors, "name", name, "Name is required"); + putIfBlank(errors, "version", version, "Version is required"); + putIfBlank(errors, "release", release, "Release is required"); + putIfBlank(errors, "arch", arch, "Architecture is required"); + RpmArchitecture.putArchFieldErrorIfNotAllowed(errors, arch); + CveIdRules.putOfficialCveFieldErrorIfInvalid(errors, request.cveId()); + if (!errors.isEmpty()) { + throw new ValidationException(errors); + } + return new ValidatedNewRpmReport(name, version, release, arch, rawCve.toUpperCase()); + } + + private ReportData generateRpmPackageCheckerReport(ValidatedNewRpmReport v) throws JsonProcessingException { + String scanId = reportService.createUniqueScanId(); + Scan scan = new Scan(scanId, List.of(new VulnId(v.cveUppercase()))); + TargetPackage target = new TargetPackage(v.name(), v.version(), v.release(), v.arch(), "rpm"); + Image image = new Image( + "source", + null, + null, + null, + null, + null, + null, + PipelineMode.RPM_PACKAGE_CHECKER, + target); + + ReportInput input = new ReportInput(scan, image); + ObjectNode report = objectMapper.createObjectNode(); + report.set("input", objectMapper.convertValue(input, JsonNode.class)); + + Map metadata = new HashMap<>(); + metadata.put("user", userService.getUserName()); + report.set("metadata", objectMapper.convertValue(metadata, JsonNode.class)); + + var reportRequestId = new ReportRequestId(null, scan.id()); + return new ReportData(reportRequestId, report); + } + + private static String trimmedOrNull(String s) { + if (s == null) { + return null; + } + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static void putIfBlank(Map errors, String field, String value, String msg) { + if (value == null) { + errors.put(field, msg); + } + } +} diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/SbomReportService.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/SbomReportService.java index 04a5edcb..c657b065 100644 --- a/src/main/java/com/redhat/ecosystemappeng/morpheus/service/SbomReportService.java +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/service/SbomReportService.java @@ -21,13 +21,11 @@ import java.util.HashMap; import java.util.Map; import java.util.Objects; -import java.util.regex.Pattern; import org.jboss.logging.Logger; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.redhat.ecosystemappeng.morpheus.exception.CveIdValidationException; import com.redhat.ecosystemappeng.morpheus.exception.SbomValidationException; import com.redhat.ecosystemappeng.morpheus.exception.ValidationException; import com.redhat.ecosystemappeng.morpheus.model.FailedComponent; @@ -35,6 +33,7 @@ import com.redhat.ecosystemappeng.morpheus.model.Product; import com.redhat.ecosystemappeng.morpheus.model.ReportData; import com.redhat.ecosystemappeng.morpheus.repository.ProductRepositoryService; +import com.redhat.ecosystemappeng.morpheus.validation.CveIdRules; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -44,7 +43,6 @@ public class SbomReportService { private static final Logger LOGGER = Logger.getLogger(SbomReportService.class); - private static final Pattern CVE_ID_PATTERN = Pattern.compile("^CVE-[0-9]{4}-[0-9]{4,19}$"); private static final int CYCLONEDX_COMPONENT_COUNT = 1; private CycloneDxParsingService cycloneDxParsingService; @@ -96,20 +94,6 @@ public void setObjectMapper(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } - /** - * Validates CVE ID format - * @param cveId CVE ID to validate - * @throws CveIdValidationException if CVE ID is null, empty, or doesn't match the required pattern - */ - public void validateCveId(String cveId) { - if (Objects.isNull(cveId) || cveId.trim().isEmpty()) { - throw new CveIdValidationException(null, "CVE ID is required"); - } - - if (!CVE_ID_PATTERN.matcher(cveId).matches()) { - throw new CveIdValidationException(cveId, "Must match the official CVE pattern CVE-YYYY-NNNN+"); - } - } /** * Generates a product ID from SBOM name and version. @@ -143,15 +127,9 @@ public String generateProductId(String name, String version) { */ public ReportData submitCycloneDx(String cveId, InputStream fileInputStream, String credentialId) throws IOException { - Map errors = new HashMap<>(); - - // Validate CVE ID and collect errors - try { - validateCveId(cveId); - } catch (CveIdValidationException e) { - errors.put("cveId", e.getMessage()); - } + Map errors = new HashMap<>(); + CveIdRules.putOfficialCveFieldErrorIfInvalid(errors, cveId); // Parse and validate CycloneDX file and collect errors ParsedCycloneDx parsedCycloneDx = null; try { @@ -208,11 +186,8 @@ public String submitSpdx(InputStream fileInputStream, String cveId, String crede Map errors = new HashMap<>(); // Validate CVE ID and collect errors - try { - validateCveId(cveId); - } catch (CveIdValidationException e) { - errors.put("cveId", e.getMessage()); - } + CveIdRules.putOfficialCveFieldErrorIfInvalid(errors, cveId); + // Validate file input if (fileInputStream == null) { diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/validation/CveIdRules.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/validation/CveIdRules.java new file mode 100644 index 00000000..4108b505 --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/validation/CveIdRules.java @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.redhat.ecosystemappeng.morpheus.validation; + +import java.util.Map; +import java.util.regex.Pattern; + +import com.redhat.ecosystemappeng.morpheus.exception.CveIdValidationException; + +/** + * Single place for official CVE id validation (SPDX/CycloneDX uploads, RPM report API, etc.). + */ +public final class CveIdRules { + + private static final String FORMAT_DETAIL = "Must match the official CVE pattern CVE-YYYY-NNNN+"; + public static final String MESSAGE_REQUIRED = "CVE ID is required"; + /** Official CVE id: {@code CVE-YYYY-NNNN+}. */ + public static final Pattern OFFICIAL_CVE_PATTERN = Pattern.compile("^CVE-[0-9]{4}-[0-9]{4,19}$"); + + private CveIdRules() { + } + + /** When invalid, adds {@code errors.put("cveId", message)} consistent with {@link #validateOfficialCveOrThrow}. */ + public static void putOfficialCveFieldErrorIfInvalid(Map errors, String cveId) { + CveIdValidationException violation = violationFor(cveId); + if (violation != null) { + errors.put("cveId", violation.getMessage()); + } + } + + private static CveIdValidationException violationFor(String cveId) { + String trimmed = trimmedOrNull(cveId); + if (trimmed == null) { + return new CveIdValidationException(null, CveIdValidationException.MESSAGE_REQUIRED); + } + if (!OFFICIAL_CVE_PATTERN.matcher(trimmed).matches()) { + return new CveIdValidationException(trimmed, FORMAT_DETAIL); + } + return null; + } + + private static String trimmedOrNull(String s) { + if (s == null) { + return null; + } + String t = s.trim(); + return t.isEmpty() ? null : t; + } +} diff --git a/src/main/java/com/redhat/ecosystemappeng/morpheus/validation/RpmArchitecture.java b/src/main/java/com/redhat/ecosystemappeng/morpheus/validation/RpmArchitecture.java new file mode 100644 index 00000000..52bb6890 --- /dev/null +++ b/src/main/java/com/redhat/ecosystemappeng/morpheus/validation/RpmArchitecture.java @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.redhat.ecosystemappeng.morpheus.validation; + +import java.util.Map; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +/** + * Allowed RPM target architectures for {@code POST /api/v1/reports/new-rpm-report} + * (aligned with Request Analysis RPM UI). + */ +@RegisterForReflection +public enum RpmArchitecture { + X86_64("x86_64"), + AMD64("amd64"), + AARCH64("aarch64"), + ARM64("arm64"), + PPC64LE("ppc64le"), + S390X("s390x"); + + public static final String MESSAGE_NOT_ALLOWED = + "Architecture must be one of: x86_64, amd64, aarch64, arm64, ppc64le, s390x"; + + private final String wireValue; + + RpmArchitecture(String wireValue) { + this.wireValue = wireValue; + } + + public String wireValue() { + return wireValue; + } + + /** {@code true} when {@code trimmedNonEmpty} equals a {@link #wireValue()} (case-sensitive). */ + public static boolean isAllowed(String trimmedNonEmpty) { + if (trimmedNonEmpty == null || trimmedNonEmpty.isEmpty()) { + return false; + } + for (RpmArchitecture arch : values()) { + if (arch.wireValue.equals(trimmedNonEmpty)) { + return true; + } + } + return false; + } + + /** When invalid, adds {@code errors.put("arch", message)} after required-field checks. */ + public static void putArchFieldErrorIfNotAllowed(Map errors, String arch) { + if (arch != null && !isAllowed(arch)) { + errors.put("arch", MESSAGE_NOT_ALLOWED); + } + } +} diff --git a/src/main/webui/openapi.json b/src/main/webui/openapi.json index 2632157f..8e04c896 100644 --- a/src/main/webui/openapi.json +++ b/src/main/webui/openapi.json @@ -306,6 +306,20 @@ "sbom_info": { "description": "SBOM information", "type": "object" + }, + "pipeline_mode": { + "description": "Agent pipeline mode; omit when not applicable", + "type": "string", + "enum": [ + "full_pipeline", + "rpm_package_checker" + ], + "$ref": "#/components/schemas/PipelineMode" + }, + "target_package": { + "description": "RPM target package when pipeline_mode is rpm_package_checker", + "type": "object", + "$ref": "#/components/schemas/TargetPackage" } } }, @@ -451,6 +465,50 @@ "CHECKLIST_QUALITY" ] }, + "NewRpmReportRequest": { + "description": "RPM package plus CVE for new analysis request", + "type": "object", + "required": [ + "name", + "version", + "release", + "arch", + "cveId" + ], + "properties": { + "name": { + "type": "string", + "description": "RPM package name" + }, + "version": { + "type": "string", + "description": "RPM package version" + }, + "release": { + "type": "string", + "description": "RPM package release" + }, + "arch": { + "type": "string", + "description": "RPM architecture", + "enum": [ + "x86_64", + "amd64", + "aarch64", + "arm64", + "ppc64le", + "s390x" + ] + }, + "cveId": { + "type": "string", + "description": "Vulnerability identifier (CVE-YYYY-NNNN+)", + "examples": [ + "CVE-2024-12345" + ] + } + } + }, "OverviewMetrics": { "description": "Metrics for the home page calculated from data in the last week", "type": "object", @@ -472,6 +530,14 @@ } } }, + "PipelineMode": { + "description": "Morpheus agent pipeline mode", + "type": "string", + "enum": [ + "full_pipeline", + "rpm_package_checker" + ] + }, "Product": { "description": "Product metadata", "type": "object", @@ -738,6 +804,14 @@ "submittedAt": { "type": "string", "description": "Submitted at timestamp from metadata.submitted_at" + }, + "rpmPackage": { + "type": "string", + "description": "RPM NVR hyphenated triple from target_package when present" + }, + "rpmArchitecture": { + "type": "string", + "description": "RPM architecture from target_package.arch when present" } } }, @@ -832,6 +906,20 @@ "sbom_info": { "description": "SBOM information", "type": "object" + }, + "pipeline_mode": { + "description": "Agent pipeline mode; omit when not applicable", + "type": "string", + "enum": [ + "full_pipeline", + "rpm_package_checker" + ], + "$ref": "#/components/schemas/PipelineMode" + }, + "target_package": { + "description": "RPM target package when pipeline_mode is rpm_package_checker", + "type": "object", + "$ref": "#/components/schemas/TargetPackage" } } }, @@ -917,6 +1005,9 @@ } } }, + "ReportSseMessage": { + "type": "object" + }, "ReportWithStatus": { "description": "Report data with calculated analysis status", "type": "object", @@ -982,6 +1073,38 @@ } } }, + "TargetPackage": { + "type": "object", + "description": "RPM target package descriptor for rpm_package_checker pipeline", + "required": [ + "name", + "version", + "release", + "arch", + "ecosystem" + ], + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "release": { + "type": "string" + }, + "arch": { + "type": "string" + }, + "ecosystem": { + "type": "string", + "description": "Package ecosystem", + "examples": [ + "rpm" + ] + } + } + }, "Trace": { "type": "object", "properties": { @@ -2885,6 +3008,14 @@ "type": "string" } }, + { + "description": "Standalone Reports tab filter: \"repository\" (no product id, not rpm_package_checker), \"rpm\" (no product id, rpm_package_checker), or omit for no input-type filter", + "name": "inputType", + "in": "query", + "schema": { + "type": "string" + } + }, { "description": "Page number (0-based)", "name": "page", @@ -2921,6 +3052,14 @@ "type": "string" } }, + { + "description": "Case-insensitive substring match on RPM NVR as displayed: trimmed non-empty input.image.target_package name, version, and release joined with hyphens (documents missing any of the three are excluded). Literal match only—not a regex vocabulary. Comma-separated values match if any term matches (OR).", + "name": "rpmPackage", + "in": "query", + "schema": { + "type": "string" + } + }, { "description": "Sort criteria in format 'field:direction'", "name": "sortBy", @@ -2948,15 +3087,6 @@ "schema": { "type": "string" } - }, - { - "description": "When true, return only reports that have no metadata.product_id (single repositories not part of a product)", - "name": "withoutProduct", - "in": "query", - "schema": { - "type": "string", - "default": "false" - } } ], "responses": { @@ -2973,6 +3103,9 @@ } } }, + "400": { + "description": "Invalid query parameters (for example unsupported inputType)" + }, "500": { "description": "Internal server error" } @@ -3133,6 +3266,52 @@ ] } }, + "/api/v1/reports/new-rpm-report": { + "post": { + "summary": "Create analysis request for an RPM package", + "description": "Accepts RPM name, version, release, architecture, and a CVE id; builds a Morpheus input with pipeline_mode rpm_package_checker and target_package, persists the report, and always submits it for analysis (same queue path as POST /reports/new with submit=true). Validation errors use the same field-mapped JSON shape as POST /products/upload-spdx (object \"errors\" mapping field names to messages).", + "requestBody": { + "description": "RPM package coordinates and CVE identifier", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewRpmReportRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Analysis request accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReportData" + } + } + } + }, + "400": { + "description": "Missing or invalid fields; response body has an \"errors\" object mapping field names (name, version, release, arch, cveId) to messages" + }, + "429": { + "description": "Request queue exceeded" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "jwt": [] + } + ], + "tags": [ + "Report Endpoint" + ] + } + }, "/api/v1/reports/product": { "delete": { "summary": "Delete product by IDs", @@ -3424,6 +3603,32 @@ ] } }, + "/api/v1/reports/stream": { + "get": { + "summary": "Report live-update stream (SSE)", + "description": "Server-Sent Events. Each event `data` line is JSON `{}` (empty object): report or product data may have changed; clients should refetch their REST views. The payload may gain fields later for targeted invalidation.", + "responses": { + "200": { + "description": "SSE stream", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/ReportSseMessage" + } + } + } + } + }, + "security": [ + { + "jwt": [] + } + ], + "tags": [ + "Report Stream Resource" + ] + } + }, "/api/v1/reports/{id}": { "delete": { "summary": "Delete analysis report", diff --git a/src/main/webui/src/App.tsx b/src/main/webui/src/App.tsx index 00220287..7e0cf6a2 100644 --- a/src/main/webui/src/App.tsx +++ b/src/main/webui/src/App.tsx @@ -36,6 +36,7 @@ const App: React.FC = () => { path="/reports/single-repositories" element={} /> + } /> } diff --git a/src/main/webui/src/components/ContainerRepositoryArtifactDetails.tsx b/src/main/webui/src/components/ContainerRepositoryArtifactDetails.tsx new file mode 100644 index 00000000..0c8395fd --- /dev/null +++ b/src/main/webui/src/components/ContainerRepositoryArtifactDetails.tsx @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { ReactElement } from "react"; +import { + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, +} from "@patternfly/react-core"; +import type { FullReportImage } from "../types/FullReport"; +import NotAvailable from "./NotAvailable"; +import { getPullImageReference } from "../utils/containerImageReference"; + +/** Base URL for .../commit/{ref} links: no trailing slash or `.git` suffix. */ +function normalizeRepoBaseForCommitLink(repo: string): string { + let s = repo; + while (s.endsWith("/")) { + s = s.slice(0, -1); + } + if (s.endsWith(".git")) { + s = s.slice(0, -".git".length); + } + while (s.endsWith("/")) { + s = s.slice(0, -1); + } + return s; +} + +/** + * **Repository URL** and **Image** rows for non-RPM repository reports (artifact block under **CVE**). + */ +export function ContainerRepositoryArtifactDetails({ + image, +}: { + image: FullReportImage | undefined; +}): ReactElement { + const sourceInfo = image?.source_info || []; + const codeSource = Array.isArray(sourceInfo) + ? sourceInfo.find((s) => s?.type === "code") + : undefined; + const codeRepository = codeSource?.git_repo; + const codeRef = codeSource?.ref; + const repositorySnapshotUrl = + codeRepository && codeRef + ? `${normalizeRepoBaseForCommitLink(codeRepository)}/commit/${codeRef}` + : undefined; + const imagePullRef = getPullImageReference(image); + + return ( + <> + + Repository URL + + {repositorySnapshotUrl ? ( + + {repositorySnapshotUrl} + + ) : codeRepository ? ( + + {codeRepository} + + ) : ( + + )} + + + + Image + + {imagePullRef ?? } + + + + ); +} diff --git a/src/main/webui/src/components/DetailsCard.tsx b/src/main/webui/src/components/DetailsCard.tsx index 73dd5e7f..c0905f1b 100644 --- a/src/main/webui/src/components/DetailsCard.tsx +++ b/src/main/webui/src/components/DetailsCard.tsx @@ -10,6 +10,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import type { ReactNode } from "react"; import { Card, CardTitle, @@ -24,27 +25,17 @@ import { import { Link } from "react-router"; import ReactMarkdown from "react-markdown"; import type { FullReport } from "../types/FullReport"; -import CvssBanner from "./CvssBanner"; import Finding from "./Finding"; import IntelReliabilityScore from "./IntelReliabilityScore"; import NotAvailable from "./NotAvailable"; import { getFindingForReportRow } from "../utils/findingDisplay"; -import { getPullImageReference } from "../utils/containerImageReference"; +import { findAnalysisRowForRouteCve } from "../utils/repositoryReport"; -/** Base URL for .../commit/{ref} links: no trailing slash or `.git` suffix. */ -function normalizeRepoBaseForCommitLink(repo: string): string { - let s = repo; - while (s.endsWith("/")) { - s = s.slice(0, -1); - } - if (s.endsWith(".git")) { - s = s.slice(0, -".git".length); - } - while (s.endsWith("/")) { - s = s.slice(0, -1); - } - return s; -} +export const DEFAULT_REPOSITORY_REPORT_DETAILS_CARD_TITLE = + "CVE repository report details"; + +export const RPM_REPOSITORY_REPORT_DETAILS_CARD_TITLE = + "CVE RPM Report Details"; interface DetailsCardProps { report: FullReport; @@ -55,6 +46,12 @@ interface DetailsCardProps { analysisState?: string; /** When true (failed or expired API status), show failure UI; omit agent output fields; CVE line shows Failed like Finding */ isFailed?: boolean; + /** + * Artifact rows after **CVE** (e.g. {@link ContainerRepositoryArtifactDetails} or {@link RpmTargetPackageArtifactDetails}). + */ + artifactDetails: ReactNode; + /** Card heading; defaults to {@link DEFAULT_REPOSITORY_REPORT_DETAILS_CARD_TITLE}. */ + cardTitle?: string; } const DetailsCard: React.FC = ({ @@ -64,22 +61,10 @@ const DetailsCard: React.FC = ({ productId, analysisState, isFailed = false, + artifactDetails, + cardTitle = DEFAULT_REPOSITORY_REPORT_DETAILS_CARD_TITLE, }) => { - const image = report.input?.image; - const sourceInfo = image?.source_info || []; - const codeSource = Array.isArray(sourceInfo) - ? sourceInfo.find((s) => s?.type === "code") - : undefined; - const codeRepository = codeSource?.git_repo; - const codeRef = codeSource?.ref; - const repositorySnapshotUrl = - codeRepository && codeRef - ? `${normalizeRepoBaseForCommitLink(codeRepository)}/commit/${codeRef}` - : undefined; - const imagePullRef = getPullImageReference(image); - const output = report.output?.analysis || []; - const vuln = report.input?.scan?.vulns?.find((v) => v.vuln_id === cveId); - const outputVuln = output.find((v) => v.vuln_id === cveId); + const outputVuln = findAnalysisRowForRouteCve(report, cveId); const finding = getFindingForReportRow( analysisState ?? "", outputVuln?.justification?.status, @@ -89,112 +74,89 @@ const DetailsCard: React.FC = ({ - CVE repository report details + {cardTitle} - {vuln && ( - + + + Finding + + {finding ? : } + + + {isFailed && ( - Finding + Failure reason - {finding ? : } + {report.error?.message ?? } - {isFailed && ( + )} + + CVE + + + {cveId} + + + + {artifactDetails} + {!isFailed && ( + <> - Failure reason + + Intel Reliability Score + - {report.error?.message ?? } + - )} - - CVE - - - {vuln.vuln_id} - - - - - Repository URL - - {repositorySnapshotUrl ? ( - - {repositorySnapshotUrl} - - ) : codeRepository ? ( - - {codeRepository} - - ) : ( - - )} - - - - Image - - {imagePullRef ?? } - - - {!isFailed && ( - <> - - CVSS Score - - - - - - - Intel Reliability Score - - - - - - - Reason - - {outputVuln?.justification?.reason ? ( - - - {outputVuln.justification.reason} - - - ) : ( - - )} - - - - Summary - - {outputVuln?.summary ? ( - - {outputVuln.summary} - - ) : ( - - )} - - - - )} - - )} + + Justification + + {outputVuln?.justification?.label?.trim() ? ( + outputVuln.justification.label + ) : ( + + )} + + + + Reason + + {outputVuln?.justification?.reason ? ( + + + {outputVuln.justification.reason} + + + ) : ( + + )} + + + + Summary + + {outputVuln?.summary ? ( + + {outputVuln.summary} + + ) : ( + + )} + + + + )} + ); diff --git a/src/main/webui/src/components/DownloadVex.tsx b/src/main/webui/src/components/DownloadVex.tsx index 88b4fc51..0447266e 100644 --- a/src/main/webui/src/components/DownloadVex.tsx +++ b/src/main/webui/src/components/DownloadVex.tsx @@ -44,16 +44,17 @@ interface DownloadDropdownProps { report: FullReport; /** Analysis status from the API (e.g. pending, queued, sent, completed). */ analysisStatus?: string | null; -} - -// Scan ID for filename; falls back to "report" when missing (per FullReport type: input.scan.id is string | undefined). -function scanIdString(report: FullReport): string { - return report.input?.scan?.id ?? "report"; + /** Route **CVE** segment; used in download filenames with **reportIdSegment**. */ + cveId: string; + /** Route **report** id (scan id); used with **cveId** for filenames. */ + reportIdSegment: string; } const DownloadDropdown: React.FC = ({ report, analysisStatus, + cveId, + reportIdSegment, }) => { const [isOpen, setIsOpen] = useState(false); @@ -86,12 +87,12 @@ const DownloadDropdown: React.FC = ({ return; } - const filename = `vex-${scanIdString(report)}.json`; + const filename = `vex-${cveId}-${reportIdSegment}.json`; downloadFile(vexPayloadForDownload(vexData), filename); }; const handleDownloadReport = () => { - const filename = `${scanIdString(report)}.json`; + const filename = `report-${cveId}-${reportIdSegment}.json`; downloadFile(reportPayloadForDownload(report), filename); }; diff --git a/src/main/webui/src/components/ReportsPageContent.tsx b/src/main/webui/src/components/ReportsPageContent.tsx index 6303b23d..6cdf2870 100644 --- a/src/main/webui/src/components/ReportsPageContent.tsx +++ b/src/main/webui/src/components/ReportsPageContent.tsx @@ -21,22 +21,31 @@ import { } from "@patternfly/react-core"; import SbomsTable from "./SbomsTable"; import SingleRepositoriesTable from "./SingleRepositoriesTable"; +import RpmRepositoriesTable from "./RpmRepositoriesTable"; const TAB_CONTENT_0 = "reports-tab-content-0"; const TAB_CONTENT_1 = "reports-tab-content-1"; +const TAB_CONTENT_2 = "reports-tab-content-2"; const ReportsPageContent: React.FC = () => { const location = useLocation(); const navigate = useNavigate(); - const activeTabKey = - location.pathname === "/reports/single-repositories" ? 1 : 0; + + let activeTabKey = 0; + if (location.pathname === "/reports/single-repositories") { + activeTabKey = 1; + } else if (location.pathname === "/reports/rpm") { + activeTabKey = 2; + } const handleTabClick = ( _event: React.MouseEvent | React.KeyboardEvent, tabIndex: string | number ) => { - if (tabIndex === 0) navigate("/reports"); - else if (tabIndex === 1) navigate("/reports/single-repositories"); + const idx = typeof tabIndex === "string" ? parseInt(tabIndex, 10) : tabIndex; + if (idx === 0) navigate("/reports"); + else if (idx === 1) navigate("/reports/single-repositories"); + else if (idx === 2) navigate("/reports/rpm"); }; return ( @@ -58,6 +67,11 @@ const ReportsPageContent: React.FC = () => { title={Single Repositories} tabContentId={TAB_CONTENT_1} /> + RPM} + tabContentId={TAB_CONTENT_2} + /> @@ -83,6 +97,17 @@ const ReportsPageContent: React.FC = () => { {activeTabKey === 1 && } + ); diff --git a/src/main/webui/src/components/RepositoryAdditionalDetailsCard.tsx b/src/main/webui/src/components/RepositoryAdditionalDetailsCard.tsx index 95c32aa6..5ceeab88 100644 --- a/src/main/webui/src/components/RepositoryAdditionalDetailsCard.tsx +++ b/src/main/webui/src/components/RepositoryAdditionalDetailsCard.tsx @@ -69,12 +69,10 @@ const RepositoryAdditionalDetailsCard: React.FC { const [isExpanded, setIsExpanded] = useState(false); - const firstVuln = report?.output?.analysis?.[0] || {}; - const cvssVector = firstVuln?.cvss?.vector_string ?? ""; const submittedAt = parseMetadataTimestamp(report?.metadata, "submitted_at"); const sentAt = parseMetadataTimestamp(report?.metadata, "sent_at"); - + const started = report?.input?.scan?.started_at ?? ""; const completed = report?.input?.scan?.completed_at ?? ""; @@ -99,12 +97,6 @@ const RepositoryAdditionalDetailsCard: React.FC - - CVSS Vector String - - {cvssVector || } - - Submitted @@ -143,4 +135,3 @@ const RepositoryAdditionalDetailsCard: React.FC c.key === key); - return col?.sortable === true; +const RPM_LAYOUT_COLUMNS: ColumnDef[] = [ + { key: "id", label: "ID", width: 10 }, + { key: "rpmPackage", label: "Package", sortable: true, width: 25 }, + { key: "rpmArchitecture", label: "Architecture", sortable: true, width: 10 }, + { key: "cveId", label: "CVE ID" }, + { key: "finding", label: "Finding", width: 10 }, + { key: "submittedAt", label: "Date Requested", sortable: true, width: 15 }, + { key: "completedAt", label: "Date Completed", sortable: true, width: 15 }, +]; + +function defsFor(layout: RepositoryTableLayout): ColumnDef[] { + return layout === "rpm" ? RPM_LAYOUT_COLUMNS : REPOSITORY_LAYOUT_COLUMNS; +} + +function isSortableRepoColumn( + col: ColumnDef +): col is ColumnDef & { key: RepoSortColumn } { + return col.sortable === true; } export interface RepositoryReportsTableContentProps { @@ -71,8 +98,10 @@ export interface RepositoryReportsTableContentProps { pagination: { totalElements: number; totalPages: number } | null; tableParams: UseTableParamsResult; getViewPath: (report: Report) => string; - /** When true, show CVE ID column and CVE ID filter (Single Repositories only). */ + /** When true, show CVE ID column and CVE ID toolbar filter (Standalone tabs). */ showCveIdColumn?: boolean; + /** `repository`: embedded / Single Repositories; `rpm`: `/reports/rpm` layout. */ + tableLayout?: RepositoryTableLayout; ariaLabel?: string; } @@ -86,22 +115,22 @@ const RepositoryReportsTableContent: React.FC< tableParams, getViewPath, showCveIdColumn = false, + tableLayout = "repository", ariaLabel = "Repository reports table", }) => { const showCveIdFilter = showCveIdColumn; + const displayReports = reports || []; const totalFilteredCount = pagination?.totalElements ?? 0; - const visibleColumns = REPOSITORY_REPORTS_COLUMNS.filter( + const layoutColumns = useMemo(() => defsFor(tableLayout), [tableLayout]); + const visibleColumns = layoutColumns.filter( (col) => col.key !== "cveId" || (col.key === "cveId" && showCveIdColumn) ); - const sortColumn = tableParams.data.sortColumn ?? "submittedAt"; + const sortColumn = + tableParams.data.sortColumn ?? ("submittedAt" as RepoSortColumn); const sortDirection = tableParams.data.sortDirection ?? "desc"; const activeSortIndex = useMemo( - () => - Math.max( - 0, - visibleColumns.findIndex((c) => c.key === sortColumn) - ), + () => Math.max(0, visibleColumns.findIndex((c) => c.key === sortColumn)), [visibleColumns, sortColumn] ); @@ -110,6 +139,7 @@ const RepositoryReportsTableContent: React.FC< tableParams={tableParams} loading={loading} showCveIdFilter={showCveIdFilter} + inputType={tableLayout} itemCount={pagination?.totalElements ?? totalFilteredCount} /> ); @@ -127,8 +157,20 @@ const RepositoryReportsTableContent: React.FC< {report.gitRepo || ""} ); case "commitId": + return {report.ref ?? ""}; + case "rpmPackage": return ( - {report.ref} + + {report.rpmPackage?.trim() ? report.rpmPackage : MISSING_RPM_ARTIFACT_CELL} + + ); + case "rpmArchitecture": + return ( + + {report.rpmArchitecture?.trim() + ? report.rpmArchitecture.trim() + : MISSING_RPM_ARTIFACT_CELL} + ); case "cveId": return ( @@ -168,14 +210,16 @@ const RepositoryReportsTableContent: React.FC< key={col.key} width={col.width} sort={ - isSortableColumn(col.key) + isSortableRepoColumn(col) ? { sortBy: { index: activeSortIndex, direction: sortDirection, }, onSort: () => - tableParams.handlers.handleSortToggle(col.key as SortColumn), + tableParams.handlers.handleSortToggle( + col.key as RepoSortColumn + ), columnIndex: index, } : undefined diff --git a/src/main/webui/src/components/RepositoryTableToolbar.tsx b/src/main/webui/src/components/RepositoryTableToolbar.tsx index aa5fa7cc..b63a74c1 100644 --- a/src/main/webui/src/components/RepositoryTableToolbar.tsx +++ b/src/main/webui/src/components/RepositoryTableToolbar.tsx @@ -25,7 +25,11 @@ import { FilterIcon } from "@patternfly/react-icons"; import { AttributeSelector, SingleSelectFilter } from "./Filtering"; import { PER_PAGE_OPTIONS } from "../constants/pagination"; import type { UseTableParamsResult } from "../hooks/useTableParams"; -import type { SortColumn, RepoFilterKey } from "../hooks/repositoryReportsTableParams"; +import type { + RepositoryReportsInputType, + SortColumn, + RepoFilterKey, +} from "../hooks/repositoryReportsTableParams"; const FINDING_FILTER_OPTIONS = [ "Vulnerable", @@ -40,38 +44,73 @@ const DEFAULT_PER_PAGE = 10; interface RepositoryTableToolbarProps { tableParams: UseTableParamsResult; loading: boolean; - /** When true, show CVE ID filter (Single Repositories only). */ + /** When true, show CVE ID filter on Standalone (`/reports/single-repositories` / `/reports/rpm`). */ showCveIdFilter?: boolean; + /** + * Standalone artifact search: **`repository`** reads/writes URL key **`gitRepo`** (repository name); + * **`rpm`** reads/writes **`rpmPackage`** (NVR substring). Aligns with **`GET /api/v1/reports` `inputType`**. + */ + inputType?: RepositoryReportsInputType; itemCount?: number; } -type ActiveAttribute = "Repository Name" | "CVE ID" | "Finding"; +type ActiveAttribute = "Repository Name" | "CVE ID" | "Finding" | "Package"; + +/** URL / API filter field backing the artifact search box for the given tab. */ +function artifactFilterUrlKey(inputType: RepositoryReportsInputType): "gitRepo" | "rpmPackage" { + return inputType === "rpm" ? "rpmPackage" : "gitRepo"; +} const RepositoryTableToolbar: React.FC = ({ tableParams, loading, showCveIdFilter = false, + inputType = "repository", itemCount, }) => { - const [activeAttribute, setActiveAttribute] = - useState("Repository Name"); + const [activeAttribute, setActiveAttribute] = useState( + inputType === "rpm" ? "Package" : "Repository Name" + ); const { data, handlers } = tableParams; - const repositorySearchValue = data.getFilterValue("gitRepo") ?? ""; + const artifactKey = artifactFilterUrlKey(inputType); + const inputArtifactFilter = data.getFilterValue(artifactKey) ?? ""; + const setInputArtifactFilter = (value: string) => { + handlers.setFilterValue(artifactKey, value); + }; + const findingFilter = data.getFilterValue("finding") ?? undefined; const cveIdFilterValue = data.getFilterValue("cveId") ?? ""; const page = data.page ?? 1; const perPage = data.perPage ?? DEFAULT_PER_PAGE; - const attributes: ActiveAttribute[] = ["Repository Name", ...(showCveIdFilter ? (["CVE ID"] as const) : []), "Finding"]; + const attributes: ActiveAttribute[] = + inputType === "rpm" + ? ["Package", ...(showCveIdFilter ? (["CVE ID"] as const) : []), "Finding"] + : [ + "Repository Name", + ...(showCveIdFilter ? (["CVE ID"] as const) : []), + "Finding", + ]; + + const artifactCategoryName: ActiveAttribute = + inputType === "rpm" ? "Package" : "Repository Name"; - const repositorySearchInput = ( + const artifactSearchInput = ( handlers.setFilterValue("gitRepo", value)} - onClear={() => handlers.setFilterValue("gitRepo", "")} + aria-label={ + inputType === "rpm" + ? "Filter by RPM package — name, version, or release (substring match)" + : "Search by repository name" + } + placeholder={ + inputType === "rpm" + ? "Filter by package (substring)" + : "Search by Repository Name" + } + value={inputArtifactFilter} + onChange={(_event, value) => setInputArtifactFilter(value)} + onClear={() => setInputArtifactFilter("")} /> ); @@ -103,15 +142,13 @@ const RepositoryTableToolbar: React.FC = ({ /> handlers.setFilterValue("gitRepo", "")} - deleteLabelGroup={() => handlers.setFilterValue("gitRepo", "")} - categoryName="Repository Name" - showToolbarItem={activeAttribute === "Repository Name"} + labels={inputArtifactFilter !== "" ? [inputArtifactFilter] : []} + deleteLabel={() => setInputArtifactFilter("")} + deleteLabelGroup={() => setInputArtifactFilter("")} + categoryName={artifactCategoryName} + showToolbarItem={activeAttribute === artifactCategoryName} > - {repositorySearchInput} + {artifactSearchInput} {showCveIdFilter && ( = ({ + detailsMarkdown, +}) => ( + + + + Analysis Details + + + + {detailsMarkdown?.trim() ? ( + + {detailsMarkdown.trim()} + + ) : ( + + )} + + +); + +export default RpmAnalysisDetailsSection; diff --git a/src/main/webui/src/components/RpmRepositoriesTable.tsx b/src/main/webui/src/components/RpmRepositoriesTable.tsx new file mode 100644 index 00000000..b560e486 --- /dev/null +++ b/src/main/webui/src/components/RpmRepositoriesTable.tsx @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Report } from "../generated-client/models/Report"; +import RepositoryReportsTableContent from "./RepositoryReportsTableContent"; +import { useTableParams } from "../hooks/useTableParams"; +import { + REPOSITORY_REPORTS_RPM_SORT_COLUMNS, + REPOSITORY_REPORTS_VALID_FILTER_KEYS, +} from "../hooks/repositoryReportsTableParams"; +import { useRepositoryReports } from "../hooks/useRepositoryReports"; + +function getCveIdForReport(report: Report): string | undefined { + return report.vulns?.[0]?.vulnId; +} + +const RpmRepositoriesTable: React.FC = () => { + const params = useTableParams({ + validSortColumns: REPOSITORY_REPORTS_RPM_SORT_COLUMNS, + validFilterKeys: REPOSITORY_REPORTS_VALID_FILTER_KEYS, + }); + + const { + data: reports, + loading, + error, + pagination, + } = useRepositoryReports({ + tableData: params.data, + rpmTab: true, + }); + + const getViewPath = (report: Report) => { + const cveId = getCveIdForReport(report); + return cveId + ? `/reports/component/${cveId}/${report.scanId}` + : "/reports/rpm"; + }; + + return ( + + ); +}; + +export default RpmRepositoriesTable; diff --git a/src/main/webui/src/components/RpmTargetPackageArtifactDetails.tsx b/src/main/webui/src/components/RpmTargetPackageArtifactDetails.tsx new file mode 100644 index 00000000..d3e79374 --- /dev/null +++ b/src/main/webui/src/components/RpmTargetPackageArtifactDetails.tsx @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { ReactElement } from "react"; +import { + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, +} from "@patternfly/react-core"; +import type { RpmTargetPackage } from "../types/FullReport"; +import NotAvailable from "./NotAvailable"; +import { formatRpmPackageNvr } from "../utils/rpmReport"; + +/** Replaces repository URL / commit snapshot rows for RPM package checker reports. */ +export function RpmTargetPackageArtifactDetails({ + targetPackage, + rpmPackageUrl, +}: { + targetPackage: RpmTargetPackage | undefined; + /** `report.info.checker_context.artifacts.source_url`, trimmed — see {@link getRpmPackageSourceUrl}. */ + rpmPackageUrl?: string; +}): ReactElement { + const nvr = formatRpmPackageNvr(targetPackage); + return ( + <> + + Package + + {nvr ?? } + + + + Architecture + + {targetPackage?.arch?.trim() ? ( + targetPackage.arch + ) : ( + + )} + + + + RPM package URL + + {rpmPackageUrl ? ( + + {rpmPackageUrl} + + ) : ( + + )} + + + + ); +} diff --git a/src/main/webui/src/components/request-analysis/RequestAnalysisModal.tsx b/src/main/webui/src/components/request-analysis/RequestAnalysisModal.tsx index aa96f2bc..aab916cd 100644 --- a/src/main/webui/src/components/request-analysis/RequestAnalysisModal.tsx +++ b/src/main/webui/src/components/request-analysis/RequestAnalysisModal.tsx @@ -27,6 +27,8 @@ import RequestAnalysisCveIdField from "./RequestAnalysisCveIdField"; import RequestAnalysisSbomFileField from "./RequestAnalysisSbomFileField"; import RequestAnalysisSingleRepositoryFields from "./RequestAnalysisSingleRepositoryFields"; import RequestAnalysisPrivateRepositorySection from "./RequestAnalysisPrivateRepositorySection"; +import RequestAnalysisRpmPackageField from "./RequestAnalysisRpmPackageField"; +import RequestAnalysisRpmArchitectureField from "./RequestAnalysisRpmArchitectureField"; export type { AnalysisRequestMode } from "../../hooks/useAnalysisRequestForm"; @@ -85,15 +87,33 @@ const RequestAnalysisModal: React.FC = ({ onClose }) handlers={handlers} /> )} - + {values.mode === "rpm" && ( + <> + + + + )} + {values.mode !== "rpm" && ( + + )} diff --git a/src/main/webui/src/components/request-analysis/RequestAnalysisModeToggle.tsx b/src/main/webui/src/components/request-analysis/RequestAnalysisModeToggle.tsx index 68061da3..347c447c 100644 --- a/src/main/webui/src/components/request-analysis/RequestAnalysisModeToggle.tsx +++ b/src/main/webui/src/components/request-analysis/RequestAnalysisModeToggle.tsx @@ -29,6 +29,12 @@ const RequestAnalysisModeToggle: React.FC = ({ isSelected={mode === "single-repository"} onChange={() => handlers.changeMode("single-repository")} /> + handlers.changeMode("rpm")} + /> ); diff --git a/src/main/webui/src/components/request-analysis/RequestAnalysisRpmArchitectureField.tsx b/src/main/webui/src/components/request-analysis/RequestAnalysisRpmArchitectureField.tsx new file mode 100644 index 00000000..998f5dcf --- /dev/null +++ b/src/main/webui/src/components/request-analysis/RequestAnalysisRpmArchitectureField.tsx @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from "react"; +import { + FormGroup, + FormSelect, + FormSelectOption, + FormHelperText, + HelperText, + HelperTextItem, +} from "@patternfly/react-core"; +import type { RpmArchChoice } from "../../utils/requestAnalysisRpm"; +import { RPM_ARCH_CHOICES } from "../../utils/requestAnalysisRpm"; +import type { AnalysisRequestFormRpmArchHandlers } from "../../hooks/useAnalysisRequestForm"; + +export interface RequestAnalysisRpmArchitectureFieldProps { + rpmArch: RpmArchChoice; + rpmArchError: string | null; + isSubmitting: boolean; + handlers: AnalysisRequestFormRpmArchHandlers; +} + +const RequestAnalysisRpmArchitectureField: React.FC = ({ + rpmArch, + rpmArchError, + isSubmitting, + handlers, +}) => ( + + handlers.onRpmArchChange(event, value)} + > + {RPM_ARCH_CHOICES.map((choice) => ( + + ))} + + + + {rpmArchError && {rpmArchError}} + + + +); + +export default RequestAnalysisRpmArchitectureField; diff --git a/src/main/webui/src/components/request-analysis/RequestAnalysisRpmPackageField.tsx b/src/main/webui/src/components/request-analysis/RequestAnalysisRpmPackageField.tsx new file mode 100644 index 00000000..e1129365 --- /dev/null +++ b/src/main/webui/src/components/request-analysis/RequestAnalysisRpmPackageField.tsx @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from "react"; +import { + FormGroup, + TextInput, + FormHelperText, + HelperText, + HelperTextItem, +} from "@patternfly/react-core"; +import type { AnalysisRequestFormRpmPackageHandlers } from "../../hooks/useAnalysisRequestForm"; + +/** Example N-V-R for placeholder — matches sibling spec narratives. */ +const RPM_PACKAGE_NVR_PLACEHOLDER_EXAMPLE = "e.g. openssl-3.0.7-5.el9"; + +const RPM_PACKAGE_NVR_HELPER = + "Use hyphens between the package name, version, and release (N-V-R)."; + +export interface RequestAnalysisRpmPackageFieldProps { + rpmPackageNvr: string; + rpmPackageNvrError: string | null; + isSubmitting: boolean; + handlers: AnalysisRequestFormRpmPackageHandlers; +} + +const RequestAnalysisRpmPackageField: React.FC = ({ + rpmPackageNvr, + rpmPackageNvrError, + isSubmitting, + handlers, +}) => ( + + handlers.onTextChange("rpmPackageNvr", e, v)} + onBlur={() => handlers.onTextBlur("rpmPackageNvr")} + onKeyDown={(e) => handlers.onTextKeyDown("rpmPackageNvr", e)} + isDisabled={isSubmitting} + validated={rpmPackageNvrError ? "error" : "default"} + placeholder={RPM_PACKAGE_NVR_PLACEHOLDER_EXAMPLE} + style={{ fontSize: "1rem" }} + /> + + + {RPM_PACKAGE_NVR_HELPER} + {rpmPackageNvrError && ( + {rpmPackageNvrError} + )} + + + +); + +export default RequestAnalysisRpmPackageField; diff --git a/src/main/webui/src/generated-client/index.ts b/src/main/webui/src/generated-client/index.ts index 90c6ee67..1da53636 100644 --- a/src/main/webui/src/generated-client/index.ts +++ b/src/main/webui/src/generated-client/index.ts @@ -22,7 +22,9 @@ export type { LLMStage } from './models/LLMStage'; export type { LocalDateTime } from './models/LocalDateTime'; export type { MarkReportFailedRequest } from './models/MarkReportFailedRequest'; export type { MetricName } from './models/MetricName'; +export type { NewRpmReportRequest } from './models/NewRpmReportRequest'; export type { OverviewMetrics } from './models/OverviewMetrics'; +export type { PipelineMode } from './models/PipelineMode'; export type { Product } from './models/Product'; export type { ProductReportsSummary } from './models/ProductReportsSummary'; export type { ProductSummary } from './models/ProductSummary'; @@ -30,9 +32,11 @@ export type { Report } from './models/Report'; export type { ReportData } from './models/ReportData'; export type { ReportRequest } from './models/ReportRequest'; export type { ReportRequestId } from './models/ReportRequestId'; +export type { ReportSseMessage } from './models/ReportSseMessage'; export type { ReportWithStatus } from './models/ReportWithStatus'; export type { SbomInfoType } from './models/SbomInfoType'; export type { SourceInfo } from './models/SourceInfo'; +export type { TargetPackage } from './models/TargetPackage'; export type { Trace } from './models/Trace'; export type { UserComments } from './models/UserComments'; export type { ValidationErrorResponse } from './models/ValidationErrorResponse'; @@ -48,3 +52,4 @@ export { OverviewMetricsService } from './services/OverviewMetricsService'; export { PreProcessingEndpointService } from './services/PreProcessingEndpointService'; export { ProductEndpointService } from './services/ProductEndpointService'; export { ReportEndpointService } from './services/ReportEndpointService'; +export { ReportStreamResourceService } from './services/ReportStreamResourceService'; diff --git a/src/main/webui/src/generated-client/models/Image.ts b/src/main/webui/src/generated-client/models/Image.ts index 114a2fa2..4c5119bc 100644 --- a/src/main/webui/src/generated-client/models/Image.ts +++ b/src/main/webui/src/generated-client/models/Image.ts @@ -2,7 +2,9 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { PipelineMode } from './PipelineMode'; import type { SourceInfo } from './SourceInfo'; +import type { TargetPackage } from './TargetPackage'; /** * Image data (required if SBOM is not provided) */ @@ -35,5 +37,13 @@ export type Image = { * SBOM information */ sbom_info?: Record; + /** + * Agent pipeline mode; omit when not applicable + */ + pipeline_mode?: PipelineMode; + /** + * RPM target package when pipeline_mode is rpm_package_checker + */ + target_package?: TargetPackage; }; diff --git a/src/main/webui/src/generated-client/models/NewRpmReportRequest.ts b/src/main/webui/src/generated-client/models/NewRpmReportRequest.ts new file mode 100644 index 00000000..ee803a61 --- /dev/null +++ b/src/main/webui/src/generated-client/models/NewRpmReportRequest.ts @@ -0,0 +1,30 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * RPM package plus CVE for new analysis request + */ +export type NewRpmReportRequest = { + /** + * RPM package name + */ + name: string; + /** + * RPM package version + */ + version: string; + /** + * RPM package release + */ + release: string; + /** + * RPM architecture + */ + arch: 'x86_64' | 'amd64' | 'aarch64' | 'arm64' | 'ppc64le' | 's390x'; + /** + * Vulnerability identifier (CVE-YYYY-NNNN+) + */ + cveId: string; +}; + diff --git a/src/main/webui/src/generated-client/models/PipelineMode.ts b/src/main/webui/src/generated-client/models/PipelineMode.ts new file mode 100644 index 00000000..81de02eb --- /dev/null +++ b/src/main/webui/src/generated-client/models/PipelineMode.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Morpheus agent pipeline mode + */ +export type PipelineMode = 'full_pipeline' | 'rpm_package_checker'; diff --git a/src/main/webui/src/generated-client/models/Report.ts b/src/main/webui/src/generated-client/models/Report.ts index b7ae564f..61500bfb 100644 --- a/src/main/webui/src/generated-client/models/Report.ts +++ b/src/main/webui/src/generated-client/models/Report.ts @@ -55,5 +55,13 @@ export type Report = { * Submitted at timestamp from metadata.submitted_at */ submittedAt?: string; + /** + * RPM NVR hyphenated triple from target_package when present + */ + rpmPackage?: string; + /** + * RPM architecture from target_package.arch when present + */ + rpmArchitecture?: string; }; diff --git a/src/main/webui/src/generated-client/models/ReportRequest.ts b/src/main/webui/src/generated-client/models/ReportRequest.ts index db0a1b89..cbf05f2a 100644 --- a/src/main/webui/src/generated-client/models/ReportRequest.ts +++ b/src/main/webui/src/generated-client/models/ReportRequest.ts @@ -3,7 +3,9 @@ /* tslint:disable */ /* eslint-disable */ import type { InlineCredential } from './InlineCredential'; +import type { PipelineMode } from './PipelineMode'; import type { SourceInfo } from './SourceInfo'; +import type { TargetPackage } from './TargetPackage'; /** * A single report request */ @@ -52,6 +54,14 @@ export type ReportRequest = { * SBOM information */ sbom_info?: Record; + /** + * Agent pipeline mode; omit when not applicable + */ + pipeline_mode?: PipelineMode; + /** + * RPM target package when pipeline_mode is rpm_package_checker + */ + target_package?: TargetPackage; }; /** * Credential for private repository access (optional, required only for private repository access) diff --git a/src/main/webui/src/generated-client/models/ReportSseMessage.ts b/src/main/webui/src/generated-client/models/ReportSseMessage.ts index 7501efa6..d065b687 100644 --- a/src/main/webui/src/generated-client/models/ReportSseMessage.ts +++ b/src/main/webui/src/generated-client/models/ReportSseMessage.ts @@ -2,9 +2,4 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -/** - * JSON object sent as each SSE event `data` line (OpenAPI lists this under text/event-stream). - * Today this is the empty object `{}`: any message means data may have changed; refetch REST as needed. - * Future OpenAPI versions may add optional properties for targeted invalidation. - */ -export type ReportSseMessage = Record; +export type ReportSseMessage = Record; diff --git a/src/main/webui/src/generated-client/models/TargetPackage.ts b/src/main/webui/src/generated-client/models/TargetPackage.ts new file mode 100644 index 00000000..668527e5 --- /dev/null +++ b/src/main/webui/src/generated-client/models/TargetPackage.ts @@ -0,0 +1,18 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * RPM target package descriptor for rpm_package_checker pipeline + */ +export type TargetPackage = { + name: string; + version: string; + release: string; + arch: string; + /** + * Package ecosystem + */ + ecosystem: string; +}; + diff --git a/src/main/webui/src/generated-client/services/ReportEndpointService.ts b/src/main/webui/src/generated-client/services/ReportEndpointService.ts index 2d68a24d..90958d27 100644 --- a/src/main/webui/src/generated-client/services/ReportEndpointService.ts +++ b/src/main/webui/src/generated-client/services/ReportEndpointService.ts @@ -4,6 +4,7 @@ /* eslint-disable */ import type { FailedComponent } from '../models/FailedComponent'; import type { MarkReportFailedRequest } from '../models/MarkReportFailedRequest'; +import type { NewRpmReportRequest } from '../models/NewRpmReportRequest'; import type { ProductSummary } from '../models/ProductSummary'; import type { Report } from '../models/Report'; import type { ReportData } from '../models/ReportData'; @@ -70,14 +71,15 @@ export class ReportEndpointService { exploitIqStatus, imageName, imageTag, + inputType, page = 0, pageSize = 100, productId, reportId, + rpmPackage, sortBy, status, vulnId, - withoutProduct = 'false', }: { /** * Filter by ExploitIQ status. Valid values: TRUE, FALSE, UNKNOWN @@ -91,6 +93,10 @@ export class ReportEndpointService { * Filter by image tag */ imageTag?: string, + /** + * Standalone Reports tab filter: "repository" (no product id, not rpm_package_checker), "rpm" (no product id, rpm_package_checker), or omit for no input-type filter + */ + inputType?: string, /** * Page number (0-based) */ @@ -107,6 +113,10 @@ export class ReportEndpointService { * Filter by report ID (input.scan.id) */ reportId?: string, + /** + * Case-insensitive substring match on RPM NVR as displayed: trimmed non-empty input.image.target_package name, version, and release joined with hyphens (documents missing any of the three are excluded). Literal match only—not a regex vocabulary. Comma-separated values match if any term matches (OR). + */ + rpmPackage?: string, /** * Sort criteria in format 'field:direction' */ @@ -119,10 +129,6 @@ export class ReportEndpointService { * Filter by vulnerability ID (CVE ID) */ vulnId?: string, - /** - * When true, return only reports that have no metadata.product_id (single repositories not part of a product) - */ - withoutProduct?: string, }): CancelablePromise> { return __request(OpenAPI, { method: 'GET', @@ -131,16 +137,18 @@ export class ReportEndpointService { 'exploitIqStatus': exploitIqStatus, 'imageName': imageName, 'imageTag': imageTag, + 'inputType': inputType, 'page': page, 'pageSize': pageSize, 'productId': productId, 'reportId': reportId, + 'rpmPackage': rpmPackage, 'sortBy': sortBy, 'status': status, 'vulnId': vulnId, - 'withoutProduct': withoutProduct, }, errors: { + 400: `Invalid query parameters (for example unsupported inputType)`, 500: `Internal server error`, }, }); @@ -231,6 +239,32 @@ export class ReportEndpointService { }, }); } + /** + * Create analysis request for an RPM package + * Accepts RPM name, version, release, architecture, and a CVE id; builds a Morpheus input with pipeline_mode rpm_package_checker and target_package, persists the report, and always submits it for analysis (same queue path as POST /reports/new with submit=true). Validation errors use the same field-mapped JSON shape as POST /products/upload-spdx (object "errors" mapping field names to messages). + * @returns ReportData Analysis request accepted + * @throws ApiError + */ + public static postApiV1ReportsNewRpmReport({ + requestBody, + }: { + /** + * RPM package coordinates and CVE identifier + */ + requestBody: NewRpmReportRequest, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/reports/new-rpm-report', + body: requestBody, + mediaType: 'application/json', + errors: { + 400: `Missing or invalid fields; response body has an "errors" object mapping field names (name, version, release, arch, cveId) to messages`, + 429: `Request queue exceeded`, + 500: `Internal server error`, + }, + }); + } /** * Delete product by IDs * Deletes all component analysis reports and product metadata associated with specified product IDs diff --git a/src/main/webui/src/generated-client/services/ReportStreamResourceService.ts b/src/main/webui/src/generated-client/services/ReportStreamResourceService.ts new file mode 100644 index 00000000..acc93308 --- /dev/null +++ b/src/main/webui/src/generated-client/services/ReportStreamResourceService.ts @@ -0,0 +1,22 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ReportSseMessage } from '../models/ReportSseMessage'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class ReportStreamResourceService { + /** + * Report live-update stream (SSE) + * Server-Sent Events. Each event `data` line is JSON `{}` (empty object): report or product data may have changed; clients should refetch their REST views. The payload may gain fields later for targeted invalidation. + * @returns ReportSseMessage SSE stream + * @throws ApiError + */ + public static getApiV1ReportsStream(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/reports/stream', + }); + } +} diff --git a/src/main/webui/src/generated-client/services/ReportsService.ts b/src/main/webui/src/generated-client/services/ReportsService.ts deleted file mode 100644 index 542ed907..00000000 --- a/src/main/webui/src/generated-client/services/ReportsService.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; -import { request as __request } from '../core/request'; -export class ReportsService { - /** - * Report live-update stream (SSE) - * Long-lived Server-Sent Events (media type text/event-stream). Each event's `data` line is JSON `{}` (empty object): report or product data may have changed. Use EventSource or another SSE-capable client with the same authentication as other /api/v1 routes. The TypeScript client generated from this specification does not implement streaming; keep using the generated client for REST and subscribe to this path manually. - * @returns any Open stream of SSE events until the client disconnects or the server closes the connection. - * @throws ApiError - */ - public static getApiV1ReportsStream(): CancelablePromise> { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v1/reports/stream', - errors: { - 401: `Authentication required (missing or invalid JWT)`, - }, - }); - } -} diff --git a/src/main/webui/src/hooks/repositoryReportsTableParams.ts b/src/main/webui/src/hooks/repositoryReportsTableParams.ts index 6db5f99a..841be0ed 100644 --- a/src/main/webui/src/hooks/repositoryReportsTableParams.ts +++ b/src/main/webui/src/hooks/repositoryReportsTableParams.ts @@ -17,20 +17,45 @@ import type { SortDirection as TableParamsSortDirection } from "./useTableParams"; -export type SortColumn = "gitRepo" | "submittedAt" | "completedAt"; +/** Matches repository reports table layouts and `GET /api/v1/reports` `inputType` for standalone tabs. */ +export type RepositoryReportsInputType = "repository" | "rpm"; + +/** Sort columns for **`GET /api/v1/reports`** `sortBy` (maps via backend SORT_MAPPINGS). */ +export type RepoSortColumn = + | "gitRepo" + | "submittedAt" + | "completedAt" + | "rpmPackage" + | "rpmArchitecture"; + export type SortDirection = TableParamsSortDirection; -export const REPOSITORY_REPORTS_VALID_SORT_COLUMNS: readonly SortColumn[] = [ +export const REPOSITORY_REPORTS_REPOSITORY_SORT_COLUMNS: readonly RepoSortColumn[] = [ "gitRepo", "submittedAt", "completedAt", ]; +export const REPOSITORY_REPORTS_RPM_SORT_COLUMNS: readonly RepoSortColumn[] = [ + "rpmPackage", + "rpmArchitecture", + "submittedAt", + "completedAt", +]; + export const REPOSITORY_REPORTS_VALID_FILTER_KEYS = [ "gitRepo", "cveId", "finding", + "rpmPackage", ] as const; export type RepoFilterKey = (typeof REPOSITORY_REPORTS_VALID_FILTER_KEYS)[number]; + +/** @deprecated Prefer {@link RepoSortColumn}. */ +export type SortColumn = RepoSortColumn; + +/** @deprecated Prefer {@link REPOSITORY_REPORTS_REPOSITORY_SORT_COLUMNS}. */ +export const REPOSITORY_REPORTS_VALID_SORT_COLUMNS = + REPOSITORY_REPORTS_REPOSITORY_SORT_COLUMNS; diff --git a/src/main/webui/src/hooks/useAnalysisRequestForm.ts b/src/main/webui/src/hooks/useAnalysisRequestForm.ts index e60f4225..a78ae6e8 100644 --- a/src/main/webui/src/hooks/useAnalysisRequestForm.ts +++ b/src/main/webui/src/hooks/useAnalysisRequestForm.ts @@ -15,6 +15,14 @@ import { validateSourceRepoUrl, detectCredentialType, } from "../utils/requestAnalysisValidation"; +import { + parseTrimmedRpmNvr, + validateRpmPackageNvrBlur, + RPM_PACKAGE_NVR_FORMAT_ERROR_MESSAGE, + DEFAULT_RPM_ARCH, + isRpmArchChoice, + type RpmArchChoice, +} from "../utils/requestAnalysisRpm"; import { detectSbomFormat, SbomFormat, @@ -30,7 +38,7 @@ const VALIDATION_MESSAGE_USERNAME_REQUIRED_FOR_PAT = const VALIDATION_MESSAGE_SBOM_FORMAT_DETECT_UNKNOWN = "An unknown error occurred while detecting the SBOM format"; -export type AnalysisRequestMode = "sbom" | "single-repository"; +export type AnalysisRequestMode = "sbom" | "single-repository" | "rpm"; export interface AnalysisRequestFormValues { mode: AnalysisRequestMode; @@ -39,6 +47,8 @@ export interface AnalysisRequestFormValues { filename: string; sourceRepo: string; commitId: string; + rpmPackageNvr: string; + rpmArch: RpmArchChoice; isAuthenticationSecretChecked: boolean; authenticationSecret: string; username: string; @@ -49,29 +59,47 @@ export interface AnalysisRequestStoredValues extends AnalysisRequestFormValues { selectedFile: File | null; } -/** Text inputs controlled via `handlers.onTextChange` / `onTextBlur` / `onTextKeyDown`. Blur handling no-ops for `commitId` (validated on submit only). */ +/** + * Text inputs controlled via `handlers.onTextChange` / `onTextBlur` / `onTextKeyDown`. + * Blur/`applyTextBlurValidation`: `commitId` no-ops (validated on submit only). + */ export type AnalysisRequestFormTextField = keyof Pick< AnalysisRequestStoredValues, - "cveId" | "sourceRepo" | "commitId" | "authenticationSecret" | "username" + "cveId" | "sourceRepo" | "commitId" | "rpmPackageNvr" | "authenticationSecret" | "username" >; export interface AnalysisRequestFormErrors { cveId: string | null; + /** SBOM upload / server field `file`; not `fileValue` / `filename` on values. */ file: string | null; sourceRepo: string | null; commitId: string | null; + rpmPackageNvr: string | null; + rpmArch: string | null; authenticationSecret: string | null; username: string | null; error: string | null; sbomValidationIssues: SbomValidationIssueEntry[] | null; } +function combineRpmCoordinateFieldErrors(fieldErrors: Record): string | null { + const msgs = ["name", "version", "release"] + .map((k) => fieldErrors[k]) + .filter((m): m is string => typeof m === "string" && m.trim() !== ""); + if (msgs.length === 0) { + return null; + } + return msgs.join(" "); +} + function createInitialFormErrors(): AnalysisRequestFormErrors { return { cveId: null, file: null, sourceRepo: null, commitId: null, + rpmPackageNvr: null, + rpmArch: null, authenticationSecret: null, username: null, error: null, @@ -90,6 +118,8 @@ function createInitialStoredValues(): AnalysisRequestStoredValues { selectedFile: null, sourceRepo: "", commitId: "", + rpmPackageNvr: "", + rpmArch: DEFAULT_RPM_ARCH, isAuthenticationSecretChecked: false, authenticationSecret: "", username: "", @@ -112,7 +142,7 @@ function getClientValidationErrors(s: AnalysisRequestStoredValues): Partial ) => void; + onRpmArchChange: (event: React.FormEvent, value: string) => void; onPrivateRepoSwitch: (_event: React.FormEvent, checked: boolean) => void; onFileInputChange: (_event: DropEvent, file: File) => void; onClearFile: (_event: React.MouseEvent) => void; @@ -198,6 +236,12 @@ export type AnalysisRequestFormRepositoryTextHandlers = Pick< "onTextChange" | "onTextBlur" | "onTextKeyDown" >; +/** RPM package NVR leaf (blur/Enter parity with CVE). */ +export type AnalysisRequestFormRpmPackageHandlers = Pick< + AnalysisRequestFormHandlers, + "onTextChange" | "onTextBlur" | "onTextKeyDown" +>; + /** Handlers used by private repo credential section leaf. */ export type AnalysisRequestFormPrivateRepoHandlers = Pick< AnalysisRequestFormHandlers, @@ -213,6 +257,9 @@ export type AnalysisRequestFormFileHandlers = Pick< /** Handlers used by analysis mode toggle leaf. */ export type AnalysisRequestFormModeHandlers = Pick; +/** Handlers for RPM architecture `FormSelect`. */ +export type AnalysisRequestFormRpmArchHandlers = Pick; + export interface UseAnalysisRequestFormResult { values: AnalysisRequestFormValues; state: AnalysisRequestFormStateSlice; @@ -237,18 +284,32 @@ export function useAnalysisRequestForm({ ...CLEARED_SUBMISSION_ERRORS, cveId: prev.cveId, })); - setValues((prev) => ({ ...prev, mode: newMode })); + setValues((prev) => { + const nextMode = newMode; + if (nextMode === "rpm" || prev.mode === "rpm") { + return { + ...prev, + mode: nextMode, + rpmPackageNvr: "", + rpmArch: DEFAULT_RPM_ARCH, + }; + } + return { ...prev, mode: nextMode }; + }); }, []); const handleSubmitError = useCallback((err: unknown): void => { const { fieldErrors, genericMessage, sbomValidationIssues: issues } = parseRequestAnalysisSubmissionError(err, FALLBACK_ERROR_MESSAGE); + const rpmCombined = combineRpmCoordinateFieldErrors(fieldErrors); setErrors((prev) => ({ ...prev, cveId: fieldErrors.cveId ?? null, file: fieldErrors.file ?? null, sourceRepo: fieldErrors.sourceRepo ?? null, commitId: fieldErrors.commitId ?? null, + rpmPackageNvr: rpmCombined ?? null, + rpmArch: fieldErrors.arch ?? null, sbomValidationIssues: issues && issues.length > 0 ? issues : null, error: genericMessage, })); @@ -273,6 +334,17 @@ export function useAnalysisRequestForm({ [] ); + const onRpmArchChange = useCallback( + (_event: React.FormEvent, value: string) => { + if (!isRpmArchChoice(value)) { + return; + } + setValues((prev) => ({ ...prev, rpmArch: value })); + setErrors((prev) => (prev.rpmArch ? { ...prev, rpmArch: null } : prev)); + }, + [] + ); + const applyTextBlurValidation = useCallback( (field: AnalysisRequestFormTextField) => { switch (field) { @@ -316,6 +388,13 @@ export function useAnalysisRequestForm({ return v; }); break; + case "rpmPackageNvr": + setValues((v) => { + const err = validateRpmPackageNvrBlur(v.rpmPackageNvr); + setErrors((e) => ({ ...e, rpmPackageNvr: err })); + return v; + }); + break; } }, [flushCveIdFormatValidation] @@ -417,6 +496,38 @@ export function useAnalysisRequestForm({ return; } + if (values.mode === "rpm") { + setState({ isSubmitting: true }); + try { + const trimmedPkg = values.rpmPackageNvr.trim(); + const coords = parseTrimmedRpmNvr(trimmedPkg); + if (!coords) { + setErrors((prev) => ({ + ...prev, + rpmPackageNvr: RPM_PACKAGE_NVR_FORMAT_ERROR_MESSAGE, + })); + return; + } + const response: ReportData = await ReportEndpointService.postApiV1ReportsNewRpmReport({ + requestBody: { + name: coords.name, + version: coords.version, + release: coords.release, + arch: values.rpmArch, + cveId: trimmedCveId, + }, + }); + const reportId = response.reportRequestId.reportId; + navigate(`/reports/component/${trimmedCveId}/${reportId}`); + onClose(); + } catch (err: unknown) { + handleSubmitError(err); + } finally { + setState({ isSubmitting: false }); + } + return; + } + setState({ isSubmitting: true }); try { const file = values.selectedFile; @@ -465,7 +576,9 @@ export function useAnalysisRequestForm({ const submitDisabled = state.isSubmitting || - (values.isAuthenticationSecretChecked && values.authenticationSecret.trim() === ""); + (values.mode !== "rpm" && + values.isAuthenticationSecretChecked && + values.authenticationSecret.trim() === ""); const { selectedFile: _selectedFile, ...exportedValues } = values; @@ -475,6 +588,7 @@ export function useAnalysisRequestForm({ onTextChange, onTextBlur, onTextKeyDown, + onRpmArchChange, onPrivateRepoSwitch, onFileInputChange, onClearFile, @@ -485,6 +599,7 @@ export function useAnalysisRequestForm({ onTextChange, onTextBlur, onTextKeyDown, + onRpmArchChange, onPrivateRepoSwitch, onFileInputChange, onClearFile, @@ -502,3 +617,4 @@ export function useAnalysisRequestForm({ handlers, }; } + diff --git a/src/main/webui/src/hooks/useRepositoryReports.ts b/src/main/webui/src/hooks/useRepositoryReports.ts index 60b5ffe6..44c08e57 100644 --- a/src/main/webui/src/hooks/useRepositoryReports.ts +++ b/src/main/webui/src/hooks/useRepositoryReports.ts @@ -19,7 +19,7 @@ import { usePaginatedApi } from "./usePaginatedApi"; import { Report } from "../generated-client"; import { displayToApi, JUSTIFICATION_DISPLAY_LABELS } from "../utils/justificationStatus"; import type { UseTableParamsData } from "./useTableParams"; -import type { RepoFilterKey } from "./repositoryReportsTableParams"; +import type { RepoFilterKey, RepoSortColumn } from "./repositoryReportsTableParams"; export interface UseRepositoryReportsOptions { /** When provided, fetches reports for this product and CVE. When omitted, fetches single-repository reports (no product_id). */ @@ -28,7 +28,9 @@ export interface UseRepositoryReportsOptions { /** When provided, SSE-driven refetches run only while this returns true (e.g. until product analysis completes). */ shouldContinueLiveRefresh?: () => boolean; /** Table state from useTableParams().data; defaults applied inside this hook. */ - tableData: UseTableParamsData<"gitRepo" | "submittedAt" | "completedAt", RepoFilterKey>; + tableData: UseTableParamsData; + /** When true (**`/reports/rpm`**), restricts to **`rpm_package_checker`** pipeline and skips **`git_repo`** query wiring. */ + rpmTab?: boolean; } export interface UseRepositoryReportsResult { @@ -87,13 +89,16 @@ export function useRepositoryReports( cveId, tableData, shouldContinueLiveRefresh, + rpmTab = false, } = options; const page = tableData.page ?? 1; const perPage = tableData.perPage ?? DEFAULT_PER_PAGE; const sortColumn = tableData.sortColumn ?? "submittedAt"; const sortDirection = tableData.sortDirection ?? "desc"; - const repositorySearchValue = tableData.getFilterValue("gitRepo") ?? ""; + const gitRepoRaw = tableData.getFilterValue("gitRepo") ?? ""; + const repositorySearchValue = rpmTab ? "" : gitRepoRaw; + const rpmPackageFilter = rpmTab ? (tableData.getFilterValue("rpmPackage") ?? "") : ""; const cveIdFilter = tableData.getFilterValue("cveId") ?? ""; const findingFilter = tableData.getFilterValue("finding") ?? undefined; @@ -121,7 +126,7 @@ export function useRepositoryReports( ...(isProductContext ? { productId, vulnId: cveId } : { - withoutProduct: "true", + inputType: rpmTab ? "rpm" : "repository", ...(cveIdFilter?.trim() && { vulnId: cveIdFilter.trim() }), }), sortBy: sortByParam, @@ -129,7 +134,13 @@ export function useRepositoryReports( ...(exploitIqStatusApiValue && { exploitIqStatus: exploitIqStatusApiValue, }), - ...(repositorySearchValue && { gitRepo: repositorySearchValue }), + ...(repositorySearchValue?.trim() && { + gitRepo: repositorySearchValue.trim(), + }), + ...(rpmTab && + rpmPackageFilter.trim() && { + rpmPackage: rpmPackageFilter.trim(), + }), }, }), { @@ -144,6 +155,8 @@ export function useRepositoryReports( exploitIqStatusApiValue ?? "", repositorySearchValue, cveIdFilter ?? "", + rpmPackageFilter, + rpmTab, ], liveUpdatesRefresh: true, ...(shouldContinueLiveRefresh && { diff --git a/src/main/webui/src/mocks/handlers.ts b/src/main/webui/src/mocks/handlers.ts index 2f8c7b75..ee16803c 100644 --- a/src/main/webui/src/mocks/handlers.ts +++ b/src/main/webui/src/mocks/handlers.ts @@ -698,7 +698,35 @@ const mockReports: Report[] = [ }), ]; -/** Reports with no `product_id` / `productId` in metadata (MSW mirror of `withoutProduct=true`). */ +/** Standalone RPM package checker rows (MSW mirror of `GET /reports?inputType=rpm`). */ +const mockStandaloneRpmReports: Report[] = [ + { + id: "rpm-msw-standalone-1", + scanId: "rpm-msw-standalone-1", + startedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + completedAt: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), + imageName: "source", + imageTag: "n/a", + state: "completed", + vulns: [ + { + vulnId: "CVE-2024-RPMTAB", + justification: { status: "FALSE", label: "not_vulnerable" }, + }, + ], + metadata: { + environment: "msw-rpm-standalone", + submitted_at: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + }, + gitRepo: "https://brew.example.redhat.com/mock-rpm-src", + ref: "libmock-1.0-1", + submittedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + rpmPackage: "libmock-1.0-1", + rpmArchitecture: "x86_64", + }, +]; + +/** Reports with no `product_id` / `productId` in metadata (`GET /reports?inputType=repository`). */ const mockSingleRepositoryReports: Report[] = [ { id: "sr-msw-1", @@ -840,17 +868,25 @@ function reportHasProductMetadata(r: Report): boolean { return Boolean(m.productId || m.product_id); } +/** MSW heuristic: RPM package-checker summaries carry a non-empty `rpmPackage` (from target_package NVR). */ +function mockReportIsStandaloneRpmChecker(r: Report): boolean { + const p = r.rpmPackage; + return typeof p === "string" && p.trim() !== ""; +} + function findReportByScanId(scanId: string): Report | undefined { return ( mockReports.find((r) => r.scanId === scanId) ?? - mockSingleRepositoryReports.find((r) => r.scanId === scanId) + mockSingleRepositoryReports.find((r) => r.scanId === scanId) ?? + mockStandaloneRpmReports.find((r) => r.scanId === scanId) ); } function findReportById(id: string): Report | undefined { return ( mockReports.find((r) => r.id === id) ?? - mockSingleRepositoryReports.find((r) => r.id === id) + mockSingleRepositoryReports.find((r) => r.id === id) ?? + mockStandaloneRpmReports.find((r) => r.id === id) ); } @@ -865,6 +901,11 @@ function removeReportById(id: string): boolean { mockSingleRepositoryReports.splice(iSingle, 1); return true; } + const iRpm = mockStandaloneRpmReports.findIndex((r) => r.id === id); + if (iRpm !== -1) { + mockStandaloneRpmReports.splice(iRpm, 1); + return true; + } return false; } @@ -958,8 +999,9 @@ export const handlers = [ const productId = url.searchParams.get("productId"); const vulnId = url.searchParams.get("vulnId"); const status = url.searchParams.get("status"); + const rawInputType = url.searchParams.get("inputType")?.trim(); - let filteredReports = [...mockReports, ...mockSingleRepositoryReports]; + let filteredReports = [...mockReports, ...mockSingleRepositoryReports, ...mockStandaloneRpmReports]; // Apply filters if (productId) { @@ -968,8 +1010,17 @@ export const handlers = [ r.metadata?.productId === productId || r.metadata?.product_id === productId ); - } else if (url.searchParams.get("withoutProduct") === "true") { - filteredReports = filteredReports.filter((r) => !reportHasProductMetadata(r)); + } else { + const it = rawInputType?.toLowerCase(); + if (it === "repository") { + filteredReports = filteredReports.filter( + (r) => !reportHasProductMetadata(r) && !mockReportIsStandaloneRpmChecker(r) + ); + } else if (it === "rpm") { + filteredReports = filteredReports.filter( + (r) => !reportHasProductMetadata(r) && mockReportIsStandaloneRpmChecker(r) + ); + } } if (vulnId) { diff --git a/src/main/webui/src/pages/ReportsPage.tsx b/src/main/webui/src/pages/ReportsPage.tsx index 87d4a28f..7cb5a121 100644 --- a/src/main/webui/src/pages/ReportsPage.tsx +++ b/src/main/webui/src/pages/ReportsPage.tsx @@ -17,14 +17,17 @@ import { useDocumentTitle } from "../hooks/useDocumentTitle"; import { PAGE_TITLE_REPORTS_SBOMS, PAGE_TITLE_REPORTS_SINGLE_REPOSITORIES, + PAGE_TITLE_REPORTS_RPM, } from "./pageTitles"; const ReportsPage: React.FC = () => { const location = useLocation(); const documentTitle = - location.pathname === "/reports/single-repositories" - ? PAGE_TITLE_REPORTS_SINGLE_REPOSITORIES - : PAGE_TITLE_REPORTS_SBOMS; + location.pathname === "/reports/rpm" + ? PAGE_TITLE_REPORTS_RPM + : location.pathname === "/reports/single-repositories" + ? PAGE_TITLE_REPORTS_SINGLE_REPOSITORIES + : PAGE_TITLE_REPORTS_SBOMS; useDocumentTitle(documentTitle); return ( diff --git a/src/main/webui/src/pages/RepositoryReportPage.tsx b/src/main/webui/src/pages/RepositoryReportPage.tsx index 64317da8..091721af 100644 --- a/src/main/webui/src/pages/RepositoryReportPage.tsx +++ b/src/main/webui/src/pages/RepositoryReportPage.tsx @@ -29,7 +29,9 @@ import { ExclamationCircleIcon } from "@patternfly/react-icons"; import { useRepositoryReport } from "../hooks/useRepositoryReport"; import { getErrorMessage } from "../utils/errorHandling"; import { isFailingState } from "../utils/findingDisplay"; -import DetailsCard from "../components/DetailsCard"; +import DetailsCard, { + RPM_REPOSITORY_REPORT_DETAILS_CARD_TITLE, +} from "../components/DetailsCard"; import ChecklistCard from "../components/ChecklistCard"; import RepositoryAdditionalDetailsCard from "../components/RepositoryAdditionalDetailsCard"; import RepositoryReportPageSkeleton from "../components/RepositoryReportPageSkeleton"; @@ -42,8 +44,17 @@ import { pageTitleRepositoryReportInvalidUrl, pageTitleRepositoryReportLoadError, pageTitleRepositoryReportNotFound, - pageTitleRepositoryReportVulnNotFound, } from "./pageTitles"; +import { + formatRpmRepositoryReportDocumentTitleSuffix, + getRpmPackageSourceUrl, + isRpmPackageCheckerReport, + repositoryReportSubtitleDisplay, +} from "../utils/rpmReport"; +import { findAnalysisRowForRouteCve } from "../utils/repositoryReport"; +import RpmAnalysisDetailsSection from "../components/RpmAnalysisDetailsSection"; +import { RpmTargetPackageArtifactDetails } from "../components/RpmTargetPackageArtifactDetails"; +import { ContainerRepositoryArtifactDetails } from "../components/ContainerRepositoryArtifactDetails"; interface RepositoryReportPageErrorProps { title: string; @@ -67,6 +78,7 @@ const RepositoryReportPageError: React.FC = ({ ); }; + interface RepositoryReportPageApiErrorProps { error: Error; reportId: string; @@ -116,11 +128,6 @@ const RepositoryReportPage: React.FC = () => { [report] ); - const scanVulnForCve = useMemo( - () => report?.input?.scan?.vulns?.find((v) => v.vuln_id === cveId), - [report, cveId] - ); - const documentTitle = useMemo(() => { if (!cveId) { return pageTitleRepositoryReportInvalidUrl(); @@ -137,12 +144,17 @@ const RepositoryReportPage: React.FC = () => { if (!report) { return pageTitleRepositoryReportNotFound(reportId || ""); } - if (!scanVulnForCve) { - return pageTitleRepositoryReportVulnNotFound(cveId); - } const image = report.input?.image; - return pageTitleRepositoryReport(cveId, image?.name, image?.tag); - }, [cveId, loading, error, report, reportId, scanVulnForCve]); + const rpmId = isRpmPackageCheckerReport(report) + ? formatRpmRepositoryReportDocumentTitleSuffix(image?.target_package) + : undefined; + return pageTitleRepositoryReport( + cveId, + image?.name, + image?.tag, + rpmId, + ); + }, [cveId, loading, error, report, reportId]); useDocumentTitle(documentTitle); @@ -174,27 +186,11 @@ const RepositoryReportPage: React.FC = () => { ); } - const image = report.input?.image; - const vuln = scanVulnForCve; - - if (!vuln) { - return ( - - ); - } - - const reportIdDisplay = vuln.vuln_id - ? `${vuln.vuln_id} | ${image?.name || ""} | ${image?.tag || ""}` - : ""; - // Extract product name from metadata, fallback to productId - const productName = report?.metadata?.product_id; - const output = report.output?.analysis || []; - const outputVuln = output.find((v) => v.vuln_id === cveId); + const outputVuln = findAnalysisRowForRouteCve(report, cveId); + const isRpm = isRpmPackageCheckerReport(report); /** Terminal analysis failure: same UI as Finding "failed" (failed or expired; details in error.message). */ const isFailed = isFailingState(status ?? ""); + const reportIdDisplay = repositoryReportSubtitleDisplay(report, cveId); const showReport = () => { return ( @@ -207,18 +203,23 @@ const RepositoryReportPage: React.FC = () => { > - CVE Repository Report:{" "} + {isRpm ? "CVE RPM Report:" : "CVE Repository Report:"}{" "} <span style={{ fontSize: "var(--pf-t--global--font--size--heading--h6)", }} > - {cveId} | {image?.name || ""} | {image?.tag || ""} + {reportIdDisplay} </span> - + @@ -252,13 +253,33 @@ const RepositoryReportPage: React.FC = () => { productId={productId} analysisState={status} isFailed={isFailed} + cardTitle={ + isRpm ? RPM_REPOSITORY_REPORT_DETAILS_CARD_TITLE : undefined + } + artifactDetails={ + isRpm ? ( + + ) : ( + + ) + } /> - {!isFailed && ( + {!isFailed && !isRpm && ( )} + {isRpm && !isFailed && ( + + + + )} @@ -279,14 +300,22 @@ const RepositoryReportPage: React.FC = () => { - + {"Reports"} {productId && ( - {productName} + {productId} )} diff --git a/src/main/webui/src/pages/pageTitles.ts b/src/main/webui/src/pages/pageTitles.ts index d8768804..bd20bdf2 100644 --- a/src/main/webui/src/pages/pageTitles.ts +++ b/src/main/webui/src/pages/pageTitles.ts @@ -30,6 +30,8 @@ export const PAGE_TITLE_REPORTS_SINGLE_REPOSITORIES = withAppTitle( "Reports — Single repositories" ); +export const PAGE_TITLE_REPORTS_RPM = withAppTitle("Reports — RPM"); + export function pageTitleProductReport( productName: string, cveId: string @@ -41,13 +43,20 @@ export function pageTitleProductReport( export function pageTitleRepositoryReport( cveId: string, imageName?: string, - imageTag?: string + imageTag?: string, + /** + * When `image.name` / `tag` empty (RPM checker): tab title suffix from hyphenated **N-V-R** + * plus **architecture**, e.g. `openssl-3.0.7-5.el9 | x86_64` (from `target_package`; not spaced Nevra). + */ + rpmPackageIdentity?: string, ): string { const repoParts = [imageName, imageTag].filter( - (part): part is string => Boolean(part && part.trim()) + (part): part is string => Boolean(part && part.trim()), ); - const repo = repoParts.join(" "); - const segment = repo ? `${cveId} — ${repo}` : cveId; + const repo = repoParts.join(" ").trim(); + const rpm = rpmPackageIdentity?.trim(); + const suffix = repo || rpm; + const segment = suffix ? `${cveId} — ${suffix}` : cveId; return withAppTitle(segment); } diff --git a/src/main/webui/src/types/FullReport.ts b/src/main/webui/src/types/FullReport.ts index dc7a9c5c..39bceae6 100644 --- a/src/main/webui/src/types/FullReport.ts +++ b/src/main/webui/src/types/FullReport.ts @@ -42,6 +42,15 @@ export interface Justification { reason?: string; } +/** RPM NEVRA persisted under Morpheus `input.image.target_package`. */ +export interface RpmTargetPackage { + name?: string; + version?: string; + release?: string; + arch?: string; + ecosystem?: string; +} + /** * CVSS score information */ @@ -62,6 +71,8 @@ export interface ReportOutput { checklist?: ChecklistItem[]; /** Summary of the analysis */ summary?: string; + /** Long-form markdown details (e.g. RPM package checker payloads) */ + details?: string; /** Justification for the vulnerability analysis result */ justification?: Justification; /** Intel score for the vulnerability */ @@ -76,6 +87,10 @@ export interface ReportOutput { export interface FullReportImage { /** Analysis form type (image|source) */ analysis_type: string; + /** e.g. `rpm_package_checker` for RPM CVE checks (`new-rpm-report-api`). */ + pipeline_mode?: string; + /** Populated when `pipeline_mode` is RPM checker. */ + target_package?: RpmTargetPackage; /** Programming language ecosystem */ ecosystem?: string; /** Manifest file path */ @@ -135,6 +150,16 @@ export interface FullReportOutput { vex?: object | null; } +/** Paths and download URL surfaced by RPM package checker analysis (`checker_context` in persisted reports). */ +export interface FullReportCheckerContextArtifacts { + source_url?: string; +} + +/** RPM checker metadata under `report.info.checker_context`. */ +export interface FullReportCheckerContext { + artifacts?: FullReportCheckerContextArtifacts; +} + /** * Report information structure containing VDB, intel, and potentially other fields * Supports both new format (intel as IntelEntry[]) and legacy format (intel as object with score) @@ -146,6 +171,8 @@ export interface FullReportInfo { }; /** Intel information - can be array of IntelEntry (new format) or legacy object format */ intel?: IntelEntry[] | { score?: number } | Record; + /** RPM package checker context (e.g. downloadable artifact URL). */ + checker_context?: FullReportCheckerContext; /** Additional fields that may exist in info */ [key: string]: unknown; } diff --git a/src/main/webui/src/utils/feedbackReportSummary.ts b/src/main/webui/src/utils/feedbackReportSummary.ts index f4e5e03e..c749c7aa 100644 --- a/src/main/webui/src/utils/feedbackReportSummary.ts +++ b/src/main/webui/src/utils/feedbackReportSummary.ts @@ -19,6 +19,10 @@ import type { FullReport } from "../types/FullReport"; import { getPullImageReference } from "./containerImageReference"; +import { + formatRpmTargetPackageIdentity, + isRpmPackageCheckerReport, +} from "./rpmReport"; export function getReportSummaryForFeedback(report: FullReport): string { if (!report?.input) { @@ -26,27 +30,35 @@ export function getReportSummaryForFeedback(report: FullReport): string { } const lines: string[] = []; - const name = - report.input.scan?.id ?? - report.metadata?.product_id ?? - report._id ?? - "Report"; - const image = report.input.image; - const sourceInfo = image?.source_info ?? []; - const repoSource = - sourceInfo.find((s) => s?.type === "code" && s?.git_repo) ?? - sourceInfo.find((s) => s?.git_repo); + const isRpm = isRpmPackageCheckerReport(report); - lines.push(`Name: ${name}`); - lines.push(""); - const pullable = getPullImageReference(image); - if (pullable) { - lines.push(`Image: ${pullable}`); + if (isRpm) { + const tp = report.input.image?.target_package; + lines.push(`RPM package: ${formatRpmTargetPackageIdentity(tp)}`); + lines.push(""); + } else { + const name = + report.input.scan?.id ?? + report.metadata?.product_id ?? + report._id ?? + "Report"; + const image = report.input.image; + const sourceInfo = image?.source_info ?? []; + const repoSource = + sourceInfo.find((s) => s?.type === "code" && s?.git_repo) ?? + sourceInfo.find((s) => s?.git_repo); + + lines.push(`Name: ${name}`); + lines.push(""); + const pullable = getPullImageReference(image); + if (pullable) { + lines.push(`Image: ${pullable}`); + lines.push(""); + } + lines.push(`Repository: ${repoSource?.git_repo ?? ""}`); + lines.push(`Commit/ref: ${repoSource?.ref ?? ""}`); lines.push(""); } - lines.push(`Repository: ${repoSource?.git_repo ?? ""}`); - lines.push(`Commit/ref: ${repoSource?.ref ?? ""}`); - lines.push(""); const analysis = report.output?.analysis ?? []; for (const vuln of analysis) { @@ -65,11 +77,17 @@ export function getReportSummaryForFeedback(report: FullReport): string { lines.push(`Summary: ${vuln.summary}`); lines.push(""); } - lines.push("Checklist:"); - if (vuln.checklist?.length) { - for (const item of vuln.checklist) { - if (item.input) lines.push(`Q: ${item.input}`); - if (item.response) lines.push(`A: ${item.response}`); + if (isRpm && vuln.details?.trim()) { + lines.push(`Details: ${vuln.details}`); + lines.push(""); + } + if (!isRpm) { + lines.push("Checklist:"); + if (vuln.checklist?.length) { + for (const item of vuln.checklist) { + if (item.input) lines.push(`Q: ${item.input}`); + if (item.response) lines.push(`A: ${item.response}`); + } } } lines.push("---"); diff --git a/src/main/webui/src/utils/repositoryReport.ts b/src/main/webui/src/utils/repositoryReport.ts new file mode 100644 index 00000000..099d0b2e --- /dev/null +++ b/src/main/webui/src/utils/repositoryReport.ts @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { FullReport } from "../types/FullReport"; + +/** + * **`output.analysis`** row for the repository report page: **`vuln_id`** matches route **`cveId`**. + */ +export function findAnalysisRowForRouteCve( + report: FullReport | undefined, + routeCveId: string, +) { + return report?.output?.analysis?.find((r) => r.vuln_id === routeCveId); +} diff --git a/src/main/webui/src/utils/requestAnalysisRpm.ts b/src/main/webui/src/utils/requestAnalysisRpm.ts new file mode 100644 index 00000000..7e6744c7 --- /dev/null +++ b/src/main/webui/src/utils/requestAnalysisRpm.ts @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { NewRpmReportRequest } from "../generated-client/models/NewRpmReportRequest"; + +/** Shown when the package string cannot be split into nonempty name, version, and release (RPM-style split from the right). */ +export const RPM_PACKAGE_NVR_FORMAT_ERROR_MESSAGE = + "Enter the package as name-version-release (for example openssl-3.0.7-5.el9), with hyphens separating all three parts."; + +export type RpmArchChoice = NewRpmReportRequest["arch"]; + +export const DEFAULT_RPM_ARCH: RpmArchChoice = "x86_64"; + +export const RPM_ARCH_CHOICES: readonly RpmArchChoice[] = ["x86_64", "amd64", "aarch64", "arm64", "ppc64le", "s390x"]; + +export function isRpmArchChoice(value: string | undefined): value is RpmArchChoice { + return value !== undefined && RPM_ARCH_CHOICES.includes(value as RpmArchChoice); +} + +/** Parses a trimmed RPM N-V-R: release after last hyphen, version before that, name is the leading remainder (may contain hyphens). */ +export function parseTrimmedRpmNvr( + trimmed: string +): { name: string; version: string; release: string } | null { + const lastHyphen = trimmed.lastIndexOf("-"); + if (lastHyphen <= 0) { + return null; + } + const remainder = trimmed.slice(0, lastHyphen); + const release = trimmed.slice(lastHyphen + 1).trim(); + const secondHyphen = remainder.lastIndexOf("-"); + if (secondHyphen < 0) { + return null; + } + const name = remainder.slice(0, secondHyphen).trim(); + const version = remainder.slice(secondHyphen + 1).trim(); + if (name === "" || version === "" || release === "") { + return null; + } + return { name, version, release }; +} + +/** Blur-only: empty yields no format error ("Required" is enforced on submit). */ +export function validateRpmPackageNvrBlur(raw: string): string | null { + const t = raw.trim(); + if (t === "") { + return null; + } + return parseTrimmedRpmNvr(t) ? null : RPM_PACKAGE_NVR_FORMAT_ERROR_MESSAGE; +} diff --git a/src/main/webui/src/utils/rpmReport.ts b/src/main/webui/src/utils/rpmReport.ts new file mode 100644 index 00000000..ff58339c --- /dev/null +++ b/src/main/webui/src/utils/rpmReport.ts @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { FullReport, RpmTargetPackage } from "../types/FullReport"; + +/** Persisted Morpheus discriminant for RPM vulnerability checks (`new-rpm-report-api`). */ +export const RPM_PIPELINE_MODE = "rpm_package_checker" as const; + +export function isRpmPackageCheckerReport( + report: FullReport | undefined, +): boolean { + return report?.input?.image?.pipeline_mode === RPM_PIPELINE_MODE; +} + +export function formatRpmTargetPackageIdentity( + pkg: RpmTargetPackage | undefined, +): string { + if (!pkg) { + return ""; + } + const parts = [pkg.name, pkg.version, pkg.release, pkg.arch].filter( + (p): p is string => Boolean(p && String(p).trim()), + ); + return parts.join(" "); +} + +/** + * RPM NVR for Details **Package** row: `name-version-release`, only when all segments are present. + */ +export function formatRpmPackageNvr( + pkg: RpmTargetPackage | undefined, +): string | undefined { + if (!pkg) { + return undefined; + } + const n = pkg.name?.trim(); + const v = pkg.version?.trim(); + const r = pkg.release?.trim(); + if (!n || !v || !r) { + return undefined; + } + return `${n}-${v}-${r}`; +} + +export interface RpmRepositoryReportSubtitleSlots { + /** Hyphenated N-V-R, or empty if name/version/release incomplete */ + nvrSegment: string; + /** Trimmed architecture, or empty */ + archSegment: string; +} + +/** Slots matching `{CVE} | {name} | {tag}` layout for RPM checker breadcrumb/title. */ +export function getRpmRepositoryReportSubtitleSlots( + pkg: RpmTargetPackage | undefined, +): RpmRepositoryReportSubtitleSlots { + return { + nvrSegment: formatRpmPackageNvr(pkg) ?? "", + archSegment: pkg?.arch?.trim() ?? "", + }; +} + +/** `"{nvr} | {arch}"` with empty placeholders preserved (mirrors container name/tag pipes). */ +export function formatRpmRepositoryReportSubtitleSuffix( + pkg: RpmTargetPackage | undefined, +): string { + const { nvrSegment, archSegment } = getRpmRepositoryReportSubtitleSlots(pkg); + return `${nvrSegment} | ${archSegment}`; +} + +/** Trimmed `report.info.checker_context.artifacts.source_url` when present (RPM checker). */ +export function getRpmPackageSourceUrl( + report: FullReport | undefined, +): string | undefined { + const raw = report?.info?.checker_context?.artifacts?.source_url; + if (typeof raw !== "string") { + return undefined; + } + const trimmed = raw.trim(); + return trimmed === "" ? undefined : trimmed; +} + +/** Tab title suffix: non-empty segments only, joined by ` | ` (em dash separates CVE elsewhere). */ +export function formatRpmRepositoryReportDocumentTitleSuffix( + pkg: RpmTargetPackage | undefined, +): string { + const { nvrSegment, archSegment } = getRpmRepositoryReportSubtitleSlots(pkg); + return [nvrSegment, archSegment].filter((s) => s !== "").join(" | "); +} + +/** Heading / breadcrumb subtitle: CVE plus image refs or RPM N-V-R + arch (`rpm.json`). */ +export function repositoryReportSubtitleDisplay( + report: FullReport, + cveId: string, +): string { + const image = report.input?.image; + if (isRpmPackageCheckerReport(report)) { + return `${cveId} | ${formatRpmRepositoryReportSubtitleSuffix(image?.target_package)}`; + } + return `${cveId} | ${image?.name ?? ""} | ${image?.tag ?? ""}`; +} diff --git a/src/test/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryServiceTest.java b/src/test/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryServiceTest.java new file mode 100644 index 00000000..e59d3207 --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/morpheus/repository/ReportRepositoryServiceTest.java @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.redhat.ecosystemappeng.morpheus.repository; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** Unit coverage for RPM package substring filter literals (Mongo {@code $regexMatch} input). */ +class ReportRepositoryServiceTest { + + @Test + void rpmPackageMatchRegexLiteral_quotesDotsAndHyphensForLiteralSubstring() { + Assertions.assertEquals(".*\\Q3.1.2-14.el7\\E.*", + ReportRepositoryService.rpmPackageMatchRegexLiteral("3.1.2-14.el7"), + "dots and hyphens must not act as regex metacharacters"); + } + + @Test + void rpmPackageMatchRegexLiteral_caseFoldsAscii() { + Assertions.assertEquals(ReportRepositoryService.rpmPackageMatchRegexLiteral("LibArchive"), + ReportRepositoryService.rpmPackageMatchRegexLiteral("libarchive")); + } +} diff --git a/src/test/java/com/redhat/ecosystemappeng/morpheus/rest/NewRpmReportRestTest.java b/src/test/java/com/redhat/ecosystemappeng/morpheus/rest/NewRpmReportRestTest.java new file mode 100644 index 00000000..56458a80 --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/morpheus/rest/NewRpmReportRestTest.java @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, Red Hat Inc. & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.redhat.ecosystemappeng.morpheus.rest; + +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.equalTo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.redhat.ecosystemappeng.morpheus.exception.CveIdValidationException; +import com.redhat.ecosystemappeng.morpheus.model.morpheus.PipelineMode; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +/** + * {@code POST /api/v1/reports/new-rpm-report} ({@link io.quarkus.test.junit.QuarkusTest}). + */ +@QuarkusTest +class NewRpmReportRestTest { + + private static final String PATH = "/api/v1/reports/new-rpm-report"; + /** Aligns with devservices fixture {@code src/test/resources/devservices/reports/rpm.json}. */ + private static final String VALID_CVE = "CVE-2016-8687"; + + @BeforeEach + void configureRestAssured() { + RestApiTestFixture.configureRestAssuredIfExternal(); + } + + @Test + void acceptedReportHasRpmCheckerInputAndNormalizedCve() { + Map body = baseValidBody(); + + String reportId = RestAssured.given() + .contentType(ContentType.JSON) + .body(body) + .when() + .post(PATH) + .then() + .statusCode(202) + .contentType(ContentType.JSON) + .extract() + .path("reportRequestId.id"); + + RestAssured.given() + .when() + .get("/api/v1/reports/" + reportId) + .then() + .statusCode(200) + .body("report.input.image.pipeline_mode", equalTo(PipelineMode.RPM_PACKAGE_CHECKER.toString())) + .body("report.input.image.analysis_type", equalTo("source")) + .body("report.input.image.source_info", equalTo(null)) + .body("report.input.image.target_package.name", equalTo("libarchive")) + .body("report.input.image.target_package.version", equalTo("3.1.2")) + .body("report.input.image.target_package.release", equalTo("14.el7_9.1")) + .body("report.input.image.target_package.arch", equalTo("x86_64")) + .body("report.input.image.target_package.ecosystem", equalTo("rpm")) + .body("report.input.scan.vulns[0].vuln_id", equalTo("CVE-2016-8687")); + } + + @Test + void missingNameReturns400WithErrorsWrapper() { + Map body = baseValidBody(); + body = Map.of( + "name", "", + "version", body.get("version"), + "release", body.get("release"), + "arch", body.get("arch"), + "cveId", body.get("cveId")); + + RestAssured.given() + .contentType(ContentType.JSON) + .body(body) + .when() + .post(PATH) + .then() + .statusCode(400) + .body("errors.name", equalTo("Name is required")); + } + + @Test + void invalidCveFormatReturns400AlignedWithUploadSpdx() { + Map body = new HashMap<>(baseValidBody()); + body.put("cveId", "not-a-cve"); + + RestAssured.given() + .contentType(ContentType.JSON) + .body(body) + .when() + .post(PATH) + .then() + .statusCode(400) + .body("errors.cveId", + equalTo("Invalid CVE ID: not-a-cve. Must match the official CVE pattern CVE-YYYY-NNNN+")); + } + + @Test + void missingCveIdKeyReturns400() { + Map body = new HashMap<>(); + body.put("name", "libarchive"); + body.put("version", "3.1.2"); + body.put("release", "14.el7_9.1"); + body.put("arch", "x86_64"); + + RestAssured.given() + .contentType(ContentType.JSON) + .body(body) + .when() + .post(PATH) + .then() + .statusCode(400) + .body("errors.cveId", equalTo(CveIdValidationException.MESSAGE_REQUIRED)); + } + + @Test + void multipleFieldErrorsAggregated() { + Map body = Map.of( + "name", "libarchive", + "version", "1", + "release", " ", + "arch", "x86_64", + "cveId", "bad"); + + RestAssured.given() + .contentType(ContentType.JSON) + .body(body) + .when() + .post(PATH) + .then() + .statusCode(400) + .body("errors.release", equalTo("Release is required")) + .body("errors.cveId", + equalTo("Invalid CVE ID: bad. Must match the official CVE pattern CVE-YYYY-NNNN+")); + } + + @Test + void invalidArchitectureReturns400WithFieldMapping() { + Map body = new HashMap<>(baseValidBody()); + body.put("arch", "i686"); + + RestAssured.given() + .contentType(ContentType.JSON) + .body(body) + .when() + .post(PATH) + .then() + .statusCode(400) + .body("errors.arch", equalTo("Architecture must be one of: x86_64, amd64, aarch64, arm64, ppc64le, s390x")); + } + + private static Map baseValidBody() { + return Map.of( + "name", "libarchive", + "version", "3.1.2", + "release", "14.el7_9.1", + "arch", "x86_64", + "cveId", VALID_CVE); + } +} diff --git a/src/test/java/com/redhat/ecosystemappeng/morpheus/rest/ReportListInputTypeRestTest.java b/src/test/java/com/redhat/ecosystemappeng/morpheus/rest/ReportListInputTypeRestTest.java new file mode 100644 index 00000000..b812dabd --- /dev/null +++ b/src/test/java/com/redhat/ecosystemappeng/morpheus/rest/ReportListInputTypeRestTest.java @@ -0,0 +1,144 @@ +package com.redhat.ecosystemappeng.morpheus.rest; + +import static org.hamcrest.Matchers.equalTo; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +/** + * {@code GET /api/v1/reports} filtering by {@code inputType} ({@link io.quarkus.test.junit.QuarkusTest} + devservices data). + */ +@QuarkusTest +class ReportListInputTypeRestTest { + + /** + * Seed: {@code src/test/resources/devservices/reports/rpm.json}; identify row by + * {@code input.image.target_package} NVR (not {@code scan.id}, which varies per document). + */ + static final String RPM_DEVSERVICES_PACKAGE_NVR = "libarchive-3.1.2-14.el7_9.1"; + /** Seed: {@code src/test/resources/devservices/reports/test-single-repo-1.json}. */ + static final String STANDALONE_REPOSITORY_SCAN_ID = "test-single-repo-1"; + + @BeforeEach + void restAssuredBase() { + RestApiTestFixture.configureRestAssuredIfExternal(); + } + + static List> listReports(Map queryParams) { + var req = RestAssured.given(); + queryParams.forEach((k, v) -> req.queryParam(k, v)); + return req.when() + .get("/api/v1/reports") + .then() + .statusCode(200) + .contentType(ContentType.JSON) + .extract() + .jsonPath() + .getList("$"); + } + + static boolean metadataHasProductId(Map reportRow) { + Object mdObj = reportRow.get("metadata"); + if (!(mdObj instanceof Map md)) { + return false; + } + Object pid = md.get("product_id"); + Object camel = md.get("productId"); + return (pid != null && !(pid instanceof String s && s.isEmpty())) + || (camel != null && !(camel instanceof String s2 && s2.isEmpty())); + } + + @Test + void listReports_inputTypeRpm_containsDevservicesRpmStandaloneRow_andAllRowsMatchFilter() { + List> reports = listReports( + Map.of("inputType", "rpm", "pageSize", 500)); + + Assertions.assertTrue( + reports.stream().anyMatch(r -> RPM_DEVSERVICES_PACKAGE_NVR.equals(r.get("rpmPackage"))), + "expected devservices rpm.json report in inputType=rpm results (match by target_package NVR)"); + + for (Map r : reports) { + Object pkg = r.get("rpmPackage"); + Assertions.assertTrue( + pkg instanceof String s && !s.isBlank(), + "expected RPM tab rows to expose rpmPackage (derived from target_package); rpmPackage=" + + r.get("rpmPackage")); + Assertions.assertFalse(metadataHasProductId(r), "rpmPackage=" + r.get("rpmPackage")); + } + } + + @Test + void listReports_inputTypeRepository_containsDevservicesStandaloneRepo_andExcludesRpmTabRow() { + List> reports = listReports( + Map.of("inputType", "repository", "pageSize", 500)); + + Assertions.assertTrue( + reports.stream().anyMatch(r -> STANDALONE_REPOSITORY_SCAN_ID.equals(r.get("scanId"))), + "expected test-single-repo-1 in inputType=repository results"); + + Assertions.assertTrue( + reports.stream().noneMatch(r -> RPM_DEVSERVICES_PACKAGE_NVR.equals(r.get("rpmPackage"))), + "RPM checker devservices row must not appear for inputType=repository"); + + for (Map r : reports) { + Object pkg = r.get("rpmPackage"); + Assertions.assertTrue( + !(pkg instanceof String s && !s.isBlank()), + "repository rows must not carry RPM package NVR from target_package; rpmPackage=" + + r.get("rpmPackage")); + Assertions.assertFalse(metadataHasProductId(r), "scanId=" + r.get("scanId")); + } + } + + @Test + void listReports_inputTypeInvalid_returns400() { + RestAssured.given() + .queryParam("inputType", "sbom") + .when() + .get("/api/v1/reports") + .then() + .statusCode(400) + .body("error", equalTo("inputType must be repository or rpm if provided")); + } + + /** Devservices {@code rpm.json} exposes {@code libarchive-3.1.2-14.el7_9.1}. */ + @Test + void listReports_inputTypeRpm_rpmPackageSubstring_matchesSeedRow() { + List> reports = listReports( + Map.of("inputType", "rpm", "pageSize", 500, "rpmPackage", "libarchive")); + + Assertions.assertTrue( + reports.stream().anyMatch(r -> RPM_DEVSERVICES_PACKAGE_NVR.equals(r.get("rpmPackage"))), + "expected rpmPackage filter to keep libarchive seed row (exact NVR from target_package)"); + } + + @Test + void listReports_inputTypeRpm_rpmPackageCrossFieldSubstring_matchesSeedRow() { + List> reports = listReports( + Map.of("inputType", "rpm", "pageSize", 500, "rpmPackage", "archive-3.1")); + + Assertions.assertTrue( + reports.stream().anyMatch(r -> RPM_DEVSERVICES_PACKAGE_NVR.equals(r.get("rpmPackage"))), + "substring spanning name into version must match joined N-V-R"); + } + + @Test + void listReports_inputTypeRpm_rpmPackageNoMatch_returnsEmptyResults() { + List> reports = listReports( + Map.of( + "inputType", "rpm", + "pageSize", 500, + "rpmPackage", "zzzzz-no-matching-package-nvr-substring")); + + Assertions.assertEquals(0, reports.size(), "only one seeded RPM row exists; filter must exclude it"); + } +} + diff --git a/src/test/resources/devservices/reports/rpm.json b/src/test/resources/devservices/reports/rpm.json new file mode 100644 index 00000000..c3bb3220 --- /dev/null +++ b/src/test/resources/devservices/reports/rpm.json @@ -0,0 +1,683 @@ +{ + "ai_usage_notice": "This report was generated by AI; check results before using.", + "_id": { + "$oid": "6a145739818de760286aeaf6" + }, + "input": { + "scan": { + "id": "ea2c8e14-ba86-4cc1-9dee-5deca984be81", + "type": null, + "started_at": "2026-05-25T14:05:45.459964+00:00", + "completed_at": "2026-05-25T14:07:27.520935+00:00", + "vulns": [ + { + "vuln_id": "CVE-2016-8687", + "description": null, + "score": null, + "severity": null, + "published_date": null, + "last_modified_date": null, + "url": null, + "feed_group": null, + "package": null, + "package_version": null, + "package_name": null, + "package_type": null + } + ] + }, + "image": { + "analysis_type": "source", + "pipeline_mode": "rpm_package_checker", + "target_package": { + "name": "libarchive", + "version": "3.1.2", + "release": "14.el7_9.1", + "arch": "x86_64", + "ecosystem": "rpm" + } + } + }, + "metadata": { + "user": "anonymous", + "submitted_at": { + "$date": "2026-05-25T14:05:45.448Z" + }, + "sent_at": { + "$date": "2026-05-25T14:05:45.452Z" + } + }, + "info": { + "vdb": { + "code_vdb_path": null, + "doc_vdb_path": null, + "code_index_path": ".cache/am_cache/code_index/tantivy/9253d5a8f6777cb2" + }, + "intel": [ + { + "vuln_id": "CVE-2016-8687", + "ghsa": { + "ghsa_id": "GHSA-c42q-jv3x-rq38", + "cve_id": "CVE-2016-8687", + "summary": "Stack-based buffer overflow in the safe_fprintf function in tar/util.c in libarchive 3.2.1 allows...", + "description": "Stack-based buffer overflow in the safe_fprintf function in tar/util.c in libarchive 3.2.1 allows remote attackers to cause a denial of service via a crafted non-printable multibyte character in a filename.", + "severity": "high", + "vulnerabilities": [], + "cvss": { + "score": 7.5, + "vector_string": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" + }, + "cwes": [ + { + "cwe_id": "CWE-119", + "name": "Improper Restriction of Operations within the Bounds of a Memory Buffer" + } + ], + "published_at": "2022-05-14T01:54:41Z", + "updated_at": "2025-04-20T03:33:58Z", + "url": "https://api.github.com/advisories/GHSA-c42q-jv3x-rq38", + "html_url": "https://github.com/advisories/GHSA-c42q-jv3x-rq38", + "type": "unreviewed", + "repository_advisory_url": null, + "source_code_location": "", + "identifiers": [ + { + "value": "GHSA-c42q-jv3x-rq38", + "type": "GHSA" + }, + { + "value": "CVE-2016-8687", + "type": "CVE" + } + ], + "references": [ + "https://nvd.nist.gov/vuln/detail/CVE-2016-8687", + "https://github.com/libarchive/libarchive/commit/e37b620fe8f14535d737e89a4dcabaed4517bf1a", + "https://bugzilla.redhat.com/show_bug.cgi?id=1377926", + "https://lists.debian.org/debian-lts-announce/2018/11/msg00037.html", + "https://security.gentoo.org/glsa/201701-03", + "http://lists.opensuse.org/opensuse-updates/2016-12/msg00027.html", + "http://www.openwall.com/lists/oss-security/2016/10/16/11", + "http://www.securityfocus.com/bid/93781", + "http://www.securitytracker.com/id/1037668", + "https://blogs.gentoo.org/ago/2016/09/11/libarchive-bsdtar-stack-based-buffer-overflow-in-bsdtar_expand_char-util-c", + "https://github.com/advisories/GHSA-c42q-jv3x-rq38" + ], + "github_reviewed_at": null, + "nvd_published_at": "2017-02-15T19:59:00Z", + "withdrawn_at": null, + "cvss_severities": { + "cvss_v3": { + "vector_string": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "score": 7.5 + }, + "cvss_v4": { + "vector_string": null, + "score": 0 + } + }, + "credits": [], + "epss": { + "percentage": 0.01379, + "percentile": 0.80505 + } + }, + "nvd": { + "cve_id": "CVE-2016-8687", + "cve_description": "Stack-based buffer overflow in the safe_fprintf function in tar/util.c in libarchive 3.2.1 allows remote attackers to cause a denial of service via a crafted non-printable multibyte character in a filename.", + "cvss_vector": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "cvss_base_score": 7.5, + "cvss_severity": "HIGH", + "cwe_id": "CWE-119", + "cwe_name": "CWE-119: Improper Restriction of Operations within the Bounds of a Memory Buffer (4.20)", + "cwe_description": "The product performs operations on a memory buffer, but it reads from or writes to a memory location outside the buffer's intended boundary. This may result in read or write operations on unexpected memory locations that could be linked to other variables, data structures, or internal program data.", + "cwe_extended_description": null, + "configurations": [ + { + "package": "libarchive", + "vendor": "libarchive", + "system": null, + "versionStartExcluding": null, + "versionEndExcluding": null, + "versionStartIncluding": "3.2.1", + "versionEndIncluding": "3.2.1" + }, + { + "package": "leap", + "vendor": "opensuse", + "system": null, + "versionStartExcluding": null, + "versionEndExcluding": null, + "versionStartIncluding": "42.2", + "versionEndIncluding": "42.2" + } + ], + "vendor_names": [ + "Libarchive", + "Opensuse" + ], + "references": [ + "http://lists.opensuse.org/opensuse-updates/2016-12/msg00027.html", + "http://www.openwall.com/lists/oss-security/2016/10/16/11", + "http://www.securityfocus.com/bid/93781", + "http://www.securitytracker.com/id/1037668", + "https://blogs.gentoo.org/ago/2016/09/11/libarchive-bsdtar-stack-based-buffer-overflow-in-bsdtar_expand_char-util-c/", + "https://bugzilla.redhat.com/show_bug.cgi?id=1377926", + "https://github.com/libarchive/libarchive/commit/e37b620fe8f14535d737e89a4dcabaed4517bf1a", + "https://lists.debian.org/debian-lts-announce/2018/11/msg00037.html", + "https://security.gentoo.org/glsa/201701-03", + "http://lists.opensuse.org/opensuse-updates/2016-12/msg00027.html", + "http://www.openwall.com/lists/oss-security/2016/10/16/11", + "http://www.securityfocus.com/bid/93781", + "http://www.securitytracker.com/id/1037668", + "https://blogs.gentoo.org/ago/2016/09/11/libarchive-bsdtar-stack-based-buffer-overflow-in-bsdtar_expand_char-util-c/", + "https://bugzilla.redhat.com/show_bug.cgi?id=1377926", + "https://github.com/libarchive/libarchive/commit/e37b620fe8f14535d737e89a4dcabaed4517bf1a", + "https://lists.debian.org/debian-lts-announce/2018/11/msg00037.html", + "https://security.gentoo.org/glsa/201701-03" + ], + "disputed": false, + "published_at": "2017-02-15T19:59:00.580", + "updated_at": "2026-05-13T00:24:29.033" + }, + "rhsa": { + "bugzilla": { + "description": "libarchive: stack based buffer overflow in bsdtar_expand_char (util.c)", + "id": "1377926", + "url": "https://bugzilla.redhat.com/show_bug.cgi?id=1377926" + }, + "details": [ + "Stack-based buffer overflow in the safe_fprintf function in tar/util.c in libarchive 3.2.1 allows remote attackers to cause a denial of service via a crafted non-printable multibyte character in a filename." + ], + "statement": "Red Hat Product Security has rated this issue as having Low security impact. This issue is not currently planned to be addressed in future updates. For additional information, refer to the Issue Severity Classification: https://access.redhat.com/security/updates/classification/.", + "package_state": [ + { + "product_name": "Red Hat Enterprise Linux 6", + "fix_state": "Not affected", + "package_name": "libarchive", + "cpe": "cpe:/o:redhat:enterprise_linux:6" + }, + { + "product_name": "Red Hat Enterprise Linux 7", + "fix_state": "Will not fix", + "package_name": "libarchive", + "cpe": "cpe:/o:redhat:enterprise_linux:7" + } + ], + "upstream_fix": null, + "cvss3": { + "cvss3_base_score": 3.3, + "cvss3_scoring_vector": "CVSS:3.0/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L", + "status": "draft" + }, + "threat_severity": "Low", + "public_date": "2016-09-15T00:00:00Z", + "cvss": { + "cvss_base_score": "1.9", + "cvss_scoring_vector": "AV:L/AC:M/Au:N/C:N/I:N/A:P", + "status": "draft" + }, + "cwe": "CWE-131", + "references": [ + "https://www.cve.org/CVERecord?id=CVE-2016-8687\nhttps://nvd.nist.gov/vuln/detail/CVE-2016-8687" + ], + "name": "CVE-2016-8687", + "csaw": false + }, + "ubuntu": { + "description": "\nStack-based buffer overflow in the safe_fprintf function in tar/util.c in\nlibarchive 3.2.1 allows remote attackers to cause a denial of service via a\ncrafted non-printable multibyte character in a filename.", + "notes": [], + "notices": [ + { + "id": "USN-3225-1", + "title": "libarchive vulnerabilities", + "summary": "libarchive could be made to crash, overwrite files, or run programs as your\nlogin if it opened a specially crafted file.\n", + "instructions": "In general, a standard system update will make all the necessary changes.\n", + "references": [], + "published": "2017-03-09T18:41:10.874589", + "description": "It was discovered that libarchive incorrectly handled hardlink entries when\nextracting archives. A remote attacker could possibly use this issue to\noverwrite arbitrary files. (CVE-2016-5418)\n\nChristian Wressnegger, Alwin Maier, and Fabian Yamaguchi discovered that\nlibarchive incorrectly handled filename lengths when writing ISO9660\narchives. A remote attacker could use this issue to cause libarchive to\ncrash, resulting in a denial of service, or possibly execute arbitrary\ncode. This issue only applied to Ubuntu 12.04 LTS, Ubuntu 14.04 LTS and\nUbuntu 16.04 LTS. (CVE-2016-6250)\n\nAlexander Cherepanov discovered that libarchive incorrectly handled\nrecursive decompressions. A remote attacker could possibly use this issue\nto cause libarchive to hang, resulting in a denial of service. This issue\nonly applied to Ubuntu 12.04 LTS, Ubuntu 14.04 LTS and Ubuntu 16.04 LTS.\n(CVE-2016-7166)\n\nIt was discovered that libarchive incorrectly handled non-printable\nmultibyte characters in filenames. A remote attacker could possibly use\nthis issue to cause libarchive to crash, resulting in a denial of service.\n(CVE-2016-8687)\n\nIt was discovered that libarchive incorrectly handled line sizes when\nextracting certain archives. A remote attacker could possibly use this\nissue to cause libarchive to crash, resulting in a denial of service.\n(CVE-2016-8688)\n\nIt was discovered that libarchive incorrectly handled multiple EmptyStream\nattributes when extracting certain 7zip archives. A remote attacker could\npossibly use this issue to cause libarchive to crash, resulting in a denial\nof service. (CVE-2016-8689)\n\nJakub Jirasek discovered that libarchive incorrectly handled memory when\nextracting certain archives. A remote attacker could possibly use this\nissue to cause libarchive to crash, resulting in a denial of service.\n(CVE-2017-5601)\n", + "is_hidden": false, + "release_packages": { + "precise": [ + { + "name": "libarchive", + "version": "3.0.3-6ubuntu1.4", + "description": "Library to read/write archive files", + "is_source": true + }, + { + "name": "libarchive12", + "version": "3.0.3-6ubuntu1.4", + "is_source": false, + "is_visible": true, + "source_link": "https://launchpad.net/ubuntu/+source/libarchive", + "version_link": "https://launchpad.net/ubuntu/+source/libarchive/3.0.3-6ubuntu1.4" + } + ], + "trusty": [ + { + "name": "libarchive", + "version": "3.1.2-7ubuntu2.4", + "description": "Library to read/write archive files", + "is_source": true + }, + { + "name": "bsdcpio", + "version": "3.1.2-7ubuntu2.4", + "is_source": false, + "is_visible": false, + "source_link": "https://launchpad.net/ubuntu/+source/libarchive", + "version_link": "https://launchpad.net/ubuntu/+source/libarchive/3.1.2-7ubuntu2.4", + "pocket": "security" + }, + { + "name": "bsdtar", + "version": "3.1.2-7ubuntu2.4", + "is_source": false, + "is_visible": false, + "source_link": "https://launchpad.net/ubuntu/+source/libarchive", + "version_link": "https://launchpad.net/ubuntu/+source/libarchive/3.1.2-7ubuntu2.4", + "pocket": "security" + }, + { + "name": "libarchive-dev", + "version": "3.1.2-7ubuntu2.4", + "is_source": false, + "is_visible": false, + "source_link": "https://launchpad.net/ubuntu/+source/libarchive", + "version_link": "https://launchpad.net/ubuntu/+source/libarchive/3.1.2-7ubuntu2.4", + "pocket": "security" + }, + { + "name": "libarchive13", + "version": "3.1.2-7ubuntu2.4", + "is_source": false, + "is_visible": true, + "source_link": "https://launchpad.net/ubuntu/+source/libarchive", + "version_link": "https://launchpad.net/ubuntu/+source/libarchive/3.1.2-7ubuntu2.4", + "pocket": "security" + } + ], + "xenial": [ + { + "name": "libarchive", + "version": "3.1.2-11ubuntu0.16.04.3", + "description": "Library to read/write archive files", + "is_source": true + }, + { + "name": "bsdcpio", + "version": "3.1.2-11ubuntu0.16.04.3", + "is_source": false, + "is_visible": false, + "source_link": "https://launchpad.net/ubuntu/+source/libarchive", + "version_link": "https://launchpad.net/ubuntu/+source/libarchive/3.1.2-11ubuntu0.16.04.3", + "pocket": "security" + }, + { + "name": "bsdtar", + "version": "3.1.2-11ubuntu0.16.04.3", + "is_source": false, + "is_visible": false, + "source_link": "https://launchpad.net/ubuntu/+source/libarchive", + "version_link": "https://launchpad.net/ubuntu/+source/libarchive/3.1.2-11ubuntu0.16.04.3", + "pocket": "security" + }, + { + "name": "libarchive-dev", + "version": "3.1.2-11ubuntu0.16.04.3", + "is_source": false, + "is_visible": false, + "source_link": "https://launchpad.net/ubuntu/+source/libarchive", + "version_link": "https://launchpad.net/ubuntu/+source/libarchive/3.1.2-11ubuntu0.16.04.3", + "pocket": "security" + }, + { + "name": "libarchive13", + "version": "3.1.2-11ubuntu0.16.04.3", + "is_source": false, + "is_visible": true, + "source_link": "https://launchpad.net/ubuntu/+source/libarchive", + "version_link": "https://launchpad.net/ubuntu/+source/libarchive/3.1.2-11ubuntu0.16.04.3", + "pocket": "security" + } + ], + "yakkety": [ + { + "name": "libarchive", + "version": "3.2.1-2ubuntu0.1", + "description": "Library to read/write archive files", + "is_source": true + }, + { + "name": "libarchive13", + "version": "3.2.1-2ubuntu0.1", + "is_source": false, + "is_visible": true, + "source_link": "https://launchpad.net/ubuntu/+source/libarchive", + "version_link": "https://launchpad.net/ubuntu/+source/libarchive/3.2.1-2ubuntu0.1" + } + ] + }, + "type": "USN", + "cves_ids": [ + "CVE-2016-5418", + "CVE-2016-6250", + "CVE-2016-7166", + "CVE-2016-8687", + "CVE-2016-8688", + "CVE-2016-8689", + "CVE-2017-5601" + ] + } + ], + "priority": "low", + "ubuntu_description": "", + "impact": { + "baseMetricV3": { + "cvssV3": { + "attackComplexity": "LOW", + "attackVector": "NETWORK", + "availabilityImpact": "HIGH", + "baseScore": 7.5, + "baseSeverity": "HIGH", + "confidentialityImpact": "NONE", + "integrityImpact": "NONE", + "privilegesRequired": "NONE", + "scope": "UNCHANGED", + "userInteraction": "NONE", + "vectorString": "CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "version": "3.0" + }, + "exploitabilityScore": null, + "impactScore": null + } + }, + "patches": { + "libarchive": [ + "upstream: https://github.com/libarchive/libarchive/commit/e37b620fe8f14535d737e89a4dcabaed4517bf1a" + ] + }, + "id": "CVE-2016-8687", + "published": "2017-02-15T00:00:00", + "updated_at": "2025-08-25T22:12:34.883473+00:00", + "codename": null, + "cvss3": 7.5, + "status": "active", + "mitigation": "", + "references": [ + "https://blogs.gentoo.org/ago/2016/09/11/libarchive-bsdtar-stack-based-buffer-overflow-in-bsdtar_expand_char-util-c/", + "https://ubuntu.com/security/notices/USN-3225-1", + "https://www.cve.org/CVERecord?id=CVE-2016-8687" + ], + "bugs": [ + "http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=840936", + "https://github.com/libarchive/libarchive/issues/767" + ], + "tags": { + "libarchive": [ + "stack-protector" + ] + }, + "packages": [ + { + "name": "libarchive", + "source": "https://ubuntu.com/security/cve?package=libarchive", + "ubuntu": "https://packages.ubuntu.com/search?suite=all§ion=all&arch=any&searchon=sourcenames&keywords=libarchive", + "debian": "https://tracker.debian.org/pkg/libarchive", + "statuses": [ + { + "release_codename": "precise", + "status": "released", + "description": "3.0.3-6ubuntu1.4", + "component": null, + "pocket": "security" + }, + { + "release_codename": "trusty", + "status": "released", + "description": "3.1.2-7ubuntu2.4", + "component": null, + "pocket": "security" + }, + { + "release_codename": "upstream", + "status": "released", + "description": "3.2.1-5", + "component": null, + "pocket": "security" + }, + { + "release_codename": "xenial", + "status": "released", + "description": "3.1.2-11ubuntu0.16.04.3", + "component": null, + "pocket": "security" + }, + { + "release_codename": "yakkety", + "status": "released", + "description": "3.2.1-2ubuntu0.1", + "component": null, + "pocket": "security" + } + ] + } + ], + "notices_ids": [ + "USN-3225-1" + ] + }, + "epss": { + "epss": 0.01379, + "percentile": 0.80506, + "date": "2026-05-25", + "cve": "CVE-2016-8687" + }, + "plugin_data": [ + { + "label": "Product Security research", + "description": "No data available for this intel source" + } + ], + "intel_score": 76, + "has_sufficient_intel_for_agent": true + } + ], + "sbom": null, + "vulnerable_dependencies": null, + "checker_context": { + "status": 0, + "source_key": "9253d5a8f6777cb2", + "artifacts": { + "srpm_path": ".cache/am_cache/checker/9253d5a8f6777cb2/source", + "source_dir": null, + "build_log_path": ".cache/am_cache/checker/9253d5a8f6777cb2/logs/x86_64/build.log", + "binary_rpm_path": ".cache/am_cache/checker/9253d5a8f6777cb2/binaries", + "patch_source_dir": null, + "patch_diff_path": null, + "source_url": "https://download-01.beak-001.prod.iad2.dc.redhat.com/brewroot/vol/rhel-7/packages/libarchive/3.1.2/14.el7_9.1/src/libarchive-3.1.2-14.el7_9.1.src.rpm" + }, + "identify_result": { + "affected_rpm_list": [ + "libarchive" + ], + "fixed_rpm_list": [], + "is_target_package_affected": "yes", + "is_target_package_fixed": "unknown", + "conclusion_reason": "" + }, + "l1_result": { + "downstream_report": { + "is_patch_file_available": false, + "patch_file_name": "", + "is_patch_in_spec_file": false, + "spec_file_log_change": "", + "is_patch_applied_in_build": false, + "build_log_patch_applied": "", + "spec_patch_directives_for_cve": [], + "spec_changelog_cve_lines": "", + "spec_source0_line": "Source0: http://www.libarchive.org/downloads/%{name}-%{version}.tar.gz", + "spec_version_line": "Version: 3.1.2", + "parsed_patch": null + }, + "upstream_report": { + "is_fixed_srpm_is_needed": true, + "fixed_srpm_file_name": "https://github.com/libarchive/libarchive/commit/e37b620fe8f14535d737e89a4dcabaed4517bf1a.patch", + "fixed_parsed_patch": { + "patch_filename": "CVE-2016-8687_e37b620f.patch", + "files": [ + { + "source_path": "a/tar/util.c", + "target_path": "b/tar/util.c", + "hunks": [ + { + "source_start": 182, + "source_length": 7, + "target_start": 182, + "target_length": 7, + "context_lines": [ + "\t\t}", + "", + "\t\t/* If our output buffer is full, dump it and keep going. */", + "\t\t\toutbuff[i] = '\\0';", + "\t\t\tfprintf(f, \"%s\", outbuff);", + "\t\t\ti = 0;" + ], + "removed_lines": [ + "\t\tif (i > (sizeof(outbuff) - 20)) {" + ], + "added_lines": [ + "\t\tif (i > (sizeof(outbuff) - 128)) {" + ] + } + ], + "is_new_file": false, + "is_deleted_file": false + } + ] + }, + "reference_package_nvr": "", + "reason_cve_code": "", + "is_code_fixed_by_rebase": "unknown", + "spec_file_log_change": "", + "spec_fixed_srpm_change": "", + "reason_code_fixed_by_rebase": "", + "osv_result": { + "cve_id": "CVE-2016-8687", + "fixed_commit": "e37b620f", + "repo_url": "https://github.com/libarchive/libarchive", + "patch_url": "https://github.com/libarchive/libarchive/commit/e37b620fe8f14535d737e89a4dcabaed4517bf1a.patch", + "patch_content": "From e37b620fe8f14535d737e89a4dcabaed4517bf1a Mon Sep 17 00:00:00 2001\nFrom: Tim Kientzle \nDate: Sun, 21 Aug 2016 10:51:43 -0700\nSubject: [PATCH] Issue #767: Buffer overflow printing a filename\n\nThe safe_fprintf function attempts to ensure clean output for an\narbitrary sequence of bytes by doing a trial conversion of the\nmultibyte characters to wide characters -- if the resulting wide\ncharacter is printable then we pass through the corresponding bytes\nunaltered, otherwise, we convert them to C-style ASCII escapes.\n\nThe stack trace in Issue #767 suggest that the 20-byte buffer\nwas getting overflowed trying to format a non-printable multibyte\ncharacter. This should only happen if there is a valid multibyte\ncharacter of more than 5 bytes that was unprintable. (Each byte\nwould get expanded to a four-charcter octal-style escape of the form\n\"\\123\" resulting in >20 characters for the >5 byte multibyte character.)\n\nI've not been able to reproduce this, but have expanded the conversion\nbuffer to 128 bytes on the belief that no multibyte character set\nhas a single character of more than 32 bytes.\n---\n tar/util.c | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n\ndiff --git a/tar/util.c b/tar/util.c\nindex 9ff22f2b61..2b4aebe8e6 100644\n--- a/tar/util.c\n+++ b/tar/util.c\n@@ -182,7 +182,7 @@ safe_fprintf(FILE *f, const char *fmt, ...)\n \t\t}\n \n \t\t/* If our output buffer is full, dump it and keep going. */\n-\t\tif (i > (sizeof(outbuff) - 20)) {\n+\t\tif (i > (sizeof(outbuff) - 128)) {\n \t\t\toutbuff[i] = '\\0';\n \t\t\tfprintf(f, \"%s\", outbuff);\n \t\t\ti = 0;\n", + "parsed_patch": { + "patch_filename": "CVE-2016-8687_e37b620f.patch", + "files": [ + { + "source_path": "a/tar/util.c", + "target_path": "b/tar/util.c", + "hunks": [ + { + "source_start": 182, + "source_length": 7, + "target_start": 182, + "target_length": 7, + "context_lines": [ + "\t\t}", + "", + "\t\t/* If our output buffer is full, dump it and keep going. */", + "\t\t\toutbuff[i] = '\\0';", + "\t\t\tfprintf(f, \"%s\", outbuff);", + "\t\t\ti = 0;" + ], + "removed_lines": [ + "\t\tif (i > (sizeof(outbuff) - 20)) {" + ], + "added_lines": [ + "\t\tif (i > (sizeof(outbuff) - 128)) {" + ] + } + ], + "is_new_file": false, + "is_deleted_file": false + } + ] + }, + "commit_message": "Issue #767: Buffer overflow printing a filename", + "commit_author": "Tim Kientzle ", + "commit_date": "Sun, 21 Aug 2016 10:51:43 -0700", + "source": "ubuntu_patches", + "url_type": "commit", + "platform": "github" + } + }, + "l1_agent_answer": "The package is VULNERABLE. Found vulnerable code pattern at tar/util.c:184: if (i > (sizeof(outbuff) - 20)). The fix from the patched version is NOT present - searched for if (i > (sizeof(outbuff) - 128)) with no matches. The target package lacks the security fix.", + "vulnerability_intel": { + "affected_files": [ + "tar/util.c" + ], + "vulnerable_functions": [ + "safe_fprintf" + ], + "vulnerable_variables": [ + "outbuff" + ], + "vulnerable_patterns": [ + "if (i > (sizeof(outbuff) - 20))" + ], + "fix_patterns": [ + "if (i > (sizeof(outbuff) - 128))" + ], + "root_cause": "The code is vulnerable due to a stack-based buffer overflow in the safe_fprintf function, caused by a crafted non-printable multibyte character in a filename.", + "vulnerability_type": "buffer_overflow", + "search_keywords": [ + "safe_fprintf", + "outbuff", + "tar/util.c", + "libarchive" + ], + "affected_bitness": "both", + "affected_architectures": null, + "is_downstream_patch_available": false, + "is_patch_applied_in_build": false, + "patch_file_name": "", + "known_mitigations": "" + }, + "preliminary_verdict": "vulnerable", + "confidence": 1 + }, + "l2_result": { + "compilation_status": "compiled", + "compilation_confidence": 1, + "compilation_evidence": "Compilation evidence confirms the vulnerable code is compiled into the binary.", + "hardening_relevant": true, + "hardening_flags": [ + "-D_FORTIFY_SOURCE=2" + ], + "hardening_rationale": "Found -D_FORTIFY_SOURCE=2 which is listed in EXPECTED_HARDENING for CWE-119, improving memory safety.", + "l2_override_verdict": "vulnerable_mitigated", + "evidence_sources": [ + "build_log", + "spec_file" + ] + } + } + }, + "output": { + "analysis": [ + { + "vuln_id": "CVE-2016-8687", + "checklist": [], + "summary": "The libarchive package is vulnerable to a stack-based buffer overflow in the safe_fprintf function in tar/util.c. However, the build agent found that the FORTIFY_SOURCE=2 hardening flag mitigates the vulnerability, preventing buffer overflow exploitation. The code agent analysis confirmed the presence of vulnerable code patterns in the source, but the build agent's hardening flags negate the exploitability. The package is compiled with the vulnerable code, but the hardening flags provide sufficient protection. The build agent's override verdict is 'vulnerable_mitigated', indicating that the vulnerability is mitigated by the hardening flags.", + "justification": { + "label": "protected_by_compiler", + "reason": "Labeled protected by compiler because the build agent found compiler hardening that mitigates exploitation of this flaw.", + "status": "FALSE" + }, + "intel_score": 76, + "cvss": null, + "details": "---\n\n## CVE fix search on target package (libarchive-3.1.2-14.el7_9.1 (x86_64))\n\n- **Patch file on target:** **Fail** — _not found_\n- **Referenced in spec:** **Fail** — _not referenced_\n- **Applied in build:** **Fail** — _not observed in build log_\n\n> **Note:** Target fix search (0/3): no CVE-named patch file, spec reference, or build-time application found on the target package.\n\n- **Spec version line:** `Version: 3.1.2`\n- **Spec Source0 line:** `Source0: http://www.libarchive.org/downloads/%{name}-%{version}.tar.gz`\n> **Note:** Version/Source0 describe the upstream snapshot named in the spec; they may differ from the tree used for source review.\n\n---\n\n## Fix clues from advisories and reference builds\n\n- **Reference patch source:** `https://github.com/libarchive/libarchive/commit/e37b620fe8f14535d737e89a4dcabaed4517bf1a.patch`\n- **Patch file:** `CVE-2016-8687_e37b620f.patch`\n- **File in patch:** `tar/util.c`\n - Removed: `if (i > (sizeof(outbuff) - 20)) {`\n - Added: `if (i > (sizeof(outbuff) - 128)) {`\n- **Fetched patch URL:** https://github.com/libarchive/libarchive/commit/e37b620fe8f14535d737e89a4dcabaed4517bf1a.patch\n- **Fixed commit:** `e37b620f`\n- **Patch source:** ubuntu_patches\n\n---\n\n## Target source check\n\n- **Code agent summary:** The package is VULNERABLE. Found vulnerable code pattern at tar/util.c:184: if (i > (sizeof(outbuff) - 20)). The fix from the patched version is NOT present - searched for if (i > (sizeof(outbuff) - 128)) with no matches. The target package lacks the security fix.\n- **Affected source files:**\n - `tar/util.c`\n- **Pattern before fix (reference or target):**\n### `tar/util.c` (line 182)\n```c\nif (i > (sizeof(outbuff) - 20)) {\n```\n\n- **Fix pattern (reference or expected on target):**\n### `tar/util.c` (line 182)\n```c\nif (i > (sizeof(outbuff) - 128)) {\n```\n\n- Code analysis findings: The code agent found vulnerable code patterns in tar/util.c, but the build agent's hardening flags mitigate the vulnerability.\n\n---\n\n## Target build check\n\n- **Compilation:** compiled\n- **Compilation evidence:** Compilation evidence confirms the vulnerable code is compiled into the binary.\n- **Compiler hardening:** `-D_FORTIFY_SOURCE=2`\n- **Hardening rationale:** Found -D_FORTIFY_SOURCE=2 which is listed in EXPECTED_HARDENING for CWE-119, improving memory safety.\n- **Build agent override:** `vulnerable_mitigated`" + } + ], + "vex": null + } +} \ No newline at end of file