diff --git a/CLAUDE.md b/CLAUDE.md index cfe1777..459774a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,8 +3,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview - -This is a Go-based CLI tool for interacting with JuliaHub, a platform for Julia computing. The CLI provides commands for authentication, dataset management, registry management, package management, project management, user information, token management, Git integration, and Julia integration. +This is a Go-based CLI tool for interacting with JuliaHub, a platform for Julia computing. The CLI provides commands for authentication, dataset management, registry management, package management, project management, user information, token management, vulnerability scanning, Git integration, and Julia integration. ## Architecture @@ -19,6 +18,7 @@ The application follows a command-line interface pattern using the Cobra library - **user.go**: User information retrieval using GraphQL API and REST API for listing users - **tokens.go**: Token management operations (list) with REST API integration - **landing.go**: Landing page management (show, update, remove) with REST API integration +- **vuln.go**: Vulnerability scanning for Julia packages via REST API - **git.go**: Git integration (clone, push, fetch, pull) with JuliaHub authentication - **julia.go**: Julia installation and management - **run.go**: Julia execution with JuliaHub configuration @@ -33,7 +33,7 @@ The application follows a command-line interface pattern using the Cobra library - Stores tokens securely in `~/.juliahub` with 0600 permissions 2. **API Integration**: - - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`, `/api/v1/registry/config/registry/{name}`), package search/info primary path (`/packages/info`), token management (`/app/token/activelist`), user management (`/app/config/features/manage`), and landing page management (`/app/homepage` GET, `/app/config/homepage` POST/DELETE) + - **REST API**: Used for dataset operations (`/api/v1/datasets`, `/datasets/{uuid}/url/{version}`), registry operations (`/api/v1/registry/registries/descriptions`, `/api/v1/registry/config/registry/{name}`), package search/info primary path (`/packages/info`), token management (`/app/token/activelist`), user management (`/app/config/features/manage`), landing page management (`/app/homepage` GET, `/app/config/homepage` POST/DELETE), and vulnerability scanning (`/api/v1/ui/vulnerabilities/packages/{name}`) - **GraphQL API**: Used for projects, user info, and package search/info fallback (`/v1/graphql`) - **Headers**: All GraphQL requests require `X-Hasura-Role: jhuser` header - **Authentication**: Uses ID tokens (`token.IDToken`) for API calls @@ -50,6 +50,7 @@ The application follows a command-line interface pattern using the Cobra library - `jh admin user`: User management (list all users with REST API, supports verbose mode) - `jh admin token`: Token management (list all tokens with REST API, supports verbose mode) - `jh admin landing-page`: Landing page management (show/update/remove custom markdown landing page with REST API) + - `jh vuln`: Vulnerability scanning for Julia packages (REST API; defaults to latest stable version via `GET /docs///versions.json`; supports `--version` for a specific version, `--registry` to specify the registry for version lookup (default: General), `--advisory` to filter to a specific advisory ID, `--all` to show all advisories regardless of affected status, and `--verbose` for additional details) - `jh clone`: Git clone with JuliaHub authentication and project name resolution - `jh push/fetch/pull`: Git operations with JuliaHub authentication - `jh git-credential`: Git credential helper for seamless authentication @@ -163,6 +164,18 @@ cat landing.md | go run . admin landing-page update go run . admin landing-page remove ``` +### Test vulnerability scan operations +```bash +go run . vuln MbedTLS_jll +go run . vuln MbedTLS_jll --version 2.28.1010+0 +go run . vuln MbedTLS_jll --all +go run . vuln MbedTLS_jll --advisory JLSEC-2025-232 +go run . vuln MbedTLS_jll --advisory JLSEC-2025-232 --verbose +go run . vuln MbedTLS_jll --verbose +go run . vuln MyPkg --registry MyRegistry +go run . vuln SomePackage -s nightly.juliahub.dev +``` + ### Test Git operations ```bash go run . clone john/my-project # Clone from another user @@ -412,6 +425,14 @@ jh run setup - `executeGraphQL(server, token, req)` in `packages.go` is a shared helper for GraphQL POST requests (sets Authorization, Content-Type, Accept, X-Hasura-Role headers) - `getPackageInfo` in `packages.go` implements exact name-match lookup using REST-first (`getPackageInfoREST`), GraphQL fallback (`getPackageInfoGraphQL`); `packageInfoCmd` in `main.go` resolves registries via `fetchRegistries` - `getPackageDependencies` uses GraphQL (`fetchGraphQLPackages`) to locate the package, then fetches `/docs/{registry}/{package}/stable/pkg.json` for dependency data; no REST fallback (docs endpoint is authoritative) +- `jh vuln` uses two REST endpoints: vulnerabilities at `/api/v1/ui/vulnerabilities/packages/{name}?version=` and latest version at `/docs///versions.json` (first entry is latest); no GraphQL fallback +- When no `--version` is given, `fetchLatestVersion` calls the versions.json endpoint (registry defaults to `General`, overridable with `--registry`) +- By default only advisories where `is_affected == true` are shown; `--all` overrides this +- Each advisory is printed as: Advisory ID (clickable OSC8 hyperlink to JuliaLang SecurityAdvisories), Affected (Yes/No), Severity scores (all, comma-separated), full Summary, Affected versions (one line), Version ranges (one line, with ranges type) +- `--advisory ` filters to a single advisory (case-insensitive match); same output format +- `--verbose` adds: Aliases, Published date, Modified date, References +- `advisoryLink` in `vuln.go` builds the OSC8 terminal hyperlink to `https://github.com/JuliaLang/SecurityAdvisories.jl/blob/main/advisories/published//.md` +- `topSeverity` in `vuln.go` prefers CVSS_V3 scores; falls back to first available score; returns "N/A" if none ## Implementation Details diff --git a/README.md b/README.md index 5baa413..d030751 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A command-line interface for interacting with JuliaHub, a platform for Julia com - **Git Integration**: Clone, push, fetch, and pull with automatic JuliaHub authentication - **Julia Integration**: Install Julia and run with JuliaHub package server configuration - **User Management**: Display user information and view profile details +- **Vulnerability Scanning**: Scan Julia packages for known security vulnerabilities - **Administrative Commands**: Manage users, tokens, and system resources (requires admin permissions) ## Installation @@ -223,6 +224,18 @@ go build -o jh . - `cat landing.md | jh admin landing-page update` - Read content from stdin - `jh admin landing-page remove` - Remove the custom landing page and revert to the default +### Vulnerability Scanning (`jh vuln`) + +- `jh vuln ` - Show known vulnerabilities for a Julia package + - Defaults to the latest stable version (fetched from the registry); only shows advisories where that version is affected + - `--version ` - Check a specific version instead of the latest + - `--registry ` - Registry to use for version lookup (default: `General`) + - `--advisory ` - Filter to a specific advisory ID (e.g. a CVE or GHSA identifier) + - `--all` - Show all advisories regardless of whether the queried version is affected + - `--verbose` / `-v` - Show additional details: aliases, published/modified dates, and references + - Advisory IDs are clickable links to the JuliaLang SecurityAdvisories repository + - Each advisory shows: severity scores, affected status, full summary, affected versions, and version ranges + ### Update (`jh update`) - `jh update` - Check for updates and automatically install the latest version @@ -378,6 +391,31 @@ cat landing.md | jh admin landing-page update jh admin landing-page remove ``` +### Vulnerability Scanning + +```bash +# Scan latest stable version (only shows advisories where it is affected) +jh vuln MbedTLS_jll + +# Scan a specific version +jh vuln MbedTLS_jll --version 2.28.1010+0 + +# Show all advisories regardless of affected status +jh vuln MbedTLS_jll --all + +# Filter to a specific advisory +jh vuln MbedTLS_jll --advisory JLSEC-2025-232 + +# Show extra details (aliases, dates, references) +jh vuln MbedTLS_jll --verbose + +# Use a non-General registry for version lookup +jh vuln MyPkg --registry MyRegistry + +# Scan against a custom server +jh vuln SomePackage -s nightly.juliahub.dev +``` + ### Git Workflow ```bash diff --git a/main.go b/main.go index dac96d0..91e20f0 100644 --- a/main.go +++ b/main.go @@ -175,6 +175,7 @@ Available command categories: pull - Pull changes with authentication julia - Julia installation and management run - Run Julia with JuliaHub configuration + scan - Scan a package for known vulnerabilities Use 'jh --help' for more information about a specific command.`, } @@ -643,6 +644,96 @@ PROVIDER TYPES }` } +var vulnCmd = &cobra.Command{ + Use: "vuln ", + Short: "Show known vulnerabilities for a package", + Long: `Show known security vulnerabilities for a Julia package. + +Defaults to checking the latest stable version of the package. Use --version to +check a specific version. Only advisories that affect the queried version are shown +by default; use --all to list all advisories regardless of affected status. + +Use --advisory to look up a specific advisory by ID.`, + Example: " jh scan MbedTLS_jll\n jh scan MbedTLS_jll --version 2.28.1010+0\n jh scan MbedTLS_jll --all\n jh scan MbedTLS_jll --advisory GHSA-xxx-yyy-zzz", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + server, err := getServerFromFlagOrConfig(cmd) + if err != nil { + fmt.Printf("Failed to get server config: %v\n", err) + os.Exit(1) + } + + packageName := args[0] + version, _ := cmd.Flags().GetString("version") + advisory, _ := cmd.Flags().GetString("advisory") + registry, _ := cmd.Flags().GetString("registry") + all, _ := cmd.Flags().GetBool("all") + verbose, _ := cmd.Flags().GetBool("verbose") + + if version == "" { + latest, err := fetchLatestVersion(server, registry, packageName) + if err != nil { + fmt.Printf("Failed to fetch latest version: %v\n", err) + os.Exit(1) + } + version = latest + } + + vulns, err := fetchVulnerabilities(server, packageName, version) + if err != nil { + fmt.Printf("Failed to fetch vulnerabilities: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Package: %s", packageName) + if version != "" { + fmt.Printf(" (%s)", version) + } + fmt.Println() + fmt.Println() + + if advisory != "" { + for _, v := range vulns { + if strings.EqualFold(v.AdvisoryID, advisory) { + printAdvisory(&v, true, verbose) + return + } + } + fmt.Printf("Advisory %q not found for package %s.\n", advisory, packageName) + os.Exit(1) + } + + var toShow []PackageVulnerability + for _, v := range vulns { + if all || (v.IsAffected != nil && *v.IsAffected) { + toShow = append(toShow, v) + } + } + + if len(toShow) == 0 { + if all { + fmt.Println("No vulnerabilities found.") + } else { + fmt.Println("No known vulnerabilities affecting this version.") + } + return + } + + suffix := "ies" + if len(toShow) == 1 { + suffix = "y" + } + fmt.Printf("Found %d vulnerabilit%s:\n\n", len(toShow), suffix) + + for i := range toShow { + if i > 0 { + fmt.Println() + } + printAdvisory(&toShow[i], false, verbose) + } + }, +} + var packageCmd = &cobra.Command{ Use: "package", Short: "Package search commands", @@ -1671,6 +1762,12 @@ func init() { fetchCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") pullCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") updateCmd.Flags().Bool("force", false, "Force update even if current version is newer than latest release") + vulnCmd.Flags().StringP("server", "s", "juliahub.com", "JuliaHub server") + vulnCmd.Flags().StringP("version", "V", "", "Package version to check (defaults to latest stable)") + vulnCmd.Flags().StringP("advisory", "a", "", "Look up a specific advisory by ID") + vulnCmd.Flags().StringP("registry", "r", "General", "Registry name for version lookup") + vulnCmd.Flags().Bool("all", false, "Show all advisories regardless of affected status") + vulnCmd.Flags().BoolP("verbose", "v", false, "Show full advisory details (aliases, dates, details, references)") authCmd.AddCommand(authLoginCmd, authRefreshCmd, authStatusCmd, authEnvCmd) jobCmd.AddCommand(jobListCmd, jobStartCmd) @@ -1693,7 +1790,7 @@ func init() { runCmd.AddCommand(runSetupCmd) gitCredentialCmd.AddCommand(gitCredentialHelperCmd, gitCredentialGetCmd, gitCredentialStoreCmd, gitCredentialEraseCmd, gitCredentialSetupCmd) - rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, projectCmd, packageCmd, registryCmd, userCmd, adminCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd) + rootCmd.AddCommand(authCmd, jobCmd, datasetCmd, projectCmd, packageCmd, registryCmd, userCmd, adminCmd, juliaCmd, cloneCmd, pushCmd, fetchCmd, pullCmd, runCmd, gitCredentialCmd, updateCmd, vulnCmd) } func main() { diff --git a/vuln.go b/vuln.go new file mode 100644 index 0000000..04ac4bb --- /dev/null +++ b/vuln.go @@ -0,0 +1,154 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + "time" +) + +type SeverityScore struct { + Type string `json:"type"` + Score string `json:"score"` +} + +type RangeEvent struct { + EventType string `json:"event_type"` + Version string `json:"version"` +} + +type PackageVulnerability struct { + AdvisoryID string `json:"advisory_id"` + Summary string `json:"summary"` + Details string `json:"details"` + Published *time.Time `json:"published"` + Modified *time.Time `json:"modified"` + SeverityScores []SeverityScore `json:"severity_scores"` + Aliases []string `json:"aliases"` + References []string `json:"references"` + AffectedVersions []string `json:"affected_versions"` + RangesType string `json:"ranges_type"` + RangeEvents []RangeEvent `json:"range_events"` + IsAffected *bool `json:"is_affected"` +} + +func fetchVulnerabilities(server, packageName, version string) ([]PackageVulnerability, error) { + token, err := ensureValidToken() + if err != nil { + return nil, fmt.Errorf("authentication required: %w", err) + } + + endpoint := fmt.Sprintf("https://%s/api/v1/ui/vulnerabilities/packages/%s", + server, url.PathEscape(packageName)) + if version != "" { + endpoint += "?version=" + url.QueryEscape(version) + } + + body, err := apiGet(endpoint, token.IDToken) + if err != nil { + return nil, err + } + + var vulns []PackageVulnerability + if err := json.Unmarshal(body, &vulns); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + return vulns, nil +} + +func fetchLatestVersion(server, registry, packageName string) (string, error) { + token, err := ensureValidToken() + if err != nil { + return "", fmt.Errorf("authentication required: %w", err) + } + + endpoint := fmt.Sprintf("https://%s/docs/%s/%s/versions.json", server, url.PathEscape(registry), url.PathEscape(packageName)) + body, err := apiGet(endpoint, token.IDToken) + if err != nil { + return "", err + } + + var versions []string + if err := json.Unmarshal(body, &versions); err != nil { + return "", fmt.Errorf("failed to parse versions: %w", err) + } + if len(versions) == 0 { + return "", fmt.Errorf("no versions found for %s", packageName) + } + return versions[0], nil +} + +// topSeverity returns the highest CVSS_V3 score string, or the first available, or "N/A". +func topSeverity(scores []SeverityScore) string { + for _, s := range scores { + if strings.HasPrefix(s.Type, "CVSS_V3") && s.Score != "" { + return s.Score + } + } + if len(scores) > 0 && scores[0].Score != "" { + return scores[0].Score + } + return "N/A" +} + +func advisoryLink(v *PackageVulnerability) string { + year := "unknown" + if v.Published != nil { + year = fmt.Sprintf("%d", v.Published.Year()) + } + url := fmt.Sprintf("https://github.com/JuliaLang/SecurityAdvisories.jl/blob/main/advisories/published/%s/%s.md", year, v.AdvisoryID) + return fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, v.AdvisoryID) +} + +func printAdvisory(v *PackageVulnerability, showRanges bool, verbose bool) { + fmt.Printf("Advisory: %s\n", advisoryLink(v)) + if v.IsAffected != nil { + if *v.IsAffected { + fmt.Println("Affected: Yes") + } else { + fmt.Println("Affected: No") + } + } + if len(v.SeverityScores) > 0 { + parts := make([]string, len(v.SeverityScores)) + for i, s := range v.SeverityScores { + parts[i] = s.Type + ": " + s.Score + } + fmt.Printf("Severity: %s\n", strings.Join(parts, ", ")) + } + if v.Summary != "" { + fmt.Printf("Summary: %s\n", v.Summary) + } + if len(v.AffectedVersions) > 0 { + fmt.Printf("Affected versions: %s\n", strings.Join(v.AffectedVersions, ", ")) + } + if len(v.RangeEvents) > 0 { + parts := make([]string, len(v.RangeEvents)) + for i, re := range v.RangeEvents { + parts[i] = re.EventType + ": " + re.Version + } + rangeLabel := "Version ranges" + if v.RangesType != "" { + rangeLabel += fmt.Sprintf(" (%s)", v.RangesType) + } + fmt.Printf("%s: %s\n", rangeLabel, strings.Join(parts, ", ")) + } + if verbose { + if len(v.Aliases) > 0 { + fmt.Printf("Aliases: %s\n", strings.Join(v.Aliases, ", ")) + } + if v.Published != nil { + fmt.Printf("Published: %s\n", v.Published.Format("2006-01-02")) + } + if v.Modified != nil { + fmt.Printf("Modified: %s\n", v.Modified.Format("2006-01-02")) + } + if len(v.References) > 0 { + fmt.Println("References:") + for _, r := range v.References { + fmt.Printf(" %s\n", r) + } + } + } +}