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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ $ ./cvelint -show-rules # Display available validation rules
$ ./cvelint -h # Display help
```

CVE JSON can be piped directly to `cvelint` instead of passing a file path:

```bash
$ cat CVE-2023-3618.json | cvelint
$ curl -s https://cveawg.mitre.org/api/cve/CVE-2023-3618 | cvelint
$ echo '{"cveMetadata":...}' | cvelint -select E005 -format json
```

You can also pass `-` as the file argument to explicitly read from stdin:

```bash
$ cvelint - < CVE-2023-3618.json
```

## GitHub Action

[cvelint-action](https://github.com/jgamblin/cvelint-action) runs daily and produces a CSV and JSON output of all errors in the current CVE v5 data set.
57 changes: 47 additions & 10 deletions cmd/cvelint/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package main
import (
"flag"
"fmt"
"github.com/mprpic/cvelint/internal"
"io"
"io/fs"
"log"
"os"
Expand All @@ -12,6 +12,8 @@ import (
"sort"
"strings"
"time"

"github.com/mprpic/cvelint/internal"
)

func determineCachePath() string {
Expand Down Expand Up @@ -103,13 +105,40 @@ func collectFiles(args []string) ([]string, error) {
return files, err
}

func readStdin(args []string) *string {
if len(args) == 1 && args[0] == "-" {
data, err := io.ReadAll(os.Stdin)
if err != nil {
log.Fatalf("ERROR: could not read from stdin: %s", err)
}
content := string(data)
return &content
}
if len(args) == 0 {
stat, err := os.Stdin.Stat()
if err != nil {
return nil
}
if (stat.Mode() & os.ModeCharDevice) == 0 {
data, err := io.ReadAll(os.Stdin)
if err != nil {
log.Fatalf("ERROR: could not read from stdin: %s", err)
}
content := string(data)
return &content
}
}
return nil
}

func main() {
log.SetFlags(0)

flag.Usage = func() {
w := flag.CommandLine.Output()
fmt.Fprintf(w, "Usage of %s: [OPTION] [DIRECTORY|FILE]\n", os.Args[0])
fmt.Fprintf(w, "\nIf no directory or file is specified, a clone of the cvelistV5 repo is stored in\n")
fmt.Fprintf(w, "Usage of %s: [OPTION] [DIRECTORY|FILE|-]\n", os.Args[0])
fmt.Fprintf(w, "\nReads from standard input when data is piped or when - is passed as argument.\n")
fmt.Fprintf(w, "If no directory or file is specified, a clone of the cvelistV5 repo is stored in\n")
fmt.Fprintf(w, "the location pointed to in CVELINT_CACHE_DIR, or a standard OS cache location.\n\n")
flag.PrintDefaults()
}
Expand Down Expand Up @@ -147,12 +176,20 @@ func main() {
os.Exit(0)
}

files, err := collectFiles(args)
if err != nil {
log.Fatalf("ERROR: %s", err)
}
if len(files) == 0 {
log.Fatal("ERROR: no CVE record JSON files found")
stdinInput := readStdin(args)

var files []string
if stdinInput != nil {
files = []string{"<stdin>"}
} else {
var err error
files, err = collectFiles(args)
if err != nil {
log.Fatalf("ERROR: %s", err)
}
if len(files) == 0 {
log.Fatal("ERROR: no CVE record JSON files found")
}
}

var ruleCodes = make(map[string]struct{})
Expand Down Expand Up @@ -183,7 +220,7 @@ func main() {
}
}

linter := internal.Linter{Timestamp: time.Now().UTC(), FileInput: &files}
linter := internal.Linter{Timestamp: time.Now().UTC(), FileInput: &files, StdinInput: stdinInput}
linter.Run(&selectedRules, cna)

if summary {
Expand Down
38 changes: 38 additions & 0 deletions internal/linter.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
type Linter struct {
Timestamp time.Time
FileInput *[]string
StdinInput *string
FilesChecked int
Results []LintResult
GenericErrors []string
Expand All @@ -32,7 +33,44 @@ type LintResult struct {
Rule
}

func (l *Linter) runStdin(selectedRules *[]Rule, cna string) {
jsonText := *l.StdinInput
if !gjson.Valid(jsonText) {
l.GenericErrors = append(l.GenericErrors, "Standard input contains invalid JSON")
return
}
recordCna := gjson.Get(jsonText, "cveMetadata.assignerShortName").String()
if recordCna == "" {
return
}
if cna != "" && cna != recordCna {
return
}
cveId := gjson.Get(jsonText, "cveMetadata.cveId").String()
if cveId == "" {
cveId = "<unknown>"
}
for _, rule := range *selectedRules {
errors := rule.CheckFunc(&jsonText)
for _, e := range errors {
l.Results = append(l.Results, LintResult{
File: "<stdin>",
CveId: cveId,
Cna: recordCna,
Error: e,
Rule: rule,
})
}
}
l.FilesChecked = 1
}

func (l *Linter) Run(selectedRules *[]Rule, cna string) {
if l.StdinInput != nil {
l.runStdin(selectedRules, cna)
return
}

var checkedFiles int64
lintResultsChan := make(chan LintResult)
genErrorChan := make(chan string)
Expand Down
178 changes: 178 additions & 0 deletions internal/linter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -480,3 +480,181 @@ func TestLinter_Run_MultipleRules(t *testing.T) {
}
}
}

// TestLinter_Run_StdinInput validates linting from stdin content
func TestLinter_Run_StdinInput(t *testing.T) {
testCVE := `{
"cveMetadata": {
"cveId": "CVE-2023-0001",
"state": "PUBLISHED",
"assignerShortName": "vendor"
},
"containers": {"cna": {}}
}`

files := []string{"<stdin>"}
linter := &Linter{
Timestamp: time.Now().UTC(),
FileInput: &files,
StdinInput: &testCVE,
}

errorRule := Rule{
Code: "ERR001",
Name: "test-error",
Description: "Test error rule",
CheckFunc: func(j *string) []rules.ValidationError {
return []rules.ValidationError{
{Text: "Error 1", JsonPath: "path.1"},
}
},
}

selectedRules := []Rule{errorRule}
linter.Run(&selectedRules, "")

if linter.FilesChecked != 1 {
t.Errorf("Expected 1 file checked, got %d", linter.FilesChecked)
}
if len(linter.Results) != 1 {
t.Errorf("Expected 1 error, got %d", len(linter.Results))
}
if linter.Results[0].File != "<stdin>" {
t.Errorf("Expected file '<stdin>', got '%s'", linter.Results[0].File)
}
if linter.Results[0].CveId != "CVE-2023-0001" {
t.Errorf("Expected CVE ID 'CVE-2023-0001', got '%s'", linter.Results[0].CveId)
}
}

// TestLinter_Run_StdinInvalidJSON validates error handling for invalid JSON via stdin
func TestLinter_Run_StdinInvalidJSON(t *testing.T) {
invalidJSON := `{ this is not valid json }`

files := []string{"<stdin>"}
linter := &Linter{
Timestamp: time.Now().UTC(),
FileInput: &files,
StdinInput: &invalidJSON,
}

dummyRule := Rule{
Code: "DUMMY",
CheckFunc: func(j *string) []rules.ValidationError {
return nil
},
}

selectedRules := []Rule{dummyRule}
linter.Run(&selectedRules, "")

if len(linter.GenericErrors) == 0 {
t.Errorf("Expected generic error for invalid JSON, got none")
}
if !strings.Contains(linter.GenericErrors[0], "invalid JSON") {
t.Errorf("Expected 'invalid JSON' error, got: %s", linter.GenericErrors[0])
}
}

// TestLinter_Run_StdinCNAFilter validates CNA filtering with stdin input
func TestLinter_Run_StdinCNAFilter(t *testing.T) {
testCVE := `{
"cveMetadata": {
"cveId": "CVE-2023-0001",
"state": "PUBLISHED",
"assignerShortName": "vendor1"
},
"containers": {"cna": {}}
}`

files := []string{"<stdin>"}
linter := &Linter{
Timestamp: time.Now().UTC(),
FileInput: &files,
StdinInput: &testCVE,
}

errorRule := Rule{
Code: "ERR001",
CheckFunc: func(j *string) []rules.ValidationError {
return []rules.ValidationError{{Text: "Error", JsonPath: "path"}}
},
}

selectedRules := []Rule{errorRule}
linter.Run(&selectedRules, "vendor2")

if linter.FilesChecked != 0 {
t.Errorf("Expected 0 files checked when CNA doesn't match, got %d", linter.FilesChecked)
}
if len(linter.Results) != 0 {
t.Errorf("Expected 0 results when CNA doesn't match, got %d", len(linter.Results))
}
}

// TestLinter_Run_StdinMissingCveId validates that missing cveId falls back to a placeholder
func TestLinter_Run_StdinMissingCveId(t *testing.T) {
testCVE := `{
"cveMetadata": {
"state": "PUBLISHED",
"assignerShortName": "vendor"
},
"containers": {"cna": {}}
}`

files := []string{"<stdin>"}
linter := &Linter{
Timestamp: time.Now().UTC(),
FileInput: &files,
StdinInput: &testCVE,
}

errorRule := Rule{
Code: "ERR001",
CheckFunc: func(j *string) []rules.ValidationError {
return []rules.ValidationError{{Text: "Error", JsonPath: "path"}}
},
}

selectedRules := []Rule{errorRule}
linter.Run(&selectedRules, "")

if linter.FilesChecked != 1 {
t.Errorf("Expected 1 file checked, got %d", linter.FilesChecked)
}
if len(linter.Results) != 1 {
t.Errorf("Expected 1 result, got %d", len(linter.Results))
}
if linter.Results[0].CveId != "<unknown>" {
t.Errorf("Expected CveId '<unknown>' for missing cveId, got '%s'", linter.Results[0].CveId)
}
}

// TestLinter_Run_StdinNonCVE validates handling of non-CVE JSON via stdin
func TestLinter_Run_StdinNonCVE(t *testing.T) {
nonCVE := `{"key": "value"}`

files := []string{"<stdin>"}
linter := &Linter{
Timestamp: time.Now().UTC(),
FileInput: &files,
StdinInput: &nonCVE,
}

dummyRule := Rule{
Code: "DUMMY",
CheckFunc: func(j *string) []rules.ValidationError {
return []rules.ValidationError{{Text: "Error", JsonPath: "path"}}
},
}

selectedRules := []Rule{dummyRule}
linter.Run(&selectedRules, "")

if linter.FilesChecked != 0 {
t.Errorf("Expected 0 files checked for non-CVE JSON, got %d", linter.FilesChecked)
}
if len(linter.Results) != 0 {
t.Errorf("Expected 0 results for non-CVE JSON, got %d", len(linter.Results))
}
}
Loading