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
44 changes: 31 additions & 13 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,29 @@ All notable changes to CryptoScan are recorded here. This project follows
out, where it called them quantum-safe; `pqc --help` calls them PARTIAL too. The `--fail-on` help says
that, when neither the flag nor `failOn` in the config file is set, the scan
exits 1 when any finding is CRITICAL.
- **The release configuration uses the current GoReleaser keys.**
`goreleaser check` (GoReleaser 2.17.1) reported `archives.format`,
`archives.format_overrides.format` and `brews` as deprecated and exited 2.
The archives now set `formats`, and the Homebrew definition is a cask
(`homebrew_casks`), written to `dist/homebrew/Casks/cryptoscan.rb` in place
of the formula in `dist/homebrew/Formula/cryptoscan.rb`. It is still not
uploaded to the tap (`skip_upload: true`). A cask has no `install` or `test`
block, so the formula's `bin.install` and `--version` test are gone; the
cask's `binary "cryptoscan"` stanza installs the binary. `goreleaser check`
now exits 0, and a snapshot build writes the same six archives as before:
tar.gz for Linux and macOS, zip for Windows.

### Fixed

- **Files with the same risk score are listed in the same order on every
run.** `migrationScore.topRiskFiles` in JSON output, and the
`TOP PRIORITY FILES` box of the text report, ordered files by risk score
alone, so files that tied came out in a different order from run to run.
When the tie fell at the fifth place, which files made the list changed
too. With three copies of `crypto-samples` in one directory, six runs of
`cryptoscan scan <dir> --format json --no-config` gave six different
lists. Files that tie are now ordered by path.

- **A scan that read no files no longer reports a clean result.** A scan of an
empty directory, or of one where every file was left out, printed
`Scanned: 0 files • 0 lines • 0 B` and then a check mark with
Expand Down Expand Up @@ -122,19 +142,17 @@ All notable changes to CryptoScan are recorded here. This project follows
`fix` and `remediation` fields of JSON findings; SARIF writes it as the
`help.text` of the finding's rule, and CSV in its Remediation column.

### Changed

- **The release configuration uses the current GoReleaser keys.**
`goreleaser check` (GoReleaser 2.17.1) reported `archives.format`,
`archives.format_overrides.format` and `brews` as deprecated and exited 2.
The archives now set `formats`, and the Homebrew definition is a cask
(`homebrew_casks`), written to `dist/homebrew/Casks/cryptoscan.rb` in place
of the formula in `dist/homebrew/Formula/cryptoscan.rb`. It is still not
uploaded to the tap (`skip_upload: true`). A cask has no `install` or `test`
block, so the formula's `bin.install` and `--version` test are gone; the
cask's `binary "cryptoscan"` stanza installs the binary. `goreleaser check`
now exits 0, and a snapshot build writes the same six archives as before:
tar.gz for Linux and macOS, zip for Windows.
- **The dependency insight lists its libraries in name order.** In
`--format json` output, the `description` of the `dependency_update`
insight names the quantum-vulnerable libraries found in dependency
manifests after `Libraries detected:`. That list followed the iteration
order of a Go map, which changes from run to run, so scans of the same tree
could list the libraries in different orders. Eight scans of a directory
whose `package.json` depends on `node-forge`, `tweetnacl` and `jose` and
whose `requirements.txt` lists `pycryptodome`, `pynacl` and `cryptography`
gave four different orders. The list is now sorted by name, as in
`Libraries detected: cryptography, jose, node-forge, pycryptodome, pynacl, tweetnacl.`,
and the same eight scans give that one order.

## [1.5.0] - 2026-10-18

Expand Down
22 changes: 15 additions & 7 deletions internal/cli/pqc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,26 +261,34 @@ func TestPqcOutputIsDeterministic(t *testing.T) {
}
}

// The JSON check covers pqcFindings, the part pqc adds. The embedded
// migrationScore is the object scan reports, and its topRiskFiles order
// among files with equal risk scores is not yet fixed from run to run.
var first string
// The JSON check covers pqcFindings, the part pqc adds, and the embedded
// migrationScore, whose topRiskFiles lists files the six fixture files
// tie on.
var firstFindings, firstScore string
for n := 0; n < 3; n++ {
resetPipelineFlags(t)
outputFormat = "json"
out, _ := runPqcCapture(t, dir)
var view struct {
PQCFindings json.RawMessage `json:"pqcFindings"`
PQCFindings json.RawMessage `json:"pqcFindings"`
MigrationScore json.RawMessage `json:"migrationScore"`
}
if err := json.Unmarshal([]byte(out), &view); err != nil {
t.Fatalf("pqc --format json is not JSON: %v", err)
}
if n == 0 {
first = string(view.PQCFindings)
firstFindings = string(view.PQCFindings)
firstScore = string(view.MigrationScore)
if firstScore == "" {
t.Fatalf("pqc --format json has no migrationScore:\n%s", out)
}
continue
}
if string(view.PQCFindings) != first {
if string(view.PQCFindings) != firstFindings {
t.Fatalf("pqc --format json: pqcFindings in run %d differs from run 0", n)
}
if string(view.MigrationScore) != firstScore {
t.Fatalf("pqc --format json: migrationScore in run %d differs from run 0", n)
}
}
}
7 changes: 6 additions & 1 deletion pkg/analyzer/scoring.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,13 @@ func CalculateMigrationScoreForRoot(findings []types.Finding, root string) *type
fileList = append(fileList, *fr)
}
}
// fileList comes from a map, so files with the same risk score are
// ordered by path to give the same list on every run.
sort.Slice(fileList, func(i, j int) bool {
return fileList[i].RiskScore > fileList[j].RiskScore
if fileList[i].RiskScore != fileList[j].RiskScore {
return fileList[i].RiskScore > fileList[j].RiskScore
}
return fileList[i].File < fileList[j].File
})
if len(fileList) > 5 {
fileList = fileList[:5]
Expand Down
37 changes: 37 additions & 0 deletions pkg/analyzer/scoring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,43 @@ func TestCalculateMigrationScoreForRootIgnoresDirectoriesAboveTheTarget(t *testi
}
}

func TestTopRiskFilesOrdersTiedFilesByPath(t *testing.T) {
// src/high.go has two findings and the seven other files one each, so
// six files tie at the second place and only four of them fit in the
// list of five.
findings := []types.Finding{
scoringFinding("src/g.go", types.QuantumVulnerable),
scoringFinding("src/c.go", types.QuantumVulnerable),
scoringFinding("src/high.go", types.QuantumVulnerable),
scoringFinding("src/e.go", types.QuantumVulnerable),
scoringFinding("src/a.go", types.QuantumVulnerable),
scoringFinding("src/high.go", types.QuantumVulnerable),
scoringFinding("src/f.go", types.QuantumVulnerable),
scoringFinding("src/b.go", types.QuantumVulnerable),
scoringFinding("src/d.go", types.QuantumVulnerable),
}
want := []string{"src/high.go", "src/a.go", "src/b.go", "src/c.go", "src/d.go"}

// The files are collected in a map, so one run can match by chance.
for run := 0; run < 20; run++ {
input := make([]types.Finding, len(findings))
copy(input, findings)
score := CalculateMigrationScore(input)

var got []string
for _, fr := range score.TopRiskFiles {
got = append(got, fr.File)
}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("run %d: TopRiskFiles = %v, want %v", run, got, want)
}
if score.TopRiskFiles[0].RiskScore != 2*score.TopRiskFiles[1].RiskScore {
t.Fatalf("run %d: RiskScore of %s = %d, want twice the %d of %s", run,
want[0], score.TopRiskFiles[0].RiskScore, score.TopRiskFiles[1].RiskScore, want[1])
}
}
}

func TestNoQuantumSafeRecommendationRespectsHybridFindings(t *testing.T) {
const noSafe = "No quantum-safe algorithms detected"

Expand Down
49 changes: 48 additions & 1 deletion pkg/reporter/changelog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package reporter
import (
"encoding/csv"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
Expand All @@ -15,7 +16,7 @@ import (
)

// changelogFile is the release history. Its entries quote text that the
// reporters write, and the test in this file holds such a quote to the SARIF
// reporters write, and a test in this file holds such a quote to the SARIF
// and CSV output of a real scan.
const changelogFile = "../../CHANGELOG.md"

Expand Down Expand Up @@ -80,6 +81,52 @@ func TestChangelogEntries(t *testing.T) {
}
}

// repeatedChangelogHeadings returns one line for each "### " heading of
// markdown that appears again under the same "## " release, naming the
// heading, the release and the line numbers of both.
func repeatedChangelogHeadings(markdown string) []string {
var repeats []string
var release string
seen := map[string]int{}
for i, line := range strings.Split(markdown, "\n") {
switch {
case strings.HasPrefix(line, "## "):
release = strings.TrimPrefix(line, "## ")
seen = map[string]int{}
case strings.HasPrefix(line, "### "):
if first, ok := seen[line]; ok {
repeats = append(repeats, fmt.Sprintf("%q under %q is at lines %d and %d", line, release, first, i+1))
} else {
seen[line] = i + 1
}
}
}
return repeats
}

func TestRepeatedChangelogHeadings(t *testing.T) {
markdown := "# Changelog\n\n## [Unreleased]\n\n### Changed\n\n- **A.**\n\n### Fixed\n\n- **B.**\n\n" +
"### Changed\n\n- **C.**\n\n## [1.0.0]\n\n### Changed\n\n- **D.**\n"
got := repeatedChangelogHeadings(markdown)
want := []string{`"### Changed" under "[Unreleased]" is at lines 5 and 13`}
if len(got) != len(want) || got[0] != want[0] {
t.Fatalf("got %q, want %q", got, want)
}
}

// Each release lists one heading per kind of change. The [Unreleased] section
// had a second "### Changed" after "### Fixed", holding an entry that belongs
// under the first one.
func TestChangelogReleasesHaveOneHeadingPerKind(t *testing.T) {
raw, err := os.ReadFile(changelogFile)
if err != nil {
t.Fatal(err)
}
for _, repeat := range repeatedChangelogHeadings(string(raw)) {
t.Errorf("%s: %s; move the entries under the second one to the first", changelogFile, repeat)
}
}

// scanCryptoJSDependency runs the real scanner over a directory whose
// package.json lists crypto-js, and returns the ID and help text of the
// crypto-js dependency rule in the SARIF output, and the Remediation column
Expand Down
43 changes: 43 additions & 0 deletions pkg/scanner/determinism_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,46 @@ d = 1 # sha1 stays until the legacy peers migrate
}
}
}

// TestDependencyInsightListsLibrariesByName covers the "Libraries detected:"
// list of the dependency_update insight. The list was built by ranging over a
// map, and Go randomises map iteration order, so two scans of the same tree
// could list the libraries in different orders. It is sorted by name now.
func TestDependencyInsightListsLibrariesByName(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "package.json"), `{
"name": "fixture",
"version": "1.0.0",
"dependencies": {
"node-forge": "^1.3.1",
"tweetnacl": "^1.0.3",
"jose": "^5.2.0"
}
}
`)
write(t, filepath.Join(dir, "requirements.txt"), `pycryptodome==3.20.0
pynacl==1.5.0
cryptography==42.0.5
`)

const want = "Libraries detected: cryptography, jose, node-forge, pycryptodome, pynacl, tweetnacl. " +
"These provide quantum-vulnerable algorithms."
for run := 0; run < 5; run++ {
results, err := New(Config{Target: dir, MinSeverity: types.SeverityInfo}).Scan()
if err != nil {
t.Fatalf("scan: %v", err)
}
var got []string
for _, insight := range results.Insights {
if insight.Type == "dependency_update" {
got = append(got, insight.Description)
}
}
if len(got) != 1 {
t.Fatalf("run %d: want one dependency_update insight, got %d: %q", run, len(got), got)
}
if got[0] != want {
t.Fatalf("run %d: description = %q, want %q", run, got[0], want)
}
}
}
3 changes: 3 additions & 0 deletions pkg/scanner/scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -1693,6 +1693,9 @@ func (s *Scanner) generateInsights() []Insight {
for lib := range libs {
libList = append(libList, lib)
}
// Go randomises map iteration order, so without this sort two scans
// of the same tree could list the libraries in different orders.
sort.Strings(libList)
insights = append(insights, Insight{
Type: "dependency_update",
Title: fmt.Sprintf("Crypto Libraries Need PQC Upgrade (%d libraries)", len(libs)),
Expand Down
Loading