diff --git a/CLAUDE.md b/CLAUDE.md index d728cde..cf96c44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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, registry credential management, landing page 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, registry credential management, landing page management, vulnerability scanning, Git integration, and Julia integration. ## Architecture @@ -20,6 +20,7 @@ The application follows a command-line interface pattern using the Cobra library - **tokens.go**: Token management operations (list) with REST API integration - **credentials.go**: Registry credential management (list, add, update, delete) 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 @@ -34,7 +35,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`), registry credential management (old API tried first, new API fallback), 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`), registry credential management (old API tried first, new API fallback), 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 @@ -56,6 +57,7 @@ The application follows a command-line interface pattern using the Cobra library - `jh admin credential update`: Update a credential — subcommands: `token`, `ssh`, `github-app`; accepts JSON argument or stdin - `jh admin credential delete`: Delete a credential — subcommands: `token`, `ssh`, `github-app`; takes positional identifier - `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 @@ -190,6 +192,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 @@ -448,6 +462,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 6eb6ba3..a935909 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, credentials, and system resources (requires admin permissions) ## Installation @@ -242,6 +243,18 @@ echo '{"name":"MyToken","url":"https://github.com","value":"ghp_xxxx"}' | jh adm - `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 @@ -421,6 +434,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 618cb9b..6aaa32a 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 + vuln - 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 vuln MbedTLS_jll\n jh vuln MbedTLS_jll --version 2.28.1010+0\n jh vuln MbedTLS_jll --all\n jh vuln MbedTLS_jll --advisory JLSEC-2025-232", + 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, 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], verbose) + } + }, +} + var packageCmd = &cobra.Command{ Use: "package", Short: "Package search commands", @@ -2019,6 +2110,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) @@ -2045,7 +2142,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..f40e66e --- /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, 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) + } + } + } +}