diff --git a/README.md b/README.md index 280898d..675d28c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ hodeishield login # sign in through the browser and pick a tenant hodeishield vendors list # your third-party inventory ``` +On a server, in a container or in CI there is usually no keychain and `login` stops with an error: use a +[tenant API key](#with-a-tenant-api-key-scripts-ci-servers) instead. + Verify the download before you run it: see [Install](#install) and [Verify a download](#verify-a-download). Read your third-party inventory, supply-chain alerts, risk register, compliance posture, evidence and @@ -60,6 +63,14 @@ section), and put `hodeishield` (`hodeishield.exe` on Windows) somewhere on your statically linked and run on any distribution. Homebrew and Scoop packages (next section) and a [container image](#container-image) are also available. +On Linux you need `curl` (or another way to download), `tar`, `gzip` and a CA certificate bundle +(the `ca-certificates` package). Minimal Debian, Ubuntu and Alpine images do not include `curl` or +the bundle: install them first, for example `apt-get install curl ca-certificates` or +`apk add curl ca-certificates`. Verifying a download also needs +[`cosign`](https://docs.sigstore.dev/cosign/system_config/installation/) v3 (or v2.4 or newer) and, +for the provenance check, [`slsa-verifier`](https://github.com/slsa-framework/slsa-verifier#installation); +neither is part of a distribution's default install. + From source, with the Rust toolchain installed (pin the release tag you want): ```sh @@ -77,7 +88,8 @@ scoop bucket add hodeishield https://github.com/Hodeitek/scoop-hodeishield scoop install hodeishield # Windows, per user ``` -`scoop bucket add` needs Git: on a fresh Scoop, run `scoop install git` first. Scoop's own installer +Scoop itself must be installed first (see [scoop.sh](https://scoop.sh)); on a clean Windows machine +`scoop` is not a known command. `scoop bucket add` needs Git: on a fresh Scoop, run `scoop install git` first. Scoop's own installer refuses an elevated (administrator) shell unless it is run with `-RunAsAdmin`; installing per user, from a normal shell, is the recommended way and needs no administrator rights. @@ -110,6 +122,33 @@ sudo dnf install ./hodeishield--1.x86_64.rpm # Fedora, RHEL (arm64: Upgrading is the same command with the newer package; `sudo apt remove hodeishield` or `sudo dnf remove hodeishield` uninstalls it. +### Uninstall an archive or install.sh install + +Nothing else is installed on the machine, so remove the files you copied. `install.sh` puts the +binary in `/usr/local/bin` or `~/.local/bin` (or the `--dir` you gave) and the man pages in +`share/man/man1` next to it: + +```sh +hodeishield logout # revokes the sign-in and removes the stored token +rm "$(command -v hodeishield)" # the binary +rm -f /usr/local/share/man/man1/hodeishield*.1 # man pages (~/.local/share/man/man1 for ~/.local/bin) +rm -f "$(hodeishield config path)" # optional: the settings +``` + +Run `hodeishield logout` first; with the binary gone it can no longer do it. Remove the completion +files too if you copied them. + +On Windows, `hodeishield logout` first removes the sign-in from Credential Manager, then: + +- **Zip:** delete the folder you unpacked it into, and remove it from your `PATH` if you added it + (the zip never changes `PATH`). +- **MSI:** uninstall from **Settings → Apps** or with `msiexec /x`. This removes the program folder, + the `PATH` entry and the Apps entry. +- **Scoop:** `scoop uninstall hodeishield`, which removes the program and its shim. + +None of these remove the per-user settings in `%APPDATA%\Hodeitek\HodeiShield\config`, one folder +in each user profile that ran `config set`; delete it if you want them gone. + ### Windows installer (MSI) From 0.3.0, each release also publishes @@ -229,7 +268,7 @@ Each release has a `SHA256SUMS` file, a Sigstore bundle (`*.sigstore.json`) for `SHA256SUMS`, and SLSA provenance (`hodeishield.intoto.jsonl`). In short: ```sh -sha256sum --ignore-missing -c SHA256SUMS +grep hodeishield-0.5.0-x86_64-unknown-linux-musl.tar.gz SHA256SUMS | sha256sum -c - cosign verify-blob hodeishield-0.5.0-x86_64-unknown-linux-musl.tar.gz \ --bundle hodeishield-0.5.0-x86_64-unknown-linux-musl.tar.gz.sigstore.json \ @@ -238,6 +277,21 @@ cosign verify-blob hodeishield-0.5.0-x86_64-unknown-linux-musl.tar.gz \ --certificate-github-workflow-trigger push ``` +On Windows, PowerShell has no `sha256sum`: compare the hash of the zip or MSI with its line in +`SHA256SUMS`. `cosign` for Windows is a `cosign-windows-amd64.exe` download on its +[release page](https://github.com/sigstore/cosign/releases) (`winget install sigstore.cosign` may +fail with a missing-source error), and the same `verify-blob` command works with Windows paths: + +```powershell +(Get-FileHash .\hodeishield-0.5.0-x86_64-pc-windows-msvc.zip -Algorithm SHA256).Hash.ToLower() +Select-String hodeishield-0.5.0-x86_64-pc-windows-msvc.zip SHA256SUMS # the two hashes must match +cosign verify-blob .\hodeishield-0.5.0-x86_64-pc-windows-msvc.zip ` + --bundle .\hodeishield-0.5.0-x86_64-pc-windows-msvc.zip.sigstore.json ` + --certificate-identity https://github.com/Hodeitek/hodeishield-cli/.github/workflows/release.yml@refs/tags/v0.5.0 ` + --certificate-oidc-issuer https://token.actions.githubusercontent.com ` + --certificate-github-workflow-trigger push +``` + A valid signature proves the file was built by this repository's release workflow from exactly that tag, with no long-lived key involved; put your version in the identity rather than matching any tag. The full steps, including the provenance check with `slsa-verifier`, are in [docs/verifying-releases.md](docs/verifying-releases.md). @@ -313,6 +367,7 @@ leaves those fields null and says why on standard error. ```sh hodeishield login # opens the browser; the app redirects back to 127.0.0.1 hodeishield login --device # no browser here: approve a short code from another device +hodeishield login --no-browser # print the sign-in URL instead of opening a browser hodeishield whoami hodeishield logout # revokes the token at the app and removes it from the keychain ``` @@ -351,10 +406,11 @@ the file store below is an explicit choice, never a fallback. The keychain can also be out of reach in a session that has one on the machine, such as an SSH or other remote logon on Windows. With no API key set, commands then stop with exit code 3 and say that the system keychain is not available in this session; set `HODEISHIELD_API_KEY` in remote or -automated sessions. With the key set, the keychain is not read at all. +automated sessions. `login` and `logout` exit 1 in that case, not 3. With the key set, the keychain +is not read at all. To cut the CLI's access from elsewhere, revoke it in [the app](https://app.hodeishield.com) under -**Account → Application access**. Signing out of the web app does not cut it. +**Account → Applications**. Signing out of the web app does not cut it. ### Servers, containers and remote sessions @@ -373,7 +429,7 @@ a code. You have two options, in this order of preference: - **How long.** At most 90 days from the sign-in, however often the session is refreshed, and at most 30 days after the CLI last refreshed it. It ends earlier with `hodeishield logout`, when you change or reset your password, when an administrator revokes it, when you are removed from - the tenant, or when you revoke the access in the app under **Account › Applications**. + the tenant, or when you revoke the access in the app under **Account → Applications**. - **Reuse is detected.** Each refresh replaces the refresh token. If someone refreshes with a copy of your file, the next refresh by your own CLI is seen as a reuse of an old token and ends the session for both. An unexpected "sign in again" is a reason to revoke the access in @@ -675,6 +731,10 @@ hodeishield --profile default vendors list # or pick one per command | `HODEISHIELD_TOKEN_STORE` | Where sign-in tokens are kept: `keychain` (the default) or `plaintext-file`, an [opt-in](#servers-containers-and-remote-sessions); takes precedence over `token_store` in the configuration file | | `HODEISHIELD_NO_UPDATE_CHECK` | Any non-empty value turns off the [new-version notice](#new-version-notice) | +`config set` and `config unset` take `api_url`, `app_url`, `oauth_client_id` and `oauth_scopes` +(per profile), and `update_check` (`true` or `false`) and `token_store` (`keychain` or +`plaintext-file`), which are global. + The configuration file holds URLs and sign-in settings only, never a credential. `update_check` and `token_store` are global settings, not per profile. An older CLI refuses a configuration file that contains `token_store`; use the environment variable there. URLs must use HTTPS; diff --git a/crates/hodeishield-cli/src/auth/oauth.rs b/crates/hodeishield-cli/src/auth/oauth.rs index 8600518..7159e4e 100644 --- a/crates/hodeishield-cli/src/auth/oauth.rs +++ b/crates/hodeishield-cli/src/auth/oauth.rs @@ -7,7 +7,7 @@ //! //! No endpoint is written here: every URL comes from the metadata the app publishes. -use crate::failure::{Failure, Kind, Result, error_chain}; +use crate::failure::{Failure, Kind, Result, ca_hint, error_chain}; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use hodeishield_api::v1::operations; @@ -102,10 +102,11 @@ pub fn http_client(app_url: &Url) -> Result { .https_only(!hodeishield_api::is_loopback_host(app_url)) .build() .map_err(|e| { - Failure::general(format!( - "Cannot start the HTTP client: {}.", - error_chain(&e) - )) + let chain = error_chain(&e); + ca_hint( + Failure::general(format!("Cannot start the HTTP client: {chain}.")), + &chain, + ) }) } diff --git a/crates/hodeishield-cli/src/cli.rs b/crates/hodeishield-cli/src/cli.rs index 3731181..ec228d4 100644 --- a/crates/hodeishield-cli/src/cli.rs +++ b/crates/hodeishield-cli/src/cli.rs @@ -567,12 +567,12 @@ pub enum ConfigCommand { Path, /// Show the settings in effect for the profile. Show, - /// Set a key of the profile: api_url, app_url, oauth_client_id, oauth_scopes. + /// Set a key: api_url, app_url, oauth_client_id, oauth_scopes (per profile), update_check, token_store (global). Set { /// The key to set. #[arg(value_parser = PossibleValuesParser::new(crate::config::KEYS))] key: String, - /// The value. For oauth_scopes, a space-separated list. + /// The value. For oauth_scopes, a space-separated list; update_check takes true or false; token_store takes keychain or plaintext-file. value: String, }, /// Remove a key from the profile, going back to the default. diff --git a/crates/hodeishield-cli/src/commands/session.rs b/crates/hodeishield-cli/src/commands/session.rs index 1a133f8..d868ac1 100644 --- a/crates/hodeishield-cli/src/commands/session.rs +++ b/crates/hodeishield-cli/src/commands/session.rs @@ -1008,6 +1008,16 @@ mod tests { } } + #[test] + fn whoami_shows_the_email_when_the_api_gives_no_name() { + let (text, printed) = whoami_of_a_sign_in( + Some(r#"{"id":"u-1","name":null,"email":"ana@acme.example"}"#), + None, + ); + assert!(text.contains("User ana@acme.example\n"), "{text}"); + assert_eq!(printed["user"], "ana@acme.example"); + } + #[test] fn whoami_uses_the_id_when_the_api_gives_no_name_or_email() { let (text, _) = whoami_of_a_sign_in(Some(r#"{"id":"u-1","name":null}"#), None); diff --git a/crates/hodeishield-cli/src/failure.rs b/crates/hodeishield-cli/src/failure.rs index 4af27a5..d05db28 100644 --- a/crates/hodeishield-cli/src/failure.rs +++ b/crates/hodeishield-cli/src/failure.rs @@ -260,7 +260,10 @@ pub fn from_api(error: hodeishield_api::Error, context: &ApiContext<'_>) -> Fail failure.request_id = request_id; failure } - Error::Transport(e) => Failure::new(Kind::Unavailable, format!("Could not reach the API: {}.", error_chain(&e))), + Error::Transport(e) => { + let chain = error_chain(&e); + ca_hint(Failure::new(Kind::Unavailable, format!("Could not reach the API: {chain}.")), &chain) + } Error::Decode { operation, source } => Failure::general(format!( "The answer to {operation} does not match the API description this CLI was built from: {source}." )) @@ -373,6 +376,16 @@ pub fn error_chain(error: &dyn std::error::Error) -> String { text } +/// Adds the hint for a machine with no CA certificates, which the HTTP client reports only as +/// "No CA certificates were loaded from the system". +pub fn ca_hint(failure: Failure, chain: &str) -> Failure { + if chain.contains("No CA certificates were loaded") { + failure.hint("Install the system CA certificates package (for example `ca-certificates`).") + } else { + failure + } +} + /// Where the credential of a call came from, for the hint on a 401. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CredentialSource { @@ -425,6 +438,20 @@ mod tests { assert_eq!(sentence_body("version 1.2 is old"), "version 1.2 is old"); } + #[test] + fn a_missing_ca_bundle_gets_a_hint_and_other_errors_do_not() { + let chain = + "builder error: unexpected error: No CA certificates were loaded from the system"; + let hinted = ca_hint(Failure::general("x"), chain); + assert!( + hinted + .hint + .as_deref() + .is_some_and(|h| h.contains("ca-certificates")) + ); + assert!(ca_hint(Failure::general("x"), "dns error").hint.is_none()); + } + #[test] fn message_text_never_marks_a_closed_pipe() { assert!(!Failure::general("Broken pipe").closed_pipe); diff --git a/docs/verifying-releases.md b/docs/verifying-releases.md index 0bb181b..51c8516 100644 --- a/docs/verifying-releases.md +++ b/docs/verifying-releases.md @@ -46,9 +46,12 @@ From the release page, download: ## 2. Check the checksum ```sh -sha256sum -c SHA256SUMS --ignore-missing +grep hodeishield-0.5.0-x86_64-unknown-linux-musl.tar.gz SHA256SUMS | sha256sum -c - ``` +Checking only the line of your archive works with GNU `sha256sum` and with the BusyBox one on +Alpine, which has no `--ignore-missing`. On macOS, use `shasum -a 256 -c` in place of `sha256sum -c`. + This confirms the archive you downloaded matches the checksum the release workflow recorded. It does **not** by itself prove who built it — that's what the next two steps are for.