From 3f71988ee1907c8c638cb858986ecc0c5dc0d17b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Prpi=C4=8D?= Date: Mon, 27 Jul 2026 19:46:35 -0400 Subject: [PATCH] feat: add stdin support for linting CVE records (Issue #26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow reading CVE JSON from standard input, either by piping data directly or by passing "-" as the file argument. The CVE ID is extracted from cveMetadata.cveId in the JSON content, falling back to "" when absent. Fixes #26 Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Martin Prpič --- README.md | 14 ++++ cmd/cvelint/main.go | 57 ++++++++++--- internal/linter.go | 38 +++++++++ internal/linter_test.go | 178 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 182ab20..c469e9f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/cvelint/main.go b/cmd/cvelint/main.go index 59a78c1..8bcb6fd 100644 --- a/cmd/cvelint/main.go +++ b/cmd/cvelint/main.go @@ -3,7 +3,7 @@ package main import ( "flag" "fmt" - "github.com/mprpic/cvelint/internal" + "io" "io/fs" "log" "os" @@ -12,6 +12,8 @@ import ( "sort" "strings" "time" + + "github.com/mprpic/cvelint/internal" ) func determineCachePath() string { @@ -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() } @@ -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{""} + } 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{}) @@ -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 { diff --git a/internal/linter.go b/internal/linter.go index 39f1c42..cf9bba6 100644 --- a/internal/linter.go +++ b/internal/linter.go @@ -19,6 +19,7 @@ import ( type Linter struct { Timestamp time.Time FileInput *[]string + StdinInput *string FilesChecked int Results []LintResult GenericErrors []string @@ -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 = "" + } + for _, rule := range *selectedRules { + errors := rule.CheckFunc(&jsonText) + for _, e := range errors { + l.Results = append(l.Results, LintResult{ + File: "", + 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) diff --git a/internal/linter_test.go b/internal/linter_test.go index 9c3ded1..2d0b292 100644 --- a/internal/linter_test.go +++ b/internal/linter_test.go @@ -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{""} + 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 != "" { + t.Errorf("Expected file '', 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{""} + 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{""} + 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{""} + 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 != "" { + t.Errorf("Expected CveId '' 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{""} + 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)) + } +}