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
70 changes: 65 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -110,6 +122,33 @@ sudo dnf install ./hodeishield-<version>-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
Expand Down Expand Up @@ -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 \
Expand All @@ -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).
Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 6 additions & 5 deletions crates/hodeishield-cli/src/auth/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -102,10 +102,11 @@ pub fn http_client(app_url: &Url) -> Result<reqwest::blocking::Client> {
.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,
)
})
}

Expand Down
4 changes: 2 additions & 2 deletions crates/hodeishield-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions crates/hodeishield-cli/src/commands/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
29 changes: 28 additions & 1 deletion crates/hodeishield-cli/src/failure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}."
))
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion docs/verifying-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading