Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions internal/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,21 +101,23 @@ var scanCmd = &cobra.Command{
return err
}

// Warn early if SBOM/VEX output paths are given without their generation
// flags. These are persistent flags shared by `scan repo` and `scan image`,
// so surfacing the misuse here (before auth) keeps both commands consistent
// and stops the warning from hiding behind an auth error in CI.
if sbomOutput != "" && !generateSBOM {
cli.PrintWarning("--sbom-output is ignored without --sbom flag")
}
if vexOutput != "" && !generateVEX {
cli.PrintWarning("--vex-output is ignored without --vex flag")
}

return nil
},
}

// warnOnUnusedSBOMVEXFlags emits a warning when --sbom-output / --vex-output
// are set without their corresponding generation flag. Called by the
// subcommands that treat the flags as "output for a generated artifact"
// (scan repo, scan image). `scan sbom` repurposes both flags and skips it.
func warnOnUnusedSBOMVEXFlags() {
if sbomOutput != "" && !generateSBOM {
cli.PrintWarning("--sbom-output is ignored without --sbom flag")
}
if vexOutput != "" && !generateVEX {
cli.PrintWarning("--vex-output is ignored without --vex flag")
}
}

func init() {
// Scan-output flags. These were previously root persistent flags but only
// apply to the scan subtree, so they are scoped here to keep them out of the
Expand Down
3 changes: 1 addition & 2 deletions internal/cmd/scan_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,7 @@ var scanImageCmd = &cobra.Command{
scanner := image.NewScanner(client, noProgress, tid, limit, includeTests, scanTimeoutDuration, includeNonExploitable).
WithPullPolicy(pullPolicy)

// --sbom-output/--vex-output misuse is warned about in scan.PersistentPreRunE
// (before auth), so no warning is emitted here.
warnOnUnusedSBOMVEXFlags()

// Configure SBOM/VEX options if any flags are set
if generateSBOM || generateVEX {
Expand Down
3 changes: 1 addition & 2 deletions internal/cmd/scan_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,7 @@ var scanRepoCmd = &cobra.Command{
scanTimeoutDuration := time.Duration(scanTimeout) * time.Minute
scanner := repo.NewScanner(client, noProgress, tid, limit, includeTests, scanTimeoutDuration, includeNonExploitable)

// --sbom-output/--vex-output misuse is warned about in scan.PersistentPreRunE
// (before auth), so no warning is emitted here.
warnOnUnusedSBOMVEXFlags()

// Configure SBOM/VEX options if any flags are set
if generateSBOM || generateVEX {
Expand Down
69 changes: 49 additions & 20 deletions internal/cmd/scan_sbom.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,34 @@ import (

var scanSBOMCmd = &cobra.Command{
Use: "sbom [path]",
Short: "Generate a VEX document from a pre-existing SBOM",
Long: `Upload a pre-existing SBOM file and download the OpenVEX document the Armis
backend generates from it.

The <path> may be a single SBOM file (.json/.xml), a directory of SBOMs, or an
already-built .tar/.tar.gz/.tgz. An SBOM scan produces no findings — the result
is the generated VEX document (default: .armis/<artifact>-vex.json).`,
Example: ` $ armis-cli scan sbom sbom.json
Short: "Scan a pre-existing SBOM for vulnerabilities",
Long: `Upload a pre-existing CycloneDX SBOM (single file, directory of SBOMs, or a
pre-built .tar/.tar.gz/.tgz) and get back the vulnerabilities it exposes.

The backend picks the right scanner automatically based on the SBOM contents:

- SBOMs whose components carry an explicit CPE (asset / inventory SBOMs
like Torizon) are matched against NVD directly.
- SBOMs that identify components only via purl (npm / NuGet / PyPI-style
application manifests) are matched via Trivy → deps.dev.

Findings are printed as a table (same shape as ` + "`scan repo` / `scan image`" + `);
pass ` + "`--vex-output`" + ` to also download the OpenVEX document the backend
generated alongside them.`,
Example: ` # Single SBOM
$ armis-cli scan sbom ./sbom.json

# Directory of SBOMs
$ armis-cli scan sbom ./sboms/
$ armis-cli scan sbom sbom.json --vex-output out/vex.json`,

# Pre-built tarball
$ armis-cli scan sbom ./inventory.tar.gz

# Also emit a VEX document
$ armis-cli scan sbom ./sbom.json --vex-output ./out/vex.json

# Custom path for the raw-findings JSON dump
$ armis-cli scan sbom ./sbom.json --sbom-output ./out/findings.json`,
// Path is optional and defaults to the current directory, matching scan repo.
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -33,15 +51,11 @@ is the generated VEX document (default: .armis/<artifact>-vex.json).`,
sbomPath = args[0]
}

// --sbom / --sbom-output are meaningless here: you can't generate an SBOM
// from an SBOM. Warn (consistent with scan.PersistentPreRunE's style) and
// ignore. --vex is always implied by this command.
// --sbom is a no-op here (you can't generate an SBOM from an SBOM).
// --sbom-output is repurposed as the raw-findings dump path.
if generateSBOM {
cli.PrintWarning("--sbom is ignored for `scan sbom` (the SBOM is the input, not the output)")
}
if sbomOutput != "" {
cli.PrintWarning("--sbom-output is ignored for `scan sbom`")
}

// Validate path exists before making network calls. A directory, a
// single SBOM file, and a pre-built tarball are all valid inputs, so we
Expand All @@ -67,6 +81,11 @@ is the generated VEX document (default: .armis/<artifact>-vex.json).`,
return err
}

limit, err := getPageLimit()
if err != nil {
return err
}

failOnSeverities, err := cmdutil.GetFailOn(failOn)
if err != nil {
return err
Expand All @@ -80,8 +99,21 @@ is the generated VEX document (default: .armis/<artifact>-vex.json).`,
}

scanTimeoutDuration := time.Duration(scanTimeout) * time.Minute
scanner := sbom.NewScanner(client, noProgress, tid, scanTimeoutDuration).
WithVEXOutput(vexOutput)
scanner := sbom.NewScanner(
client,
noProgress,
tid,
limit,
scanTimeoutDuration,
includeNonExploitable,
)
if sbomOutput != "" {
scanner = scanner.WithRawOutput(sbomOutput)
}
// --vex opts into VEX generation; --vex-output implies --vex.
if generateVEX || vexOutput != "" {
scanner = scanner.WithVEXOutput(vexOutput)
}

ctx, cancel := NewSignalContext()
defer cancel()
Expand All @@ -92,7 +124,6 @@ is the generated VEX document (default: .armis/<artifact>-vex.json).`,
return handleScanError(ctx, err)
}

// Resolve output destination and format (handles file creation, format auto-detection, colors)
outputCfg, err := cmdutil.ResolveOutput(cmd, outputFile, format, colorFlag)
if err != nil {
return err
Expand All @@ -115,8 +146,6 @@ is the generated VEX document (default: .armis/<artifact>-vex.json).`,
return fmt.Errorf("failed to format output: %w", err)
}

// An SBOM scan produces no findings, so CheckExit never trips --fail-on;
// call it anyway for symmetry with the other scan subcommands.
return output.CheckExit(result, failOnSeverities, exitCode)
},
}
Expand Down
206 changes: 206 additions & 0 deletions internal/scan/normalized_findings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// Shared helpers for turning /normalized-results into a *model.ScanResult
// suitable for the formatters and --fail-on gate.
//
// This code was previously duplicated across internal/scan/image,
// internal/scan/repo, and internal/scan/sbomcpe; the SBOM unification work
// (PPSC-1136) needed a fourth caller and lifted the helpers here.

package scan

import (
"encoding/json"
"fmt"
"os"
"strings"

"github.com/ArmisSecurity/armis-cli/internal/model"
"github.com/ArmisSecurity/armis-cli/internal/util"
)

// BuildScanResult converts a slice of normalized findings into the
// *model.ScanResult shape the CLI's formatters and --fail-on gate consume.
func BuildScanResult(
scanID string,
normalizedFindings []model.NormalizedFinding,
debug bool,
includeNonExploitable bool,
) *model.ScanResult {
findings, filteredCount := ConvertNormalizedFindings(normalizedFindings, debug, includeNonExploitable)

summary := model.Summary{
Total: len(findings),
BySeverity: make(map[model.Severity]int),
ByType: make(map[model.FindingType]int),
ByCategory: make(map[string]int),
FilteredNonExploitable: filteredCount,
}
for _, f := range findings {
summary.BySeverity[f.Severity]++
summary.ByType[f.Type]++
if f.FindingCategory != "" {
summary.ByCategory[f.FindingCategory]++
}
}

return &model.ScanResult{
ScanID: scanID,
Findings: findings,
Summary: summary,
}
}

// ConvertNormalizedFindings translates the backend NormalizedFinding into the
// CLI-facing Finding model, filtering out empties and non-exploitable findings
// when requested. Returns the filtered slice and the count of items dropped by
// the exploitability filter (so callers can surface that in the summary).
func ConvertNormalizedFindings(
normalizedFindings []model.NormalizedFinding, debug bool, includeNonExploitable bool,
) ([]model.Finding, int) {
var findings []model.Finding
filteredCount := 0

for i, nf := range normalizedFindings {
if IsEmptyFinding(nf) {
continue
}

if !includeNonExploitable && ShouldFilterByExploitability(nf.NormalizedTask.Labels) {
filteredCount++
continue
}

if debug {
debugCopy := nf
if debugCopy.NormalizedTask.ExtraData.CodeLocation.Snippet != nil {
masked := util.MaskSecretInLine(*debugCopy.NormalizedTask.ExtraData.CodeLocation.Snippet)
debugCopy.NormalizedTask.ExtraData.CodeLocation.Snippet = &masked
}
if len(debugCopy.NormalizedTask.ExtraData.CodeLocation.CodeSnippetLines) > 0 {
debugCopy.NormalizedTask.ExtraData.CodeLocation.CodeSnippetLines =
util.MaskSecretInLines(debugCopy.NormalizedTask.ExtraData.CodeLocation.CodeSnippetLines)
}
if debugCopy.NormalizedTask.ExtraData.Fix != nil {
debugCopy.NormalizedTask.ExtraData.Fix = MaskFixSecrets(debugCopy.NormalizedTask.ExtraData.Fix)
}
rawJSON, err := json.Marshal(debugCopy)
if err != nil {
fmt.Fprintf(os.Stderr, "\n=== DEBUG: Finding #%d JSON Marshal Error: %v ===\n\n", i+1, err)
} else {
fmt.Fprintf(os.Stderr, "\n=== DEBUG: Finding #%d Raw JSON ===\n%s\n=== END DEBUG ===\n\n", i+1, string(rawJSON))
}
}

finding := model.Finding{
ID: nf.NormalizedTask.FindingID,
Severity: MapSeverity(nf.NormalizedRemediation.ToolSeverity),
Description: nf.NormalizedRemediation.Description,
CVEs: nf.NormalizedRemediation.VulnerabilityTypeMetadata.CVEs,
CWEs: nf.NormalizedRemediation.VulnerabilityTypeMetadata.CWEs,
OWASPCategories: nf.NormalizedRemediation.VulnerabilityTypeMetadata.OWASPCategories,
LongDescriptionMarkdown: nf.NormalizedRemediation.VulnerabilityTypeMetadata.LongDescriptionMarkdown,
URLs: nf.NormalizedRemediation.VulnerabilityTypeMetadata.URLs,
}

if finding.Description == "" {
if nf.NormalizedRemediation.VulnerabilityTypeMetadata.LongDescriptionMarkdown != "" {
finding.Description = nf.NormalizedRemediation.VulnerabilityTypeMetadata.LongDescriptionMarkdown
} else if nf.NormalizedTask.LongDescription != nil {
finding.Description = *nf.NormalizedTask.LongDescription
}
}

finding.Description = CleanDescription(finding.Description)

if nf.NormalizedRemediation.FindingCategory != nil {
if category, ok := nf.NormalizedRemediation.FindingCategory.(string); ok {
finding.FindingCategory = category
}
}

loc := nf.NormalizedTask.ExtraData.CodeLocation
if loc.FileName != nil {
finding.File = *loc.FileName
}
if loc.StartLine != nil {
finding.StartLine = *loc.StartLine
}
if loc.EndLine != nil {
finding.EndLine = *loc.EndLine
}
if loc.StartCol != nil {
finding.StartColumn = *loc.StartCol
}
if loc.EndCol != nil {
finding.EndColumn = *loc.EndCol
}

if len(loc.CodeSnippetLines) > 0 {
finding.CodeSnippet = strings.Join(loc.CodeSnippetLines, "\n")
} else if loc.Snippet != nil {
finding.CodeSnippet = *loc.Snippet
}

if loc.SnippetStartLine != nil {
finding.SnippetStartLine = *loc.SnippetStartLine
}

if nf.NormalizedTask.ExtraData.Fix != nil {
finding.Fix = nf.NormalizedTask.ExtraData.Fix
}
if nf.NormalizedTask.ExtraData.FindingValidation != nil {
finding.Validation = nf.NormalizedTask.ExtraData.FindingValidation
}

finding.Type = DeriveFindingType(
len(nf.NormalizedRemediation.VulnerabilityTypeMetadata.CVEs) > 0,
loc.HasSecret,
finding.FindingCategory,
)

if loc.HasSecret && finding.CodeSnippet != "" {
finding.CodeSnippet = util.MaskSecretInLine(finding.CodeSnippet)
}
if loc.HasSecret && finding.Fix != nil {
finding.Fix = MaskFixSecrets(finding.Fix)
}

finding.Title = GenerateFindingTitle(&finding)
findings = append(findings, finding)
}

return findings, filteredCount
}

// CleanDescription strips internal-only annotation lines that leak into some
// backend descriptions (Code_location, Code Blob, Confidence).
func CleanDescription(desc string) string {
lines := strings.Split(desc, "\n")
var cleaned []string
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Code_location -") ||
strings.HasPrefix(line, "Code Blob -") ||
strings.HasPrefix(line, "Confidence -") {
continue
}
if line != "" {
cleaned = append(cleaned, line)
}
}
return strings.Join(cleaned, " ")
}

// IsEmptyFinding reports whether a NormalizedFinding carries no user-facing
// content and can be dropped from the CLI output.
func IsEmptyFinding(nf model.NormalizedFinding) bool {
hasDescription := nf.NormalizedRemediation.Description != "" ||
nf.NormalizedRemediation.VulnerabilityTypeMetadata.LongDescriptionMarkdown != "" ||
(nf.NormalizedTask.LongDescription != nil && *nf.NormalizedTask.LongDescription != "")

hasCVEsOrCWEs := len(nf.NormalizedRemediation.VulnerabilityTypeMetadata.CVEs) > 0 ||
len(nf.NormalizedRemediation.VulnerabilityTypeMetadata.CWEs) > 0

hasCategory := nf.NormalizedRemediation.FindingCategory != nil

return !hasDescription && !hasCVEsOrCWEs && !hasCategory
}
Loading
Loading