openapi: 3.1.0
info:
  title: DevRadar API
  description: >-
    DevRadar tracks how a container image's vulnerabilities and licenses change
    over time by rescanning its SBOM. Submit an SBOM pinned to an image digest;
    DevRadar rescans it on a schedule and records every change as an event.


    All endpoints are authenticated with an API token (`Authorization: Bearer
    dr_...`) minted in the UI. Responses are JSON: success bodies are objects;
    list endpoints add a `next_cursor` when more pages exist; errors are
    `{"error": "..."}`. This document is the source of truth for the API
    contract — the human-readable version is at `/api`.
  version: 0.8.0
servers:
  - url: https://devradar.thingz.io
    description: DevRadar SaaS
  - url: http://localhost:8080
    description: Local development

security:
  - bearerAuth: []

tags:
  - name: SBOMs
    description: Submit and manage SBOMs
  - name: Images
    description: Tracked images and their history
  - name: Findings
    description: Current findings and the change log
  - name: Licenses
    description: License inventory and compliance
  - name: VEX
    description: OpenVEX assertions
  - name: System
    description: Health and metadata

paths:
  /health:
    get:
      tags: [System]
      summary: Liveness probe
      description: Unauthenticated. Returns 200 with body `ok` when the service is up.
      security: []
      responses:
        "200":
          description: Service is healthy
          content:
            text/plain:
              schema: { type: string, example: ok }

  /v1/sboms:
    post:
      tags: [SBOMs]
      summary: Submit an SBOM
      description: >-
        Ingests a base64-encoded SBOM (CycloneDX or SPDX; gzip allowed within
        the size cap). The image digest is extracted from the SBOM, or from
        `image_ref` when the SBOM was generated by tag. Idempotent per
        (tenant, digest, format) — re-submitting returns the existing row with
        `existing: true`. Max 20 MiB decoded.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubmitRequest"
      responses:
        "202":
          description: SBOM accepted (or already existed)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubmitResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "422":
          description: SBOM could not be parsed or no image digest could be resolved
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/images:
    get:
      tags: [Images]
      summary: List tracked images
      description: >-
        Returns the tenant's images grouped by repository (one row per image),
        risk-ranked. `min_severity` trims each per-severity breakdown; the
        overall `total` is unaffected.
      parameters:
        - { $ref: "#/components/parameters/MinSeverity" }
        - name: q
          in: query
          description: Filter by repository name substring
          schema: { type: string }
        - name: label
          in: query
          description: Filter to images carrying this grouping label
          schema: { type: string }
        - { $ref: "#/components/parameters/Sort" }
        - { $ref: "#/components/parameters/Dir" }
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: A page of images
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      min_severity: { type: string }
                      images:
                        type: array
                        items: { $ref: "#/components/schemas/RepoImage" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/images/timeline:
    get:
      tags: [Images]
      summary: Image change history across all digests
      description: >-
        The change log for one image (`repo`) across every version and digest,
        newest first. The legacy `ref` param (exact image_ref) is still honored.
      parameters:
        - name: repo
          in: query
          description: Repository (registry/path). Preferred.
          schema: { type: string }
        - name: ref
          in: query
          description: Legacy — exact image reference (unpaginated).
          schema: { type: string }
        - { $ref: "#/components/parameters/MinSeverity" }
        - { $ref: "#/components/parameters/Sort" }
        - { $ref: "#/components/parameters/Dir" }
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: A page of timeline events
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      repository: { type: string }
                      min_severity: { type: string }
                      timeline:
                        type: array
                        items: { $ref: "#/components/schemas/TimelineEvent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/images/sboms:
    get:
      tags: [Images]
      summary: List SBOMs for one image
      description: The submitted SBOMs (versions/digests) for a repository, newest generation first.
      parameters:
        - name: repo
          in: query
          required: true
          description: Repository (registry/path)
          schema: { type: string }
        - { $ref: "#/components/parameters/Sort" }
        - { $ref: "#/components/parameters/Dir" }
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: A page of SBOMs
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      repository: { type: string }
                      sboms:
                        type: array
                        items: { $ref: "#/components/schemas/Image" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/sboms/{id}:
    get:
      tags: [SBOMs]
      summary: Get SBOM metadata
      description: Metadata and severity breakdown for one SBOM. `min_severity` trims the breakdown.
      parameters:
        - { $ref: "#/components/parameters/SBOMId" }
        - { $ref: "#/components/parameters/MinSeverity" }
      responses:
        "200":
          description: SBOM metadata
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SBOMDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [SBOMs]
      summary: Archive an SBOM
      description: >-
        Stops tracking an SBOM: it drops from the scan set and image list;
        findings and history are retained. Idempotent.
      parameters:
        - { $ref: "#/components/parameters/SBOMId" }
      responses:
        "204": { description: Archived (or already archived) }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sboms/{id}/findings:
    get:
      tags: [Findings]
      summary: Current findings for an SBOM
      description: >-
        Current findings at or above `min_severity` (unknown always included),
        keyset-paginated. EPSS/KEV risk overlays are joined when available.
      parameters:
        - { $ref: "#/components/parameters/SBOMId" }
        - { $ref: "#/components/parameters/MinSeverity" }
        - name: fixable
          in: query
          description: When `true`, only findings with a fix available.
          schema: { type: boolean }
        - name: suppressed
          in: query
          description: When `true`, include VEX-suppressed findings (hidden by default).
          schema: { type: boolean }
        - { $ref: "#/components/parameters/Sort" }
        - { $ref: "#/components/parameters/Dir" }
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: A page of findings
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      min_severity: { type: string }
                      findings:
                        type: array
                        items: { $ref: "#/components/schemas/Finding" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sboms/{id}/events:
    get:
      tags: [Findings]
      summary: Change history for an SBOM
      description: The change log for one SBOM at or above `min_severity`, newest first, keyset-paginated.
      parameters:
        - { $ref: "#/components/parameters/SBOMId" }
        - { $ref: "#/components/parameters/MinSeverity" }
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: A page of change events
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      min_severity: { type: string }
                      events:
                        type: array
                        items: { $ref: "#/components/schemas/Event" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sboms/{id}/failures:
    get:
      tags: [Findings]
      summary: Scan issues for an SBOM
      description: >-
        Recent scan failures (a scanner errored or returned nothing) for one
        SBOM, newest first — so a silently-absent scanner is visible.
      parameters:
        - { $ref: "#/components/parameters/SBOMId" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: Scan failures
          content:
            application/json:
              schema:
                type: object
                properties:
                  failures:
                    type: array
                    items: { $ref: "#/components/schemas/Failure" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/sboms/{id}/licenses:
    get:
      tags: [Licenses]
      summary: Package licenses for an SBOM
      description: >-
        Per-package license inventory for one SBOM, each classified into an
        obligation category and evaluated against the tenant's compliance
        policy. Violations are listed first.
      parameters:
        - { $ref: "#/components/parameters/SBOMId" }
      responses:
        "200":
          description: Packages with licenses and policy verdicts
          content:
            application/json:
              schema:
                type: object
                properties:
                  packages:
                    type: array
                    items: { $ref: "#/components/schemas/PackageLicense" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/licenses:
    get:
      tags: [Licenses]
      summary: Fleet license rollup
      description: >-
        The tenant's fleet-wide license landscape: distribution by license
        family and obligation category, plus unlicensed and policy-violation
        counts across all active SBOMs.
      responses:
        "200":
          description: Fleet license statistics
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FleetLicenseStats" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/vex:
    get:
      tags: [VEX]
      summary: List VEX documents
      description: The tenant's submitted OpenVEX documents (metadata only).
      responses:
        "200":
          description: VEX documents
          content:
            application/json:
              schema:
                type: object
                properties:
                  documents:
                    type: array
                    items: { type: object, additionalProperties: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [VEX]
      summary: Submit a VEX document
      description: >-
        Ingests an OpenVEX document (raw JSON body). Matched not_affected/fixed
        statements suppress those findings as a read-time overlay (never
        mutating findings). VEX is a tenant assertion (unverified) and is
        digest-scoped — a new image digest needs a new statement. Max 5 MiB.
        See the OpenVEX spec: https://github.com/openvex/spec
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: An OpenVEX document
              additionalProperties: true
      responses:
        "202":
          description: VEX accepted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/VEXResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "422":
          description: Document could not be parsed as OpenVEX
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        An API token minted in the UI (Tokens & settings). Send as
        `Authorization: Bearer dr_...`.

  parameters:
    SBOMId:
      name: id
      in: path
      required: true
      description: The SBOM id (per-tenant content hash) returned at submission.
      schema: { type: string }
    MinSeverity:
      name: min_severity
      in: query
      description: >-
        Minimum severity floor: `critical`|`high`|`medium`|`low`|`negligible`
        (default: the tenant's setting, usually `medium`). `unknown` is always
        included. Resolved independently per endpoint.
      schema:
        type: string
        enum: [critical, high, medium, low, negligible]
    Sort:
      name: sort
      in: query
      description: Sort key (endpoint-specific; omit for the default).
      schema: { type: string }
    Dir:
      name: dir
      in: query
      description: Sort direction.
      schema: { type: string, enum: [asc, desc] }
    Cursor:
      name: cursor
      in: query
      description: Opaque keyset cursor from a prior response's `next_cursor`.
      schema: { type: string }
    Limit:
      name: limit
      in: query
      description: Max rows (default 100, max 1000).
      schema: { type: integer, minimum: 1, maximum: 1000 }

  responses:
    BadRequest:
      description: Malformed request
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing or invalid API token
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource not found or not owned by the tenant
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    TooLarge:
      description: Request body exceeds the size cap
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      properties:
        error: { type: string, description: Human-readable error message }
      required: [error]

    Page:
      type: object
      description: List envelope. `next_cursor` is present only when more pages exist.
      properties:
        next_cursor:
          type: string
          description: Pass back as `?cursor=` for the next page.

    SubmitRequest:
      type: object
      required: [sbom]
      properties:
        sbom:
          type: string
          description: Base64-encoded SBOM bytes (CycloneDX or SPDX; gzip allowed).
        image_ref:
          type: string
          description: >-
            Override the image reference. Required only when the SBOM has no
            embedded digest (generated by tag) — supply `repo@sha256:...`.
        version:
          type: string
          description: The image tag (e.g. `v1.20.2`). Parsed from image_ref if omitted.
        labels:
          type: array
          items: { type: string }
          description: Grouping labels for dashboard filtering (e.g. `team-x`, `prod`).
        generated_at:
          type: string
          format: date-time
          description: RFC3339 override for the SBOM's generation time.

    SubmitResponse:
      type: object
      properties:
        sbom_id: { type: string }
        image_ref: { type: string }
        digest: { type: string, description: "sha256:..." }
        format: { type: string, enum: [cyclonedx, spdx] }
        existing: { type: boolean, description: True if this SBOM was already stored }

    SeverityCounts:
      type: object
      description: >-
        Per-severity breakdown. Buckets below the requested threshold are
        omitted; `unknown` is always kept. `total` is the overall count;
        `relevant` sums the visible buckets.
      properties:
        critical: { type: integer }
        high: { type: integer }
        medium: { type: integer }
        low: { type: integer }
        negligible: { type: integer }
        unknown: { type: integer }
        total: { type: integer }
        relevant: { type: integer }

    RepoImage:
      type: object
      properties:
        repository: { type: string }
        sbom_count: { type: integer }
        digest_count: { type: integer }
        versions: { type: array, items: { type: string } }
        latest_at: { type: string, format: date-time }
        counts: { $ref: "#/components/schemas/SeverityCounts" }
        fixable: { type: integer }
        failures: { type: integer }

    Image:
      type: object
      properties:
        sbom_id: { type: string }
        image_ref: { type: string }
        digest: { type: string }
        format: { type: string }
        submitted_at: { type: string, format: date-time }
        counts: { $ref: "#/components/schemas/SeverityCounts" }
        failures: { type: integer }

    SBOMDetail:
      type: object
      properties:
        sbom_id: { type: string }
        image_ref: { type: string }
        digest: { type: string }
        format: { type: string }
        spec_version: { type: string }
        tool: { type: string }
        tool_version: { type: string }
        status: { type: string, enum: [active, archived] }
        submitted_at: { type: string, format: date-time }
        generated_at: { type: string, format: date-time }
        counts: { $ref: "#/components/schemas/SeverityCounts" }

    Finding:
      type: object
      properties:
        scanner: { type: string, enum: [grype, trivy] }
        exposure: { type: string, description: "e.g. CVE-2024-1234" }
        package: { type: string }
        version: { type: string }
        severity: { type: string }
        score: { type: number, format: float, description: CVSS base score }
        is_fixed: { type: boolean }
        epss: { type: number, format: float, description: "[0,1] exploit probability" }
        epss_pct: { type: number, format: float, description: "[0,1] percentile" }
        kev: { type: boolean, description: In CISA Known-Exploited catalog }
        vex_status: { type: string, description: "Tenant's latest VEX assertion, if any" }

    Event:
      type: object
      properties:
        scanner: { type: string }
        event_type: { type: string, enum: [added, resolved, rerated, fixed] }
        exposure: { type: string }
        package: { type: string }
        severity: { type: string }
        score: { type: number, format: float }
        cause: { type: string, enum: [image, db, tooling] }
        occurred_at: { type: string, format: date-time }

    TimelineEvent:
      type: object
      properties:
        digest: { type: string }
        sbom_id: { type: string }
        scanner: { type: string }
        event_type: { type: string, enum: [added, resolved, rerated, fixed] }
        exposure: { type: string }
        package: { type: string }
        severity: { type: string }
        score: { type: number, format: float }
        cause: { type: string, enum: [image, db, tooling] }
        occurred_at: { type: string, format: date-time }

    Failure:
      type: object
      properties:
        scanner: { type: string }
        stage:
          type: string
          description: download|canonicalize|scan|parse|detect|convert|zero-findings|persist|panic|license-extract
        error: { type: string }
        occurred_at: { type: string, format: date-time }

    PackageLicense:
      type: object
      properties:
        package: { type: string }
        version: { type: string }
        purl: { type: string }
        licenses:
          type: array
          items: { type: string }
          description: Raw SPDX IDs / names / expressions (multi-license preserved).
        category:
          type: string
          enum: [permissive, weak-copyleft, strong-copyleft, proprietary, unknown]
        violation: { type: boolean, description: Fails the tenant's compliance policy }
        reason: { type: string, description: Which denied license triggered the violation }

    LicenseCount:
      type: object
      properties:
        key: { type: string }
        count: { type: integer }

    FleetLicenseStats:
      type: object
      properties:
        families:
          type: array
          items: { $ref: "#/components/schemas/LicenseCount" }
        categories:
          type: array
          items: { $ref: "#/components/schemas/LicenseCount" }
        packages: { type: integer, description: Distinct packages with a license }
        unlicensed: { type: integer, description: Distinct packages with no resolvable license }
        violations: { type: integer, description: Distinct packages failing the policy }

    VEXResult:
      type: object
      properties:
        document_id: { type: string }
        statements: { type: integer }
        matched: { type: integer, description: Statements that hit an existing finding }
        unmatched: { type: integer }
        skipped: { type: integer, description: Statements with no resolvable image digest }
        note: { type: string }
