Skip to content

ROX-30571: roxctl sbom scan#18503

Open
dcaravel wants to merge 8 commits intomasterfrom
dc/sbom-scan-roxctl
Open

ROX-30571: roxctl sbom scan#18503
dcaravel wants to merge 8 commits intomasterfrom
dc/sbom-scan-roxctl

Conversation

@dcaravel
Copy link
Copy Markdown
Contributor

@dcaravel dcaravel commented Jan 15, 2026

Description

Adds the roxctl sbom scan command.

PR Stack:

User-facing documentation

Testing and quality

  • the change is production ready: the change is GA, or otherwise the functionality is gated by a feature flag
  • CI results are inspected

Automated testing

  • added unit tests
  • modified existing tests

How I validated my change

$ go run ./roxctl sbom scan --file dignore/rhacs-4.9.json
output
{
  "id": "fake HashId",
  "scan": {
    "scannerVersion": "matcher=v7",
    "scanTime": "2026-01-16T00:48:23.865085543Z",
    "components": [
      {
        "name": "Fake Package #1",
        "version": "v1.0.0",
        "vulns": [
          {
            "cve": "Fake Vuln #1",
            "cvss": 3.3,
            "link": "https://nvd.nist.gov/vuln/detail/CVE-5678-1234",
            "scoreVersion": "V3",
            "cvssV3": {
              "vector": "CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N",
              "exploitabilityScore": 0.8,
              "impactScore": 2.5,
              "attackComplexity": "COMPLEXITY_HIGH",
              "privilegesRequired": "PRIVILEGE_LOW",
              "userInteraction": "UI_REQUIRED",
              "confidentiality": "IMPACT_LOW",
              "integrity": "IMPACT_LOW",
              "score": 3.3,
              "severity": "LOW"
            },
            "severity": "CRITICAL_VULNERABILITY_SEVERITY",
            "cvssMetrics": [
              {
                "source": "SOURCE_NVD",
                "url": "https://nvd.nist.gov/vuln/detail/CVE-5678-1234",
                "cvssv3": {
                  "vector": "CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N",
                  "exploitabilityScore": 0.8,
                  "impactScore": 2.5,
                  "attackComplexity": "COMPLEXITY_HIGH",
                  "privilegesRequired": "PRIVILEGE_LOW",
                  "userInteraction": "UI_REQUIRED",
                  "confidentiality": "IMPACT_LOW",
                  "integrity": "IMPACT_LOW",
                  "score": 3.3,
                  "severity": "LOW"
                }
              }
            ]
          },
          {
            "cve": "Fake Vuln #2",
            "cvss": 3.3,
            "link": "https://nvd.nist.gov/vuln/detail/CVE-5678-1234",
            "scoreVersion": "V3",
            "cvssV3": {
              "vector": "CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N",
              "exploitabilityScore": 0.8,
              "impactScore": 2.5,
              "attackComplexity": "COMPLEXITY_HIGH",
              "privilegesRequired": "PRIVILEGE_LOW",
              "userInteraction": "UI_REQUIRED",
              "confidentiality": "IMPACT_LOW",
              "integrity": "IMPACT_LOW",
              "score": 3.3,
              "severity": "LOW"
            },
            "severity": "IMPORTANT_VULNERABILITY_SEVERITY",
            "cvssMetrics": [
              {
                "source": "SOURCE_NVD",
                "url": "https://nvd.nist.gov/vuln/detail/CVE-5678-1234",
                "cvssv3": {
                  "vector": "CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N",
                  "exploitabilityScore": 0.8,
                  "impactScore": 2.5,
                  "attackComplexity": "COMPLEXITY_HIGH",
                  "privilegesRequired": "PRIVILEGE_LOW",
                  "userInteraction": "UI_REQUIRED",
                  "confidentiality": "IMPACT_LOW",
                  "integrity": "IMPACT_LOW",
                  "score": 3.3,
                  "severity": "LOW"
                }
              }
            ]
          },
          {
            "cve": "Fake Vuln #3",
            "cvss": 8.2,
            "link": "https://access.redhat.com/security/cve/CVE-1234-567",
            "scoreVersion": "V3",
            "cvssV3": {
              "vector": "CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:H",
              "exploitabilityScore": 2.8,
              "impactScore": 4.7,
              "attackVector": "ATTACK_ADJACENT",
              "scope": "CHANGED",
              "confidentiality": "IMPACT_LOW",
              "availability": "IMPACT_HIGH",
              "score": 8.2,
              "severity": "HIGH"
            },
            "severity": "MODERATE_VULNERABILITY_SEVERITY",
            "cvssMetrics": [
              {
                "source": "SOURCE_RED_HAT",
                "url": "https://access.redhat.com/security/cve/CVE-1234-567",
                "cvssv3": {
                  "vector": "CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:H",
                  "exploitabilityScore": 2.8,
                  "impactScore": 4.7,
                  "attackVector": "ATTACK_ADJACENT",
                  "scope": "CHANGED",
                  "confidentiality": "IMPACT_LOW",
                  "availability": "IMPACT_HIGH",
                  "score": 8.2,
                  "severity": "HIGH"
                }
              },
              {
                "source": "SOURCE_NVD",
                "url": "https://nvd.nist.gov/vuln/detail/CVE-1234-567",
                "cvssv2": {
                  "vector": "AV:N/AC:M/Au:M/C:C/I:N/A:P",
                  "attackVector": "ATTACK_NETWORK",
                  "accessComplexity": "ACCESS_MEDIUM",
                  "confidentiality": "IMPACT_COMPLETE",
                  "availability": "IMPACT_PARTIAL",
                  "exploitabilityScore": 5.5,
                  "impactScore": 7.8,
                  "score": 6.4,
                  "severity": "MEDIUM"
                }
              }
            ]
          },
          {
            "cve": "Fake Vuln #4",
            "cvss": 3.3,
            "link": "https://nvd.nist.gov/vuln/detail/CVE-5678-1234",
            "scoreVersion": "V3",
            "cvssV3": {
              "vector": "CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N",
              "exploitabilityScore": 0.8,
              "impactScore": 2.5,
              "attackComplexity": "COMPLEXITY_HIGH",
              "privilegesRequired": "PRIVILEGE_LOW",
              "userInteraction": "UI_REQUIRED",
              "confidentiality": "IMPACT_LOW",
              "integrity": "IMPACT_LOW",
              "score": 3.3,
              "severity": "LOW"
            },
            "severity": "LOW_VULNERABILITY_SEVERITY",
            "cvssMetrics": [
              {
                "source": "SOURCE_NVD",
                "url": "https://nvd.nist.gov/vuln/detail/CVE-5678-1234",
                "cvssv3": {
                  "vector": "CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N",
                  "exploitabilityScore": 0.8,
                  "impactScore": 2.5,
                  "attackComplexity": "COMPLEXITY_HIGH",
                  "privilegesRequired": "PRIVILEGE_LOW",
                  "userInteraction": "UI_REQUIRED",
                  "confidentiality": "IMPACT_LOW",
                  "integrity": "IMPACT_LOW",
                  "score": 3.3,
                  "severity": "LOW"
                }
              }
            ]
          }
        ]
      },
      {
        "name": "Fake Package #2",
        "version": "v2.3.4",
        "vulns": [
          {
            "cve": "Fake Vuln #3",
            "cvss": 8.2,
            "link": "https://access.redhat.com/security/cve/CVE-1234-567",
            "scoreVersion": "V3",
            "cvssV3": {
              "vector": "CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:H",
              "exploitabilityScore": 2.8,
              "impactScore": 4.7,
              "attackVector": "ATTACK_ADJACENT",
              "scope": "CHANGED",
              "confidentiality": "IMPACT_LOW",
              "availability": "IMPACT_HIGH",
              "score": 8.2,
              "severity": "HIGH"
            },
            "severity": "MODERATE_VULNERABILITY_SEVERITY",
            "cvssMetrics": [
              {
                "source": "SOURCE_RED_HAT",
                "url": "https://access.redhat.com/security/cve/CVE-1234-567",
                "cvssv3": {
                  "vector": "CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:H",
                  "exploitabilityScore": 2.8,
                  "impactScore": 4.7,
                  "attackVector": "ATTACK_ADJACENT",
                  "scope": "CHANGED",
                  "confidentiality": "IMPACT_LOW",
                  "availability": "IMPACT_HIGH",
                  "score": 8.2,
                  "severity": "HIGH"
                }
              },
              {
                "source": "SOURCE_NVD",
                "url": "https://nvd.nist.gov/vuln/detail/CVE-1234-567",
                "cvssv2": {
                  "vector": "AV:N/AC:M/Au:M/C:C/I:N/A:P",
                  "attackVector": "ATTACK_NETWORK",
                  "accessComplexity": "ACCESS_MEDIUM",
                  "confidentiality": "IMPACT_COMPLETE",
                  "availability": "IMPACT_PARTIAL",
                  "exploitabilityScore": 5.5,
                  "impactScore": 7.8,
                  "score": 6.4,
                  "severity": "MEDIUM"
                }
              }
            ]
          },
          {
            "cve": "Fake Vuln #4",
            "cvss": 3.3,
            "link": "https://nvd.nist.gov/vuln/detail/CVE-5678-1234",
            "scoreVersion": "V3",
            "cvssV3": {
              "vector": "CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N",
              "exploitabilityScore": 0.8,
              "impactScore": 2.5,
              "attackComplexity": "COMPLEXITY_HIGH",
              "privilegesRequired": "PRIVILEGE_LOW",
              "userInteraction": "UI_REQUIRED",
              "confidentiality": "IMPACT_LOW",
              "integrity": "IMPACT_LOW",
              "score": 3.3,
              "severity": "LOW"
            },
            "severity": "LOW_VULNERABILITY_SEVERITY",
            "cvssMetrics": [
              {
                "source": "SOURCE_NVD",
                "url": "https://nvd.nist.gov/vuln/detail/CVE-5678-1234",
                "cvssv3": {
                  "vector": "CVSS:3.1/AV:L/AC:H/PR:L/UI:R/S:U/C:L/I:L/A:N",
                  "exploitabilityScore": 0.8,
                  "impactScore": 2.5,
                  "attackComplexity": "COMPLEXITY_HIGH",
                  "privilegesRequired": "PRIVILEGE_LOW",
                  "userInteraction": "UI_REQUIRED",
                  "confidentiality": "IMPACT_LOW",
                  "integrity": "IMPACT_LOW",
                  "score": 3.3,
                  "severity": "LOW"
                }
              }
            ]
          }
        ]
      }
    ],
    "operatingSystem": "unknown",
    "dataSource": {
      "id": "a87471e6-9678-4e66-8348-91e302b6de07",
      "name": "Scanner V4"
    }
  }
}
$ go run ./roxctl sbom scan --file=dignore/rhacs-4.9.json --output json
output
{
  "result": {
    "summary": {
      "CRITICAL": 1,
      "IMPORTANT": 1,
      "LOW": 1,
      "MODERATE": 1,
      "TOTAL-COMPONENTS": 2,
      "TOTAL-VULNERABILITIES": 4
    },
    "vulnerabilities": [
      {
        "cveId": "Fake Vuln #1",
        "cveSeverity": "CRITICAL",
        "cveCVSS": 3.3,
        "cveInfo": "https://nvd.nist.gov/vuln/detail/CVE-5678-1234",
        "advisoryId": "",
        "advisoryInfo": "",
        "componentName": "Fake Package #1",
        "componentVersion": "v1.0.0",
        "componentFixedVersion": ""
      },
      {
        "cveId": "Fake Vuln #2",
        "cveSeverity": "IMPORTANT",
        "cveCVSS": 3.3,
        "cveInfo": "https://nvd.nist.gov/vuln/detail/CVE-5678-1234",
        "advisoryId": "",
        "advisoryInfo": "",
        "componentName": "Fake Package #1",
        "componentVersion": "v1.0.0",
        "componentFixedVersion": ""
      },
      {
        "cveId": "Fake Vuln #3",
        "cveSeverity": "MODERATE",
        "cveCVSS": 8.2,
        "cveInfo": "https://access.redhat.com/security/cve/CVE-1234-567",
        "advisoryId": "",
        "advisoryInfo": "",
        "componentName": "Fake Package #1",
        "componentVersion": "v1.0.0",
        "componentFixedVersion": ""
      },
      {
        "cveId": "Fake Vuln #4",
        "cveSeverity": "LOW",
        "cveCVSS": 3.3,
        "cveInfo": "https://nvd.nist.gov/vuln/detail/CVE-5678-1234",
        "advisoryId": "",
        "advisoryInfo": "",
        "componentName": "Fake Package #1",
        "componentVersion": "v1.0.0",
        "componentFixedVersion": ""
      },
      {
        "cveId": "Fake Vuln #3",
        "cveSeverity": "MODERATE",
        "cveCVSS": 8.2,
        "cveInfo": "https://access.redhat.com/security/cve/CVE-1234-567",
        "advisoryId": "",
        "advisoryInfo": "",
        "componentName": "Fake Package #2",
        "componentVersion": "v2.3.4",
        "componentFixedVersion": ""
      },
      {
        "cveId": "Fake Vuln #4",
        "cveSeverity": "LOW",
        "cveCVSS": 3.3,
        "cveInfo": "https://nvd.nist.gov/vuln/detail/CVE-5678-1234",
        "advisoryId": "",
        "advisoryInfo": "",
        "componentName": "Fake Package #2",
        "componentVersion": "v2.3.4",
        "componentFixedVersion": ""
      }
    ]
  }
}
go run ./roxctl sbom scan --file=dignore/rhacs-4.9.json --output table
output
Scan results for image: dignore/rhacs-4.9.json
(TOTAL-COMPONENTS: 2, TOTAL-VULNERABILITIES: 4, LOW: 1, MODERATE: 1, IMPORTANT: 1, CRITICAL: 1)

+-----------------+---------+--------------+-----------+------+-----------------------------------------------------+---------------+----------+---------------+
|    COMPONENT    | VERSION |     CVE      | SEVERITY  | CVSS |                        LINK                         | FIXED VERSION | ADVISORY | ADVISORY LINK |
+-----------------+---------+--------------+-----------+------+-----------------------------------------------------+---------------+----------+---------------+
| Fake Package #1 | v1.0.0  | Fake Vuln #1 | CRITICAL  | 3.3  |   https://nvd.nist.gov/vuln/detail/CVE-5678-1234    |       -       |    -     |       -       |
|                 |         +--------------+-----------+------+-----------------------------------------------------+---------------+----------+---------------+
|                 |         | Fake Vuln #2 | IMPORTANT | 3.3  |   https://nvd.nist.gov/vuln/detail/CVE-5678-1234    |       -       |    -     |       -       |
|                 |         +--------------+-----------+------+-----------------------------------------------------+---------------+----------+---------------+
|                 |         | Fake Vuln #3 | MODERATE  | 8.2  | https://access.redhat.com/security/cve/CVE-1234-567 |       -       |    -     |       -       |
|                 |         +--------------+-----------+------+-----------------------------------------------------+---------------+----------+---------------+
|                 |         | Fake Vuln #4 |    LOW    | 3.3  |   https://nvd.nist.gov/vuln/detail/CVE-5678-1234    |       -       |    -     |       -       |
+-----------------+---------+--------------+-----------+------+-----------------------------------------------------+---------------+----------+---------------+
| Fake Package #2 | v2.3.4  | Fake Vuln #3 | MODERATE  | 8.2  | https://access.redhat.com/security/cve/CVE-1234-567 |       -       |    -     |       -       |
|                 |         +--------------+-----------+------+-----------------------------------------------------+---------------+----------+---------------+
|                 |         | Fake Vuln #4 |    LOW    | 3.3  |   https://nvd.nist.gov/vuln/detail/CVE-5678-1234    |       -       |    -     |       -       |
+-----------------+---------+--------------+-----------+------+-----------------------------------------------------+---------------+----------+---------------+
WARN:	A total of 4 unique vulnerabilities were found in 2 components
$ go run ./roxctl sbom scan --file=rhacs-4.9.json --output csv
output
COMPONENT,VERSION,CVE,SEVERITY,CVSS,LINK,FIXED_VERSION,ADVISORY,ADVISORY_LINK
Fake Package #1,v1.0.0,Fake Vuln #1,CRITICAL,3.3,https://nvd.nist.gov/vuln/detail/CVE-5678-1234,-,-,-
Fake Package #1,v1.0.0,Fake Vuln #2,IMPORTANT,3.3,https://nvd.nist.gov/vuln/detail/CVE-5678-1234,-,-,-
Fake Package #1,v1.0.0,Fake Vuln #3,MODERATE,8.2,https://access.redhat.com/security/cve/CVE-1234-567,-,-,-
Fake Package #1,v1.0.0,Fake Vuln #4,LOW,3.3,https://nvd.nist.gov/vuln/detail/CVE-5678-1234,-,-,-
Fake Package #2,v2.3.4,Fake Vuln #3,MODERATE,8.2,https://access.redhat.com/security/cve/CVE-1234-567,-,-,-
Fake Package #2,v2.3.4,Fake Vuln #4,LOW,3.3,https://nvd.nist.gov/vuln/detail/CVE-5678-1234,-,-,-
$ go run ./roxctl sbom scan --file NOPE.json
ERROR:	SBOM file does not exist: "NOPE.json"
$ go run ./roxctl sbom scan --file dignore/rhacs-4.9.invalid.json 
ERROR:	auto detecting media type: SBOM file does not appear to be valid JSON
$ go run ./roxctl sbom scan --file dignore/rhacs-4.9.notspdx.json 
ERROR:	auto detecting media type: unsupported SBOM format
$ go run ./roxctl sbom scan --file dignore/rhacs-4.9.json --content-type WRONG
ERROR:	received unexpected status code 400. Response Body: {"code":3,"message":"validating media type: unsupported media type \"WRONG\", supported types [text/spdx+json application/spdx+json]"}

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The sbomScanCommand struct has several fields that are never used (includeSnoozed, severities, printer, standardizedFormat, noOutputFormat), which makes the implementation harder to follow; consider removing them or wiring them into behavior in this PR.
  • The --fail/failOnFinding flag is defined and exposed on the CLI but never used in ScanSBOM, which will surprise users; either implement the failure behavior or drop the flag until it is supported.
  • The JSON/SPDX detection in guessMediaType is based on simple string checks and may be brittle; for robustness consider actually parsing the initial bytes as JSON and checking a spdxVersion field in the parsed structure instead of relying on string indexes.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `sbomScanCommand` struct has several fields that are never used (`includeSnoozed`, `severities`, `printer`, `standardizedFormat`, `noOutputFormat`), which makes the implementation harder to follow; consider removing them or wiring them into behavior in this PR.
- The `--fail`/`failOnFinding` flag is defined and exposed on the CLI but never used in `ScanSBOM`, which will surprise users; either implement the failure behavior or drop the flag until it is supported.
- The JSON/SPDX detection in `guessMediaType` is based on simple string checks and may be brittle; for robustness consider actually parsing the initial bytes as JSON and checking a `spdxVersion` field in the parsed structure instead of relying on string indexes.

## Individual Comments

### Comment 1
<location> `roxctl/sbom/scan/scan.go:60-48` </location>
<code_context>
+	contentType    string
+	includeSnoozed bool
+	severities     []string
+	failOnFinding  bool
+
+	// injected or constructed values
</code_context>

<issue_to_address>
**issue (bug_risk):** The `--fail` flag is defined but never influences the command’s exit behavior.

`failOnFinding` is wired into the options and flag parsing but never used in `ScanSBOM` (or elsewhere here), so `--fail` currently has no effect, which can break scripts that rely on exit status.

If this flag is meant to be supported now, the handler should inspect the scan result and return a non-nil error when findings are present. If that behavior isn’t ready, it’s better to remove or clearly disable the flag rather than ship a no-op option.
</issue_to_address>

### Comment 2
<location> `roxctl/sbom/scan/scan.go:55` </location>
<code_context>
+}
+
+// imageScanCommand holds all configurations and metadata to execute an image scan
+type sbomScanCommand struct {
+	sbomFilePath   string
+	contentType    string
</code_context>

<issue_to_address>
**issue (complexity):** Consider simplifying the SBOM scan command by removing unused fields and using JSON decoding in guessMediaType to reduce brittle string handling.

You can trim some complexity here without changing behavior.

### 1. Remove unused fields from `sbomScanCommand`

The extra fields (`includeSnoozed`, `severities`, `printer`, `standardizedFormat`, `noOutputFormat`) are not used anywhere and suggest behaviors that don’t exist. You can simplify the command struct and keep it tightly focused:

```go
type sbomScanCommand struct {
	sbomFilePath  string
	contentType   string
	failOnFinding bool

	// injected or constructed values
	env    environment.Environment
	client common.RoxctlHTTPClient
}
```

Because these fields aren’t referenced elsewhere in this file, this change is local and doesn’t alter functionality.

### 2. Simplify `guessMediaType` using `encoding/json`

The manual string scanning in `guessMediaType` is brittle and harder to maintain. You can keep auto‑detection but leverage the JSON package to only inspect `spdxVersion`:

```go
import (
    "encoding/json"
    // ...
)

func guessMediaType(sbomFile *os.File) (string, error) {
    // Read a small prefix for detection (4KB is still fine)
    buf := make([]byte, 4096)
    n, err := sbomFile.Read(buf)
    if err != nil && err != io.EOF {
        return "", errors.Wrap(err, "reading SBOM file")
    }

    if _, err := sbomFile.Seek(0, 0); err != nil {
        return "", errors.Wrap(err, "resetting file position")
    }

    // Minimal struct to inspect only the version
    var header struct {
        SPDXVersion string `json:"spdxVersion"`
    }
    dec := json.NewDecoder(strings.NewReader(string(buf[:n])))
    if err := dec.Decode(&header); err != nil {
        return "", errox.InvalidArgs.New("SBOM file does not appear to be valid JSON")
    }

    if header.SPDXVersion == "SPDX-2.3" {
        return "text/spdx+json", nil
    }

    return "", errox.InvalidArgs.New("unsupported SBOM format")
}
```

This keeps the same user-facing behavior (“auto-detect SPDX 2.3 JSON or error”) but:

- Delegates JSON parsing to the stdlib.
- Is less sensitive to whitespace / ordering.
- Makes the responsibility explicit: “parse minimal JSON → inspect `spdxVersion` → choose media type.”
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@rhacs-bot
Copy link
Copy Markdown
Contributor

rhacs-bot commented Jan 15, 2026

Images are ready for the commit at cdbdb4c.

To use with deploy scripts, first export MAIN_IMAGE_TAG=4.11.x-536-gcdbdb4c9a5.

@dcaravel dcaravel force-pushed the dc/sbom-scan-roxctl branch 4 times, most recently from 9e98a1c to 6073028 Compare January 16, 2026 19:36
@dcaravel dcaravel marked this pull request as ready for review January 16, 2026 20:21
@dcaravel dcaravel requested a review from a team as a code owner January 16, 2026 20:21
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In sbomScanCommand, Validate only checks the SBOM file path; consider reusing the severity validation logic from the image scan command so invalid --severity values are caught early instead of silently defaulting to LOW.
  • PrintCVESummary still prints Scan results for image: %s but is now used by SBOM scans with the SBOM file path; consider either renaming the label to something neutral (e.g. target) or passing a more descriptive string for SBOM scans to avoid confusing output.
  • The guessMediaType heuristic will treat any JSON object containing a "spdxVersion": "SPDX-2.3" substring as SPDX 2.3, even if it appears in a nested or non-spec field; if this is a concern, tightening the check (e.g. minimal JSON parsing of the root object to look for a top-level spdxVersion field) would make media-type detection more robust.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `sbomScanCommand`, `Validate` only checks the SBOM file path; consider reusing the severity validation logic from the image scan command so invalid `--severity` values are caught early instead of silently defaulting to LOW.
- `PrintCVESummary` still prints `Scan results for image: %s` but is now used by SBOM scans with the SBOM file path; consider either renaming the label to something neutral (e.g. `target`) or passing a more descriptive string for SBOM scans to avoid confusing output.
- The `guessMediaType` heuristic will treat any JSON object containing a `"spdxVersion": "SPDX-2.3"` substring as SPDX 2.3, even if it appears in a nested or non-spec field; if this is a concern, tightening the check (e.g. minimal JSON parsing of the root object to look for a top-level `spdxVersion` field) would make media-type detection more robust.

## Individual Comments

### Comment 1
<location> `roxctl/sbom/scan/scan.go:116-111` </location>
<code_context>
+
+// Validate will validate the injected values and check whether it's possible to execute the operation with the
+// provided values.
+func (s *sbomScanCommand) Validate() error {
+	// Check if the SBOM file exists.
+	if _, err := os.Stat(s.sbomFilePath); err != nil {
+		if os.IsNotExist(err) {
+			return errox.InvalidArgs.Newf("SBOM file does not exist: %q", s.sbomFilePath)
+		}
+		return errors.Wrapf(err, "checking SBOM file %q", s.sbomFilePath)
+	}
+
+	return nil
+}
+
</code_context>

<issue_to_address>
**issue (bug_risk):** SBOM scan does not validate `--severity` values, unlike `image scan`.

`sbomScanCommand.Validate` only checks the SBOM file and never validates `s.severities`. For `image scan`, `Validate` rejects invalid severities by checking against `scan.AllSeverities`. Here, invalid values reach `NewCVESummaryForPrinting` and are mapped by `cveSeverityFromString`, which currently defaults to `LOW`, leading to inconsistent behavior and potentially misleading filtering. Please reuse the `image scan` severity validation (e.g., uppercase + membership in `scan.AllSeverities`) and fail fast on invalid values.
</issue_to_address>

### Comment 2
<location> `roxctl/sbom/scan/scan_test.go:587-239` </location>
<code_context>
+	s.Assert().Equal(1, cveSummary.CountComponents(), "should count 1 component")
+}
+
+func (s *sbomScanTestSuite) TestPrintSBOMScanResults_FormattedOutput() {
+	responseData, err := protojson.Marshal(testSBOMScanResponse)
+	s.Require().NoError(err)
+
+	jsonFactory := printer.NewJSONPrinterFactory(false, false)
+	jsonPrinter, err := jsonFactory.CreatePrinter("json")
+	s.Require().NoError(err)
+
+	testIO, _, out, _ := cliIO.TestIO()
+	env := environment.NewTestCLIEnvironment(s.T(), testIO, printer.DefaultColorPrinter())
+
+	cmd := &sbomScanCommand{
+		env:                env,
+		printer:            jsonPrinter,
+		standardizedFormat: true,
+		severities:         scan.AllSeverities,
+		sbomFilePath:       "test.json",
+	}
+
+	err = cmd.printSBOMScanResults(bytes.NewReader(responseData))
+	s.Require().NoError(err)
+	s.Assert().Contains(out.String(), "CVE-2024-1234")
+	s.Assert().Contains(out.String(), "test-component")
+}
+
</code_context>

<issue_to_address>
**suggestion (testing):** Add coverage for non-standardized (legacy-style) SBOM output path

`TestPrintSBOMScanResults_FormattedOutput` only covers the `standardizedFormat: true` path, so the SBOM branches that call `scan.PrintCVESummary` and `scan.PrintCVEWarning` remain untested. Please add a companion test with `standardizedFormat: false` using a tabular printer that asserts on the logger output (summary and warning lines), mirroring the image-scan behavior to verify the human-readable SBOM output.

Suggested implementation:

```golang
func (s *sbomScanTestSuite) TestPrintSBOMScanResults_FormattedOutput() {
	responseData, err := protojson.Marshal(testSBOMScanResponse)
	s.Require().NoError(err)

	jsonFactory := printer.NewJSONPrinterFactory(false, false)
	jsonPrinter, err := jsonFactory.CreatePrinter("json")
	s.Require().NoError(err)

	testIO, _, out, _ := cliIO.TestIO()
	env := environment.NewTestCLIEnvironment(s.T(), testIO, printer.DefaultColorPrinter())

	cmd := &sbomScanCommand{
		env:                env,
		printer:            jsonPrinter,
		standardizedFormat: true,
		severities:         scan.AllSeverities,
		sbomFilePath:       "test.json",
	}

	err = cmd.printSBOMScanResults(bytes.NewReader(responseData))
	s.Require().NoError(err)
	s.Assert().Contains(out.String(), "CVE-2024-1234")
	s.Assert().Contains(out.String(), "test-component")
}

func (s *sbomScanTestSuite) TestPrintSBOMScanResults_LegacyFormattedOutput() {
	// Use the same test response as the standardized-format test.
	responseData, err := protojson.Marshal(testSBOMScanResponse)
	s.Require().NoError(err)

	// Use a tabular (human-readable) printer to exercise the non-standardized path,
	// which calls scan.PrintCVESummary and scan.PrintCVEWarning.
	tabularFactory := printer.NewTabularPrinterFactory(false, false)
	tabularPrinter, err := tabularFactory.CreatePrinter("table")
	s.Require().NoError(err)

	testIO, _, _, errOut := cliIO.TestIO()
	env := environment.NewTestCLIEnvironment(s.T(), testIO, printer.DefaultColorPrinter())

	cmd := &sbomScanCommand{
		env:                env,
		printer:            tabularPrinter,
		standardizedFormat: false,
		severities:         scan.AllSeverities,
		sbomFilePath:       "test.json",
	}

	err = cmd.printSBOMScanResults(bytes.NewReader(responseData))
	s.Require().NoError(err)

	// Legacy-style output is logged via scan.PrintCVESummary/scan.PrintCVEWarning.
	loggerOutput := errOut.String()

	// Assert summary information is present.
	s.Assert().Contains(loggerOutput, "1", "summary should mention 1 vulnerability")
	s.Assert().True(strings.Contains(loggerOutput, "vulnerab"), "summary should mention vulnerabilities")
	s.Assert().True(strings.Contains(loggerOutput, "component") || strings.Contains(loggerOutput, "package"),
		"summary should mention affected components/packages")

	// Assert that a warning line is emitted for the SBOM scan.
	s.Assert().Contains(strings.ToLower(loggerOutput), "warning", "should emit a CVE warning line for SBOM scans")
}

```

1. Ensure `printer.NewTabularPrinterFactory` and the `"table"` printer type match the actual tabular printer factory API in your codebase; if the constructor signature or printer type string differ, adjust them to mirror how image-scan tests obtain a tabular printer.
2. Confirm that `cliIO.TestIO()` returns an `errOut` buffer that receives logger output for `environment.NewTestCLIEnvironment`; if your image-scan tests assert on a different stream (e.g. `out` instead of `errOut`), update `loggerOutput := ...` to use the same stream they use.
3. The current assertions on `loggerOutput` use generic substrings so they are resilient to minor wording changes. If your existing image-scan tests assert on more specific summary/warning text produced by `scan.PrintCVESummary` and `scan.PrintCVEWarning`, tighten these expectations to match those strings for stronger regression coverage.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@dcaravel dcaravel requested review from a team and BradLugo January 16, 2026 20:34
@stackrox stackrox deleted a comment from openshift-ci bot Jan 16, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Jan 16, 2026

Codecov Report

❌ Patch coverage is 55.17241% with 78 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.59%. Comparing base (f042134) to head (cdbdb4c).

Files with missing lines Patch % Lines
roxctl/sbom/scan/scan.go 54.91% 66 Missing and 12 partials ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##           master   #18503    +/-   ##
========================================
  Coverage   49.58%   49.59%            
========================================
  Files        2761     2762     +1     
  Lines      208140   208314   +174     
========================================
+ Hits       103214   103310    +96     
- Misses      97260    97326    +66     
- Partials     7666     7678    +12     
Flag Coverage Δ
go-unit-tests 49.59% <55.17%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@dcaravel
Copy link
Copy Markdown
Contributor Author

rebased to address conflicts from base branch

@dcaravel dcaravel force-pushed the dc/sbom-scan-roxctl branch from dedb482 to 483bac0 Compare January 16, 2026 23:34
@dcaravel
Copy link
Copy Markdown
Contributor Author

rebased

@dcaravel dcaravel force-pushed the dc/sbom-ingest-central-api branch from 2643358 to 01e65a6 Compare January 22, 2026 22:25
@dcaravel dcaravel force-pushed the dc/sbom-scan-roxctl branch from 483bac0 to 7f8a80b Compare January 22, 2026 23:25
@dcaravel
Copy link
Copy Markdown
Contributor Author

rebased to address conflicts

@dcaravel dcaravel force-pushed the dc/sbom-scan-roxctl branch from 7f8a80b to c43153d Compare January 23, 2026 00:16
Copy link
Copy Markdown
Contributor

@janisz janisz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you split it into 2 PRs?

  1. Move severity to common/scan
  2. sbom scan.

It will be easier to review as 1 will be mechanical review as it should be only name change

@dcaravel
Copy link
Copy Markdown
Contributor Author

dcaravel commented Jan 23, 2026

Can you split it into 2 PRs?

  1. Move severity to common/scan
  2. sbom scan.

It will be easier to review as 1 will be mechanical review as it should be only name change

Sure, the diff for those files will be no different from what it is right now FYI

@dcaravel dcaravel force-pushed the dc/sbom-scan-roxctl branch from c43153d to 9259334 Compare January 23, 2026 22:41
@dcaravel dcaravel changed the base branch from dc/sbom-ingest-central-api to dc/move-roxctl-scan-print January 23, 2026 22:41
@dcaravel
Copy link
Copy Markdown
Contributor Author

Can you split it into 2 PRs?

  1. Move severity to common/scan
  2. sbom scan.

It will be easier to review as 1 will be mechanical review as it should be only name change

@janisz done - #18658

@dcaravel dcaravel requested a review from janisz January 23, 2026 22:49
@dcaravel dcaravel force-pushed the dc/move-roxctl-scan-print branch 2 times, most recently from ec5db7e to 9ebda95 Compare February 13, 2026 23:42
@dcaravel dcaravel force-pushed the dc/sbom-scan-roxctl branch 2 times, most recently from 3523a7a to bdf3a36 Compare February 13, 2026 23:50

// Guess the media type.
if s.contentType == "" {
s.contentType, err = guessMediaType(sbomFile)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rely on file extension instead of guessing?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to determine the type of SBOM the file represents in addition to the file being json, for that we cannot rely solely on extension and need to interrogate the file.

//
// The backend currently requires SPDX 2.3 JSON, which when detected will return
// media type `text/spdx+json`.
func guessMediaType(sbomFile *os.File) (string, error) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we simplify it by using json path or parse dummy struct and read the .spdxVersion? ALternatively we don't need to fail fast as we can let central handle the type.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we simplify it by using json path or parse dummy struct and read the .spdxVersion?

I'll switch to use a token based json decoder, should help simplify (but make errors less specific). Parsing into a dummy struct is where I started but some SPDX docs are 500+ MiB, even if we're only interested in a single field the whole file was being loaded/parsed which was painfully slow.

ALternatively we don't need to fail fast as we can let central handle the type.

The API, by design, requires the type be provided by the client. The guess in roxctl isn't required but it is a better user experience compared to forcing the user to specify the type on every invocation.

Also the SBOM is opaque to Central by design, it is passed to scanner for processing. It would be a poor experience to wait for a large upload and processing before getting an 'unsupported SBOM' error.

Base automatically changed from dc/move-roxctl-scan-print to master April 1, 2026 23:59
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 2, 2026

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added roxctl sbom scan command to analyze SBOM files for vulnerabilities
    • Supports SPDX 2.3 format SBOMs with automatic or manual content type detection
    • Includes severity filtering and fail-on-findings option for automation workflows

Walkthrough

The pull request adds a new sbom CLI command subtree to roxctl, enabling SBOM scanning functionality. Changes include command registration in the main command tree, flag definitions in YAML configuration files, implementation of a scan subcommand with file handling and API interaction, comprehensive test coverage, and help text updates.

Changes

Cohort / File(s) Summary
Command Registration
roxctl/maincommand/command.go, roxctl/sbom/sbom.go
Register sbom as a top-level command with timeout flag and wire in the scan subcommand.
Command Tree Configuration
roxctl/maincommand/command_tree_debug.yaml, roxctl/maincommand/command_tree_release.yaml
Add sbom and sbom scan command definitions with persistent flags (timeout, output formatting), local flags (file, content-type, severity, fail), and inherited flags (TLS/auth/connection).
SBOM Scan Implementation
roxctl/sbom/scan/scan.go
Implement scan command to validate SBOM files, detect media type (SPDX 2.3 JSON), POST to /api/v1/sboms/scan, handle responses (including HTML error detection), and optionally fail on findings.
SBOM Scan Tests
roxctl/sbom/scan/scan_test.go
Add comprehensive test suite covering command construction, flag validation, SBOM file validation, media type detection, HTTP error handling, response formatting, and fail-on-finding behavior.
Help Text
tests/roxctl/bats-tests/local/expect/roxctl--help.txt
Add sbom entry to available commands in help output.

Sequence Diagram

sequenceDiagram
    participant User as User / CLI
    participant Command as sbom scan<br/>Command
    participant FileSystem as File System
    participant MediaDetect as Media Type<br/>Detector
    participant HTTPClient as HTTP Client
    participant API as Central API<br/>(/api/v1/sboms/scan)
    participant Printer as Output<br/>Formatter

    User->>Command: Execute with --file, --severity, --content-type
    Command->>Command: Validate() - Check file exists, severity supported
    Command->>FileSystem: Open SBOM file
    FileSystem-->>Command: File handle
    Command->>MediaDetect: guessMediaType() - Detect SPDX version
    MediaDetect->>FileSystem: Read and parse JSON
    FileSystem-->>MediaDetect: SPDX document
    MediaDetect-->>Command: Content-Type (text/spdx+json)
    Command->>HTTPClient: POST /api/v1/sboms/scan
    HTTPClient->>API: Send SBOM with Content-Type
    API-->>HTTPClient: Response (200 or error)
    HTTPClient-->>Command: Response status & body
    alt HTTP 200 Success
        Command->>Printer: Parse response as storage.Image
        Printer->>Printer: Format CVE summary, components, vulnerabilities
        Printer-->>User: Output results
    else Non-200 Status
        Command->>Command: Check for HTML response
        Command-->>User: Error with status code and body
    end
    alt --fail flag enabled
        Command->>Command: Check vulnerability count
        Command-->>User: Exit with error if vulnerabilities found
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title 'ROX-30571: roxctl sbom scan' clearly and specifically describes the main feature addition—a new SBOM scanning subcommand for roxctl—and is directly related to the core changeset.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering all required template sections: description of the feature, user-facing documentation status, testing and quality checkpoints, automated testing details, and thorough manual validation examples with multiple output formats and error cases.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dc/sbom-scan-roxctl

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
roxctl/sbom/sbom.go (1)

12-12: Fix copy-paste error in doc comment.

The comment says "image command tree" but this defines the sbom command tree.

📝 Suggested fix
-// Command defines the image command tree
+// Command defines the sbom command tree
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@roxctl/sbom/sbom.go` at line 12, The top-of-file doc comment is a copy-paste
error: it currently says "image command tree" but should describe the sbom
command; update the comment above the sbom command definition (the doc for the
sbom command tree in this file) to correctly refer to the "sbom command tree" or
similar accurate description so the comment matches the sbom command symbol.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@roxctl/sbom/sbom.go`:
- Line 12: The top-of-file doc comment is a copy-paste error: it currently says
"image command tree" but should describe the sbom command; update the comment
above the sbom command definition (the doc for the sbom command tree in this
file) to correctly refer to the "sbom command tree" or similar accurate
description so the comment matches the sbom command symbol.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: bfca8271-2407-4162-8782-631490c5ef65

📥 Commits

Reviewing files that changed from the base of the PR and between f042134 and 997fd14.

📒 Files selected for processing (7)
  • roxctl/maincommand/command.go
  • roxctl/maincommand/command_tree_debug.yaml
  • roxctl/maincommand/command_tree_release.yaml
  • roxctl/sbom/sbom.go
  • roxctl/sbom/scan/scan.go
  • roxctl/sbom/scan/scan_test.go
  • tests/roxctl/bats-tests/local/expect/roxctl--help.txt

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants