From 6927c1739059f94c10afbda82e3a0403cc3102a7 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:00:11 +0200 Subject: [PATCH 01/25] Accept the decision on signing in without a keychain Replace the proposal with the decision: keep the keychain as the default and add an explicit opt-in token store that is a plain-text file, with no fallback in either direction. Record the file checks, the location in the state directory, the deferral of Windows and of the Linux kernel keyring, and the server-side refresh token lifetime that is still to be confirmed. The text now matches the fail-closed sign-in behaviour, where tokens that cannot be stored are revoked. Refs #39 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .../0001-sign-in-without-a-keychain.md | 288 +++++++++--------- 1 file changed, 139 insertions(+), 149 deletions(-) diff --git a/docs/decisions/0001-sign-in-without-a-keychain.md b/docs/decisions/0001-sign-in-without-a-keychain.md index 6a3d9d3..6cd9146 100644 --- a/docs/decisions/0001-sign-in-without-a-keychain.md +++ b/docs/decisions/0001-sign-in-without-a-keychain.md @@ -1,156 +1,146 @@ # 0001. Signing in on a machine without a keychain -- Status: Proposed +- Status: Accepted - Issue: [#39](https://github.com/Hodeitek/hodeishield-cli/issues/39) +- Related: [#101](https://github.com/Hodeitek/hodeishield-cli/issues/101) (fail-closed sign-in), + [#111](https://github.com/Hodeitek/hodeishield-cli/issues/111) (keychain out of reach in a remote + session) - Deciders: maintainers -This is a proposal. Nothing here is decided or implemented; the recommendation is the author's. - ## Context -`hodeishield login` stores its tokens (an access token and, by default, a refresh token) in the -system keychain and nowhere else (`crates/hodeishield-cli/src/auth/store.rs:1-6`). On Linux the only -backend is the Secret Service over D-Bus, through `zbus-secret-service-keyring-store` -(`crates/hodeishield-cli/Cargo.toml:47-48`, `store.rs:127-131`). There is no kernel keyring (keyutils) -backend and no file fallback. - -On a machine with no Secret Service (an SSH server, a container, WSL without a desktop session, a CI -runner) this is what happens today: - -- The store is first touched when the token is saved (`commands/session.rs:100`), after the whole - browser or `--device` flow has finished. The person completes sign-in and consent, then gets - "the system keychain is not available" (`store.rs:17,100-110`). The token already issued is not - revoked by the CLI, so it stays valid at the app until revoked there. -- The error points to `HODEISHIELD_API_KEY` (`auth/mod.rs:87-97`); the README says the same - (`README.md:294-297`, and for containers `README.md:160-161`). -- `--device` does not help: it avoids the browser, not the storage. - -Who is affected: people who work from a jump host or a remote shell with their own identity (the -motivating case in the issue: MSSP staff), containers, and CI. A tenant API key works everywhere -(`auth/mod.rs:38-45`), but it is one more long-lived secret per tenant, it is not tied to a person, -and someone has to create it. - -What is at stake: a sign-in token reads one tenant's supply-chain risk data. The access token is -short-lived; the refresh token is long-lived and is the sensitive part. The README promises that -tokens are "never in a file" (`README.md:30`, `cli.rs:103-104`), so any option that writes a file -changes a documented guarantee. - -Existing seam: `TokenStore` (`store.rs:87-93`) has `load`, `save` and `delete` per profile, with a -test-only `MemoryStore` (`store.rs:185`). The CLI builds the store in one place -(`commands/mod.rs:83`). A new store is a new implementation of that trait plus a way to choose it. - -## Options - -### A. Keep keychain-only; document API keys for headless use - -Change nothing in behaviour. Improve the experience: refuse early (before opening a browser or -printing a device code) when no keychain is reachable, and say what to do; expand the README with a -"servers and containers" recipe. - -- Security: best. No new storage, no weaker path. API keys can be scoped to read scopes and rotated. -- Effort: small (an early check, a message, docs). -- Consequences: the issue's use case is answered with "request an API key", not with the person's own - identity. Per-tenant keys must be issued and tracked by someone. - -### B. Linux kernel session keyring (keyutils) as a fallback - -Store the payload with `keyctl` in the session or user keyring when no Secret Service is present. - -- Security: the secret is kernel memory, never on disk, and not readable by other users. But it is - scoped to a keyring, not a person's login: a user keyring is readable by every process of that user; - a session keyring is visible only to that session's processes. -- Lifetime: it does not survive a reboot. A session keyring disappears when the last process of the - session ends, so a new SSH connection would be signed out; a user keyring (`@u`) survives - across sessions until reboot or key timeout, but with defaults that vary by distribution and - container runtime. Containers often block the `keyctl` syscall under the default seccomp profile. -- Effort: medium. A new backend crate (or direct syscalls), Linux only, behind a selection rule and - tests that need a real kernel keyring. -- Consequences: helps SSH servers somewhat, containers and WSL unreliably. Behaviour (survives or not) - would be surprising to users and needs documenting per mode. - -### C. Encrypted file store with a passphrase - -Write the payload to a file in the config directory, encrypted with a key derived (for example with -Argon2id) from a passphrase. - -- Where the key comes from: a prompt on a terminal, or an environment variable / file descriptor for - non-interactive use (for example `HODEISHIELD_PASSPHRASE`). -- UX: a prompt on every command that needs the token, or a passphrase in the environment, which in - practice moves the secret from one place to another. Without a terminal and without the variable - the command must fail clearly. -- Threat model: protects a stolen disk, backup or file copy; does not protect against code running as - the same user once the passphrase is in memory or in the environment. A weak passphrase makes the - file an offline-guessing target for a long-lived refresh token. -- Effort: large. New crypto dependencies to vet (`deny.toml`), file format and versioning, prompting, - locking against concurrent refreshes, key rotation, tests. -- Consequences: works in all environments with a terminal; the largest new security surface to own. - -### D. Plaintext file with 0600 permissions behind an explicit opt-in - -Store the payload in a file in the config directory, mode 0600 in a 0700 directory, only when the -person opts in (for example `HODEISHIELD_INSECURE_STORAGE=file` or a config key), with a warning on -every sign-in. - -- Security: weakest. Anyone who can read the file (same user, root, backups, a mis-mounted volume, an - image layer) holds a refresh token valid until revoked. Permissions do not protect against malware - running as the user. This reverses the "never in a file" promise for those who opt in. -- Effort: small to medium (store, permission checks and refusal if the file is looser than 0600, - Windows ACL handling, docs, tests). -- Consequences: common practice in other CLIs, and it solves every environment, but it normalises the - exact exposure the CLI was designed to avoid. Requests to remove the opt-in guard would follow. - -### E. No storage: print a token once or keep it in memory for one command - -For example `hodeishield login --print-token` runs the flow and prints only the access token, to be -exported as an environment variable for the session; or the CLI uses the access token only within -one invocation. - -- Security: good if the refresh token is never requested or never stored. The access token is - short-lived, so exposure is limited to its lifetime; it can appear in shell history or process - environment if handled carelessly. -- Effort: small to medium. The CLI would need to request no `offline_access` scope, accept an access - token from the environment (a new credential source in `auth/mod.rs:38`), and print it safely - (stdout only, never with other text). -- Consequences: no refresh, so the person signs in again when the token expires (the lifetime is set - by the app). Fine for a short task on a jump host; poor for unattended jobs, which should use an API - key. Interactive device flow from a remote shell is natural here. - -### F. Delegate to an external credential helper - -The CLI calls a configured program (as some tools do with git or Docker credential helpers) to load -and save the payload, so people bring their own vault. - -- Security: depends on the helper; the CLI never writes a file. The helper's protocol and trust (the - program path comes from configuration) become part of the threat model. -- Effort: medium (protocol, spawn without a shell, error handling, docs). Larger audience needed to - justify it. - -## Recommendation (the author's, not a decision) - -Security first: the refresh token is the asset, and the options that never write it to disk or -plaintext are A, E, and B. In that order of preference: - -1. Do A now, regardless: fail early, before the browser or device code, and document the headless - path. This fixes the worst part of the current behaviour (completing sign-in and then failing, - leaving an unrevoked token) at almost no risk. -2. Add E for the jump-host case: a sign-in that requests no refresh token and does not store anything, - with the access token handed to the shell explicitly. It keeps personal identity without a stored - long-lived secret, and costs one new credential source. -3. Do not implement D or C unless the maintainers accept new storage risk. If a file is needed - anyway, prefer C over D, and only with a design review of the format and the passphrase handling. - B is worth a spike only if SSH-server users report that E is not enough. - -If the maintainers choose not to add any alternative store, the second acceptance criterion of #39 is -met by A: the README recommends API keys and explains why. - -## Open questions for the maintainers - -1. Is "tokens never in a file" a product guarantee to keep, or a default that an opt-in may change? -2. Can the app issue an access token without a refresh token (no `offline_access`) with a useful - lifetime for a jump-host session? What is that lifetime? -3. Should a person's own sign-in be preferred over per-tenant API keys for MSSP staff, or are API keys - an acceptable answer given they can be scoped and rotated? -4. For containers and CI, is an API key always the intended answer (so options B to D are out of scope - there)? -5. Should the CLI revoke the token it just obtained when it cannot store it (today it does not)? -6. Which platforms matter most: SSH servers, WSL, containers, CI? +`hodeishield login` keeps its tokens (an access token and, by default, a refresh token) in the system +keychain: macOS Keychain, Windows Credential Manager, or on Linux the Secret Service over D-Bus +(`crates/hodeishield-cli/src/auth/store.rs`). Until this decision there was no other place to keep +them, and the README promised that tokens are "never in a file". + +On a machine with no keychain (an SSH or jump host, a container, WSL without a desktop session, a CI +runner) this is what happens: + +- `login` checks that the keychain can keep a session **before** it opens a browser or shows a device + code, and stops with a pointer to `HODEISHIELD_API_KEY` (`commands/session.rs`). +- If saving fails after the app has issued tokens, the CLI revokes the refresh token and then the + access token, says so, and exits non-zero, so no grant is left that nothing holds. +- Other commands, with no API key set, exit 3 and say that the keychain is not available in this + session. +- `--device` removes the need for a browser, so storage is the only blocker. + +Who is affected: people who work from a jump host with their own identity (the case in the issue), +containers, and CI. A tenant API key works everywhere, but it is one more long-lived secret per +tenant, it is not tied to a person, and someone has to create and track it. + +The asset is the refresh token. It is long-lived, covers one tenant with the read scopes chosen at +consent, belongs to one person, and ends with `logout` or when it is revoked in the app. The access +token is short-lived. + +| Who can read it | Keychain | File readable only by its owner | API key in the environment | +|---|---|---|---| +| Code running as the same user | Yes, if the keychain is unlocked | Yes | Yes (and child processes inherit it) | +| Other users | No | No, if owner and mode are right | Depends on where it is kept | +| `root` | Yes | Yes | Yes | +| Backups, snapshots, disk images | Encrypted at rest | Plaintext | Plaintext if kept in a file | +| A container layer, if sign-in ran at build time or a signed-in container is published | Not applicable | Plaintext | Same through `ENV` or `ARG` | +| A network home directory (NFS) | Encrypted | Readable by the server's administrators and by `root` elsewhere | Same | + +On a machine with no keychain, a file readable only by its owner is weaker than a keychain mainly +against offline copies; against code already running as the user it is about the same. Compared with +the alternative that exists today, the refresh token has the same kind of exposure as an API key, but +it belongs to one person, can be attributed to that person, and `logout` revokes it. An API key lives +until someone revokes it by hand. + +## Options considered + +- **A. Keychain only; document API keys.** Strongest, and nothing new to protect. A person still + cannot use their own identity on a jump host. Already in place, and kept as the default. +- **B. Plain-text file, opt-in.** Small to medium effort; works wherever there is a file system. The + weakest storage, so it must be an explicit choice with checks on the file. +- **C. Encrypted file with a passphrase.** Protects copies of the file, but a prompt on every command + does not fit scripts, a passphrase in the environment is no better than B against code running as + the same user, and a weak one makes the file a target for offline guessing. Large: key derivation + and encryption dependencies to vet, a versioned format, sessions with no terminal. +- **D. Linux kernel keyring.** Secrets never reach disk, backups or container layers. But nothing + survives a reboot, a session keyring ends with its session (a new SSH login usually gets a new + one), the persistent keyring expires, and container runtimes often block the system call. +- **E. No stored refresh token** (print an access token once, to be exported). Short exposure, but no + refresh and not usable by unattended jobs, which should use an API key anyway. +- **F. An external credential helper.** Shifts the trust to a configured program; justified only with + a larger audience. + +## Decision + +Keep the keychain as the default and add **an opt-in token store that is a plain-text file** (B), with +the following rules. Containers and CI keep using API keys. + +- **Opt-in only.** `HODEISHIELD_TOKEN_STORE=plaintext-file`, or the global configuration key + `token_store = "plaintext-file"`. There is no per-profile option and no flag on `login`, because + every command must read from the same store. The environment wins over the configuration file. The + only other accepted value is `keychain`; anything else is an error (exit code 1) before any network + call. +- **No fallback in either direction.** With the default, nothing changes and no file is ever written, + also when the keychain is missing. With the opt-in, the keychain is never touched. Tokens are never + moved between stores: sign out, change the setting, sign in again. +- **Location.** The platform's state directory, not the configuration directory: + `$XDG_STATE_HOME/hodeishield/sessions/` (by default `~/.local/state/hodeishield/sessions/`) on + Linux, and the application support directory on macOS. One file per profile, named by the SHA-256 + of the profile name (names may contain `/` or `..`). The configuration file stays free of + credentials, so copying or syncing it never copies a token. +- **Checks on every read and write.** The CLI refuses, and never repairs, a file or directory that is + a symbolic link, is not owned by the current user, or has any group or other permission bit. It + creates the directory with mode 700 and the files with mode 600. The opened file is checked again + after it is opened, so a file swapped in between is refused. A corrupt file is an error that names + it; a file written for another profile is not accepted. +- **Atomic writes.** The CLI writes a new file created with mode 600 in the same directory, flushes it + to disk, renames it over the target and flushes the directory. A temporary file is removed when + anything fails, and no token is ever in a file that others can read. +- **Behaviour of #101 is unchanged.** `login` checks that the selected store is usable before it + opens a browser or shows a code. If saving fails after the exchange, the refresh token and then the + access token are revoked and the command exits non-zero. `logout` revokes, then deletes the file. + No new exit codes: `login` failures are 1, other commands that need a credential and find the file + refused are 3, a corrupt file is 1. +- **Windows is deferred.** There are no permission bits to check, and setting and checking an ACL + needs platform calls that the workspace's `unsafe_code = "forbid"` rules out without a vetted + wrapper. Selecting the file store on Windows is refused with exit code 1, before anything is asked + of the app. +- **Network home directories.** Documented, not refused. On Linux the CLI also warns on `login` when + the directory is on NFS or SMB, where that can be told cheaply from the file system type; elsewhere + it cannot be told reliably, so only the documentation covers it. +- **Messages.** When the keychain is not available, the hint keeps the API key first and offers the + opt-in second, with one line on the trade-off: tokens are stored unencrypted in a file only you can + read. The README has a section for servers, containers and remote sessions with the same order and + no container recipe for the file store. +- **No telemetry.** The choice of store never leaves the machine. + +## Consequences + +- The README promise "tokens never in a file" becomes "in the system keychain unless you opt in". + Every place that said so (README, `login --help`, contributing and security notes) is updated. +- People on a jump host can sign in as themselves. They accept the exposure in the table above for + that machine; the README says so next to the instructions. +- A new class of local failure exists (a loosened mode after a copy or restore, a symbolic link, a + file owned by another user). The CLI refuses and explains instead of repairing, so a mistake is + visible rather than silently fixed or silently tolerated. +- Deleting the file, or restoring it from a backup, does not end or restore a session; only the + revocation at the app does. `logout` therefore revokes first. +- A configuration file with `token_store` is refused by older CLIs (unknown field). The environment + variable avoids that. +- The tests run in the normal suite, including the refusals (symbolic link, wrong owner, wrong modes, + invalid setting, no fallback, Windows). + +## Deferred + +- **Windows.** Needs an ACL design and either a vetted wrapper crate or an exception to + `unsafe_code = "forbid"`. +- **Linux kernel keyring.** Not worth a spike until users on SSH servers report that the file store + does not fit, for example because data on disk is the main concern. Its lifetime rules (above) make + it a surprising default. +- **An encrypted file** (option C), **an access-token-only sign-in** (option E) and **a credential + helper** (option F), for the reasons above. +- **Migration between stores.** Deliberately absent; switching is sign out, change the setting, sign + in. + +## Open question + +To be confirmed with the API provider: the refresh token's lifetime, rotation and idle expiry on the +server side. They decide how long a copy of a token file stays useful, and whether the exposure of a +leaked file can be bounded by something other than revocation. Nothing in this decision depends on the +answer, but the README's wording about exposure should be revisited once it is known. From 960eeaa27497d20c65720cf9977208212b5d3c7f Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:00:11 +0200 Subject: [PATCH 02/25] Add an opt-in plain-text token store People who work from a jump host or another machine without a keychain could only use a tenant API key. Add a second token store, selected by HODEISHIELD_TOKEN_STORE=plaintext-file or the global token_store setting, that keeps one file per profile in the state directory. The keychain stays the default and the two stores never fall back to each other. Every read and write refuses, without repairing, a symbolic link, a file or directory owned by another user, or one open to group or others. Files are created with mode 600 and replaced atomically. The effective user id comes from rustix, which is already in the dependency graph on Linux, so no unsafe code is needed. Windows is refused for now. Refs #39 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- Cargo.lock | 1 + crates/hodeishield-cli/Cargo.toml | 3 + crates/hodeishield-cli/src/auth/file_store.rs | 718 ++++++++++++++++++ crates/hodeishield-cli/src/auth/mod.rs | 23 +- crates/hodeishield-cli/src/auth/store.rs | 77 +- crates/hodeishield-cli/src/cli.rs | 8 +- crates/hodeishield-cli/src/commands/config.rs | 9 + crates/hodeishield-cli/src/commands/mod.rs | 2 +- .../hodeishield-cli/src/commands/session.rs | 9 +- crates/hodeishield-cli/src/config.rs | 143 +++- crates/hodeishield-cli/tests/cli.rs | 478 ++++++++++++ 11 files changed, 1443 insertions(+), 28 deletions(-) create mode 100644 crates/hodeishield-cli/src/auth/file_store.rs diff --git a/Cargo.lock b/Cargo.lock index 61bf3f2..3f1eebc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -998,6 +998,7 @@ dependencies = [ "mockito", "predicates", "reqwest", + "rustix", "secrecy", "serde", "serde_json", diff --git a/crates/hodeishield-cli/Cargo.toml b/crates/hodeishield-cli/Cargo.toml index 8b825c7..d33ed82 100644 --- a/crates/hodeishield-cli/Cargo.toml +++ b/crates/hodeishield-cli/Cargo.toml @@ -41,6 +41,9 @@ webbrowser = "1" [target.'cfg(target_os = "macos")'.dependencies] apple-native-keyring-store = { version = "1.0.2", features = ["keychain"] } +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1.5", default-features = false, features = ["std", "process", "fs"] } + [target.'cfg(windows)'.dependencies] windows-native-keyring-store = { version = "1.1.0", default-features = false } diff --git a/crates/hodeishield-cli/src/auth/file_store.rs b/crates/hodeishield-cli/src/auth/file_store.rs new file mode 100644 index 0000000..554b902 --- /dev/null +++ b/crates/hodeishield-cli/src/auth/file_store.rs @@ -0,0 +1,718 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Hodeitek S.L. + +//! The opt-in plain-text token store: one JSON file per profile, readable only by its owner. +//! +//! This is the weaker of the two stores and is never chosen on its own: it is used only when +//! `HODEISHIELD_TOKEN_STORE=plaintext-file` or the global `token_store` setting says so. There is +//! no fallback in either direction (see `docs/decisions/0001-sign-in-without-a-keychain.md`). +//! +//! Files live in the platform's state directory (`$XDG_STATE_HOME/hodeishield/sessions/` on Linux), +//! not in the configuration directory, so a copied or synced configuration never carries a token. +//! The file name is a hash of the profile name, which may contain `/` or `..`. +//! +//! On every read and write the CLI refuses, and never repairs, a file or directory that is a +//! symbolic link, that belongs to another user, or that is open to group or others. Windows has no +//! mode bits to check and is not supported yet. + +use super::store::{Payload, StoredToken, TokenStore}; +use crate::failure::{Failure, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Start of the message when the store refuses a file or directory it cannot trust. Commands that +/// need a credential turn it into "not authenticated" (exit code 3), like an unreachable keychain. +pub const TOKEN_FILE_REFUSED: &str = "the plain-text token store cannot be used"; + +/// What is written to the file: the profile it belongs to (checked on read) and the keychain +/// payload. +#[cfg_attr(not(unix), allow(dead_code))] +#[derive(Serialize, Deserialize)] +struct Record { + profile: String, + #[serde(flatten)] + payload: Payload, +} + +/// The sessions directory of this user, or why there is none. +pub fn sessions_dir() -> Result { + let dirs = + directories::ProjectDirs::from("com", "Hodeitek", "HodeiShield").ok_or_else(|| { + Failure::general("Cannot find a state directory for this user.") + .hint("Use the system keychain or a tenant API key instead.") + })?; + // `state_dir` exists on Linux and the BSDs; macOS has only the application support directory. + Ok(dirs + .state_dir() + .unwrap_or_else(|| dirs.data_local_dir()) + .join("sessions")) +} + +/// The file name for a profile: the hex SHA-256 of its name, so no profile name can leave the +/// directory. +#[cfg_attr(not(unix), allow(dead_code))] +fn file_name(profile: &str) -> String { + use sha2::{Digest, Sha256}; + use std::fmt::Write; + let digest = Sha256::digest(profile.as_bytes()); + let mut name = digest.iter().fold(String::with_capacity(68), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }); + name.push_str(".json"); + name +} + +/// Plain-text files in a private directory. +#[cfg_attr(not(unix), allow(dead_code))] +#[derive(Debug, Clone)] +pub struct FileStore { + dir: PathBuf, +} + +impl FileStore { + /// The store in the platform's state directory. Refused on Windows. + pub fn platform() -> Result { + if cfg!(windows) { + return Err(unsupported()); + } + Ok(Self::in_dir(sessions_dir()?)) + } + + pub(crate) fn in_dir(dir: PathBuf) -> Self { + Self { dir } + } + + #[cfg_attr(not(unix), allow(dead_code))] + fn path_for(&self, profile: &str) -> PathBuf { + self.dir.join(file_name(profile)) + } +} + +fn unsupported() -> Failure { + Failure::general( + "the plain-text token store is not supported on Windows yet; signing in needs the \ + Windows Credential Manager", + ) + .hint( + "Leave token_store unset (or `keychain`), or use a tenant API key in HODEISHIELD_API_KEY.", + ) +} + +#[cfg_attr(not(unix), allow(dead_code))] +fn refused(path: &Path, reason: &str, fix: Option) -> Failure { + let failure = Failure::general(format!("{TOKEN_FILE_REFUSED}: {} {reason}", path.display())); + match fix { + Some(fix) => failure.hint(format!( + "It was left untouched. {fix} Or use a tenant API key in HODEISHIELD_API_KEY." + )), + None => failure.hint( + "It was left untouched. Remove it yourself if you do not need it, or use a tenant API \ + key in HODEISHIELD_API_KEY.", + ), + } +} + +#[cfg_attr(not(unix), allow(dead_code))] +fn io_failure(what: &str, path: &Path, error: &std::io::Error) -> Failure { + Failure::general(format!("{what} {}: {error}", path.display())) +} + +#[cfg(not(unix))] +impl TokenStore for FileStore { + fn load(&self, _: &str) -> Result> { + Err(unsupported()) + } + fn save(&self, _: &str, _: &StoredToken) -> Result<()> { + Err(unsupported()) + } + fn delete(&self, _: &str) -> Result { + Err(unsupported()) + } + fn check_available(&self, _: &str) -> Result<()> { + Err(unsupported()) + } + fn location(&self, _: &str) -> Option { + None + } +} + +#[cfg(unix)] +impl TokenStore for FileStore { + fn load(&self, profile: &str) -> Result> { + unix::load(self, profile) + } + fn save(&self, profile: &str, token: &StoredToken) -> Result<()> { + unix::save(self, profile, token) + } + fn delete(&self, profile: &str) -> Result { + unix::delete(self, profile) + } + fn check_available(&self, profile: &str) -> Result<()> { + unix::check_available(self, profile) + } + fn location(&self, profile: &str) -> Option { + Some(self.path_for(profile)) + } +} + +#[cfg(unix)] +mod unix { + use super::{FileStore, Record, io_failure, refused}; + use crate::auth::store::StoredToken; + use crate::failure::{Failure, Result}; + use std::fs::{File, Metadata, OpenOptions}; + use std::io::{Read, Write}; + use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}; + use std::path::{Path, PathBuf}; + + /// Biggest file read: a token payload is well under a kilobyte. + const MAX_BYTES: u64 = 64 * 1024; + + /// The effective user id of this process. + fn euid() -> u32 { + rustix::process::geteuid().as_raw() + } + + /// What is wrong with a file or directory, judged from its own metadata (never a followed + /// link): `None` when it can be trusted. + pub(super) fn problem(meta: &Metadata, want_dir: bool, euid: u32) -> Option<(String, String)> { + let kind = if want_dir { "directory" } else { "file" }; + let fix_mode = if want_dir { "700" } else { "600" }; + if meta.file_type().is_symlink() { + return Some(( + "is a symbolic link, which is never followed".into(), + String::new(), + )); + } + let right_type = if want_dir { + meta.is_dir() + } else { + meta.is_file() + }; + if !right_type { + return Some((format!("is not a regular {kind}"), String::new())); + } + if meta.uid() != euid { + return Some(( + format!("belongs to another user (uid {}), not to you", meta.uid()), + String::new(), + )); + } + let mode = meta.mode() & 0o777; + if mode & 0o077 != 0 { + return Some(( + format!("is accessible to other users (mode {mode:03o}); it must be {fix_mode}"), + format!( + "If you own it and nobody else read it, `chmod {fix_mode}` fixes it; a token that may have been read should be revoked from the app." + ), + )); + } + None + } + + fn check(path: &Path, meta: &Metadata, want_dir: bool) -> Result<()> { + match problem(meta, want_dir, euid()) { + None => Ok(()), + Some((reason, fix)) => Err(refused(path, &reason, Some(fix).filter(|f| !f.is_empty()))), + } + } + + /// Looks at a path without following a link at its end. `None` when it is absent. + fn look(path: &Path) -> Result> { + match std::fs::symlink_metadata(path) { + Ok(meta) => Ok(Some(meta)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(io_failure("Cannot look at", path, &e)), + } + } + + /// The sessions directory, checked. `None` when absent and `create` is false; created with + /// mode 0700 otherwise. + fn dir(store: &FileStore, create: bool) -> Result> { + if let Some(meta) = look(&store.dir)? { + check(&store.dir, &meta, true)?; + return Ok(Some(())); + } + if !create { + return Ok(None); + } + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&store.dir) + .map_err(|e| io_failure("Cannot create", &store.dir, &e))?; + // Whatever the umask or a concurrent creator did, it is judged like an existing one. + let meta = look(&store.dir)?.ok_or_else(|| { + Failure::general(format!( + "{} disappeared while it was created.", + store.dir.display() + )) + })?; + check(&store.dir, &meta, true)?; + Ok(Some(())) + } + + /// Opens a file for reading and judges the opened handle, which must be the very file that + /// was looked at (a swap in between is refused). + fn open_checked(path: &Path) -> Result> { + let Some(seen) = look(path)? else { + return Ok(None); + }; + check(path, &seen, false)?; + let file = match File::open(path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(io_failure("Cannot read", path, &e)), + }; + let opened = file + .metadata() + .map_err(|e| io_failure("Cannot read", path, &e))?; + if (opened.dev(), opened.ino()) != (seen.dev(), seen.ino()) { + return Err(refused(path, "changed while it was being opened", None)); + } + check(path, &opened, false)?; + Ok(Some(file)) + } + + pub(super) fn load(store: &FileStore, profile: &str) -> Result> { + if dir(store, false)?.is_none() { + return Ok(None); + } + let path = store.path_for(profile); + let Some(file) = open_checked(&path)? else { + return Ok(None); + }; + let mut text = String::new(); + file.take(MAX_BYTES) + .read_to_string(&mut text) + .map_err(|e| io_failure("Cannot read", &path, &e))?; + let corrupt = || { + Failure::general(format!( + "the sign-in stored in {} is unreadable", + path.display() + )) + .hint(format!( + "Remove {} and sign in again with `hodeishield login`.", + path.display() + )) + }; + let record: Record = serde_json::from_str(&text).map_err(|_| corrupt())?; + if record.profile != profile { + return Err(corrupt()); + } + Ok(Some(StoredToken::from_payload(record.payload))) + } + + /// Removes the temporary file unless it was renamed into place. + struct Temp<'a> { + path: &'a Path, + armed: bool, + } + + impl Drop for Temp<'_> { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(self.path); + } + } + } + + fn temp_path(store: &FileStore, profile: &str) -> Result { + let mut random = [0u8; 8]; + getrandom::fill(&mut random) + .map_err(|e| Failure::general(format!("cannot get random bytes: {e}")))?; + let suffix: String = random.iter().map(|b| format!("{b:02x}")).collect(); + Ok(store.dir.join(format!( + ".{}.{suffix}.tmp", + &super::file_name(profile)[..16] + ))) + } + + /// Writes `bytes` to a new 0600 file next to `path` and renames it over `path`. + fn write_atomically(store: &FileStore, profile: &str, path: &Path, bytes: &[u8]) -> Result<()> { + let temp = temp_path(store, profile)?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temp) + .map_err(|e| io_failure("Cannot create", &temp, &e))?; + let mut guard = Temp { + path: &temp, + armed: true, + }; + // The umask can only remove bits from 0600; say 0600 whatever it was. + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .and_then(|()| file.write_all(bytes)) + .and_then(|()| file.sync_all()) + .map_err(|e| io_failure("Cannot write", &temp, &e))?; + drop(file); + std::fs::rename(&temp, path).map_err(|e| io_failure("Cannot replace", path, &e))?; + guard.armed = false; + sync_dir(&store.dir); + Ok(()) + } + + /// Makes a rename or removal durable. Best effort: not every file system allows it. + fn sync_dir(dir: &Path) { + if let Ok(handle) = File::open(dir) { + let _ = handle.sync_all(); + } + } + + pub(super) fn save(store: &FileStore, profile: &str, token: &StoredToken) -> Result<()> { + dir(store, true)?; + let path = store.path_for(profile); + // An existing file is judged before it is replaced, even though it will not be read. + if let Some(meta) = look(&path)? { + check(&path, &meta, false)?; + } + let record = Record { + profile: profile.to_owned(), + payload: token.to_payload(), + }; + let mut text = serde_json::to_string(&record) + .map_err(|e| Failure::general(format!("cannot encode the sign-in: {e}")))?; + text.push('\n'); + write_atomically(store, profile, &path, text.as_bytes()) + } + + pub(super) fn delete(store: &FileStore, profile: &str) -> Result { + if dir(store, false)?.is_none() { + return Ok(false); + } + let path = store.path_for(profile); + let Some(meta) = look(&path)? else { + return Ok(false); + }; + check(&path, &meta, false)?; + match std::fs::remove_file(&path) { + Ok(()) => { + sync_dir(&store.dir); + Ok(true) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(io_failure("Cannot remove", &path, &e)), + } + } + + /// Proves the store can be used: the directory exists with the right owner and mode (it is + /// created if absent), the profile's file, if any, is trustworthy, and an empty file can be + /// created and removed there. No token is written. + pub(super) fn check_available(store: &FileStore, profile: &str) -> Result<()> { + dir(store, true)?; + let path = store.path_for(profile); + if let Some(meta) = look(&path)? { + check(&path, &meta, false)?; + } + let probe = temp_path(store, profile)?; + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&probe) + .map_err(|e| { + Failure::general(format!( + "the plain-text token store cannot be used: cannot write in {}: {e}", + store.dir.display() + )) + .hint("Use the system keychain or a tenant API key in HODEISHIELD_API_KEY.") + })?; + let _ = std::fs::remove_file(&probe); + if let Some(fs) = super::network_file_system(&store.dir) { + eprintln!( + "Warning: {} is on {fs}. A file there can be read by the server's administrator \ + and by root on other machines that mount it; use a local directory \ + (XDG_STATE_HOME) if that matters.", + store.dir.display() + ); + } + Ok(()) + } +} + +/// The name of the network file system `path` is on, when it can be told cheaply and reliably. +/// Only Linux's `statfs` magic numbers are used; elsewhere nothing is detected. +#[cfg(all(unix, target_os = "linux"))] +fn network_file_system(path: &Path) -> Option<&'static str> { + let stat = rustix::fs::statfs(path).ok()?; + network_file_system_for(stat.f_type as u64 & 0xFFFF_FFFF) +} + +#[cfg(all(unix, not(target_os = "linux")))] +fn network_file_system(_: &Path) -> Option<&'static str> { + None +} + +/// Linux `statfs` `f_type` values of file systems served by another machine. +#[cfg(any(test, all(unix, target_os = "linux")))] +fn network_file_system_for(f_type: u64) -> Option<&'static str> { + match f_type { + 0x6969 => Some("NFS"), + 0xFF53_4D42 | 0xFE53_4D42 => Some("SMB/CIFS"), + _ => None, + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use secrecy::{ExposeSecret, SecretString}; + use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink}; + + fn token() -> StoredToken { + StoredToken { + access_token: SecretString::from("at_secret"), + refresh_token: Some(SecretString::from("rt_secret")), + expires_at: Some(1_000), + scope: Some("read".to_owned()), + issuer: "https://app.example.test".to_owned(), + api_url: "https://api.example.test".to_owned(), + client_id: "cli".to_owned(), + } + } + + /// A store in `/sessions`, which does not exist yet. + fn store() -> (tempfile::TempDir, FileStore) { + let tmp = tempfile::tempdir().expect("tempdir"); + let store = FileStore::in_dir(tmp.path().join("sessions")); + (tmp, store) + } + + fn mode(path: &Path) -> u32 { + std::fs::metadata(path).expect("meta").mode() & 0o777 + } + + fn refused_with(result: Result, needle: &str) { + let err = result.expect_err("refused"); + assert!( + err.message.starts_with(TOKEN_FILE_REFUSED) && err.message.contains(needle), + "{}", + err.message + ); + assert!(err.hint.is_some()); + } + + fn saved() -> (tempfile::TempDir, FileStore) { + let (tmp, store) = store(); + store.save("p", &token()).expect("save"); + (tmp, store) + } + + #[test] + fn a_session_round_trips_with_private_modes() { + let (_tmp, store) = saved(); + let back = store.load("p").expect("load").expect("stored"); + assert_eq!(back.access_token.expose_secret(), "at_secret"); + assert_eq!(back.refresh_token.expect("rt").expose_secret(), "rt_secret"); + assert_eq!(mode(&store.dir), 0o700); + assert_eq!(mode(&store.path_for("p")), 0o600); + assert!(store.load("other").expect("load").is_none()); + } + + #[test] + fn an_atomic_write_leaves_no_temporary_file_and_replaces_the_old_one() { + let (_tmp, store) = saved(); + let newer = StoredToken { + access_token: SecretString::from("at_newer"), + ..token() + }; + store.save("p", &newer).expect("save again"); + let names: Vec<_> = std::fs::read_dir(&store.dir) + .expect("dir") + .map(|e| e.expect("entry").file_name()) + .collect(); + assert_eq!(names, [std::ffi::OsString::from(file_name("p"))]); + assert_eq!(mode(&store.path_for("p")), 0o600); + let back = store.load("p").expect("load").expect("stored"); + assert_eq!(back.access_token.expose_secret(), "at_newer"); + } + + #[test] + fn a_refused_target_leaves_no_temporary_file() { + let (_tmp, store) = saved(); + std::fs::create_dir(store.path_for("q")).expect("blocker"); + refused_with(store.save("q", &token()), "not a regular file"); + let leftovers: Vec<_> = std::fs::read_dir(&store.dir) + .expect("dir") + .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned())) + .filter(|n| n.ends_with(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "{leftovers:?}"); + assert!(store.load("p").expect("load").is_some()); + } + + #[test] + fn a_symlinked_file_is_refused_on_every_operation() { + let (tmp, store) = saved(); + let real = tmp.path().join("real.json"); + std::fs::copy(store.path_for("p"), &real).expect("copy"); + std::fs::remove_file(store.path_for("p")).expect("rm"); + symlink(&real, store.path_for("p")).expect("link"); + refused_with(store.load("p"), "symbolic link"); + refused_with(store.save("p", &token()), "symbolic link"); + refused_with(store.delete("p"), "symbolic link"); + refused_with(store.check_available("p"), "symbolic link"); + // Left as it was. + assert!( + std::fs::symlink_metadata(store.path_for("p")) + .expect("meta") + .is_symlink() + ); + assert!(real.exists()); + } + + #[test] + fn a_symlinked_directory_is_refused() { + let (tmp, store) = store(); + let elsewhere = tmp.path().join("elsewhere"); + std::fs::create_dir(&elsewhere).expect("dir"); + std::fs::set_permissions(&elsewhere, std::fs::Permissions::from_mode(0o700)) + .expect("chmod"); + symlink(&elsewhere, &store.dir).expect("link"); + refused_with(store.load("p"), "symbolic link"); + refused_with(store.save("p", &token()), "symbolic link"); + refused_with(store.delete("p"), "symbolic link"); + refused_with(store.check_available("p"), "symbolic link"); + assert_eq!(std::fs::read_dir(&elsewhere).expect("dir").count(), 0); + } + + #[test] + fn a_file_open_to_group_or_others_is_refused() { + for bits in [0o644, 0o640, 0o604, 0o660] { + let (_tmp, store) = saved(); + let path = store.path_for("p"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(bits)).expect("chmod"); + refused_with(store.load("p"), "accessible to other users"); + refused_with(store.save("p", &token()), "accessible to other users"); + refused_with(store.delete("p"), "accessible to other users"); + refused_with(store.check_available("p"), "accessible to other users"); + // Never repaired. + assert_eq!(mode(&path), bits); + } + } + + #[test] + fn a_directory_open_to_group_or_others_is_refused() { + for bits in [0o755, 0o750, 0o705] { + let (_tmp, store) = saved(); + std::fs::set_permissions(&store.dir, std::fs::Permissions::from_mode(bits)) + .expect("chmod"); + refused_with(store.load("p"), "must be 700"); + refused_with(store.save("p", &token()), "must be 700"); + refused_with(store.delete("p"), "must be 700"); + refused_with(store.check_available("p"), "must be 700"); + assert_eq!(mode(&store.dir), bits); + std::fs::set_permissions(&store.dir, std::fs::Permissions::from_mode(0o700)) + .expect("chmod back"); + } + } + + #[test] + fn the_owner_is_compared_with_the_effective_user() { + // Another user's files cannot be made in a test, so the judgement is exercised with the + // effective uid replaced: the same file is then someone else's. + let (_tmp, store) = saved(); + let meta = std::fs::symlink_metadata(store.path_for("p")).expect("meta"); + let me = meta.uid(); + assert!(unix::problem(&meta, false, me).is_none()); + let (reason, _) = unix::problem(&meta, false, me.wrapping_add(1)).expect("another owner"); + assert!(reason.contains("belongs to another user"), "{reason}"); + let dir_meta = std::fs::symlink_metadata(&store.dir).expect("meta"); + assert!(unix::problem(&dir_meta, true, me.wrapping_add(1)).is_some()); + assert_eq!(rustix::process::geteuid().as_raw(), me); + } + + #[test] + fn something_that_is_not_a_regular_file_is_refused() { + let (_tmp, store) = saved(); + std::fs::remove_file(store.path_for("p")).expect("rm"); + std::fs::create_dir(store.path_for("p")).expect("dir"); + refused_with(store.load("p"), "not a regular file"); + } + + #[test] + fn a_corrupt_file_is_an_error_that_names_it() { + let (_tmp, store) = saved(); + std::fs::write(store.path_for("p"), "not json").expect("write"); + let err = store.load("p").expect_err("corrupt"); + assert!(err.message.contains("is unreadable"), "{}", err.message); + assert!(!err.message.starts_with(TOKEN_FILE_REFUSED)); + assert!(!err.message.contains("not json")); + } + + #[test] + fn a_file_for_another_profile_is_not_accepted() { + let (_tmp, store) = saved(); + std::fs::copy(store.path_for("p"), store.path_for("q")).expect("copy"); + let err = store.load("q").expect_err("wrong profile"); + assert!(err.message.contains("is unreadable"), "{}", err.message); + } + + #[test] + fn two_profiles_are_two_files_and_odd_names_stay_inside() { + let (_tmp, store) = store(); + for name in ["default", "client-b", "../../escape", "a/b", ""] { + store.save(name, &token()).expect("save"); + } + let names: Vec = std::fs::read_dir(&store.dir) + .expect("dir") + .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(names.len(), 5, "{names:?}"); + assert!( + names.iter().all(|n| n.len() == 69 && n.ends_with(".json")), + "{names:?}" + ); + assert!(store.load("../../escape").expect("load").is_some()); + assert!(store.delete("client-b").expect("delete")); + assert!(store.load("default").expect("load").is_some()); + assert!(store.load("client-b").expect("load").is_none()); + } + + #[test] + fn delete_removes_the_file_and_says_whether_there_was_one() { + let (_tmp, store) = saved(); + assert!(store.delete("p").expect("delete")); + assert!(!store.path_for("p").exists()); + assert!(!store.delete("p").expect("again")); + let (_tmp, never) = self::store(); + assert!(!never.delete("p").expect("no directory")); + assert!(!never.dir.exists(), "deleting creates nothing"); + } + + #[test] + fn checking_availability_writes_no_token_and_leaves_no_file() { + let (_tmp, store) = store(); + store.check_available("p").expect("available"); + assert_eq!(mode(&store.dir), 0o700); + assert_eq!(std::fs::read_dir(&store.dir).expect("dir").count(), 0); + assert!(store.load("p").expect("load").is_none()); + } + + #[test] + fn reading_creates_nothing() { + let (_tmp, store) = store(); + assert!(store.load("p").expect("load").is_none()); + assert!(!store.dir.exists()); + } + + #[test] + fn network_file_systems_are_recognised_by_their_magic_number() { + assert_eq!(network_file_system_for(0x6969), Some("NFS")); + assert_eq!(network_file_system_for(0xFF53_4D42), Some("SMB/CIFS")); + assert_eq!(network_file_system_for(0xEF53), None); + } + + #[test] + fn the_state_directory_is_not_the_configuration_directory() { + let dir = sessions_dir().expect("dir"); + assert!(dir.ends_with("sessions")); + assert!( + !dir.to_string_lossy().contains("/.config/"), + "{}", + dir.display() + ); + } +} diff --git a/crates/hodeishield-cli/src/auth/mod.rs b/crates/hodeishield-cli/src/auth/mod.rs index b7a7acd..54dafea 100644 --- a/crates/hodeishield-cli/src/auth/mod.rs +++ b/crates/hodeishield-cli/src/auth/mod.rs @@ -3,6 +3,7 @@ //! Credentials: a tenant API key from the environment, or a sign-in token from the keychain. +pub mod file_store; pub mod loopback; pub mod oauth; pub mod store; @@ -96,6 +97,15 @@ pub fn not_signed_in(settings: &Settings) -> Failure { /// Windows): it is "not authenticated" (exit code 3), with a pointer to the API key. Any other /// store failure (a corrupt entry, say) passes through unchanged: it already says what happened. pub fn explain_store_failure(failure: Failure) -> Failure { + if failure.message.starts_with(file_store::TOKEN_FILE_REFUSED) { + // A token file that is refused is not a credential: "not authenticated", with the store's + // own hint, which already names the API key. + let refused = Failure::new(Kind::NotAuthenticated, failure.message); + return match failure.hint { + Some(own) => refused.hint(own), + None => refused, + }; + } let Some(rest) = failure.message.strip_prefix(store::KEYCHAIN_UNAVAILABLE) else { return failure; }; @@ -216,10 +226,13 @@ pub fn issuer_url(token: &StoredToken) -> Result { } /// Whether a stored token is the one that would be sent, for `whoami`. -pub fn describe_source(source: CredentialSource) -> &'static str { - match source { - CredentialSource::ApiKey => "tenant API key (HODEISHIELD_API_KEY)", - CredentialSource::OAuth => "sign-in token in the system keychain", +pub fn describe_source(source: CredentialSource, file: Option<&std::path::Path>) -> String { + match (source, file) { + (CredentialSource::ApiKey, _) => "tenant API key (HODEISHIELD_API_KEY)".to_owned(), + (CredentialSource::OAuth, None) => "sign-in token in the system keychain".to_owned(), + (CredentialSource::OAuth, Some(path)) => { + format!("sign-in token in a plain-text file ({})", path.display()) + } } } @@ -529,7 +542,7 @@ mod tests { let hint = err.hint.expect("hint"); // The platform's own advice stays, followed by the remote-session one. assert!( - hint.starts_with("Sign-in tokens are only kept in the system keychain."), + hint.starts_with("Sign-in tokens are kept in the system keychain."), "{hint}" ); assert!( diff --git a/crates/hodeishield-cli/src/auth/store.rs b/crates/hodeishield-cli/src/auth/store.rs index 78edfb6..362bc93 100644 --- a/crates/hodeishield-cli/src/auth/store.rs +++ b/crates/hodeishield-cli/src/auth/store.rs @@ -1,10 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Hodeitek S.L. -//! Where sign-in tokens are kept: the system keychain, and nowhere else. +//! Where sign-in tokens are kept: the system keychain, or, only when the person opts in, a +//! plain-text file. //! -//! macOS Keychain, Windows Credential Manager, or the Secret Service on Linux and the BSDs (GNOME -//! Keyring, KWallet). If none is available, signing in fails: there is no fallback to a file. +//! The keychain is the default: macOS Keychain, Windows Credential Manager, or the Secret Service +//! on Linux and the BSDs (GNOME Keyring, KWallet). The other store is +//! [`file_store`](super::file_store), chosen by `HODEISHIELD_TOKEN_STORE=plaintext-file` or the +//! `token_store` setting. Neither is ever used as a fallback for the other: if the chosen one is +//! not available, signing in fails. use crate::failure::{Failure, Result}; use secrecy::{ExposeSecret, SecretString}; @@ -33,7 +37,7 @@ pub struct StoredToken { /// The keychain payload. Plain strings live only for the instant of (de)serialisation. #[derive(Serialize, Deserialize)] -struct Payload { +pub(super) struct Payload { v: u32, access_token: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -48,7 +52,7 @@ struct Payload { } impl StoredToken { - fn to_payload(&self) -> Payload { + pub(super) fn to_payload(&self) -> Payload { Payload { v: 1, access_token: self.access_token.expose_secret().to_owned(), @@ -64,7 +68,7 @@ impl StoredToken { } } - fn from_payload(payload: Payload) -> Self { + pub(super) fn from_payload(payload: Payload) -> Self { Self { access_token: SecretString::from(payload.access_token), refresh_token: payload.refresh_token.map(SecretString::from), @@ -92,12 +96,53 @@ pub trait TokenStore { /// Whether tokens can be kept for this profile, without writing anything. Sign-in checks it /// before it asks the person to approve anything. fn check_available(&self, profile: &str) -> Result<()>; + /// The file that holds the profile's tokens, for stores that are files. `None` for the + /// keychain. + fn location(&self, _profile: &str) -> Option { + None + } } /// The system keychain. #[derive(Debug, Default)] pub struct Keychain; +/// The store the settings choose, looked up when it is used: the environment and the +/// configuration file are read at that point, so a command that never needs a token never fails +/// on them. A value that is not a store, or a platform that cannot have it, is an error at the +/// first use, and nothing falls back to the other store. +#[derive(Debug, Default)] +pub struct Configured; + +impl Configured { + fn chosen(&self) -> Result> { + Ok(match crate::config::token_store()? { + crate::config::TokenStoreKind::Keychain => Box::new(Keychain), + crate::config::TokenStoreKind::PlaintextFile => { + Box::new(super::file_store::FileStore::platform()?) + } + }) + } +} + +impl TokenStore for Configured { + fn load(&self, profile: &str) -> Result> { + self.chosen()?.load(profile) + } + fn save(&self, profile: &str, token: &StoredToken) -> Result<()> { + self.chosen()?.save(profile, token) + } + fn delete(&self, profile: &str) -> Result { + self.chosen()?.delete(profile) + } + fn check_available(&self, profile: &str) -> Result<()> { + self.chosen()?.check_available(profile) + } + fn location(&self, profile: &str) -> Option { + self.chosen().ok()?.location(profile) + } +} + static STORE_READY: OnceLock> = OnceLock::new(); fn ensure_store() -> Result<()> { @@ -114,16 +159,20 @@ fn unavailable(reason: &str) -> Failure { /// What to do about a missing keychain, for the platform this build runs on. fn unavailable_hint() -> &'static str { if cfg!(target_os = "macos") { - "Sign-in tokens are only kept in the system keychain. Allow access when the Keychain \ - prompt appears (or in Keychain Access), or use a tenant API key in HODEISHIELD_API_KEY \ - instead." + "Sign-in tokens are kept in the system keychain. Allow access when the Keychain prompt \ + appears (or in Keychain Access), or use a tenant API key in HODEISHIELD_API_KEY \ + instead, or opt in to a plain-text token file with \ + HODEISHIELD_TOKEN_STORE=plaintext-file (tokens are then stored unencrypted in a file \ + only you can read)." } else if cfg!(windows) { - "Sign-in tokens are only kept in the system keychain. Check that Windows Credential \ - Manager is available, or use a tenant API key in HODEISHIELD_API_KEY instead." + "Sign-in tokens are kept in the system keychain. Check that Windows Credential Manager \ + is available, or use a tenant API key in HODEISHIELD_API_KEY instead." } else { - "Sign-in tokens are only kept in the system keychain. Start a Secret Service \ - (GNOME Keyring or KWallet); on a server without one, use a tenant API key in \ - HODEISHIELD_API_KEY instead." + "Sign-in tokens are kept in the system keychain. Start a Secret Service (GNOME Keyring \ + or KWallet); on a server without one, use a tenant API key in HODEISHIELD_API_KEY \ + instead, or opt in to a plain-text token file with \ + HODEISHIELD_TOKEN_STORE=plaintext-file (tokens are then stored unencrypted in a file \ + only you can read)." } } diff --git a/crates/hodeishield-cli/src/cli.rs b/crates/hodeishield-cli/src/cli.rs index 2f17fbb..da1106c 100644 --- a/crates/hodeishield-cli/src/cli.rs +++ b/crates/hodeishield-cli/src/cli.rs @@ -17,7 +17,8 @@ use std::str::FromStr; const AFTER_HELP: &str = "\ Credentials: HODEISHIELD_API_KEY A tenant API key. When set, it is used for every API call. - hodeishield login Otherwise, sign in to the app; the token is kept in the system keychain. + hodeishield login Otherwise, sign in to the app; the token is kept in the system keychain + (or, only if you opt in, in a plain-text file). Exit codes: 0 ok · 1 error · 2 usage · 3 not authenticated · 4 missing scope · 5 not found @@ -101,7 +102,10 @@ pub enum Command { const LOGIN_ABOUT: &str = "\ Sign in to the HodeiShield app. The token is kept in the system keychain (macOS Keychain, Windows \ -Credential Manager, Secret Service on Linux), never in a file, and is refreshed when it expires. +Credential Manager, Secret Service on Linux) and is refreshed when it expires. Only if you opt in \ +with HODEISHIELD_TOKEN_STORE=plaintext-file (Linux and macOS), it is kept instead in a file in your \ +state directory, unencrypted and readable only by you. The CLI never switches between the two on \ +its own. By default a browser window opens and the app redirects back to a one-shot listener on 127.0.0.1. \ With --device, you get a short code to type in on any other device instead. diff --git a/crates/hodeishield-cli/src/commands/config.rs b/crates/hodeishield-cli/src/commands/config.rs index 116e29b..e6c13de 100644 --- a/crates/hodeishield-cli/src/commands/config.rs +++ b/crates/hodeishield-cli/src/commands/config.rs @@ -29,6 +29,7 @@ pub fn run(ctx: &Context, command: ConfigCommand, out: &mut dyn Write) -> Result "oauth_client_id": settings.oauth_client_id, "oauth_scopes": settings.oauth_scopes, "update_check": update_check, + "token_store": settings.token_store.name(), }); if ctx.json { return Ok(print_json(out, &value)?); @@ -43,6 +44,7 @@ pub fn run(ctx: &Context, command: ConfigCommand, out: &mut dyn Write) -> Result writeln!(out, "oauth_client_id {}", settings.oauth_client_id)?; writeln!(out, "oauth_scopes {scopes}")?; writeln!(out, "update_check {update_check}")?; + writeln!(out, "token_store {}", settings.token_store.name())?; Ok(()) } ConfigCommand::Set { key, value } if key == config::UPDATE_CHECK => { @@ -52,6 +54,13 @@ pub fn run(ctx: &Context, command: ConfigCommand, out: &mut dyn Write) -> Result file.update_check = None; Ok(()) }), + ConfigCommand::Set { key, value } if key == config::TOKEN_STORE => { + edit_global(&path, |file| file.set_token_store(&value)) + } + ConfigCommand::Unset { key } if key == config::TOKEN_STORE => edit_global(&path, |file| { + file.token_store = None; + Ok(()) + }), ConfigCommand::Set { key, value } => edit(ctx, &path, |profile| profile.set(&key, &value)), ConfigCommand::Unset { key } => edit(ctx, &path, |profile| profile.unset(&key)), ConfigCommand::Use { name } => { diff --git a/crates/hodeishield-cli/src/commands/mod.rs b/crates/hodeishield-cli/src/commands/mod.rs index ad117b9..86e0af9 100644 --- a/crates/hodeishield-cli/src/commands/mod.rs +++ b/crates/hodeishield-cli/src/commands/mod.rs @@ -80,7 +80,7 @@ pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> { }, json: cli.json, verbose: cli.verbose, - store: Arc::new(auth::store::Keychain), + store: Arc::new(auth::store::Configured), }; match cli.command { Command::Vendors(command) => resources::vendors(&ctx, command, out), diff --git a/crates/hodeishield-cli/src/commands/session.rs b/crates/hodeishield-cli/src/commands/session.rs index f8c84e1..1303fa1 100644 --- a/crates/hodeishield-cli/src/commands/session.rs +++ b/crates/hodeishield-cli/src/commands/session.rs @@ -130,6 +130,13 @@ fn login_with( .ok() .flatten() .and_then(|info| user_label(&info)); + if let Some(path) = store.location(&settings.profile) { + eprintln!( + "The sign-in is stored unencrypted in {}, protected only by its permissions. \ + `hodeishield logout` revokes it and deletes the file.", + path.display() + ); + } eprintln!( "Signed in to {}{} (profile {}).", settings.app_url.host_str().unwrap_or("the app"), @@ -263,7 +270,7 @@ fn whoami_with( "Credential", source.map_or_else( || "none".to_owned(), - |s| auth::describe_source(s).to_owned(), + |s| auth::describe_source(s, store.location(&settings.profile).as_deref()), ), ), ]; diff --git a/crates/hodeishield-cli/src/config.rs b/crates/hodeishield-cli/src/config.rs index 71c3fb9..c5bf5a2 100644 --- a/crates/hodeishield-cli/src/config.rs +++ b/crates/hodeishield-cli/src/config.rs @@ -4,7 +4,8 @@ //! Configuration file and profiles. //! //! The file holds URLs and OAuth client settings per profile. It never holds a credential: tokens -//! live in the system keychain and API keys in the environment. +//! live in the system keychain (or, if the person opts in, in a separate token file) and API keys +//! in the environment. use crate::failure::{Failure, Result}; use serde::{Deserialize, Serialize}; @@ -28,6 +29,10 @@ pub struct ConfigFile { /// Whether to look for a newer release once a day. Global, not per profile. Unset: yes. #[serde(default, skip_serializing_if = "Option::is_none")] pub update_check: Option, + /// Where sign-in tokens are kept: `keychain` (unset means this) or `plaintext-file`. Global, + /// not per profile. `HODEISHIELD_TOKEN_STORE` takes precedence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_store: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub profiles: BTreeMap, } @@ -49,20 +54,83 @@ pub struct Profile { pub oauth_scopes: Option>, } -/// The keys `config set` and `config unset` accept. `update_check` is global; the rest belong to a -/// profile. +/// The keys `config set` and `config unset` accept. `update_check` and `token_store` are global; +/// the rest belong to a profile. pub const KEYS: &[&str] = &[ "api_url", "app_url", "oauth_client_id", "oauth_scopes", UPDATE_CHECK, + TOKEN_STORE, ]; -/// The one global key. +/// A global key: whether to look for a newer release. pub const UPDATE_CHECK: &str = "update_check"; +/// A global key: where sign-in tokens are kept. +pub const TOKEN_STORE: &str = "token_store"; + +/// The environment variable that overrides [`TOKEN_STORE`]. +pub const TOKEN_STORE_ENV: &str = "HODEISHIELD_TOKEN_STORE"; + +/// Where sign-in tokens are kept. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum TokenStoreKind { + /// The system keychain. The default. + #[default] + Keychain, + /// A plain-text file only the user can read. Opt-in. + PlaintextFile, +} + +impl TokenStoreKind { + pub const fn name(self) -> &'static str { + match self { + Self::Keychain => "keychain", + Self::PlaintextFile => "plaintext-file", + } + } + + /// Parses a setting. `source` says where the value came from, for the error. + pub fn parse(value: &str, source: &str) -> Result { + match value.trim() { + "keychain" => Ok(Self::Keychain), + "plaintext-file" => Ok(Self::PlaintextFile), + other => Err(Failure::general(format!( + "{source}: `{}` is not a token store.", + crate::output::clean(other) + )) + .hint("Use `keychain` (the default) or `plaintext-file`.")), + } + } +} + +/// The token store in effect: the environment, then the configuration file, then the keychain. +pub fn token_store() -> Result { + let config = load(&path()?)?; + choose_token_store(&config, |name| std::env::var(name).ok()) +} + +fn choose_token_store( + config: &ConfigFile, + env: impl Fn(&str) -> Option, +) -> Result { + if let Some(value) = env(TOKEN_STORE_ENV).filter(|v| !v.trim().is_empty()) { + return TokenStoreKind::parse(&value, TOKEN_STORE_ENV); + } + match &config.token_store { + Some(value) => TokenStoreKind::parse(value, TOKEN_STORE), + None => Ok(TokenStoreKind::Keychain), + } +} + impl ConfigFile { + pub fn set_token_store(&mut self, value: &str) -> Result<()> { + self.token_store = Some(TokenStoreKind::parse(value, TOKEN_STORE)?.name().to_owned()); + Ok(()) + } + pub fn set_update_check(&mut self, value: &str) -> Result<()> { self.update_check = Some(match value.trim().to_ascii_lowercase().as_str() { "true" => true, @@ -179,7 +247,7 @@ pub fn save(path: &Path, config: &ConfigFile) -> Result<()> { .map_err(|e| Failure::general(format!("Cannot write the configuration: {e}.")))?; let body = format!( "# HodeiShield CLI configuration. It holds no credential: tokens live in the system\n\ - # keychain and API keys in HODEISHIELD_API_KEY.\n\n{text}" + # keychain (or the token file you opted in to) and API keys in HODEISHIELD_API_KEY.\n\n{text}" ); write_private(path, body.as_bytes()) .map_err(|e| Failure::general(format!("Cannot write {}: {e}.", path.display()))) @@ -212,6 +280,7 @@ pub struct Settings { pub app_url: Url, pub oauth_client_id: String, pub oauth_scopes: Option>, + pub token_store: TokenStoreKind, pub config_path: PathBuf, } @@ -272,6 +341,7 @@ pub fn resolve_with( app_url, oauth_client_id, oauth_scopes: profile.oauth_scopes, + token_store: choose_token_store(config, |name| env(name))?, config_path, }) } @@ -388,6 +458,69 @@ mod tests { assert_eq!(text.trim(), "update_check = false"); } + #[test] + fn the_token_store_is_the_keychain_unless_the_environment_or_the_file_says_otherwise() { + let none = |_: &str| None; + let from_env = |_: &str| Some("plaintext-file".to_owned()); + let mut config = ConfigFile::default(); + assert_eq!( + choose_token_store(&config, none).expect("default"), + TokenStoreKind::Keychain + ); + config.set_token_store(" plaintext-file ").expect("set"); + assert_eq!(config.token_store.as_deref(), Some("plaintext-file")); + assert_eq!( + choose_token_store(&config, none).expect("file"), + TokenStoreKind::PlaintextFile + ); + // The environment wins, in both directions; an empty variable does not count. + config.token_store = Some("keychain".to_owned()); + assert_eq!( + choose_token_store(&config, from_env).expect("env"), + TokenStoreKind::PlaintextFile + ); + config.token_store = Some("plaintext-file".to_owned()); + let keychain = |_: &str| Some("keychain".to_owned()); + assert_eq!( + choose_token_store(&config, keychain).expect("env"), + TokenStoreKind::Keychain + ); + let empty = |_: &str| Some(" ".to_owned()); + assert_eq!( + choose_token_store(&config, empty).expect("empty"), + TokenStoreKind::PlaintextFile + ); + } + + #[test] + fn any_other_token_store_is_an_error_naming_where_it_came_from() { + let mut config = ConfigFile::default(); + assert!(config.set_token_store("file").is_err()); + assert!(config.set_token_store("").is_err()); + assert!(config.token_store.is_none()); + config.token_store = Some("Keychain".to_owned()); + let err = choose_token_store(&config, |_| None).expect_err("not a store"); + assert!( + err.message.starts_with("token_store: `Keychain`"), + "{}", + err.message + ); + let err = choose_token_store(&ConfigFile::default(), |_| Some("file\u{1b}[2J".to_owned())) + .expect_err("not a store"); + assert!( + err.message.starts_with("HODEISHIELD_TOKEN_STORE:"), + "{}", + err.message + ); + assert!(!err.message.contains('\u{1b}')); + // Settings resolution reports it too, so no command runs on a misspelt setting. + let bad = ConfigFile { + token_store: Some("plain".to_owned()), + ..ConfigFile::default() + }; + assert!(resolve_with(&bad, PathBuf::new(), &Overrides::default(), no_env).is_err()); + } + #[test] fn unknown_keys_are_refused() { let mut profile = Profile::default(); diff --git a/crates/hodeishield-cli/tests/cli.rs b/crates/hodeishield-cli/tests/cli.rs index 18a8fe6..7b54192 100644 --- a/crates/hodeishield-cli/tests/cli.rs +++ b/crates/hodeishield-cli/tests/cli.rs @@ -33,6 +33,7 @@ impl Env { "HODEISHIELD_APP_URL", "HODEISHIELD_PROFILE", "HODEISHIELD_OAUTH_CLIENT_ID", + "HODEISHIELD_TOKEN_STORE", ] { cmd.env_remove(var); } @@ -41,10 +42,38 @@ impl Env { "DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent/hodeishield-test", ) + .env("XDG_STATE_HOME", self.state()) .env("NO_COLOR", "1"); cmd } + /// Where the state directory is for this test. + fn state(&self) -> PathBuf { + self._dir.path().join("state") + } + + /// The binary with the opt-in plain-text token store, and a home that is the test's own, so + /// the platform's state directory is inside the test's directory on every system. + #[cfg(unix)] + fn cmd_file(&self) -> Command { + let mut cmd = self.cmd(); + cmd.env("HODEISHIELD_TOKEN_STORE", "plaintext-file") + .env("HOME", self._dir.path().join("home")); + cmd + } + + /// The sessions directory of the opt-in store, where the platform keeps it. + #[cfg(unix)] + fn sessions(&self) -> PathBuf { + if cfg!(target_os = "macos") { + self._dir + .path() + .join("home/Library/Application Support/com.Hodeitek.HodeiShield/sessions") + } else { + self.state().join("hodeishield/sessions") + } + } + fn api(&self, server: &mockito::Server) -> Command { let mut cmd = self.cmd(); cmd.env("HODEISHIELD_API_KEY", KEY) @@ -771,3 +800,452 @@ fn free_form_filters_are_sent_as_typed_without_a_warning() { mock.assert(); } } + +/// The opt-in plain-text token store, end to end. Unix only: Windows refuses it (last test). +#[cfg(unix)] +mod file_store { + use super::*; + use serde_json::json; + use std::os::unix::fs::{PermissionsExt, symlink}; + use std::sync::{Arc, Mutex}; + + /// An app that offers the device flow and issues `at-new` / `rt-new`; revocation requests are + /// recorded in arrival order. + struct App { + api: mockito::ServerGuard, + app: mockito::ServerGuard, + revoked: Arc>>, + _mocks: Vec, + } + + fn app() -> App { + let mut api = mockito::Server::new(); + let mut app = mockito::Server::new(); + let revoked = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&revoked); + let url = app.url(); + let mocks = vec![ + api.mock("GET", "/.well-known/oauth-protected-resource") + .with_status(404) + .create(), + app.mock("GET", "/.well-known/oauth-authorization-server") + .with_body( + json!({ + "issuer": url, + "token_endpoint": format!("{url}/token"), + "device_authorization_endpoint": format!("{url}/device/code"), + "revocation_endpoint": format!("{url}/revoke"), + "scopes_supported": ["offline_access"], + }) + .to_string(), + ) + .create(), + app.mock("POST", "/device/code") + .with_body( + json!({ + "device_code": "dc", "user_code": "ABCD-EFGH", + "verification_uri": format!("{url}/device"), + "expires_in": 600, "interval": 1, + }) + .to_string(), + ) + .create(), + app.mock("POST", "/token") + .with_body( + json!({ + "access_token": "at-new", "refresh_token": "rt-new", + "token_type": "Bearer", "expires_in": 3600, + }) + .to_string(), + ) + .create(), + app.mock("POST", "/revoke") + .with_body_from_request(move |request| { + let form: Vec<(String, String)> = + url::form_urlencoded::parse(request.body().expect("body")) + .into_owned() + .collect(); + let get = |key: &str| { + form.iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + .unwrap_or_default() + }; + seen.lock().expect("lock").push(format!( + "{}={}", + get("token_type_hint"), + get("token") + )); + Vec::new() + }) + .create(), + ]; + App { + api, + app, + revoked, + _mocks: mocks, + } + } + + impl App { + fn with(&self, mut cmd: Command) -> Command { + cmd.env("HODEISHIELD_API_URL", self.api.url()) + .env("HODEISHIELD_APP_URL", self.app.url()); + cmd + } + } + + fn mode(path: &std::path::Path) -> u32 { + use std::os::unix::fs::MetadataExt; + std::fs::metadata(path).expect("metadata").mode() & 0o777 + } + + /// The files in the sessions directory. + fn kept(env: &Env) -> Vec { + let mut found: Vec = std::fs::read_dir(env.sessions()) + .map(|dir| dir.map(|e| e.expect("entry").path()).collect()) + .unwrap_or_default(); + found.sort(); + found + } + + fn signed_in(env: &Env, app: &App) -> PathBuf { + app.with(env.cmd_file()) + .args(["login", "--device"]) + .assert() + .success(); + let files = kept(env); + assert_eq!(files.len(), 1, "{files:?}"); + files[0].clone() + } + + fn all_output(output: &std::process::Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + } + + #[test] + fn login_whoami_and_logout_use_a_private_file_and_never_show_a_token() { + let env = Env::new(); + let app = app(); + let login = app + .with(env.cmd_file()) + .args(["login", "--device"]) + .output() + .expect("run"); + assert!(login.status.success(), "{}", all_output(&login)); + assert!(login.stdout.is_empty()); + assert!( + String::from_utf8_lossy(&login.stderr).contains("stored unencrypted"), + "{}", + all_output(&login) + ); + + let files = kept(&env); + assert_eq!(files.len(), 1, "{files:?}"); + let file = &files[0]; + assert_eq!(mode(file), 0o600); + assert_eq!(mode(&env.sessions()), 0o700); + assert!(file.to_string_lossy().ends_with(".json")); + let text = std::fs::read_to_string(file).expect("file"); + assert!(text.contains("at-new") && text.contains("rt-new")); + + let whoami = app + .with(env.cmd_file()) + .arg("whoami") + .output() + .expect("run"); + assert!(whoami.status.success(), "{}", all_output(&whoami)); + let shown = all_output(&whoami); + assert!(shown.contains("plain-text file"), "{shown}"); + assert!( + !shown.contains("at-new") && !shown.contains("rt-new"), + "{shown}" + ); + + let logout = app + .with(env.cmd_file()) + .arg("logout") + .output() + .expect("run"); + assert!(logout.status.success(), "{}", all_output(&logout)); + assert_eq!( + *app.revoked.lock().expect("lock"), + ["refresh_token=rt-new", "access_token=at-new"] + ); + assert!(kept(&env).is_empty()); + assert!(!all_output(&logout).contains("-new")); + } + + #[test] + fn two_profiles_are_kept_in_two_files() { + let env = Env::new(); + let app = app(); + env.cmd() + .args(["config", "set", "--profile", "client-b", "api_url"]) + .arg(app.api.url()) + .assert() + .success(); + signed_in(&env, &app); + app.with(env.cmd_file()) + .args(["--profile", "client-b", "login", "--device"]) + .assert() + .success(); + assert_eq!(kept(&env).len(), 2); + app.with(env.cmd_file()) + .args(["--profile", "client-b", "logout"]) + .assert() + .success(); + assert_eq!(kept(&env).len(), 1); + app.with(env.cmd_file()).arg("whoami").assert().success(); + } + + #[test] + fn a_corrupt_file_is_an_error_that_names_it() { + let env = Env::new(); + let app = app(); + let file = signed_in(&env, &app); + std::fs::write(&file, "{ not json").expect("corrupt it"); + for args in [&["whoami"][..], &["vendors", "list"][..]] { + app.with(env.cmd_file()) + .args(args) + .assert() + .code(1) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("is unreadable")) + .stderr(predicate::str::contains(file.to_string_lossy().as_ref())); + } + } + + /// Every way the store refuses a file or directory: signing in is refused before the app is + /// asked for anything (exit 1), commands that need a credential are "not authenticated" + /// (exit 3), and nothing is repaired or deleted. + fn assert_refused(env: &Env, app: &App, reason: &str) { + let mut login_api = mockito::Server::new(); + let mut login_app = mockito::Server::new(); + let calls: Vec<_> = [&mut login_api, &mut login_app] + .into_iter() + .flat_map(|server| { + ["GET", "POST"].map(|method| { + server + .mock(method, mockito::Matcher::Any) + .expect(0) + .create() + }) + }) + .collect(); + env.cmd_file() + .env("HODEISHIELD_API_URL", login_api.url()) + .env("HODEISHIELD_APP_URL", login_app.url()) + .args(["login", "--device"]) + .assert() + .code(1) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains( + "plain-text token store cannot be used", + )) + .stderr(predicate::str::contains(reason)); + for call in &calls { + call.assert(); + } + for args in [&["whoami"][..], &["vendors", "list"][..]] { + app.with(env.cmd_file()) + .args(args) + .assert() + .code(3) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains(reason)); + } + // Nothing was sent to the app that signed in, and logout does not revoke what it cannot + // trust. + app.revoked.lock().expect("lock").clear(); + app.with(env.cmd_file()) + .arg("logout") + .assert() + .code(1) + .stderr(predicate::str::contains(reason)); + assert!(app.revoked.lock().expect("lock").is_empty()); + } + + #[test] + fn a_file_open_to_group_or_others_is_refused_and_left_alone() { + for bits in [0o644, 0o640] { + let env = Env::new(); + let app = app(); + let file = signed_in(&env, &app); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(bits)).expect("chmod"); + assert_refused(&env, &app, "accessible to other users"); + assert_eq!(mode(&file), bits); + } + } + + #[test] + fn a_directory_open_to_group_or_others_is_refused_and_left_alone() { + let env = Env::new(); + let app = app(); + signed_in(&env, &app); + std::fs::set_permissions(env.sessions(), std::fs::Permissions::from_mode(0o755)) + .expect("chmod"); + assert_refused(&env, &app, "accessible to other users"); + assert_eq!(mode(&env.sessions()), 0o755); + } + + #[test] + fn a_symlinked_file_is_refused_and_left_alone() { + let env = Env::new(); + let app = app(); + let file = signed_in(&env, &app); + let real = env.sessions().join("elsewhere"); + std::fs::rename(&file, &real).expect("move"); + symlink(&real, &file).expect("link"); + assert_refused(&env, &app, "symbolic link"); + assert!(std::fs::symlink_metadata(&file).expect("meta").is_symlink()); + assert!(real.exists()); + } + + #[test] + fn a_symlinked_directory_is_refused_and_left_alone() { + let env = Env::new(); + let app = app(); + signed_in(&env, &app); + let moved = env.state().join("moved"); + std::fs::rename(env.sessions(), &moved).expect("move"); + symlink(&moved, env.sessions()).expect("link"); + assert_refused(&env, &app, "symbolic link"); + assert!( + std::fs::symlink_metadata(env.sessions()) + .expect("meta") + .is_symlink() + ); + assert_eq!(std::fs::read_dir(&moved).expect("dir").count(), 1); + } + + #[test] + fn an_invalid_setting_is_an_error_before_anything_else_happens() { + let env = Env::new(); + let mut server = mockito::Server::new(); + let calls = server.mock("GET", mockito::Matcher::Any).expect(0).create(); + // Even with an API key, which would otherwise never look at the token store. + for value in ["file", "Keychain", "plaintext"] { + env.api(&server) + .env("HODEISHIELD_TOKEN_STORE", value) + .args(["vendors", "list"]) + .assert() + .code(1) + .stderr(predicate::str::contains("not a token store")) + .stderr(predicate::str::contains("plaintext-file")); + } + env.cmd() + .args(["config", "set", "token_store", "file"]) + .assert() + .code(1) + .stderr(predicate::str::contains("not a token store")); + std::fs::write(&env.config, "token_store = \"nope\"\n").expect("config"); + env.api(&server) + .args(["vendors", "list"]) + .assert() + .code(1) + .stderr(predicate::str::contains( + "token_store: `nope` is not a token store", + )); + calls.assert(); + assert!(!env.sessions().exists()); + } + + #[test] + fn the_setting_can_come_from_the_configuration_file_and_the_environment_wins() { + let env = Env::new(); + env.cmd() + .args(["config", "set", "token_store", "plaintext-file"]) + .assert() + .success(); + env.cmd() + .args(["config", "show"]) + .assert() + .success() + .stdout(predicate::str::contains("token_store plaintext-file")); + env.cmd() + .env("HODEISHIELD_TOKEN_STORE", "keychain") + .args(["config", "show"]) + .assert() + .success() + .stdout(predicate::str::contains("token_store keychain")); + let app = app(); + // From the file alone, and without the environment variable, the file store is used. + app.with(env.cmd()) + .env("HOME", env._dir.path().join("home")) + .args(["login", "--device"]) + .assert() + .success(); + assert_eq!(kept(&env).len(), 1); + env.cmd() + .args(["config", "unset", "token_store"]) + .assert() + .success(); + env.cmd() + .args(["config", "show"]) + .assert() + .success() + .stdout(predicate::str::contains("token_store keychain")); + } + + /// Only on Linux, where the tests make the Secret Service unreachable. + #[cfg(not(target_os = "macos"))] + #[test] + fn there_is_no_fallback_to_a_file_without_the_opt_in() { + let env = Env::new(); + let app = app(); + app.with(env.cmd()) + .args(["login", "--device"]) + .assert() + .code(1) + .stderr(predicate::str::contains("system keychain is not available")) + .stderr(predicate::str::contains( + "HODEISHIELD_TOKEN_STORE=plaintext-file", + )) + .stderr(predicate::str::contains("HODEISHIELD_API_KEY")); + assert!(!env.state().exists(), "no file or directory is made"); + // And the other way round: with the opt-in the keychain is never asked, which here would + // be an error. + signed_in(&env, &app); + } +} + +/// Windows has no mode bits to check, so the opt-in is refused there, before the app is asked for +/// anything. +#[cfg(windows)] +#[test] +fn the_plain_text_token_store_is_refused_on_windows() { + let env = Env::new(); + let mut api = mockito::Server::new(); + let mut app = mockito::Server::new(); + let calls: Vec<_> = [&mut api, &mut app] + .into_iter() + .flat_map(|server| { + ["GET", "POST"].map(|method| { + server + .mock(method, mockito::Matcher::Any) + .expect(0) + .create() + }) + }) + .collect(); + for args in [&["login"][..], &["login", "--device"][..]] { + env.cmd() + .env("HODEISHIELD_TOKEN_STORE", "plaintext-file") + .env("HODEISHIELD_API_URL", api.url()) + .env("HODEISHIELD_APP_URL", app.url()) + .args(args) + .assert() + .code(1) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("not supported on Windows")); + } + for call in &calls { + call.assert(); + } +} From 15c481fb1a2cfe6ddaa3a34d0ecc932b594668cb Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:00:11 +0200 Subject: [PATCH 03/25] Document the plain-text token store Add a README section for servers, containers and remote sessions that puts API keys first and the opt-in file second with its trade-off, the new variable and setting in their tables, the network home directory note, and the exit code for a refused token file. Update the places that promised tokens are never in a file, and add the changelog entry. Refs #39 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- CHANGELOG.md | 12 +++++++++ CONTRIBUTING.md | 9 ++++--- README.md | 71 ++++++++++++++++++++++++++++++++++++++++++------- SECURITY.md | 3 ++- 4 files changed, 81 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e38822..044e23a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ commands, `--json` output and exit codes. Each release's notes start with its se ### Added +- An opt-in plain-text token store, for machines that have no system keychain (an SSH or jump host, + WSL without a desktop). Set `HODEISHIELD_TOKEN_STORE=plaintext-file`, or the global setting + `hodeishield config set token_store plaintext-file`, and `login --device` keeps the session in a + file only your user can read, one per profile, in the platform's state directory + (`$XDG_STATE_HOME/hodeishield/sessions/` on Linux). The keychain stays the default, nothing ever + falls back from one store to the other, and the file store is not available on Windows yet. Every + read and write refuses a file or directory that is a symbolic link, belongs to another user or is + open to group or others; files are replaced atomically with mode 600. `logout` revokes the tokens + and deletes the file. The README has a new section, "Servers, containers and remote sessions", + with the trade-off and the advice for containers and CI (a tenant API key); ADR 0001 records the + decision. A configuration file that contains `token_store` is refused by older CLIs. + ([#39](https://github.com/Hodeitek/hodeishield-cli/issues/39)) - Each release now includes a Windows on Arm build, `hodeishield--aarch64-pc-windows-msvc.zip`. It is built natively on a Windows on Arm runner, its `hodeishield.exe` is signed with Authenticode like the x64 one and checked to need no Visual C++ runtime, and the archive is listed in diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1456741..80ea4dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,8 +6,9 @@ Thanks for helping. Bug reports, fixes and documentation improvements are welcom - **The CLI only reads.** Changes that write to the API will not be accepted; the `/v1` API is read-only anyway. -- **Credentials never leave the keychain or the environment.** No change may write a token or key to - a file, print it, or log it. Tests assert this; keep them passing. +- **Credentials stay in the keychain, the environment or the opt-in token file.** No change may write + a token or key anywhere else, print it, or log it, and nothing may fall back from one store to the + other. Tests assert this; keep them passing. - **Do not include real data** in issues, tests or examples: no real API keys, tokens, tenant names, hostnames other than the public `*.hodeishield.com`, or customer data. Use `example.com` / `example.test` and made-up values. @@ -105,8 +106,8 @@ implementation plugged into one. **Depth** is the behaviour available per unit o - **The interface is the test surface.** Test through the interface. A test that reaches past it pins the implementation and breaks on every refactor. - **One adapter is a hypothetical seam; two make it real.** Add a trait only when a second - implementation exists today, a test double included. `TokenStore` qualifies: the OS keychain and - the in-memory store used by tests both implement it. + implementation exists today, a test double included. `TokenStore` qualifies: the OS keychain, the + opt-in token file and the in-memory store used by tests all implement it. - **Locality.** Table, JSON and CSV rendering, including the sanitising of terminal control characters, lives in `output.rs`, so a change to it touches one module, not every command. diff --git a/README.md b/README.md index ffe8e0e..ee26b63 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ endpoint agents from a terminal or a script. - **Read-only.** Every command is a `GET` against the public `/v1` API. Nothing here can change data in your organisation. -- **Safe with credentials.** Sign-in tokens live in the system keychain, never in a file; nothing is - ever printed or logged; credentials only travel over HTTPS and are never replayed to a redirect. +- **Safe with credentials.** Sign-in tokens live in the system keychain unless you opt in to a + plain-text file for machines that have none; nothing is ever printed or logged; credentials only travel over HTTPS and are never replayed to a redirect. - **Built for scripts.** `--json` prints the API's own JSON, exit codes say what went wrong, and `HODEISHIELD_API_KEY` works without any interactive step. - **Verifiable.** Every release is signed with Sigstore (keyless, from GitHub Actions) and carries @@ -169,7 +169,8 @@ docker run --rm -e HODEISHIELD_API_KEY ghcr.io/hodeitek/hodeishield-cli:$v vendo `HODEISHIELD_API_KEY` is passed through from your environment (`-e NAME` without a value) so the key never appears on the command line. `hodeishield login` does not work in a container (there is no -browser and no system keychain): use a tenant API key, see [Authenticate](#authenticate). +browser and no system keychain): use a tenant API key, see +[Servers, containers and remote sessions](#servers-containers-and-remote-sessions). The image is signed by digest with cosign (keyless) and carries the release's CycloneDX SBOM as a signed attestation. Verify both before you rely on the image: @@ -297,8 +298,9 @@ hodeishield login --profile client-b A profile that has not been defined is refused (`Profile ... is not defined`). -`login` first checks that the system keychain is available and stops before opening the browser or -showing a code if it is not; use a tenant API key on such a machine. +`login` first checks that the selected token store is available and stops before opening the browser +or showing a code if it is not. On a machine without a keychain, see +[Servers, containers and remote sessions](#servers-containers-and-remote-sessions). With `--device`, open the address the CLI prints on any device and **type the code by hand**: the app does not accept a link with the code filled in, on purpose. @@ -309,8 +311,8 @@ framework the app offers; evidence; endpoints) and for a refresh token. To ask f The token is kept in the system keychain (macOS Keychain, Windows Credential Manager, or the Secret Service — GNOME Keyring, KWallet — on Linux) and refreshed automatically, also when the app ends an -access token early. Without a keychain, signing in is refused rather than falling back to a file: use -an API key there. +access token early. Without a keychain, signing in is refused rather than falling back to a file; +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 @@ -320,6 +322,53 @@ automated sessions. 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. +### Servers, containers and remote sessions + +Where there is no desktop session (an SSH server, a container, WSL without a desktop, a CI runner) +there is usually no keychain either, and `hodeishield login` stops before it opens a browser or shows +a code. You have two options, in this order of preference: + +1. **A tenant API key** (see [With a tenant API key](#with-a-tenant-api-key-scripts-ci-servers)). + It works everywhere, needs no storage on the machine, can be limited to the read scopes you need + and is the right choice for containers and CI. Do not sign in during `docker build`, and never + commit or publish a container in which someone has signed in. +2. **A plain-text token file, if you want to sign in as yourself** on a machine you control, such as + a jump host. You opt in explicitly, and the trade-off is that the tokens are stored + **unencrypted** in a file that only your user can read. Anyone who can read your files, such as + `root`, a backup, a snapshot or a copy of your home directory, can use the refresh token until it + is revoked. + + ```sh + export HODEISHIELD_TOKEN_STORE=plaintext-file # or: hodeishield config set token_store plaintext-file + hodeishield login --device + hodeishield whoami + hodeishield logout # revokes the tokens, then deletes the file + ``` + + - **Where.** One file per profile in the sessions directory of your state directory: + `$XDG_STATE_HOME/hodeishield/sessions/` (by default `~/.local/state/hodeishield/sessions/`) on + Linux, and `~/Library/Application Support/com.Hodeitek.HodeiShield/sessions/` on macOS. The + configuration file never holds a token, and the file names do not reveal profile names. + - **Checks.** The directory is created with mode 700 and the files with mode 600, written by + replacing the file atomically. On every read and write the CLI refuses, and does not repair, a + file or directory that is a symbolic link, belongs to another user, or is open to group or + others. Commands then stop with exit code 3 (`login`: 1) and tell you what is wrong; fix it + yourself (`chmod`), and revoke the tokens in the app if someone else could have read them. + - **No fallback.** The setting must be the same on every command, and the CLI never switches + between the keychain and the file on its own. Tokens are not moved when you change it: sign out, + change the setting, sign in again. The values are `plaintext-file` and `keychain` (the default); + anything else is an error. + - **Network home directories.** Do not keep the state directory on NFS or another shared file + system: the file would be readable by the server's administrators and by `root` on other + machines that mount it, and its permissions are not enforced the same way. On Linux the CLI + warns on `login` when the directory is on NFS or SMB; it does not stop you. Point + `XDG_STATE_HOME` at a local directory instead. + - **Deleting is not revoking.** `logout` revokes the tokens at the app and then deletes the file, + but removing the file by hand, or restoring it from a backup, does not end the session. Only + revoking does. + - **Not on Windows yet.** Windows has no permission bits to check, so selecting the file store + there is refused with exit code 1, before anything is asked of the app. + ## Use ```sh @@ -380,7 +429,7 @@ hodeishield vendors list --all --csv > vendors.csv | 0 | Success | | 1 | Error (invalid input, configuration, unexpected answer) | | 2 | Invalid command line | -| 3 | Not authenticated: no credential (also when the system keychain cannot be reached in this session), or the API rejected it | +| 3 | Not authenticated: no credential (also when the system keychain cannot be reached in this session, or the opt-in token file is refused as unsafe), or the API rejected it | | 4 | The credential lacks the scope the command needs | | 5 | Not found (or not in your organisation) | | 6 | Rate limit exhausted (the CLI already retried short waits) | @@ -426,6 +475,7 @@ hodeishield config path # where the file is hodeishield --profile staging config set api_url https://api.example.test hodeishield config use staging # make it the default profile hodeishield config set update_check false # no new-version notice (global, not per profile) +hodeishield config set token_store plaintext-file # opt-in token file, see the section on servers hodeishield --profile default vendors list # or pick one per command ``` @@ -436,9 +486,12 @@ hodeishield --profile default vendors list # or pick one per command | `HODEISHIELD_API_URL` | API base URL | | `HODEISHIELD_APP_URL` | App base URL (sign-in) | | `HODEISHIELD_CONFIG` | Path of the configuration file | +| `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) | -The configuration file holds URLs and sign-in settings only, never a credential. URLs must use HTTPS; +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; plain HTTP is accepted only for `localhost`. ## New-version notice diff --git a/SECURITY.md b/SECURITY.md index 4ede4b2..d15796a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,7 +24,8 @@ Only the latest release receives fixes. ## What is in scope - Anything that could expose a credential: the tenant API key in `HODEISHIELD_API_KEY`, or the - sign-in tokens the CLI keeps in the system keychain. + sign-in tokens the CLI keeps in the system keychain or, if the user opted in, in its token file + (including the checks that refuse an unsafe file or directory). - Sending a credential anywhere other than the configured HodeiShield hosts (redirects, plain HTTP, crafted URLs or identifiers). - Output from the API reaching your terminal in a way that can run or hide commands. From 8a93e2092cb66f952b664c105423d790388772b6 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:10:02 +0200 Subject: [PATCH 04/25] Clarify where the token files are and when commands refuse them Say that on macOS the sessions directory is a sibling of the application support folder, not inside the configuration folder, that the parents of the sessions directory must not be writable by other users, that the file names are a hash and not a way to hide a profile name, that running as root for a user's home does not work, and that a refused file makes login and logout exit 1 while other commands exit 3. Refs #39 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- CHANGELOG.md | 3 ++- README.md | 19 +++++++++++---- .../0001-sign-in-without-a-keychain.md | 24 ++++++++++++------- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 044e23a..b52c98f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ commands, `--json` output and exit codes. Each release's notes start with its se (`$XDG_STATE_HOME/hodeishield/sessions/` on Linux). The keychain stays the default, nothing ever falls back from one store to the other, and the file store is not available on Windows yet. Every read and write refuses a file or directory that is a symbolic link, belongs to another user or is - open to group or others; files are replaced atomically with mode 600. `logout` revokes the tokens + open to group or others (exit code 3 for commands that need a credential, 1 for `login` and + `logout`); files are replaced atomically with mode 600. `logout` revokes the tokens and deletes the file. The README has a new section, "Servers, containers and remote sessions", with the trade-off and the advice for containers and CI (a tenant API key); ADR 0001 records the decision. A configuration file that contains `token_store` is refused by older CLIs. diff --git a/README.md b/README.md index ee26b63..21b4040 100644 --- a/README.md +++ b/README.md @@ -347,13 +347,22 @@ a code. You have two options, in this order of preference: - **Where.** One file per profile in the sessions directory of your state directory: `$XDG_STATE_HOME/hodeishield/sessions/` (by default `~/.local/state/hodeishield/sessions/`) on - Linux, and `~/Library/Application Support/com.Hodeitek.HodeiShield/sessions/` on macOS. The - configuration file never holds a token, and the file names do not reveal profile names. + Linux, and `~/Library/Application Support/com.Hodeitek.HodeiShield.sessions/` on macOS (next to, + not inside, the folder that holds the configuration file). The configuration file never holds + a token, and the file names are a hash of the profile name, not the name itself (a hash of a + guessable name can be reversed, so do not treat it as hiding the profile). The parents of that + directory, such as your state directory, must not be writable by other users. - **Checks.** The directory is created with mode 700 and the files with mode 600, written by replacing the file atomically. On every read and write the CLI refuses, and does not repair, a file or directory that is a symbolic link, belongs to another user, or is open to group or - others. Commands then stop with exit code 3 (`login`: 1) and tell you what is wrong; fix it - yourself (`chmod`), and revoke the tokens in the app if someone else could have read them. + others. Commands that need a credential then stop with exit code 3 (`login` and `logout`: 1) and + tell you what is wrong; fix it yourself (`chmod`), and revoke the tokens in the app if someone + else could have read them. Temporary files left by a write that was interrupted are removed + the next time that profile is saved or signed out. + - **Do not run it as `root` or with `sudo` for your user.** A run as `root` that uses your home + directory can create root-owned directories there, which your own runs then refuse (and + `root` refuses yours, since they belong to another user). Sign in with the user that will run + the commands. - **No fallback.** The setting must be the same on every command, and the CLI never switches between the keychain and the file on its own. Tokens are not moved when you change it: sign out, change the setting, sign in again. The values are `plaintext-file` and `keychain` (the default); @@ -429,7 +438,7 @@ hodeishield vendors list --all --csv > vendors.csv | 0 | Success | | 1 | Error (invalid input, configuration, unexpected answer) | | 2 | Invalid command line | -| 3 | Not authenticated: no credential (also when the system keychain cannot be reached in this session, or the opt-in token file is refused as unsafe), or the API rejected it | +| 3 | Not authenticated: no credential (also when the system keychain cannot be reached in this session, or the opt-in token file is refused as unsafe; `login` and `logout` exit 1 for that), or the API rejected it | | 4 | The credential lacks the scope the command needs | | 5 | Not found (or not in your organisation) | | 6 | Rate limit exhausted (the CLI already retried short waits) | diff --git a/docs/decisions/0001-sign-in-without-a-keychain.md b/docs/decisions/0001-sign-in-without-a-keychain.md index 6cd9146..4691042 100644 --- a/docs/decisions/0001-sign-in-without-a-keychain.md +++ b/docs/decisions/0001-sign-in-without-a-keychain.md @@ -81,22 +81,30 @@ the following rules. Containers and CI keep using API keys. moved between stores: sign out, change the setting, sign in again. - **Location.** The platform's state directory, not the configuration directory: `$XDG_STATE_HOME/hodeishield/sessions/` (by default `~/.local/state/hodeishield/sessions/`) on - Linux, and the application support directory on macOS. One file per profile, named by the SHA-256 - of the profile name (names may contain `/` or `..`). The configuration file stays free of - credentials, so copying or syncing it never copies a token. + Linux, and on macOS a sibling of the application support folder + (`~/Library/Application Support/com.Hodeitek.HodeiShield.sessions/`), because there the + configuration and data directories are the same folder. One file per profile, named by the SHA-256 + of the profile name (names may contain `/` or `..`; the hash is not a way to hide a guessable + name). The configuration file stays free of credentials, so copying or syncing it never copies a + token. The parents of the sessions directory are not walked or checked, because that would refuse + legitimate setups; the README says they must not be writable by other users. - **Checks on every read and write.** The CLI refuses, and never repairs, a file or directory that is a symbolic link, is not owned by the current user, or has any group or other permission bit. It - creates the directory with mode 700 and the files with mode 600. The opened file is checked again - after it is opened, so a file swapped in between is refused. A corrupt file is an error that names + creates the directory with mode 700 and the files with mode 600. The directory is opened once + without following links and checked on the open handle; every later operation is relative to that + handle, so replacing the path afterwards cannot redirect it. Files are opened without following + links and without blocking, and checked again after they are opened. Running as `root` for a + user's home is not special-cased: a directory of another owner is refused in both directions. A corrupt file is an error that names it; a file written for another profile is not accepted. - **Atomic writes.** The CLI writes a new file created with mode 600 in the same directory, flushes it to disk, renames it over the target and flushes the directory. A temporary file is removed when - anything fails, and no token is ever in a file that others can read. + anything fails, and no token is ever in a file that others can read. Leftovers of a crash (a + temporary file of that profile older than a minute) are removed on the next save or logout. - **Behaviour of #101 is unchanged.** `login` checks that the selected store is usable before it opens a browser or shows a code. If saving fails after the exchange, the refresh token and then the access token are revoked and the command exits non-zero. `logout` revokes, then deletes the file. - No new exit codes: `login` failures are 1, other commands that need a credential and find the file - refused are 3, a corrupt file is 1. + No new exit codes: `login` and `logout` failures are 1, other commands that need a credential and + find the file refused are 3, a corrupt file is 1. - **Windows is deferred.** There are no permission bits to check, and setting and checking an ACL needs platform calls that the workspace's `unsafe_code = "forbid"` rules out without a vetted wrapper. Selecting the file store on Windows is refused with exit code 1, before anything is asked From e981c73f8626248132bab897b2c1e6d02a51f1dd Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:10:02 +0200 Subject: [PATCH 05/25] Harden the plain-text token store Keep the sessions outside the configuration directory on macOS, where the platform gives the same folder for both. Open the sessions directory once without following links, check it on the open handle and do every later operation relative to it, so replacing the path after the check cannot redirect a read, write or removal. Open files without following links and without blocking, remove stale temporary files that a crash could leave with live tokens, and choose the store once per process so a command checks and saves in the same one. State precisely in the message that group and others must have no access. Tests cover the temporary file guard, the leftovers, the opposite direction of the missing fallback, and that a refused file is left byte for byte as it was. Refs #39 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- crates/hodeishield-cli/src/auth/file_store.rs | 451 ++++++++++++------ crates/hodeishield-cli/src/auth/store.rs | 23 +- crates/hodeishield-cli/src/commands/mod.rs | 2 +- crates/hodeishield-cli/tests/cli.rs | 34 ++ 4 files changed, 356 insertions(+), 154 deletions(-) diff --git a/crates/hodeishield-cli/src/auth/file_store.rs b/crates/hodeishield-cli/src/auth/file_store.rs index 554b902..de84fd2 100644 --- a/crates/hodeishield-cli/src/auth/file_store.rs +++ b/crates/hodeishield-cli/src/auth/file_store.rs @@ -7,8 +7,8 @@ //! `HODEISHIELD_TOKEN_STORE=plaintext-file` or the global `token_store` setting says so. There is //! no fallback in either direction (see `docs/decisions/0001-sign-in-without-a-keychain.md`). //! -//! Files live in the platform's state directory (`$XDG_STATE_HOME/hodeishield/sessions/` on Linux), -//! not in the configuration directory, so a copied or synced configuration never carries a token. +//! Files live in the platform's state directory (`$XDG_STATE_HOME/hodeishield/sessions/` on Linux, a +//! sibling of the application support folder on macOS), not in the configuration directory, so a copied or synced configuration never carries a token. //! The file name is a hash of the profile name, which may contain `/` or `..`. //! //! On every read and write the CLI refuses, and never repairs, a file or directory that is a @@ -41,11 +41,20 @@ pub fn sessions_dir() -> Result { Failure::general("Cannot find a state directory for this user.") .hint("Use the system keychain or a tenant API key instead.") })?; - // `state_dir` exists on Linux and the BSDs; macOS has only the application support directory. - Ok(dirs - .state_dir() - .unwrap_or_else(|| dirs.data_local_dir()) - .join("sessions")) + if let Some(state) = dirs.state_dir() { + return Ok(state.join("sessions")); + } + // macOS has no state directory, and its configuration and data directories are the same one. + // A sibling of it, not a folder inside it, keeps the sessions out of the configuration + // directory, which people copy, sync and back up. + let data = dirs.data_local_dir(); + let name = data.file_name().map_or_else( + || "com.Hodeitek.HodeiShield".into(), + std::ffi::OsStr::to_os_string, + ); + let mut sibling = name; + sibling.push(".sessions"); + Ok(data.parent().unwrap_or(data).join(sibling)) } /// The file name for a profile: the hex SHA-256 of its name, so no profile name can leave the @@ -161,126 +170,159 @@ mod unix { use super::{FileStore, Record, io_failure, refused}; use crate::auth::store::StoredToken; use crate::failure::{Failure, Result}; - use std::fs::{File, Metadata, OpenOptions}; + use rustix::fs::{AtFlags, Dir, FileType, Mode, OFlags, Stat}; + use rustix::io::Errno; + use std::fs::File; use std::io::{Read, Write}; - use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}; - use std::path::{Path, PathBuf}; + use std::os::fd::OwnedFd; + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + use std::path::Path; /// Biggest file read: a token payload is well under a kilobyte. const MAX_BYTES: u64 = 64 * 1024; + /// A temporary file this old is a leftover of a crashed write, not one being written. + const STALE_SECONDS: i64 = 60; + /// The effective user id of this process. fn euid() -> u32 { rustix::process::geteuid().as_raw() } - /// What is wrong with a file or directory, judged from its own metadata (never a followed - /// link): `None` when it can be trusted. - pub(super) fn problem(meta: &Metadata, want_dir: bool, euid: u32) -> Option<(String, String)> { - let kind = if want_dir { "directory" } else { "file" }; + fn io_error(errno: Errno) -> std::io::Error { + std::io::Error::from(errno) + } + + /// What is wrong with a file or directory, judged from its own `stat` (never a followed + /// link): `None` when it can be trusted. The second string says how to fix a mode. + pub(super) fn problem(stat: &Stat, want_dir: bool, euid: u32) -> Option<(String, String)> { + let kind = FileType::from_raw_mode(stat.st_mode); + let noun = if want_dir { "directory" } else { "file" }; let fix_mode = if want_dir { "700" } else { "600" }; - if meta.file_type().is_symlink() { + if kind == FileType::Symlink { return Some(( "is a symbolic link, which is never followed".into(), String::new(), )); } let right_type = if want_dir { - meta.is_dir() + kind == FileType::Directory } else { - meta.is_file() + kind == FileType::RegularFile }; if !right_type { - return Some((format!("is not a regular {kind}"), String::new())); + return Some((format!("is not a regular {noun}"), String::new())); } - if meta.uid() != euid { + if stat.st_uid != euid { return Some(( - format!("belongs to another user (uid {}), not to you", meta.uid()), + format!("belongs to another user (uid {}), not to you", stat.st_uid), String::new(), )); } - let mode = meta.mode() & 0o777; + let mode = Mode::from_raw_mode(stat.st_mode).bits() & 0o777; if mode & 0o077 != 0 { return Some(( - format!("is accessible to other users (mode {mode:03o}); it must be {fix_mode}"), format!( - "If you own it and nobody else read it, `chmod {fix_mode}` fixes it; a token that may have been read should be revoked from the app." + "is accessible to other users (mode {mode:03o}); group and others must have \ + no access" + ), + format!( + "If you own it and nobody else read it, `chmod {fix_mode}` fixes it; a token \ + that may have been read should be revoked from the app." ), )); } None } - fn check(path: &Path, meta: &Metadata, want_dir: bool) -> Result<()> { - match problem(meta, want_dir, euid()) { + fn check(path: &Path, stat: &Stat, want_dir: bool) -> Result<()> { + match problem(stat, want_dir, euid()) { None => Ok(()), Some((reason, fix)) => Err(refused(path, &reason, Some(fix).filter(|f| !f.is_empty()))), } } - /// Looks at a path without following a link at its end. `None` when it is absent. - fn look(path: &Path) -> Result> { - match std::fs::symlink_metadata(path) { - Ok(meta) => Ok(Some(meta)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(io_failure("Cannot look at", path, &e)), + /// The sessions directory as an open handle, checked with `fstat` on the handle itself. Every + /// later operation is relative to it, so replacing the path afterwards cannot redirect them. + /// `None` when absent and `create` is false; created with mode 0700 otherwise. + pub(super) fn open_dir(store: &FileStore, create: bool) -> Result> { + let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let mut created = false; + loop { + match rustix::fs::open(&store.dir, flags, Mode::empty()) { + Ok(fd) => { + let stat = rustix::fs::fstat(&fd) + .map_err(|e| io_failure("Cannot look at", &store.dir, &io_error(e)))?; + check(&store.dir, &stat, true)?; + return Ok(Some(fd)); + } + Err(Errno::NOENT) if create && !created => { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&store.dir) + .map_err(|e| io_failure("Cannot create", &store.dir, &e))?; + created = true; + } + Err(Errno::NOENT) => return Ok(None), + Err(e @ (Errno::LOOP | Errno::NOTDIR)) => { + // A link or something that is not a directory: say which. + let stat = + rustix::fs::statat(rustix::fs::CWD, &store.dir, AtFlags::SYMLINK_NOFOLLOW) + .map_err(|e| io_failure("Cannot look at", &store.dir, &io_error(e)))?; + check(&store.dir, &stat, true)?; + return Err(io_failure("Cannot open", &store.dir, &io_error(e))); + } + Err(e) => return Err(io_failure("Cannot open", &store.dir, &io_error(e))), + } } } - /// The sessions directory, checked. `None` when absent and `create` is false; created with - /// mode 0700 otherwise. - fn dir(store: &FileStore, create: bool) -> Result> { - if let Some(meta) = look(&store.dir)? { - check(&store.dir, &meta, true)?; - return Ok(Some(())); - } - if !create { - return Ok(None); + /// Looks at an entry of the directory without following a link. `None` when it is absent. + fn look(dir: &OwnedFd, name: &str, path: &Path) -> Result> { + match rustix::fs::statat(dir, name, AtFlags::SYMLINK_NOFOLLOW) { + Ok(stat) => Ok(Some(stat)), + Err(Errno::NOENT) => Ok(None), + Err(e) => Err(io_failure("Cannot look at", path, &io_error(e))), } - std::fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(&store.dir) - .map_err(|e| io_failure("Cannot create", &store.dir, &e))?; - // Whatever the umask or a concurrent creator did, it is judged like an existing one. - let meta = look(&store.dir)?.ok_or_else(|| { - Failure::general(format!( - "{} disappeared while it was created.", - store.dir.display() - )) - })?; - check(&store.dir, &meta, true)?; - Ok(Some(())) } /// Opens a file for reading and judges the opened handle, which must be the very file that - /// was looked at (a swap in between is refused). - fn open_checked(path: &Path) -> Result> { - let Some(seen) = look(path)? else { + /// was looked at (a swap in between is refused). A link, a FIFO or a device is never opened + /// for long: `O_NOFOLLOW` refuses the first and `O_NONBLOCK` keeps the others from blocking. + fn open_checked(dir: &OwnedFd, name: &str, path: &Path) -> Result> { + let Some(seen) = look(dir, name, path)? else { return Ok(None); }; check(path, &seen, false)?; - let file = match File::open(path) { - Ok(file) => file, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => return Err(io_failure("Cannot read", path, &e)), + let flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC; + let fd = match rustix::fs::openat(dir, name, flags, Mode::empty()) { + Ok(fd) => fd, + Err(Errno::NOENT) => return Ok(None), + Err(Errno::LOOP) => { + return Err(refused( + path, + "is a symbolic link, which is never followed", + None, + )); + } + Err(e) => return Err(io_failure("Cannot read", path, &io_error(e))), }; - let opened = file - .metadata() - .map_err(|e| io_failure("Cannot read", path, &e))?; - if (opened.dev(), opened.ino()) != (seen.dev(), seen.ino()) { + let opened = + rustix::fs::fstat(&fd).map_err(|e| io_failure("Cannot read", path, &io_error(e)))?; + if (opened.st_dev, opened.st_ino) != (seen.st_dev, seen.st_ino) { return Err(refused(path, "changed while it was being opened", None)); } check(path, &opened, false)?; - Ok(Some(file)) + Ok(Some(File::from(fd))) } pub(super) fn load(store: &FileStore, profile: &str) -> Result> { - if dir(store, false)?.is_none() { + let Some(dir) = open_dir(store, false)? else { return Ok(None); - } + }; let path = store.path_for(profile); - let Some(file) = open_checked(&path)? else { + let Some(file) = open_checked(&dir, &super::file_name(profile), &path)? else { return Ok(None); }; let mut text = String::new(); @@ -304,69 +346,120 @@ mod unix { Ok(Some(StoredToken::from_payload(record.payload))) } + /// The first 16 hex digits of the profile's hash: what its temporary files start with. + fn prefix(profile: &str) -> String { + super::file_name(profile)[..16].to_owned() + } + + fn temp_name(profile: &str) -> Result { + let mut random = [0u8; 8]; + getrandom::fill(&mut random) + .map_err(|e| Failure::general(format!("cannot get random bytes: {e}")))?; + let suffix: String = random.iter().map(|b| format!("{b:02x}")).collect(); + Ok(format!(".{}.{suffix}.tmp", prefix(profile))) + } + + /// Whether `name` is `..<16 hex digits>.tmp`, exactly what [`temp_name`] makes. + fn is_temp_of(name: &str, profile: &str) -> bool { + name.strip_prefix('.') + .and_then(|rest| rest.strip_prefix(prefix(profile).as_str())) + .and_then(|rest| rest.strip_prefix('.')) + .and_then(|rest| rest.strip_suffix(".tmp")) + .is_some_and(|random| { + random.len() == 16 + && random + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) + }) + } + + /// Removes the profile's temporary files left by a write that crashed between creating the + /// file and renaming it: they hold live tokens. Only regular files of this user that are old + /// enough not to be a write in progress by another process. Best effort. + fn remove_leftovers(dir: &OwnedFd, profile: &str) { + let Ok(entries) = Dir::read_from(dir) else { + return; + }; + let now = i64::try_from(crate::auth::now()).unwrap_or(i64::MAX); + let names: Vec = entries + .filter_map(|entry| entry.ok()) + .filter_map(|entry| entry.file_name().to_str().ok().map(str::to_owned)) + .filter(|name| is_temp_of(name, profile)) + .collect(); + for name in names { + let Ok(stat) = rustix::fs::statat(dir, name.as_str(), AtFlags::SYMLINK_NOFOLLOW) else { + continue; + }; + if FileType::from_raw_mode(stat.st_mode) == FileType::RegularFile + && stat.st_uid == euid() + && now.saturating_sub(stat.st_mtime) >= STALE_SECONDS + { + let _ = rustix::fs::unlinkat(dir, name.as_str(), AtFlags::empty()); + } + } + } + /// Removes the temporary file unless it was renamed into place. struct Temp<'a> { - path: &'a Path, + dir: &'a OwnedFd, + name: String, armed: bool, } impl Drop for Temp<'_> { fn drop(&mut self) { if self.armed { - let _ = std::fs::remove_file(self.path); + let _ = rustix::fs::unlinkat(self.dir, self.name.as_str(), AtFlags::empty()); } } } - fn temp_path(store: &FileStore, profile: &str) -> Result { - let mut random = [0u8; 8]; - getrandom::fill(&mut random) - .map_err(|e| Failure::general(format!("cannot get random bytes: {e}")))?; - let suffix: String = random.iter().map(|b| format!("{b:02x}")).collect(); - Ok(store.dir.join(format!( - ".{}.{suffix}.tmp", - &super::file_name(profile)[..16] - ))) - } - - /// Writes `bytes` to a new 0600 file next to `path` and renames it over `path`. - fn write_atomically(store: &FileStore, profile: &str, path: &Path, bytes: &[u8]) -> Result<()> { - let temp = temp_path(store, profile)?; - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&temp) - .map_err(|e| io_failure("Cannot create", &temp, &e))?; + fn create_new(dir: &OwnedFd, name: &str, path: &Path) -> Result { + let flags = + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC; + rustix::fs::openat(dir, name, flags, Mode::RUSR | Mode::WUSR) + .map(File::from) + .map_err(|e| io_failure("Cannot create", path, &io_error(e))) + } + + /// Writes `bytes` to a new 0600 file in the directory and renames it over `name`. + pub(super) fn write_atomically( + store: &FileStore, + dir: &OwnedFd, + profile: &str, + bytes: &[u8], + ) -> Result<()> { + let temp = temp_name(profile)?; + let temp_path = store.dir.join(&temp); + let mut file = create_new(dir, &temp, &temp_path)?; let mut guard = Temp { - path: &temp, + dir, + name: temp.clone(), armed: true, }; // The umask can only remove bits from 0600; say 0600 whatever it was. file.set_permissions(std::fs::Permissions::from_mode(0o600)) .and_then(|()| file.write_all(bytes)) .and_then(|()| file.sync_all()) - .map_err(|e| io_failure("Cannot write", &temp, &e))?; + .map_err(|e| io_failure("Cannot write", &temp_path, &e))?; drop(file); - std::fs::rename(&temp, path).map_err(|e| io_failure("Cannot replace", path, &e))?; + let target = store.path_for(profile); + rustix::fs::renameat(dir, temp.as_str(), dir, super::file_name(profile).as_str()) + .map_err(|e| io_failure("Cannot replace", &target, &io_error(e)))?; guard.armed = false; - sync_dir(&store.dir); + // Makes the rename durable. Best effort: not every file system allows it. + let _ = rustix::fs::fsync(dir); Ok(()) } - /// Makes a rename or removal durable. Best effort: not every file system allows it. - fn sync_dir(dir: &Path) { - if let Ok(handle) = File::open(dir) { - let _ = handle.sync_all(); - } - } - pub(super) fn save(store: &FileStore, profile: &str, token: &StoredToken) -> Result<()> { - dir(store, true)?; + let dir = open_dir(store, true)? + .ok_or_else(|| Failure::general(format!("Cannot create {}.", store.dir.display())))?; + remove_leftovers(&dir, profile); let path = store.path_for(profile); // An existing file is judged before it is replaced, even though it will not be read. - if let Some(meta) = look(&path)? { - check(&path, &meta, false)?; + if let Some(stat) = look(&dir, &super::file_name(profile), &path)? { + check(&path, &stat, false)?; } let record = Record { profile: profile.to_owned(), @@ -375,25 +468,27 @@ mod unix { let mut text = serde_json::to_string(&record) .map_err(|e| Failure::general(format!("cannot encode the sign-in: {e}")))?; text.push('\n'); - write_atomically(store, profile, &path, text.as_bytes()) + write_atomically(store, &dir, profile, text.as_bytes()) } pub(super) fn delete(store: &FileStore, profile: &str) -> Result { - if dir(store, false)?.is_none() { + let Some(dir) = open_dir(store, false)? else { return Ok(false); - } + }; + remove_leftovers(&dir, profile); + let name = super::file_name(profile); let path = store.path_for(profile); - let Some(meta) = look(&path)? else { + let Some(stat) = look(&dir, &name, &path)? else { return Ok(false); }; - check(&path, &meta, false)?; - match std::fs::remove_file(&path) { + check(&path, &stat, false)?; + match rustix::fs::unlinkat(&dir, name.as_str(), AtFlags::empty()) { Ok(()) => { - sync_dir(&store.dir); + let _ = rustix::fs::fsync(&dir); Ok(true) } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(e) => Err(io_failure("Cannot remove", &path, &e)), + Err(Errno::NOENT) => Ok(false), + Err(e) => Err(io_failure("Cannot remove", &path, &io_error(e))), } } @@ -401,25 +496,23 @@ mod unix { /// created if absent), the profile's file, if any, is trustworthy, and an empty file can be /// created and removed there. No token is written. pub(super) fn check_available(store: &FileStore, profile: &str) -> Result<()> { - dir(store, true)?; + let dir = open_dir(store, true)? + .ok_or_else(|| Failure::general(format!("Cannot create {}.", store.dir.display())))?; let path = store.path_for(profile); - if let Some(meta) = look(&path)? { - check(&path, &meta, false)?; + if let Some(stat) = look(&dir, &super::file_name(profile), &path)? { + check(&path, &stat, false)?; } - let probe = temp_path(store, profile)?; - OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&probe) - .map_err(|e| { - Failure::general(format!( - "the plain-text token store cannot be used: cannot write in {}: {e}", - store.dir.display() - )) - .hint("Use the system keychain or a tenant API key in HODEISHIELD_API_KEY.") - })?; - let _ = std::fs::remove_file(&probe); + let probe = temp_name(profile)?; + create_new(&dir, &probe, &store.dir.join(&probe)).map_err(|e| { + Failure::general(format!( + "{}: cannot write in {}: {}", + super::TOKEN_FILE_REFUSED, + store.dir.display(), + e.message + )) + .hint("Use the system keychain or a tenant API key in HODEISHIELD_API_KEY.") + })?; + let _ = rustix::fs::unlinkat(&dir, probe.as_str(), AtFlags::empty()); if let Some(fs) = super::network_file_system(&store.dir) { eprintln!( "Warning: {} is on {fs}. A file there can be read by the server's administrator \ @@ -599,10 +692,10 @@ mod tests { let (_tmp, store) = saved(); std::fs::set_permissions(&store.dir, std::fs::Permissions::from_mode(bits)) .expect("chmod"); - refused_with(store.load("p"), "must be 700"); - refused_with(store.save("p", &token()), "must be 700"); - refused_with(store.delete("p"), "must be 700"); - refused_with(store.check_available("p"), "must be 700"); + refused_with(store.load("p"), "no access"); + refused_with(store.save("p", &token()), "no access"); + refused_with(store.delete("p"), "no access"); + refused_with(store.check_available("p"), "no access"); assert_eq!(mode(&store.dir), bits); std::fs::set_permissions(&store.dir, std::fs::Permissions::from_mode(0o700)) .expect("chmod back"); @@ -614,16 +707,75 @@ mod tests { // Another user's files cannot be made in a test, so the judgement is exercised with the // effective uid replaced: the same file is then someone else's. let (_tmp, store) = saved(); - let meta = std::fs::symlink_metadata(store.path_for("p")).expect("meta"); - let me = meta.uid(); - assert!(unix::problem(&meta, false, me).is_none()); - let (reason, _) = unix::problem(&meta, false, me.wrapping_add(1)).expect("another owner"); + let stat = rustix::fs::lstat(store.path_for("p")).expect("stat"); + let me = stat.st_uid; + assert!(unix::problem(&stat, false, me).is_none()); + let (reason, _) = unix::problem(&stat, false, me.wrapping_add(1)).expect("another owner"); assert!(reason.contains("belongs to another user"), "{reason}"); - let dir_meta = std::fs::symlink_metadata(&store.dir).expect("meta"); - assert!(unix::problem(&dir_meta, true, me.wrapping_add(1)).is_some()); + let dir_stat = rustix::fs::lstat(&store.dir).expect("stat"); + assert!(unix::problem(&dir_stat, true, me.wrapping_add(1)).is_some()); assert_eq!(rustix::process::geteuid().as_raw(), me); } + fn tmp_files(store: &FileStore) -> Vec { + std::fs::read_dir(&store.dir) + .expect("dir") + .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned())) + .filter(|n| n.ends_with(".tmp")) + .collect() + } + + #[test] + fn a_failed_rename_removes_the_temporary_file() { + let (_tmp, store) = saved(); + // A non-empty directory where the file belongs makes the rename fail after the temporary + // file was written. + let target = store.path_for("q"); + std::fs::create_dir(&target).expect("blocker"); + std::fs::write(target.join("x"), "x").expect("fill"); + let dir = unix::open_dir(&store, false) + .expect("open") + .expect("exists"); + let err = unix::write_atomically(&store, &dir, "q", b"secret").expect_err("rename fails"); + assert!(err.message.starts_with("Cannot replace"), "{}", err.message); + assert!(tmp_files(&store).is_empty(), "{:?}", tmp_files(&store)); + } + + #[test] + fn leftover_temporary_files_of_the_profile_are_removed_when_stale() { + for deleting in [false, true] { + let (_tmp, store) = saved(); + let prefix = &file_name("p")[..16]; + let stale = store.dir.join(format!(".{prefix}.0123456789abcdef.tmp")); + let fresh = store.dir.join(format!(".{prefix}.fedcba9876543210.tmp")); + let other = store + .dir + .join(format!(".{}.0123456789abcdef.tmp", &file_name("q")[..16])); + let odd = store.dir.join(format!(".{prefix}.not-hex.tmp")); + for path in [&stale, &fresh, &other, &odd] { + std::fs::write(path, "token").expect("write"); + } + let old = std::time::SystemTime::now() - std::time::Duration::from_secs(600); + for path in [&stale, &other] { + std::fs::File::options() + .write(true) + .open(path) + .expect("open") + .set_modified(old) + .expect("age it"); + } + if deleting { + store.delete("p").expect("delete"); + } else { + store.save("p", &token()).expect("save"); + } + assert!(!stale.exists(), "a stale leftover of this profile goes"); + assert!(fresh.exists(), "a recent one may be a write in progress"); + assert!(other.exists(), "another profile's is not touched"); + assert!(odd.exists(), "only the exact pattern is matched"); + } + } + #[test] fn something_that_is_not_a_regular_file_is_refused() { let (_tmp, store) = saved(); @@ -706,13 +858,18 @@ mod tests { } #[test] - fn the_state_directory_is_not_the_configuration_directory() { - let dir = sessions_dir().expect("dir"); - assert!(dir.ends_with("sessions")); + fn the_sessions_directory_is_outside_the_configuration_directory() { + let sessions = sessions_dir().expect("dir"); + let config = crate::config::path().expect("config path"); + let config_dir = config.parent().expect("config dir"); assert!( - !dir.to_string_lossy().contains("/.config/"), - "{}", - dir.display() + sessions.ends_with("sessions") || sessions.to_string_lossy().ends_with(".sessions") + ); + assert!( + !sessions.starts_with(config_dir), + "{} is inside {}", + sessions.display(), + config_dir.display() ); } } diff --git a/crates/hodeishield-cli/src/auth/store.rs b/crates/hodeishield-cli/src/auth/store.rs index 362bc93..c712515 100644 --- a/crates/hodeishield-cli/src/auth/store.rs +++ b/crates/hodeishield-cli/src/auth/store.rs @@ -107,16 +107,27 @@ pub trait TokenStore { #[derive(Debug, Default)] pub struct Keychain; -/// The store the settings choose, looked up when it is used: the environment and the -/// configuration file are read at that point, so a command that never needs a token never fails -/// on them. A value that is not a store, or a platform that cannot have it, is an error at the -/// first use, and nothing falls back to the other store. +/// The store the settings choose. The choice is made at the first use and kept for the rest of +/// the process, so one command checks, reads and saves in the same store even if the environment +/// or the configuration file changed meanwhile. The environment and the configuration file are +/// read at that point, so a command that never needs a token never fails on them. A value that is +/// not a store, or a platform that cannot have it, is an error at the first use, and nothing +/// falls back to the other store. #[derive(Debug, Default)] -pub struct Configured; +pub struct Configured { + kind: OnceLock, +} impl Configured { fn chosen(&self) -> Result> { - Ok(match crate::config::token_store()? { + let kind = match self.kind.get() { + Some(kind) => *kind, + None => { + let chosen = crate::config::token_store()?; + *self.kind.get_or_init(|| chosen) + } + }; + Ok(match kind { crate::config::TokenStoreKind::Keychain => Box::new(Keychain), crate::config::TokenStoreKind::PlaintextFile => { Box::new(super::file_store::FileStore::platform()?) diff --git a/crates/hodeishield-cli/src/commands/mod.rs b/crates/hodeishield-cli/src/commands/mod.rs index 86e0af9..166057f 100644 --- a/crates/hodeishield-cli/src/commands/mod.rs +++ b/crates/hodeishield-cli/src/commands/mod.rs @@ -80,7 +80,7 @@ pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> { }, json: cli.json, verbose: cli.verbose, - store: Arc::new(auth::store::Configured), + store: Arc::new(auth::store::Configured::default()), }; match cli.command { Command::Vendors(command) => resources::vendors(&ctx, command, out), diff --git a/crates/hodeishield-cli/tests/cli.rs b/crates/hodeishield-cli/tests/cli.rs index 7b54192..5cc5e2e 100644 --- a/crates/hodeishield-cli/tests/cli.rs +++ b/crates/hodeishield-cli/tests/cli.rs @@ -1025,6 +1025,7 @@ mod file_store { /// asked for anything (exit 1), commands that need a credential are "not authenticated" /// (exit 3), and nothing is repaired or deleted. fn assert_refused(env: &Env, app: &App, reason: &str) { + let before = snapshot(env); let mut login_api = mockito::Server::new(); let mut login_app = mockito::Server::new(); let calls: Vec<_> = [&mut login_api, &mut login_app] @@ -1069,6 +1070,18 @@ mod file_store { .code(1) .stderr(predicate::str::contains(reason)); assert!(app.revoked.lock().expect("lock").is_empty()); + assert_eq!(snapshot(env), before, "a refused file is never touched"); + } + + /// Every file in the sessions directory with its bytes. + fn snapshot(env: &Env) -> Vec<(PathBuf, Vec)> { + kept(env) + .into_iter() + .map(|path| { + let bytes = std::fs::read(&path).expect("read"); + (path, bytes) + }) + .collect() } #[test] @@ -1213,6 +1226,27 @@ mod file_store { // be an error. signed_in(&env, &app); } + + /// The other direction of "no fallback": a valid session file is not used by the default store. + #[cfg(not(target_os = "macos"))] + #[test] + fn a_session_file_is_not_used_when_the_keychain_is_selected() { + let env = Env::new(); + let app = app(); + let file = signed_in(&env, &app); + let before = std::fs::read(&file).expect("read"); + app.with(env.cmd()) + .arg("whoami") + .assert() + .code(3) + .stdout(predicate::str::is_empty()) + .stderr(predicate::str::contains("keychain is not available")); + app.with(env.cmd()) + .args(["vendors", "list"]) + .assert() + .code(3); + assert_eq!(std::fs::read(&file).expect("read"), before); + } } /// Windows has no mode bits to check, so the opt-in is refused there, before the app is asked for From 0c33d49b257b4e36dca620e40aa16a2c004de513 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:13:06 +0200 Subject: [PATCH 06/25] Point the macOS test at the sessions directory outside the configuration The sessions moved to a sibling of the configuration directory on macOS; the integration tests still looked inside it, so on macOS they found no file. Refs #39. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- crates/hodeishield-cli/tests/cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/hodeishield-cli/tests/cli.rs b/crates/hodeishield-cli/tests/cli.rs index 5cc5e2e..265194a 100644 --- a/crates/hodeishield-cli/tests/cli.rs +++ b/crates/hodeishield-cli/tests/cli.rs @@ -68,7 +68,7 @@ impl Env { if cfg!(target_os = "macos") { self._dir .path() - .join("home/Library/Application Support/com.Hodeitek.HodeiShield/sessions") + .join("home/Library/Application Support/com.Hodeitek.HodeiShield.sessions") } else { self.state().join("hodeishield/sessions") } From 868c0a4b7a6c851e947d21f67371dc156b489151 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:13:54 +0200 Subject: [PATCH 07/25] State how long a copied token file stays useful The service's token lifetimes are now known: access tokens last 15 minutes, refresh tokens 30 days from the last refresh with reuse detection, and an absolute 90-day limit is being added. The README and the decision record state today's exposure of a leaked file and how to end it, instead of an open question. Refs #39. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- README.md | 12 +++++++-- .../0001-sign-in-without-a-keychain.md | 25 ++++++++++++++----- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 21b4040..f9df8cc 100644 --- a/README.md +++ b/README.md @@ -335,8 +335,16 @@ a code. You have two options, in this order of preference: 2. **A plain-text token file, if you want to sign in as yourself** on a machine you control, such as a jump host. You opt in explicitly, and the trade-off is that the tokens are stored **unencrypted** in a file that only your user can read. Anyone who can read your files, such as - `root`, a backup, a snapshot or a copy of your home directory, can use the refresh token until it - is revoked. + `root`, a backup, a snapshot or a copy of your home directory, can use the refresh token: + - **How long.** Today, up to 30 days after the CLI last refreshed the session (every refresh + starts a new 30 days), unless you end it earlier with `hodeishield logout` or by revoking the + access in the app under **Account › Applications**. The service is adding an absolute limit of + 90 days from the sign-in, and will also end these sessions when you change your password; this + section will say so once that is in place. + - **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 + the app and check who could read the file. ```sh export HODEISHIELD_TOKEN_STORE=plaintext-file # or: hodeishield config set token_store plaintext-file diff --git a/docs/decisions/0001-sign-in-without-a-keychain.md b/docs/decisions/0001-sign-in-without-a-keychain.md index 4691042..1175fae 100644 --- a/docs/decisions/0001-sign-in-without-a-keychain.md +++ b/docs/decisions/0001-sign-in-without-a-keychain.md @@ -146,9 +146,22 @@ the following rules. Containers and CI keep using API keys. - **Migration between stores.** Deliberately absent; switching is sign out, change the setting, sign in. -## Open question - -To be confirmed with the API provider: the refresh token's lifetime, rotation and idle expiry on the -server side. They decide how long a copy of a token file stays useful, and whether the exposure of a -leaked file can be bounded by something other than revocation. Nothing in this decision depends on the -answer, but the README's wording about exposure should be revisited once it is known. +## Server-side lifetimes + +How long a copy of a token file stays useful is set by the service, not by the CLI: + +- The access token lives 15 minutes and is checked by the service on every use. +- The refresh token lives 30 days and is replaced on every refresh, which starts a new 30 days, so + today a session has no absolute limit while it keeps being refreshed. There is no idle expiry + beyond those 30 days. +- Replaying a refresh token that was already replaced ends the whole session. If an attacker + refreshes with a copied file, the legitimate CLI's next refresh ends both. +- A session ends with `hodeishield logout` (token revocation), with a revocation in the app under + Account › Applications, and when the account or the organisation is suspended. Today it does not + end when the password is changed. + +The service is adding an absolute limit of 90 days from the original sign-in and will end these +sessions when the password is changed or an administrator revokes them. From then on, the exposure of +a leaked file is at most 90 days, ended earlier by `hodeishield logout`, a password change or a +revocation under Account › Applications. Until then it is up to 30 days after the last refresh. The +README states the current figure and is updated when the limit is in place. From 82ad624bd1bb37ad1a394442252597ed7697cb81 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:16:41 +0200 Subject: [PATCH 08/25] Build the symlinked-directory test next to the sessions directory The test moved the sessions directory into the state directory, which does not exist on macOS (the sessions directory sits under Application Support there), so the move failed before the case under test ran. Refs #39. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- crates/hodeishield-cli/tests/cli.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/hodeishield-cli/tests/cli.rs b/crates/hodeishield-cli/tests/cli.rs index 265194a..8334ef6 100644 --- a/crates/hodeishield-cli/tests/cli.rs +++ b/crates/hodeishield-cli/tests/cli.rs @@ -1125,7 +1125,9 @@ mod file_store { let env = Env::new(); let app = app(); signed_in(&env, &app); - let moved = env.state().join("moved"); + // Next to the sessions directory, which exists on every platform (the state directory is + // not used on macOS). + let moved = env.sessions().parent().expect("parent").join("moved"); std::fs::rename(env.sessions(), &moved).expect("move"); symlink(&moved, env.sessions()).expect("link"); assert_refused(&env, &app, "symbolic link"); From 7edc12a26ff6b7932f2273933b9e3b7cb2279845 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:20:34 +0200 Subject: [PATCH 09/25] Compile the network file system table on Unix only Its only users are the Linux check and a Unix-only test, so on a Windows test build it was dead code and clippy refused it. Refs #39. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- crates/hodeishield-cli/src/auth/file_store.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/hodeishield-cli/src/auth/file_store.rs b/crates/hodeishield-cli/src/auth/file_store.rs index de84fd2..3bb3cb9 100644 --- a/crates/hodeishield-cli/src/auth/file_store.rs +++ b/crates/hodeishield-cli/src/auth/file_store.rs @@ -539,7 +539,7 @@ fn network_file_system(_: &Path) -> Option<&'static str> { } /// Linux `statfs` `f_type` values of file systems served by another machine. -#[cfg(any(test, all(unix, target_os = "linux")))] +#[cfg(all(unix, any(test, target_os = "linux")))] fn network_file_system_for(f_type: u64) -> Option<&'static str> { match f_type { 0x6969 => Some("NFS"), From 305520d09eab0fce92bd5d92a0fe81b561b9e6b3 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:20:21 +0200 Subject: [PATCH 10/25] State the 90-day session limit now that the service enforces it The service now caps a refresh chain at 90 days from the original sign-in and ends CLI sessions on a password change or reset, an administrator revocation or removal from the tenant. The README and ADR 0001 give these figures instead of the earlier "is adding" wording. The changelog entry moves from the released 0.4.0 section, where the merge of dev left it, to Unreleased. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- CHANGELOG.md | 30 +++++++++++-------- README.md | 9 +++--- .../0001-sign-in-without-a-keychain.md | 21 +++++++------ 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4060c..ff470ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ commands, `--json` output and exit codes. Each release's notes start with its se ## [Unreleased] +### Added + +- An opt-in plain-text token store, for machines that have no system keychain (an SSH or jump host, + WSL without a desktop). Set `HODEISHIELD_TOKEN_STORE=plaintext-file`, or the global setting + `hodeishield config set token_store plaintext-file`, and `login --device` keeps the session in a + file only your user can read, one per profile, in the platform's state directory + (`$XDG_STATE_HOME/hodeishield/sessions/` on Linux). The keychain stays the default, nothing ever + falls back from one store to the other, and the file store is not available on Windows yet. Every + read and write refuses a file or directory that is a symbolic link, belongs to another user or is + open to group or others (exit code 3 for commands that need a credential, 1 for `login` and + `logout`); files are replaced atomically with mode 600. `logout` revokes the tokens + and deletes the file. The README has a new section, "Servers, containers and remote sessions", + with the trade-off and the advice for containers and CI (a tenant API key); ADR 0001 records the + decision. A copy of the file stays usable at most 90 days from the sign-in, the limit the + service sets. A configuration file that contains `token_store` is refused by older CLIs. + ([#39](https://github.com/Hodeitek/hodeishield-cli/issues/39)) + ## [0.4.4] - 2026-10-15 ### Security @@ -106,19 +123,6 @@ commands, `--json` output and exit codes. Each release's notes start with its se ### Added -- An opt-in plain-text token store, for machines that have no system keychain (an SSH or jump host, - WSL without a desktop). Set `HODEISHIELD_TOKEN_STORE=plaintext-file`, or the global setting - `hodeishield config set token_store plaintext-file`, and `login --device` keeps the session in a - file only your user can read, one per profile, in the platform's state directory - (`$XDG_STATE_HOME/hodeishield/sessions/` on Linux). The keychain stays the default, nothing ever - falls back from one store to the other, and the file store is not available on Windows yet. Every - read and write refuses a file or directory that is a symbolic link, belongs to another user or is - open to group or others (exit code 3 for commands that need a credential, 1 for `login` and - `logout`); files are replaced atomically with mode 600. `logout` revokes the tokens - and deletes the file. The README has a new section, "Servers, containers and remote sessions", - with the trade-off and the advice for containers and CI (a tenant API key); ADR 0001 records the - decision. A configuration file that contains `token_store` is refused by older CLIs. - ([#39](https://github.com/Hodeitek/hodeishield-cli/issues/39)) - Each release now includes a Windows on Arm build, `hodeishield--aarch64-pc-windows-msvc.zip`. It is built natively on a Windows on Arm runner, its `hodeishield.exe` is signed with Authenticode like the x64 one and checked to need no Visual C++ runtime, and the archive is listed in diff --git a/README.md b/README.md index eeba13f..ca4e44a 100644 --- a/README.md +++ b/README.md @@ -350,11 +350,10 @@ a code. You have two options, in this order of preference: a jump host. You opt in explicitly, and the trade-off is that the tokens are stored **unencrypted** in a file that only your user can read. Anyone who can read your files, such as `root`, a backup, a snapshot or a copy of your home directory, can use the refresh token: - - **How long.** Today, up to 30 days after the CLI last refreshed the session (every refresh - starts a new 30 days), unless you end it earlier with `hodeishield logout` or by revoking the - access in the app under **Account › Applications**. The service is adding an absolute limit of - 90 days from the sign-in, and will also end these sessions when you change your password; this - section will say so once that is in place. + - **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**. - **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 diff --git a/docs/decisions/0001-sign-in-without-a-keychain.md b/docs/decisions/0001-sign-in-without-a-keychain.md index 1175fae..6de0252 100644 --- a/docs/decisions/0001-sign-in-without-a-keychain.md +++ b/docs/decisions/0001-sign-in-without-a-keychain.md @@ -151,17 +151,16 @@ the following rules. Containers and CI keep using API keys. How long a copy of a token file stays useful is set by the service, not by the CLI: - The access token lives 15 minutes and is checked by the service on every use. -- The refresh token lives 30 days and is replaced on every refresh, which starts a new 30 days, so - today a session has no absolute limit while it keeps being refreshed. There is no idle expiry - beyond those 30 days. +- The refresh token lives 30 days and is replaced on every refresh, which starts a new 30 days, up + to an absolute limit of 90 days from the original sign-in that refreshing never extends. There is + no idle expiry beyond those 30 days. - Replaying a refresh token that was already replaced ends the whole session. If an attacker refreshes with a copied file, the legitimate CLI's next refresh ends both. - A session ends with `hodeishield logout` (token revocation), with a revocation in the app under - Account › Applications, and when the account or the organisation is suspended. Today it does not - end when the password is changed. - -The service is adding an absolute limit of 90 days from the original sign-in and will end these -sessions when the password is changed or an administrator revokes them. From then on, the exposure of -a leaked file is at most 90 days, ended earlier by `hodeishield logout`, a password change or a -revocation under Account › Applications. Until then it is up to 30 days after the last refresh. The -README states the current figure and is updated when the limit is in place. + Account › Applications, when the password is changed or reset, when an administrator revokes it, + when the user is removed from the tenant, and when the account or the organisation is suspended. + +So the exposure of a leaked file is at most 90 days from the sign-in and at most 30 days after the +last refresh, ended earlier by any of the events above. Past the limit, the service refuses the +refresh (`invalid_grant`) and the CLI reports the profile as not signed in until `hodeishield login` +is run again. The README states the same figures. From 956b8c334d9d5578e9750641cb3585c7a29f0d18 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:27:59 +0200 Subject: [PATCH 11/25] Do not print the uid when the owner test fails CodeQL (rust/cleartext-logging) reads the assert_eq! on the effective uid as writing it to a log. A uid is not secret, but the comparison needs no values in its message either. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- crates/hodeishield-cli/src/auth/file_store.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/hodeishield-cli/src/auth/file_store.rs b/crates/hodeishield-cli/src/auth/file_store.rs index 3bb3cb9..088ed58 100644 --- a/crates/hodeishield-cli/src/auth/file_store.rs +++ b/crates/hodeishield-cli/src/auth/file_store.rs @@ -714,7 +714,10 @@ mod tests { assert!(reason.contains("belongs to another user"), "{reason}"); let dir_stat = rustix::fs::lstat(&store.dir).expect("stat"); assert!(unix::problem(&dir_stat, true, me.wrapping_add(1)).is_some()); - assert_eq!(rustix::process::geteuid().as_raw(), me); + assert!( + rustix::process::geteuid().as_raw() == me, + "the file belongs to the effective user" + ); } fn tmp_files(store: &FileStore) -> Vec { From 310385fd358965f359a1746f3aede8fd7a228eb2 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:36:20 +0200 Subject: [PATCH 12/25] Do not print the owner's uid when the owner test fails CodeQL (rust/cleartext-logging) flags the assertion that prints the refusal reason, which names the file owner's uid. The assertion now prints nothing; the effective-uid comparison goes back to assert_eq!, which was not the flagged line. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- crates/hodeishield-cli/src/auth/file_store.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/hodeishield-cli/src/auth/file_store.rs b/crates/hodeishield-cli/src/auth/file_store.rs index 088ed58..1262784 100644 --- a/crates/hodeishield-cli/src/auth/file_store.rs +++ b/crates/hodeishield-cli/src/auth/file_store.rs @@ -711,13 +711,11 @@ mod tests { let me = stat.st_uid; assert!(unix::problem(&stat, false, me).is_none()); let (reason, _) = unix::problem(&stat, false, me.wrapping_add(1)).expect("another owner"); - assert!(reason.contains("belongs to another user"), "{reason}"); + // The reason names the owner's uid: the assertion does not print it. + assert!(reason.contains("belongs to another user")); let dir_stat = rustix::fs::lstat(&store.dir).expect("stat"); assert!(unix::problem(&dir_stat, true, me.wrapping_add(1)).is_some()); - assert!( - rustix::process::geteuid().as_raw() == me, - "the file belongs to the effective user" - ); + assert_eq!(rustix::process::geteuid().as_raw(), me); } fn tmp_files(store: &FileStore) -> Vec { From bd383d98b2eb2c9bf98a54b05aca89512bf77acd Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:37:01 +0200 Subject: [PATCH 13/25] Follow version 2026-10-19 of the API document The vendored document adds GET /v1/me, the reports operations and the known values of the risks domain and status and the compliance controls status filters. The generator now accepts an operation that needs no scope (x-hs-scope none) and an answer that is a file: such an operation returns a Download with the bytes, the content type and the file name, read up to the document's 20 MiB limit, and sends an Accept header with the declared media types. Two shapes that share a name are renamed (WhoAmIOrganization, ReportDetail). The filter tests now cover the warning for each newly listed filter; the vendors category filter stays free text. Sign-in asks for reports:read when the app offers it. Closes #145 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- CHANGELOG.md | 11 + crates/hodeishield-api/src/client.rs | 114 ++++- crates/hodeishield-api/src/generated.rs | 511 ++++++++++++++++++++++- crates/hodeishield-api/src/lib.rs | 5 +- crates/hodeishield-api/tests/api.rs | 63 ++- crates/hodeishield-cli/src/auth/oauth.rs | 1 + crates/hodeishield-cli/tests/cli.rs | 100 ++++- openapi/README.md | 6 +- openapi/v1.json | 2 +- xtask/src/main.rs | 127 +++++- 10 files changed, 866 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b39d7f..61c058f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ commands, `--json` output and exit codes. Each release's notes start with its se ## [Unreleased] +### Changed + +- `risks list --domain` and `--status` and `compliance controls --status` now list the values the + API publishes for them in `--help`. A value that is not listed gets a warning on standard error + (with a suggestion when only the case differs) and is still sent exactly as typed. The vendors + `--category` filter stays free text: the API keeps no list of categories. + ([#145](https://github.com/Hodeitek/hodeishield-cli/issues/145)) +- The generated `/v1` client follows version 2026-10-19 of the API document, which adds `GET /v1/me` + and the reports operations, including the download of a report file. `hodeishield login` now also + asks for `reports:read` when the app offers it. + ## [0.4.4] - 2026-10-15 ### Security diff --git a/crates/hodeishield-api/src/client.rs b/crates/hodeishield-api/src/client.rs index 6f4612e..dcf8878 100644 --- a/crates/hodeishield-api/src/client.rs +++ b/crates/hodeishield-api/src/client.rs @@ -3,7 +3,9 @@ use crate::error::Error; use reqwest::StatusCode; -use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue}; +use reqwest::header::{ + ACCEPT, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderMap, HeaderValue, +}; use secrecy::{ExposeSecret, SecretString}; use serde::de::DeserializeOwned; use std::collections::hash_map::RandomState; @@ -87,6 +89,21 @@ pub struct ApiResponse { pub meta: ResponseMeta, } +/// A successful answer of an operation whose body is a file (`format: binary`), such as a report +/// download. +#[derive(Debug, Clone)] +pub struct Download { + /// The file, exactly as received. + pub bytes: Vec, + /// `Content-Type`, as sent. + pub content_type: Option, + /// The `filename` of `Content-Disposition`, as sent. It comes from the server: a caller that + /// writes the file must reduce it to a plain file name of its own choosing. + pub file_name: Option, + /// Status, request id and rate-limit headers. + pub meta: ResponseMeta, +} + /// What an answer's status line and headers said. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ResponseMeta { @@ -444,16 +461,67 @@ impl Client { std::thread::sleep(wait); } - /// A GET. The operation decides whether the client's tenant selection applies: scope headers - /// are sent and checked only when `operation.tenant_scoped`. + /// A GET whose answer is JSON, decoded into `T`. pub(crate) fn get( &self, operation: &'static Operation, path: String, query: Vec<(&'static str, String)>, ) -> Result, Error> { + let (meta, _, body) = self.fetch(operation, &path, &query, None, MAX_RESPONSE_BYTES)?; + let raw: serde_json::Value = + serde_json::from_slice(&body).map_err(|source| Error::Decode { + operation: operation.id, + source, + })?; + let data = T::deserialize(&raw).map_err(|source| Error::Decode { + operation: operation.id, + source, + })?; + Ok(ApiResponse { data, raw, meta }) + } + + /// A GET whose answer is a file. `accept` lists the media types the document declares (an + /// error still comes back as JSON). + pub(crate) fn get_binary( + &self, + operation: &'static Operation, + path: String, + query: Vec<(&'static str, String)>, + accept: &'static str, + ) -> Result { + let (meta, headers, bytes) = + self.fetch(operation, &path, &query, Some(accept), MAX_DOWNLOAD_BYTES)?; + let text = |name| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + Ok(Download { + bytes, + content_type: text(CONTENT_TYPE), + file_name: text(CONTENT_DISPOSITION) + .as_deref() + .and_then(content_disposition_file_name), + meta, + }) + } + + /// Sends a GET, with the retries, the credential renewal and the tenant check, and returns a + /// successful answer's metadata, headers and body (at most `limit` bytes). The operation decides + /// whether the client's tenant selection applies: scope headers are sent and checked only when + /// `operation.tenant_scoped`. + fn fetch( + &self, + operation: &'static Operation, + path: &str, + query: &[(&'static str, String)], + accept: Option<&'static str>, + limit: u64, + ) -> Result<(ResponseMeta, HeaderMap, Vec), Error> { let scoped = operation.tenant_scoped; - let url = self.url(&path, &query); + let url = self.url(path, query); let mut rate_limit_retries = 0; let mut transient_retries = 0; let mut renewed = false; @@ -466,6 +534,9 @@ impl Client { if scoped { request = request.headers(self.scope.headers.clone()); } + if let Some(accept) = accept { + request = request.header(ACCEPT, accept); + } let response = match request.send() { Ok(response) => response, Err(e) @@ -500,19 +571,11 @@ impl Client { (Some(check), true, true) => check(response.headers()), _ => Ok(()), }; - let body = read_body(response, MAX_RESPONSE_BYTES).map_err(Error::Body)?; + let headers = response.headers().clone(); + let body = read_body(response, limit).map_err(Error::Body)?; if status.is_success() { verdict?; - let raw: serde_json::Value = - serde_json::from_slice(&body).map_err(|source| Error::Decode { - operation: operation.id, - source, - })?; - let data = T::deserialize(&raw).map_err(|source| Error::Decode { - operation: operation.id, - source, - })?; - return Ok(ApiResponse { data, raw, meta }); + return Ok((meta, headers, body)); } if status == StatusCode::TOO_MANY_REQUESTS @@ -589,6 +652,27 @@ fn backoff(base: Duration, retry: u32) -> Duration { /// memory. pub const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024; +/// Largest file this client downloads: the limit the document sets for a report file (20 MiB; a +/// larger one is refused by the server with `FILE_TOO_LARGE`). +pub const MAX_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024; + +/// The `filename` parameter of a `Content-Disposition` value (`attachment; filename="a.pdf"`), +/// quoted or not, or `None` when there is none or it is empty. +fn content_disposition_file_name(value: &str) -> Option { + value.split(';').skip(1).find_map(|part| { + let (key, raw) = part.split_once('=')?; + if !key.trim().eq_ignore_ascii_case("filename") { + return None; + } + let raw = raw.trim(); + let name = raw + .strip_prefix('"') + .and_then(|r| r.strip_suffix('"')) + .unwrap_or(raw); + (!name.is_empty()).then(|| name.to_owned()) + }) +} + /// Reads the body of `response`, failing once it is larger than `limit` bytes instead of buffering /// the rest. /// diff --git a/crates/hodeishield-api/src/generated.rs b/crates/hodeishield-api/src/generated.rs index db4a5c5..8a4f456 100644 --- a/crates/hodeishield-api/src/generated.rs +++ b/crates/hodeishield-api/src/generated.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; /// `info.version` of the OpenAPI document this client was generated from. -pub const OPENAPI_VERSION: &str = "2026-10-16"; +pub const OPENAPI_VERSION: &str = "2026-10-19"; /// The server the OpenAPI document declares. pub const DEFAULT_SERVER: &str = "https://api.hodeishield.com"; @@ -22,7 +22,7 @@ pub const DEFAULT_SERVER: &str = "https://api.hodeishield.com"; pub struct ErrorBody { /// Closed vocabulary; new members may be added (see Versioning). /// - /// Documented values: `invalid_request`, `unauthorized`, `forbidden`, `not_found`, `method_not_allowed`, `rate_limited`, `internal_error`, `service_unavailable`. Treat any other value as valid: the vocabulary may grow. + /// Documented values: `invalid_request`, `unauthorized`, `forbidden`, `not_found`, `method_not_allowed`, `conflict`, `rate_limited`, `internal_error`, `service_unavailable`. Treat any other value as valid: the vocabulary may grow. pub code: String, /// For a human reading a log. Not stable; do not parse it. pub message: String, @@ -51,10 +51,10 @@ pub struct Vendor { /// Primary domain, lowercase, no scheme. #[serde(default)] pub domain: Option, - /// Free-text category assigned by the tenant. + /// Free-text category assigned by the tenant. NOT a closed vocabulary: the console form stores the label of the tenant’s language, and the API accepts any text. List the vendors to see the values a tenant uses. #[serde(default)] pub category: Option, - /// Kind of third party (provider, product, …). + /// Kind of third party: software | cloud | hardware | service_provider. Open vocabulary: a new kind is a compatible change. #[serde(default)] pub vendor_type: Option, /// Level of access the vendor has to the tenant systems. @@ -228,7 +228,7 @@ pub struct ComplianceControl { pub framework: String, /// Control identifier inside the framework. pub control_code: String, - /// missing | partial | covered, as evaluated by the platform. + /// covered | partial | missing, as evaluated by the platform. pub status: String, /// Evidence items currently linked to the control. pub evidence_count: i64, @@ -369,6 +369,116 @@ pub struct ListPortfolioTenantsResponse { pub pagination: Pagination, } +/// The credential that made this call. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Credential { + /// Open vocabulary: `tenant` (a tenant API key, `hsk_…`), `oauth` (a user access token, `hs_at_…`) or `portfolio` (an MSSP portfolio key, `hsp_…`). + pub r#type: String, + /// The clear key prefix (`hsp_…`) of a portfolio key. Null for a tenant key or an OAuth token. Never the full key and never a hash. + #[serde(default)] + pub prefix: Option, + /// The scopes the credential carries. + pub scopes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Tenant { + /// The tenant this credential reads (lowercase). + pub id: String, + /// Display name. Untrusted text. + pub name: String, + /// The type of the organisation that owns the tenant. Open vocabulary: `direct` (a customer organisation), `mssp` (a managed security service provider) or `root` (the platform operator). Treat an unknown value as opaque. + pub r#type: String, +} + +/// The organisation of the tenant (tenant key, OAuth token) or the MSSP the key belongs to (portfolio key). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WhoAmIOrganization { + pub id: String, + /// Display name. Null for a portfolio key whose client subset holds none of the provider’s own tenants, the only place a portfolio key sees the name. Untrusted text. + #[serde(default)] + pub name: Option, + /// `direct`, `mssp` or `root`; `mssp` for a portfolio key. + pub r#type: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Portfolio { + /// How many tenants this key can select, right now. + pub tenant_count: i64, + /// Where the tenants are listed: `GET /v1/portfolio/tenants`. + pub tenants_path: String, +} + +/// The credential that made the call, and what it may see about itself. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WhoAmI { + /// The credential that made this call. + pub credential: Credential, + /// The credential’s own tenant. Null for a portfolio key, which has no tenant. + #[serde(default)] + pub tenant: Option, + /// The organisation of the tenant (tenant key, OAuth token) or the MSSP the key belongs to (portfolio key). + pub organization: WhoAmIOrganization, + /// Portfolio keys only. Null for a tenant key or an OAuth token. + #[serde(default)] + pub portfolio: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WhoAmIResponse { + /// The credential that made the call, and what it may see about itself. + pub data: WhoAmI, +} + +/// A report the platform generated for the tenant (metadata only). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Report { + pub id: String, + /// What kind of report this is. Known values: monthly, vendor_risk, nis2_compliance, ens, cyberbrief, compliance, security_posture, board_report. Open vocabulary: a new kind may appear. + pub report_type: String, + pub title: String, + /// First day the report covers, `YYYY-MM-DD`. + pub period_start: String, + /// Last day the report covers, `YYYY-MM-DD`. + pub period_end: String, + /// Lifecycle: generating | completed | failed. Only a `completed` report can be downloaded. + pub status: String, + /// RFC 3339, UTC. + pub created_at: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ListReportsResponse { + pub data: Vec, + pub pagination: Pagination, +} + +/// One report, with the formats it can be downloaded in. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ReportDetail { + pub id: String, + /// What kind of report this is. Known values: monthly, vendor_risk, nis2_compliance, ens, cyberbrief, compliance, security_posture, board_report. Open vocabulary: a new kind may appear. + pub report_type: String, + pub title: String, + /// First day the report covers, `YYYY-MM-DD`. + pub period_start: String, + /// Last day the report covers, `YYYY-MM-DD`. + pub period_end: String, + /// Lifecycle: generating | completed | failed. Only a `completed` report can be downloaded. + pub status: String, + /// RFC 3339, UTC. + pub created_at: String, + /// The formats `downloadReport` can serve for this report now. Empty while it is not `completed`, or when its content has no renderer (download it from the console as Markdown or JSON). + pub download_formats: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GetReportResponse { + /// One report, with the formats it can be downloaded in. + pub data: ReportDetail, +} + /// Accepted values of a query parameter. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum VendorSort { @@ -849,6 +959,145 @@ impl std::str::FromStr for PortfolioTenantSort { } } +/// Accepted values of a query parameter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ReportSort { + /// `created_at` + CreatedAt, + /// `period_end` + PeriodEnd, +} + +impl ReportSort { + /// Every accepted value, as sent on the wire. + pub const VALUES: &'static [&'static str] = &["created_at", "period_end"]; + + /// The value as sent on the wire. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::CreatedAt => "created_at", + Self::PeriodEnd => "period_end", + } + } +} + +impl std::fmt::Display for ReportSort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for ReportSort { + type Err = crate::ParseEnumError; + + fn from_str(value: &str) -> Result { + match value { + "created_at" => Ok(Self::CreatedAt), + "period_end" => Ok(Self::PeriodEnd), + _ => Err(crate::ParseEnumError::new(value, Self::VALUES)), + } + } +} + +/// Accepted values of a query parameter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Format { + /// `pdf` + Pdf, + /// `xlsx` + Xlsx, +} + +impl Format { + /// Every accepted value, as sent on the wire. + pub const VALUES: &'static [&'static str] = &["pdf", "xlsx"]; + + /// The value as sent on the wire. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Pdf => "pdf", + Self::Xlsx => "xlsx", + } + } +} + +impl std::fmt::Display for Format { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for Format { + type Err = crate::ParseEnumError; + + fn from_str(value: &str) -> Result { + match value { + "pdf" => Ok(Self::Pdf), + "xlsx" => Ok(Self::Xlsx), + _ => Err(crate::ParseEnumError::new(value, Self::VALUES)), + } + } +} + +/// Accepted values of a query parameter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Locale { + /// `es` + Es, + /// `en` + En, + /// `de` + De, + /// `fr` + Fr, + /// `it` + It, + /// `eu` + Eu, +} + +impl Locale { + /// Every accepted value, as sent on the wire. + pub const VALUES: &'static [&'static str] = &["es", "en", "de", "fr", "it", "eu"]; + + /// The value as sent on the wire. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Es => "es", + Self::En => "en", + Self::De => "de", + Self::Fr => "fr", + Self::It => "it", + Self::Eu => "eu", + } + } +} + +impl std::fmt::Display for Locale { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for Locale { + type Err = crate::ParseEnumError; + + fn from_str(value: &str) -> Result { + match value { + "es" => Ok(Self::Es), + "en" => Ok(Self::En), + "de" => Ok(Self::De), + "fr" => Ok(Self::Fr), + "it" => Ok(Self::It), + "eu" => Ok(Self::Eu), + _ => Err(crate::ParseEnumError::new(value, Self::VALUES)), + } + } +} + /// Query parameters of `listVendors`. #[derive(Debug, Clone, Default, PartialEq)] pub struct ListVendorsParams { @@ -862,7 +1111,7 @@ pub struct ListVendorsParams { pub order: Option, /// Case-insensitive substring of the name or the domain. pub q: Option, - /// Exact category match. + /// Exact category match, as the tenant wrote it (case-sensitive). Category is free text with NO closed vocabulary, so there is no list of values to publish: read `category` from `GET /v1/vendors`. To filter by the kind of third party, read `vendor_type` (`x-hs-known-values`). pub category: Option, /// Filter by the operational flag. pub active: Option, @@ -968,9 +1217,9 @@ pub struct ListRisksParams { pub sort: Option, /// Sort direction. Defaults to desc. pub order: Option, - /// Exact status match. + /// Exact status match. Known values: identified, assessed, treatment_planned, treatment_implemented, closed. Open vocabulary: any other value is matched exactly and matches nothing. pub status: Option, - /// Exact domain match. + /// Exact domain match. Known values: vendor, cyber, data, operational, compliance, concentration, strategic, legal, financial, reputational. Open vocabulary: any other value is matched exactly and matches nothing. pub domain: Option, /// Only risks linked to this vendor. pub vendor_id: Option, @@ -1024,7 +1273,7 @@ pub struct ListComplianceControlsParams { pub order: Option, /// Framework slug. One of: nis2, ens, iso27001, dora, gdpr, soc2, iso42001, nistcsf, pcidss, euaiact, hipaa, cmmc, cyberessentials, uaeia, sama, saudiecc, ndpa. pub framework: String, - /// Exact status match. + /// Exact status match. Known values: covered, partial, missing. Open vocabulary: any other value is matched exactly and matches nothing. pub status: Option, } @@ -1198,6 +1447,79 @@ impl ListPortfolioTenantsParams { } } +/// Query parameters of `listReports`. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ListReportsParams { + /// 1-based page number. Defaults to 1. + pub page: Option, + /// Items per page, 1..200. Defaults to 50. + pub per_page: Option, + /// Field to order by. One of: created_at, period_end. + pub sort: Option, + /// Sort direction. Defaults to desc. + pub order: Option, + /// Exact type match. Known values: monthly, vendor_risk, nis2_compliance, ens, cyberbrief, compliance, security_posture, board_report. Open vocabulary: any other value is matched exactly and matches nothing. + pub report_type: Option, + /// Exact status match. Known values: generating, completed, failed. Open vocabulary: any other value is matched exactly and matches nothing. + pub status: Option, + /// Only reports created at or after this RFC 3339 instant. + pub since: Option, +} + +impl ListReportsParams { + /// The query string, in document order, without the parameters that are unset. + #[must_use] + pub fn query_pairs(&self) -> Vec<(&'static str, String)> { + let mut pairs = Vec::new(); + if let Some(value) = &self.page { + pairs.push(("page", value.to_string())); + } + if let Some(value) = &self.per_page { + pairs.push(("per_page", value.to_string())); + } + if let Some(value) = &self.sort { + pairs.push(("sort", value.as_str().to_owned())); + } + if let Some(value) = &self.order { + pairs.push(("order", value.as_str().to_owned())); + } + if let Some(value) = &self.report_type { + pairs.push(("report_type", value.clone())); + } + if let Some(value) = &self.status { + pairs.push(("status", value.clone())); + } + if let Some(value) = &self.since { + pairs.push(("since", value.clone())); + } + pairs + } +} + +/// Query parameters of `downloadReport`. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DownloadReportParams { + /// File format, one of: pdf, xlsx. Defaults to pdf. `getReport` lists the formats a report can be served in (`download_formats`). + pub format: Option, + /// Language of the file, one of: es, en, de, fr, it, eu. Defaults to en. + pub locale: Option, +} + +impl DownloadReportParams { + /// The query string, in document order, without the parameters that are unset. + #[must_use] + pub fn query_pairs(&self) -> Vec<(&'static str, String)> { + let mut pairs = Vec::new(); + if let Some(value) = &self.format { + pairs.push(("format", value.as_str().to_owned())); + } + if let Some(value) = &self.locale { + pairs.push(("locale", value.as_str().to_owned())); + } + pairs + } +} + /// Every `/v1` operation, as the OpenAPI document describes it. pub mod operations { use crate::Operation; @@ -1261,7 +1583,33 @@ pub mod operations { path: "/v1/risks", summary: "List risk-register entries", scopes: &["supply_risk:read"], - known_values: &[], + known_values: &[ + ( + "status", + &[ + "identified", + "assessed", + "treatment_planned", + "treatment_implemented", + "closed", + ], + ), + ( + "domain", + &[ + "vendor", + "cyber", + "data", + "operational", + "compliance", + "concentration", + "strategic", + "legal", + "financial", + "reputational", + ], + ), + ], deprecated_values: &[], tenant_scoped: true, }; @@ -1285,7 +1633,7 @@ pub mod operations { path: "/v1/compliance/controls", summary: "List control status for a framework", scopes: &["compliance.:read"], - known_values: &[], + known_values: &[("status", &["covered", "partial", "missing"])], deprecated_values: &[], tenant_scoped: true, }; @@ -1350,6 +1698,69 @@ pub mod operations { tenant_scoped: false, }; + /// `GET /v1/me`: Describe the calling credential. + pub const WHO_AM_I: Operation = Operation { + id: "whoAmI", + method: "GET", + path: "/v1/me", + summary: "Describe the calling credential", + scopes: &[], + known_values: &[], + deprecated_values: &[], + tenant_scoped: false, + }; + + /// `GET /v1/reports`: List generated reports. + pub const LIST_REPORTS: Operation = Operation { + id: "listReports", + method: "GET", + path: "/v1/reports", + summary: "List generated reports", + scopes: &["reports:read"], + known_values: &[ + ( + "report_type", + &[ + "monthly", + "vendor_risk", + "nis2_compliance", + "ens", + "cyberbrief", + "compliance", + "security_posture", + "board_report", + ], + ), + ("status", &["generating", "completed", "failed"]), + ], + deprecated_values: &[], + tenant_scoped: true, + }; + + /// `GET /v1/reports/{id}`: Fetch one report. + pub const GET_REPORT: Operation = Operation { + id: "getReport", + method: "GET", + path: "/v1/reports/{id}", + summary: "Fetch one report", + scopes: &["reports:read"], + known_values: &[], + deprecated_values: &[], + tenant_scoped: true, + }; + + /// `GET /v1/reports/{id}/download`: Download a report file. + pub const DOWNLOAD_REPORT: Operation = Operation { + id: "downloadReport", + method: "GET", + path: "/v1/reports/{id}/download", + summary: "Download a report file", + scopes: &["reports:read"], + known_values: &[], + deprecated_values: &[], + tenant_scoped: true, + }; + /// All operations, in document order. pub const ALL: &[Operation] = &[ LIST_VENDORS, @@ -1364,6 +1775,10 @@ pub mod operations { GET_EVIDENCE, LIST_ENDPOINTS, LIST_PORTFOLIO_TENANTS, + WHO_AM_I, + LIST_REPORTS, + GET_REPORT, + DOWNLOAD_REPORT, ]; } @@ -1619,4 +2034,78 @@ impl crate::Client { params.query_pairs(), ) } + + /// Describe the calling credential. + /// + /// Who the presented credential is: its type and scopes, and the tenant and organisation it reads (a tenant API key or an OAuth token), or the MSSP organisation it belongs to and how many tenants it can reach (a portfolio key; the tenants themselves are on `GET /v1/portfolio/tenants`). It describes the caller only: no other tenant, no secret, never the key or its hash. Any valid credential may call it: it requires NO scope, and it uses the same authentication and the same rate limits as every other operation. It never takes `X-HS-Tenant`. + /// + /// Required scope: `none (any valid credential)`. Published in `/v1` since 2026-10-17. + /// + /// # Errors + /// + /// [`crate::Error`] when the request cannot be sent, the API answers with an error, or the + /// body does not match the document. + pub fn who_am_i(&self) -> Result, crate::Error> { + self.get(&operations::WHO_AM_I, "/v1/me".to_owned(), Vec::new()) + } + + /// List generated reports. + /// + /// The reports generated for the tenant, newest first by default. Poll for new ones with `since` plus `order=asc`; download one with `downloadReport` once its `status` is `completed`. + /// + /// Required scope: `reports:read`. Published in `/v1` since 2026-10-19. + /// + /// # Errors + /// + /// [`crate::Error`] when the request cannot be sent, the API answers with an error, or the + /// body does not match the document. + pub fn list_reports( + &self, + params: &ListReportsParams, + ) -> Result, crate::Error> { + self.get( + &operations::LIST_REPORTS, + "/v1/reports".to_owned(), + params.query_pairs(), + ) + } + + /// Fetch one report. + /// + /// The same object `listReports` returns, for a single report, plus the formats it can be downloaded in now (`download_formats`). + /// + /// Required scope: `reports:read`. Published in `/v1` since 2026-10-19. + /// + /// # Errors + /// + /// [`crate::Error`] when the request cannot be sent, the API answers with an error, or the + /// body does not match the document. + pub fn get_report( + &self, + id: &str, + ) -> Result, crate::Error> { + self.get( + &operations::GET_REPORT, + format!("/v1/reports/{}", crate::encode_path_segment(id)?), + Vec::new(), + ) + } + + /// Download a report file. + /// + /// The report rendered as a file (PDF or XLSX), returned as the response body with `Content-Disposition: attachment`; never a link. Each delivery is recorded in the tenant’s audit trail with the credential that received it. A report that is not `completed`, a format its content has no renderer for (see `download_formats` in `getReport`) and a file over the size cap are a 409. + /// + /// Required scope: `reports:read`. Published in `/v1` since 2026-10-19. + /// + /// # Errors + /// + /// [`crate::Error`] when the request cannot be sent, the API answers with an error, or the + /// body does not match the document. + pub fn download_report( + &self, + id: &str, + params: &DownloadReportParams, + ) -> Result { + self.get_binary(&operations::DOWNLOAD_REPORT, format!("/v1/reports/{}/download", crate::encode_path_segment(id)?), params.query_pairs(), "application/pdf, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/json") + } } diff --git a/crates/hodeishield-api/src/lib.rs b/crates/hodeishield-api/src/lib.rs index 78b513e..1c0fb00 100644 --- a/crates/hodeishield-api/src/lib.rs +++ b/crates/hodeishield-api/src/lib.rs @@ -30,7 +30,8 @@ pub mod portfolio; pub mod v1; pub use client::{ - ApiResponse, Client, ClientBuilder, DeprecatedValues, MAX_RESPONSE_BYTES, Operation, RateLimit, - RequestEvent, ResponseMeta, RetryEvent, encode_path_segment, is_loopback_host, read_body, + ApiResponse, Client, ClientBuilder, DeprecatedValues, Download, MAX_DOWNLOAD_BYTES, + MAX_RESPONSE_BYTES, Operation, RateLimit, RequestEvent, ResponseMeta, RetryEvent, + encode_path_segment, is_loopback_host, read_body, }; pub use error::{ApiError, ApiErrorBody, Error, ParseEnumError}; diff --git a/crates/hodeishield-api/tests/api.rs b/crates/hodeishield-api/tests/api.rs index 261797c..3bf6846 100644 --- a/crates/hodeishield-api/tests/api.rs +++ b/crates/hodeishield-api/tests/api.rs @@ -1186,9 +1186,14 @@ fn the_listing_is_unscoped_by_its_operation_and_the_data_operations_are_scoped() assert_eq!(listing.path, "/v1/portfolio/tenants"); assert_eq!(listing.scopes, ["portfolio:read"]); assert!(!listing.tenant_scoped); + // `GET /v1/me` describes the credential itself: it needs no scope and refuses `X-HS-Tenant`. + let me = operations::WHO_AM_I; + assert_eq!(me.path, "/v1/me"); + assert!(me.scopes.is_empty()); + assert!(!me.tenant_scoped); let data: Vec<_> = operations::ALL .iter() - .filter(|op| op.id != listing.id) + .filter(|op| op.id != listing.id && op.id != me.id) .collect(); assert!(data.len() >= 11); assert!(data.iter().all(|op| op.tenant_scoped)); @@ -1234,3 +1239,59 @@ fn a_404_or_405_with_a_portfolio_code_is_the_apis_own_error() { "{err:?}" ); } + +#[test] +fn a_report_download_returns_the_file_its_type_and_its_name() { + use hodeishield_api::v1::{DownloadReportParams, Format}; + let mut server = mockito::Server::new(); + let file = b"%PDF-1.7\n\x00\xff binary".to_vec(); + let mock = server + .mock("GET", "/v1/reports/r-1/download") + .match_query(mockito::Matcher::UrlEncoded("format".into(), "pdf".into())) + .match_header( + "accept", + "application/pdf, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, \ + application/json", + ) + .with_header("content-type", "application/pdf") + .with_header( + "content-disposition", + r#"attachment; filename="board-2026-09.pdf""#, + ) + .with_body(file.clone()) + .expect(1) + .create(); + let params = DownloadReportParams { + format: Some(Format::Pdf), + ..DownloadReportParams::default() + }; + let download = client(&server) + .download_report("r-1", ¶ms) + .expect("download"); + assert_eq!(download.bytes, file); + assert_eq!(download.content_type.as_deref(), Some("application/pdf")); + assert_eq!(download.file_name.as_deref(), Some("board-2026-09.pdf")); + assert_eq!(download.meta.status, 200); + mock.assert(); +} + +#[test] +fn a_report_that_is_not_ready_is_an_api_error_with_its_code() { + use hodeishield_api::v1::DownloadReportParams; + let mut server = mockito::Server::new(); + server + .mock("GET", "/v1/reports/r-2/download") + .with_status(409) + .with_header("content-type", "application/json") + .with_body( + r#"{"error":{"code":"REPORT_NOT_READY","message":"The report is still being generated.","request_id":"req-1"}}"#, + ) + .create(); + let err = client(&server) + .download_report("r-2", &DownloadReportParams::default()) + .expect_err("not ready"); + let api = err.api().expect("api error"); + assert_eq!(api.status, 409); + assert_eq!(api.code(), Some("REPORT_NOT_READY")); + assert_eq!(api.operation, "downloadReport"); +} diff --git a/crates/hodeishield-cli/src/auth/oauth.rs b/crates/hodeishield-cli/src/auth/oauth.rs index 0628c5f..8600518 100644 --- a/crates/hodeishield-cli/src/auth/oauth.rs +++ b/crates/hodeishield-cli/src/auth/oauth.rs @@ -896,6 +896,7 @@ mod tests { "supply_risk:read", "evidence:read", "endpoints:read", + "reports:read", "offline_access" ] ); diff --git a/crates/hodeishield-cli/tests/cli.rs b/crates/hodeishield-cli/tests/cli.rs index 0821612..a72046d 100644 --- a/crates/hodeishield-cli/tests/cli.rs +++ b/crates/hodeishield-cli/tests/cli.rs @@ -712,42 +712,95 @@ fn compliance_controls_csv() { assert_no_key(&output); } -/// The vendored document lists no values for these filters, so they are sent exactly as typed, -/// without a warning, and the command succeeds. (The warning itself is covered by the unit tests -/// of `filters`, with operations that carry lists.) +/// The vendors `category` filter is free text with no list in the document, so it is sent exactly +/// as typed, without a warning, and the command succeeds. #[test] -fn free_form_filters_are_sent_as_typed_without_a_warning() { - for (args, path, param, value) in [ +fn a_free_form_filter_is_sent_as_typed_without_a_warning() { + let env = Env::new(); + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/v1/vendors") + .match_query(mockito::Matcher::UrlEncoded( + "category".into(), + "Cloud Host".into(), + )) + .with_body(page(&[], 1, 0, 1)) + .expect(1) + .create(); + let output = env + .api(&server) + .args(["vendors", "list", "--category", "Cloud Host"]) + .output() + .expect("run"); + assert!(output.status.success(), "{output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("warning:"), "{stderr}"); + mock.assert(); +} + +/// The document lists values for the risks `domain` and `status` filters and the compliance +/// controls `status` filter: a listed value goes through silently, another one gets a warning and +/// is still sent exactly as typed. +#[test] +fn listed_risk_and_control_filters_warn_and_are_sent_as_typed() { + let controls = |status: &'static str| -> Vec<&'static str> { + vec![ + "compliance", + "controls", + "--framework", + "nis2", + "--status", + status, + ] + }; + for (args, path, param, value, expected) in [ ( - &["vendors", "list", "--category", "Cloud Host"][..], - "/v1/vendors", - "category", - "Cloud Host", + vec!["risks", "list", "--domain", "cyber"], + "/v1/risks", + "domain", + "cyber", + None, ), ( - &["risks", "list", "--domain", "Cyber"], + vec!["risks", "list", "--domain", "Cyber"], "/v1/risks", "domain", "Cyber", + Some( + "for --domain (vendor, cyber, data, operational, compliance, concentration, strategic, legal, financial, reputational); did you mean `cyber`? Sending it anyway.", + ), ), ( - &["risks", "list", "--status", "OPEN"], + vec!["risks", "list", "--status", "closed"], "/v1/risks", "status", - "OPEN", + "closed", + None, ), ( - &[ - "compliance", - "controls", - "--framework", - "nis2", - "--status", - "Covered", - ], + vec!["risks", "list", "--status", "open"], + "/v1/risks", + "status", + "open", + Some( + "warning: `open` is not one of the values the API lists for --status (identified, assessed, treatment_planned, treatment_implemented, closed); sending it anyway.", + ), + ), + ( + controls("covered"), + "/v1/compliance/controls", + "status", + "covered", + None, + ), + ( + controls("Covered"), "/v1/compliance/controls", "status", "Covered", + Some( + "for --status (covered, partial, missing); did you mean `covered`? Sending it anyway.", + ), ), ] { let env = Env::new(); @@ -758,10 +811,13 @@ fn free_form_filters_are_sent_as_typed_without_a_warning() { .with_body(page(&[], 1, 0, 1)) .expect(1) .create(); - let output = env.api(&server).args(args).output().expect("run"); + let output = env.api(&server).args(&args).output().expect("run"); assert!(output.status.success(), "{args:?}: {output:?}"); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(!stderr.contains("warning:"), "{args:?}: {stderr}"); + match expected { + Some(warning) => assert!(stderr.contains(warning), "{args:?}: {stderr}"), + None => assert!(!stderr.contains("warning:"), "{args:?}: {stderr}"), + } mock.assert(); } } diff --git a/openapi/README.md b/openapi/README.md index 1282ecf..6d094cb 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -6,9 +6,9 @@ from it; nothing in that client is written by hand from memory. | Field | Value | |---|---| -| `info.version` | `2026-10-16` | -| SHA-256 | `1321a417aba28e7b60dbc28a35ce2dc3711d8973b985107f7701d70ff3be6b0d` | -| Operations | 12, all `GET` | +| `info.version` | `2026-10-19` | +| SHA-256 | `8c9a44bc696584105f10b67d490a8816dabc26b07b80ed18369e6d3e498359c4` | +| Operations | 16, all `GET` | ## Updating it diff --git a/openapi/v1.json b/openapi/v1.json index 5ab3c5f..3dda78e 100644 --- a/openapi/v1.json +++ b/openapi/v1.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Hodei-Shield API","version":"2026-10-16","summary":"Third-party risk and compliance posture, for your own systems.","description":"## Versioning\n\n`/v1` is frozen. These changes are **compatible** and will happen inside it\nwithout notice:\n\n- a new operation;\n- a new **optional** query parameter;\n- a new field in a response object;\n- a new value in a field documented as an open vocabulary (`status`,\n `severity`, `evidence_type`, the keys of `by_status`);\n- a new `error.code`, always with the HTTP status its siblings already use;\n- anything under `error.details`, which is diagnostic and not part of the\n contract — except `error.details.code` and the keys the error table of\n the operation names (`missing`, `licence`, `parameter`, `scope`,\n `limit`, `window_seconds`, `retry_after_seconds`): those are contract.\n `error.details.code` is an OPEN vocabulary: a value, once published, keeps\n its meaning and the HTTP status it is sent with; new values may be added.\n\nWrite your client so those do not break it: ignore unknown fields, do not\nswitch exhaustively on a documented-open vocabulary, and treat an unknown\n`error.code` as \"an error of this HTTP status\".\n\nThese changes are **incompatible** and will NOT happen inside `/v1`. They go\nto `/v2`, and `/v1` keeps serving:\n\n- removing or renaming an operation, a parameter or a field;\n- changing the type, the unit or the meaning of a field;\n- making an optional parameter required, or narrowing what one accepts;\n- changing which scope an operation requires;\n- changing the HTTP status an existing `error.code` is sent with.\n\n## Read-only\n\n`/v1` only reads. Every operation is a `GET`. Any other method (`POST`, `PUT`,\n`PATCH`, `DELETE`) on a path that exists answers **405** with `Allow: GET`, and on\na path that does not exist answers **404**: neither changes any state. A write\nwill be offered only as a new operation, which is a compatible change, once a\ncustomer asks for a concrete one.\n\n## Authentication\n\nEvery operation takes `Authorization: Bearer `. Three credentials\nexist:\n\n- a **tenant API key** (`hsk_…`): belongs to ONE tenant and carries an\n explicit list of scopes; it can read nothing outside that tenant, whatever it\n asks for;\n- a user's **OAuth access token** (`hs_at_…`): one user, ONE tenant chosen\n at consent, read scopes only;\n- an MSSP **portfolio key** (`hsp_…`): held by a managed security service\n provider, it reads ANY of its client tenants, ONE per request, selected with\n the request header `X-HS-Tenant: ` (a UUID, never a slug).\n `GET /v1/portfolio/tenants` lists the tenants it may select. Read-only, with\n a mandatory expiry. A tenant outside the key's portfolio answers **404**\n (`error.details.code: \"TENANT_NOT_FOUND\"`), exactly as one that does not\n exist.\n\nEvery successful (2xx) answer of a data operation carries `X-HS-Tenant` with\nthe id of the tenant whose data the body holds, for every credential; no error\ncarries it. A tenant API key or OAuth token may send `X-HS-Tenant` only with\nits own tenant's id; any other value is a **400**\n(`TENANT_SELECTOR_NOT_SUPPORTED`).\n\nEach operation below declares the scope it requires. A credential that\nauthenticates but lacks the scope is answered **403**\n(`error.details.code: \"INSUFFICIENT_SCOPE\"`), never 404: you are told what you\nare missing, not that the resource does not exist.\n\n## Errors\n\nEvery error has the same body:\n\n```json\n{ \"error\": { \"code\": \"forbidden\", \"message\": \"…\", \"request_id\": \"…\" } }\n```\n\nBranch on `code`, log `request_id`, show `message` to nobody but yourself.\n`request_id` is also the `X-Request-Id` response header, and it is what\nsupport will ask you for.\n\n## Rate limits\n\nEvery response that got past authentication carries the limit that is closest\nto being exhausted:\n\n- `RateLimit-Limit` — requests allowed in the window;\n- `RateLimit-Remaining` — requests left in the current window;\n- `RateLimit-Reset` — **seconds** until the window renews;\n- `RateLimit-Policy` — `;w=`.\n\nThree limits apply, each per one-minute window: per API key, per organisation\n(all of its keys together) and per service. The first two come from the licence\nof the organisation the key belongs to: a higher licence tier has more room, and\nan MSSP partner has more than a direct customer of the same tier. Read the\ncurrent limit from `RateLimit-Limit` and `RateLimit-Policy` rather than\nhard-coding it. When one is exhausted the answer is\n**429** with `error.code: \"rate_limited\"`, `Retry-After` (seconds) and\n`error.details.scope` (`key`, `tenant` or `service`) saying which. A\nportfolio key is limited per key (`portfolio_key`), per provider across all\nits portfolio keys (`portfolio_org`) and per service; once the selected tenant\nis known to be in its portfolio, the client tenant's own limit (`tenant`) and\nthe provider's share of it (`portfolio_tenant`) apply too. Your\norganisation's usage never counts against another's. Windows are fixed, so a\nclient can burst up to twice the limit across a window boundary: back off on\n`Retry-After` rather than pacing to the second. The limits are part of the\nservice, not of the frozen contract: they may be raised without notice.\n\n## Pagination, filtering and ordering\n\nEvery collection takes `page` and `per_page` (max 200) and answers with a\n`pagination` object carrying `total` and `total_pages`. Every collection\ntakes `sort` (an enum of the fields THAT collection can order by) and\n`order` (`asc`/`desc`, default `desc`). Filters are exact matches unless\nthe parameter says otherwise. A parameter or field with a known vocabulary lists\nit in `x-hs-known-values` (an OpenAPI extension, not a JSON Schema `enum`: the\nserver still accepts what it accepted before). Values retired from a parameter\nare listed in `x-hs-deprecated-values`, with the date until which the server is\ndocumented to accept them in `x-hs-deprecated-values-sunset`.\n\n## Contract changes\n\nCorrections and additions to the published contract, newest first. They ship\nwith the contract version (`info.version`) named in each entry. Detect a\ncapability by the operation, not by this string: the portfolio contract is\npresent when `GET /v1` lists `listPortfolioTenants`.\n\n- **2026-10-16 — addition: MSSP portfolio keys.** A third credential, the\n portfolio key (`hsp_…`); the operation `GET /v1/portfolio/tenants`\n (`listPortfolioTenants`, scope `portfolio:read`); the optional request\n header `X-HS-Tenant` on every data operation, echoed on every 2xx answer of\n them for every credential, with `Vary: X-HS-Tenant`;\n `error.details.code` becomes contract (`TENANT_HEADER_INVALID`,\n `TENANT_REQUIRED`, `TENANT_SELECTOR_NOT_SUPPORTED`, `TENANT_NOT_FOUND`,\n `INSUFFICIENT_SCOPE`, `LICENCE_INACTIVE`), and every scope 403 now carries\n `INSUFFICIENT_SCOPE`; three new 429 `details.scope` values\n (`portfolio_key`, `portfolio_org`, `portfolio_tenant`). Nothing existing\n was removed, renamed or re-statused.\n\n- **2026-10-16 — correction: alert `severity` is English.** `GET /v1/alerts`\n and `GET /v1/alerts/{id}` returned `severity` as stored, in Spanish\n (`critico`, `alto`, `medio`, `bajo`, `info`), while this document\n promised `critical | high | medium | low`. They now return `critical`,\n `high`, `medium`, `low` and `info` (`info` is added to the documented\n values). **This changes what an integrator who adapted to the Spanish values\n receives.** The `severity` filter accepts both the English values and the\n Spanish ones; the Spanish ones are documented as accepted until\n **2027-01-14** (90 days after this version), so switch to the English ones\n before then. A value outside both lists is still matched exactly and matches\n nothing. `GET /v1/risks`: the descriptions of `domain` and `status` named\n values that do not exist; they now list the real ones (no value changed).","contact":{"name":"Hodeitek S.L.","url":"https://hodeishield.com"}},"servers":[{"url":"https://api.hodeishield.com"}],"tags":[{"name":"vendors"},{"name":"alerts"},{"name":"risks"},{"name":"compliance"},{"name":"evidence"},{"name":"endpoints"},{"name":"Portfolio"}],"paths":{"/v1/vendors":{"get":{"operationId":"listVendors","summary":"List third parties","description":"The tenant third-party inventory, newest first by default. Soft-deleted vendors are never returned.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["vendors"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at, updated_at, name.","schema":{"default":"created_at","type":"string","enum":["created_at","updated_at","name"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"q","in":"query","required":false,"description":"Case-insensitive substring of the name or the domain.","schema":{"type":"string","minLength":1,"maxLength":120}},{"name":"category","in":"query","required":false,"description":"Exact category match.","schema":{"type":"string","minLength":1,"maxLength":120}},{"name":"active","in":"query","required":false,"description":"Filter by the operational flag.","schema":{"type":"string"}},{"name":"business_criticality","in":"query","required":false,"description":"Exact criticality match.","schema":{"type":"string","enum":["low","medium","high","critical"]}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"Stable identifier of the vendor in this tenant."},"name":{"type":"string","description":"Display name."},"legal_name":{"description":"Registered legal name, when it differs.","type":["string","null"]},"domain":{"description":"Primary domain, lowercase, no scheme.","type":["string","null"]},"category":{"description":"Free-text category assigned by the tenant.","type":["string","null"]},"vendor_type":{"description":"Kind of third party (provider, product, …).","type":["string","null"]},"access_level":{"description":"Level of access the vendor has to the tenant systems.","type":["string","null"]},"business_criticality":{"description":"low | medium | high | critical, as set by the tenant.","type":["string","null"]},"data_sensitivity":{"description":"public | internal | confidential | restricted.","type":["string","null"]},"economic_impact":{"description":"Estimated economic impact of an outage, in the tenant currency.","type":["number","null"]},"active":{"type":"boolean","description":"Whether the vendor is operationally enabled."},"monitoring_status":{"description":"Continuous-monitoring state.","type":["string","null"]},"last_scan_at":{"description":"RFC 3339, UTC. Null if never scanned.","type":["string","null"]},"lei":{"description":"Legal Entity Identifier (ISO 17442), for DORA RoI.","type":["string","null"]},"hq_country":{"description":"Head-office country, ISO 3166-1 alpha-2.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","name","legal_name","domain","category","vendor_type","access_level","business_criticality","data_sensitivity","economic_impact","active","monitoring_status","last_scan_at","lei","hq_country","created_at","updated_at"],"additionalProperties":false,"description":"A third party in the tenant inventory."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/vendors/{id}":{"get":{"operationId":"getVendor","summary":"Fetch one third party","description":"The same object `listVendors` returns, for a single vendor.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["vendors"],"parameters":[{"name":"id","in":"path","required":true,"description":"Vendor identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"Stable identifier of the vendor in this tenant."},"name":{"type":"string","description":"Display name."},"legal_name":{"description":"Registered legal name, when it differs.","type":["string","null"]},"domain":{"description":"Primary domain, lowercase, no scheme.","type":["string","null"]},"category":{"description":"Free-text category assigned by the tenant.","type":["string","null"]},"vendor_type":{"description":"Kind of third party (provider, product, …).","type":["string","null"]},"access_level":{"description":"Level of access the vendor has to the tenant systems.","type":["string","null"]},"business_criticality":{"description":"low | medium | high | critical, as set by the tenant.","type":["string","null"]},"data_sensitivity":{"description":"public | internal | confidential | restricted.","type":["string","null"]},"economic_impact":{"description":"Estimated economic impact of an outage, in the tenant currency.","type":["number","null"]},"active":{"type":"boolean","description":"Whether the vendor is operationally enabled."},"monitoring_status":{"description":"Continuous-monitoring state.","type":["string","null"]},"last_scan_at":{"description":"RFC 3339, UTC. Null if never scanned.","type":["string","null"]},"lei":{"description":"Legal Entity Identifier (ISO 17442), for DORA RoI.","type":["string","null"]},"hq_country":{"description":"Head-office country, ISO 3166-1 alpha-2.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","name","legal_name","domain","category","vendor_type","access_level","business_criticality","data_sensitivity","economic_impact","active","monitoring_status","last_scan_at","lei","hq_country","created_at","updated_at"],"additionalProperties":false,"description":"A third party in the tenant inventory."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/alerts":{"get":{"operationId":"listAlerts","summary":"List supply-chain alerts","description":"Alerts raised against the tenant vendors. Poll incrementally with `since` plus `order=asc`.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["alerts"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at.","schema":{"default":"created_at","type":"string","enum":["created_at"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"status","in":"query","required":false,"description":"Filter by the derived status.","schema":{"type":"string","enum":["open","acknowledged","resolved"]}},{"name":"severity","in":"query","required":false,"description":"Exact severity match, in the vocabulary the response uses (critical | high | medium | low | info). The legacy Spanish values (critico, alto, medio, bajo, info) are still accepted and mean the same; they are documented as accepted until 2027-01-14, so move to the English ones.","schema":{"x-hs-known-values":["critical","high","medium","low","info"],"x-hs-deprecated-values":["critico","alto","medio","bajo"],"x-hs-deprecated-values-sunset":"2027-01-14","type":"string","minLength":1,"maxLength":40}},{"name":"vendor_id","in":"query","required":false,"description":"Only alerts about this vendor.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"since","in":"query","required":false,"description":"Only alerts created at or after this RFC 3339 instant.","schema":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"vendor_id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"The vendor this alert is about (`/v1/vendors/{id}`)."},"type":{"type":"string","description":"Alert family, e.g. `breach`, `cve`, `rating_drop`."},"severity":{"description":"critical | high | medium | low | info, when classified. Open vocabulary: a new value is a compatible change.","x-hs-known-values":["critical","high","medium","low","info"],"type":["string","null"]},"title":{"type":"string"},"description":{"type":["string","null"]},"source_url":{"description":"Where the finding came from, when public.","type":["string","null"]},"economic_impact":{"type":["number","null"]},"status":{"type":"string","enum":["open","acknowledged","resolved"],"description":"Derived: resolved if closed, acknowledged if taken, otherwise open."},"acknowledged_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolved_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolution_reason":{"description":"Why it was closed, when it was.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","vendor_id","type","severity","title","description","source_url","economic_impact","status","acknowledged_at","resolved_at","resolution_reason","created_at"],"additionalProperties":false,"description":"A supply-chain alert raised against one vendor."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/alerts/{id}":{"get":{"operationId":"getAlert","summary":"Fetch one alert","description":"The same object `listAlerts` returns, for a single alert.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["alerts"],"parameters":[{"name":"id","in":"path","required":true,"description":"Alert identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"vendor_id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"The vendor this alert is about (`/v1/vendors/{id}`)."},"type":{"type":"string","description":"Alert family, e.g. `breach`, `cve`, `rating_drop`."},"severity":{"description":"critical | high | medium | low | info, when classified. Open vocabulary: a new value is a compatible change.","x-hs-known-values":["critical","high","medium","low","info"],"type":["string","null"]},"title":{"type":"string"},"description":{"type":["string","null"]},"source_url":{"description":"Where the finding came from, when public.","type":["string","null"]},"economic_impact":{"type":["number","null"]},"status":{"type":"string","enum":["open","acknowledged","resolved"],"description":"Derived: resolved if closed, acknowledged if taken, otherwise open."},"acknowledged_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolved_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolution_reason":{"description":"Why it was closed, when it was.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","vendor_id","type","severity","title","description","source_url","economic_impact","status","acknowledged_at","resolved_at","resolution_reason","created_at"],"additionalProperties":false,"description":"A supply-chain alert raised against one vendor."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/risks":{"get":{"operationId":"listRisks","summary":"List risk-register entries","description":"The tenant risk register, with inherent and residual figures.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["risks"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at, updated_at, inherent_score, residual_score.","schema":{"default":"created_at","type":"string","enum":["created_at","updated_at","inherent_score","residual_score"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"status","in":"query","required":false,"description":"Exact status match.","schema":{"type":"string","minLength":1,"maxLength":24}},{"name":"domain","in":"query","required":false,"description":"Exact domain match.","schema":{"type":"string","minLength":1,"maxLength":20}},{"name":"vendor_id","in":"query","required":false,"description":"Only risks linked to this vendor.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"min_inherent_score","in":"query","required":false,"description":"Only risks whose inherent score is at least this.","schema":{"type":"integer","minimum":0,"maximum":9007199254740991}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"title":{"type":"string"},"description":{"type":["string","null"]},"domain":{"type":"string","description":"Risk domain: vendor | cyber | data | operational | compliance | concentration | strategic | legal | financial | reputational."},"status":{"type":"string","description":"Risk lifecycle status: identified | assessed | treatment_planned | treatment_implemented | closed."},"treatment":{"description":"mitigate | accept | transfer | avoid.","type":["string","null"]},"treatment_notes":{"type":["string","null"]},"threat_source":{"type":["string","null"]},"vulnerability":{"type":["string","null"]},"likelihood_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"impact_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"inherent_score":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"likelihood_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"impact_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"residual_score":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"source":{"type":"string","description":"manual | imported | derived."},"owner_user_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"User of this tenant who owns the risk."},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor this risk is about, when any."},"control_codes":{"type":"array","items":{"type":"string"},"description":"Framework control codes linked to the risk."},"review_date":{"description":"Calendar date, `YYYY-MM-DD`.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","title","description","domain","status","treatment","treatment_notes","threat_source","vulnerability","likelihood_inherent","impact_inherent","inherent_score","likelihood_residual","impact_residual","residual_score","source","owner_user_id","vendor_id","control_codes","review_date","created_at","updated_at"],"additionalProperties":false,"description":"An entry of the tenant risk register."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/risks/{id}":{"get":{"operationId":"getRisk","summary":"Fetch one risk-register entry","description":"The same object `listRisks` returns, for a single entry.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["risks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Risk identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"title":{"type":"string"},"description":{"type":["string","null"]},"domain":{"type":"string","description":"Risk domain: vendor | cyber | data | operational | compliance | concentration | strategic | legal | financial | reputational."},"status":{"type":"string","description":"Risk lifecycle status: identified | assessed | treatment_planned | treatment_implemented | closed."},"treatment":{"description":"mitigate | accept | transfer | avoid.","type":["string","null"]},"treatment_notes":{"type":["string","null"]},"threat_source":{"type":["string","null"]},"vulnerability":{"type":["string","null"]},"likelihood_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"impact_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"inherent_score":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"likelihood_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"impact_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"residual_score":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"source":{"type":"string","description":"manual | imported | derived."},"owner_user_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"User of this tenant who owns the risk."},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor this risk is about, when any."},"control_codes":{"type":"array","items":{"type":"string"},"description":"Framework control codes linked to the risk."},"review_date":{"description":"Calendar date, `YYYY-MM-DD`.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","title","description","domain","status","treatment","treatment_notes","threat_source","vulnerability","likelihood_inherent","impact_inherent","inherent_score","likelihood_residual","impact_residual","residual_score","source","owner_user_id","vendor_id","control_codes","review_date","created_at","updated_at"],"additionalProperties":false,"description":"An entry of the tenant risk register."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/compliance/controls":{"get":{"operationId":"listComplianceControls","summary":"List control status for a framework","description":"Per-control status of one framework. Requires the read scope OF THAT FRAMEWORK: `compliance.:read`.\n\nRequired scope: `compliance.:read`. Published in `/v1` since 2026-09-20.","tags":["compliance"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: control_code, last_evaluated_at.","schema":{"default":"control_code","type":"string","enum":["control_code","last_evaluated_at"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"framework","in":"query","required":true,"description":"Framework slug. One of: nis2, ens, iso27001, dora, gdpr, soc2, iso42001, nistcsf, pcidss, euaiact, hipaa, cmmc, cyberessentials, uaeia, sama, saudiecc, ndpa.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"description":"Exact status match.","schema":{"type":"string","minLength":1,"maxLength":20}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"framework":{"type":"string","description":"Framework slug the control belongs to."},"control_code":{"type":"string","description":"Control identifier inside the framework."},"status":{"type":"string","description":"missing | partial | covered, as evaluated by the platform."},"evidence_count":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Evidence items currently linked to the control."},"last_evaluated_at":{"description":"RFC 3339, UTC.","type":["string","null"]}},"required":["framework","control_code","status","evidence_count","last_evaluated_at"],"additionalProperties":false,"description":"The tenant status of one framework control."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":[]},{"portfolioKey":[]}],"x-hs-scope":{"template":"compliance.:read"}}},"/v1/compliance/frameworks/{framework}":{"get":{"operationId":"getFrameworkPosture","summary":"Framework posture summary","description":"Counts of evaluated controls by status for one framework, for a dashboard tile. Requires `compliance.:read`.\n\nRequired scope: `compliance.:read`. Published in `/v1` since 2026-09-20.","tags":["compliance"],"parameters":[{"name":"framework","in":"path","required":true,"description":"Framework slug. One of: nis2, ens, iso27001, dora, gdpr, soc2, iso42001, nistcsf, pcidss, euaiact, hipaa, cmmc, cyberessentials, uaeia, sama, saudiecc, ndpa.","schema":{"type":"string"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"framework":{"type":"string"},"controls_total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Controls the platform has evaluated."},"by_status":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"description":"Control count per status. Keys are whatever statuses exist; may grow."},"evidence_total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Sum of the evidence linked across the evaluated controls."},"last_evaluated_at":{"description":"Most recent control evaluation, RFC 3339 UTC. Null if never evaluated.","type":["string","null"]}},"required":["framework","controls_total","by_status","evidence_total","last_evaluated_at"],"additionalProperties":false,"description":"Posture summary for one framework."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":[]},{"portfolioKey":[]}],"x-hs-scope":{"template":"compliance.:read"}}},"/v1/evidence":{"get":{"operationId":"listEvidence","summary":"List security evidence","description":"Evidence held for the tenant. Use `expiring_before` plus `sort=valid_until&order=asc` for a renewal queue.\n\nRequired scope: `evidence:read`. Published in `/v1` since 2026-09-20.","tags":["evidence"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at, valid_until.","schema":{"default":"created_at","type":"string","enum":["created_at","valid_until"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"evidence_type","in":"query","required":false,"description":"Exact type match.","schema":{"type":"string","minLength":1,"maxLength":50}},{"name":"source_type","in":"query","required":false,"description":"Exact producer match.","schema":{"type":"string","minLength":1,"maxLength":50}},{"name":"vendor_id","in":"query","required":false,"description":"Only evidence about this vendor.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"expiring_before","in":"query","required":false,"description":"Only evidence with a `valid_until` strictly before this `YYYY-MM-DD`.","schema":{"type":"string","format":"date","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"evidence_type":{"type":"string","description":"What kind of evidence this is."},"source_type":{"type":"string","description":"Which subsystem produced it."},"source_id":{"type":"string","description":"Its identifier inside that subsystem."},"title":{"type":"string"},"description":{"type":["string","null"]},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor it is about, when any."},"asset_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The asset it is about, when any."},"auto_generated":{"type":"boolean","description":"False when a person uploaded it."},"valid_until":{"description":"Calendar date, `YYYY-MM-DD`. Null = no expiry.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","evidence_type","source_type","source_id","title","description","vendor_id","asset_id","auto_generated","valid_until","created_at"],"additionalProperties":false,"description":"One item of security evidence."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["evidence:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["evidence:read"]}}},"/v1/evidence/{id}":{"get":{"operationId":"getEvidence","summary":"Fetch one evidence item","description":"The same object `listEvidence` returns, for a single item.\n\nRequired scope: `evidence:read`. Published in `/v1` since 2026-09-20.","tags":["evidence"],"parameters":[{"name":"id","in":"path","required":true,"description":"Evidence identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"evidence_type":{"type":"string","description":"What kind of evidence this is."},"source_type":{"type":"string","description":"Which subsystem produced it."},"source_id":{"type":"string","description":"Its identifier inside that subsystem."},"title":{"type":"string"},"description":{"type":["string","null"]},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor it is about, when any."},"asset_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The asset it is about, when any."},"auto_generated":{"type":"boolean","description":"False when a person uploaded it."},"valid_until":{"description":"Calendar date, `YYYY-MM-DD`. Null = no expiry.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","evidence_type","source_type","source_id","title","description","vendor_id","asset_id","auto_generated","valid_until","created_at"],"additionalProperties":false,"description":"One item of security evidence."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["evidence:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["evidence:read"]}}},"/v1/endpoints":{"get":{"operationId":"listEndpoints","summary":"List enrolled endpoint agents","description":"The endpoint agents enrolled in this tenant. `silent_since` selects the ones that have gone quiet, including those that never reported.\n\nRequired scope: `endpoints:read`. Published in `/v1` since 2026-09-20.","tags":["endpoints"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: enrolled_at, last_seen_at, producer.","schema":{"default":"enrolled_at","type":"string","enum":["enrolled_at","last_seen_at","producer"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"status","in":"query","required":false,"description":"Exact status match.","schema":{"type":"string","enum":["active","revoked"]}},{"name":"silent_since","in":"query","required":false,"description":"Agents last seen before this RFC 3339 instant, or never seen at all.","schema":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"producer":{"type":"string","description":"The agent name, unique among the active ones of the tenant."},"producer_kind":{"type":"string","description":"What kind of producer it is."},"status":{"type":"string","description":"active | revoked."},"signature_alg":{"type":"string","description":"Algorithm this enrollment signs with."},"agent_version":{"type":["string","null"]},"hostname":{"description":"Last hostname the agent reported.","type":["string","null"]},"enrolled_at":{"type":"string","description":"RFC 3339, UTC."},"last_seen_at":{"description":"RFC 3339, UTC. Null until the first envelope.","type":["string","null"]},"revoked_at":{"description":"RFC 3339, UTC.","type":["string","null"]}},"required":["id","producer","producer_kind","status","signature_alg","agent_version","hostname","enrolled_at","last_seen_at","revoked_at"],"additionalProperties":false,"description":"An endpoint agent enrolled in this tenant."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["endpoints:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["endpoints:read"]}}},"/v1/portfolio/tenants":{"get":{"operationId":"listPortfolioTenants","summary":"List the client tenants of an MSSP portfolio","description":"Every tenant the presented PORTFOLIO key may select with `X-HS-Tenant`: the MSSP’s own tenants (its internal one included) and every tenant of its active client organisations, active or not, licensed or not, narrowed to the key’s client subset when it has one. Membership is evaluated live on every request. Only a portfolio key (`hsp_…`) can call this operation; this operation never takes `X-HS-Tenant`.\n\nRequired scope: `portfolio:read`. Published in `/v1` since 2026-10-16.","tags":["Portfolio"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: name, slug.","schema":{"default":"name","type":"string","enum":["name","slug"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"Stable identifier (lowercase). The ONLY value `X-HS-Tenant` accepts."},"name":{"type":"string","description":"Display name. Untrusted text."},"slug":{"type":"string","description":"Short name, unique across the platform. A client may resolve a slug to an `id` from this list; the server never accepts a slug as a selector."},"organization":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":false,"description":"The organisation the tenant belongs to: the MSSP itself for the tenants it holds directly (including its internal one), or the client organisation."},"internal":{"type":"boolean","description":"True only for the MSSP’s own internal tenant."},"active":{"type":"boolean","description":"Whether the tenant is enabled. A deactivated tenant is listed but cannot be read."},"licence_status":{"type":"string","description":"Open vocabulary: `in_force` (licence in force), `grace` (grace period: reads are served), `inactive` (none, suspended, revoked or expired: data operations answer 403 `LICENCE_INACTIVE`). Treat an unknown value as \"may not be readable\". A tenant is readable when `active` is true and `licence_status` is not `inactive`.","x-hs-known-values":["in_force","grace","inactive"]}},"required":["id","name","slug","organization","internal","active","licence_status"],"additionalProperties":false,"description":"A client tenant an MSSP portfolio key may select."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"portfolioKey":[]}],"x-hs-scope":{"scopes":["portfolio:read"]}}}},"components":{"schemas":{"Error":{"type":"object","required":["error"],"additionalProperties":false,"properties":{"error":{"type":"object","required":["code","message","request_id"],"properties":{"code":{"type":"string","enum":["invalid_request","unauthorized","forbidden","not_found","method_not_allowed","rate_limited","internal_error","service_unavailable"],"description":"Closed vocabulary; new members may be added (see Versioning)."},"message":{"type":"string","description":"For a human reading a log. Not stable; do not parse it."},"request_id":{"type":"string","description":"Also the `X-Request-Id` response header."},"details":{"type":"object","additionalProperties":true,"description":"Diagnostic, except `code` and the keys the operation’s error descriptions name, which are contract.","properties":{"code":{"type":"string","description":"OPEN vocabulary (a value keeps its meaning and its HTTP status; new values may be added): `TENANT_HEADER_INVALID`, `TENANT_REQUIRED`, `TENANT_SELECTOR_NOT_SUPPORTED` (400), `INSUFFICIENT_SCOPE`, `LICENCE_INACTIVE` (403), `TENANT_NOT_FOUND` (404).","x-hs-known-values":["TENANT_HEADER_INVALID","TENANT_REQUIRED","TENANT_SELECTOR_NOT_SUPPORTED","INSUFFICIENT_SCOPE","LICENCE_INACTIVE","TENANT_NOT_FOUND"]}}}}}}}},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"A tenant API key, created in the console under Settings → API keys with the minimum scopes it needs."},"portfolioKey":{"type":"http","scheme":"bearer","description":"An MSSP portfolio key (`hsp_…`), created by an organisation administrator of an active managed security service provider in the console’s MSSP area (API keys). It lists the provider’s client tenants (`portfolio:read`) and reads ONE of them per request, selected with `X-HS-Tenant`, with the same data scopes a tenant API key uses. Read-only; mandatory expiry (default 90 days, at most 365); optional client subset and source IP ranges. Refused with 401 everywhere but `/v1`."},"oauth2":{"type":"oauth2","description":"A USER access token (`hs_at_…`) issued by the HodeiShield console, the OAuth 2.1 authorization server at `https://app.hodeishield.com/api/auth` (metadata: `/.well-known/oauth-authorization-server` on its origin). Authorization code with PKCE (S256, mandatory) for applications with a browser; the RFC 8628 device authorization grant (`https://app.hodeishield.com/api/auth/device/code`) for those without one. Every token is bound to ONE user and ONE tenant, chosen by the user on the consent screen, and carries read scopes only; the tenant is re-checked against what the user can see on every request. Public clients register themselves (RFC 7591) at `/oauth2/register`; the HodeiShield CLI is pre-registered as `hodeishield-cli`. Revocation: RFC 7009 at `/oauth2/revoke`, or by the user in the console under Account → Application access.","flows":{"authorizationCode":{"authorizationUrl":"https://app.hodeishield.com/api/auth/oauth2/authorize","tokenUrl":"https://app.hodeishield.com/api/auth/oauth2/token","refreshUrl":"https://app.hodeishield.com/api/auth/oauth2/token","scopes":{"supply_risk:read":"Read supply_risk data of the tenant the token is bound to.","compliance.nis2:read":"Read compliance.nis2 data of the tenant the token is bound to.","compliance.ens:read":"Read compliance.ens data of the tenant the token is bound to.","compliance.iso27001:read":"Read compliance.iso27001 data of the tenant the token is bound to.","compliance.dora:read":"Read compliance.dora data of the tenant the token is bound to.","compliance.gdpr:read":"Read compliance.gdpr data of the tenant the token is bound to.","compliance.soc2:read":"Read compliance.soc2 data of the tenant the token is bound to.","compliance.iso42001:read":"Read compliance.iso42001 data of the tenant the token is bound to.","compliance.nistcsf:read":"Read compliance.nistcsf data of the tenant the token is bound to.","compliance.pcidss:read":"Read compliance.pcidss data of the tenant the token is bound to.","compliance.euaiact:read":"Read compliance.euaiact data of the tenant the token is bound to.","compliance.hipaa:read":"Read compliance.hipaa data of the tenant the token is bound to.","compliance.cmmc:read":"Read compliance.cmmc data of the tenant the token is bound to.","compliance.cyberessentials:read":"Read compliance.cyberessentials data of the tenant the token is bound to.","compliance.uaeia:read":"Read compliance.uaeia data of the tenant the token is bound to.","compliance.sama:read":"Read compliance.sama data of the tenant the token is bound to.","compliance.saudiecc:read":"Read compliance.saudiecc data of the tenant the token is bound to.","compliance.ndpa:read":"Read compliance.ndpa data of the tenant the token is bound to.","radar:read":"Read radar data of the tenant the token is bound to.","connectors:read":"Read connectors data of the tenant the token is bound to.","endpoints:read":"Read endpoints data of the tenant the token is bound to.","hodeiai:read":"Read hodeiai data of the tenant the token is bound to.","virtual_ciso:read":"Read virtual_ciso data of the tenant the token is bound to.","simulator:read":"Read simulator data of the tenant the token is bound to.","evidence:read":"Read evidence data of the tenant the token is bound to.","reports:read":"Read reports data of the tenant the token is bound to.","ai_governance:read":"Read ai_governance data of the tenant the token is bound to.","management:read":"Read management data of the tenant the token is bound to.","offline_access":"Issue a refresh token (30 days, rotated on every use)."}}}}}},"security":[{"bearerAuth":[]},{"oauth2":[]},{"portfolioKey":[]}]} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Hodei-Shield API","version":"2026-10-19","summary":"Third-party risk and compliance posture, for your own systems.","description":"## Versioning\n\n`/v1` is frozen. These changes are **compatible** and will happen inside it\nwithout notice:\n\n- a new operation;\n- a new **optional** query parameter;\n- a new field in a response object;\n- a new value in a field documented as an open vocabulary (`status`,\n `severity`, `evidence_type`, the keys of `by_status`);\n- a new `error.code`, always with the HTTP status its siblings already use;\n- anything under `error.details`, which is diagnostic and not part of the\n contract — except `error.details.code` and the keys the error table of\n the operation names (`missing`, `licence`, `parameter`, `scope`,\n `limit`, `window_seconds`, `retry_after_seconds`, `status`,\n `formats`, `limit_bytes`): those are contract.\n `error.details.code` is an OPEN vocabulary: a value, once published, keeps\n its meaning and the HTTP status it is sent with; new values may be added.\n\nWrite your client so those do not break it: ignore unknown fields, do not\nswitch exhaustively on a documented-open vocabulary, and treat an unknown\n`error.code` as \"an error of this HTTP status\".\n\nThese changes are **incompatible** and will NOT happen inside `/v1`. They go\nto `/v2`, and `/v1` keeps serving:\n\n- removing or renaming an operation, a parameter or a field;\n- changing the type, the unit or the meaning of a field;\n- making an optional parameter required, or narrowing what one accepts;\n- changing which scope an operation requires;\n- changing the HTTP status an existing `error.code` is sent with.\n\n## Read-only\n\n`/v1` only reads. Every operation is a `GET`. Any other method (`POST`, `PUT`,\n`PATCH`, `DELETE`) on a path that exists answers **405** with `Allow: GET`, and on\na path that does not exist answers **404**: neither changes any state. A write\nwill be offered only as a new operation, which is a compatible change, once a\ncustomer asks for a concrete one.\n\n## Downloads\n\nAn operation whose success is a FILE (`GET /v1/reports/{id}/download`) answers\n**200** with the bytes, the `Content-Type` of the format asked for and\n`Content-Disposition: attachment; filename=\"…\"`; it never answers a URL to fetch\nthem from. Its errors are the same JSON envelope as everywhere else. A file that\ncannot be served as asked is a **409** with `error.details.code`: the report is\nnot ready yet (`REPORT_NOT_READY`), its content has no renderer for that format\n(`FORMAT_UNSUPPORTED`) or the file exceeds the size the operation serves\n(`FILE_TOO_LARGE`, with `limit_bytes`). Every file delivered is recorded in the\ntenant's audit trail with the credential that received it.\n\n## Authentication\n\nEvery operation takes `Authorization: Bearer `. Three credentials\nexist:\n\n- a **tenant API key** (`hsk_…`): belongs to ONE tenant and carries an\n explicit list of scopes; it can read nothing outside that tenant, whatever it\n asks for;\n- a user's **OAuth access token** (`hs_at_…`): one user, ONE tenant chosen\n at consent, read scopes only;\n- an MSSP **portfolio key** (`hsp_…`): held by a managed security service\n provider, it reads ANY of its client tenants, ONE per request, selected with\n the request header `X-HS-Tenant: ` (a UUID, never a slug).\n `GET /v1/portfolio/tenants` lists the tenants it may select. Read-only, with\n a mandatory expiry. A tenant outside the key's portfolio answers **404**\n (`error.details.code: \"TENANT_NOT_FOUND\"`), exactly as one that does not\n exist.\n\nEvery successful (2xx) answer of a data operation carries `X-HS-Tenant` with\nthe id of the tenant whose data the body holds, for every credential; no error\ncarries it. A tenant API key or OAuth token may send `X-HS-Tenant` only with\nits own tenant's id; any other value is a **400**\n(`TENANT_SELECTOR_NOT_SUPPORTED`).\n\nEach operation below declares the scope it requires. A credential that\nauthenticates but lacks the scope is answered **403**\n(`error.details.code: \"INSUFFICIENT_SCOPE\"`), never 404: you are told what you\nare missing, not that the resource does not exist.\n\n## Errors\n\nEvery error has the same body:\n\n```json\n{ \"error\": { \"code\": \"forbidden\", \"message\": \"…\", \"request_id\": \"…\" } }\n```\n\nBranch on `code`, log `request_id`, show `message` to nobody but yourself.\n`request_id` is also the `X-Request-Id` response header, and it is what\nsupport will ask you for.\n\n## Rate limits\n\nEvery response that got past authentication carries the limit that is closest\nto being exhausted:\n\n- `RateLimit-Limit` — requests allowed in the window;\n- `RateLimit-Remaining` — requests left in the current window;\n- `RateLimit-Reset` — **seconds** until the window renews;\n- `RateLimit-Policy` — `;w=`.\n\nThree limits apply, each per one-minute window: per API key, per organisation\n(all of its keys together) and per service. The first two come from the licence\nof the organisation the key belongs to: a higher licence tier has more room, and\nan MSSP partner has more than a direct customer of the same tier. Read the\ncurrent limit from `RateLimit-Limit` and `RateLimit-Policy` rather than\nhard-coding it. When one is exhausted the answer is\n**429** with `error.code: \"rate_limited\"`, `Retry-After` (seconds) and\n`error.details.scope` (`key`, `tenant` or `service`) saying which. A\nportfolio key is limited per key (`portfolio_key`), per provider across all\nits portfolio keys (`portfolio_org`) and per service; once the selected tenant\nis known to be in its portfolio, the client tenant's own limit (`tenant`) and\nthe provider's share of it (`portfolio_tenant`) apply too. Your\norganisation's usage never counts against another's. Windows are fixed, so a\nclient can burst up to twice the limit across a window boundary: back off on\n`Retry-After` rather than pacing to the second. The limits are part of the\nservice, not of the frozen contract: they may be raised without notice.\n\n## Pagination, filtering and ordering\n\nEvery collection takes `page` and `per_page` (max 200) and answers with a\n`pagination` object carrying `total` and `total_pages`. Every collection\ntakes `sort` (an enum of the fields THAT collection can order by) and\n`order` (`asc`/`desc`, default `desc`). Filters are exact matches unless\nthe parameter says otherwise. A parameter or field with a known vocabulary lists\nit in `x-hs-known-values` (an OpenAPI extension, not a JSON Schema `enum`: the\nserver still accepts what it accepted before). Values retired from a parameter\nare listed in `x-hs-deprecated-values`, with the date until which the server is\ndocumented to accept them in `x-hs-deprecated-values-sunset`.\n\n## Contract changes\n\nCorrections and additions to the published contract, newest first. They ship\nwith the contract version (`info.version`) named in each entry. Detect a\ncapability by the operation, not by this string: the portfolio contract is\npresent when `GET /v1` lists `listPortfolioTenants`.\n\n- **2026-10-19 — addition: reports.** `GET /v1/reports` (`listReports`),\n `GET /v1/reports/{id}` (`getReport`) and\n `GET /v1/reports/{id}/download` (`downloadReport`), all under the scope\n `reports:read`. The download is the contract's first non-JSON success: a\n PDF or XLSX file as an attachment (see Downloads). A new `error.code`,\n `conflict` (**409**), answered only by the download, with three new\n `error.details.code` values (`REPORT_NOT_READY`, `FORMAT_UNSUPPORTED`,\n `FILE_TOO_LARGE`). Nothing existing was removed, renamed or re-statused.\n\n- **2026-10-18 — addition: known values for four filters.** `GET /v1/risks`\n `domain` and `status`, `GET /v1/compliance/controls` `status` and the\n `vendor_type` field of `GET /v1/vendors` now list their vocabulary in\n `x-hs-known-values`, taken from the lists the platform validates against.\n They stay open: a value outside the list is still accepted and matches exactly,\n as before. `GET /v1/vendors` `category` is free text with no closed\n vocabulary, and its description now says so. Nothing existing was removed,\n renamed or re-statused.\n\n- **2026-10-17 — addition: `GET /v1/me`.** The operation `whoAmI` describes the\n calling credential: its type and scopes, the tenant (id, name) and organisation it\n reads, or, for a portfolio key, the MSSP organisation and how many tenants it can\n reach. Any valid credential may call it and it requires no scope; it uses the same\n authentication and rate limits as every other operation, and refuses\n `X-HS-Tenant`. Nothing existing was removed, renamed or re-statused.\n\n- **2026-10-16 — addition: MSSP portfolio keys.** A third credential, the\n portfolio key (`hsp_…`); the operation `GET /v1/portfolio/tenants`\n (`listPortfolioTenants`, scope `portfolio:read`); the optional request\n header `X-HS-Tenant` on every data operation, echoed on every 2xx answer of\n them for every credential, with `Vary: X-HS-Tenant`;\n `error.details.code` becomes contract (`TENANT_HEADER_INVALID`,\n `TENANT_REQUIRED`, `TENANT_SELECTOR_NOT_SUPPORTED`, `TENANT_NOT_FOUND`,\n `INSUFFICIENT_SCOPE`, `LICENCE_INACTIVE`), and every scope 403 now carries\n `INSUFFICIENT_SCOPE`; three new 429 `details.scope` values\n (`portfolio_key`, `portfolio_org`, `portfolio_tenant`). Nothing existing\n was removed, renamed or re-statused.\n\n- **2026-10-16 — correction: alert `severity` is English.** `GET /v1/alerts`\n and `GET /v1/alerts/{id}` returned `severity` as stored, in Spanish\n (`critico`, `alto`, `medio`, `bajo`, `info`), while this document\n promised `critical | high | medium | low`. They now return `critical`,\n `high`, `medium`, `low` and `info` (`info` is added to the documented\n values). **This changes what an integrator who adapted to the Spanish values\n receives.** The `severity` filter accepts both the English values and the\n Spanish ones; the Spanish ones are documented as accepted until\n **2027-01-14** (90 days after this version), so switch to the English ones\n before then. A value outside both lists is still matched exactly and matches\n nothing. `GET /v1/risks`: the descriptions of `domain` and `status` named\n values that do not exist; they now list the real ones (no value changed).","contact":{"name":"Hodeitek S.L.","url":"https://hodeishield.com"}},"servers":[{"url":"https://api.hodeishield.com"}],"tags":[{"name":"vendors"},{"name":"alerts"},{"name":"risks"},{"name":"compliance"},{"name":"evidence"},{"name":"endpoints"},{"name":"Portfolio"},{"name":"Identity"},{"name":"reports"}],"paths":{"/v1/vendors":{"get":{"operationId":"listVendors","summary":"List third parties","description":"The tenant third-party inventory, newest first by default. Soft-deleted vendors are never returned.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["vendors"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at, updated_at, name.","schema":{"default":"created_at","type":"string","enum":["created_at","updated_at","name"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"q","in":"query","required":false,"description":"Case-insensitive substring of the name or the domain.","schema":{"type":"string","minLength":1,"maxLength":120}},{"name":"category","in":"query","required":false,"description":"Exact category match, as the tenant wrote it (case-sensitive). Category is free text with NO closed vocabulary, so there is no list of values to publish: read `category` from `GET /v1/vendors`. To filter by the kind of third party, read `vendor_type` (`x-hs-known-values`).","schema":{"type":"string","minLength":1,"maxLength":120}},{"name":"active","in":"query","required":false,"description":"Filter by the operational flag.","schema":{"type":"string"}},{"name":"business_criticality","in":"query","required":false,"description":"Exact criticality match.","schema":{"type":"string","enum":["low","medium","high","critical"]}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"Stable identifier of the vendor in this tenant."},"name":{"type":"string","description":"Display name."},"legal_name":{"description":"Registered legal name, when it differs.","type":["string","null"]},"domain":{"description":"Primary domain, lowercase, no scheme.","type":["string","null"]},"category":{"description":"Free-text category assigned by the tenant. NOT a closed vocabulary: the console form stores the label of the tenant’s language, and the API accepts any text. List the vendors to see the values a tenant uses.","type":["string","null"]},"vendor_type":{"description":"Kind of third party: software | cloud | hardware | service_provider. Open vocabulary: a new kind is a compatible change.","x-hs-known-values":["software","cloud","hardware","service_provider"],"type":["string","null"]},"access_level":{"description":"Level of access the vendor has to the tenant systems.","type":["string","null"]},"business_criticality":{"description":"low | medium | high | critical, as set by the tenant.","type":["string","null"]},"data_sensitivity":{"description":"public | internal | confidential | restricted.","type":["string","null"]},"economic_impact":{"description":"Estimated economic impact of an outage, in the tenant currency.","type":["number","null"]},"active":{"type":"boolean","description":"Whether the vendor is operationally enabled."},"monitoring_status":{"description":"Continuous-monitoring state.","type":["string","null"]},"last_scan_at":{"description":"RFC 3339, UTC. Null if never scanned.","type":["string","null"]},"lei":{"description":"Legal Entity Identifier (ISO 17442), for DORA RoI.","type":["string","null"]},"hq_country":{"description":"Head-office country, ISO 3166-1 alpha-2.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","name","legal_name","domain","category","vendor_type","access_level","business_criticality","data_sensitivity","economic_impact","active","monitoring_status","last_scan_at","lei","hq_country","created_at","updated_at"],"additionalProperties":false,"description":"A third party in the tenant inventory."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/vendors/{id}":{"get":{"operationId":"getVendor","summary":"Fetch one third party","description":"The same object `listVendors` returns, for a single vendor.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["vendors"],"parameters":[{"name":"id","in":"path","required":true,"description":"Vendor identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"Stable identifier of the vendor in this tenant."},"name":{"type":"string","description":"Display name."},"legal_name":{"description":"Registered legal name, when it differs.","type":["string","null"]},"domain":{"description":"Primary domain, lowercase, no scheme.","type":["string","null"]},"category":{"description":"Free-text category assigned by the tenant. NOT a closed vocabulary: the console form stores the label of the tenant’s language, and the API accepts any text. List the vendors to see the values a tenant uses.","type":["string","null"]},"vendor_type":{"description":"Kind of third party: software | cloud | hardware | service_provider. Open vocabulary: a new kind is a compatible change.","x-hs-known-values":["software","cloud","hardware","service_provider"],"type":["string","null"]},"access_level":{"description":"Level of access the vendor has to the tenant systems.","type":["string","null"]},"business_criticality":{"description":"low | medium | high | critical, as set by the tenant.","type":["string","null"]},"data_sensitivity":{"description":"public | internal | confidential | restricted.","type":["string","null"]},"economic_impact":{"description":"Estimated economic impact of an outage, in the tenant currency.","type":["number","null"]},"active":{"type":"boolean","description":"Whether the vendor is operationally enabled."},"monitoring_status":{"description":"Continuous-monitoring state.","type":["string","null"]},"last_scan_at":{"description":"RFC 3339, UTC. Null if never scanned.","type":["string","null"]},"lei":{"description":"Legal Entity Identifier (ISO 17442), for DORA RoI.","type":["string","null"]},"hq_country":{"description":"Head-office country, ISO 3166-1 alpha-2.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","name","legal_name","domain","category","vendor_type","access_level","business_criticality","data_sensitivity","economic_impact","active","monitoring_status","last_scan_at","lei","hq_country","created_at","updated_at"],"additionalProperties":false,"description":"A third party in the tenant inventory."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/alerts":{"get":{"operationId":"listAlerts","summary":"List supply-chain alerts","description":"Alerts raised against the tenant vendors. Poll incrementally with `since` plus `order=asc`.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["alerts"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at.","schema":{"default":"created_at","type":"string","enum":["created_at"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"status","in":"query","required":false,"description":"Filter by the derived status.","schema":{"type":"string","enum":["open","acknowledged","resolved"]}},{"name":"severity","in":"query","required":false,"description":"Exact severity match, in the vocabulary the response uses (critical | high | medium | low | info). The legacy Spanish values (critico, alto, medio, bajo, info) are still accepted and mean the same; they are documented as accepted until 2027-01-14, so move to the English ones.","schema":{"x-hs-known-values":["critical","high","medium","low","info"],"x-hs-deprecated-values":["critico","alto","medio","bajo"],"x-hs-deprecated-values-sunset":"2027-01-14","type":"string","minLength":1,"maxLength":40}},{"name":"vendor_id","in":"query","required":false,"description":"Only alerts about this vendor.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"since","in":"query","required":false,"description":"Only alerts created at or after this RFC 3339 instant.","schema":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"vendor_id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"The vendor this alert is about (`/v1/vendors/{id}`)."},"type":{"type":"string","description":"Alert family, e.g. `breach`, `cve`, `rating_drop`."},"severity":{"description":"critical | high | medium | low | info, when classified. Open vocabulary: a new value is a compatible change.","x-hs-known-values":["critical","high","medium","low","info"],"type":["string","null"]},"title":{"type":"string"},"description":{"type":["string","null"]},"source_url":{"description":"Where the finding came from, when public.","type":["string","null"]},"economic_impact":{"type":["number","null"]},"status":{"type":"string","enum":["open","acknowledged","resolved"],"description":"Derived: resolved if closed, acknowledged if taken, otherwise open."},"acknowledged_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolved_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolution_reason":{"description":"Why it was closed, when it was.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","vendor_id","type","severity","title","description","source_url","economic_impact","status","acknowledged_at","resolved_at","resolution_reason","created_at"],"additionalProperties":false,"description":"A supply-chain alert raised against one vendor."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/alerts/{id}":{"get":{"operationId":"getAlert","summary":"Fetch one alert","description":"The same object `listAlerts` returns, for a single alert.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["alerts"],"parameters":[{"name":"id","in":"path","required":true,"description":"Alert identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"vendor_id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"The vendor this alert is about (`/v1/vendors/{id}`)."},"type":{"type":"string","description":"Alert family, e.g. `breach`, `cve`, `rating_drop`."},"severity":{"description":"critical | high | medium | low | info, when classified. Open vocabulary: a new value is a compatible change.","x-hs-known-values":["critical","high","medium","low","info"],"type":["string","null"]},"title":{"type":"string"},"description":{"type":["string","null"]},"source_url":{"description":"Where the finding came from, when public.","type":["string","null"]},"economic_impact":{"type":["number","null"]},"status":{"type":"string","enum":["open","acknowledged","resolved"],"description":"Derived: resolved if closed, acknowledged if taken, otherwise open."},"acknowledged_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolved_at":{"description":"RFC 3339, UTC.","type":["string","null"]},"resolution_reason":{"description":"Why it was closed, when it was.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","vendor_id","type","severity","title","description","source_url","economic_impact","status","acknowledged_at","resolved_at","resolution_reason","created_at"],"additionalProperties":false,"description":"A supply-chain alert raised against one vendor."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/risks":{"get":{"operationId":"listRisks","summary":"List risk-register entries","description":"The tenant risk register, with inherent and residual figures.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["risks"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at, updated_at, inherent_score, residual_score.","schema":{"default":"created_at","type":"string","enum":["created_at","updated_at","inherent_score","residual_score"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"status","in":"query","required":false,"description":"Exact status match. Known values: identified, assessed, treatment_planned, treatment_implemented, closed. Open vocabulary: any other value is matched exactly and matches nothing.","schema":{"x-hs-known-values":["identified","assessed","treatment_planned","treatment_implemented","closed"],"type":"string","minLength":1,"maxLength":24}},{"name":"domain","in":"query","required":false,"description":"Exact domain match. Known values: vendor, cyber, data, operational, compliance, concentration, strategic, legal, financial, reputational. Open vocabulary: any other value is matched exactly and matches nothing.","schema":{"x-hs-known-values":["vendor","cyber","data","operational","compliance","concentration","strategic","legal","financial","reputational"],"type":"string","minLength":1,"maxLength":20}},{"name":"vendor_id","in":"query","required":false,"description":"Only risks linked to this vendor.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"min_inherent_score","in":"query","required":false,"description":"Only risks whose inherent score is at least this.","schema":{"type":"integer","minimum":0,"maximum":9007199254740991}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"title":{"type":"string"},"description":{"type":["string","null"]},"domain":{"type":"string","description":"Risk domain: vendor | cyber | data | operational | compliance | concentration | strategic | legal | financial | reputational.","x-hs-known-values":["vendor","cyber","data","operational","compliance","concentration","strategic","legal","financial","reputational"]},"status":{"type":"string","description":"Risk lifecycle status: identified | assessed | treatment_planned | treatment_implemented | closed.","x-hs-known-values":["identified","assessed","treatment_planned","treatment_implemented","closed"]},"treatment":{"description":"mitigate | accept | transfer | avoid.","type":["string","null"]},"treatment_notes":{"type":["string","null"]},"threat_source":{"type":["string","null"]},"vulnerability":{"type":["string","null"]},"likelihood_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"impact_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"inherent_score":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"likelihood_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"impact_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"residual_score":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"source":{"type":"string","description":"manual | imported | derived."},"owner_user_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"User of this tenant who owns the risk."},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor this risk is about, when any."},"control_codes":{"type":"array","items":{"type":"string"},"description":"Framework control codes linked to the risk."},"review_date":{"description":"Calendar date, `YYYY-MM-DD`.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","title","description","domain","status","treatment","treatment_notes","threat_source","vulnerability","likelihood_inherent","impact_inherent","inherent_score","likelihood_residual","impact_residual","residual_score","source","owner_user_id","vendor_id","control_codes","review_date","created_at","updated_at"],"additionalProperties":false,"description":"An entry of the tenant risk register."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/risks/{id}":{"get":{"operationId":"getRisk","summary":"Fetch one risk-register entry","description":"The same object `listRisks` returns, for a single entry.\n\nRequired scope: `supply_risk:read`. Published in `/v1` since 2026-09-20.","tags":["risks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Risk identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"title":{"type":"string"},"description":{"type":["string","null"]},"domain":{"type":"string","description":"Risk domain: vendor | cyber | data | operational | compliance | concentration | strategic | legal | financial | reputational.","x-hs-known-values":["vendor","cyber","data","operational","compliance","concentration","strategic","legal","financial","reputational"]},"status":{"type":"string","description":"Risk lifecycle status: identified | assessed | treatment_planned | treatment_implemented | closed.","x-hs-known-values":["identified","assessed","treatment_planned","treatment_implemented","closed"]},"treatment":{"description":"mitigate | accept | transfer | avoid.","type":["string","null"]},"treatment_notes":{"type":["string","null"]},"threat_source":{"type":["string","null"]},"vulnerability":{"type":["string","null"]},"likelihood_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"impact_inherent":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"inherent_score":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"likelihood_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"impact_residual":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"residual_score":{"anyOf":[{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},{"type":"null"}]},"source":{"type":"string","description":"manual | imported | derived."},"owner_user_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"User of this tenant who owns the risk."},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor this risk is about, when any."},"control_codes":{"type":"array","items":{"type":"string"},"description":"Framework control codes linked to the risk."},"review_date":{"description":"Calendar date, `YYYY-MM-DD`.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"updated_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","title","description","domain","status","treatment","treatment_notes","threat_source","vulnerability","likelihood_inherent","impact_inherent","inherent_score","likelihood_residual","impact_residual","residual_score","source","owner_user_id","vendor_id","control_codes","review_date","created_at","updated_at"],"additionalProperties":false,"description":"An entry of the tenant risk register."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["supply_risk:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["supply_risk:read"]}}},"/v1/compliance/controls":{"get":{"operationId":"listComplianceControls","summary":"List control status for a framework","description":"Per-control status of one framework. Requires the read scope OF THAT FRAMEWORK: `compliance.:read`.\n\nRequired scope: `compliance.:read`. Published in `/v1` since 2026-09-20.","tags":["compliance"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: control_code, last_evaluated_at.","schema":{"default":"control_code","type":"string","enum":["control_code","last_evaluated_at"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"framework","in":"query","required":true,"description":"Framework slug. One of: nis2, ens, iso27001, dora, gdpr, soc2, iso42001, nistcsf, pcidss, euaiact, hipaa, cmmc, cyberessentials, uaeia, sama, saudiecc, ndpa.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"description":"Exact status match. Known values: covered, partial, missing. Open vocabulary: any other value is matched exactly and matches nothing.","schema":{"x-hs-known-values":["covered","partial","missing"],"type":"string","minLength":1,"maxLength":20}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"framework":{"type":"string","description":"Framework slug the control belongs to."},"control_code":{"type":"string","description":"Control identifier inside the framework."},"status":{"type":"string","description":"covered | partial | missing, as evaluated by the platform.","x-hs-known-values":["covered","partial","missing"]},"evidence_count":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Evidence items currently linked to the control."},"last_evaluated_at":{"description":"RFC 3339, UTC.","type":["string","null"]}},"required":["framework","control_code","status","evidence_count","last_evaluated_at"],"additionalProperties":false,"description":"The tenant status of one framework control."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":[]},{"portfolioKey":[]}],"x-hs-scope":{"template":"compliance.:read"}}},"/v1/compliance/frameworks/{framework}":{"get":{"operationId":"getFrameworkPosture","summary":"Framework posture summary","description":"Counts of evaluated controls by status for one framework, for a dashboard tile. Requires `compliance.:read`.\n\nRequired scope: `compliance.:read`. Published in `/v1` since 2026-09-20.","tags":["compliance"],"parameters":[{"name":"framework","in":"path","required":true,"description":"Framework slug. One of: nis2, ens, iso27001, dora, gdpr, soc2, iso42001, nistcsf, pcidss, euaiact, hipaa, cmmc, cyberessentials, uaeia, sama, saudiecc, ndpa.","schema":{"type":"string"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"framework":{"type":"string"},"controls_total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Controls the platform has evaluated."},"by_status":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991},"description":"Control count per status. Keys are whatever statuses exist; may grow."},"evidence_total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Sum of the evidence linked across the evaluated controls."},"last_evaluated_at":{"description":"Most recent control evaluation, RFC 3339 UTC. Null if never evaluated.","type":["string","null"]}},"required":["framework","controls_total","by_status","evidence_total","last_evaluated_at"],"additionalProperties":false,"description":"Posture summary for one framework."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":[]},{"portfolioKey":[]}],"x-hs-scope":{"template":"compliance.:read"}}},"/v1/evidence":{"get":{"operationId":"listEvidence","summary":"List security evidence","description":"Evidence held for the tenant. Use `expiring_before` plus `sort=valid_until&order=asc` for a renewal queue.\n\nRequired scope: `evidence:read`. Published in `/v1` since 2026-09-20.","tags":["evidence"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at, valid_until.","schema":{"default":"created_at","type":"string","enum":["created_at","valid_until"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"evidence_type","in":"query","required":false,"description":"Exact type match.","schema":{"type":"string","minLength":1,"maxLength":50}},{"name":"source_type","in":"query","required":false,"description":"Exact producer match.","schema":{"type":"string","minLength":1,"maxLength":50}},{"name":"vendor_id","in":"query","required":false,"description":"Only evidence about this vendor.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"expiring_before","in":"query","required":false,"description":"Only evidence with a `valid_until` strictly before this `YYYY-MM-DD`.","schema":{"type":"string","format":"date","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"evidence_type":{"type":"string","description":"What kind of evidence this is."},"source_type":{"type":"string","description":"Which subsystem produced it."},"source_id":{"type":"string","description":"Its identifier inside that subsystem."},"title":{"type":"string"},"description":{"type":["string","null"]},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor it is about, when any."},"asset_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The asset it is about, when any."},"auto_generated":{"type":"boolean","description":"False when a person uploaded it."},"valid_until":{"description":"Calendar date, `YYYY-MM-DD`. Null = no expiry.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","evidence_type","source_type","source_id","title","description","vendor_id","asset_id","auto_generated","valid_until","created_at"],"additionalProperties":false,"description":"One item of security evidence."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["evidence:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["evidence:read"]}}},"/v1/evidence/{id}":{"get":{"operationId":"getEvidence","summary":"Fetch one evidence item","description":"The same object `listEvidence` returns, for a single item.\n\nRequired scope: `evidence:read`. Published in `/v1` since 2026-09-20.","tags":["evidence"],"parameters":[{"name":"id","in":"path","required":true,"description":"Evidence identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"evidence_type":{"type":"string","description":"What kind of evidence this is."},"source_type":{"type":"string","description":"Which subsystem produced it."},"source_id":{"type":"string","description":"Its identifier inside that subsystem."},"title":{"type":"string"},"description":{"type":["string","null"]},"vendor_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The vendor it is about, when any."},"asset_id":{"anyOf":[{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},{"type":"null"}],"description":"The asset it is about, when any."},"auto_generated":{"type":"boolean","description":"False when a person uploaded it."},"valid_until":{"description":"Calendar date, `YYYY-MM-DD`. Null = no expiry.","type":["string","null"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","evidence_type","source_type","source_id","title","description","vendor_id","asset_id","auto_generated","valid_until","created_at"],"additionalProperties":false,"description":"One item of security evidence."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["evidence:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["evidence:read"]}}},"/v1/endpoints":{"get":{"operationId":"listEndpoints","summary":"List enrolled endpoint agents","description":"The endpoint agents enrolled in this tenant. `silent_since` selects the ones that have gone quiet, including those that never reported.\n\nRequired scope: `endpoints:read`. Published in `/v1` since 2026-09-20.","tags":["endpoints"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: enrolled_at, last_seen_at, producer.","schema":{"default":"enrolled_at","type":"string","enum":["enrolled_at","last_seen_at","producer"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"status","in":"query","required":false,"description":"Exact status match.","schema":{"type":"string","enum":["active","revoked"]}},{"name":"silent_since","in":"query","required":false,"description":"Agents last seen before this RFC 3339 instant, or never seen at all.","schema":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"producer":{"type":"string","description":"The agent name, unique among the active ones of the tenant."},"producer_kind":{"type":"string","description":"What kind of producer it is."},"status":{"type":"string","description":"active | revoked."},"signature_alg":{"type":"string","description":"Algorithm this enrollment signs with."},"agent_version":{"type":["string","null"]},"hostname":{"description":"Last hostname the agent reported.","type":["string","null"]},"enrolled_at":{"type":"string","description":"RFC 3339, UTC."},"last_seen_at":{"description":"RFC 3339, UTC. Null until the first envelope.","type":["string","null"]},"revoked_at":{"description":"RFC 3339, UTC.","type":["string","null"]}},"required":["id","producer","producer_kind","status","signature_alg","agent_version","hostname","enrolled_at","last_seen_at","revoked_at"],"additionalProperties":false,"description":"An endpoint agent enrolled in this tenant."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["endpoints:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["endpoints:read"]}}},"/v1/portfolio/tenants":{"get":{"operationId":"listPortfolioTenants","summary":"List the client tenants of an MSSP portfolio","description":"Every tenant the presented PORTFOLIO key may select with `X-HS-Tenant`: the MSSP’s own tenants (its internal one included) and every tenant of its active client organisations, active or not, licensed or not, narrowed to the key’s client subset when it has one. Membership is evaluated live on every request. Only a portfolio key (`hsp_…`) can call this operation; this operation never takes `X-HS-Tenant`.\n\nRequired scope: `portfolio:read`. Published in `/v1` since 2026-10-16.","tags":["Portfolio"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: name, slug.","schema":{"default":"name","type":"string","enum":["name","slug"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"Stable identifier (lowercase). The ONLY value `X-HS-Tenant` accepts."},"name":{"type":"string","description":"Display name. Untrusted text."},"slug":{"type":"string","description":"Short name, unique across the platform. A client may resolve a slug to an `id` from this list; the server never accepts a slug as a selector."},"organization":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"name":{"type":"string"}},"required":["id","name"],"additionalProperties":false,"description":"The organisation the tenant belongs to: the MSSP itself for the tenants it holds directly (including its internal one), or the client organisation."},"internal":{"type":"boolean","description":"True only for the MSSP’s own internal tenant."},"active":{"type":"boolean","description":"Whether the tenant is enabled. A deactivated tenant is listed but cannot be read."},"licence_status":{"type":"string","description":"Open vocabulary: `in_force` (licence in force), `grace` (grace period: reads are served), `inactive` (none, suspended, revoked or expired: data operations answer 403 `LICENCE_INACTIVE`). Treat an unknown value as \"may not be readable\". A tenant is readable when `active` is true and `licence_status` is not `inactive`.","x-hs-known-values":["in_force","grace","inactive"]}},"required":["id","name","slug","organization","internal","active","licence_status"],"additionalProperties":false,"description":"A client tenant an MSSP portfolio key may select."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"portfolioKey":[]}],"x-hs-scope":{"scopes":["portfolio:read"]}}},"/v1/me":{"get":{"operationId":"whoAmI","summary":"Describe the calling credential","description":"Who the presented credential is: its type and scopes, and the tenant and organisation it reads (a tenant API key or an OAuth token), or the MSSP organisation it belongs to and how many tenants it can reach (a portfolio key; the tenants themselves are on `GET /v1/portfolio/tenants`). It describes the caller only: no other tenant, no secret, never the key or its hash. Any valid credential may call it: it requires NO scope, and it uses the same authentication and the same rate limits as every other operation. It never takes `X-HS-Tenant`.\n\nRequired scope: `none (any valid credential)`. Published in `/v1` since 2026-10-17.","tags":["Identity"],"parameters":[],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"credential":{"type":"object","properties":{"type":{"type":"string","description":"Open vocabulary: `tenant` (a tenant API key, `hsk_…`), `oauth` (a user access token, `hs_at_…`) or `portfolio` (an MSSP portfolio key, `hsp_…`).","x-hs-known-values":["tenant","oauth","portfolio"]},"prefix":{"description":"The clear key prefix (`hsp_…`) of a portfolio key. Null for a tenant key or an OAuth token. Never the full key and never a hash.","type":["string","null"]},"scopes":{"type":"array","items":{"type":"string"},"description":"The scopes the credential carries."}},"required":["type","prefix","scopes"],"additionalProperties":false,"description":"The credential that made this call."},"tenant":{"anyOf":[{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$","description":"The tenant this credential reads (lowercase)."},"name":{"type":"string","description":"Display name. Untrusted text."},"type":{"type":"string","description":"The type of the organisation that owns the tenant. Open vocabulary: `direct` (a customer organisation), `mssp` (a managed security service provider) or `root` (the platform operator). Treat an unknown value as opaque.","x-hs-known-values":["direct","mssp","root"]}},"required":["id","name","type"],"additionalProperties":false},{"type":"null"}],"description":"The credential’s own tenant. Null for a portfolio key, which has no tenant."},"organization":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"name":{"description":"Display name. Null for a portfolio key whose client subset holds none of the provider’s own tenants, the only place a portfolio key sees the name. Untrusted text.","type":["string","null"]},"type":{"type":"string","description":"`direct`, `mssp` or `root`; `mssp` for a portfolio key."}},"required":["id","name","type"],"additionalProperties":false,"description":"The organisation of the tenant (tenant key, OAuth token) or the MSSP the key belongs to (portfolio key)."},"portfolio":{"anyOf":[{"type":"object","properties":{"tenant_count":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"How many tenants this key can select, right now."},"tenants_path":{"type":"string","description":"Where the tenants are listed: `GET /v1/portfolio/tenants`."}},"required":["tenant_count","tenants_path"],"additionalProperties":false},{"type":"null"}],"description":"Portfolio keys only. Null for a tenant key or an OAuth token."}},"required":["credential","tenant","organization","portfolio"],"additionalProperties":false,"description":"The credential that made the call, and what it may see about itself."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":[]},{"portfolioKey":[]}],"x-hs-scope":{"none":true}}},"/v1/reports":{"get":{"operationId":"listReports","summary":"List generated reports","description":"The reports generated for the tenant, newest first by default. Poll for new ones with `since` plus `order=asc`; download one with `downloadReport` once its `status` is `completed`.\n\nRequired scope: `reports:read`. Published in `/v1` since 2026-10-19.","tags":["reports"],"parameters":[{"name":"page","in":"query","required":false,"description":"1-based page number. Defaults to 1.","schema":{"default":1,"type":"integer","minimum":1,"maximum":9007199254740991}},{"name":"per_page","in":"query","required":false,"description":"Items per page, 1..200. Defaults to 50.","schema":{"default":50,"type":"integer","minimum":1,"maximum":200}},{"name":"sort","in":"query","required":false,"description":"Field to order by. One of: created_at, period_end.","schema":{"default":"created_at","type":"string","enum":["created_at","period_end"]}},{"name":"order","in":"query","required":false,"description":"Sort direction. Defaults to desc.","schema":{"default":"desc","type":"string","enum":["asc","desc"]}},{"name":"report_type","in":"query","required":false,"description":"Exact type match. Known values: monthly, vendor_risk, nis2_compliance, ens, cyberbrief, compliance, security_posture, board_report. Open vocabulary: any other value is matched exactly and matches nothing.","schema":{"x-hs-known-values":["monthly","vendor_risk","nis2_compliance","ens","cyberbrief","compliance","security_posture","board_report"],"type":"string","minLength":1,"maxLength":50}},{"name":"status","in":"query","required":false,"description":"Exact status match. Known values: generating, completed, failed. Open vocabulary: any other value is matched exactly and matches nothing.","schema":{"x-hs-known-values":["generating","completed","failed"],"type":"string","minLength":1,"maxLength":20}},{"name":"since","in":"query","required":false,"description":"Only reports created at or after this RFC 3339 instant.","schema":{"type":"string","format":"date-time","pattern":"^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"report_type":{"type":"string","description":"What kind of report this is. Known values: monthly, vendor_risk, nis2_compliance, ens, cyberbrief, compliance, security_posture, board_report. Open vocabulary: a new kind may appear.","x-hs-known-values":["monthly","vendor_risk","nis2_compliance","ens","cyberbrief","compliance","security_posture","board_report"]},"title":{"type":"string"},"period_start":{"type":"string","description":"First day the report covers, `YYYY-MM-DD`."},"period_end":{"type":"string","description":"Last day the report covers, `YYYY-MM-DD`."},"status":{"type":"string","description":"Lifecycle: generating | completed | failed. Only a `completed` report can be downloaded.","x-hs-known-values":["generating","completed","failed"]},"created_at":{"type":"string","description":"RFC 3339, UTC."}},"required":["id","report_type","title","period_start","period_end","status","created_at"],"additionalProperties":false,"description":"A report the platform generated for the tenant (metadata only)."}},"pagination":{"type":"object","properties":{"page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"The page that was served."},"per_page":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Items per page in effect."},"total":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Total items matching the filters, across all pages."},"total_pages":{"type":"integer","minimum":-9007199254740991,"maximum":9007199254740991,"description":"Number of pages at this `per_page`."}},"required":["page","per_page","total","total_pages"],"additionalProperties":false}},"required":["data","pagination"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["reports:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["reports:read"]}}},"/v1/reports/{id}":{"get":{"operationId":"getReport","summary":"Fetch one report","description":"The same object `listReports` returns, for a single report, plus the formats it can be downloaded in now (`download_formats`).\n\nRequired scope: `reports:read`. Published in `/v1` since 2026-10-19.","tags":["reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Report identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Success.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}}},"content":{"application/json":{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"report_type":{"type":"string","description":"What kind of report this is. Known values: monthly, vendor_risk, nis2_compliance, ens, cyberbrief, compliance, security_posture, board_report. Open vocabulary: a new kind may appear.","x-hs-known-values":["monthly","vendor_risk","nis2_compliance","ens","cyberbrief","compliance","security_posture","board_report"]},"title":{"type":"string"},"period_start":{"type":"string","description":"First day the report covers, `YYYY-MM-DD`."},"period_end":{"type":"string","description":"Last day the report covers, `YYYY-MM-DD`."},"status":{"type":"string","description":"Lifecycle: generating | completed | failed. Only a `completed` report can be downloaded.","x-hs-known-values":["generating","completed","failed"]},"created_at":{"type":"string","description":"RFC 3339, UTC."},"download_formats":{"type":"array","items":{"type":"string","description":"A format `downloadReport` serves. Known values: pdf, xlsx. Open vocabulary: a format may be added.","x-hs-known-values":["pdf","xlsx"]},"description":"The formats `downloadReport` can serve for this report now. Empty while it is not `completed`, or when its content has no renderer (download it from the console as Markdown or JSON)."}},"required":["id","report_type","title","period_start","period_end","status","created_at","download_formats"],"additionalProperties":false,"description":"One report, with the formats it can be downloaded in."}},"required":["data"],"additionalProperties":false}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["reports:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["reports:read"]}}},"/v1/reports/{id}/download":{"get":{"operationId":"downloadReport","summary":"Download a report file","description":"The report rendered as a file (PDF or XLSX), returned as the response body with `Content-Disposition: attachment`; never a link. Each delivery is recorded in the tenant’s audit trail with the credential that received it. A report that is not `completed`, a format its content has no renderer for (see `download_formats` in `getReport`) and a file over the size cap are a 409.\n\nRequired scope: `reports:read`. Published in `/v1` since 2026-10-19.","tags":["reports"],"parameters":[{"name":"id","in":"path","required":true,"description":"Report identifier.","schema":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"}},{"name":"format","in":"query","required":false,"description":"File format, one of: pdf, xlsx. Defaults to pdf. `getReport` lists the formats a report can be served in (`download_formats`).","schema":{"default":"pdf","type":"string","enum":["pdf","xlsx"]}},{"name":"locale","in":"query","required":false,"description":"Language of the file, one of: es, en, de, fr, it, eu. Defaults to en.","schema":{"default":"en","type":"string","enum":["es","en","de","fr","it","eu"]}},{"name":"X-HS-Tenant","in":"header","required":false,"description":"The tenant to read, as its id (a UUID, any case). REQUIRED with a portfolio key (`hsp_…`); with a tenant API key or an OAuth token, optional and accepted only when it equals the credential’s own tenant. Never a slug.","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The file, as an attachment. At most 20971520 bytes; a larger one is a 409 `FILE_TOO_LARGE`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"X-HS-Tenant":{"description":"The id (lowercase UUID) of the tenant whose data this body holds, for every credential. Present on every 2xx answer of a data operation and on no error. A client that selected a tenant must refuse a 2xx whose echo is missing or different.","schema":{"type":"string","format":"uuid"}},"Content-Disposition":{"description":"`attachment; filename=\".\"`. The name is `[A-Za-z0-9._-]`.","schema":{"type":"string"}},"Content-Length":{"description":"Size of the file in bytes.","schema":{"type":"integer","minimum":0}}},"content":{"application/pdf":{"schema":{"type":"string","format":"binary"}},"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"A parameter is missing, malformed or out of range: `error.details.issues` names it. Or the `X-HS-Tenant` header was refused (`error.details.parameter` is `X-HS-Tenant`): `TENANT_HEADER_INVALID` (not exactly one UUID), `TENANT_REQUIRED` (a portfolio key on a data operation without it) or `TENANT_SELECTOR_NOT_SUPPORTED` (on `listPortfolioTenants`, or a tenant API key / OAuth token naming a tenant other than its own) in `error.details.code`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"No credential, or one that is unknown, malformed, revoked or expired (a tenant API key, an OAuth access token or a portfolio key; the cases are not distinguished), a portfolio key whose organisation is no longer an active MSSP, or a portfolio key used from an address outside its allowed ranges. The `WWW-Authenticate` header carries `resource_metadata`, the RFC 9728 document that names the authorization server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The credential authenticated but may not perform this operation. Either it does not carry the scope the operation requires (`error.details.code` is `INSUFFICIENT_SCOPE` and `error.details.missing` lists the scopes), or the tenant read (the credential’s own, or the one a portfolio key selected) is deactivated or holds no licence in force: none, suspended, revoked or expired, or a write during the grace period (`error.details.code` is `LICENCE_INACTIVE` and `error.details.licence` says which). Re-authenticating does not help with the second.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"No such resource in this tenant. With a portfolio key, also: the selected tenant does not exist or is not in this key’s portfolio (`error.details.code` is `TENANT_NOT_FOUND`; the two cases are one answer).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The file cannot be served as asked. `error.details.code` says why: `REPORT_NOT_READY` (the report is still `generating`, or `failed`; `error.details.status` says which. Retry a `generating` one later); `FORMAT_UNSUPPORTED` (the report’s content has no renderer for that format; `error.details.formats` lists the ones it has, possibly none); `FILE_TOO_LARGE` (the file is larger than 20971520 bytes; `error.details.limit_bytes` says the cap, and no byte is sent).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"A rate limit is exhausted. `Retry-After` says when to come back and `error.details.scope` which limit it was: `key`, `tenant` or `service`; with a portfolio key also `portfolio_key`, `portfolio_org` or `portfolio_tenant`.","headers":{"RateLimit-Limit":{"description":"Requests allowed in the window that is closest to being exhausted.","schema":{"type":"integer","minimum":1}},"RateLimit-Remaining":{"description":"Requests left in that window.","schema":{"type":"integer","minimum":0}},"RateLimit-Reset":{"description":"Seconds until that window renews.","schema":{"type":"integer","minimum":0}},"RateLimit-Policy":{"description":"`;w=`.","schema":{"type":"string"}},"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer","minimum":1}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Something failed on our side. `error.request_id` is what support needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"The service cannot verify credentials right now. Retry with backoff.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]},{"oauth2":["reports:read"]},{"portfolioKey":[]}],"x-hs-scope":{"scopes":["reports:read"]}}}},"components":{"schemas":{"Error":{"type":"object","required":["error"],"additionalProperties":false,"properties":{"error":{"type":"object","required":["code","message","request_id"],"properties":{"code":{"type":"string","enum":["invalid_request","unauthorized","forbidden","not_found","method_not_allowed","conflict","rate_limited","internal_error","service_unavailable"],"description":"Closed vocabulary; new members may be added (see Versioning)."},"message":{"type":"string","description":"For a human reading a log. Not stable; do not parse it."},"request_id":{"type":"string","description":"Also the `X-Request-Id` response header."},"details":{"type":"object","additionalProperties":true,"description":"Diagnostic, except `code` and the keys the operation’s error descriptions name, which are contract.","properties":{"code":{"type":"string","description":"OPEN vocabulary (a value keeps its meaning and its HTTP status; new values may be added): `TENANT_HEADER_INVALID`, `TENANT_REQUIRED`, `TENANT_SELECTOR_NOT_SUPPORTED` (400), `INSUFFICIENT_SCOPE`, `LICENCE_INACTIVE` (403), `TENANT_NOT_FOUND` (404), `REPORT_NOT_READY`, `FORMAT_UNSUPPORTED`, `FILE_TOO_LARGE` (409).","x-hs-known-values":["TENANT_HEADER_INVALID","TENANT_REQUIRED","TENANT_SELECTOR_NOT_SUPPORTED","INSUFFICIENT_SCOPE","LICENCE_INACTIVE","TENANT_NOT_FOUND","REPORT_NOT_READY","FORMAT_UNSUPPORTED","FILE_TOO_LARGE"]}}}}}}}},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"A tenant API key, created in the console under Settings → API keys with the minimum scopes it needs."},"portfolioKey":{"type":"http","scheme":"bearer","description":"An MSSP portfolio key (`hsp_…`), created by an organisation administrator of an active managed security service provider in the console’s MSSP area (API keys). It lists the provider’s client tenants (`portfolio:read`) and reads ONE of them per request, selected with `X-HS-Tenant`, with the same data scopes a tenant API key uses. Read-only; mandatory expiry (default 90 days, at most 365); optional client subset and source IP ranges. Refused with 401 everywhere but `/v1`."},"oauth2":{"type":"oauth2","description":"A USER access token (`hs_at_…`) issued by the HodeiShield console, the OAuth 2.1 authorization server at `https://app.hodeishield.com/api/auth` (metadata: `/.well-known/oauth-authorization-server` on its origin). Authorization code with PKCE (S256, mandatory) for applications with a browser; the RFC 8628 device authorization grant (`https://app.hodeishield.com/api/auth/device/code`) for those without one. Every token is bound to ONE user and ONE tenant, chosen by the user on the consent screen, and carries read scopes only; the tenant is re-checked against what the user can see on every request. Public clients register themselves (RFC 7591) at `/oauth2/register`; the HodeiShield CLI is pre-registered as `hodeishield-cli`. Revocation: RFC 7009 at `/oauth2/revoke`, or by the user in the console under Account → Application access.","flows":{"authorizationCode":{"authorizationUrl":"https://app.hodeishield.com/api/auth/oauth2/authorize","tokenUrl":"https://app.hodeishield.com/api/auth/oauth2/token","refreshUrl":"https://app.hodeishield.com/api/auth/oauth2/token","scopes":{"supply_risk:read":"Read supply_risk data of the tenant the token is bound to.","compliance.nis2:read":"Read compliance.nis2 data of the tenant the token is bound to.","compliance.ens:read":"Read compliance.ens data of the tenant the token is bound to.","compliance.iso27001:read":"Read compliance.iso27001 data of the tenant the token is bound to.","compliance.dora:read":"Read compliance.dora data of the tenant the token is bound to.","compliance.gdpr:read":"Read compliance.gdpr data of the tenant the token is bound to.","compliance.soc2:read":"Read compliance.soc2 data of the tenant the token is bound to.","compliance.iso42001:read":"Read compliance.iso42001 data of the tenant the token is bound to.","compliance.nistcsf:read":"Read compliance.nistcsf data of the tenant the token is bound to.","compliance.pcidss:read":"Read compliance.pcidss data of the tenant the token is bound to.","compliance.euaiact:read":"Read compliance.euaiact data of the tenant the token is bound to.","compliance.hipaa:read":"Read compliance.hipaa data of the tenant the token is bound to.","compliance.cmmc:read":"Read compliance.cmmc data of the tenant the token is bound to.","compliance.cyberessentials:read":"Read compliance.cyberessentials data of the tenant the token is bound to.","compliance.uaeia:read":"Read compliance.uaeia data of the tenant the token is bound to.","compliance.sama:read":"Read compliance.sama data of the tenant the token is bound to.","compliance.saudiecc:read":"Read compliance.saudiecc data of the tenant the token is bound to.","compliance.ndpa:read":"Read compliance.ndpa data of the tenant the token is bound to.","radar:read":"Read radar data of the tenant the token is bound to.","connectors:read":"Read connectors data of the tenant the token is bound to.","endpoints:read":"Read endpoints data of the tenant the token is bound to.","hodeiai:read":"Read hodeiai data of the tenant the token is bound to.","virtual_ciso:read":"Read virtual_ciso data of the tenant the token is bound to.","simulator:read":"Read simulator data of the tenant the token is bound to.","evidence:read":"Read evidence data of the tenant the token is bound to.","reports:read":"Read reports data of the tenant the token is bound to.","ai_governance:read":"Read ai_governance data of the tenant the token is bound to.","management:read":"Read management data of the tenant the token is bound to.","offline_access":"Issue a refresh token (30 days, rotated on every use)."}}}}}},"security":[{"bearerAuth":[]},{"oauth2":[]},{"portfolioKey":[]}]} \ No newline at end of file diff --git a/xtask/src/main.rs b/xtask/src/main.rs index b91e5c5..d7359ee 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -32,12 +32,17 @@ use std::process::{Command, ExitCode, Stdio}; const SPEC: &str = "openapi/v1.json"; const OUT: &str = "crates/hodeishield-api/src/generated.rs"; /// SHA-256 of `openapi/v1.json`. Updating the document means updating this and `openapi/README.md`. -const EXPECTED_SHA256: &str = "1321a417aba28e7b60dbc28a35ce2dc3711d8973b985107f7701d70ff3be6b0d"; +const EXPECTED_SHA256: &str = "8c9a44bc696584105f10b67d490a8816dabc26b07b80ed18369e6d3e498359c4"; /// Type names that the default naming rules would get wrong, keyed by the schema's location. const RENAMES: &[(&str, &str)] = &[ ("#/components/schemas/Error", "ErrorEnvelope"), ("#/components/schemas/Error/properties/error", "ErrorBody"), + ( + "whoAmI/responses/200/properties/data/properties/organization", + "WhoAmIOrganization", + ), + ("getReport/responses/200/properties/data", "ReportDetail"), ]; type Result = std::result::Result; @@ -184,6 +189,14 @@ struct Param { doc: Option, } +/// What a successful answer carries. +enum Response { + /// JSON, decoded into this generated type. + Json(String), + /// A file: the `Accept` value to send (the declared media types, then JSON for the errors). + Binary(String), +} + struct OperationDef { id: String, path: String, @@ -192,7 +205,7 @@ struct OperationDef { scopes: Vec, path_params: Vec, query_params: Vec, - response: String, + response: Response, /// Whether the operation takes the `X-HS-Tenant` header that selects a client tenant. tenant_scoped: bool, } @@ -379,6 +392,9 @@ impl<'a> Generator<'a> { .collect::, _>>()? } else if let Some(template) = scope.get("template").and_then(Value::as_str) { vec![template.to_owned()] + } else if scope.get("none").and_then(Value::as_bool) == Some(true) { + // Any valid credential may call it (`GET /v1/me`). + Vec::new() } else { return Err(format!("{id}: x-hs-scope has neither scopes nor template")); }; @@ -446,15 +462,19 @@ impl<'a> Generator<'a> { } } - let schema = operation - .pointer("/responses/200/content/application~1json/schema") - .ok_or_else(|| format!("{id}: no JSON 200 response"))?; - let response_name = format!("{}Response", pascal(&id)); - let (response, nullable) = - self.rust_type(schema, &response_name, &format!("{id}/responses/200"))?; - if nullable { - return Err(format!("{id}: a nullable 200 body is not supported")); - } + let response = if let Some(schema) = + operation.pointer("/responses/200/content/application~1json/schema") + { + let response_name = format!("{}Response", pascal(&id)); + let (response, nullable) = + self.rust_type(schema, &response_name, &format!("{id}/responses/200"))?; + if nullable { + return Err(format!("{id}: a nullable 200 body is not supported")); + } + Response::Json(response) + } else { + Response::Binary(binary_accept(&id, operation)?) + }; self.operations.push(OperationDef { id, path: path.to_owned(), @@ -849,20 +869,56 @@ impl<'a> Generator<'a> { } else { "params.query_pairs()" }; - let _ = writeln!( - out, - " pub fn {}({}) -> Result, crate::Error> {{\n self.get(&operations::{}, {path_value}, {query})\n }}\n", - snake(&op.id), - args.join(", "), - op.response, - screaming(&op.id), - ); + match &op.response { + Response::Json(ty) => { + let _ = writeln!( + out, + " pub fn {}({}) -> Result, crate::Error> {{\n self.get(&operations::{}, {path_value}, {query})\n }}\n", + snake(&op.id), + args.join(", "), + screaming(&op.id), + ); + } + Response::Binary(accept) => { + let _ = writeln!( + out, + " pub fn {}({}) -> Result {{\n self.get_binary(&operations::{}, {path_value}, {query}, {accept:?})\n }}\n", + snake(&op.id), + args.join(", "), + screaming(&op.id), + ); + } + } } out.push_str("}\n"); out } } +/// The `Accept` value of an operation whose 200 answer is a file: every media type it declares, each +/// with a `format: binary` string schema, then `application/json` for the error bodies. +fn binary_accept(id: &str, operation: &Value) -> Result { + let content = operation + .pointer("/responses/200/content") + .and_then(Value::as_object) + .filter(|content| !content.is_empty()) + .ok_or_else(|| format!("{id}: no 200 response content"))?; + let mut types = Vec::new(); + for (media_type, body) in content { + let schema = body.get("schema"); + let is_file = schema.and_then(|s| s.get("type")).and_then(Value::as_str) == Some("string") + && schema.and_then(|s| s.get("format")).and_then(Value::as_str) == Some("binary"); + if !is_file { + return Err(format!( + "{id}: 200 `{media_type}` is neither JSON nor a binary string" + )); + } + types.push(media_type.as_str()); + } + types.push("application/json"); + Ok(types.join(", ")) +} + fn render_doc(out: &mut String, doc: Option<&str>, indent: &str) { let Some(doc) = doc else { return }; for line in doc.trim().lines() { @@ -1326,4 +1382,37 @@ mod tests { "listThings.X-Other: parameters in `header` are not supported" ); } + + #[test] + fn an_operation_that_needs_no_scope_lists_none() { + let mut spec = document(None); + spec["paths"]["/v1/things"]["get"]["x-hs-scope"] = serde_json::json!({"none": true}); + let out = generate_from(&spec).expect("generate"); + assert!(out.contains("scopes: &[],"), "{out}"); + spec["paths"]["/v1/things"]["get"]["x-hs-scope"] = serde_json::json!({"none": false}); + assert_eq!( + generate_from(&spec).expect_err("not none"), + "listThings: x-hs-scope has neither scopes nor template" + ); + } + + #[test] + fn a_file_answer_is_a_download_with_its_media_types_then_json() { + let mut spec = document(None); + spec["paths"]["/v1/things"]["get"]["responses"]["200"]["content"] = serde_json::json!({ + "application/pdf": {"schema": {"type": "string", "format": "binary"}}, + "text/csv": {"schema": {"type": "string", "format": "binary"}} + }); + let out = generate_from(&spec).expect("generate"); + assert!( + out.contains("-> Result { self.get_binary(&operations::LIST_THINGS, \"/v1/things\".to_owned(), params.query_pairs(), \"application/pdf, text/csv, application/json\") }"), + "{out}" + ); + spec["paths"]["/v1/things"]["get"]["responses"]["200"]["content"] = + serde_json::json!({"text/plain": {"schema": {"type": "string"}}}); + assert_eq!( + generate_from(&spec).expect_err("not a file"), + "listThings: 200 `text/plain` is neither JSON nor a binary string" + ); + } } From 71d19625b7f672300b054400f280c7a14065dc30 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:41:39 +0200 Subject: [PATCH 14/25] Install with Homebrew by the formula's full name Homebrew now refuses to load a formula from a tap that is not trusted, and adding a tap does not trust it, so the documented brew tap then brew install hodeishield fails. Installing the fully qualified name adds the tap and trusts only this formula. The README says so and how to trust the formula when the tap was added earlier. Refs #179 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ README.md | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b39d7f..ed954ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ commands, `--json` output and exit codes. Each release's notes start with its se ## [Unreleased] +### Fixed + +- The README installs with Homebrew by the full name, `brew install hodeitek/hodeishield/hodeishield`, + which adds the tap and trusts only this formula. Homebrew now refuses a formula from a tap that is + not trusted, so the earlier `brew tap` then `brew install hodeishield` failed. The README also says + how to trust the formula when the tap was added before. + ([#179](https://github.com/Hodeitek/hodeishield-cli/issues/179)) + ## [0.4.4] - 2026-10-15 ### Security diff --git a/README.md b/README.md index c8460f9..2dc729c 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ cargo install --locked --git https://github.com/Hodeitek/hodeishield-cli --tag v ### Homebrew and Scoop ```sh -brew tap hodeitek/hodeishield && brew install hodeishield # macOS and Linux +brew install hodeitek/hodeishield/hodeishield # macOS and Linux ``` ```powershell @@ -81,6 +81,12 @@ scoop install hodeishield # Windows, per u 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. +Homebrew asks you to trust a formula from a tap other than its own before it loads it +([Tap Trust](https://docs.brew.sh/Tap-Trust)). The full name above adds the tap and trusts only +this formula. If you added the tap before and see "Refusing to load formula … from untrusted +tap", run `brew trust --formula hodeitek/hodeishield/hodeishield` once. There is no need to trust +the whole tap or to turn tap trust off. + The [Homebrew tap](https://github.com/Hodeitek/homebrew-hodeishield) and the [Scoop bucket](https://github.com/Hodeitek/scoop-hodeishield) are updated from each release's own files and `SHA256SUMS` when it is published, and both check those hashes when installing. Homebrew From 0c67c0c24cb9ccc223bdf22f31d8078f24e61846 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:45:54 +0200 Subject: [PATCH 15/25] Show the credential's tenant in whoami whoami reads GET /v1/me and shows the tenant the credential reads (name and id), its organisation and, for a key, its scopes; for a portfolio key, the MSSP and the number of client tenants. --json gains the tenant, organization, portfolio and credential_scopes fields. When the API cannot be read, whoami still prints the local lines, the new fields are null and a warning says why; the exit code does not change. Names from the API are cleaned before they are printed. Closes #48 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- CHANGELOG.md | 6 + README.md | 6 + .../hodeishield-cli/src/commands/session.rs | 180 +++++++++++++++++- crates/hodeishield-cli/tests/contract.rs | 62 +++++- 4 files changed, 245 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c058f..a6962a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ commands, `--json` output and exit codes. Each release's notes start with its se - The generated `/v1` client follows version 2026-10-19 of the API document, which adds `GET /v1/me` and the reports operations, including the download of a report file. `hodeishield login` now also asks for `reports:read` when the app offers it. +- `whoami` shows the tenant the credential reads (name and id), its organisation and, for a key, its + scopes; for a portfolio key, the MSSP and the number of client tenants. It reads them from the + new `GET /v1/me`, so it now makes one API call. `--json` gains the `tenant`, `organization`, + `portfolio` and `credential_scopes` fields, null when the API cannot be read; the exit code does + not depend on that call. + ([#48](https://github.com/Hodeitek/hodeishield-cli/issues/48)) ## [0.4.4] - 2026-10-15 diff --git a/README.md b/README.md index c8460f9..f1f5dfe 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,12 @@ hodeishield whoami When `HODEISHIELD_API_KEY` is set it is used for every call, whatever else is configured. Keep it in your CI's secret store; the CLI never prints it. +`whoami` asks the API who the credential is and shows the tenant it reads (name and id), its +organisation and, for a key, its scopes; for a portfolio key, the MSSP and how many client tenants it +can select. With `--json` these are the `tenant`, `organization`, `portfolio` and +`credential_scopes` fields. If the API cannot be reached, `whoami` still shows the local settings, +leaves those fields null and says why on standard error. + ### By signing in (people) ```sh diff --git a/crates/hodeishield-cli/src/commands/session.rs b/crates/hodeishield-cli/src/commands/session.rs index 2627449..f5b7a07 100644 --- a/crates/hodeishield-cli/src/commands/session.rs +++ b/crates/hodeishield-cli/src/commands/session.rs @@ -9,6 +9,7 @@ use crate::auth::{self, StoredToken, loopback::Loopback}; use crate::cli::LoginArgs; use crate::failure::{CredentialSource, Failure, Result}; use crate::output::{clean, print_json, unix_to_rfc3339}; +use hodeishield_api::v1::WhoAmI; use serde_json::{Map, Value, json}; use std::io::Write; use std::time::{Duration, Instant}; @@ -212,22 +213,38 @@ fn note_api_key() { pub fn whoami(ctx: &Context, out: &mut dyn Write) -> Result<()> { let settings = ctx.settings()?; + // `GET /v1/me` describes the credential itself: it takes no tenant selection. + let me = || -> std::result::Result { + let (client, source) = ctx.api(&settings, None).map_err(|f| f.message)?; + client + .who_am_i() + .map(|answer| answer.data.data) + .map_err(|e| { + let mut call = ctx.call(source, None); + call.tenant_selected = false; + crate::failure::from_api(e, &call).message + }) + }; whoami_with( &settings, auth::api_key_from_env().is_some(), ctx.store.as_ref(), ctx.json, + &me, out, ) } -/// `whoami` with the settings and whether an API key is set given, so it can be tested without the -/// process environment. With a key, the store is not read. +/// `whoami` with the settings, whether an API key is set and the call to `GET /v1/me` given, so it +/// can be tested without the process environment. With a key, the store is not read. The call is +/// made only when there is a credential; when it fails, the local lines are still printed, the +/// API's lines are left out (null in `--json`) and a warning says why. fn whoami_with( settings: &crate::config::Settings, has_api_key: bool, store: &dyn auth::TokenStore, json: bool, + me: &dyn Fn() -> std::result::Result, out: &mut dyn Write, ) -> Result<()> { let mut info = Map::new(); @@ -271,6 +288,30 @@ fn whoami_with( CredentialSource::OAuth => "oauth", })), ); + let me = match source.map(|_| me()) { + Some(Ok(me)) => Some(me), + Some(Err(reason)) => { + eprintln!("warning: could not read from the API who this credential is: {reason}"); + None + } + None => None, + }; + info.insert( + "tenant".into(), + json!(me.as_ref().and_then(|me| me.tenant.as_ref())), + ); + info.insert( + "organization".into(), + json!(me.as_ref().map(|me| &me.organization)), + ); + info.insert( + "portfolio".into(), + json!(me.as_ref().and_then(|me| me.portfolio.as_ref())), + ); + info.insert( + "credential_scopes".into(), + json!(me.as_ref().map(|me| &me.credential.scopes)), + ); if json { print_json(out, &Value::Object(info))?; @@ -297,6 +338,9 @@ fn whoami_with( rows.push((label, clean(value))); } } + if let Some(me) = &me { + rows.extend(me_rows(me, source == Some(CredentialSource::ApiKey))); + } for (label, value) in rows { writeln!(out, "{label:<11} {value}")?; } @@ -307,6 +351,44 @@ fn whoami_with( } } +/// The table lines `GET /v1/me` adds: the tenant (tenant key or sign-in), the organisation, the +/// number of client tenants (portfolio key) and, for a key, its scopes (a sign-in already shows +/// the token's). Names come from the API and are cleaned before they are printed. +fn me_rows(me: &WhoAmI, is_key: bool) -> Vec<(&'static str, String)> { + let mut rows = Vec::new(); + if let Some(tenant) = &me.tenant { + rows.push(( + "Tenant", + format!("{} ({})", clean(&tenant.name), clean(&tenant.id)), + )); + } + let organization = &me.organization; + rows.push(( + "Org", + format!( + "{} ({})", + organization + .name + .as_deref() + .map_or_else(|| "-".to_owned(), clean), + clean(&organization.r#type) + ), + )); + if let Some(portfolio) = &me.portfolio { + rows.push(( + "Tenants", + format!( + "{} client tenants (hodeishield tenants list)", + portfolio.tenant_count + ), + )); + } + if is_key { + rows.push(("Scopes", clean(&me.credential.scopes.join(" ")))); + } + rows +} + /// Revokes a stored sign-in at the app that issued it, never at whatever the profile points to /// now: the tokens themselves travel in the requests. /// @@ -692,8 +774,15 @@ mod tests { let (api, app) = (mockito::Server::new(), mockito::Server::new()); let settings = settings_for(&api, &app); let mut out = Vec::new(); - let err = whoami_with(&settings, false, &UnreachableStore, false, &mut out) - .expect_err("no credential"); + let err = whoami_with( + &settings, + false, + &UnreachableStore, + false, + &no_call, + &mut out, + ) + .expect_err("no credential"); assert_eq!(err.kind, crate::failure::Kind::NotAuthenticated); assert!( err.message @@ -716,8 +805,87 @@ mod tests { let (api, app) = (mockito::Server::new(), mockito::Server::new()); let settings = settings_for(&api, &app); let mut out = Vec::new(); - whoami_with(&settings, true, &UnreachableStore, true, &mut out).expect("key set"); - assert!(String::from_utf8(out).expect("utf8").contains("api_key")); + let me = || Err("offline".to_owned()); + whoami_with(&settings, true, &UnreachableStore, true, &me, &mut out).expect("key set"); + let printed: Value = serde_json::from_slice(&out).expect("json"); + assert_eq!(printed["credential"], "api_key"); + // The API could not be read: its fields are there, null. + for key in ["tenant", "organization", "portfolio", "credential_scopes"] { + assert_eq!(printed[key], Value::Null, "{key}"); + } + } + + fn no_call() -> std::result::Result { + panic!("GET /v1/me is not called without a credential") + } + + fn who(json: &str) -> WhoAmI { + serde_json::from_str(json).expect("WhoAmI") + } + + #[test] + fn whoami_shows_the_tenant_and_organisation_of_a_tenant_key() { + let (api, app) = (mockito::Server::new(), mockito::Server::new()); + let settings = settings_for(&api, &app); + let me = || { + Ok(who( + r#"{"credential":{"type":"tenant","prefix":null,"scopes":["supply_risk:read","evidence:read"]}, + "tenant":{"id":"6f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f","name":"Acme\u001b[31m S.L.","type":"direct"}, + "organization":{"id":"7a1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f","name":"Acme Group","type":"direct"}, + "portfolio":null}"#, + )) + }; + let mut out = Vec::new(); + whoami_with(&settings, true, &UnreachableStore, false, &me, &mut out).expect("key set"); + let text = String::from_utf8(out).expect("utf8"); + assert!( + text.contains( + "Tenant Acme\u{fffd}[31m S.L. (6f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f)\n" + ), + "{text}" + ); + assert!(!text.contains('\u{1b}'), "the name is cleaned: {text:?}"); + assert!(text.contains("Org Acme Group (direct)\n"), "{text}"); + assert!( + text.contains("Scopes supply_risk:read evidence:read\n"), + "{text}" + ); + assert!(!text.contains("Tenants"), "{text}"); + + let mut out = Vec::new(); + whoami_with(&settings, true, &UnreachableStore, true, &me, &mut out).expect("key set"); + let printed: Value = serde_json::from_slice(&out).expect("json"); + assert_eq!( + printed["tenant"]["id"], + "6f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f" + ); + assert_eq!(printed["tenant"]["type"], "direct"); + assert_eq!(printed["organization"]["name"], "Acme Group"); + assert_eq!(printed["portfolio"], Value::Null); + assert_eq!(printed["credential_scopes"][1], "evidence:read"); + } + + #[test] + fn whoami_shows_the_mssp_and_the_tenant_count_of_a_portfolio_key() { + let (api, app) = (mockito::Server::new(), mockito::Server::new()); + let settings = settings_for(&api, &app); + let me = || { + Ok(who( + r#"{"credential":{"type":"portfolio","prefix":"hsp_3f2a9c0d1b4e","scopes":["portfolio:read","supply_risk:read"]}, + "tenant":null, + "organization":{"id":"7a1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f","name":null,"type":"mssp"}, + "portfolio":{"tenant_count":12,"tenants_path":"/v1/portfolio/tenants"}}"#, + )) + }; + let mut out = Vec::new(); + whoami_with(&settings, true, &UnreachableStore, false, &me, &mut out).expect("key set"); + let text = String::from_utf8(out).expect("utf8"); + assert!(!text.contains("Tenant "), "{text}"); + assert!(text.contains("Org - (mssp)\n"), "{text}"); + assert!( + text.contains("Tenants 12 client tenants (hodeishield tenants list)\n"), + "{text}" + ); } const DEVICE: LoginArgs = LoginArgs { diff --git a/crates/hodeishield-cli/tests/contract.rs b/crates/hodeishield-cli/tests/contract.rs index 65ebd8b..5e90d25 100644 --- a/crates/hodeishield-cli/tests/contract.rs +++ b/crates/hodeishield-cli/tests/contract.rs @@ -433,18 +433,74 @@ fn json_posture_is_the_api_body_with_unknown_fields_passed_through() { ); } -/// `whoami --json` is an object with these fields, in this order (an API key has no more). +/// `whoami --json` is an object with these fields, in this order (an API key has no more). The last +/// four come from `GET /v1/me` as the API sends them. #[test] fn json_whoami_fields() { let env = Env::new(); - let server = mockito::Server::new(); + let mut server = mockito::Server::new(); + let me = server + .mock("GET", "/v1/me") + .match_header("x-hs-tenant", mockito::Matcher::Missing) + .with_body( + r#"{"data":{"credential":{"type":"tenant","prefix":null,"scopes":["supply_risk:read"]}, + "tenant":{"id":"6f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f","name":"Acme S.L.","type":"direct"}, + "organization":{"id":"7a1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f","name":"Acme Group","type":"direct"}, + "portfolio":null}}"#, + ) + .expect(1) + .create(); let output = env.api(&server.url(), &["--json", "whoami"]); assert_eq!(output.status.code(), Some(0), "{output:?}"); let printed: Value = serde_json::from_str(&out(&output)).expect("json"); let keys: Vec<&String> = printed.as_object().expect("object").keys().collect(); - assert_eq!(keys, ["profile", "api_url", "app_url", "credential"]); + assert_eq!( + keys, + [ + "profile", + "api_url", + "app_url", + "credential", + "tenant", + "organization", + "portfolio", + "credential_scopes" + ] + ); assert_eq!(printed["credential"], json!("api_key")); assert_eq!(printed["api_url"], json!(format!("{}/", server.url()))); + assert_eq!( + printed["tenant"], + json!({"id": "6f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f", "name": "Acme S.L.", "type": "direct"}) + ); + assert_eq!(printed["organization"]["name"], json!("Acme Group")); + assert_eq!(printed["portfolio"], Value::Null); + assert_eq!(printed["credential_scopes"], json!(["supply_risk:read"])); + me.assert(); +} + +/// When `GET /v1/me` fails, `whoami` still succeeds with the local fields; the API's are null and +/// standard error says why. +#[test] +fn json_whoami_without_the_api() { + let env = Env::new(); + let mut server = mockito::Server::new(); + server + .mock("GET", "/v1/me") + .with_status(401) + .with_body(r#"{"error":{"code":"UNAUTHORIZED","message":"Invalid API key.","request_id":"req-1"}}"#) + .create(); + let output = env.api(&server.url(), &["--json", "whoami"]); + assert_eq!(output.status.code(), Some(0), "{output:?}"); + let printed: Value = serde_json::from_str(&out(&output)).expect("json"); + for key in ["tenant", "organization", "portfolio", "credential_scopes"] { + assert_eq!(printed[key], Value::Null, "{key}"); + } + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("warning: could not read from the API who this credential is:"), + "{stderr}" + ); } // --------------------------------------------------------------------------------------------- From 6bc74eb54cd7fa9371800be1c20f97b635a42281 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:47:47 +0200 Subject: [PATCH 16/25] Clean the API's text in the whoami warning The reason a GET /v1/me call failed can carry the API's own message, which is untrusted; the warning printed it as received. It now goes through clean(), like every other API string the CLI prints, and the contract test checks that no escape sequence reaches the terminal. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- crates/hodeishield-cli/src/commands/session.rs | 6 +++++- crates/hodeishield-cli/tests/contract.rs | 11 +++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/hodeishield-cli/src/commands/session.rs b/crates/hodeishield-cli/src/commands/session.rs index f5b7a07..6380896 100644 --- a/crates/hodeishield-cli/src/commands/session.rs +++ b/crates/hodeishield-cli/src/commands/session.rs @@ -291,7 +291,11 @@ fn whoami_with( let me = match source.map(|_| me()) { Some(Ok(me)) => Some(me), Some(Err(reason)) => { - eprintln!("warning: could not read from the API who this credential is: {reason}"); + // The reason can carry the API's own message: untrusted text. + eprintln!( + "warning: could not read from the API who this credential is: {}", + clean(&reason) + ); None } None => None, diff --git a/crates/hodeishield-cli/tests/contract.rs b/crates/hodeishield-cli/tests/contract.rs index 5e90d25..5bd055a 100644 --- a/crates/hodeishield-cli/tests/contract.rs +++ b/crates/hodeishield-cli/tests/contract.rs @@ -487,8 +487,10 @@ fn json_whoami_without_the_api() { let mut server = mockito::Server::new(); server .mock("GET", "/v1/me") - .with_status(401) - .with_body(r#"{"error":{"code":"UNAUTHORIZED","message":"Invalid API key.","request_id":"req-1"}}"#) + .with_status(404) + .with_body( + r#"{"error":{"code":"NOT_FOUND","message":"Gone\u001b]0;pwned\u0007 away.","request_id":"req-1"}}"#, + ) .create(); let output = env.api(&server.url(), &["--json", "whoami"]); assert_eq!(output.status.code(), Some(0), "{output:?}"); @@ -501,6 +503,11 @@ fn json_whoami_without_the_api() { stderr.contains("warning: could not read from the API who this credential is:"), "{stderr}" ); + // The API's message is untrusted: no escape sequence reaches the terminal. + assert!( + !stderr.contains('\u{1b}') && !stderr.contains('\u{7}'), + "{stderr:?}" + ); } // --------------------------------------------------------------------------------------------- From a7af2c1a9ae2f08a0becf05f560fec4466917388 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:02:42 +0200 Subject: [PATCH 17/25] Add the reports commands: list, get and download reports list filters by --type (also --report-type), --status and --since, with --sort, paging, --json, --csv, --tenant and --all-tenants like the other lists; the listed type and status values are shown in --help and an unlisted one gets a warning. reports get shows a report with the formats it can be downloaded in. reports download saves a report's file (--format pdf|xlsx, --locale) in the current directory under the name the API suggests, reduced to a plain file name (last component only, [A-Za-z0-9._-], no leading dots or dashes, no Windows device names), or report-. when nothing usable is left. --output saves it at a path or into a directory; --output - writes it to standard output and is refused when that is a terminal. The file goes to a temporary file in the same directory and is renamed into place; without --force the destination is first claimed with an exclusive create, so an existing file is never replaced. With --json, standard output holds the path, size and content type. The 409 answers of the download (REPORT_NOT_READY, FORMAT_UNSUPPORTED, FILE_TOO_LARGE) get their own messages and hints, still with exit code 1. All server text printed on either stream is cleaned. Closes #55 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- CHANGELOG.md | 12 + README.md | 39 +- crates/hodeishield-cli/src/cli.rs | 95 +++- crates/hodeishield-cli/src/commands/mod.rs | 2 + .../hodeishield-cli/src/commands/resources.rs | 152 +++++- crates/hodeishield-cli/src/commands/save.rs | 240 +++++++++ crates/hodeishield-cli/src/failure.rs | 60 +++ crates/hodeishield-cli/tests/cli.rs | 454 +++++++++++++++++- crates/hodeishield-cli/tests/contract.rs | 55 ++- docs/risk-assessment.md | 7 +- docs/user-information.md | 4 +- 11 files changed, 1103 insertions(+), 17 deletions(-) create mode 100644 crates/hodeishield-cli/src/commands/save.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a6962a0..2197c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ commands, `--json` output and exit codes. Each release's notes start with its se ## [Unreleased] +### Added + +- `reports list`, `reports get` and `reports download`: the reports generated for the tenant, and + their files. `reports list` filters by `--type`, `--status` and `--since` and takes the usual + paging, `--json` and `--csv`; `reports get` shows the formats a report can be downloaded in. + `reports download` saves a completed report as PDF or XLSX (`--format`, `--locale`) in the current + directory under the name the API suggests, reduced to a plain file name, or at `--output `; + `--output -` writes it to standard output when that is not a terminal. An existing file is only + replaced with `--force`, and a file is never left half written. A report that is still being + generated, failed, has no file in that format or is too large fails with a message that says so. + ([#55](https://github.com/Hodeitek/hodeishield-cli/issues/55)) + ### Changed - `risks list --domain` and `--status` and `compliance controls --status` now list the values the diff --git a/README.md b/README.md index f1f5dfe..ba5153b 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ With `--device`, open the address the CLI prints on any device and **type the co does not accept a link with the code filled in, on purpose. The CLI asks for the read scopes its commands need (vendors, alerts and risks; every compliance -framework the app offers; evidence; endpoints) and for a refresh token. To ask for fewer, set them: +framework the app offers; evidence; endpoints; reports) and for a refresh token. To ask for fewer, set them: `hodeishield config set oauth_scopes "supply_risk:read offline_access"`. The token is kept in the system keychain (macOS Keychain, Windows Credential Manager, or the Secret @@ -351,6 +351,7 @@ hodeishield compliance posture nis2 hodeishield compliance controls --framework nis2 --status failing hodeishield evidence list --expiring-before 2026-12-31T00:00:00Z hodeishield endpoints list --status active +hodeishield reports list --type monthly --status completed ``` ```console @@ -365,6 +366,39 @@ rest. `--all` stops with an error past 100,000 items (or 1,000 pages); narrow th or walk it with `--page`. Every list takes `--sort` and `--order`; `--help` on any command lists what it accepts. +### Reports + +`reports list` and `reports get` show the reports generated for the tenant; `reports download` +saves a completed report's file: + +```console +$ hodeishield reports list --status completed +ID TYPE TITLE PERIOD STATUS CREATED +7d3e1f2a-4b5c-4d6e-8f70-81a2b3c4d5e6 monthly September summary 2026-09-01 to 2026-09-30 completed 2026-10-01 06:00Z +$ hodeishield reports get 7d3e1f2a-4b5c-4d6e-8f70-81a2b3c4d5e6 # includes download_formats +$ hodeishield reports download 7d3e1f2a-4b5c-4d6e-8f70-81a2b3c4d5e6 --format xlsx --locale es +Saved monthly-2026-09.xlsx (48213 bytes). +``` + +- `--type` (also `--report-type`) and `--status` list the values the API publishes in `--help`; like + the other listed filters, a value that is not listed gets a warning and is still sent as typed. +- `--format` is `pdf` (the default) or `xlsx`, and `--locale` one of `es`, `en` (the default), `de`, + `fr`, `it` and `eu`. `reports get` lists the formats a report can be downloaded in. +- Without `--output`, the file is saved in the current directory under the name the API suggests, + reduced to a plain file name: only its last part is kept, every character other than a letter, a + digit, `.`, `_` and `-` becomes `_`, and leading dots are dropped. When nothing usable is left, + the name is `report-.`. The name is never used as a path. +- `--output ` saves it there instead; a directory saves it there under the suggested name. + `--output -` writes the file to standard output, and is refused (exit code 2) when standard + output is a terminal. +- An existing file is never replaced unless `--force` is given. The file is written next to its + destination and then renamed into place, so the path never holds part of a file. +- The note `Saved ().` goes to standard error. With `--json`, standard output holds + `{"path": ..., "bytes": ..., "content_type": ...}` instead, and there is no note. +- A report that is still being generated, that failed, that has no file in that format, or whose file + is larger than the API serves (20 MiB) fails with exit code 1 and says which. Each download is + recorded in the tenant's audit trail. + ### Several tenants (MSSP) An MSSP reads all of its client tenants with **one** credential: a **portfolio key**, created in the @@ -413,7 +447,8 @@ hodeishield --tenant acme vendors list # one client, by slug or id hodeishield --all-tenants alerts list --status open ``` -- `--tenant ` works on every read command (`list`, `get`, `compliance posture`). The CLI +- `--tenant ` works on every read command (`list`, `get`, `compliance posture`, + `reports download`). The CLI prints `Client tenant: acme ()` on standard error, so a log shows which client was read; standard output is exactly what it is without a selection. - `--all-tenants` works on `list` commands and `compliance controls`. It fetches every page of each diff --git a/crates/hodeishield-cli/src/cli.rs b/crates/hodeishield-cli/src/cli.rs index 9f52e3d..4464467 100644 --- a/crates/hodeishield-cli/src/cli.rs +++ b/crates/hodeishield-cli/src/cli.rs @@ -10,8 +10,10 @@ use hodeishield_api::ParseEnumError; use hodeishield_api::v1::operations; use hodeishield_api::v1::{ AlertSort, AlertStatus, BusinessCriticality, ComplianceControlSort, EndpointSort, - EndpointStatus, EvidenceSort, Order, RiskSort, VendorSort, + EndpointStatus, EvidenceSort, Format, Locale, Order, ReportSort, RiskSort, VendorSort, }; +use std::io::IsTerminal; +use std::path::PathBuf; use std::str::FromStr; const AFTER_HELP: &str = "\ @@ -108,6 +110,9 @@ pub enum Command { /// Enrolled endpoint agents. #[command(subcommand)] Endpoints(EndpointsCommand), + /// Reports generated for the tenant, and their files. + #[command(subcommand)] + Reports(ReportsCommand), /// Client tenants a portfolio (MSSP) credential may read. #[command(subcommand)] Tenants(TenantsCommand), @@ -168,6 +173,15 @@ impl Cli { .error(clap::error::ErrorKind::ArgumentConflict, message) .exit(); } + if cli.command.downloads_to_stdout() && std::io::stdout().is_terminal() { + Self::command() + .error( + clap::error::ErrorKind::InvalidValue, + "'--output -' writes the file to standard output, which is a terminal here; \ + redirect it to a file or a pipe, or give a path", + ) + .exit(); + } cli } @@ -214,6 +228,7 @@ impl Cli { self.json || matches!(self.command, Command::Completions { .. }) || self.command.paging().is_some_and(|paging| paging.csv) + || self.command.downloads_to_stdout() } } @@ -228,9 +243,15 @@ impl Command { | Self::Compliance(_) | Self::Evidence(_) | Self::Endpoints(_) + | Self::Reports(_) ) } + /// Whether this is a download whose file goes to standard output (`--output -`). + fn downloads_to_stdout(&self) -> bool { + matches!(self, Self::Reports(ReportsCommand::Download(download)) if download.to_stdout()) + } + /// The paging options, for the commands that list. fn paging(&self) -> Option<&Paging> { match self { @@ -240,6 +261,7 @@ impl Command { Self::Compliance(ComplianceCommand::Controls(list)) => Some(&list.paging), Self::Evidence(EvidenceCommand::List(list)) => Some(&list.paging), Self::Endpoints(EndpointsCommand::List(list)) => Some(&list.paging), + Self::Reports(ReportsCommand::List(list)) => Some(&list.paging), Self::Tenants(TenantsCommand::List(list)) => Some(&list.paging), _ => None, } @@ -464,6 +486,77 @@ pub struct EndpointsList { pub paging: Paging, } +#[derive(Debug, Subcommand)] +pub enum ReportsCommand { + /// List generated reports, newest first. + List(ReportsList), + /// Show one report, with the formats it can be downloaded in. + Get { + /// Report id. + id: String, + }, + /// Download a report's file (PDF or XLSX). + #[command(long_about = DOWNLOAD_ABOUT)] + Download(ReportsDownload), +} + +const DOWNLOAD_ABOUT: &str = "\ +Download a report's file (PDF or XLSX). Only a completed report has one; `reports get` lists the \ +formats it can be downloaded in. + +By default the file is saved in the current directory under the name the API suggests, reduced to \ +letters, digits, `.`, `_` and `-` (or report-. when nothing usable is left). An existing \ +file is never replaced without --force, and the file appears in full or not at all."; + +#[derive(Debug, Args)] +pub struct ReportsList { + /// Exact report type. + #[arg( + long = "type", + visible_alias = "report-type", + value_name = "TYPE", + help = filters::help("Exact report type.", &operations::LIST_REPORTS, "report_type") + )] + pub report_type: Option, + /// Exact status. + #[arg(long, help = filters::help("Exact status.", &operations::LIST_REPORTS, "status"))] + pub status: Option, + /// Only reports created at or after this RFC 3339 instant, e.g. 2026-01-31T00:00:00Z. + #[arg(long, value_name = "INSTANT")] + pub since: Option, + /// Field to order by. + #[arg(long, value_parser = choice::(ReportSort::VALUES))] + pub sort: Option, + #[command(flatten)] + pub paging: Paging, +} + +#[derive(Debug, Args)] +pub struct ReportsDownload { + /// Report id. + pub id: String, + /// File format; the API sends pdf when it is not given. + #[arg(long, value_parser = choice::(Format::VALUES))] + pub format: Option, + /// Language of the file; the API uses en when it is not given. + #[arg(long, value_parser = choice::(Locale::VALUES))] + pub locale: Option, + /// Where to save the file: a file path, a directory to save it in under the suggested name, or + /// `-` for standard output (not a terminal). + #[arg(long, short, value_name = "PATH")] + pub output: Option, + /// Replace the file if it exists. + #[arg(long)] + pub force: bool, +} + +impl ReportsDownload { + /// Whether the file goes to standard output (`--output -`). + pub fn to_stdout(&self) -> bool { + self.output.as_deref() == Some(std::path::Path::new("-")) + } +} + #[derive(Debug, Subcommand)] pub enum ConfigCommand { /// Print the path of the configuration file. diff --git a/crates/hodeishield-cli/src/commands/mod.rs b/crates/hodeishield-cli/src/commands/mod.rs index 0174e5e..045703c 100644 --- a/crates/hodeishield-cli/src/commands/mod.rs +++ b/crates/hodeishield-cli/src/commands/mod.rs @@ -5,6 +5,7 @@ mod config; mod resources; +mod save; mod session; mod tenants; @@ -134,6 +135,7 @@ pub fn run(cli: Cli, out: &mut dyn Write) -> Result<()> { Command::Compliance(command) => resources::compliance(&ctx, command, out), Command::Evidence(command) => resources::evidence(&ctx, command, out), Command::Endpoints(command) => resources::endpoints(&ctx, command, out), + Command::Reports(command) => resources::reports(&ctx, command, out), Command::Tenants(command) => tenants::run(&ctx, command, out), Command::Login(args) => session::login(&ctx, &args), Command::Logout => session::logout(&ctx), diff --git a/crates/hodeishield-cli/src/commands/resources.rs b/crates/hodeishield-cli/src/commands/resources.rs index 4ab60ba..76eaad3 100644 --- a/crates/hodeishield-cli/src/commands/resources.rs +++ b/crates/hodeishield-cli/src/commands/resources.rs @@ -4,10 +4,11 @@ //! The read commands: one per `/v1` collection. use super::Context; +use super::save; use super::tenants::{Selection, Target}; use crate::cli::{ - AlertsCommand, ComplianceCommand, EndpointsCommand, EvidenceCommand, Paging, RisksCommand, - VendorsCommand, + AlertsCommand, ComplianceCommand, EndpointsCommand, EvidenceCommand, Paging, ReportsCommand, + ReportsDownload, RisksCommand, VendorsCommand, }; use crate::failure::{ApiContext, CredentialSource, Failure, Kind, Result, from_api}; use crate::filters::warn_unknown; @@ -18,9 +19,9 @@ use crate::output::{ use hodeishield_api::Client; use hodeishield_api::portfolio::Tenant; use hodeishield_api::v1::{ - Alert, ComplianceControl, Endpoint, Evidence, ListAlertsParams, ListComplianceControlsParams, - ListEndpointsParams, ListEvidenceParams, ListRisksParams, ListVendorsParams, Pagination, Risk, - Vendor, operations, + Alert, ComplianceControl, DownloadReportParams, Endpoint, Evidence, Format, ListAlertsParams, + ListComplianceControlsParams, ListEndpointsParams, ListEvidenceParams, ListReportsParams, + ListRisksParams, ListVendorsParams, Pagination, Report, Risk, Vendor, operations, }; use hodeishield_api::{ApiResponse, Error}; use serde::Serialize; @@ -728,6 +729,147 @@ fn endpoint_table(items: &[Endpoint]) -> Rows { ) } +pub fn reports(ctx: &Context, command: ReportsCommand, out: &mut dyn Write) -> Result<()> { + let settings = ctx.settings()?; + match command { + ReportsCommand::List(args) => { + warn_unknown( + &operations::LIST_REPORTS, + &[ + ("report_type", args.report_type.as_deref()), + ("status", args.status.as_deref()), + ], + ); + let (targets, source) = ctx.targets(&settings)?; + let base = ListReportsParams { + report_type: args.report_type, + status: args.status, + since: args.since, + sort: args.sort, + order: args.paging.order, + ..ListReportsParams::default() + }; + list( + ctx, + out, + &args.paging, + "reports", + plain(ctx, source), + &targets, + &mut |client, page, per_page| { + client.list_reports(&ListReportsParams { + page, + per_page, + ..base.clone() + }) + }, + |r| (r.data, r.pagination), + report_table, + ) + } + ReportsCommand::Get { id } => { + let (client, source) = ctx.single(&settings)?; + show(ctx, out, plain(ctx, source), client.get_report(&id), |r| { + &r.data + }) + } + ReportsCommand::Download(args) => download(ctx, &settings, &args, out), + } +} + +fn report_table(items: &[Report]) -> Rows { + Rows::new( + &["ID", "TYPE", "TITLE", "PERIOD", "STATUS", "CREATED"], + items + .iter() + .map(|r| { + vec![ + clean(&r.id), + clean(&r.report_type), + clean(&r.title), + format!("{} to {}", clean(&r.period_start), clean(&r.period_end)), + clean(&r.status), + short_time(Some(&r.created_at)), + ] + }) + .collect(), + ) +} + +/// What `reports download --json` prints once the file is saved. +#[derive(Serialize)] +struct Saved<'a> { + path: String, + bytes: usize, + content_type: Option<&'a str>, +} + +/// Downloads a report's file and saves it (see `ReportsDownload`), or writes it to standard output. +fn download( + ctx: &Context, + settings: &crate::config::Settings, + args: &ReportsDownload, + out: &mut dyn Write, +) -> Result<()> { + // A path given up front is checked before the download, which the API records in the audit + // trail: no point fetching a file that cannot be saved. The save checks again. + let directory = args.output.as_deref().filter(|path| path.is_dir()); + if let Some(path) = &args.output + && !args.to_stdout() + && directory.is_none() + && !args.force + && save::exists(path) + { + return Err(save::already_exists(path)); + } + let (client, source) = ctx.single(settings)?; + let params = DownloadReportParams { + format: args.format, + locale: args.locale, + }; + let file = client + .download_report(&args.id, ¶ms) + .map_err(|e| from_api(e, &plain(ctx, source)))?; + if args.to_stdout() { + out.write_all(&file.bytes)?; + if !ctx.json { + eprintln!( + "Wrote {} to standard output.", + count(file.bytes.len(), "bytes") + ); + } + return Ok(()); + } + let path = match (&args.output, directory) { + (Some(path), None) => path.clone(), + (_, directory) => { + let extension = args.format.unwrap_or(Format::Pdf).as_str(); + let name = save::file_name(file.file_name.as_deref(), &args.id, extension); + match directory { + Some(directory) => directory.join(name), + None => name.into(), + } + } + }; + save::write_new(&path, &file.bytes, args.force)?; + if ctx.json { + let saved = Saved { + path: path.display().to_string(), + bytes: file.bytes.len(), + content_type: file.content_type.as_deref(), + }; + let value = serde_json::to_value(saved) + .map_err(|e| crate::failure::Failure::general(e.to_string()))?; + return Ok(print_json(out, &value)?); + } + eprintln!( + "Saved {} ({}).", + clean(&path.display().to_string()), + count(file.bytes.len(), "bytes") + ); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/hodeishield-cli/src/commands/save.rs b/crates/hodeishield-cli/src/commands/save.rs new file mode 100644 index 0000000..a376b60 --- /dev/null +++ b/crates/hodeishield-cli/src/commands/save.rs @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Hodeitek S.L. + +//! Saving a downloaded file: the name to save it under, and a write that never leaves part of it. + +use crate::failure::{Failure, Result}; +use crate::output::clean; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// Longest file name kept from the server, in bytes: well within every common file system's limit +/// of 255, with room for the temporary name next to it. +const MAX_NAME: usize = 200; + +/// Names Windows keeps for devices, with any extension: `NUL.pdf` is the null device there. +const RESERVED: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +]; + +/// The plain file name to save a download under. The name the server suggested is never used as a +/// path: only its last component is kept, every character other than an ASCII letter, a digit, `.`, +/// `_` and `-` becomes `_`, and leading dots and dashes and trailing dots are dropped. When nothing +/// usable is left (or the name is too long, or a Windows device name), the name is +/// `report-.`, the fallback reduced the same way, or `report.`. +pub fn file_name(suggested: Option<&str>, fallback: &str, extension: &str) -> String { + if let Some(name) = suggested.and_then(plain) { + return name; + } + match plain(fallback) { + Some(stem) => format!("report-{stem}.{extension}"), + None => format!("report.{extension}"), + } +} + +/// `name` reduced to a plain file name, or `None` when nothing usable is left. +fn plain(name: &str) -> Option { + let last = name.rsplit(['/', '\\']).next().unwrap_or(""); + let reduced: String = last + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '_' + } + }) + .collect(); + let reduced = reduced.trim_start_matches(['.', '-']).trim_end_matches('.'); + let stem = reduced.split('.').next().unwrap_or(""); + let usable = !reduced.is_empty() + && reduced.len() <= MAX_NAME + && reduced.chars().any(|c| c.is_ascii_alphanumeric()) + && !RESERVED.iter().any(|r| r.eq_ignore_ascii_case(stem)); + usable.then(|| reduced.to_owned()) +} + +/// Whether something (a file, a directory, a link, even a broken one) is at `path`. +pub fn exists(path: &Path) -> bool { + path.symlink_metadata().is_ok() +} + +/// The refusal to replace what is at `path`. +pub fn already_exists(path: &Path) -> Failure { + Failure::general(format!( + "{} already exists; it was left as it is.", + clean(&path.display().to_string()) + )) + .hint("Add --force to replace it, or choose another path with --output.") +} + +/// Writes `bytes` to `path` so that the path never holds part of the file: they go to a new file +/// in the same directory, which is then renamed into place. Without `force`, the path is first +/// claimed with an exclusive create, so an existing file (or link) is never replaced, even one that +/// appears while this runs; with it, the rename replaces what is there. +pub fn write_new(path: &Path, bytes: &[u8], force: bool) -> Result<()> { + if !force { + match OpenOptions::new().write(true).create_new(true).open(path) { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(already_exists(path)); + } + Err(e) => return Err(cannot_write(path, &e)), + } + } + let written = write_and_rename(path, bytes); + if written.is_err() && !force { + // The empty file claimed above; nothing else is removed. + let _ = std::fs::remove_file(path); + } + written +} + +fn write_and_rename(path: &Path, bytes: &[u8]) -> Result<()> { + let (temporary, mut file) = temporary_next_to(path)?; + let written = file.write_all(bytes).and_then(|()| file.sync_all()); + // Closed before the rename, which Windows refuses on an open file. + drop(file); + written + .and_then(|()| std::fs::rename(&temporary, path)) + .map_err(|e| { + let _ = std::fs::remove_file(&temporary); + cannot_write(path, &e) + }) +} + +/// A new, empty file in the directory of `path`, under a name nothing else uses. +fn temporary_next_to(path: &Path) -> Result<(PathBuf, std::fs::File)> { + let directory = match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + }; + let base = path + .file_name() + .map_or_else(String::new, |n| n.to_string_lossy().into_owned()); + let base = plain(&base).unwrap_or_else(|| "download".to_owned()); + let mut last = None; + for _ in 0..8 { + let mut random = [0_u8; 6]; + getrandom::fill(&mut random) + .map_err(|e| Failure::general(format!("Could not pick a temporary file name: {e}.")))?; + let suffix: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let temporary = directory.join(format!(".{base}.{suffix}.part")); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + { + Ok(file) => return Ok((temporary, file)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = Some(e), + Err(e) => return Err(cannot_write(path, &e)), + } + } + Err(cannot_write( + path, + &last.unwrap_or_else(|| std::io::Error::other("no free temporary name")), + )) +} + +fn cannot_write(path: &Path, error: &std::io::Error) -> Failure { + Failure::general(format!( + "Could not write {}: {error}.", + clean(&path.display().to_string()) + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn name(suggested: &str) -> String { + file_name( + Some(suggested), + "0b8f2a52-6a0e-4d5c-9d3e-1f2a3b4c5d6e", + "pdf", + ) + } + + #[test] + fn a_plain_name_is_kept() { + assert_eq!(name("monthly-2026-09.pdf"), "monthly-2026-09.pdf"); + assert_eq!(name("Board_Report.v2.xlsx"), "Board_Report.v2.xlsx"); + } + + #[test] + fn only_the_last_component_is_kept() { + assert_eq!(name("../../etc/passwd"), "passwd"); + assert_eq!(name("a/b\\c.pdf"), "c.pdf"); + assert_eq!(name("C:\\Windows\\system.ini"), "system.ini"); + assert_eq!(name("/absolute/report.pdf"), "report.pdf"); + } + + #[test] + fn unsafe_characters_become_underscores() { + assert_eq!(name("a\u{1b}[31mb.pdf"), "a__31mb.pdf"); + assert_eq!(name("re port\r\n.pdf"), "re_port__.pdf"); + assert_eq!(name("informe\u{202e}fdp.exe"), "informe_fdp.exe"); + assert_eq!(name("a:b*c?.pdf"), "a_b_c_.pdf"); + assert_eq!(name("año.pdf"), "a_o.pdf"); + } + + #[test] + fn hidden_option_like_and_dot_names_are_not_kept_as_such() { + assert_eq!(name(".bashrc"), "bashrc"); + assert_eq!(name("-rf.pdf"), "rf.pdf"); + assert_eq!(name("report.pdf..."), "report.pdf"); + } + + #[test] + fn nothing_usable_falls_back_to_the_id() { + let fallback = "report-0b8f2a52-6a0e-4d5c-9d3e-1f2a3b4c5d6e.pdf"; + for suggested in [ + "..", + ".", + "", + "a/", + "../..", + "\u{1b}\u{7}", + "___", + "NUL.pdf", + "con", + ] { + assert_eq!(name(suggested), fallback, "{suggested:?}"); + } + assert_eq!(name(&"a".repeat(MAX_NAME + 1)), fallback); + assert_eq!( + file_name(None, "0b8f2a52-6a0e-4d5c-9d3e-1f2a3b4c5d6e", "xlsx"), + "report-0b8f2a52-6a0e-4d5c-9d3e-1f2a3b4c5d6e.xlsx" + ); + } + + #[test] + fn the_fallback_is_reduced_too() { + assert_eq!(file_name(None, "../x", "pdf"), "report-x.pdf"); + assert_eq!(file_name(None, "..", "pdf"), "report.pdf"); + } + + #[test] + fn an_existing_file_is_left_alone_without_force_and_replaced_with_it() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("report.pdf"); + write_new(&path, b"first", false).expect("new file"); + assert_eq!(std::fs::read(&path).expect("read"), b"first"); + let failure = write_new(&path, b"second", false).expect_err("exists"); + assert!( + failure.message.contains("already exists"), + "{}", + failure.message + ); + assert_eq!(std::fs::read(&path).expect("read"), b"first"); + write_new(&path, b"third", true).expect("forced"); + assert_eq!(std::fs::read(&path).expect("read"), b"third"); + let left: Vec<_> = std::fs::read_dir(dir.path()) + .expect("list") + .map(|e| e.expect("entry").file_name()) + .collect(); + assert_eq!(left, ["report.pdf"], "no temporary file is left behind"); + } +} diff --git a/crates/hodeishield-cli/src/failure.rs b/crates/hodeishield-cli/src/failure.rs index 60cb4c4..4af27a5 100644 --- a/crates/hodeishield-cli/src/failure.rs +++ b/crates/hodeishield-cli/src/failure.rs @@ -244,6 +244,7 @@ pub fn from_api(error: hodeishield_api::Error, context: &ApiContext<'_>) -> Fail _ => failure, } } + 409 => conflict(&api, server_message.as_deref()), status if status >= 500 => Failure::new( Kind::Unavailable, format!( @@ -282,6 +283,65 @@ pub fn from_api(error: hodeishield_api::Error, context: &ApiContext<'_>) -> Fail } } +/// Why a file cannot be served as asked (409), from `error.details.code`. Only the documented +/// codes get their own wording; the values shown are short tokens, anything else is left out. +fn conflict(api: &hodeishield_api::ApiError, server_message: Option<&str>) -> Failure { + let token = |s: &str| { + !s.is_empty() + && s.len() <= 16 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + }; + let details = api.body.as_ref().and_then(|b| b.details.as_ref()); + match detail(api, "code") { + Some("REPORT_NOT_READY") if detail(api, "status") == Some("failed") => { + Failure::general("The report failed to generate, so it has no file.") + .hint("Generate it again in the app.") + } + Some("REPORT_NOT_READY") => { + Failure::general("The report is still being generated, so it has no file yet.") + .hint("Try again later; `hodeishield reports get ` shows its status.") + } + Some("FORMAT_UNSUPPORTED") => { + let formats: Vec<&str> = details + .and_then(|d| d.get("formats")) + .and_then(serde_json::Value::as_array) + .map(|f| { + f.iter() + .filter_map(|v| v.as_str()) + .filter(|s| token(s)) + .collect() + }) + .unwrap_or_default(); + let failure = Failure::general("The report cannot be downloaded in that format."); + if formats.is_empty() { + failure.hint( + "It has no format the API can render; `hodeishield reports get ` lists \ + the formats it can be downloaded in.", + ) + } else { + failure.hint(format!( + "Choose one of its formats with --format: {}.", + formats.join(", ") + )) + } + } + Some("FILE_TOO_LARGE") => { + let limit = details + .and_then(|d| d.get("limit_bytes")) + .and_then(serde_json::Value::as_u64) + .map_or_else(String::new, |b| format!(" ({b} bytes)")); + Failure::general(format!( + "The report's file is larger than the API serves{limit}, so it was not sent." + )) + } + _ => Failure::general(format!( + "The API refused the request: {}.", + server_message.map_or("conflict", sentence_body) + )), + } +} + /// A string member of `error.details`. /// The server's message without its closing punctuation, for a sentence the CLI ends itself, so /// that "Bad value." does not become "Bad value..". diff --git a/crates/hodeishield-cli/tests/cli.rs b/crates/hodeishield-cli/tests/cli.rs index a72046d..a37b0bc 100644 --- a/crates/hodeishield-cli/tests/cli.rs +++ b/crates/hodeishield-cli/tests/cli.rs @@ -499,7 +499,7 @@ fn only_get_is_ever_sent() { let gets = server .mock("GET", mockito::Matcher::Any) .with_body(page(&[], 1, 0, 0)) - .expect(6) + .expect(7) .create(); for args in [ vec!["vendors", "list"], @@ -507,6 +507,7 @@ fn only_get_is_ever_sent() { vec!["risks", "list"], vec!["evidence", "list"], vec!["endpoints", "list"], + vec!["reports", "list"], vec!["compliance", "controls", "-f", "ens"], ] { env.api(&server).args(&args).assert().success(); @@ -1365,6 +1366,8 @@ fn the_selection_is_refused_where_it_cannot_apply() { &["--tenant", "acme", "whoami"][..], &["--all-tenants", "tenants", "list"][..], &["--all-tenants", "vendors", "get", "v1"][..], + &["--all-tenants", "reports", "get", "r1"][..], + &["--all-tenants", "reports", "download", "r1"][..], &["--all-tenants", "compliance", "posture", "nis2"][..], &["--all-tenants", "vendors", "list", "--page", "2"][..], ] { @@ -2203,3 +2206,452 @@ fn the_client_wording_of_a_tenant_rate_limit_needs_a_selected_client() { ); } } + +const REPORT_ID: &str = "0b8f2a52-6a0e-4d5c-9d3e-1f2a3b4c5d6e"; + +fn report(id: &str, title: &str, status: &str) -> String { + format!( + r#"{{"id":"{id}","report_type":"monthly","title":"{title}","period_start":"2026-09-01","period_end":"2026-09-30","status":"{status}","created_at":"2026-10-01T06:00:00.000Z"}}"# + ) +} + +/// A mock of the download of [`REPORT_ID`] that serves `body` under `disposition`. +fn mock_download(server: &mut mockito::Server, disposition: &str, body: &[u8]) -> mockito::Mock { + server + .mock("GET", format!("/v1/reports/{REPORT_ID}/download").as_str()) + .match_query(mockito::Matcher::Any) + .with_header("content-type", "application/pdf") + .with_header("content-disposition", disposition) + .with_body(body) + .create() +} + +/// The names of the entries of `dir`, sorted. +fn entries(dir: &std::path::Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .expect("list") + .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names +} + +#[test] +fn reports_list_prints_a_table_and_the_json_body() { + let env = Env::new(); + let mut server = mockito::Server::new(); + server + .mock("GET", "/v1/reports") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("report_type".into(), "monthly".into()), + mockito::Matcher::UrlEncoded("status".into(), "completed".into()), + mockito::Matcher::UrlEncoded("sort".into(), "period_end".into()), + ])) + .with_body(page( + &[report(REPORT_ID, "September summary", "completed")], + 1, + 1, + 1, + )) + .expect(2) + .create(); + let args = [ + "reports", + "list", + "--type", + "monthly", + "--status", + "completed", + "--sort", + "period_end", + ]; + let output = env.api(&server).args(args).output().expect("run"); + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + let header: Vec<&str> = stdout + .lines() + .next() + .expect("header") + .split_whitespace() + .collect(); + assert_eq!( + header, + ["ID", "TYPE", "TITLE", "PERIOD", "STATUS", "CREATED"] + ); + assert!( + stdout.contains("September summary") + && stdout.contains("2026-09-01 to 2026-09-30") + && stdout.contains("2026-10-01 06:00Z"), + "{stdout}" + ); + assert!(output.stderr.is_empty(), "{output:?}"); + + let output = env + .api(&server) + .arg("--json") + .args(args) + .output() + .expect("run"); + assert!(output.status.success(), "{output:?}"); + let body: serde_json::Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(body["data"][0]["title"], "September summary"); +} + +#[test] +fn listed_report_filters_warn_and_are_sent_as_typed() { + for (args, param, value, expected) in [ + ( + vec!["reports", "list", "--type", "Monthly"], + "report_type", + "Monthly", + Some( + "for --report-type (monthly, vendor_risk, nis2_compliance, ens, cyberbrief, compliance, security_posture, board_report); did you mean `monthly`? Sending it anyway.", + ), + ), + ( + vec!["reports", "list", "--report-type", "ens"], + "report_type", + "ens", + None, + ), + ( + vec!["reports", "list", "--status", "ready"], + "status", + "ready", + Some( + "warning: `ready` is not one of the values the API lists for --status (generating, completed, failed); sending it anyway.", + ), + ), + ] { + let env = Env::new(); + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/v1/reports") + .match_query(mockito::Matcher::UrlEncoded(param.into(), value.into())) + .with_body(page(&[], 1, 0, 1)) + .expect(1) + .create(); + let output = env.api(&server).args(&args).output().expect("run"); + assert!(output.status.success(), "{args:?}: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + match expected { + Some(warning) => assert!(stderr.contains(warning), "{args:?}: {stderr}"), + None => assert!(!stderr.contains("warning:"), "{args:?}: {stderr}"), + } + mock.assert(); + } +} + +#[test] +fn reports_get_shows_the_download_formats() { + let env = Env::new(); + let mut server = mockito::Server::new(); + let detail = report(REPORT_ID, "September summary", "completed") + .replace('}', r#","download_formats":["pdf","xlsx"]}"#); + server + .mock("GET", format!("/v1/reports/{REPORT_ID}").as_str()) + .with_body(format!(r#"{{"data":{detail}}}"#)) + .expect(2) + .create(); + let output = env + .api(&server) + .args(["reports", "get", REPORT_ID]) + .output() + .expect("run"); + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("download_formats pdf, xlsx"), "{stdout}"); + let output = env + .api(&server) + .args(["--json", "reports", "get", REPORT_ID]) + .output() + .expect("run"); + assert!(output.status.success(), "{output:?}"); + let body: serde_json::Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(body["data"]["download_formats"][1], "xlsx"); +} + +#[test] +fn a_download_is_saved_in_the_current_directory_under_the_suggested_name() { + let env = Env::new(); + let cwd = tempfile::tempdir().expect("tempdir"); + let mut server = mockito::Server::new(); + let bytes = b"%PDF-1.7\n\x00\x01binary\xff"; + let mock = server + .mock("GET", format!("/v1/reports/{REPORT_ID}/download").as_str()) + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("format".into(), "xlsx".into()), + mockito::Matcher::UrlEncoded("locale".into(), "es".into()), + ])) + .with_header( + "content-type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + .with_header( + "content-disposition", + r#"attachment; filename="monthly-2026-09.xlsx""#, + ) + .with_body(bytes) + .expect(1) + .create(); + let output = env + .api(&server) + .current_dir(cwd.path()) + .args([ + "reports", "download", REPORT_ID, "--format", "xlsx", "--locale", "es", + ]) + .output() + .expect("run"); + assert!(output.status.success(), "{output:?}"); + mock.assert(); + assert!(output.stdout.is_empty(), "{output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + format!("Saved monthly-2026-09.xlsx ({} bytes).\n", bytes.len()) + ); + assert_eq!(entries(cwd.path()), ["monthly-2026-09.xlsx"]); + assert_eq!( + std::fs::read(cwd.path().join("monthly-2026-09.xlsx")).expect("read"), + bytes + ); +} + +#[test] +fn a_download_goes_to_the_output_path_or_into_the_output_directory() { + let env = Env::new(); + let cwd = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(cwd.path().join("out")).expect("mkdir"); + let mut server = mockito::Server::new(); + mock_download(&mut server, r#"attachment; filename="r.pdf""#, b"%PDF-"); + let target = cwd.path().join("chosen.pdf"); + env.api(&server) + .current_dir(cwd.path()) + .args(["reports", "download", REPORT_ID, "-o"]) + .arg(&target) + .assert() + .success(); + assert_eq!(std::fs::read(&target).expect("read"), b"%PDF-"); + env.api(&server) + .current_dir(cwd.path()) + .args(["reports", "download", REPORT_ID, "--output", "out"]) + .assert() + .success() + .stderr(predicate::str::contains("r.pdf (5 bytes)")); + assert_eq!(entries(&cwd.path().join("out")), ["r.pdf"]); + assert_eq!(entries(cwd.path()), ["chosen.pdf", "out"]); +} + +#[test] +fn an_existing_file_is_not_replaced_without_force() { + let env = Env::new(); + let cwd = tempfile::tempdir().expect("tempdir"); + let target = cwd.path().join("keep.pdf"); + std::fs::write(&target, b"mine").expect("write"); + std::fs::write(cwd.path().join("r.pdf"), b"also mine").expect("write"); + let mut server = mockito::Server::new(); + let mock = mock_download(&mut server, r#"attachment; filename="r.pdf""#, b"theirs"); + + // A path given with -o is checked before anything is downloaded. + let output = env + .api(&server) + .current_dir(cwd.path()) + .args(["reports", "download", REPORT_ID, "-o", "keep.pdf"]) + .output() + .expect("run"); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("keep.pdf already exists") && stderr.contains("--force"), + "{stderr}" + ); + assert_eq!(std::fs::read(&target).expect("read"), b"mine"); + + // The suggested name is only known after the download; it is not replaced either. + let output = env + .api(&server) + .current_dir(cwd.path()) + .args(["reports", "download", REPORT_ID]) + .output() + .expect("run"); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert_eq!( + std::fs::read(cwd.path().join("r.pdf")).expect("read"), + b"also mine" + ); + assert_eq!(entries(cwd.path()), ["keep.pdf", "r.pdf"]); + mock.expect(1).assert(); + + // --force replaces both. + for args in [&["-o", "keep.pdf", "--force"][..], &["--force"][..]] { + env.api(&server) + .current_dir(cwd.path()) + .args(["reports", "download", REPORT_ID]) + .args(args) + .assert() + .success(); + } + assert_eq!(std::fs::read(&target).expect("read"), b"theirs"); + assert_eq!( + std::fs::read(cwd.path().join("r.pdf")).expect("read"), + b"theirs" + ); + assert_eq!(entries(cwd.path()), ["keep.pdf", "r.pdf"]); +} + +#[test] +fn a_hostile_suggested_name_is_saved_as_a_plain_name_in_the_current_directory() { + let fallback = format!("report-{REPORT_ID}.pdf"); + for (disposition, expected) in [ + (r#"attachment; filename="../../etc/passwd""#, "passwd"), + (r#"attachment; filename="a/b\\c.pdf""#, "c.pdf"), + (r#"attachment; filename="..""#, fallback.as_str()), + (r#"attachment; filename=".hidden rc""#, "hidden_rc"), + (r#"attachment; filename="-rf *.pdf""#, "rf__.pdf"), + ( + "attachment; filename=\"\u{202e}fdp.exe\"", + fallback.as_str(), + ), + ("attachment", fallback.as_str()), + ] { + let env = Env::new(); + let root = tempfile::tempdir().expect("tempdir"); + let cwd = root.path().join("work"); + std::fs::create_dir(&cwd).expect("mkdir"); + let mut server = mockito::Server::new(); + mock_download(&mut server, disposition, b"%PDF-"); + let output = env + .api(&server) + .current_dir(&cwd) + .args(["reports", "download", REPORT_ID]) + .output() + .expect("run"); + assert!(output.status.success(), "{disposition}: {output:?}"); + assert_eq!(entries(&cwd), [expected], "{disposition}"); + assert_eq!(entries(root.path()), ["work"], "{disposition}"); + assert!(!output.stderr.contains(&0x1b), "{disposition}"); + } +} + +#[test] +fn output_dash_writes_the_file_to_standard_output() { + let env = Env::new(); + let cwd = tempfile::tempdir().expect("tempdir"); + let mut server = mockito::Server::new(); + let bytes = b"%PDF-\x00\x1b\xff"; + mock_download(&mut server, r#"attachment; filename="r.pdf""#, bytes); + for json in [false, true] { + let mut cmd = env.api(&server); + if json { + cmd.arg("--json"); + } + let output = cmd + .current_dir(cwd.path()) + .args(["reports", "download", REPORT_ID, "-o", "-"]) + .output() + .expect("run"); + assert!(output.status.success(), "{output:?}"); + assert_eq!(output.stdout, bytes, "nothing but the file on stdout"); + let stderr = String::from_utf8_lossy(&output.stderr); + if json { + assert!(stderr.is_empty(), "{stderr}"); + } else { + assert_eq!(stderr, "Wrote 8 bytes to standard output.\n"); + } + } + assert!(entries(cwd.path()).is_empty()); +} + +#[test] +fn a_report_that_cannot_be_served_yet_explains_why() { + for (details, message, hint) in [ + ( + r#"{"code":"REPORT_NOT_READY","status":"generating"}"#, + "The report is still being generated, so it has no file yet.", + "Try again later", + ), + ( + r#"{"code":"REPORT_NOT_READY","status":"failed"}"#, + "The report failed to generate, so it has no file.", + "Generate it again", + ), + ( + r#"{"code":"FORMAT_UNSUPPORTED","formats":["pdf"]}"#, + "The report cannot be downloaded in that format.", + "Choose one of its formats with --format: pdf.", + ), + ( + r#"{"code":"FORMAT_UNSUPPORTED","formats":[]}"#, + "The report cannot be downloaded in that format.", + "reports get ` lists the formats", + ), + ( + r#"{"code":"FILE_TOO_LARGE","limit_bytes":20971520}"#, + "larger than the API serves (20971520 bytes)", + "", + ), + ] { + let env = Env::new(); + let cwd = tempfile::tempdir().expect("tempdir"); + let mut server = mockito::Server::new(); + server + .mock("GET", format!("/v1/reports/{REPORT_ID}/download").as_str()) + .match_query(mockito::Matcher::Any) + .with_status(409) + .with_body(format!( + r#"{{"error":{{"code":"conflict","message":"no","request_id":"req_409","details":{details}}}}}"# + )) + .create(); + let output = env + .api(&server) + .current_dir(cwd.path()) + .args(["reports", "download", REPORT_ID]) + .output() + .expect("run"); + assert_eq!(output.status.code(), Some(1), "{details}: {output:?}"); + assert!(output.stdout.is_empty(), "{details}: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(message) && stderr.contains(hint), + "{details}: {stderr}" + ); + assert!(stderr.contains("request id: req_409"), "{stderr}"); + assert!(entries(cwd.path()).is_empty(), "{details}"); + } +} + +#[test] +fn server_text_about_a_download_cannot_drive_the_terminal() { + let env = Env::new(); + let cwd = tempfile::tempdir().expect("tempdir"); + let mut server = mockito::Server::new(); + server + .mock("GET", format!("/v1/reports/{REPORT_ID}/download").as_str()) + .match_query(mockito::Matcher::Any) + .with_status(409) + .with_body(r#"{"error":{"code":"conflict","message":"busy \u001b]0;pwned\u0007 \u009b31m","request_id":"r\u001b[5m","details":{"code":"FORMAT_UNSUPPORTED","formats":["pdf\u001b[2J","xlsx"]}}}"#) + .create(); + server + .mock("GET", "/v1/reports/other/download") + .match_query(mockito::Matcher::Any) + .with_status(409) + .with_body(r#"{"error":{"code":"conflict","message":"busy \u001b]0;pwned\u0007 \u009b31m","request_id":"r","details":{"code":"SOMETHING_NEW"}}}"#) + .create(); + for id in [REPORT_ID, "other"] { + let output = env + .api(&server) + .current_dir(cwd.path()) + .args(["reports", "download", id]) + .output() + .expect("run"); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + let all = [output.stdout.as_slice(), output.stderr.as_slice()].concat(); + assert!(!all.contains(&0x1b) && !all.contains(&0x07), "{output:?}"); + let text = String::from_utf8_lossy(&all); + assert!(!text.contains('\u{9b}'), "{text}"); + if id == "other" { + assert!(text.contains("The API refused the request: busy"), "{text}"); + } else { + assert!(text.contains("--format: xlsx."), "{text}"); + } + } +} diff --git a/crates/hodeishield-cli/tests/contract.rs b/crates/hodeishield-cli/tests/contract.rs index 5e90d25..3c18eed 100644 --- a/crates/hodeishield-cli/tests/contract.rs +++ b/crates/hodeishield-cli/tests/contract.rs @@ -247,7 +247,7 @@ struct Resource { csv_header: &'static str, } -const RESOURCES: [Resource; 5] = [ +const RESOURCES: [Resource; 6] = [ Resource { command: &["vendors"], list_args: &[], @@ -276,6 +276,13 @@ const RESOURCES: [Resource; 5] = [ item: r#"{"id":"e1","evidence_type":"policy","source_type":"manual","source_id":"s1","title":"Example evidence","description":null,"vendor_id":null,"asset_id":null,"auto_generated":false,"valid_until":null,"created_at":"2026-01-01T00:00:00Z","added_later":7}"#, csv_header: "id,evidence_type,source_type,source_id,title,description,vendor_id,asset_id,auto_generated,valid_until,created_at,added_later", }, + Resource { + command: &["reports"], + list_args: &[], + path: "/v1/reports", + item: r#"{"id":"0b8f2a52-6a0e-4d5c-9d3e-1f2a3b4c5d6e","report_type":"monthly","title":"Example report","period_start":"2026-09-01","period_end":"2026-09-30","status":"completed","created_at":"2026-10-01T00:00:00Z","added_later":{"k":"v"}}"#, + csv_header: "id,report_type,title,period_start,period_end,status,created_at,added_later", + }, Resource { command: &["endpoints"], list_args: &[], @@ -394,11 +401,19 @@ fn json_all_is_an_array_of_the_items() { #[test] fn json_get_is_the_api_body_with_unknown_fields_passed_through() { let env = Env::new(); - for resource in &RESOURCES[..4] { + for resource in &RESOURCES[..5] { let name = resource.command[0]; let id = "x1"; let path = format!("{}/{id}", resource.path); - let body = format!(r#"{{"data":{},"added_later_top":1}}"#, resource.item); + // A single report also says which formats it can be downloaded in. + let item = if name == "reports" { + resource + .item + .replacen('{', r#"{"download_formats":["pdf"],"#, 1) + } else { + resource.item.to_owned() + }; + let body = format!(r#"{{"data":{item},"added_later_top":1}}"#); let mut server = mockito::Server::new(); server.mock("GET", path.as_str()).with_body(&body).create(); let output = env.api(&server.url(), &["--json", name, "get", id]); @@ -414,6 +429,40 @@ fn json_get_is_the_api_body_with_unknown_fields_passed_through() { } } +/// `reports download --json` prints where the file went, its size and its type, and nothing else. +#[test] +fn json_report_download_fields() { + let env = Env::new(); + let dir = tempfile::tempdir().expect("tempdir"); + let mut server = mockito::Server::new(); + server + .mock("GET", "/v1/reports/r1/download") + .with_header("content-type", "application/pdf") + .with_header( + "content-disposition", + r#"attachment; filename="report.pdf""#, + ) + .with_body("%PDF-") + .create(); + let output = env + .cmd() + .env("HODEISHIELD_API_KEY", KEY) + .env("HODEISHIELD_API_URL", server.url()) + .current_dir(dir.path()) + .args(["--json", "reports", "download", "r1"]) + .output() + .expect("run"); + assert_eq!(output.status.code(), Some(0), "{output:?}"); + let printed: Value = serde_json::from_str(&out(&output)).expect("json"); + assert_eq!( + printed, + json!({"path": "report.pdf", "bytes": 5, "content_type": "application/pdf"}) + ); + let keys: Vec<&String> = printed.as_object().expect("object").keys().collect(); + assert_eq!(keys, ["path", "bytes", "content_type"]); + assert!(err(&output).is_empty(), "{}", err(&output)); +} + /// `compliance posture` prints the API's body, `{"data": {...}}`. #[test] fn json_posture_is_the_api_body_with_unknown_fields_passed_through() { diff --git a/docs/risk-assessment.md b/docs/risk-assessment.md index f5fcc1b..97a39af 100644 --- a/docs/risk-assessment.md +++ b/docs/risk-assessment.md @@ -15,8 +15,8 @@ For what users need to know to use the CLI securely, see ## Intended purpose and conditions of use `hodeishield` is a read-only command-line client for the public `/v1` API of HodeiShield. It reads -one organisation's third-party inventory, alerts, risk register, compliance posture, evidence and -endpoint agents ([user-information.md](user-information.md#intended-purpose)). It runs as a short-lived +one organisation's third-party inventory, alerts, risk register, compliance posture, evidence, +endpoint agents and reports ([user-information.md](user-information.md#intended-purpose)). It runs as a short-lived process on the user's own machine, started by a person or a script, and has no server part, daemon or plug-in interface. @@ -41,6 +41,7 @@ or plug-in interface. | Configuration file | `config.toml` in the user's configuration directory (`config.rs`) | URLs and sign-in settings. A changed URL could redirect sign-in or requests | | Update-check record | `update-check.json` next to the configuration file (`update_check.rs`) | A time and a version number; no personal data | | Command output | Standard output, as table, JSON or CSV | The organisation's data, in the user's hands once printed | +| Saved report files | Where `reports download` saves them: the current directory or the path given (`commands/save.rs`) | The organisation's data, in the user's hands once saved | | Source repository and release workflow | GitHub: `main`, `.github/workflows/release.yml`, the `release tags` ruleset, the `release-signing` and `packaging` environments ([release-signing.md](release-signing.md#who-may-change-them)) | Whoever controls them controls what is signed as genuine | | Release artefacts | Archives, `.deb`, `.rpm`, MSI, container image, `SHA256SUMS`, SBOMs, provenance, with their signatures ([verifying-releases.md](verifying-releases.md)) | Users run them with their credentials | | Signing identities | The Sigstore workflow identity, the SLSA generator identity, the Authenticode certificate profile ([release-signing.md](release-signing.md#signatures-and-identities)) | They are what users verify | @@ -54,7 +55,7 @@ or plug-in interface. | T1 | A token or key is read from disk, a backup or a repository | Tokens are written only to the system keychain; there is no file fallback (`auth/store.rs`, module comment). The configuration file holds no credential and is created with mode 0600 on Unix (`config.rs`, `write_private`). The API key is read from the environment and never stored. Secrets are held as `SecretString`, and a test checks that `Debug` output never shows tokens (`auth/store.rs`, test `debug_never_shows_tokens`) | | T2 | A credential is sent to the wrong host: plain HTTP, a redirect, a crafted URL, a changed profile | Base URLs must be HTTPS and carry no user name or password; plain HTTP is accepted only for a loopback host (`config.rs`, `parse_url`; `hodeishield-api/src/client.rs`, `check_base_url`). The client refuses to follow redirects and is built with `https_only` (`client.rs`, `build`); the sign-in client does the same (`auth/oauth.rs`, `http_client`). A sign-in token is sent only to the API it was obtained for, and only the app that issued it is used to refresh or revoke it (`auth/mod.rs`, `credential_with`, `current_token`) | | T3 | An attacker on the network reads or changes traffic | TLS with the operating system's certificate store; no option to skip verification (README, "Networking"). Loopback traffic never uses a proxy (`client.rs`, `build`) | -| T4 | The API or the app sends hostile content: terminal escape sequences, spreadsheet formulas, oversized bodies | Text from the server is cleaned of control, invisible and bidirectional characters before it is printed; JSON output escapes them; CSV cells that a spreadsheet would run are prefixed with `'` (`output.rs`, `clean`, `print_json`, `defuse`). Bodies larger than 8 MiB are refused (`client.rs`, `MAX_RESPONSE_BYTES`) | +| T4 | The API or the app sends hostile content: terminal escape sequences, spreadsheet formulas, oversized bodies | Text from the server is cleaned of control, invisible and bidirectional characters before it is printed; JSON output escapes them; CSV cells that a spreadsheet would run are prefixed with `'` (`output.rs`, `clean`, `print_json`, `defuse`). Bodies larger than 8 MiB are refused (`client.rs`, `MAX_RESPONSE_BYTES`), and report files larger than 20 MiB (`MAX_DOWNLOAD_BYTES`). The file name the server suggests for a report is reduced to a plain name in the current directory and never used as a path, and an existing file is replaced only with `--force` (`commands/save.rs`, `file_name`, `write_new`) | | T5 | Another local program or a web page abuses the sign-in listener | The listener binds `127.0.0.1` on a port chosen by the system, answers only `/callback`, requires the `state` of this sign-in and, when the server sends it, a matching `iss`, serves one valid callback and stops, and bounds how long a connection and the whole wait may last (`auth/loopback.rs`). The flow uses PKCE with S256 and refuses an app that does not support it (`auth/oauth.rs`). `login --device` avoids the listener | | T6 | A script or a repository leaks the API key | The key is read from the environment, never from a flag, so it is not on a command line; the CLI never prints it, and `--verbose` logs no header ([README.md](../README.md#authenticate), `client.rs`, `RequestEvent`). [user-information.md](user-information.md#security-properties-and-secure-use) tells users to keep it in a secret store and to give it only the read scopes needed | | T7 | The CLI changes data in the user's organisation | The API client has only `GET` (`client.rs`; "Only `GET` requests exist in this client"). The only other requests are the sign-in, token refresh and revocation calls to the app's OAuth endpoints (`auth/oauth.rs`) and the update check | diff --git a/docs/user-information.md b/docs/user-information.md index a971731..3f91895 100644 --- a/docs/user-information.md +++ b/docs/user-information.md @@ -57,8 +57,8 @@ advisories are under `hodeishield` is a read-only command-line client for the public `/v1` API of [HodeiShield](https://hodeishield.com). It reads one organisation's third-party inventory, -supply-chain alerts, risk register, compliance posture, evidence and endpoint agents, from a -terminal or a script. Every command reads: its requests to the API are `GET` only, and it cannot +supply-chain alerts, risk register, compliance posture, evidence, endpoint agents and reports, from +a terminal or a script. Every command reads: its requests to the API are `GET` only, and it cannot change data in your organisation. It needs a HodeiShield account to sign in with, or a tenant API key. From f2fda5f4bb48ecc6d4ccbcae331d8289d2835054 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:13:04 +0200 Subject: [PATCH 18/25] Harden saving a downloaded report The extension of a saved report is always that of its format, whatever name the API suggests. The file is written and synced under a temporary name and then linked into place, so an interrupted save leaves nothing behind, and on Unix it is readable only by its owner. The target directory is checked before the download, and error bodies are read with the normal size limit. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- CHANGELOG.md | 5 +- README.md | 12 +- crates/hodeishield-api/src/client.rs | 8 +- crates/hodeishield-api/tests/api.rs | 15 + .../hodeishield-cli/src/commands/resources.rs | 17 +- crates/hodeishield-cli/src/commands/save.rs | 378 ++++++++++++++---- crates/hodeishield-cli/tests/cli.rs | 41 +- 7 files changed, 391 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4c122d..57b4c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,10 @@ commands, `--json` output and exit codes. Each release's notes start with its se their files. `reports list` filters by `--type`, `--status` and `--since` and takes the usual paging, `--json` and `--csv`; `reports get` shows the formats a report can be downloaded in. `reports download` saves a completed report as PDF or XLSX (`--format`, `--locale`) in the current - directory under the name the API suggests, reduced to a plain file name, or at `--output `; + directory under the name the API suggests, reduced to a plain stem with the extension of the + format, or at `--output `; `--output -` writes it to standard output when that is not a terminal. An existing file is only - replaced with `--force`, and a file is never left half written. A report that is still being + replaced with `--force`, a file is never left half written, and on Unix it is readable only by you. A report that is still being generated, failed, has no file in that format or is too large fails with a message that says so. ([#55](https://github.com/Hodeitek/hodeishield-cli/issues/55)) diff --git a/README.md b/README.md index 72acb98..994d2fa 100644 --- a/README.md +++ b/README.md @@ -456,14 +456,18 @@ Saved monthly-2026-09.xlsx (48213 bytes). - `--format` is `pdf` (the default) or `xlsx`, and `--locale` one of `es`, `en` (the default), `de`, `fr`, `it` and `eu`. `reports get` lists the formats a report can be downloaded in. - Without `--output`, the file is saved in the current directory under the name the API suggests, - reduced to a plain file name: only its last part is kept, every character other than a letter, a - digit, `.`, `_` and `-` becomes `_`, and leading dots are dropped. When nothing usable is left, - the name is `report-.`. The name is never used as a path. + reduced to a plain stem: only its last part is kept, every character other than a letter, a + digit, `.`, `_` and `-` becomes `_`, leading dots are dropped and everything from the first `.` + on is cut. The extension is always that of the format (`.pdf` or `.xlsx`), never the one the API + suggested, so `conftest.py` is saved as `conftest.pdf`. When nothing usable is left, the name is + `report-.`. The name is never used as a path. - `--output ` saves it there instead; a directory saves it there under the suggested name. `--output -` writes the file to standard output, and is refused (exit code 2) when standard output is a terminal. - An existing file is never replaced unless `--force` is given. The file is written next to its - destination and then renamed into place, so the path never holds part of a file. + destination and then put in place, so the path never holds part of a file. On Unix, the saved + file is readable only by you (mode 0600); `--force` keeps the permissions of the file it replaces. + The directory is checked before the download starts. - The note `Saved ().` goes to standard error. With `--json`, standard output holds `{"path": ..., "bytes": ..., "content_type": ...}` instead, and there is no note. - A report that is still being generated, that failed, that has no file in that format, or whose file diff --git a/crates/hodeishield-api/src/client.rs b/crates/hodeishield-api/src/client.rs index dcf8878..29185d8 100644 --- a/crates/hodeishield-api/src/client.rs +++ b/crates/hodeishield-api/src/client.rs @@ -509,7 +509,8 @@ impl Client { } /// Sends a GET, with the retries, the credential renewal and the tenant check, and returns a - /// successful answer's metadata, headers and body (at most `limit` bytes). The operation decides + /// successful answer's metadata, headers and body (at most `limit` bytes; the body of an error is + /// read up to [`MAX_RESPONSE_BYTES`] whatever `limit` is). The operation decides /// whether the client's tenant selection applies: scope headers are sent and checked only when /// `operation.tenant_scoped`. fn fetch( @@ -572,6 +573,11 @@ impl Client { _ => Ok(()), }; let headers = response.headers().clone(); + let limit = if status.is_success() { + limit + } else { + MAX_RESPONSE_BYTES + }; let body = read_body(response, limit).map_err(Error::Body)?; if status.is_success() { verdict?; diff --git a/crates/hodeishield-api/tests/api.rs b/crates/hodeishield-api/tests/api.rs index 3bf6846..5e5d391 100644 --- a/crates/hodeishield-api/tests/api.rs +++ b/crates/hodeishield-api/tests/api.rs @@ -1295,3 +1295,18 @@ fn a_report_that_is_not_ready_is_an_api_error_with_its_code() { assert_eq!(api.code(), Some("REPORT_NOT_READY")); assert_eq!(api.operation, "downloadReport"); } + +#[test] +fn the_body_of_an_error_is_read_with_the_normal_limit_not_the_download_one() { + use hodeishield_api::v1::DownloadReportParams; + let mut server = mockito::Server::new(); + server + .mock("GET", "/v1/reports/r-3/download") + .with_status(500) + .with_body(vec![b'x'; 9 * 1024 * 1024]) + .create(); + let err = client(&server) + .download_report("r-3", &DownloadReportParams::default()) + .expect_err("too large"); + assert!(matches!(err, hodeishield_api::Error::Body(_)), "{err:?}"); +} diff --git a/crates/hodeishield-cli/src/commands/resources.rs b/crates/hodeishield-cli/src/commands/resources.rs index 76eaad3..c0c03a6 100644 --- a/crates/hodeishield-cli/src/commands/resources.rs +++ b/crates/hodeishield-cli/src/commands/resources.rs @@ -27,6 +27,7 @@ use hodeishield_api::{ApiResponse, Error}; use serde::Serialize; use serde_json::Value; use std::io::Write; +use std::path::Path; type Fetch<'a, R> = dyn FnMut(Option, Option) -> std::result::Result, Error> + 'a; @@ -822,6 +823,10 @@ fn download( { return Err(save::already_exists(path)); } + // The same for the directory a name chosen from the answer will go to. + if !args.to_stdout() && (directory.is_some() || args.output.is_none()) { + save::check_writable(directory.unwrap_or_else(|| Path::new(".")))?; + } let (client, source) = ctx.single(settings)?; let params = DownloadReportParams { format: args.format, @@ -843,12 +848,20 @@ fn download( let path = match (&args.output, directory) { (Some(path), None) => path.clone(), (_, directory) => { - let extension = args.format.unwrap_or(Format::Pdf).as_str(); + let extension = save::extension( + args.format.map(Format::as_str), + file.content_type.as_deref(), + ); let name = save::file_name(file.file_name.as_deref(), &args.id, extension); - match directory { + let path = match directory { Some(directory) => directory.join(name), None => name.into(), + }; + // The name is only known now that the report is downloaded. + if !args.force && save::exists(&path) { + return Err(save::not_saved(&path)); } + path } }; save::write_new(&path, &file.bytes, args.force)?; diff --git a/crates/hodeishield-cli/src/commands/save.rs b/crates/hodeishield-cli/src/commands/save.rs index a376b60..8d48d89 100644 --- a/crates/hodeishield-cli/src/commands/save.rs +++ b/crates/hodeishield-cli/src/commands/save.rs @@ -19,23 +19,48 @@ const RESERVED: &[&str] = &[ "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", ]; -/// The plain file name to save a download under. The name the server suggested is never used as a -/// path: only its last component is kept, every character other than an ASCII letter, a digit, `.`, -/// `_` and `-` becomes `_`, and leading dots and dashes and trailing dots are dropped. When nothing -/// usable is left (or the name is too long, or a Windows device name), the name is -/// `report-.`, the fallback reduced the same way, or `report.`. +/// The extension of a downloaded file: that of the format that was asked for, else the one the +/// answer's `Content-Type` says, else `pdf` (the API's default format). The name the server +/// suggests never decides it. +pub fn extension(requested: Option<&str>, content_type: Option<&str>) -> &'static str { + let from = |value: &str| match value { + "pdf" => Some("pdf"), + "xlsx" => Some("xlsx"), + _ => None, + }; + requested + .and_then(from) + .or_else(|| { + let media = content_type?.split(';').next()?.trim().to_ascii_lowercase(); + match media.as_str() { + "application/pdf" => Some("pdf"), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => Some("xlsx"), + _ => None, + } + }) + .unwrap_or("pdf") +} + +/// The plain file name to save a download under. The name the server suggested is only a stem and +/// is never used as a path: only its last component is kept, every character other than an ASCII +/// letter, a digit, `.`, `_` and `-` becomes `_`, leading dots and dashes and trailing dots are +/// dropped, and everything from the first `.` on is cut, so the server cannot choose the extension: +/// it is always `extension`. When nothing usable is left (or the stem is too long, or a Windows +/// device name), the name is `report-.`, the fallback reduced the same way, or +/// `report.`. pub fn file_name(suggested: Option<&str>, fallback: &str, extension: &str) -> String { - if let Some(name) = suggested.and_then(plain) { - return name; + if let Some(stem) = suggested.and_then(stem) { + return format!("{stem}.{extension}"); } - match plain(fallback) { + match stem(fallback) { Some(stem) => format!("report-{stem}.{extension}"), None => format!("report.{extension}"), } } -/// `name` reduced to a plain file name, or `None` when nothing usable is left. -fn plain(name: &str) -> Option { +/// `name` reduced to a plain file name: the last component, safe characters only, no leading dots +/// or dashes and no trailing dots. +fn reduce(name: &str) -> String { let last = name.rsplit(['/', '\\']).next().unwrap_or(""); let reduced: String = last .chars() @@ -47,13 +72,32 @@ fn plain(name: &str) -> Option { } }) .collect(); - let reduced = reduced.trim_start_matches(['.', '-']).trim_end_matches('.'); + reduced + .trim_start_matches(['.', '-']) + .trim_end_matches('.') + .to_owned() +} + +/// `name` reduced to a plain file name, or `None` when nothing usable is left. +fn plain(name: &str) -> Option { + let reduced = reduce(name); let stem = reduced.split('.').next().unwrap_or(""); let usable = !reduced.is_empty() && reduced.len() <= MAX_NAME && reduced.chars().any(|c| c.is_ascii_alphanumeric()) && !RESERVED.iter().any(|r| r.eq_ignore_ascii_case(stem)); - usable.then(|| reduced.to_owned()) + usable.then_some(reduced) +} + +/// The part of `name` before its first dot, once reduced to a plain file name, or `None` when +/// nothing usable is left of it. +fn stem(name: &str) -> Option { + let reduced = reduce(name); + let stem = reduced.split('.').next().unwrap_or(""); + let usable = stem.len() <= MAX_NAME + && stem.chars().any(|c| c.is_ascii_alphanumeric()) + && !RESERVED.iter().any(|r| r.eq_ignore_ascii_case(stem)); + usable.then(|| stem.to_owned()) } /// Whether something (a file, a directory, a link, even a broken one) is at `path`. @@ -70,72 +114,143 @@ pub fn already_exists(path: &Path) -> Failure { .hint("Add --force to replace it, or choose another path with --output.") } -/// Writes `bytes` to `path` so that the path never holds part of the file: they go to a new file -/// in the same directory, which is then renamed into place. Without `force`, the path is first -/// claimed with an exclusive create, so an existing file (or link) is never replaced, even one that -/// appears while this runs; with it, the rename replaces what is there. -pub fn write_new(path: &Path, bytes: &[u8], force: bool) -> Result<()> { - if !force { - match OpenOptions::new().write(true).create_new(true).open(path) { - Ok(_) => {} - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - return Err(already_exists(path)); +/// The refusal to replace what is at `path` once the report is already downloaded. +pub fn not_saved(path: &Path) -> Failure { + Failure::general(format!( + "The report was downloaded but not saved: {} already exists; it was left as it is.", + clean(&path.display().to_string()) + )) + .hint("Run the command again with --force to replace it, or with --output to choose another path.") +} + +/// Checks that `directory` exists and that a file can be created in it, before anything is +/// downloaded for it. +pub fn check_writable(directory: &Path) -> Result<()> { + if !directory.is_dir() { + return Err(Failure::general(format!( + "{} is not a directory.", + clean(&directory.display().to_string()) + )) + .hint("Create it, or choose another path with --output.")); + } + Temporary::next_to(&directory.join("probe")).map(drop) +} + +/// A temporary file that is removed when it goes out of scope, however that happens. +struct Temporary { + path: PathBuf, + file: Option, +} + +impl Drop for Temporary { + fn drop(&mut self) { + // Closed first, as Windows does not remove an open file. + self.file = None; + let _ = std::fs::remove_file(&self.path); + } +} + +impl Temporary { + /// A new, empty file in the directory of `path`, under a name nothing else uses. On Unix only + /// its owner can read or write it. + fn next_to(path: &Path) -> Result { + let directory = match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + }; + let base = path + .file_name() + .map_or_else(String::new, |n| n.to_string_lossy().into_owned()); + let base = plain(&base).unwrap_or_else(|| "download".to_owned()); + let mut last = None; + for _ in 0..8 { + let mut random = [0_u8; 6]; + getrandom::fill(&mut random).map_err(|e| { + Failure::general(format!("Could not pick a temporary file name: {e}.")) + })?; + let suffix: String = random.iter().map(|b| format!("{b:02x}")).collect(); + let temporary = directory.join(format!(".{base}.{suffix}.part")); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + match options.open(&temporary) { + Ok(file) => { + return Ok(Self { + path: temporary, + file: Some(file), + }); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = Some(e), + Err(e) => return Err(cannot_write(path, &e)), } - Err(e) => return Err(cannot_write(path, &e)), } + Err(cannot_write( + path, + &last.unwrap_or_else(|| std::io::Error::other("no free temporary name")), + )) } - let written = write_and_rename(path, bytes); - if written.is_err() && !force { - // The empty file claimed above; nothing else is removed. - let _ = std::fs::remove_file(path); +} + +/// Writes `bytes` to `path` so that the path never holds part of the file: they go to a new file +/// in the same directory, written and synced in full, which is then put in place. Without `force` +/// it is linked under the final name, which fails when something (a file, or a link, even a +/// broken one) is there, even if it appeared while this ran, and never follows a link; where hard +/// links are not available the path is claimed with an exclusive create and the file renamed over +/// it. With `force`, the rename replaces what is there, keeping its permissions on Unix. +pub fn write_new(path: &Path, bytes: &[u8], force: bool) -> Result<()> { + let mut temporary = Temporary::next_to(path)?; + if let Some(mut file) = temporary.file.take() { + // Closed when it goes out of scope, before it is linked or renamed: Windows refuses to + // rename an open file. + file.write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(|e| cannot_write(path, &e))?; + } + if force { + replace(&temporary, path) + } else { + publish(&temporary, path) } - written } -fn write_and_rename(path: &Path, bytes: &[u8]) -> Result<()> { - let (temporary, mut file) = temporary_next_to(path)?; - let written = file.write_all(bytes).and_then(|()| file.sync_all()); - // Closed before the rename, which Windows refuses on an open file. - drop(file); - written - .and_then(|()| std::fs::rename(&temporary, path)) - .map_err(|e| { - let _ = std::fs::remove_file(&temporary); - cannot_write(path, &e) - }) +/// Renames the temporary file over `path`, with the permissions of the file it replaces. +fn replace(temporary: &Temporary, path: &Path) -> Result<()> { + #[cfg(unix)] + if let Ok(existing) = path.symlink_metadata() + && existing.is_file() + { + std::fs::set_permissions(&temporary.path, existing.permissions()) + .map_err(|e| cannot_write(path, &e))?; + } + std::fs::rename(&temporary.path, path).map_err(|e| cannot_write(path, &e)) } -/// A new, empty file in the directory of `path`, under a name nothing else uses. -fn temporary_next_to(path: &Path) -> Result<(PathBuf, std::fs::File)> { - let directory = match path.parent() { - Some(parent) if !parent.as_os_str().is_empty() => parent, - _ => Path::new("."), - }; - let base = path - .file_name() - .map_or_else(String::new, |n| n.to_string_lossy().into_owned()); - let base = plain(&base).unwrap_or_else(|| "download".to_owned()); - let mut last = None; - for _ in 0..8 { - let mut random = [0_u8; 6]; - getrandom::fill(&mut random) - .map_err(|e| Failure::general(format!("Could not pick a temporary file name: {e}.")))?; - let suffix: String = random.iter().map(|b| format!("{b:02x}")).collect(); - let temporary = directory.join(format!(".{base}.{suffix}.part")); - match OpenOptions::new() - .write(true) - .create_new(true) - .open(&temporary) - { - Ok(file) => return Ok((temporary, file)), - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last = Some(e), - Err(e) => return Err(cannot_write(path, &e)), +/// Puts the temporary file at `path` without replacing anything. +fn publish(temporary: &Temporary, path: &Path) -> Result<()> { + match std::fs::hard_link(&temporary.path, path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(already_exists(path)), + // Not every file system has hard links (FAT, some network shares). + Err(_) => publish_by_placeholder(temporary, path), + } +} + +/// [`publish`] where there are no hard links: the path is claimed with an exclusive create, then +/// the temporary file is renamed over that empty file. +fn publish_by_placeholder(temporary: &Temporary, path: &Path) -> Result<()> { + match OpenOptions::new().write(true).create_new(true).open(path) { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(already_exists(path)); } + Err(e) => return Err(cannot_write(path, &e)), } - Err(cannot_write( - path, - &last.unwrap_or_else(|| std::io::Error::other("no free temporary name")), - )) + std::fs::rename(&temporary.path, path).map_err(|e| { + // The empty file claimed above; nothing else is removed. + let _ = std::fs::remove_file(path); + cannot_write(path, &e) + }) } fn cannot_write(path: &Path, error: &std::io::Error) -> Failure { @@ -160,14 +275,52 @@ mod tests { #[test] fn a_plain_name_is_kept() { assert_eq!(name("monthly-2026-09.pdf"), "monthly-2026-09.pdf"); - assert_eq!(name("Board_Report.v2.xlsx"), "Board_Report.v2.xlsx"); + assert_eq!(name("monthly-2026-09"), "monthly-2026-09.pdf"); + } + + #[test] + fn the_server_cannot_choose_the_extension() { + assert_eq!(name("conftest.py"), "conftest.pdf"); + assert_eq!(name("x.library-ms"), "x.pdf"); + assert_eq!(name("a.pdf.exe"), "a.pdf"); + assert_eq!(name("a.b.pdf"), "a.pdf"); + assert_eq!(name("Board_Report.v2.xlsx"), "Board_Report.pdf"); + assert_eq!(name("evil.url"), "evil.pdf"); + assert_eq!( + file_name(Some("board.pdf"), "r-1", "xlsx"), + "board.xlsx", + "the extension is the one of the format" + ); + } + + #[test] + fn a_name_with_no_stem_left_falls_back_to_the_id() { + for suggested in ["_.pdf", "__.exe", "...", "NUL.exe"] { + assert_eq!( + file_name(Some(suggested), "r-1", "pdf"), + "report-r-1.pdf", + "{suggested:?}" + ); + } + } + + #[test] + fn the_extension_comes_from_the_format_then_the_content_type() { + let xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + assert_eq!(extension(Some("xlsx"), Some("application/pdf")), "xlsx"); + assert_eq!(extension(Some("pdf"), Some(xlsx)), "pdf"); + assert_eq!(extension(None, Some(xlsx)), "xlsx"); + assert_eq!(extension(None, Some("Application/PDF; charset=x")), "pdf"); + assert_eq!(extension(None, Some("text/html")), "pdf"); + assert_eq!(extension(Some("docx"), Some(xlsx)), "xlsx"); + assert_eq!(extension(None, None), "pdf"); } #[test] fn only_the_last_component_is_kept() { - assert_eq!(name("../../etc/passwd"), "passwd"); + assert_eq!(name("../../etc/passwd"), "passwd.pdf"); assert_eq!(name("a/b\\c.pdf"), "c.pdf"); - assert_eq!(name("C:\\Windows\\system.ini"), "system.ini"); + assert_eq!(name("C:\\Windows\\system.ini"), "system.pdf"); assert_eq!(name("/absolute/report.pdf"), "report.pdf"); } @@ -175,14 +328,14 @@ mod tests { fn unsafe_characters_become_underscores() { assert_eq!(name("a\u{1b}[31mb.pdf"), "a__31mb.pdf"); assert_eq!(name("re port\r\n.pdf"), "re_port__.pdf"); - assert_eq!(name("informe\u{202e}fdp.exe"), "informe_fdp.exe"); + assert_eq!(name("informe\u{202e}fdp.exe"), "informe_fdp.pdf"); assert_eq!(name("a:b*c?.pdf"), "a_b_c_.pdf"); assert_eq!(name("año.pdf"), "a_o.pdf"); } #[test] fn hidden_option_like_and_dot_names_are_not_kept_as_such() { - assert_eq!(name(".bashrc"), "bashrc"); + assert_eq!(name(".bashrc"), "bashrc.pdf"); assert_eq!(name("-rf.pdf"), "rf.pdf"); assert_eq!(name("report.pdf..."), "report.pdf"); } @@ -237,4 +390,83 @@ mod tests { .collect(); assert_eq!(left, ["report.pdf"], "no temporary file is left behind"); } + + #[test] + fn publishing_refuses_an_existing_file_and_leaves_no_temporary_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("report.pdf"); + std::fs::write(&path, b"mine").expect("write"); + let temporary = Temporary::next_to(&path).expect("temporary"); + let failure = publish(&temporary, &path).expect_err("hard link refuses"); + assert!(failure.message.contains("already exists")); + let failure = publish_by_placeholder(&temporary, &path).expect_err("placeholder refuses"); + assert!(failure.message.contains("already exists")); + drop(temporary); + assert_eq!(std::fs::read(&path).expect("read"), b"mine"); + assert_eq!(entries(dir.path()), ["report.pdf"]); + write_new(&path, b"other", false).expect_err("exists"); + assert_eq!(entries(dir.path()), ["report.pdf"]); + } + + #[test] + fn the_placeholder_fallback_publishes_the_whole_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("report.pdf"); + let mut temporary = Temporary::next_to(&path).expect("temporary"); + let mut file = temporary.file.take().expect("file"); + file.write_all(b"whole").expect("write"); + drop(file); + publish_by_placeholder(&temporary, &path).expect("published"); + drop(temporary); + assert_eq!(std::fs::read(&path).expect("read"), b"whole"); + assert_eq!(entries(dir.path()), ["report.pdf"]); + } + + #[test] + fn a_failed_write_leaves_nothing_behind() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("missing").join("report.pdf"); + write_new(&path, b"x", false).expect_err("no directory"); + assert!(entries(dir.path()).is_empty()); + // A directory where the file should go: the rename is refused. + let path = dir.path().join("taken"); + std::fs::create_dir(&path).expect("mkdir"); + write_new(&path, b"x", true).expect_err("a directory"); + assert_eq!(entries(dir.path()), ["taken"]); + } + + #[test] + fn a_directory_is_checked_before_a_download() { + let dir = tempfile::tempdir().expect("tempdir"); + check_writable(dir.path()).expect("writable"); + assert!(entries(dir.path()).is_empty()); + let missing = dir.path().join("nope"); + let failure = check_writable(&missing).expect_err("missing"); + assert!(failure.message.contains("is not a directory")); + } + + fn entries(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .expect("list") + .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names + } + + #[cfg(unix)] + #[test] + fn a_saved_file_is_readable_by_its_owner_only_and_force_keeps_the_mode_it_had() { + use std::os::unix::fs::PermissionsExt; + let mode = |p: &Path| p.metadata().expect("metadata").permissions().mode() & 0o777; + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("report.pdf"); + write_new(&path, b"first", false).expect("new file"); + assert_eq!(mode(&path), 0o600); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).expect("chmod"); + write_new(&path, b"second", true).expect("forced"); + assert_eq!(std::fs::read(&path).expect("read"), b"second"); + assert_eq!(mode(&path), 0o640); + assert_eq!(entries(dir.path()), ["report.pdf"]); + } } diff --git a/crates/hodeishield-cli/tests/cli.rs b/crates/hodeishield-cli/tests/cli.rs index b317974..4fb7f15 100644 --- a/crates/hodeishield-cli/tests/cli.rs +++ b/crates/hodeishield-cli/tests/cli.rs @@ -2988,6 +2988,14 @@ fn an_existing_file_is_not_replaced_without_force() { .output() .expect("run"); assert_eq!(output.status.code(), Some(1), "{output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("downloaded but not saved") + && stderr.contains("r.pdf already exists") + && stderr.contains("--force") + && stderr.contains("--output"), + "{stderr}" + ); assert_eq!( std::fs::read(cwd.path().join("r.pdf")).expect("read"), b"also mine" @@ -3013,13 +3021,17 @@ fn an_existing_file_is_not_replaced_without_force() { } #[test] -fn a_hostile_suggested_name_is_saved_as_a_plain_name_in_the_current_directory() { +fn a_hostile_suggested_name_is_saved_as_a_plain_stem_with_the_extension_of_the_format() { let fallback = format!("report-{REPORT_ID}.pdf"); for (disposition, expected) in [ - (r#"attachment; filename="../../etc/passwd""#, "passwd"), + (r#"attachment; filename="../../etc/passwd""#, "passwd.pdf"), + (r#"attachment; filename="conftest.py""#, "conftest.pdf"), + (r#"attachment; filename="x.library-ms""#, "x.pdf"), + (r#"attachment; filename="a.pdf.exe""#, "a.pdf"), + (r#"attachment; filename="a.b.pdf""#, "a.pdf"), (r#"attachment; filename="a/b\\c.pdf""#, "c.pdf"), (r#"attachment; filename="..""#, fallback.as_str()), - (r#"attachment; filename=".hidden rc""#, "hidden_rc"), + (r#"attachment; filename=".hidden rc""#, "hidden_rc.pdf"), (r#"attachment; filename="-rf *.pdf""#, "rf__.pdf"), ( "attachment; filename=\"\u{202e}fdp.exe\"", @@ -3046,6 +3058,29 @@ fn a_hostile_suggested_name_is_saved_as_a_plain_name_in_the_current_directory() } } +#[test] +fn the_extension_follows_the_content_type_when_no_format_is_given() { + let env = Env::new(); + let cwd = tempfile::tempdir().expect("tempdir"); + let mut server = mockito::Server::new(); + server + .mock("GET", format!("/v1/reports/{REPORT_ID}/download").as_str()) + .match_query(mockito::Matcher::Any) + .with_header( + "content-type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + .with_header("content-disposition", r#"attachment; filename="board.dll""#) + .with_body(b"PK") + .create(); + env.api(&server) + .current_dir(cwd.path()) + .args(["reports", "download", REPORT_ID]) + .assert() + .success(); + assert_eq!(entries(cwd.path()), ["board.xlsx"]); +} + #[test] fn output_dash_writes_the_file_to_standard_output() { let env = Env::new(); From 454ea3c63fefa9ac198e4bc2b056fa4291357f29 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:25:33 +0200 Subject: [PATCH 19/25] Explain how to open a blocked macOS binary The README now describes the System Settings route (Privacy & Security, Open Anyway), says that unpacking with tar in Terminal keeps the quarantine flag while a curl download carries none, and notes that the Gatekeeper dialog follows the system language. Refs #16 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- CHANGELOG.md | 3 +++ README.md | 11 +++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b4c2a..4b155ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,9 @@ commands, `--json` output and exit codes. Each release's notes start with its se not trusted, so the earlier `brew tap` then `brew install hodeishield` failed. The README also says how to trust the formula when the tap was added before. ([#179](https://github.com/Hodeitek/hodeishield-cli/issues/179)) +- The README explains the System Settings route to open a blocked macOS binary, that unpacking with + `tar` in Terminal keeps the block, and that the dialog follows the system language. + ([#16](https://github.com/Hodeitek/hodeishield-cli/issues/16)) ## [0.4.4] - 2026-10-15 diff --git a/README.md b/README.md index 994d2fa..585f1a7 100644 --- a/README.md +++ b/README.md @@ -252,13 +252,20 @@ before 0.2.0, a copy downloaded with a browser can be blocked or flagged. The co the proof that the file is genuine; once it passes, unblock the binary as follows. **macOS.** Gatekeeper may refuse to open `hodeishield` ("Apple could not verify… is free of -malware"). Remove the quarantine attribute from the unpacked binary: +malware"). Verify the download first, then allow the binary in System Settings: after the first +blocked attempt, open **System Settings → Privacy & Security**, scroll down to the message about +`hodeishield`, click **Open Anyway**, confirm, and run the command again. The dialog's wording and +button labels follow the system language, so they may differ from the English text quoted here. + +Or remove the quarantine attribute from the unpacked binary: ```sh xattr -d com.apple.quarantine ./hodeishield # "No such xattr" means there was nothing to remove ``` -Or download with `curl`, which does not set that attribute in the first place: +A browser download sets that attribute, and unpacking the archive with `tar` in Terminal keeps it +on the binary, so the block still applies. A download with `curl` does not set it in the first +place: ```sh curl -LO https://github.com/Hodeitek/hodeishield-cli/releases/download/v0.4.4/hodeishield-0.4.4-universal-apple-darwin.tar.gz From 86e430a06fd5d7ae2d9578c2c39ac914644a555b Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:29:12 +0200 Subject: [PATCH 20/25] Publish a security.txt and check its expiry Add .well-known/security.txt (RFC 9116) with the private reporting link, the security address, the preferred languages, the policy and the canonical URL. SECURITY.md, the risk assessment and the technical documentation point to it. A weekly workflow (and any pull request that changes the file or the check) validates the file and fails when it is invalid, has expired or has less than 60 days left; the scheduled run opens an issue labelled security-txt and closes it when the check passes again. Closes #125 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .../security-txt-check.cpython-313.pyc | Bin 0 -> 8437 bytes .github/scripts/security-txt-check-test.py | 119 ++++++++++++++++ .github/scripts/security-txt-check.py | 130 ++++++++++++++++++ .github/workflows/security-txt.yml | 112 +++++++++++++++ .well-known/security.txt | 8 ++ CHANGELOG.md | 5 + SECURITY.md | 4 + docs/risk-assessment.md | 2 +- docs/technical-documentation.md | 2 +- 9 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/__pycache__/security-txt-check.cpython-313.pyc create mode 100755 .github/scripts/security-txt-check-test.py create mode 100755 .github/scripts/security-txt-check.py create mode 100644 .github/workflows/security-txt.yml create mode 100644 .well-known/security.txt diff --git a/.github/scripts/__pycache__/security-txt-check.cpython-313.pyc b/.github/scripts/__pycache__/security-txt-check.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d4a24ccd907fb2e1f7088d56ef8ee8154bbf48ac GIT binary patch literal 8437 zcmb7JeQX;?cAw?8N8!d8=U?oXQqtsv`p!a5|pU{T_-pzWrh~pl%2BpFC#=BLTE}8AWKw~S__)8!A}!t zQCdoG%1Ws;Na3ZtNfcE@Rc;&o83=K5jtRfOlfD~FJnnsgP26BRc_zHT#e_wla8n@Z zC>>*YfeehEBnP^>j*&DQAvtC~#zuHjm}AI!_%6VY;oyG)KC#RVGA8&;rXWv889$j^ z6y_3a59u8zebJaOw=mPmhq+i%;ESBRz{wlNlK7H~WN38!+{iUD;`yUHZT%rYFThc`~M(FNFCen^dJ z=)(z6fv_N%7&go;CWTmnmF#pFt5ckbMDeoosn+L1!``d3H+Ah+x2OM=HgDnY5%0jY zTV2rNZg+(XW&u@@N*)XqdjwGce|@C%7XDL2FO0&a5}p1pgcjgm z@@P;K(B@mqpUN0k8`QQHKF~D?1+@X?NGR)0sT4yh_66GKkM7%F{e%9s&{q{OmJuyf zDNRsqgQKLUIEF;$Y3Qv9nD^+dQz$RMN+hGKA*e6P)PzvTqKpaHlv?SHkD;JWStVs$ zr6vP#36F>ZP}`(w#`*yVoiN4L`O^%A8?k{u?1PBGF~%QI!k1 zjj4+PMp)ojI1grHOgzGS6r1ARelp(#CX~b{LOcPM>9W*}S+sGcq@TaO3-7hJ<_gZ_Z!7q2G^TOd{h9<*_d2BFllVoJrg?Wa914A3>%S*Nob16_NM~I#@U5<9FDi}9Q0;{!YL(b<;lB^l15-| z3X(3rGLoTi0P&Iz_|7n#q`?d&gUm0VNlBYuA*{osQl!bwxx_rv89A~r z6Yq(3UgQ#sunY+vnj=h%pTnEt4aZ|%a4~S?cRrRskhCxEeCMv$$(x^E*Rt z4Xw9kfrz0lt-0I#h28mf%R8Q5dBnp{eO#4m7|k?{J~DnpY#P098N=fZww$3pW2j$s zz0>|y`>#Fgs;r?SXXwrtx;J|NcJRZ&zd7;1F#NC<)%S1LA&XNqH;S6Z9hfBAbsTD- z0n8lj{5?G0L?L+QOQ3MjDgW^sjGG7+jjU+gaEOG82Vm(SV~7wwY~>Ot)|3{6_B7HW zu3!l`0%TNG8i8x!eYph*FjsYIa+oE@sS9NPu_IlwUzEXx5Ce0>S`D@!*HhpnJtWPS z+;+j8^WgsI7|-zJbJ$!*I4;5YQdMnNUk-J6uT4d6b-&`5pKe#m!X`@8+&T`9IV9n)6 zIyoP-dEv)jfX5R0%51$e@Y;Y_?OA7X=A#+&(Jfou>Vd4SO*FJ^VSQ}Mn45Cu_KdlG zy*F#_S~|6D&BcHo0cE}#X4dfo+w zilc%>Cj&0>Izrg}g|L>%We`Tu?fW`4LZMpJtt#0~?d8-|L9Gq-6x}8Xjx|)&mqFkb z!4C8!=%%&699{0kYO3&{)c!XLy`I~T3+6#-%DJ46=iNh49k45OOK(bBazd;ws4LC` zoEf;=z2;GLw=e@8tl2SGGkvH+p}8N{59$?Xr|_Whd=Ku+2i*8^)$SEL-=CigWdy#@ z%6qlvtb%2;f}SwyF(fqoeOPQ>_AZzJX2PxbP!{4xkYB zi*gB?smgN8VJ(+`=LK71twC!5AmO70q_&0*D6MGb|)0Ltj$l;4XR|ipfxnibRXNUE6GmP2TgF6Gz7s0sm7jStgdj( z&xco$ppC3y79B*P7G(wXh&~LfZQF-SQ_u!9pHO(oR<0?{s2NcetZmqq=a8?@60_)p zx*5$9?gOdwMS!bdHIEz)PRE&9!R;&6F%X@=(WM*^E3qd8a~Pe@Fc1*a5noDm)0?s_ zuyWX6fS??X6jC+h`~m=Bj4El4#6cxI!VHH`?UeImKHS4Rz(4X3IHvmB<^&1LJ-YoHvFjoB=;@RK(#hhr?RWVegf)l!@X>)#ayBP8jH%^OGDt;w8fK z1CZ4N?K|h-(FzC1n<$7QNKkr~D1%%@CkovPC0KNcFQGTJa2SHQ;Ug%D`qZ#_#DrTV z8O{w)j0K0sMkK8a%b`i-J0htCI=Tx=7>`FGk8(4~4M}zRyvXr*0)lXU$XEO_0epFE z(q99*5S@L$ajo~f(Z4dZJhaxDt2`uD9{P>zPdr|cU$nT0)X|H@@co<;zANDhghPe6 zM~Uo7ERmm_;BWw(aw-Z`+HnpepHvmj0Lg(*9`!^(O~@EaAmk<_4bFt5#>&UyWQd$S zp~*$8S@ay};b1wi%SS(u(ugxWPB4KsxC`rX2aKNLy)C%IqF2W}{Mr zWl~@h0J4vXJju3{8lp$HQw~4?GA1|(>?G~ki3^t|I5?;fhdr01qsAx3&W%XwIKxWn zC?m)alf%jc5gU%)xJq1CLsCb^h8#F&#I@r#BgUH%CJq6e8~(fP96rz`HRMkuBTj-% zO_DwS4?(&%+?;nR@sHBw{NLonYBsd-;A|-As zyz?klE9)C~8ou|zkI%tI1A47(TAh6TRz_2`G`dw$bN8iR+R_?;qHio@%}r~Q?@hlu zy)l`4(w}+KuS7!Z`sDhk*wl01aujzw_7_8Mhu03h>&YJI+&K8zfgYuwusSQc{9@DK z1IzJ;M&xMTwxEidJE_~LRrgvTYj0aR{e`)5(@=58e%mhAdF~s$pF8T)+AX`|&dt|u ziglD2yqdFzvi4AcquU=RW<|=!#l|7pkbu0E|``Xd9Lt@+0A6Ng*D3WI$RGi(>qMG)HYD{I@ zgq(FN4a*H;Q`biK1IMwncJr|3y<6|z+UU7|xG!t(zjwHAO}%1VHvZC_Ioy}li1z+1 zyEEhL`_Pqj_T}u*r>`h#K%_2b0wJ+!>Vf6jLp^f#0Ru<%()gyW>dy7s*H`P;p2^w{ zFO7X+cJ5k)?G|LI5+Q`u9QgJiGFj7?{=8|`ku^4Kt87N+RwcPsyY6_mAzSH5pV~H{ z>iXp~>5dh zPHo$YjJB=FS^IWfuD;`5eaA*+roK1p=vBs%vHKO*L3l-~ZDV=o`b_;`)-hPHhpo|MX24su=j6Z);FR z!ww>h0#RJZE8^8@@ygG{pM}N93u1ge6Jayq7sVznXW>N)k7Mh?a(%zuh(~_MW8m`- zp87N0y5INP&h(*w?lYZjQ~!ZzJlm@NL#r0*yPN}dJNV$=`8#<2bOc_CrS?iADj1X^ zu;-#w1B4t$?-h5bDNQL}qO=2Q5+~jE>ZvQG{6g4~%Z1Ya$Or>vq)Y=Unc`k)ykZQ- zsvu>#$8Cm!XK4s?L-;t9OQ2X&S|E$C2V&owrh2gzQ1GbY0i?iu(FX>biNcnPki@?ztEe4dI9&|xIb z$Y6;ZgU)$yG=jiBcEG3|m$2b~%Imboe%N+dpS3n7u(3Z72XLa4Px_(=m>pGd~ zI+^Vn`Iydhoz7Utmqs@=rktiWqp4lht%ZMkT*URKOO4jQK6!uOjNE#*)M^yluE}lB zZ<-zX1mEgp*4&a$?WJB#ZP{vaw&skjIcM``Y~JIziVFYUX89^5p9Pb7}@~+R5ksl-o9zC*wPr&lXtG%zLGvG)^@CquMci8qUrd3 z&52F3<&U*M{WGiY`>)K-9i#$DO7z4=?>e)7NwoFeHyqotSA4&%g%02GkV!+III2c{ z=#wWMqfe+mIc9+Rr%#whJM^C(R^xhy{PdZ|n$(~6H;mP)e_yMGx@4H1#=8l6j7olo?td4SK5Iz$}gOb~~2j+`GJ`08>#2`0~V<0gCv=dn44zp{d0`uNsk zdRo%pOJ;5czPyj*1Z-ZJ49Djak%c%j#If)RR0;8a29IquK@blOdP1|~L`22s$o@Gp zevV9E+TBZ*&8iv^IsV-^xuk>pfqRY<8N-Ps?U#@u_P^#AYmR5lCzcG~= 1: + problems.append(f"{name} must appear only once") + + contacts = fields.get("contact", []) + if not contacts: + problems.append("Contact is missing") + for value in contacts: + if not re.match(r"(https://\S+|mailto:[^@\s]+@[^@\s]+|tel:\+?[0-9 .()-]+)$", value): + problems.append(f"Contact must be an https:, mailto: or tel: URI: {value!r}") + + for name in ("policy", "canonical", "encryption", "acknowledgments", "hiring", "csaf"): + for value in fields.get(name, []): + if not re.match(r"https://\S+$", value): + problems.append(f"{name} must be an https: URL: {value!r}") + + for value in fields.get("preferred-languages", []): + tags = [tag.strip() for tag in value.split(",")] + if not all(LANGUAGE.match(tag) for tag in tags): + problems.append(f"Preferred-Languages is not a list of language tags: {value!r}") + + days_left = None + expires = fields.get("expires", []) + if not expires: + problems.append("Expires is missing") + elif len(expires) == 1: + try: + when = parse_expires(expires[0]) + except ValueError as error: + problems.append(f"Expires is not an ISO 8601 date and time with a time zone ({error}): {expires[0]!r}") + else: + delta = when - now + days_left = delta.days + if delta <= datetime.timedelta(0): + problems.append(f"the file expired on {expires[0]}") + elif delta > datetime.timedelta(days=366): + problems.append("Expires is more than a year ahead; RFC 9116 recommends less than a year") + elif delta < datetime.timedelta(days=warn_days): + problems.append( + f"the file expires on {expires[0]}, in {days_left} days (less than {warn_days}); renew it" + ) + return problems, days_left + + +def main(argv): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("file", nargs="?", default=DEFAULT_FILE) + parser.add_argument("--warn-days", type=int, default=DEFAULT_WARN_DAYS) + parser.add_argument("--today", help="UTC date to check against, for tests (default: now)") + args = parser.parse_args(argv) + if args.today: + now = datetime.datetime.fromisoformat(args.today).replace(tzinfo=datetime.timezone.utc) + else: + now = datetime.datetime.now(datetime.timezone.utc) + try: + with open(args.file, encoding="utf-8") as handle: + text = handle.read() + except OSError as error: + print(f"{args.file}: cannot be read: {error}") + return 1 + problems, days_left = check(text, now, args.warn_days) + for problem in problems: + print(f"{args.file}: {problem}") + if problems: + return 1 + print(f"{args.file}: valid, expires in {days_left} days") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/workflows/security-txt.yml b/.github/workflows/security-txt.yml new file mode 100644 index 0000000..5de9546 --- /dev/null +++ b/.github/workflows/security-txt.yml @@ -0,0 +1,112 @@ +name: security.txt + +# Once a week (and on demand) checks .well-known/security.txt against RFC 9116 and that it does not +# expire within 60 days, so that the maintainers renew it in time (see +# .github/scripts/security-txt-check.py and SECURITY.md). On a pull request that changes the file or +# the check, it runs the check's tests and the check, and fails on the same conditions. +# +# It reads the repository only, uses no secret and no environment, and writes nothing but an issue +# labelled `security-txt`: opened (or commented on) while the check fails, and closed when it passes +# again. +on: + schedule: + - cron: "41 6 * * 1" # Mondays, 06:41 UTC + workflow_dispatch: + pull_request: + paths: + - ".well-known/security.txt" + - ".github/scripts/security-txt-check*" + - ".github/workflows/security-txt.yml" + +permissions: {} + +concurrency: + group: security-txt-${{ github.ref }} + cancel-in-progress: false + +env: + LABEL: security-txt + +jobs: + check: + name: check security.txt + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read # the checkout + issues: write # the issue and its label; not used on a pull request + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Test the check + run: python3 -I .github/scripts/security-txt-check-test.py + + - name: Check the file + id: check + run: | + set -uo pipefail + report="$RUNNER_TEMP/report.txt" + status=0 + python3 -I .github/scripts/security-txt-check.py --warn-days 60 > "$report" 2>&1 || status=$? + cat "$report" + if [ "$status" = 0 ]; then result=pass; else result=fail; fi + echo "result=$result" >> "$GITHUB_OUTPUT" + + - name: Report the result + if: "!cancelled() && github.event_name != 'pull_request'" + env: + GH_TOKEN: ${{ github.token }} + RESULT: ${{ steps.check.outputs.result }} + run: | + set -euo pipefail + report="$RUNNER_TEMP/report.txt" + [ -f "$report" ] || echo "No report: an earlier step failed before the check ran (see the run)." > "$report" + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + { + echo "### security.txt: ${RESULT:-fail}" + echo + echo '```' + head -c 6000 "$report" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + issue=$(gh issue list -R "$GITHUB_REPOSITORY" --label "$LABEL" --state open --json number --jq '.[0].number // empty') + case ${RESULT:-fail} in + pass) + if [ -n "$issue" ]; then + gh issue close "$issue" -R "$GITHUB_REPOSITORY" --reason completed \ + --comment "security.txt is valid and does not expire soon ($run_url)." + fi + ;; + *) + { + echo "The weekly check of \`.well-known/security.txt\` failed: the file is invalid, has expired, or expires in less than 60 days." + echo "Renew it by moving \`Expires:\` to about eleven months ahead (RFC 9116 recommends less than a year), in a pull request." + echo + echo '```' + head -c 6000 "$report" + echo '```' + echo + echo "Run: $run_url" + } > "$RUNNER_TEMP/issue.md" + if [ -n "$issue" ]; then + gh issue comment "$issue" -R "$GITHUB_REPOSITORY" --body-file "$RUNNER_TEMP/issue.md" + else + gh label list -R "$GITHUB_REPOSITORY" --search "$LABEL" --json name --jq '.[].name' | grep -qx "$LABEL" || + gh label create "$LABEL" -R "$GITHUB_REPOSITORY" --color B60205 \ + --description "security.txt is invalid or about to expire" + gh issue create -R "$GITHUB_REPOSITORY" --label "$LABEL" \ + --title "security.txt needs to be renewed or fixed" \ + --body-file "$RUNNER_TEMP/issue.md" + fi + ;; + esac + + - name: Fail if the check failed + if: "!cancelled() && steps.check.outputs.result != 'pass'" + run: | + echo "::error::The security.txt check did not pass; see the report above." + exit 1 diff --git a/.well-known/security.txt b/.well-known/security.txt new file mode 100644 index 0000000..f63f79b --- /dev/null +++ b/.well-known/security.txt @@ -0,0 +1,8 @@ +# Security contact for the hodeishield command-line client (RFC 9116). +# The policy, response times and disclosure terms are in SECURITY.md. +Contact: https://github.com/Hodeitek/hodeishield-cli/security/advisories/new +Contact: mailto:security@hodeitek.com +Expires: 2027-09-15T00:00:00.000Z +Preferred-Languages: en, es +Policy: https://github.com/Hodeitek/hodeishield-cli/blob/main/SECURITY.md +Canonical: https://raw.githubusercontent.com/Hodeitek/hodeishield-cli/main/.well-known/security.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 57b4c2a..a6c88d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ commands, `--json` output and exit codes. Each release's notes start with its se replaced with `--force`, a file is never left half written, and on Unix it is readable only by you. A report that is still being generated, failed, has no file in that format or is too large fails with a message that says so. ([#55](https://github.com/Hodeitek/hodeishield-cli/issues/55)) +- A `security.txt` (RFC 9116) for this repository, at `.well-known/security.txt` on `main`, with the + private reporting link, the security address, the preferred languages and the link to + `SECURITY.md`; `SECURITY.md` points to it. A weekly check validates the file and opens an issue + when it is invalid or has less than 60 days left before its expiry date. + ([#125](https://github.com/Hodeitek/hodeishield-cli/issues/125)) ### Changed diff --git a/SECURITY.md b/SECURITY.md index 3b26943..a6dcbd1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,6 +20,10 @@ advisory if you want to be credited. - Machine-readable contact: [security.txt](https://hodeishield.com/.well-known/security.txt) (RFC 9116), for the whole HodeiShield product. The same file is served on `app.hodeishield.com` and `docs.hodeishield.com`. +- Machine-readable contact for this CLI: [security.txt](.well-known/security.txt) (RFC 9116), published in + this repository at `.well-known/security.txt` on `main`, where it is served as + . It is + not signed. A weekly check validates it and opens an issue when it has less than 60 days left. - Encrypted email: not offered at the moment. - Bug bounty: there is no bounty programme; we credit reporters publicly in the advisory. diff --git a/docs/risk-assessment.md b/docs/risk-assessment.md index 97a39af..1c8572a 100644 --- a/docs/risk-assessment.md +++ b/docs/risk-assessment.md @@ -112,7 +112,7 @@ point is paraphrased from the Official Journal. | (3) | Effective and regular tests and reviews of security | The tests, scans and their schedule are in [CONTRIBUTING.md](../CONTRIBUTING.md#security-testing) (`ci.yml`, `security-tests.yml`, `workflow-lint.yml`, `scorecard.yml`). This document is reviewed as described in [Review](#review) | | (4) | Disclose information about fixed vulnerabilities once an update is available | Every fixed vulnerability gets a GitHub security advisory written from [security-advisory-template.md](security-advisory-template.md): description, affected and fixed versions, severity, workarounds ([SECURITY.md](../SECURITY.md#how-advisories-are-published)) | | (5) | A coordinated vulnerability disclosure policy | [SECURITY.md](../SECURITY.md#coordinated-disclosure): confidentiality until the advisory, 90 days, safe harbour | -| (6) | Facilitate sharing information about vulnerabilities, with a contact address | Private reporting on GitHub, `security@hodeitek.com` and `security.txt` ([SECURITY.md](../SECURITY.md#reporting-a-vulnerability)). Vulnerabilities found in third-party components are reported to their maintainers, with any fix, and recorded as described in [third-party-components.md](third-party-components.md#reporting-a-vulnerability-to-the-components-maintainers) | +| (6) | Facilitate sharing information about vulnerabilities, with a contact address | Private reporting on GitHub, `security@hodeitek.com` and `security.txt` ([.well-known/security.txt](../.well-known/security.txt); [SECURITY.md](../SECURITY.md#reporting-a-vulnerability)). Vulnerabilities found in third-party components are reported to their maintainers, with any fix, and recorded as described in [third-party-components.md](third-party-components.md#reporting-a-vulnerability-to-the-components-maintainers) | | (7) | Mechanisms to securely distribute updates; automatic where applicable | Releases are signed and verifiable ([verifying-releases.md](verifying-releases.md), [release-signing.md](release-signing.md)); each channel (release archive, Homebrew, Scoop, packages, MSI, `install.sh`, container image, `cargo install`) has its update command ([user-information.md](user-information.md#updating)). Automatic installation does not apply (see [No automatic updates](#no-automatic-updates)). The APT and DNF repository key is tracked in [#114](https://github.com/Hodeitek/hodeishield-cli/issues/114) | | (8) | Disseminate security updates without delay, free of charge, with advisory messages | Updates are public downloads that need no subscription ([SECURITY.md](../SECURITY.md#supported-versions)). They are announced in the advisory, the notice at the top of the README and the release notes, in that order ([SECURITY.md](../SECURITY.md#security-notices)) | diff --git a/docs/technical-documentation.md b/docs/technical-documentation.md index d5119a1..aa41cea 100644 --- a/docs/technical-documentation.md +++ b/docs/technical-documentation.md @@ -91,7 +91,7 @@ first one that contains this file were published before this documentation exist | Identification and monitoring of vulnerabilities and components | [risk-assessment.md](risk-assessment.md#requirements-of-annex-i-part-ii), point (1), and [CONTRIBUTING.md](../CONTRIBUTING.md#security-testing) | | Vulnerabilities in third-party components | Assessment, fix and release rule, reporting to the component's maintainers with any fix, and the record of reports ([third-party-components.md](third-party-components.md#when-a-component-has-a-vulnerability); Article 13(6)) | | Coordinated vulnerability disclosure policy | [SECURITY.md](../SECURITY.md#coordinated-disclosure) | -| Point of contact for reports | Private reporting on GitHub and security@hodeitek.com; `security.txt` ([SECURITY.md](../SECURITY.md#reporting-a-vulnerability)) | +| Point of contact for reports | Private reporting on GitHub and security@hodeitek.com; `security.txt` ([.well-known/security.txt](../.well-known/security.txt); [SECURITY.md](../SECURITY.md#reporting-a-vulnerability)) | | What happens after a report, fix targets and advisories | [SECURITY.md](../SECURITY.md#what-happens-after-you-report), [fix targets](../SECURITY.md#severity-and-fix-targets) and [advisories](../SECURITY.md#how-advisories-are-published); the advisory template is [security-advisory-template.md](security-advisory-template.md) | | Secure distribution of updates | Signed releases with checksums, Sigstore signatures and SLSA provenance ([verifying-releases.md](verifying-releases.md), [release-signing.md](release-signing.md)); the update command of each channel is in [user-information.md](user-information.md#updating). The CLI does not update itself ([risk-assessment.md](risk-assessment.md#no-automatic-updates)) | From 2d0512cc68e0420ec6114d23d06e6e2c1b0f5eb0 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:38:54 +0200 Subject: [PATCH 21/25] Keep the refused-connection test from racing other tests a_connection_error_is_retried bound a listener on an ephemeral port, dropped it and expected every connection to that port to be refused. Between the drop and the client's attempts, another test running in parallel (mockito servers bind ephemeral ports) could receive the same port, so the client got an answer and the retry count was not 2. The test now keeps the listener bound for its whole duration, so the port cannot be reused. The listener never accepts, so each attempt times out (short client timeout), which the client retries the same way as a refused connection. A reset after accept would not be retried, as it is not a connect error, so accepting and closing was not used. The assertions are unchanged: Error::Transport and exactly 2 retries. Closes #183 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- crates/hodeishield-api/tests/api.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/hodeishield-api/tests/api.rs b/crates/hodeishield-api/tests/api.rs index 5e5d391..4be6f57 100644 --- a/crates/hodeishield-api/tests/api.rs +++ b/crates/hodeishield-api/tests/api.rs @@ -585,17 +585,18 @@ fn a_503_with_retry_after_longer_than_max_wait_does_not_retry() { #[test] fn a_connection_error_is_retried() { - // Bind to port 0 to get an ephemeral port, then drop the listener to free it. - // The next connection attempt to that port will be refused. + // Keep the listener bound for the whole test so no other test can take the port. It never + // accepts, so every attempt connects (the kernel completes the handshake) and then gets no + // answer: a timeout, which is retried like a refused connection. let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("bind"); let port = listener.local_addr().expect("addr").port(); - drop(listener); let url = format!("http://127.0.0.1:{port}"); let retry_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let counted = std::sync::Arc::clone(&retry_count); let result = Client::builder(url.parse().expect("url"), SecretString::from("hsk_example")) + .timeout(Duration::from_millis(200)) .transient_retries(2, Duration::from_millis(1)) .retry_observer(move |_event| { counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -608,6 +609,7 @@ fn a_connection_error_is_retried() { assert!(matches!(result.expect_err("error"), Error::Transport(_))); let retries = retry_count.load(std::sync::atomic::Ordering::SeqCst); assert_eq!(retries, 2, "should have retried exactly 2 times"); + drop(listener); } #[test] From 52bfc293890a23604f11a2167d845746e38bcb9d Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:51:19 +0200 Subject: [PATCH 22/25] Prepare Developer ID signing and notarization for macOS The release workflow already holds the macOS signing steps behind the MACOS_SIGNING_ENABLED variable. This makes them safe to switch on: - When signing is off, the macOS job says so in the run summary and as a notice, so a skip cannot be read as a pass. - When signing is on, the job checks the Developer ID Application authority, the Team ID, the hardened runtime and the timestamp after signing, and asserts that Gatekeeper reports the binary as Notarized Developer ID and that the package is signed, stapled and accepted. It writes the result to the run summary. - A new on-demand "macOS signing check" workflow, bound to the release-signing environment, runs the same chain on a throwaway executable and package. It fails and lists the names of any missing secret. - docs/release-signing.md describes the identities, the secrets, who may change them and what to do if they are compromised; docs/verifying-releases.md explains how to verify the signatures. The signing steps stay inline in both workflows: the signing job checks nothing out and runs no repository code, and a shared script would break that. Refs #50 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- .github/workflows/macos-signing-check.yml | 206 ++++++++++++++++++++++ .github/workflows/release.yml | 77 +++++++- CHANGELOG.md | 6 + docs/release-signing.md | 90 +++++++++- docs/verifying-releases.md | 32 +++- 5 files changed, 398 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/macos-signing-check.yml diff --git a/.github/workflows/macos-signing-check.yml b/.github/workflows/macos-signing-check.yml new file mode 100644 index 0000000..0878cfb --- /dev/null +++ b/.github/workflows/macos-signing-check.yml @@ -0,0 +1,206 @@ +name: macOS signing check + +# On-demand check of the macOS signing secrets, without a release. It signs and notarizes a tiny +# throwaway executable and installer package with the stored Developer ID certificates and App +# Store Connect key, then verifies the result the way release.yml does. Nothing is published or +# uploaded. +# +# The job is bound to the `release-signing` environment, so it needs the same approval as a release +# and is the only way to read the secrets. If a secret is absent the run fails and lists the missing +# names: an explicit check never passes by skipping. +# +# The steps from the import to the verification mirror the `sign-macos` job of release.yml; keep +# the two in step. Like that job, this one checks nothing out and runs no repository code. +on: + workflow_dispatch: + +permissions: {} + +concurrency: + group: macos-signing-check + cancel-in-progress: false + +jobs: + check: + name: check the macOS signing secrets + runs-on: macos-latest + timeout-minutes: 60 + environment: release-signing + permissions: + contents: read # the job's default; nothing is checked out + steps: + - name: Check the signing inputs are present + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + MACOS_INSTALLER_CERTIFICATE: ${{ secrets.MACOS_INSTALLER_CERTIFICATE }} + MACOS_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_PASSWORD }} + MACOS_INSTALLER_IDENTITY: ${{ secrets.MACOS_INSTALLER_IDENTITY }} + APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + run: | + set -euo pipefail + missing="" + for name in MACOS_CERTIFICATE MACOS_CERTIFICATE_PASSWORD MACOS_SIGNING_IDENTITY \ + MACOS_INSTALLER_CERTIFICATE MACOS_INSTALLER_CERTIFICATE_PASSWORD \ + MACOS_INSTALLER_IDENTITY APPLE_NOTARIZATION_KEY APPLE_NOTARIZATION_KEY_ID \ + APPLE_NOTARIZATION_ISSUER_ID; do + if [ -z "${!name}" ]; then + echo "::error::The secret $name is missing from the release-signing environment." + missing="$missing $name" + fi + done + if [ -n "$missing" ]; then + { + printf '### macOS signing check\n\n' + printf "Failed: these secrets are missing from the \`release-signing\` environment:\n\n" + for name in $missing; do printf -- "- \`%s\`\n" "$name"; done + printf '\nStore them as described in docs/release-signing.md, then run this check again.\n' + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + - name: Build a universal test executable + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/check" + printf 'int main(void) { return 0; }\n' > "$RUNNER_TEMP/check/main.c" + cc -arch x86_64 -arch arm64 -o "$RUNNER_TEMP/check/signing-check" "$RUNNER_TEMP/check/main.c" + lipo -info "$RUNNER_TEMP/check/signing-check" + + - name: Import the Developer ID certificates into a temporary keychain + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_INSTALLER_CERTIFICATE: ${{ secrets.MACOS_INSTALLER_CERTIFICATE }} + MACOS_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + keychain="$RUNNER_TEMP/signing.keychain-db" + password="$(openssl rand -hex 24)" + echo "::add-mask::$password" + security create-keychain -p "$password" "$keychain" + security set-keychain-settings -lut 1800 "$keychain" + security unlock-keychain -p "$password" "$keychain" + printf '%s' "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/application.p12" + printf '%s' "$MACOS_INSTALLER_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/installer.p12" + security import "$RUNNER_TEMP/application.p12" -k "$keychain" \ + -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security import "$RUNNER_TEMP/installer.p12" -k "$keychain" \ + -P "$MACOS_INSTALLER_CERTIFICATE_PASSWORD" -T /usr/bin/productsign + rm -f "$RUNNER_TEMP/application.p12" "$RUNNER_TEMP/installer.p12" + security list-keychains -d user -s "$keychain" login.keychain + security set-key-partition-list -S apple-tool:,apple: -s -k "$password" "$keychain" > /dev/null + + - name: Sign the executable (hardened runtime, secure timestamp) + env: + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + run: | + set -euo pipefail + bin="$RUNNER_TEMP/check/signing-check" + codesign --force --timestamp --options runtime \ + --identifier com.hodeitek.hodeishield.signing-check \ + --keychain "$RUNNER_TEMP/signing.keychain-db" \ + --sign "$MACOS_SIGNING_IDENTITY" "$bin" + codesign --verify --strict --verbose=2 "$bin" + # The signature must come from a Developer ID Application certificate of a team, with the + # hardened runtime and a secure timestamp. codesign prints no secrets. + details="$(codesign --display -vv "$bin" 2>&1)" + echo "$details" + team="$(sed -n 's/^TeamIdentifier=//p' <<< "$details")" + for expected in '^Authority=Developer ID Application: ' '^CodeDirectory .*flags=.*runtime' '^Timestamp='; do + if ! grep -q -- "$expected" <<< "$details"; then + echo "::error::The signature lacks the expected '${expected#^}'." + exit 1 + fi + done + if [ -z "$team" ] || [ "$team" = "not set" ]; then + echo "::error::The signature carries no TeamIdentifier." + exit 1 + fi + + - name: Build and sign a test installer package + env: + MACOS_INSTALLER_IDENTITY: ${{ secrets.MACOS_INSTALLER_IDENTITY }} + run: | + set -euo pipefail + root="$RUNNER_TEMP/pkgroot" + mkdir -p "$root/usr/local/bin" + cp -p "$RUNNER_TEMP/check/signing-check" "$root/usr/local/bin/" + pkgbuild --root "$root" --install-location / \ + --identifier com.hodeitek.hodeishield.signing-check --version 0.0.0 \ + "$RUNNER_TEMP/unsigned.pkg" + productsign --timestamp --keychain "$RUNNER_TEMP/signing.keychain-db" \ + --sign "$MACOS_INSTALLER_IDENTITY" \ + "$RUNNER_TEMP/unsigned.pkg" "$RUNNER_TEMP/signing-check.pkg" + pkgutil --check-signature "$RUNNER_TEMP/signing-check.pkg" + + - name: Remove the temporary keychain + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/signing.keychain-db" 2>/dev/null || true + rm -f "$RUNNER_TEMP/application.p12" "$RUNNER_TEMP/installer.p12" + + - name: Notarize the executable and the package, staple the package + env: + APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + run: | + set -euo pipefail + key="$RUNNER_TEMP/notary.p8" + trap 'rm -f "$key"' EXIT + printf '%s' "$APPLE_NOTARIZATION_KEY" | base64 --decode > "$key" + notarize() { + xcrun notarytool submit "$1" --key "$key" --key-id "$APPLE_NOTARIZATION_KEY_ID" \ + --issuer "$APPLE_NOTARIZATION_ISSUER_ID" --wait --timeout 30m + } + ditto -c -k "$RUNNER_TEMP/check/signing-check" "$RUNNER_TEMP/notarize.zip" + notarize "$RUNNER_TEMP/notarize.zip" + pkg="$RUNNER_TEMP/signing-check.pkg" + notarize "$pkg" + xcrun stapler staple "$pkg" + xcrun stapler validate "$pkg" + spctl --assess --type install --verbose=2 "$pkg" + + - name: Verify the signed and notarized files + run: | + set -euo pipefail + bin="$RUNNER_TEMP/check/signing-check" + pkg="$RUNNER_TEMP/signing-check.pkg" + team="$(codesign --display -vv "$bin" 2>&1 | sed -n 's/^TeamIdentifier=//p')" + if [ -z "$team" ] || [ "$team" = "not set" ]; then + echo "::error::The signature carries no TeamIdentifier." + exit 1 + fi + accepted=false + for _ in 1 2 3 4 5 6; do + out="$(spctl --assess --type execute --verbose=2 "$bin" 2>&1 || true)" + if grep -qx 'source=Notarized Developer ID' <<< "$out"; then accepted=true; break; fi + sleep 20 + done + echo "$out" + if [ "$accepted" != true ]; then + echo "::error::Gatekeeper does not report the executable as 'Notarized Developer ID'." + exit 1 + fi + sig="$(pkgutil --check-signature "$pkg")" + echo "$sig" + if ! grep -q 'Developer ID Installer: ' <<< "$sig"; then + echo "::error::The package is not signed with a Developer ID Installer certificate." + exit 1 + fi + xcrun stapler validate "$pkg" + spctl --assess --type install --verbose=2 "$pkg" + { + printf '### macOS signing check\n\n' + printf 'Passed: the stored secrets sign and notarize with the Developer ID of team %s.\n\n' "$team" + printf -- '- All nine secrets are present.\n' + printf -- "- \`codesign --verify --strict\`: passed; Developer ID Application authority, hardened runtime and secure timestamp present.\n" + printf -- "- \`spctl --assess --type execute\`: \`source=Notarized Developer ID\`.\n" + printf -- "- \`pkgutil --check-signature\`: signed with a Developer ID Installer certificate.\n" + printf -- "- \`xcrun stapler validate\` and \`spctl --assess --type install\` on the package: passed.\n" + printf '\nOnly a throwaway test executable was signed; no release file was touched or uploaded.\n' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d740002..28a1251 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -255,6 +255,9 @@ jobs: # - a .pkg that installs /usr/local/bin/hodeishield, signed with Developer ID Installer, # notarized and stapled, so it also passes Gatekeeper offline and can be deployed by MDM. # + # The same import, sign, notarize and verify chain runs without a release in + # macos-signing-check.yml; keep the two in step. + # # Environment secrets (release-signing): MACOS_CERTIFICATE and MACOS_CERTIFICATE_PASSWORD # (Developer ID Application, base64 .p12), MACOS_SIGNING_IDENTITY, MACOS_INSTALLER_CERTIFICATE, # MACOS_INSTALLER_CERTIFICATE_PASSWORD, MACOS_INSTALLER_IDENTITY (Developer ID Installer), @@ -305,6 +308,22 @@ jobs: chmod 0755 "stage/${BIN_NAME}" find stage/man -type f -exec chmod 0644 {} \; + # A skip must never read as a pass: say so in the run summary and as an annotation. + - name: Record that macOS signing was skipped + if: env.SIGN != 'true' + env: + EVENT_NAME: ${{ github.event_name }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = push ]; then + why="MACOS_SIGNING_ENABLED is not true" + else + why="this is a manual dry run, which never signs" + fi + msg="macOS signing: skipped — ${why}; the binary is not Developer ID signed or notarized." + echo "::notice title=macOS signing skipped::${msg}" + printf '### macOS signing\n\n%s\n' "$msg" >> "$GITHUB_STEP_SUMMARY" + - name: Check the signing inputs are present if: env.SIGN == 'true' env: @@ -367,7 +386,21 @@ jobs: --keychain "$RUNNER_TEMP/signing.keychain-db" \ --sign "$MACOS_SIGNING_IDENTITY" "stage/${BIN_NAME}" codesign --verify --strict --verbose=2 "stage/${BIN_NAME}" - codesign --display --verbose=2 "stage/${BIN_NAME}" + # The signature must come from a Developer ID Application certificate of a team, with the + # hardened runtime and a secure timestamp. codesign prints no secrets. + details="$(codesign --display -vv "stage/${BIN_NAME}" 2>&1)" + echo "$details" + team="$(sed -n 's/^TeamIdentifier=//p' <<< "$details")" + for expected in '^Authority=Developer ID Application: ' '^CodeDirectory .*flags=.*runtime' '^Timestamp='; do + if ! grep -q -- "$expected" <<< "$details"; then + echo "::error::The signature lacks the expected '${expected#^}'." + exit 1 + fi + done + if [ -z "$team" ] || [ "$team" = "not set" ]; then + echo "::error::The signature carries no TeamIdentifier." + exit 1 + fi - name: Build and sign the installer package if: env.SIGN == 'true' @@ -418,6 +451,48 @@ jobs: xcrun stapler validate "$pkg" spctl --assess --type install --verbose=2 "$pkg" + # What Gatekeeper and the system tools say about the finished files, and the run summary. The + # binary cannot be stapled, so Gatekeeper looks its ticket up online; that can lag a little + # behind the notarization, hence the retries. + - name: Verify the signed and notarized files + if: env.SIGN == 'true' + run: | + set -euo pipefail + bin="stage/${BIN_NAME}" + pkg="dist/hodeishield-${VERSION}-${TARGET}.pkg" + team="$(codesign --display -vv "$bin" 2>&1 | sed -n 's/^TeamIdentifier=//p')" + if [ -z "$team" ] || [ "$team" = "not set" ]; then + echo "::error::The signature carries no TeamIdentifier." + exit 1 + fi + accepted=false + for _ in 1 2 3 4 5 6; do + out="$(spctl --assess --type execute --verbose=2 "$bin" 2>&1 || true)" + if grep -qx 'source=Notarized Developer ID' <<< "$out"; then accepted=true; break; fi + sleep 20 + done + echo "$out" + if [ "$accepted" != true ]; then + echo "::error::Gatekeeper does not report the binary as 'Notarized Developer ID'." + exit 1 + fi + sig="$(pkgutil --check-signature "$pkg")" + echo "$sig" + if ! grep -q 'Developer ID Installer: ' <<< "$sig"; then + echo "::error::The package is not signed with a Developer ID Installer certificate." + exit 1 + fi + xcrun stapler validate "$pkg" + spctl --assess --type install --verbose=2 "$pkg" + { + printf '### macOS signing\n\n' + printf 'macOS signing: signed and notarized with the Developer ID of team %s.\n\n' "$team" + printf -- "- \`codesign --verify --strict\`: passed; Developer ID Application authority, hardened runtime and secure timestamp present.\n" + printf -- "- \`spctl --assess --type execute\` on the binary: \`source=Notarized Developer ID\`.\n" + printf -- "- \`pkgutil --check-signature\` on the package: signed with a Developer ID Installer certificate.\n" + printf -- "- \`xcrun stapler validate\` and \`spctl --assess --type install\` on the package: passed.\n" + } >> "$GITHUB_STEP_SUMMARY" + - name: Package archive run: | set -euo pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ddb06..353fead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,12 @@ commands, `--json` output and exit codes. Each release's notes start with its se `SECURITY.md`; `SECURITY.md` points to it. A weekly check validates the file and opens an issue when it is invalid or has less than 60 days left before its expiry date. ([#125](https://github.com/Hodeitek/hodeishield-cli/issues/125)) +- The release workflow can sign and notarize the macOS binaries with a Developer ID: the universal + binary is signed with the hardened runtime and notarized, and a `.pkg` installer is signed, + notarized and stapled. A new on-demand workflow, "macOS signing check", tests the stored signing + secrets without a release. Until the signing identity is stored, releases say in their run summary + that macOS signing was skipped. + ([#50](https://github.com/Hodeitek/hodeishield-cli/issues/50)) ### Changed diff --git a/docs/release-signing.md b/docs/release-signing.md index dafd098..10790ef 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -87,12 +87,58 @@ The signatures of every signed release so far (0.2.0 to 0.4.4) carry: | Subject | `CN=Hodeitek S.L., O=Hodeitek S.L., L=Aretxabaleta, S=Gipuzkoa, C=ES` | | Certificate profile EKU | `1.3.6.1.4.1.311.97.449523565.535854065.272767902.439799148` | -### macOS - -macOS signing is not active ([#50](https://github.com/Hodeitek/hodeishield-cli/issues/50)): -`MACOS_SIGNING_ENABLED` is not set, and no Apple certificate or notarization key is stored. The -workflow's macOS steps would use Developer ID certificates and an App Store Connect key kept as -`release-signing` environment secrets. This document must cover them before they are used. +### Developer ID (macOS) + +The `sign-macos` job of `release.yml` signs the universal `hodeishield` binary with an Apple +Developer ID Application certificate, with the hardened runtime and a secure timestamp. It builds +`hodeishield--universal-apple-darwin.pkg`, which installs `/usr/local/bin/hodeishield`, and +signs it with a Developer ID Installer certificate. Both the binary and the package are notarized +by Apple, and the package is stapled. A bare executable cannot carry a stapled ticket, so Gatekeeper +checks the binary's notarization online on first run. See +[#50](https://github.com/Hodeitek/hodeishield-cli/issues/50). + +- **Identities.** A Developer ID Application certificate for the binary and a Developer ID Installer + certificate for the package, both issued to Hodeitek S.L. by Apple's Developer ID certification + authority. The signature carries the Apple Developer Team ID of the account. +- **Notarization.** `notarytool` authenticates with an App Store Connect API key (a `.p8` private + key, its key ID and the issuer ID). +- **Where they are kept.** Only as secrets of the `release-signing` environment; none is in the + repository or in a variable. The job imports the certificates into a temporary keychain that is + deleted at the end of the job, and writes the API key to a temporary file that is deleted too. + + | Secret | Content | + |---|---| + | `MACOS_CERTIFICATE`, `MACOS_CERTIFICATE_PASSWORD` | Developer ID Application certificate and key (`.p12`, base64) and its password | + | `MACOS_SIGNING_IDENTITY` | Name of that certificate, as `codesign` takes it | + | `MACOS_INSTALLER_CERTIFICATE`, `MACOS_INSTALLER_CERTIFICATE_PASSWORD` | Developer ID Installer certificate and key (`.p12`, base64) and its password | + | `MACOS_INSTALLER_IDENTITY` | Name of that certificate, as `productsign` takes it | + | `APPLE_NOTARIZATION_KEY` | App Store Connect API private key (`.p8`, base64) | + | `APPLE_NOTARIZATION_KEY_ID`, `APPLE_NOTARIZATION_ISSUER_ID` | Key ID and issuer ID of that key | + +- **Switch.** The repository variable `MACOS_SIGNING_ENABLED` turns the signing on, and only for a + run started by a pushed `v*` tag. The environment's secrets are visible only to a job bound to + the environment, so the workflow cannot tell in advance whether they exist: when the variable is + `true` and a secret is missing, the job fails and names it. When the variable is not `true`, the + release still builds and publishes, the macOS binary is not signed or notarized, and the run + summary says so. +- **Checking the secrets without a release.** The **macOS signing check** workflow + (`macos-signing-check.yml`, started by hand) is bound to the same environment, so it needs the + same approval. It signs and notarizes a throwaway test executable and package with the stored + secrets, verifies them like a release does, and uploads nothing. It fails, listing the names, if a + secret is missing. + +To enable it, a maintainer with the right to change the environment: + +1. stores the nine secrets in the `release-signing` environment; +2. lets the **macOS signing check** run: the environment accepts deployments from `v*` tags only, so + a repository administrator adds `main`, where the workflow runs, to its allowed branches for the + check and removes it afterwards; +3. runs **macOS signing check** and reads its summary: it must pass; +4. sets the repository variable `MACOS_SIGNING_ENABLED` to `true`. + +The first release signed this way is the first one whose run summary says +"macOS signing: signed and notarized". Releases before it are not signed with a Developer ID. To +check a download, see [verifying-releases.md](verifying-releases.md#5-platform-signatures-windows-macos). ### Package repository key @@ -111,11 +157,16 @@ separately in [#114](https://github.com/Hodeitek/hodeishield-cli/issues/114). runs after the Windows signing jobs, which wait for an approval, but needs no approval of its own: a `release.yml` changed at a tag could sign without one. - **The `release-signing` environment.** Every job that uses it waits for a required reviewer, and - only a `v*` tag can deploy to it. It holds the Azure identifiers and the signing account names. + only a `v*` tag can deploy to it. It holds the Azure identifiers and the signing account names, + and the Apple certificates and notarization key. - **The `packaging` environment.** The same protections. It gates the package managers and the container image. - **Repository settings.** Repository administrators manage the repository variables, the environments with their reviewers, secrets and variables, and the ruleset. +- **Apple Developer account.** The Developer ID certificates are issued, and revoked, in the Apple + Developer account of the organisation, and the notarization key in App Store Connect. Only the + account's Account Holder can create or revoke Developer ID certificates. Storing or replacing the + secrets needs administrator rights on the repository. - **Azure.** The signing account, its identity validations and certificate profiles are managed in the Azure subscription of the account. One account holds the Owner role on that subscription. Revoking a certificate needs that role and is done in the Azure portal. The app registration has @@ -231,10 +282,33 @@ the profile that the release workflow did not sign. document and in [verifying-releases.md](verifying-releases.md#6-releases-after-a-signing-change). Windows users check the new signature as described in -[verifying-releases.md](verifying-releases.md#5-platform-signatures-windows-macos-in-a-later-release): +[verifying-releases.md](verifying-releases.md#5-platform-signatures-windows-macos): `Status` is `Valid`, the subject names Hodeitek S.L., a timestamp is present, and the profile EKU is the new one given in the advisory. +## If the macOS Developer ID signing is compromised + +This applies when a Developer ID certificate or its private key, the notarization key or the +`release-signing` environment is compromised, or when a file is found signed with the certificate +that the release workflow did not sign. + +1. **Stop signing.** Set the repository variable `MACOS_SIGNING_ENABLED` to `false`, reject any + pending deployment to `release-signing` and cancel running Release workflow runs. A release + built meanwhile has an unsigned macOS binary and no `.pkg`: do not publish one before step 3. +2. **Revoke.** In the Apple Developer account, revoke the affected Developer ID certificate or + certificates. In App Store Connect, revoke the notarization API key. Apple stops trusting new + signatures made with a revoked certificate. Binaries notarized earlier stay valid unless Apple + revokes their notarization tickets, which the maintainers can ask Apple to do for files known to + be malicious. +3. **Replace.** Create new certificates and a new API key, store them as the secrets listed under + [Developer ID (macOS)](#developer-id-macos), run the **macOS signing check** workflow, and set + `MACOS_SIGNING_ENABLED` back to `true`. Then publish a new patch release of each + [supported version](../SECURITY.md#supported-versions) through the normal release process: a + published file cannot be signed again in place. +4. **Tell users** as in the Windows section above (security advisory, notice in the README, release + notes), naming the affected versions and the Team ID, and update this document and + [verifying-releases.md](verifying-releases.md#6-releases-after-a-signing-change). + ## If the release workflow identity is compromised This applies when a malicious change to `release.yml` reaches a tag, when a token or account that can diff --git a/docs/verifying-releases.md b/docs/verifying-releases.md index 6dd9122..eb13afb 100644 --- a/docs/verifying-releases.md +++ b/docs/verifying-releases.md @@ -115,7 +115,7 @@ This confirms the archive was built by the `release.yml` GitHub Actions workflow, from the `Hodeitek/hodeishield-cli` source repository, at the tag you expect — not from a modified build or a malicious fork. -## 5. Platform signatures (Windows; macOS in a later release) +## 5. Platform signatures (Windows, macOS) From 0.2.0, `hodeishield.exe` also carries an Authenticode signature, which Windows checks by itself. It does not replace the steps above: it says the file comes from Hodeitek, not which @@ -142,9 +142,33 @@ starts with `1.3.6.1.4.1.311.97.`. To list the EKU values: From 0.2.0 the profile EKU is `1.3.6.1.4.1.311.97.449523565.535854065.272767902.439799148` ([release-signing.md](release-signing.md#authenticode-windows) lists it with the subject). -The macOS binary is not yet signed with a Developer ID or notarized; that, and a signed and -notarized `.pkg` installer, come in a later release -([#50](https://github.com/Hodeitek/hodeishield-cli/issues/50)). +### macOS + +This applies from the first release signed with a Developer ID; earlier macOS binaries are not +signed or notarized, and the release's run summary says whether a release's macOS files are. For a +release that is signed, the binary carries a Developer ID Application signature and is notarized by +Apple, and the `.pkg` installer carries a Developer ID Installer signature, is notarized and has its +ticket stapled. + +```sh +# The binary: signed by Hodeitek's Developer ID, with the hardened runtime and a timestamp. +codesign -dvv ./hodeishield 2>&1 | grep -E 'Authority|TeamIdentifier|Timestamp|flags' +codesign --verify --strict --verbose=2 ./hodeishield + +# Gatekeeper's verdict. Expect "accepted" and "source=Notarized Developer ID". A bare +# executable cannot be stapled, so this check needs a network connection. +spctl -a -vv -t exec ./hodeishield + +# The installer package. +pkgutil --check-signature hodeishield--universal-apple-darwin.pkg +xcrun stapler validate hodeishield--universal-apple-darwin.pkg +``` + +`Authority` must list `Developer ID Application: Hodeitek S.L.`, and `TeamIdentifier` must be the +Team ID that [release-signing.md](release-signing.md#developer-id-macos) and the release notes give. +The package must show a `Developer ID Installer: Hodeitek S.L.` certificate. As with Windows, this +says the file comes from Hodeitek; the Sigstore and SLSA checks above say which workflow run built +it. ## 6. Releases after a signing change From f5dfb2290e4440522d660c6530ca9bdaa2da0165 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:58:28 +0200 Subject: [PATCH 23/25] Show the signed-in user in whoami from /v1/me whoami now reads the user of a sign-in from the body of GET /v1/me as received (data.user), not from the generated type, because the vendored API document does not describe the field yet. It is read leniently: a missing field, null or a malformed value is no user, never an error. The User line and the --json user string keep their name and type. They are filled from the API first, formatted as Name , and the authorization server's userinfo endpoint is only asked when the API gives no user. For a key the field is ignored. Every string is cleaned before it is printed. No extra request is made, and nothing changes in the output until the API returns the field. Closes #182 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- CHANGELOG.md | 5 + README.md | 3 +- .../hodeishield-cli/src/commands/session.rs | 304 ++++++++++++++++-- crates/hodeishield-cli/tests/contract.rs | 27 ++ 4 files changed, 308 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ddb06..383afb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,11 @@ commands, `--json` output and exit codes. Each release's notes start with its se `portfolio` and `credential_scopes` fields, null when the API cannot be read; the exit code does not depend on that call. ([#48](https://github.com/Hodeitek/hodeishield-cli/issues/48)) +- `whoami` shows the signed-in user (`Name `, in the `User` line and the `--json` `user` + string) from `GET /v1/me` when the API provides it for a sign-in, with no extra call. The + authorization server's userinfo endpoint stays as the fallback; a key still shows no user, and + nothing changes while the API does not send one. + ([#182](https://github.com/Hodeitek/hodeishield-cli/issues/182)) ### Fixed diff --git a/README.md b/README.md index 585f1a7..e2bc4ee 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,8 @@ When `HODEISHIELD_API_KEY` is set it is used for every call, whatever else is co your CI's secret store; the CLI never prints it. `whoami` asks the API who the credential is and shows the tenant it reads (name and id), its -organisation and, for a key, its scopes; for a portfolio key, the MSSP and how many client tenants it +organisation and, for a key, its scopes; for a sign-in, also the signed-in user when the service +provides it; for a portfolio key, the MSSP and how many client tenants it can select. With `--json` these are the `tenant`, `organization`, `portfolio` and `credential_scopes` fields. If the API cannot be reached, `whoami` still shows the local settings, leaves those fields null and says why on standard error. diff --git a/crates/hodeishield-cli/src/commands/session.rs b/crates/hodeishield-cli/src/commands/session.rs index 9d4928c..1a133f8 100644 --- a/crates/hodeishield-cli/src/commands/session.rs +++ b/crates/hodeishield-cli/src/commands/session.rs @@ -221,11 +221,11 @@ fn note_api_key() { pub fn whoami(ctx: &Context, out: &mut dyn Write) -> Result<()> { let settings = ctx.settings()?; // `GET /v1/me` describes the credential itself: it takes no tenant selection. - let me = || -> std::result::Result { + let me = || -> std::result::Result { let (client, source) = ctx.api(&settings, None).map_err(|f| f.message)?; client .who_am_i() - .map(|answer| answer.data.data) + .map(|answer| Me::from_answer(answer.data.data, &answer.raw)) .map_err(|e| { let mut call = ctx.call(source, None); call.tenant_selected = false; @@ -238,12 +238,78 @@ pub fn whoami(ctx: &Context, out: &mut dyn Write) -> Result<()> { ctx.store.as_ref(), ctx.json, &me, + &userinfo_label, out, ) } -/// `whoami` with the settings, whether an API key is set and the call to `GET /v1/me` given, so it -/// can be tested without the process environment. With a key, the store is not read. The call is +/// What `GET /v1/me` says about the credential: the generated type, and the signed-in user, which +/// the document of the API does not describe yet and so is read from the body as received. +struct Me { + who: WhoAmI, + user: Option, +} + +impl Me { + fn from_answer(who: WhoAmI, raw: &Value) -> Self { + Self { + who, + user: MeUser::from_raw(&raw["data"]["user"]), + } + } +} + +/// The user of a sign-in, as `GET /v1/me` gives it. Read leniently: anything that is not an object +/// with at least one text field is no user, never an error. +struct MeUser { + id: Option, + name: Option, + email: Option, +} + +impl MeUser { + fn from_raw(raw: &Value) -> Option { + let object = raw.as_object()?; + let text = |key: &str| object.get(key).and_then(Value::as_str).map(str::to_owned); + let user = Self { + id: text("id"), + name: text("name"), + email: text("email"), + }; + (user.id.is_some() || user.name.is_some() || user.email.is_some()).then_some(user) + } + + /// Labelled like the user of the userinfo endpoint: `Name `, whichever of them there is, + /// or the id. + fn label(&self) -> Option { + let mut info = Map::new(); + for (key, value) in [ + ("name", &self.name), + ("email", &self.email), + ("sub", &self.id), + ] { + if let Some(value) = value { + info.insert(key.into(), json!(value)); + } + } + user_label(&info) + } +} + +/// The user from the authorization server's userinfo endpoint: the fallback for an API that does +/// not say who the user is. Nothing when the app has no such endpoint or cannot be reached. +fn userinfo_label(token: &StoredToken) -> Option { + let issuer = auth::issuer_url(token).ok()?; + let http = oauth::http_client(&issuer).ok()?; + let metadata = oauth::discover(&http, &issuer).ok()?; + let info = oauth::userinfo(&http, &metadata, &token.access_token) + .ok() + .flatten()?; + user_label(&info) +} + +/// `whoami` with the settings, whether an API key is set, the call to `GET /v1/me` and the userinfo +/// fallback given, so it can be tested without the process environment. With a key, the store is not read. The call is /// made only when there is a credential; when it fails, the local lines are still printed, the /// API's lines are left out (null in `--json`) and a warning says why. fn whoami_with( @@ -251,7 +317,8 @@ fn whoami_with( has_api_key: bool, store: &dyn auth::TokenStore, json: bool, - me: &dyn Fn() -> std::result::Result, + me: &dyn Fn() -> std::result::Result, + userinfo: &dyn Fn(&StoredToken) -> Option, out: &mut dyn Write, ) -> Result<()> { let mut info = Map::new(); @@ -259,6 +326,7 @@ fn whoami_with( info.insert("api_url".into(), json!(settings.api_url.as_str())); info.insert("app_url".into(), json!(settings.app_url.as_str())); + let mut signed_in_token = None; let source = if has_api_key { Some(CredentialSource::ApiKey) } else { @@ -273,16 +341,9 @@ fn whoami_with( .map_or(Value::Null, |t| json!(unix_to_rfc3339(t))), ); info.insert("scope".into(), json!(token.scope)); - let user = auth::issuer_url(&token) - .and_then(|issuer| { - let http = oauth::http_client(&issuer)?; - let metadata = oauth::discover(&http, &issuer)?; - oauth::userinfo(&http, &metadata, &token.access_token) - }) - .ok() - .flatten() - .and_then(|u| user_label(&u)); - info.insert("user".into(), json!(user)); + // Filled in below, once the API has been asked who the user is. + info.insert("user".into(), Value::Null); + signed_in_token = Some(token); Some(CredentialSource::OAuth) } None => None, @@ -307,21 +368,29 @@ fn whoami_with( } None => None, }; + if let Some(token) = &signed_in_token { + // The API first; the userinfo endpoint only when it gives no user. A key has no user, + // whatever the API sends. + let user = me + .as_ref() + .and_then(|me| me.user.as_ref()) + .and_then(MeUser::label) + .or_else(|| userinfo(token)); + info.insert("user".into(), json!(user)); + } + let who = me.as_ref().map(|me| &me.who); info.insert( "tenant".into(), - json!(me.as_ref().and_then(|me| me.tenant.as_ref())), - ); - info.insert( - "organization".into(), - json!(me.as_ref().map(|me| &me.organization)), + json!(who.and_then(|me| me.tenant.as_ref())), ); + info.insert("organization".into(), json!(who.map(|me| &me.organization))); info.insert( "portfolio".into(), - json!(me.as_ref().and_then(|me| me.portfolio.as_ref())), + json!(who.and_then(|me| me.portfolio.as_ref())), ); info.insert( "credential_scopes".into(), - json!(me.as_ref().map(|me| &me.credential.scopes)), + json!(who.map(|me| &me.credential.scopes)), ); if json { @@ -349,7 +418,7 @@ fn whoami_with( rows.push((label, clean(value))); } } - if let Some(me) = &me { + if let Some(me) = who { rows.extend(me_rows(me, source == Some(CredentialSource::ApiKey))); } for (label, value) in rows { @@ -791,6 +860,7 @@ mod tests { &UnreachableStore, false, &no_call, + &no_userinfo, &mut out, ) .expect_err("no credential"); @@ -817,7 +887,16 @@ mod tests { let settings = settings_for(&api, &app); let mut out = Vec::new(); let me = || Err("offline".to_owned()); - whoami_with(&settings, true, &UnreachableStore, true, &me, &mut out).expect("key set"); + whoami_with( + &settings, + true, + &UnreachableStore, + true, + &me, + &no_userinfo, + &mut out, + ) + .expect("key set"); let printed: Value = serde_json::from_slice(&out).expect("json"); assert_eq!(printed["credential"], "api_key"); // The API could not be read: its fields are there, null. @@ -826,12 +905,150 @@ mod tests { } } - fn no_call() -> std::result::Result { + fn no_call() -> std::result::Result { panic!("GET /v1/me is not called without a credential") } - fn who(json: &str) -> WhoAmI { - serde_json::from_str(json).expect("WhoAmI") + fn no_userinfo(_: &StoredToken) -> Option { + panic!("the userinfo endpoint is not asked here") + } + + /// The `data` of a `GET /v1/me` answer, read the way the client reads it. + fn who(json: &str) -> Me { + let data: Value = serde_json::from_str(json).expect("json"); + let typed = serde_json::from_value(data.clone()).expect("WhoAmI"); + Me::from_answer(typed, &json!({ "data": data })) + } + + /// A `data` for a sign-in, with `user` spliced in as written (nothing when `None`). + fn sign_in_me(user: Option<&str>) -> Me { + let user = user.map_or_else(String::new, |u| format!(r#","user":{u}"#)); + who(&format!( + r#"{{"credential":{{"type":"oauth","prefix":null,"scopes":["supply_risk:read"]}}, + "tenant":{{"id":"6f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f","name":"Acme S.L.","type":"direct"}}, + "organization":{{"id":"7a1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f","name":"Acme Group","type":"direct"}}, + "portfolio":null{user}}}"# + )) + } + + /// `whoami` of a sign-in whose `GET /v1/me` answers with `user` (as JSON text), and the + /// userinfo fallback gives `fallback`; the table and the JSON are returned. + fn whoami_of_a_sign_in(user: Option<&str>, fallback: Option<&str>) -> (String, Value) { + let (api, app) = (mockito::Server::new(), mockito::Server::new()); + let settings = settings_for(&api, &app); + let store = MemoryStore::default(); + store.save("default", &signed_in(&app, None)).expect("save"); + let me = || Ok(sign_in_me(user)); + let userinfo = |_: &StoredToken| fallback.map(str::to_owned); + let mut table = Vec::new(); + whoami_with(&settings, false, &store, false, &me, &userinfo, &mut table) + .expect("signed in"); + let mut json = Vec::new(); + whoami_with(&settings, false, &store, true, &me, &userinfo, &mut json).expect("signed in"); + ( + String::from_utf8(table).expect("utf8"), + serde_json::from_slice(&json).expect("json"), + ) + } + + #[test] + fn whoami_shows_the_user_that_the_api_gives_for_a_sign_in() { + let (text, printed) = whoami_of_a_sign_in( + Some(r#"{"id":"u-1","name":"Ana\u001b[31m Ruiz","email":"ana@acme.example"}"#), + None, + ); + assert!( + text.contains("User Ana\u{fffd}[31m Ruiz \n"), + "{text}" + ); + assert!(!text.contains('\u{1b}'), "the name is cleaned: {text:?}"); + // The same text and type as before: a string. + assert_eq!(printed["user"], "Ana\u{fffd}[31m Ruiz "); + assert!(!printed["user"].as_str().expect("string").contains('\u{1b}')); + let keys: Vec<&String> = printed.as_object().expect("object").keys().collect(); + let position = |k: &str| keys.iter().position(|key| *key == k).expect(k); + assert!(position("scope") < position("user") && position("user") < position("credential")); + } + + #[test] + fn whoami_prefers_the_api_over_the_userinfo_endpoint() { + let (text, printed) = whoami_of_a_sign_in( + Some(r#"{"id":"u-1","name":"Ana","email":"ana@acme.example"}"#), + Some("Someone Else"), + ); + assert!( + text.contains("User Ana \n"), + "{text}" + ); + assert_eq!(printed["user"], "Ana "); + } + + #[test] + fn whoami_without_a_user_from_the_api_falls_back_to_userinfo() { + // Today's API (no `user` field), `user: null`, and a malformed `user`. + for user in [None, Some("null"), Some(r#""x""#), Some("[]"), Some("{}")] { + let (text, printed) = whoami_of_a_sign_in(user, Some("Old Way ")); + assert!( + text.contains("User Old Way \n"), + "{user:?}: {text}" + ); + assert_eq!(printed["user"], "Old Way ", "{user:?}"); + } + } + + #[test] + fn whoami_without_any_user_has_no_user_line_and_does_not_fail() { + for user in [None, Some("null"), Some(r#""x""#), Some("42"), Some("{}")] { + let (text, printed) = whoami_of_a_sign_in(user, None); + assert!(!text.contains("User"), "{user:?}: {text}"); + assert_eq!(printed["user"], Value::Null, "{user:?}"); + // The rest of the answer is still shown. + assert!(text.contains("Org Acme Group (direct)\n"), "{text}"); + assert_eq!(printed["organization"]["name"], "Acme Group"); + } + } + + #[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); + assert!(text.contains("User u-1\n"), "{text}"); + } + + #[test] + fn whoami_ignores_the_user_the_api_sends_for_a_key() { + let (api, app) = (mockito::Server::new(), mockito::Server::new()); + let settings = settings_for(&api, &app); + let me = || { + Ok(sign_in_me(Some( + r#"{"id":"u-1","name":"Ana","email":"a@b.example"}"#, + ))) + }; + let mut out = Vec::new(); + whoami_with( + &settings, + true, + &UnreachableStore, + false, + &me, + &no_userinfo, + &mut out, + ) + .expect("key set"); + let text = String::from_utf8(out).expect("utf8"); + assert!(!text.contains("User") && !text.contains("Ana"), "{text}"); + let mut out = Vec::new(); + whoami_with( + &settings, + true, + &UnreachableStore, + true, + &me, + &no_userinfo, + &mut out, + ) + .expect("key set"); + let printed: Value = serde_json::from_slice(&out).expect("json"); + assert!(printed.get("user").is_none(), "{printed}"); } #[test] @@ -847,7 +1064,16 @@ mod tests { )) }; let mut out = Vec::new(); - whoami_with(&settings, true, &UnreachableStore, false, &me, &mut out).expect("key set"); + whoami_with( + &settings, + true, + &UnreachableStore, + false, + &me, + &no_userinfo, + &mut out, + ) + .expect("key set"); let text = String::from_utf8(out).expect("utf8"); assert!( text.contains( @@ -864,7 +1090,16 @@ mod tests { assert!(!text.contains("Tenants"), "{text}"); let mut out = Vec::new(); - whoami_with(&settings, true, &UnreachableStore, true, &me, &mut out).expect("key set"); + whoami_with( + &settings, + true, + &UnreachableStore, + true, + &me, + &no_userinfo, + &mut out, + ) + .expect("key set"); let printed: Value = serde_json::from_slice(&out).expect("json"); assert_eq!( printed["tenant"]["id"], @@ -889,7 +1124,16 @@ mod tests { )) }; let mut out = Vec::new(); - whoami_with(&settings, true, &UnreachableStore, false, &me, &mut out).expect("key set"); + whoami_with( + &settings, + true, + &UnreachableStore, + false, + &me, + &no_userinfo, + &mut out, + ) + .expect("key set"); let text = String::from_utf8(out).expect("utf8"); assert!(!text.contains("Tenant "), "{text}"); assert!(text.contains("Org - (mssp)\n"), "{text}"); diff --git a/crates/hodeishield-cli/tests/contract.rs b/crates/hodeishield-cli/tests/contract.rs index ef72df6..3f2974b 100644 --- a/crates/hodeishield-cli/tests/contract.rs +++ b/crates/hodeishield-cli/tests/contract.rs @@ -528,6 +528,33 @@ fn json_whoami_fields() { me.assert(); } +/// A key has no user: whatever `user` the API sends in `GET /v1/me`, neither the table nor `--json` +/// shows one, and the fields stay as they were. +#[test] +fn whoami_with_a_key_ignores_a_user_in_the_answer() { + let env = Env::new(); + let mut server = mockito::Server::new(); + server + .mock("GET", "/v1/me") + .with_body( + r#"{"data":{"credential":{"type":"tenant","prefix":null,"scopes":["supply_risk:read"]}, + "tenant":{"id":"6f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f","name":"Acme S.L.","type":"direct"}, + "organization":{"id":"7a1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f","name":"Acme Group","type":"direct"}, + "portfolio":null, + "user":{"id":"u-1","name":"Ana Ruiz","email":"ana@acme.example"}}}"#, + ) + .expect(2) + .create(); + let output = env.api(&server.url(), &["--json", "whoami"]); + assert_eq!(output.status.code(), Some(0), "{output:?}"); + let printed: Value = serde_json::from_str(&out(&output)).expect("json"); + assert!(printed.get("user").is_none(), "{printed}"); + assert!(!out(&output).contains("Ana Ruiz")); + let output = env.api(&server.url(), &["whoami"]); + assert_eq!(output.status.code(), Some(0), "{output:?}"); + assert!(!out(&output).contains("User") && !out(&output).contains("Ana")); +} + /// When `GET /v1/me` fails, `whoami` still succeeds with the local fields; the API's are null and /// standard error says why. #[test] From a49b89e2c285b78da5aa3b4312f5513d2eefd5c7 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:10:50 +0200 Subject: [PATCH 24/25] Make the macOS signing check optional in the release-signing steps The default procedure to enable macOS signing is to store the nine secrets and set MACOS_SIGNING_ENABLED to true: the next release signs and notarizes, and its draft is published only after the macOS job and its checks pass. The signing check workflow stays documented as an optional way to check the secrets before a release, with its two preconditions. Refs #50 Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- docs/release-signing.md | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/release-signing.md b/docs/release-signing.md index 10790ef..945e22d 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -121,20 +121,23 @@ checks the binary's notarization online on first run. See `true` and a secret is missing, the job fails and names it. When the variable is not `true`, the release still builds and publishes, the macOS binary is not signed or notarized, and the run summary says so. -- **Checking the secrets without a release.** The **macOS signing check** workflow - (`macos-signing-check.yml`, started by hand) is bound to the same environment, so it needs the - same approval. It signs and notarizes a throwaway test executable and package with the stored - secrets, verifies them like a release does, and uploads nothing. It fails, listing the names, if a - secret is missing. +- **Checking the secrets without a release (optional).** The **macOS signing check** workflow + (`macos-signing-check.yml`, started by hand) signs and notarizes a throwaway test executable and + package with the stored secrets, verifies them like a release does, and uploads nothing. It fails, + listing the names, if a secret is missing. It is bound to the same environment, so it needs the + same approval, and it has two preconditions: the workflow must be on the default branch to be + dispatched, and the environment accepts deployments from `v*` tags only, so a repository + administrator adds `main` to its allowed branches for the check and removes it afterwards. To enable it, a maintainer with the right to change the environment: 1. stores the nine secrets in the `release-signing` environment; -2. lets the **macOS signing check** run: the environment accepts deployments from `v*` tags only, so - a repository administrator adds `main`, where the workflow runs, to its allowed branches for the - check and removes it afterwards; -3. runs **macOS signing check** and reads its summary: it must pass; -4. sets the repository variable `MACOS_SIGNING_ENABLED` to `true`. +2. sets the repository variable `MACOS_SIGNING_ENABLED` to `true`. + +The next release signs and notarizes the macOS binaries, and its draft is published only after the +macOS job and its checks pass. If signing or notarization fails, the release run fails and nothing +is published, so the first signed release is itself the test. To check the secrets before that +release, run the optional **macOS signing check** workflow as described above. The first release signed this way is the first one whose run summary says "macOS signing: signed and notarized". Releases before it are not signed with a Developer ID. To @@ -301,10 +304,10 @@ that the release workflow did not sign. revokes their notarization tickets, which the maintainers can ask Apple to do for files known to be malicious. 3. **Replace.** Create new certificates and a new API key, store them as the secrets listed under - [Developer ID (macOS)](#developer-id-macos), run the **macOS signing check** workflow, and set - `MACOS_SIGNING_ENABLED` back to `true`. Then publish a new patch release of each - [supported version](../SECURITY.md#supported-versions) through the normal release process: a - published file cannot be signed again in place. + [Developer ID (macOS)](#developer-id-macos), optionally run the **macOS signing check** + workflow, and set `MACOS_SIGNING_ENABLED` back to `true`. Then publish a new patch release of + each [supported version](../SECURITY.md#supported-versions) through the normal release process: + a published file cannot be signed again in place. 4. **Tell users** as in the Windows section above (security advisory, notice in the README, release notes), naming the affected versions and the Team ID, and update this document and [verifying-releases.md](verifying-releases.md#6-releases-after-a-signing-change). From d65173703e1a37b934eb4afd2e6a82445309d336 Mon Sep 17 00:00:00 2001 From: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:25:42 +0200 Subject: [PATCH 25/25] Prepare 0.5.0: version and changelog Version 0.5.0 (workspace crates only in Cargo.lock), the changelog section dated 2026-10-15 with an empty Unreleased section above it and the compare links, the version in the examples of the README and docs/verifying-releases.md, and 0.5.0 in the range of the Authenticode table. Signed-off-by: d0ubl3g <48954024+d0ubl3g@users.noreply.github.com> --- CHANGELOG.md | 5 ++++- Cargo.lock | 6 +++--- Cargo.toml | 2 +- README.md | 16 ++++++++-------- docs/release-signing.md | 2 +- docs/verifying-releases.md | 22 +++++++++++----------- 6 files changed, 28 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7328c02..55a75f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ commands, `--json` output and exit codes. Each release's notes start with its se ## [Unreleased] +## [0.5.0] - 2026-10-15 + ### Added - An opt-in plain-text token store, for machines that have no system keychain (an SSH or jump host, @@ -379,7 +381,8 @@ commands, `--json` output and exit codes. Each release's notes start with its se - Profiles (`config`), shell completions, and release archives for Linux, macOS and Windows signed with Sigstore and with SLSA provenance. -[Unreleased]: https://github.com/Hodeitek/hodeishield-cli/compare/v0.4.4...HEAD +[Unreleased]: https://github.com/Hodeitek/hodeishield-cli/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/Hodeitek/hodeishield-cli/compare/v0.4.4...v0.5.0 [0.4.4]: https://github.com/Hodeitek/hodeishield-cli/compare/v0.4.3...v0.4.4 [0.4.3]: https://github.com/Hodeitek/hodeishield-cli/compare/v0.4.2...v0.4.3 [0.4.2]: https://github.com/Hodeitek/hodeishield-cli/compare/v0.4.1...v0.4.2 diff --git a/Cargo.lock b/Cargo.lock index 0672816..bf518b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -970,7 +970,7 @@ dependencies = [ [[package]] name = "hodeishield-api" -version = "0.4.4" +version = "0.5.0" dependencies = [ "mockito", "reqwest", @@ -983,7 +983,7 @@ dependencies = [ [[package]] name = "hodeishield-cli" -version = "0.4.4" +version = "0.5.0" dependencies = [ "apple-native-keyring-store", "assert_cmd", @@ -3067,7 +3067,7 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "xtask" -version = "0.4.4" +version = "0.5.0" dependencies = [ "clap", "clap_mangen", diff --git a/Cargo.toml b/Cargo.toml index 71fff82..8ae10c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ default-members = ["crates/hodeishield-api", "crates/hodeishield-cli"] [workspace.package] authors = ["Hodeitek S.L. "] -version = "0.4.4" +version = "0.5.0" edition = "2024" rust-version = "1.98" license = "Apache-2.0" diff --git a/README.md b/README.md index e2bc4ee..8d4e54b 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,14 @@ section), and put `hodeishield` (`hodeishield.exe` on Windows) somewhere on your | Windows x86_64 | `hodeishield--x86_64-pc-windows-msvc.zip` | | Windows arm64 | `hodeishield--aarch64-pc-windows-msvc.zip` (from 0.4.0) | -`` has no leading `v`: release `v0.4.4` ships `hodeishield-0.4.4-…`. The Linux binaries are +`` has no leading `v`: release `v0.5.0` ships `hodeishield-0.5.0-…`. The Linux binaries are statically linked and run on any distribution. Homebrew and Scoop packages (next section) and a [container image](#container-image) are also available. From source, with the Rust toolchain installed (pin the release tag you want): ```sh -cargo install --locked --git https://github.com/Hodeitek/hodeishield-cli --tag v0.4.4 hodeishield-cli +cargo install --locked --git https://github.com/Hodeitek/hodeishield-cli --tag v0.5.0 hodeishield-cli ``` ### Homebrew and Scoop @@ -179,7 +179,7 @@ exact version, or better its digest. The image carries no documentation: the 0.4.2, as `USER-INFORMATION.md` in its archives. ```sh -v=0.4.4 # the version you want +v=0.5.0 # the version you want docker pull ghcr.io/hodeitek/hodeishield-cli:$v docker run --rm -e HODEISHIELD_API_KEY ghcr.io/hodeitek/hodeishield-cli:$v vendors list ``` @@ -193,7 +193,7 @@ The image is signed by digest with cosign (keyless) and carries the release's Cy signed attestation. Verify both before you rely on the image: ```sh -v=0.4.4 +v=0.5.0 image=ghcr.io/hodeitek/hodeishield-cli:$v cosign verify $image \ --certificate-identity https://github.com/Hodeitek/hodeishield-cli/.github/workflows/container-image.yml@refs/tags/v$v \ @@ -231,9 +231,9 @@ Each release has a `SHA256SUMS` file, a Sigstore bundle (`*.sigstore.json`) for ```sh sha256sum --ignore-missing -c SHA256SUMS -cosign verify-blob hodeishield-0.4.4-x86_64-unknown-linux-musl.tar.gz \ - --bundle hodeishield-0.4.4-x86_64-unknown-linux-musl.tar.gz.sigstore.json \ - --certificate-identity https://github.com/Hodeitek/hodeishield-cli/.github/workflows/release.yml@refs/tags/v0.4.4 \ +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 \ + --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 ``` @@ -268,7 +268,7 @@ on the binary, so the block still applies. A download with `curl` does not set i place: ```sh -curl -LO https://github.com/Hodeitek/hodeishield-cli/releases/download/v0.4.4/hodeishield-0.4.4-universal-apple-darwin.tar.gz +curl -LO https://github.com/Hodeitek/hodeishield-cli/releases/download/v0.5.0/hodeishield-0.5.0-universal-apple-darwin.tar.gz ``` **Windows, before 0.2.0.** Unblock the archive before unpacking it, so the executable does not diff --git a/docs/release-signing.md b/docs/release-signing.md index 945e22d..e47d5a8 100644 --- a/docs/release-signing.md +++ b/docs/release-signing.md @@ -80,7 +80,7 @@ an RFC 3161 timestamp from `http://timestamp.acs.microsoft.com`. - **Switch.** The repository variable `WINDOWS_SIGNING_ENABLED` turns the signing on, and only for a run started by a pushed `v*` tag. -The signatures of every signed release so far (0.2.0 to 0.4.4) carry: +The signatures of every signed release so far (0.2.0 to 0.5.0) carry: | Field | Value | |---|---| diff --git a/docs/verifying-releases.md b/docs/verifying-releases.md index eb13afb..0bb181b 100644 --- a/docs/verifying-releases.md +++ b/docs/verifying-releases.md @@ -30,14 +30,14 @@ From 0.4.2, every archive, `.deb`, `.rpm` and MSI also contains `USER-INFORMATIO too. The `.deb` and `.rpm` install it in `/usr/share/doc/hodeishield/`, the MSI next to `hodeishield.exe`. -The steps below use `v0.4.4` and `hodeishield-0.4.4-x86_64-unknown-linux-musl.tar.gz` +The steps below use `v0.5.0` and `hodeishield-0.5.0-x86_64-unknown-linux-musl.tar.gz` as examples — substitute the actual version and archive you downloaded. ## 1. Download the files From the release page, download: -- the archive you want (e.g. `hodeishield-0.4.4-x86_64-unknown-linux-musl.tar.gz`) +- the archive you want (e.g. `hodeishield-0.5.0-x86_64-unknown-linux-musl.tar.gz`) - `SHA256SUMS` - the matching `.sigstore.json` - `SHA256SUMS.sigstore.json` @@ -62,7 +62,7 @@ in the format cosign v3 reads by default. Older cosign versions cannot read them ```sh cosign verify-blob \ --bundle SHA256SUMS.sigstore.json \ - --certificate-identity https://github.com/Hodeitek/hodeishield-cli/.github/workflows/release.yml@refs/tags/v0.4.4 \ + --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 \ SHA256SUMS @@ -72,11 +72,11 @@ Repeat for the archive itself, using its own `.sigstore.json`: ```sh cosign verify-blob \ - --bundle hodeishield-0.4.4-x86_64-unknown-linux-musl.tar.gz.sigstore.json \ - --certificate-identity https://github.com/Hodeitek/hodeishield-cli/.github/workflows/release.yml@refs/tags/v0.4.4 \ + --bundle hodeishield-0.5.0-x86_64-unknown-linux-musl.tar.gz.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 \ - hodeishield-0.4.4-x86_64-unknown-linux-musl.tar.gz + hodeishield-0.5.0-x86_64-unknown-linux-musl.tar.gz ``` A successful verification confirms the file was signed by the @@ -89,11 +89,11 @@ The SBOM is verified the same way, with its own bundle: ```sh cosign verify-blob \ - --bundle hodeishield-0.4.4-x86_64-unknown-linux-musl.cdx.json.sigstore.json \ - --certificate-identity https://github.com/Hodeitek/hodeishield-cli/.github/workflows/release.yml@refs/tags/v0.4.4 \ + --bundle hodeishield-0.5.0-x86_64-unknown-linux-musl.cdx.json.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 \ - hodeishield-0.4.4-x86_64-unknown-linux-musl.cdx.json + hodeishield-0.5.0-x86_64-unknown-linux-musl.cdx.json ``` ## 4. Verify SLSA build provenance @@ -102,10 +102,10 @@ Requires [`slsa-verifier`](https://github.com/slsa-framework/slsa-verifier). ```sh slsa-verifier verify-artifact \ - hodeishield-0.4.4-x86_64-unknown-linux-musl.tar.gz \ + hodeishield-0.5.0-x86_64-unknown-linux-musl.tar.gz \ --provenance-path hodeishield.intoto.jsonl \ --source-uri github.com/Hodeitek/hodeishield-cli \ - --source-tag v0.4.4 + --source-tag v0.5.0 ``` `hodeishield.intoto.jsonl` is attached to the release and covers every