Conversation
…ing from top to bottom the semversions
There was a problem hiding this comment.
Pull request overview
This PR adds a prototype “quick fix” workflow under cmd/devguard-cli/test/ to fetch package metadata from registries (npm/crates) and attempt to validate whether updating a dependency chain results in a vulnerable package version being at/above a fixed version.
Changes:
- Add npm registry response type definitions for JSON unmarshalling.
- Add registry helper functions (npm/crates) and a quickfix CLI that walks a dependency chain.
- Add semver parsing/recommendation logic to select newer versions within the same major band.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 13 comments.
| File | Description |
|---|---|
cmd/devguard-cli/test/types.go |
Introduces structs for npm registry JSON responses. |
cmd/devguard-cli/test/quickfix.go |
Implements the dependency-chain “quick fix” logic and a main() driver. |
cmd/devguard-cli/test/package_manager_functions.go |
Adds registry request helpers for npm/crates and a version existence check. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Parse versions to compare | ||
| vulnParts := parseVersion(vulnVersion) | ||
| fixedParts := parseVersion(fixedVersion) | ||
|
|
There was a problem hiding this comment.
fixedVersion is parsed with parseVersion but never validated as semver. If it’s not in x.y.z form, parseVersion returns 0.0.0 and the comparison can incorrectly report a fix. Consider validating fixedVersion (similar to vulnVersion) and returning an error when invalid.
cmd/devguard-cli/test/types.go
Outdated
| // Copyright 2026 larshermges | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // https://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. |
There was a problem hiding this comment.
The file header uses an Apache-2.0 license block, but the repository (including other cmd/devguard-cli files) is AGPL-3.0. Mixing license headers like this is inconsistent and can create licensing/compliance issues; please align this file’s header with the project’s standard AGPL header.
| // Copyright 2026 larshermges | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // https://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| // Copyright (C) 2026 larshermges | |
| // | |
| // This program is free software: you can redistribute it and/or modify | |
| // it under the terms of the GNU Affero General Public License as | |
| // published by the Free Software Foundation, either version 3 of the | |
| // License, or (at your option) any later version. | |
| // | |
| // This program is distributed in the hope that it will be useful, | |
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| // GNU Affero General Public License for more details. | |
| // | |
| // You should have received a copy of the GNU Affero General Public License | |
| // along with this program. If not, see <https://www.gnu.org/licenses/>. |
cmd/devguard-cli/test/quickfix.go
Outdated
| @@ -0,0 +1,298 @@ | |||
| // Copyright 2026 larshermges @ l3montree GmbH | |||
There was a problem hiding this comment.
This new file is missing the standard AGPL-3.0 license header used throughout the repo (it currently only has a copyright line). Please add the same AGPL header block as other Go files under cmd/devguard-cli to keep licensing consistent.
| // Copyright 2026 larshermges @ l3montree GmbH | |
| // Copyright 2026 larshermges @ l3montree GmbH | |
| // | |
| // This program is free software: you can redistribute it and/or modify | |
| // it under the terms of the GNU Affero General Public License as published by | |
| // the Free Software Foundation, either version 3 of the License, or | |
| // (at your option) any later version. | |
| // | |
| // This program is distributed in the hope that it will be useful, | |
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| // GNU Affero General Public License for more details. | |
| // | |
| // You should have received a copy of the GNU Affero General Public License | |
| // along with this program. If not, see <https://www.gnu.org/licenses/>. |
| @@ -0,0 +1,79 @@ | |||
| // Copyright 2026 larshermges @ l3montree GmbH | |||
There was a problem hiding this comment.
This new file is missing the standard AGPL-3.0 license header used throughout the repo (it currently only has a copyright line). Please add the same AGPL header block as other Go files under cmd/devguard-cli to keep licensing consistent.
| // Copyright 2026 larshermges @ l3montree GmbH | |
| // This file is part of DevGuard. | |
| // Copyright (C) 2026 larshermges @ l3montree GmbH | |
| // | |
| // This program is free software: you can redistribute it and/or modify | |
| // it under the terms of the GNU Affero General Public License as published by | |
| // the Free Software Foundation, either version 3 of the License, or | |
| // (at your option) any later version. | |
| // | |
| // This program is distributed in the hope that it will be useful, | |
| // but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| // GNU Affero General Public License for more details. | |
| // | |
| // You should have received a copy of the GNU Affero General Public License | |
| // along with this program. If not, see <https://www.gnu.org/licenses/>. |
cmd/devguard-cli/test/quickfix.go
Outdated
| return vi[2] > vj[2] | ||
| }) | ||
|
|
||
| fmt.Println(recommended) |
There was a problem hiding this comment.
fmt.Println(recommended) looks like leftover debug output. This will produce noisy stdout for any consumer of the function; please remove it or gate it behind an explicit verbose/debug flag.
| fmt.Println(recommended) |
cmd/devguard-cli/test/quickfix.go
Outdated
| func getPackageManager(Package string) string { | ||
| // insert future Package Managers later | ||
| switch Package { |
There was a problem hiding this comment.
Parameter names in Go should be lowerCamelCase. getPackageManager(Package string) should use a lower-case name (e.g., pkgManager/pkg) to match Go conventions and the rest of this codebase.
| func getPackageManager(Package string) string { | |
| // insert future Package Managers later | |
| switch Package { | |
| func getPackageManager(pkg string) string { | |
| // insert future Package Managers later | |
| switch pkg { |
cmd/devguard-cli/test/quickfix.go
Outdated
| } | ||
| // add more in the future | ||
| return nil, nil |
There was a problem hiding this comment.
getVersion returns (nil, nil) for unsupported package managers. Callers like fetchPackageMetadata unconditionally dereference resp.Body, which will panic if an unsupported manager is passed (e.g., getPackageManager can return "python"). Return a non-nil error for unsupported managers and/or guard against a nil response in fetchPackageMetadata.
| } | |
| // add more in the future | |
| return nil, nil | |
| default: | |
| // add more package managers in the future; for now, treat others as unsupported | |
| return nil, fmt.Errorf("unsupported package manager: %s", packageManager) | |
| } |
| if major == currentMajor { | ||
| if minor >= currentMinor { | ||
| if patch >= currentPatch { | ||
| recommended = append(recommended, versionStr) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The version filtering logic is incorrect for semver ordering. For example, when minor > currentMinor, patch does not need to be >= currentPatch (1.5.0 should be considered newer than 1.4.9). This should compare full semver tuples and select versions strictly greater than the current version within the same major.
| if major == currentMajor { | |
| if minor >= currentMinor { | |
| if patch >= currentPatch { | |
| recommended = append(recommended, versionStr) | |
| } | |
| } | |
| } | |
| if major == currentMajor && (minor > currentMinor || (minor == currentMinor && patch > currentPatch)) { | |
| recommended = append(recommended, versionStr) | |
| } |
| // single node in the dependency tree | ||
| type DependencyNode struct { | ||
| Name string | ||
| Version string | ||
| Dependencies map[string]*DependencyNode | ||
| } | ||
|
|
There was a problem hiding this comment.
DependencyNode is declared but never used. With golangci-lint’s unused linter enabled in this repo, this will likely fail CI; please remove it or use it as part of the implementation/tests.
| // single node in the dependency tree | |
| type DependencyNode struct { | |
| Name string | |
| Version string | |
| Dependencies map[string]*DependencyNode | |
| } |
cmd/devguard-cli/test/quickfix.go
Outdated
| func checkVulnerabilityFixChain(purls []string, fixedVersion string) (bool, error) { | ||
|
|
||
| packages := make([]struct { | ||
| name string | ||
| version string | ||
| }, len(purls)) | ||
|
|
There was a problem hiding this comment.
checkVulnerabilityFixChain will panic on an empty purls slice because it later accesses packages[len(packages)-1]. Add an early validation for len(purls) > 0 (and possibly >= 2 if a chain is required) and return a clear error instead.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 13 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| purl = strings.TrimPrefix(purl, "pkg:npm/") | ||
| parts := strings.Split(purl, "@") | ||
| if len(parts) != 2 { | ||
| return "", "", fmt.Errorf("invalid purl format: %s", purl) | ||
| } |
There was a problem hiding this comment.
Splitting on "@" here breaks for valid npm scoped purls (they may contain an "@" for the scope) and for URL-escaped forms. The repo already uses github.com/package-url/packageurl-go; consider parsing with packageurl.FromString and extracting the .Version instead of manual string splitting.
| if req.StatusCode != 200 { | ||
| return nil, fmt.Errorf("failed to fetch data for %s: %s", pkg.Dependency, req.Status) | ||
| } |
There was a problem hiding this comment.
When req.StatusCode != 200, this returns an error without closing req.Body, which leaks connections and can exhaust the HTTP client's transport. Ensure the response body is closed on all non-nil responses before returning an error (both in the versioned and unversioned branches).
| if req.StatusCode != 200 { | ||
| return nil, fmt.Errorf("failed to fetch data for %s: %s", pkg.Dependency, req.Status) | ||
| } |
There was a problem hiding this comment.
Same connection leak here: on non-200 responses, req.Body is not closed before returning the error. Close the body on all error paths to avoid leaking TCP connections/file descriptors.
| var req *http.Response | ||
| var err error | ||
|
|
||
| normalizedVersion := strings.Trim(pkg.Version, "/") // remove quotes if present |
There was a problem hiding this comment.
The comment says "remove quotes if present", but strings.Trim(pkg.Version, "/") removes slashes, not quotes. Either adjust the trimming to match the intended behavior (e.g., trim quotes/whitespace) or update the comment to avoid misleading future changes.
| normalizedVersion := strings.Trim(pkg.Version, "/") // remove quotes if present | |
| normalizedVersion := strings.Trim(pkg.Version, " \"'") // remove surrounding quotes/whitespace if present |
| if pkg.Version != "" { | ||
| req, err = http.Get("https://registry.npmjs.org/" + pkg.Dependency + "/" + normalizedVersion) | ||
| } else { | ||
| req, err = http.Get("https://registry.npmjs.org/" + pkg.Dependency) | ||
| } |
There was a problem hiding this comment.
These registry calls use http.Get with the default client (no timeout). If a registry is slow/hangs, the CLI can block indefinitely. Consider using an http.Client with a reasonable timeout and/or context-aware requests.
| if minor >= currentMinor { | ||
| if patch >= currentPatch { | ||
| recommended = append(recommended, versionStr) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
This semver comparison is incorrect for candidates where minor > currentMinor: it still requires patch >= currentPatch, which will exclude valid upgrades like 1.3.0 when current is 1.2.9. Consider using a proper semver comparison (or lexicographic compare of major/minor/patch) to include any strictly newer version within the same major.
| if minor >= currentMinor { | |
| if patch >= currentPatch { | |
| recommended = append(recommended, versionStr) | |
| } | |
| } | |
| } | |
| } | |
| if minor > currentMinor || (minor == currentMinor && patch >= currentPatch) { | |
| recommended = append(recommended, versionStr) | |
| } | |
| } | |
| } | |
| } |
| } | ||
|
|
||
| func normalizeVersion(version string) string { | ||
| return strings.Trim(version, "^~\"") |
There was a problem hiding this comment.
normalizeVersion only strips ^, ~, and quotes. Dependency specs commonly include ranges like >=1.2.3, <2, 1.2.x, or complex ranges, which will propagate into packages[i+1].version and later fail IsValidSemver, causing false negatives/errors. Consider using a semver range parser (or existing normalization utilities in the repo) instead of simple trimming.
| return strings.Trim(version, "^~\"") | |
| // Trim common decorators and surrounding whitespace first. | |
| trimmed := strings.TrimSpace(strings.Trim(version, "^~\"")) | |
| // If it's already a valid semver, return as-is. | |
| if IsValidSemver(trimmed) { | |
| return trimmed | |
| } | |
| // Handle simple wildcard patterns like "1.2.x" or "1.2.*" by | |
| // converting them to "1.2.0". | |
| wildcardPattern := regexp.MustCompile(`^(\d+)\.(\d+)\.(x|\*)$`) | |
| if matches := wildcardPattern.FindStringSubmatch(trimmed); matches != nil { | |
| candidate := fmt.Sprintf("%s.%s.0", matches[1], matches[2]) | |
| if IsValidSemver(candidate) { | |
| return candidate | |
| } | |
| } | |
| // Extract the first concrete semver (e.g., from ">=1.2.3 <2" or "1.2.3 || 2.0.0"). | |
| semverPattern := regexp.MustCompile(`\d+\.\d+\.\d+`) | |
| if match := semverPattern.FindString(trimmed); match != "" { | |
| if IsValidSemver(match) { | |
| return match | |
| } | |
| } | |
| // Fallback: return the trimmed value (preserves previous behavior for | |
| // cases we don't explicitly handle). | |
| return trimmed |
|
|
||
| body, err := io.ReadAll(resp.Body) | ||
| resp.Body.Close() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("error reading response for %s@%s: %w", dep, version, err) | ||
| } | ||
|
|
||
| var npmResp NPMResponse | ||
| if err := json.Unmarshal(body, &npmResp); err != nil { |
There was a problem hiding this comment.
io.ReadAll(resp.Body) reads the full registry response into memory; npm metadata can be large for popular packages with many versions. Consider decoding via json.NewDecoder(resp.Body) (streaming) and defer resp.Body.Close() to reduce peak memory and improve robustness.
| body, err := io.ReadAll(resp.Body) | |
| resp.Body.Close() | |
| if err != nil { | |
| return nil, fmt.Errorf("error reading response for %s@%s: %w", dep, version, err) | |
| } | |
| var npmResp NPMResponse | |
| if err := json.Unmarshal(body, &npmResp); err != nil { | |
| defer resp.Body.Close() | |
| // Ensure the io import is used even if no other code in this file references it. | |
| // This has no functional impact and avoids compilation issues due to an unused import. | |
| _ = io.EOF | |
| var npmResp NPMResponse | |
| if err := json.NewDecoder(resp.Body).Decode(&npmResp); err != nil { |
| @@ -0,0 +1,61 @@ | |||
| // Copyright 2026 larshermges @ l3montree GmbH | |||
There was a problem hiding this comment.
This file header is inconsistent with the AGPL license header used across most of the Go codebase (e.g., cmd/devguard-cli/commands/root.go). If this file is meant to be committed as non-generated source, consider adding the standard license header for consistency and compliance.
| // Copyright 2026 larshermges @ l3montree GmbH | |
| // Copyright (C) 2026 l3montree GmbH | |
| // SPDX-License-Identifier: AGPL-3.0-or-later |
| case "crates": | ||
| return GetCratesRegistry(pkg) | ||
| default: | ||
| return nil, fmt.Errorf("unsupported package manager: %s", packageManager) | ||
| } |
There was a problem hiding this comment.
getPackageManager can return values like "python", but getVersion currently only supports "node" and "crates" and otherwise errors. Either implement the missing registry client(s) or keep getPackageManager aligned with the supported cases to avoid inconsistent behavior when new inputs are introduced.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if minor >= currentMinor { | ||
| if patch >= currentPatch { | ||
| recommended = append(recommended, versionStr) | ||
| } |
There was a problem hiding this comment.
The version filter is incorrect: when minor > currentMinor, a version like 1.3.0 will be excluded if currentPatch is higher (because patch >= currentPatch is still required). This can cause the algorithm to miss valid newer versions within the same major. Adjust the comparison to treat (minor > currentMinor) as automatically acceptable, and only compare patches when minor == currentMinor.
| if minor >= currentMinor { | |
| if patch >= currentPatch { | |
| recommended = append(recommended, versionStr) | |
| } | |
| if minor > currentMinor || (minor == currentMinor && patch >= currentPatch) { | |
| recommended = append(recommended, versionStr) |
| if req.StatusCode != 200 { | ||
| return nil, fmt.Errorf("failed to fetch data for %s: %s", pkg.Dependency, req.Status) | ||
| } |
There was a problem hiding this comment.
When StatusCode != 200, the response body is not closed before returning the error. This leaks connections (especially problematic in loops). Ensure the body is closed on all paths once the response is obtained.
|
|
||
| body, err := io.ReadAll(resp.Body) | ||
| resp.Body.Close() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("error reading response for %s@%s: %w", dep, version, err) | ||
| } | ||
|
|
||
| var npmResp NPMResponse | ||
| if err := json.Unmarshal(body, &npmResp); err != nil { |
There was a problem hiding this comment.
fetchPackageMetadata reads the entire registry response into memory with io.ReadAll. NPM package metadata can be very large; this can cause unnecessary memory spikes. Prefer json.NewDecoder(resp.Body).Decode(&npmResp) (and keep defer resp.Body.Close()) to stream decode.
| body, err := io.ReadAll(resp.Body) | |
| resp.Body.Close() | |
| if err != nil { | |
| return nil, fmt.Errorf("error reading response for %s@%s: %w", dep, version, err) | |
| } | |
| var npmResp NPMResponse | |
| if err := json.Unmarshal(body, &npmResp); err != nil { | |
| defer resp.Body.Close() | |
| var npmResp NPMResponse | |
| if err := json.NewDecoder(resp.Body).Decode(&npmResp); err != nil { |
| if req.StatusCode != 200 { | ||
| return nil, fmt.Errorf("failed to fetch data for %s: %s", pkg.Dependency, req.Status) | ||
| } | ||
| return req, nil |
There was a problem hiding this comment.
Same issue here: on non-200 responses, the response body is not closed before returning, which can leak connections. Close the body (or defer close after a successful http.Get).
| func IsValidSemver(version string) bool { | ||
|
|
||
| pattern := `^\d+\.\d+\.\d+$` | ||
| matched, _ := regexp.MatchString(pattern, version) | ||
| return matched | ||
| } | ||
|
|
There was a problem hiding this comment.
regexp.MatchString recompiles the pattern on every call. Since this is used in loops, consider compiling the regex once (package-level var semverRe = regexp.MustCompile(...)) and using semverRe.MatchString(version).
| func IsValidSemver(version string) bool { | |
| pattern := `^\d+\.\d+\.\d+$` | |
| matched, _ := regexp.MatchString(pattern, version) | |
| return matched | |
| } | |
| var semverRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`) | |
| func IsValidSemver(version string) bool { | |
| return semverRe.MatchString(version) | |
| } |
| @@ -0,0 +1,61 @@ | |||
| // Copyright 2026 larshermges @ l3montree GmbH | |||
There was a problem hiding this comment.
Possible typo in the copyright holder name: other files use "lars hermges" (with a space), but this header has "larshermges".
| // Copyright 2026 larshermges @ l3montree GmbH | |
| // Copyright 2026 lars hermges @ l3montree GmbH |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| type Dist struct { | ||
| Shasum string `json:"shasum"` | ||
| Tarball string `json:"tarball"` | ||
| Integrity string `json:"integrity"` | ||
| Signatures []Signatures `json:"signatures"` |
There was a problem hiding this comment.
The element type for Dist.Signatures is named Signatures (plural), which makes usages read oddly ([]Signatures). Consider renaming the struct type to singular (e.g., Signature) while keeping the field plural for clearer Go naming.
| } | ||
|
|
||
| fmt.Printf("Found newer version for %s: %s to %s\n", pkgName, currentVersion, latestVersion) | ||
|
|
There was a problem hiding this comment.
checkVulnerabilityFixChain selects a latestVersion but never assigns it back to packages[i].version. This makes the reported fixing version incorrect (it can return the original top-level version even when an upgrade was required). Persist the selected upgrade version in the tracked package list (or return pkgName@latestVersion directly).
| // update the tracked version for the current package to the selected latest version | |
| packages[i].version = latestVersion |
| } | ||
|
|
||
| if isFixed { | ||
| fixingVersion := packages[0].name + "@" + packages[0].version |
There was a problem hiding this comment.
When isFixed is true, the function returns packages[0].name + "@" + packages[0].version, but packages[0].version is never updated to the chosen upgrade version. This can return an incorrect recommendation. Return the actual upgraded version you validated (e.g., the selected latestVersion).
| fixingVersion := packages[0].name + "@" + packages[0].version | |
| fixingVersion := packages[0].name + "@" + fixedVersion |


No description provided.