diff --git a/.github/scripts/__pycache__/security-txt-check.cpython-313.pyc b/.github/scripts/__pycache__/security-txt-check.cpython-313.pyc deleted file mode 100644 index d4a24cc..0000000 Binary files a/.github/scripts/__pycache__/security-txt-check.cpython-313.pyc and /dev/null differ diff --git a/.github/scripts/release-integrity-test.py b/.github/scripts/release-integrity-test.py index e5d1abb..8d9d0e6 100644 --- a/.github/scripts/release-integrity-test.py +++ b/.github/scripts/release-integrity-test.py @@ -174,6 +174,25 @@ def test_negative_a_certificate_whose_signature_does_not_verify_does_not_chain(s self.assertFalse(self.trust.certificate_chains(changed, self.entry["integratedTime"])) +class Bundles(unittest.TestCase): + SUMS = {"hodeishield-1.0.0-x86_64.tar.gz": "a" * 64, "hodeishield-1.0.0.pkg": "b" * 64} + + def test_the_required_set_ignores_which_bundles_are_attached(self): + assets = {"SHA256SUMS", "install.sh", "hodeishield-1.0.0.msi", *self.SUMS} + self.assertEqual(ri.must_be_signed(assets, self.SUMS), {"SHA256SUMS", "install.sh", "hodeishield-1.0.0.msi", *self.SUMS}) + + def test_negative_a_deleted_bundle_does_not_shrink_the_required_set(self): + assets = {"SHA256SUMS", "SHA256SUMS.sigstore.json", *self.SUMS} + self.assertEqual(ri.must_be_signed(assets, self.SUMS), {"SHA256SUMS", *self.SUMS}) + + def test_a_file_in_sha256sums_is_required_even_if_its_name_matches_no_pattern(self): + self.assertIn("other.bin", ri.must_be_signed({"SHA256SUMS"}, {"other.bin": "c" * 64})) + + def test_bundles_and_provenance_are_not_required_to_have_bundles(self): + names = {"x.tar.gz.sigstore.json", "multiple.intoto.jsonl"} + self.assertEqual(ri.must_be_signed(names, {}), {"SHA256SUMS"}) + + class Parsing(unittest.TestCase): def test_sha256sums_in_text_and_binary_mode(self): text = "a" * 64 + " one.tar.gz\n" + "b" * 64 + " *two.zip\n\n" diff --git a/.github/scripts/release-integrity.py b/.github/scripts/release-integrity.py index cc15c6e..93ce4fe 100644 --- a/.github/scripts/release-integrity.py +++ b/.github/scripts/release-integrity.py @@ -17,8 +17,10 @@ commit, and from 0.4.0 a successful container-image.yml run; files every release file matches its line in the release's SHA256SUMS, and nothing else is attached; - Rekor every file the release signs (it has a `.sigstore.json` bundle) is looked up in - Rekor by its SHA-256. An entry counts only if its signed entry timestamp + bundles every archive and installer attached, every file in SHA256SUMS and SHA256SUMS + itself must carry a `.sigstore.json` bundle; this set does not depend on which + bundles are attached, so deleting a bundle is reported; + Rekor every one of those files is looked up in Rekor by its SHA-256. An entry counts only if its signed entry timestamp verifies with Rekor's key and its certificate chains to Sigstore's certificate authority at the time it was logged: Rekor accepts any certificate, so one that does not chain could claim any identity. At least @@ -70,6 +72,10 @@ ACCEPTED_RUNS: dict[int, str] = {} VERSION_TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$") +# The release assets release.yml signs, by name (the "Sign every archive and SHA256SUMS" step). +SIGNED_ASSET = re.compile( + r"hodeishield-.*\.(tar\.gz|zip|msi|pkg|rpm|cdx\.json)|hodeishield[-_].*\.deb|install\.sh" +) IMAGE_SIGNATURE_TAG = re.compile(r"^sha256-[0-9a-f]{64}(\.sig|\.att|\.sbom)?$") @@ -232,6 +238,12 @@ def download(url: str) -> bytes: return body +def must_be_signed(assets, sums) -> set[str]: + """The files that must carry a Sigstore bundle, whichever bundles are attached: SHA256SUMS, + every file it lists and every attached file that release.yml signs by name.""" + return {"SHA256SUMS", *sums} | {name for name in assets if SIGNED_ASSET.fullmatch(name)} + + def parse_sha256sums(text: str) -> dict[str, str]: sums = {} for line in text.splitlines(): @@ -575,9 +587,12 @@ def problem(text: str): ): problem(f"{tag}: {name} is attached but not in SHA256SUMS") - signed = [name for name in digests if f"{name}.sigstore.json" in assets] - if not signed: - problem(f"{tag}: no file has a Sigstore bundle") + signed = [] + for name in sorted(must_be_signed(assets, sums)): + if f"{name}.sigstore.json" not in assets: + problem(f"{tag}: {name} has no Sigstore bundle") + elif name in digests: + signed.append(name) for name in signed: matched, wrong = judge_rekor( rekor_entries(digests[name], trust), repository=repository, tag=tag, releases=signers diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4874d6..fefd16b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -238,6 +238,11 @@ jobs: - name: shellcheck run: shellcheck -s sh install.sh packaging/repo/*.sh packaging/build/*.sh + # packaging/gpg.key, the README and the constant in keycheck.sh must all name the same key, and + # the file must hold that one key and no other. + - name: The repository key is the pinned one + run: sh packaging/repo/keycheck.sh packaging/gpg.key README.md + - name: Every curl call in install.sh is HTTPS-only run: | awk ' @@ -302,7 +307,9 @@ jobs: # The repository freshness monitor must go red: its control builds small repositories with a # throwaway key, serves them from localhost and checks that every stale, altered, unsigned or - # missing case fails and the current one passes. + # missing case fails and the current one passes. It also checks the live switch: inert only when + # the domain serves nothing (all 404); a live repository without the key, or a fetch error that is + # not a 404, is red. repo-freshness: name: repo-freshness runs-on: ubuntu-latest diff --git a/.github/workflows/macos-signing-check.yml b/.github/workflows/macos-signing-check.yml index 0878cfb..4fb6e5b 100644 --- a/.github/workflows/macos-signing-check.yml +++ b/.github/workflows/macos-signing-check.yml @@ -16,6 +16,12 @@ on: permissions: {} +# The Apple Team ID the Developer ID certificates must belong to: repository policy, changed only +# through a reviewed pull request. Empty until a maintainer sets it, so signing fails closed. The +# same value is in release.yml; keep the two in step. +env: + MACOS_TEAM_ID: "" + concurrency: group: macos-signing-check cancel-in-progress: false @@ -42,6 +48,12 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} run: | set -euo pipefail + # The expected Apple Team ID is repository policy (MACOS_TEAM_ID above), not a secret: the + # certificates in the environment must belong to it. Empty or malformed fails closed. + if ! [[ "$MACOS_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]]; then + echo "::error::MACOS_TEAM_ID is not set to a 10-character Apple Team ID in this workflow." + exit 1 + fi missing="" for name in MACOS_CERTIFICATE MACOS_CERTIFICATE_PASSWORD MACOS_SIGNING_IDENTITY \ MACOS_INSTALLER_CERTIFICATE MACOS_INSTALLER_CERTIFICATE_PASSWORD \ @@ -116,8 +128,8 @@ jobs: exit 1 fi done - if [ -z "$team" ] || [ "$team" = "not set" ]; then - echo "::error::The signature carries no TeamIdentifier." + if [ "$team" != "$MACOS_TEAM_ID" ]; then + echo "::error::The signature's TeamIdentifier is not the expected MACOS_TEAM_ID." exit 1 fi @@ -171,8 +183,8 @@ jobs: 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." + if [ "$team" != "$MACOS_TEAM_ID" ]; then + echo "::error::The signature's TeamIdentifier is not the expected MACOS_TEAM_ID." exit 1 fi accepted=false @@ -192,6 +204,12 @@ jobs: echo "::error::The package is not signed with a Developer ID Installer certificate." exit 1 fi + # The installer certificate's Team ID is the last parenthesis of its name. + pkg_team="$(sed -n 's/^ *[0-9]*\. Developer ID Installer: .* (\([A-Z0-9]\{10\}\))$/\1/p' <<< "$sig")" + if [ "$pkg_team" != "$MACOS_TEAM_ID" ]; then + echo "::error::The installer certificate's Team ID is not the expected MACOS_TEAM_ID." + exit 1 + fi xcrun stapler validate "$pkg" spctl --assess --type install --verbose=2 "$pkg" { diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml index cc795ba..82217b1 100644 --- a/.github/workflows/package-managers.yml +++ b/.github/workflows/package-managers.yml @@ -99,7 +99,7 @@ jobs: - name: Upload the manifests if: steps.version.outputs.version != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: package-manifests path: out/ @@ -133,7 +133,7 @@ jobs: - name: Download the manifests if: steps.app.outputs.ready == 'true' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: package-manifests path: out @@ -141,7 +141,7 @@ jobs: - name: Mint a token for the tap and the bucket if: steps.app.outputs.ready == 'true' id: token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 with: client-id: ${{ vars.PACKAGING_APP_CLIENT_ID }} private-key: ${{ secrets.PACKAGING_APP_PRIVATE_KEY }} @@ -232,6 +232,8 @@ jobs: echo "::error::packaging/gpg.key (the public key of the signing subkey) is not in this release; the repositories cannot be published." exit 1 fi + # Exactly one key, and the pinned one, before anything is signed or uploaded. + sh packaging/repo/keycheck.sh packaging/gpg.key - name: Install cosign if: steps.config.outputs.ready == 'true' @@ -290,8 +292,13 @@ jobs: set -euo pipefail mkdir -m 700 "$GNUPGHOME" printf '%s\n' "$SIGNING_KEY" | gpg --batch --quiet --import + # The public key carries the current binding signatures: after a renewal it is newer than the + # secret's copy, whose subkey gpg would refuse to sign with once the old expiry has passed. + gpg --batch --quiet --import packaging/gpg.key + # Only a signing subkey that is usable and whose secret is present counts: not a stub without + # its secret (field 15 is #), nor a revoked, expired or invalid one (field 2 is r, e or i). fingerprints=$(gpg --batch --with-colons --list-secret-keys | - awk -F: '$1 == "ssb" { sign = ($12 ~ /s/) } $1 == "fpr" && sign { print $10; sign = 0 }') + awk -F: '$1 == "ssb" { sign = ($12 ~ /s/ && $15 !~ /#/ && $2 !~ /[rei]/) } $1 == "fpr" && sign { print $10; sign = 0 }') if [ "$(printf '%s\n' "$fingerprints" | grep -c .)" != 1 ]; then echo "::error::PACKAGES_GPG_SIGNING_KEY must hold exactly one signing subkey." exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28a1251..fb3dba5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -184,7 +184,7 @@ jobs: tar -C "$(dirname "$stage")" -czf "hodeishield-${VERSION}-${TARGET}.tar.gz" "$(basename "$stage")" - name: Upload archive - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: archive-${{ matrix.target }} path: hodeishield-*.tar.gz @@ -236,7 +236,7 @@ jobs: cp target/man/*.1 stage/man/ - name: Upload the unsigned binary - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: unsigned-universal-apple-darwin path: stage/* @@ -276,9 +276,13 @@ jobs: VERSION: ${{ needs.version.outputs.version }} TARGET: universal-apple-darwin SIGN: ${{ needs.version.outputs.sign_macos }} + # The Apple Team ID the Developer ID certificates must belong to: repository policy, changed + # only through a reviewed pull request. Empty until a maintainer sets it, so signing fails + # closed. The same value is in macos-signing-check.yml; keep the two in step. + MACOS_TEAM_ID: "" steps: - name: Download the unsigned binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: unsigned-universal-apple-darwin path: stage @@ -338,6 +342,12 @@ jobs: APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} run: | set -euo pipefail + # The expected Apple Team ID is repository policy (MACOS_TEAM_ID above), not a secret: the + # certificates in the environment must belong to it. Empty or malformed fails closed. + if ! [[ "$MACOS_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]]; then + echo "::error::MACOS_TEAM_ID is not set to a 10-character Apple Team ID in this workflow." + exit 1 + fi missing=0 for name in MACOS_CERTIFICATE MACOS_CERTIFICATE_PASSWORD MACOS_SIGNING_IDENTITY \ MACOS_INSTALLER_CERTIFICATE MACOS_INSTALLER_CERTIFICATE_PASSWORD \ @@ -397,8 +407,8 @@ jobs: exit 1 fi done - if [ -z "$team" ] || [ "$team" = "not set" ]; then - echo "::error::The signature carries no TeamIdentifier." + if [ "$team" != "$MACOS_TEAM_ID" ]; then + echo "::error::The signature's TeamIdentifier is not the expected MACOS_TEAM_ID." exit 1 fi @@ -461,8 +471,8 @@ jobs: 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." + if [ "$team" != "$MACOS_TEAM_ID" ]; then + echo "::error::The signature's TeamIdentifier is not the expected MACOS_TEAM_ID." exit 1 fi accepted=false @@ -482,6 +492,12 @@ jobs: echo "::error::The package is not signed with a Developer ID Installer certificate." exit 1 fi + # The installer certificate's Team ID is the last parenthesis of its name. + pkg_team="$(sed -n 's/^ *[0-9]*\. Developer ID Installer: .* (\([A-Z0-9]\{10\}\))$/\1/p' <<< "$sig")" + if [ "$pkg_team" != "$MACOS_TEAM_ID" ]; then + echo "::error::The installer certificate's Team ID is not the expected MACOS_TEAM_ID." + exit 1 + fi xcrun stapler validate "$pkg" spctl --assess --type install --verbose=2 "$pkg" { @@ -504,7 +520,7 @@ jobs: ls -l dist - name: Upload archive - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: archive-universal-apple-darwin path: dist/* @@ -549,7 +565,7 @@ jobs: Copy-Item -Path "docs/user-information.md" -Destination stage/USER-INFORMATION.md - name: Upload the unsigned executable - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: unsigned-${{ matrix.target }} path: stage/* @@ -604,7 +620,7 @@ jobs: exe_sha256_arm64: ${{ steps.handoff.outputs.exe_sha256_arm64 }} steps: - name: Download the unsigned executable - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: unsigned-${{ matrix.target }} path: stage @@ -734,7 +750,7 @@ jobs: # The MSI is built for x64 only (an arm64 MSI is a follow-up). - name: Upload the signed executable for the MSI build if: matrix.key == 'x64' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: windows-msi-input path: stage/* @@ -752,7 +768,7 @@ jobs: Compress-Archive -Path "$dir/*" -DestinationPath "dist/$stageName.zip" - name: Upload archive - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: archive-${{ matrix.target }} path: dist/*.zip @@ -782,7 +798,7 @@ jobs: persist-credentials: false - name: Download the signed executable - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: windows-msi-input path: msi-input @@ -840,7 +856,7 @@ jobs: Write-Host "Install, PATH entry and uninstall: OK" - name: Upload the unsigned MSI - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: windows-msi-unsigned path: msi-out/*.msi @@ -848,7 +864,8 @@ jobs: retention-days: 1 # Signs the MSI with the same Azure Artifact Signing profile as the exe. Like `sign-windows`, it - # checks nothing out and runs no repository or WiX code. The MSI is published only when signed. + # runs no repository or WiX code: the checkout is only the reference copy of LICENSE, NOTICE and + # user-information.md that the extracted payload is compared with. The MSI is published only when signed. sign-msi: name: msi (sign) needs: [version, sign-windows, windows-msi] @@ -862,8 +879,13 @@ jobs: VERSION: ${{ needs.version.outputs.version }} SIGN: ${{ needs.version.outputs.sign_windows }} steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Download the MSI - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: windows-msi-unsigned path: msi @@ -880,9 +902,11 @@ jobs: # The MSI was built by a job that ran WiX, third-party code: do not sign it on trust. Using # only Windows' own Installer API, check that it carries no custom actions or embedded - # binaries (so nothing runs at install time but standard actions), then extract it with an - # administrative install and check its payload is exactly the exe sign-windows signed, plus - # LICENSE, NOTICE and USER-INFORMATION.md. + # binaries (so nothing runs at install time but standard actions), and that its administrative + # sequences hold only passive standard actions: `msiexec /a` runs AdminExecuteSequence, and + # actions such as SelfRegModules would load a DLL shipped as a payload file. Then extract it + # with an administrative install and check its payload is exactly the exe sign-windows signed, + # plus LICENSE, NOTICE and USER-INFORMATION.md byte for byte as in the checked-out commit. - name: Check the MSI before signing it shell: powershell env: @@ -893,21 +917,57 @@ jobs: $installer = New-Object -ComObject WindowsInstaller.Installer $db = $installer.OpenDatabase($msi, 0) # The COM calls return values PowerShell would add to the function's output: discard them. - function Count-Rows([string]$table) { - try { $view = $db.OpenView("SELECT * FROM ``$table``") } catch { return [int]0 } + # Fails closed: only a table that is not in _Tables is empty; any other error stops the job. + function Get-Rows([string]$table, [string[]]$columns) { + $list = ($columns | ForEach-Object { "``$_``" }) -join ", " + $view = $db.OpenView("SELECT $list FROM ``$table``") [void]$view.Execute() - $n = 0 - while ($null -ne $view.Fetch()) { $n++ } + $rows = @() + while ($null -ne ($record = $view.Fetch())) { + $rows += ,((1..$columns.Count | ForEach-Object { $record.StringData($_) }) -join "|") + } [void]$view.Close() - return [int]$n + return $rows } $bad = @() - foreach ($table in @("CustomAction", "Binary", "MsiEmbeddedUI", "MsiEmbeddedChainer")) { - $n = [int](Count-Rows $table) - Write-Host "$table rows: $n" - if ($n -ne 0) { $bad += $table } + # Every table must be one of those WiX writes for this package (those of the published + # 0.5.1 MSI). Anything else, CustomAction, Binary, SelfReg, Class, TypeLib, MsiAssembly, + # ServiceInstall, RemoveFile, Shortcut, IniFile... included, is refused by not being listed. + $tables = @(Get-Rows "_Tables" @("Name")) + Write-Host "Tables: $($tables -join ', ')" + $knownTables = @("Property", "Upgrade", "LaunchCondition", "Directory", "File", "Component", "Environment", "Registry", "Feature", "FeatureComponents", "AdminUISequence", "AdminExecuteSequence", "AdvtExecuteSequence", "InstallUISequence", "InstallExecuteSequence", "Media", "MsiFileHash", "_Validation") + $extraTables = @($tables | Where-Object { $knownTables -notcontains $_ }) + if ($extraTables.Count -gt 0) { $bad += "tables ($($extraTables -join ', '))" } + # Only these standard actions may appear in a sequence (a subset: a WiX that omits one + # still passes). `msiexec /a` runs AdminExecuteSequence, so a custom or payload-loading + # action there would run code on this runner. + $allowedActions = @{ + AdminExecuteSequence = @("CostInitialize", "FileCost", "CostFinalize", "InstallValidate", "InstallInitialize", "InstallAdminPackage", "InstallFiles", "InstallFinalize") + AdminUISequence = @("CostInitialize", "FileCost", "CostFinalize", "ExecuteAction") + AdvtExecuteSequence = @("CostInitialize", "CostFinalize", "InstallValidate", "InstallInitialize", "InstallFinalize", "PublishFeatures", "PublishProduct") + InstallUISequence = @("CostInitialize", "FileCost", "CostFinalize", "ExecuteAction", "FindRelatedProducts", "LaunchConditions", "ValidateProductID", "MigrateFeatureStates") + InstallExecuteSequence = @("CostInitialize", "FileCost", "CostFinalize", "InstallValidate", "InstallInitialize", "InstallFiles", "InstallFinalize", "PublishFeatures", "PublishProduct", "FindRelatedProducts", "LaunchConditions", "ValidateProductID", "MigrateFeatureStates", "ProcessComponents", "UnpublishFeatures", "RemoveRegistryValues", "RemoveEnvironmentStrings", "RemoveFiles", "RemoveFolders", "CreateFolders", "WriteRegistryValues", "WriteEnvironmentStrings", "RegisterUser", "RegisterProduct", "RemoveExistingProducts") + } + foreach ($sequence in $allowedActions.Keys) { + if ($tables -notcontains $sequence) { continue } + $found = @(Get-Rows $sequence @("Action")) + Write-Host "$sequence actions: $($found -join ', ')" + $extra = @($found | Where-Object { $allowedActions[$sequence] -notcontains $_ }) + if ($extra.Count -gt 0) { $bad += "$sequence ($($extra -join ', '))" } } - $files = [int](Count-Rows "File") + # The only state the package changes: its InstallDir value and the machine PATH entry. + $pinned = @{ + Registry = @{ Columns = @("Root", "Key", "Name", "Value", "Component_"); Rows = @("2|Software\Hodeitek\HodeiShield CLI|InstallDir|[INSTALLFOLDER]|SystemPath") } + Environment = @{ Columns = @("Environment", "Name", "Value", "Component_"); Rows = @("PathEntry|=-*PATH|[~];[INSTALLFOLDER]|SystemPath") } + } + foreach ($table in $pinned.Keys) { + if ($tables -notcontains $table) { continue } + $rows = @(Get-Rows $table $pinned[$table].Columns) + Write-Host "$table rows: $($rows -join ' ; ')" + $extra = @($rows | Where-Object { $pinned[$table].Rows -notcontains $_ }) + if ($extra.Count -gt 0) { $bad += "$table ($($extra -join ' ; '))" } + } + $files = @(Get-Rows "File" @("File")).Count Write-Host "File rows: $files" if ($files -ne 4) { $bad += "File ($files rows, expected 4)" } [System.Runtime.InteropServices.Marshal]::ReleaseComObject($db) | Out-Null @@ -922,8 +982,15 @@ jobs: $exe = $payload | Where-Object { $_.Name -eq "hodeishield.exe" } $hash = (Get-FileHash -LiteralPath $exe.FullName -Algorithm SHA256).Hash.ToLower() if ($hash -ne $env:EXE_SHA256) { Write-Host "::error::the exe in the MSI is $hash, not the signed $env:EXE_SHA256"; exit 1 } + # The texts must be the ones in the repository at this commit, which windows-msi packaged. + $reference = @{ "LICENSE" = "LICENSE"; "NOTICE" = "NOTICE"; "USER-INFORMATION.md" = "docs\user-information.md" } + foreach ($name in $reference.Keys) { + $inMsi = (Get-FileHash -LiteralPath ($payload | Where-Object { $_.Name -eq $name }).FullName -Algorithm SHA256).Hash + $inRepo = (Get-FileHash -LiteralPath $reference[$name] -Algorithm SHA256).Hash + if ($inMsi -ne $inRepo) { Write-Host "::error::$name in the MSI ($inMsi) differs from the repository's ($inRepo)"; exit 1 } + } Remove-Item -Recurse -Force $target - Write-Host "MSI content: OK (no custom actions or binaries; payload is the signed exe, LICENSE, NOTICE, USER-INFORMATION.md)" + Write-Host "MSI content: OK (no custom actions or binaries; only passive admin actions; payload is the signed exe and the repository's LICENSE, NOTICE, USER-INFORMATION.md)" - name: Azure login (OIDC) if: env.SIGN == 'true' @@ -966,7 +1033,7 @@ jobs: - name: Upload the signed MSI if: env.SIGN == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: # `archive-` so `publish` picks it up with the archives. name: archive-msi @@ -995,7 +1062,7 @@ jobs: persist-credentials: false - name: Download the Linux archives - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: archive-*-unknown-linux-musl path: archives @@ -1041,7 +1108,7 @@ jobs: ls -l dist - name: Upload the packages - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: # `archive-` so `publish` picks them up with the archives. name: archive-packages @@ -1099,7 +1166,7 @@ jobs: done - name: Upload the SBOMs - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: # `archive-` so `publish` picks them up with the archives. name: archive-sbom @@ -1138,7 +1205,7 @@ jobs: # run third-party code (build scripts, cargo-cyclonedx, nfpm, WiX); an extra `archive-*` # artifact from any of them makes this step fail instead of being published and signed. - name: Download the release artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: archive-* path: artifacts diff --git a/.github/workflows/repo-freshness.yml b/.github/workflows/repo-freshness.yml index dc1f41a..0651d5a 100644 --- a/.github/workflows/repo-freshness.yml +++ b/.github/workflows/repo-freshness.yml @@ -7,9 +7,12 @@ name: Repository freshness # files only, uses no secret and no environment, and writes nothing but an issue labelled # `repo-freshness`: opened (or commented on) while the check fails, and closed when it passes again. # -# Until packaging/gpg.key is committed (the repositories are not live before that) it does nothing. -# From then on it fails closed: a download that fails, an unreadable release or a signature that does -# not verify is a failure, never a pass. +# It does nothing only while the domain serves no APT or DNF index (every entry point answers a clean +# 404) and the latest release is not newer than the last one published without the repositories, or is +# still inside the grace period (packaging/repo/live.sh): a 404 after that is a failure. Once any index is served, packaging/gpg.key must be present +# or the run fails, so removing the key cannot silence the monitor. From then on it fails closed: a +# download that fails, an unreadable release or a signature that does not verify is a failure, never +# a pass (packaging/repo/live.sh decides which of the two applies). on: schedule: - cron: "43 6 * * 1" # Mondays, 06:43 UTC @@ -46,14 +49,44 @@ jobs: with: persist-credentials: false - - name: Check the repositories are live + # Inert only while no index is served, and only while that is acceptable: a clean 404 is fine + # before the first release that publishes the repositories, and during the grace period after a + # later one (LAST_RELEASE_WITHOUT_REPOSITORY in packaging/repo/live.sh decides). Any other answer + # than a clean 404 (a network error, a 403, a 5xx, a redirect), a 404 that is no longer + # acceptable, a latest release that cannot be read or a served index without packaging/gpg.key + # fails the run, and the report step below opens or reuses the issue. + - name: Check whether the repositories are live id: gate + env: + GH_TOKEN: ${{ github.token }} + GRACE_HOURS: ${{ inputs.grace_hours || '48' }} run: | - if [ ! -s packaging/gpg.key ]; then - echo "::notice::packaging/gpg.key is not committed yet, so the repositories are not live; nothing to check." - exit 0 - fi - echo "active=true" >> "$GITHUB_OUTPUT" + rc=0 + # This subshell is the left side of ||, so set -e does not apply inside it: every command + # that can fail says so with its own || exit 1. + ( + sh packaging/repo/keycheck.sh packaging/gpg.key || exit 1 + [[ "$GRACE_HOURS" =~ ^[0-9]+$ ]] || { echo "live.sh: the grace period is not a number of hours."; exit 1; } + release=$(gh release view -R "$GITHUB_REPOSITORY" --json tagName,isDraft,isPrerelease,publishedAt) || + { echo "live.sh: the latest release cannot be read."; exit 1; } + tag=$(jq -r .tagName <<< "$release") || { echo "live.sh: the tag of the latest release cannot be read."; exit 1; } + [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "live.sh: the tag of the latest release is not a stable version."; exit 1; } + published=$(date -d "$(jq -r .publishedAt <<< "$release")" +%s) || + { echo "live.sh: the publication time of the latest release cannot be read."; exit 1; } + export FRESHNESS_LATEST_RELEASE=${tag#v} + export FRESHNESS_RELEASE_AGE_HOURS=$(( ($(date +%s) - published) / 3600 )) + export FRESHNESS_GRACE_HOURS=$GRACE_HOURS + sh packaging/repo/live.sh "$BASE_URL" packaging/gpg.key || exit $? + ) > "$RUNNER_TEMP/report.txt" 2>&1 || rc=$? + cat "$RUNNER_TEMP/report.txt" + case $rc in + 0) echo "active=true" >> "$GITHUB_OUTPUT" ;; + 10) echo "::notice::The repositories are not live yet and that is still acceptable; nothing to check." ;; + *) + echo "active=true" >> "$GITHUB_OUTPUT" + echo "result=fail" >> "$GITHUB_OUTPUT" + ;; + esac # GitHub disables scheduled workflows of a repository without activity for 60 days. A long gap # since the previous run is reported with the result, because a monitor that did not run @@ -89,7 +122,7 @@ jobs: # The latest published release (not a draft, not a prerelease) decides what must be served. # Anything that cannot be read is a failure. The report is what the issue shows. - name: Check the repositories serve the latest release - if: steps.gate.outputs.active == 'true' + if: steps.gate.outputs.active == 'true' && steps.gate.outputs.result != 'fail' id: check env: GH_TOKEN: ${{ github.token }} @@ -132,6 +165,10 @@ jobs: result=grace echo "::warning::The latest release is less than $GRACE_HOURS hours old and the repositories do not serve it yet; not treated as a failure." ;; + 5) + result=warn + echo "::warning::The repository key, or its signing subkey, expires within 60 days; renew it (CONTRIBUTING.md)." + ;; *) result=fail ;; esac echo "result=$result" >> "$GITHUB_OUTPUT" @@ -140,7 +177,7 @@ jobs: if: "!cancelled() && steps.gate.outputs.active == 'true'" env: GH_TOKEN: ${{ github.token }} - RESULT: ${{ steps.check.outputs.result }} + RESULT: ${{ steps.gate.outputs.result || steps.check.outputs.result }} EXPECTED: ${{ steps.check.outputs.expected }} GAP: ${{ steps.gap.outputs.note }} run: | @@ -166,6 +203,29 @@ jobs: issue=$(gh issue list -R "$GITHUB_REPOSITORY" --label "$LABEL" --state open --json number --jq '.[0].number // empty') case ${RESULT:-fail} in + warn) + { + echo "The APT and DNF repositories at $BASE_URL are current, but the repository key or its signing subkey expires within 60 days." + echo + echo "Renew it before then and publish a release: see \"The repository signing key\" in CONTRIBUTING.md. Clients that still hold the old copy reject signatures made after the expiry." + echo + echo '```' + print_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 D93F0B \ + --description "The APT or DNF repository is not serving the latest release" + gh issue create -R "$GITHUB_REPOSITORY" --label "$LABEL" \ + --title "The repository signing key expires within 60 days" \ + --body-file "$RUNNER_TEMP/issue.md" + fi + ;; pass) if [ -n "$issue" ]; then gh issue close "$issue" -R "$GITHUB_REPOSITORY" --reason completed \ @@ -176,7 +236,7 @@ jobs: { echo "The APT and DNF repositories at $BASE_URL did not pass the weekly freshness check." echo - echo "Expected version: ${EXPECTED:-unknown (the latest release could not be read)}" + echo "Expected version: ${EXPECTED:-not determined (the check stopped before reading the latest release)}" echo echo '```' print_report @@ -199,7 +259,7 @@ jobs: esac - name: Fail if the check failed - if: "!cancelled() && steps.gate.outputs.active == 'true' && steps.check.outputs.result != 'pass' && steps.check.outputs.result != 'grace'" + if: "!cancelled() && steps.gate.outputs.active == 'true' && steps.check.outputs.result != 'pass' && steps.check.outputs.result != 'grace' && steps.check.outputs.result != 'warn'" run: | echo "::error::The APT and DNF repositories did not pass the freshness check; see the issue labelled repo-freshness." exit 1 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 345818a..809eb21 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -35,13 +35,13 @@ jobs: persist-credentials: false - name: Run Scorecard - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 with: results_file: results.sarif results_format: sarif publish_results: true - name: Upload the results to code scanning - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: sarif_file: results.sarif diff --git a/.github/workflows/security-tests.yml b/.github/workflows/security-tests.yml index 41777b9..6cd6e69 100644 --- a/.github/workflows/security-tests.yml +++ b/.github/workflows/security-tests.yml @@ -52,14 +52,14 @@ jobs: run: rustup show - name: Initialise CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: ${{ matrix.language }} build-mode: none queries: security-extended - name: Analyse - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: /language:${{ matrix.language }} @@ -72,7 +72,7 @@ jobs: actions: read # read by the SARIF upload contents: read security-events: write # upload the results to code scanning - uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@a345acffa64b0eaede81a3d9aae6141214d9c8fc # v2.6.0 + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b1d267aea5211fa648d9d442fa64b90dbdcbdd36 # v2.7.0 with: scan-args: |- --recursive diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index baf67f4..ccb9a19 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -104,7 +104,7 @@ jobs: run: '"$RUNNER_TEMP/zizmor" --persona pedantic --no-exit-codes --format sarif . > "$RUNNER_TEMP/zizmor.sarif"' - name: Upload the results to code scanning - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: sarif_file: ${{ runner.temp }}/zizmor.sarif category: zizmor diff --git a/.gitignore b/.gitignore index ad67955..c3302fd 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 450f204..1629296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,43 @@ commands, `--json` output and exit codes. Each release's notes start with its se ## [Unreleased] +## [0.6.0] - 2026-10-26 + +### Added + +- Signed APT and DNF repositories at `packages.hodeishield.com` for x86_64 and arm64, so `apt` and + `dnf` install and upgrade the CLI. The README has the setup steps for Debian and Ubuntu, and for + Fedora, RHEL, Rocky Linux and AlmaLinux, with the signing key's fingerprint. It is the only place + that states the fingerprint and the repository configuration: check the key against it. + ([#161](https://github.com/Hodeitek/hodeishield-cli/issues/161)) + +### Changed + +- The generated `/v1` client follows version 2026-10-25 of the API document. `alerts list` now + prints each alert's `updated_at` in `--json` and `--csv` output (the table is unchanged), and + `--sort` accepts `updated_at`. The `/v1/me` answer now also carries `beta` and `user`; a `null` + user (an API key) is still no user. + ([#210](https://github.com/Hodeitek/hodeishield-cli/issues/210)) + +### Fixed + +- `whoami` works when the API leaves out the user's `email`, which it does for a token without the + `email` scope: it shows the name, or the id, and no longer fails to read the answer. Browser + sign-in now also asks for the `email` scope, but only when the app lists it among its supported + scopes. The generated client follows version 2026-10-26 of the API document. + ([#217](https://github.com/Hodeitek/hodeishield-cli/issues/217)) +- `alerts list` no longer depends on `page`, `total` and `total_pages` in the answer, which the API + now treats as optional. When they are missing, `--all` stops at a page with a null `next_cursor` + or fewer alerts than the page size, and the `Page N of M` line is not printed. + ([#210](https://github.com/Hodeitek/hodeishield-cli/issues/210)) + +### Security + +- ctutils is updated from 0.4.3 to 0.4.4 and tokio from 1.53.2 to 1.53.3. They fix RUSTSEC-2026-0385 + (ctutils mask generation and `u128` comparisons were not constant-time on some platforms) and + RUSTSEC-2026-0386 (`AsyncFd::new` and related constructors in tokio violate I/O safety). The CLI + does not call either affected API directly; both crates are dependencies of its dependencies. + ## [0.5.1] - 2026-10-18 ### Changed @@ -425,7 +462,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.5.1...HEAD +[Unreleased]: https://github.com/Hodeitek/hodeishield-cli/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/Hodeitek/hodeishield-cli/compare/v0.5.1...v0.6.0 [0.5.1]: https://github.com/Hodeitek/hodeishield-cli/compare/v0.5.0...v0.5.1 [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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d859244..6355138 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -214,9 +214,13 @@ ASCII-armored secret signing subkey only), and the matching public key committed The APT and DNF indexes carry no `Valid-Until`, so an older index that was once validly signed and is served again from a compromised bucket would still verify: the weekly `repo-freshness` workflow detects -this (once `packaging/gpg.key` is committed), it does not prevent it. It checks that both repositories +this, it does not prevent it. It checks that both repositories are signed by that key and that the newest `hodeishield` version in each index is the latest published release's (`packaging/repo/freshness.sh`, with `packaging/repo/freshness-test.sh` as its control in CI). +The monitor is inert only while the domain serves no APT or DNF index, that is, while `InRelease` and each +`repomd.xml` answer a clean 404 (`packaging/repo/live.sh`). Any other answer (a network or TLS error, 403, +5xx, a redirect), or a served index while `packaging/gpg.key` is missing or unreadable, fails the run, so +removing the key cannot silence it. A failure opens an issue labelled `repo-freshness` with what was expected, what is served and the run, and it is closed when the check passes again; a release less than 48 hours old that is not served yet only raises a warning, since the `repos` job waits for an approval. On such an issue, first check who @@ -244,6 +248,23 @@ submission to another project: `winget validate --manifest ` where a Windows machine is available. 4. Answer the reviewers there. No long-lived token is stored for this. +### The repository signing key + +The APT and DNF repositories are signed with an RSA 4096 OpenPGP key: a certify-only primary key, kept offline by the maintainers and protected by a passphrase, and a sign-only subkey, the only part CI holds (`PACKAGES_GPG_SIGNING_KEY`: the subkey without a passphrase, the primary key as a stub). Both expire two years after creation; `gpg --show-keys packaging/gpg.key` shows the date. Any work with the primary key happens in a throwaway `GNUPGHOME` on a RAM-backed filesystem (`/dev/shm`) with no disk swap active, and ends with `gpgconf --homedir --kill all` and removing the directory. + +**Renewal**, at least 60 days before the expiry. The fingerprint does not change. + +1. Import the primary key and the subkey from the offline copy. +2. Extend both: `gpg --quick-set-expire 2y`, then `gpg --quick-set-expire 2y `. The form `gpg --quick-set-expire 2y '*'` extends only the subkeys, not the primary, and skips a subkey that has already expired. +3. Export the public key to `packaging/gpg.key` (`gpg --armor --export `). +4. Export the subkey again without a passphrase (import `gpg --export-secret-subkeys '!'` into a second throwaway `GNUPGHOME`, remove its passphrase with `gpg --change-passphrase `, export `'!'` again) and replace the `PACKAGES_GPG_SIGNING_KEY` secret with it. The copy in the secret carries the binding signature it was exported with, which is out of date after a renewal. The signing job imports `packaging/gpg.key` after the secret to replace it, so the new `packaging/gpg.key` must be committed before the release that signs; without it gpg refuses to sign once the old expiry date has passed ("Unusable secret key"). +5. Commit the new `packaging/gpg.key` and publish a release from that commit. Its `repos` job signs every index again and uploads the new `/gpg.key`; do not re-run `repos` for an older release afterwards, as it would upload that release's key. The new signatures are needed: gpg replaces the old self-signatures when it extends a key, and Sequoia-based verifiers (`sqv`, used by apt on Debian 13) reject a signature made before the new self-signature when they check it against the extended key. RPM package signatures made before the renewal still verify. +6. Say in the release notes that users must refresh their copy of the key before the old expiry date (README, "When the repository key is renewed"). Clients that still hold the old copy reject the signatures made after that date. + +**If the signing subkey leaks** (for example the CI secret): delete the secret and stop publishing; with the primary key, add a new signing subkey (`gpg --quick-add-key rsa4096 sign 2y`) and revoke the old one (`gpg --edit-key `, `key `, `revkey`, reason "Key has been compromised", `save`); export the new subkey without a passphrase as in renewal step 4, check that it holds exactly one signing subkey, and set the secret; commit the new `packaging/gpg.key`, publish a release, and tell users to refresh the key. A revoked subkey invalidates every signature it made, including those of the RPM packages already published, which the bucket never replaces: they must be signed again by a deliberate maintenance change before `repos` can publish. `rpm` on RHEL and Rocky Linux 8 and 9 does not import a key that carries a revoked subkey, so the published key may need to leave it out (`gpg --export-filter drop-subkey='fpr = ' --armor --export `). + +**If the primary key is stolen**: publish its revocation certificate (kept with the offline copy) by importing it with the public key into a throwaway `GNUPGHOME` and exporting the result as `/gpg.key` and `packaging/gpg.key`. A revocation only reaches users who refresh their key, so also announce it (README, release notes, a security advisory) and tell users to remove the key and the repository file. Then create a new key with a new fingerprint, set the secret, update the fingerprint in the README (the only place that states it; the landing page points there), sign the RPM packages again, and publish a release. Users must check the new fingerprint against the README on GitHub, not against `packages.hodeishield.com` alone. **If the primary key is lost**, it can be neither renewed nor revoked: create a new key well before the expiry and move users to it the same way. + ## Closing issues An issue is closed as soon as the fix reaches `main`, never left open once fixed: diff --git a/Cargo.lock b/Cargo.lock index 41a39ba..26b871f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -607,9 +607,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865" dependencies = [ "cmov", ] @@ -779,6 +779,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -970,7 +976,7 @@ dependencies = [ [[package]] name = "hodeishield-api" -version = "0.5.1" +version = "0.6.0" dependencies = [ "mockito", "reqwest", @@ -983,7 +989,7 @@ dependencies = [ [[package]] name = "hodeishield-cli" -version = "0.5.1" +version = "0.6.0" dependencies = [ "apple-native-keyring-store", "assert_cmd", @@ -2094,9 +2100,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -2230,10 +2236,11 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.152" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1741ab7a6cc54a03a89b5d563ed60075c277d9e3cfa73ad0c1f23f23974703c6" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash", "indexmap", "itoa", "memchr", @@ -2493,9 +2500,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -2877,9 +2884,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] @@ -3067,7 +3074,7 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "xtask" -version = "0.5.1" +version = "0.6.0" dependencies = [ "clap", "clap_mangen", diff --git a/Cargo.toml b/Cargo.toml index b011531..2d9c9ec 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.5.1" +version = "0.6.0" edition = "2024" rust-version = "1.98" license = "Apache-2.0" diff --git a/README.md b/README.md index d4c8a8b..e778745 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Windows blocks the downloaded binary, see | 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.5.1` ships `hodeishield-0.5.1-…`. The Linux binaries are +`` has no leading `v`: release `v0.6.0` ships `hodeishield-0.6.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. @@ -76,7 +76,7 @@ neither is part of a distribution's default install. 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.5.1 hodeishield-cli +cargo install --locked --git https://github.com/Hodeitek/hodeishield-cli --tag v0.6.0 hodeishield-cli ``` ### Homebrew and Scoop @@ -110,19 +110,120 @@ repository; it is not available yet. ### Debian, Ubuntu, Fedora, RHEL and other Linux distributions -From 0.3.0, each release also publishes `.deb` and `.rpm` packages for x86_64 and arm64. They install `/usr/bin/hodeishield`, the man pages and the bash, zsh and fish completions. -From 0.4.2 they also install the [user information](docs/user-information.md) in -`/usr/share/doc/hodeishield/USER-INFORMATION.md`. -Verify the package as in the next section (it has its own `.sigstore.json` and is listed in -`SHA256SUMS`), then: +Signed APT and DNF repositories at `packages.hodeishield.com` provide `.deb` and `.rpm` packages for +x86_64 and arm64, and your system's normal upgrades keep the CLI current. The packages install +`/usr/bin/hodeishield`, the man pages and the bash, zsh and fish completions, and from 0.4.2 the +[user information](docs/user-information.md) in `/usr/share/doc/hodeishield/USER-INFORMATION.md`. + +The repositories are signed with this key (RSA 4096); check its fingerprint before trusting it: + +```text +AA2A F212 85A9 AB99 0833 9089 35D5 F941 71C1 CD0B +``` + +The commands below check that the downloaded file holds exactly one key and that its fingerprint is this +one. Reading the fingerprint is not enough: a file can hold the real key and a second one, and the +second would then be trusted as well. Each command below runs the check first and goes on to install +the key, and to write the repository source, only when it passes; otherwise it prints `STOP` and +deletes the file. The check needs `gpg` (`sudo apt-get install gnupg` on a minimal Debian or Ubuntu +image). + +#### Debian, Ubuntu (APT) + +```sh +sudo install -d -m 0755 /etc/apt/keyrings +curl -fsSL https://packages.hodeishield.com/gpg.key -o hodeishield.asc +gpg --show-keys --with-fingerprint hodeishield.asc # shows the fingerprint above +[ "$(gpg --show-keys --with-colons hodeishield.asc | awk -F: '$1=="pub"{n++} $1=="fpr"&&!f{f=$10} END{if(n==1)print f}')" = AA2AF21285A9AB990833908935D5F94171C1CD0B ] \ + && sudo install -m 0644 hodeishield.asc /etc/apt/keyrings/hodeishield.asc \ + && rm hodeishield.asc \ + && echo "deb [signed-by=/etc/apt/keyrings/hodeishield.asc] https://packages.hodeishield.com/apt stable main" \ + | sudo tee /etc/apt/sources.list.d/hodeishield.list \ + || { echo "STOP: the file is not the one key with the fingerprint above, or an install step failed"; rm -f hodeishield.asc; } +sudo apt update && sudo apt install hodeishield +``` + +Do not install the key into `/etc/apt/trusted.gpg.d`: with `signed-by`, it is trusted for this repository +only. Upgrade with `sudo apt update && sudo apt upgrade` (or `sudo apt install --only-upgrade hodeishield`). + +#### Fedora, RHEL, Rocky Linux, AlmaLinux (DNF) + +Download the key and check it (it must be exactly one key, with the fingerprint above), then write the repository file below by hand. It turns on +signature checking of both the packages and the repository metadata (`gpgcheck=1`, `repo_gpgcheck=1`) +and reads the key from the file you checked: + +```sh +curl -fsSL https://packages.hodeishield.com/gpg.key -o hodeishield.asc +gpg --show-keys --with-fingerprint hodeishield.asc # shows the fingerprint above +[ "$(gpg --show-keys --with-colons hodeishield.asc | awk -F: '$1=="pub"{n++} $1=="fpr"&&!f{f=$10} END{if(n==1)print f}')" = AA2AF21285A9AB990833908935D5F94171C1CD0B ] \ + && sudo install -m 0644 hodeishield.asc /etc/pki/rpm-gpg/RPM-GPG-KEY-hodeishield \ + && rm hodeishield.asc \ + && sudo tee /etc/yum.repos.d/hodeishield.repo > /dev/null || { echo "STOP: the file is not the one key with the fingerprint above, or an install step failed"; rm -f hodeishield.asc; } << 'REPO' +[hodeishield] +name=HodeiShield CLI +baseurl=https://packages.hodeishield.com/rpm/$basearch +enabled=1 +gpgcheck=1 +repo_gpgcheck=1 +gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-hodeishield +REPO +sudo dnf install hodeishield +``` + +The first time, DNF shows the key and asks to import it: confirm only if the fingerprint is the one above. +Upgrade with `sudo dnf upgrade hodeishield`. `sudo apt remove hodeishield` or +`sudo dnf remove hodeishield` uninstalls it. + +This README is the only place that states the key's fingerprint and the repository configuration. The +site `packages.hodeishield.com` serves the packages, the indexes and a copy of the key, and points here +for the rest. + +#### When the repository key is renewed + +The signing key has an expiry date and is extended before it passes. The fingerprint stays the same, so +only your copy of the public key needs refreshing, and it must be refreshed before the old expiry date: +clients that still hold the old copy reject signatures made after it. If `apt update` or `dnf` reports an +expired key, or the release notes announce a renewal, run: + +```sh +# Debian, Ubuntu +curl -fsSL https://packages.hodeishield.com/gpg.key -o hodeishield.asc +gpg --show-keys --with-fingerprint hodeishield.asc # shows the fingerprint above +[ "$(gpg --show-keys --with-colons hodeishield.asc | awk -F: '$1=="pub"{n++} $1=="fpr"&&!f{f=$10} END{if(n==1)print f}')" = AA2AF21285A9AB990833908935D5F94171C1CD0B ] \ + && sudo install -m 0644 hodeishield.asc /etc/apt/keyrings/hodeishield.asc \ + && rm hodeishield.asc \ + || { echo "STOP: the file is not the one key with the fingerprint above, or an install step failed"; rm -f hodeishield.asc; } + +# Fedora, RHEL, Rocky Linux, AlmaLinux +curl -fsSL https://packages.hodeishield.com/gpg.key -o hodeishield.asc +gpg --show-keys --with-fingerprint hodeishield.asc # shows the fingerprint above +[ "$(gpg --show-keys --with-colons hodeishield.asc | awk -F: '$1=="pub"{n++} $1=="fpr"&&!f{f=$10} END{if(n==1)print f}')" = AA2AF21285A9AB990833908935D5F94171C1CD0B ] \ + && sudo install -m 0644 hodeishield.asc /etc/pki/rpm-gpg/RPM-GPG-KEY-hodeishield \ + && rm hodeishield.asc \ + && { rpm -q gpg-pubkey --qf '%{NAME}-%{VERSION}-%{RELEASE} %{PACKAGER}\n' | awk '/HodeiShield Packages -1_amd64.deb # Debian, Ubuntu (arm64: _arm64.deb) sudo dnf install ./hodeishield--1.x86_64.rpm # Fedora, RHEL (arm64: .aarch64.rpm) ``` -Upgrading is the same command with the newer package; `sudo apt remove hodeishield` or -`sudo dnf remove hodeishield` uninstalls it. +Upgrading is the same command with the newer package. ### Uninstall an archive or install.sh install @@ -222,7 +323,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.5.1 # the version you want +v=0.6.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 ``` @@ -236,7 +337,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.5.1 +v=0.6.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 \ @@ -272,11 +373,11 @@ Each release has a `SHA256SUMS` file, a Sigstore bundle (`*.sigstore.json`) for `SHA256SUMS`, and SLSA provenance (`hodeishield.intoto.jsonl`). In short: ```sh -grep hodeishield-0.5.1-x86_64-unknown-linux-musl.tar.gz SHA256SUMS | sha256sum -c - +grep hodeishield-0.6.0-x86_64-unknown-linux-musl.tar.gz SHA256SUMS | sha256sum -c - -cosign verify-blob hodeishield-0.5.1-x86_64-unknown-linux-musl.tar.gz \ - --bundle hodeishield-0.5.1-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.1 \ +cosign verify-blob hodeishield-0.6.0-x86_64-unknown-linux-musl.tar.gz \ + --bundle hodeishield-0.6.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.6.0 \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-github-workflow-trigger push ``` @@ -287,12 +388,12 @@ On Windows, PowerShell has no `sha256sum`: compare the hash of the zip or MSI wi fail with a missing-source error), and the same `verify-blob` command works with Windows paths: ```powershell -(Get-FileHash .\hodeishield-0.5.1-x86_64-pc-windows-msvc.zip -Algorithm SHA256).Hash.ToLower() -Select-String hodeishield-0.5.1-x86_64-pc-windows-msvc.zip SHA256SUMS # the two hashes must match +(Get-FileHash .\hodeishield-0.6.0-x86_64-pc-windows-msvc.zip -Algorithm SHA256).Hash.ToLower() +Select-String hodeishield-0.6.0-x86_64-pc-windows-msvc.zip SHA256SUMS # the two hashes must match # the same two lines with .msi for the MSI -cosign verify-blob .\hodeishield-0.5.1-x86_64-pc-windows-msvc.zip ` - --bundle .\hodeishield-0.5.1-x86_64-pc-windows-msvc.zip.sigstore.json ` - --certificate-identity https://github.com/Hodeitek/hodeishield-cli/.github/workflows/release.yml@refs/tags/v0.5.1 ` +cosign verify-blob .\hodeishield-0.6.0-x86_64-pc-windows-msvc.zip ` + --bundle .\hodeishield-0.6.0-x86_64-pc-windows-msvc.zip.sigstore.json ` + --certificate-identity https://github.com/Hodeitek/hodeishield-cli/.github/workflows/release.yml@refs/tags/v0.6.0 ` --certificate-oidc-issuer https://token.actions.githubusercontent.com ` --certificate-github-workflow-trigger push ``` @@ -327,7 +428,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.5.1/hodeishield-0.5.1-universal-apple-darwin.tar.gz +curl -LO https://github.com/Hodeitek/hodeishield-cli/releases/download/v0.6.0/hodeishield-0.6.0-universal-apple-darwin.tar.gz ``` **Windows.** SmartScreen can warn about a signed file that is still new to it, and versions before diff --git a/crates/hodeishield-api/src/generated.rs b/crates/hodeishield-api/src/generated.rs index 8a4f456..94cd260 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-19"; +pub const OPENAPI_VERSION: &str = "2026-10-26"; /// The server the OpenAPI document declares. pub const DEFAULT_SERVER: &str = "https://api.hodeishield.com"; @@ -147,12 +147,32 @@ pub struct Alert { pub resolution_reason: Option, /// RFC 3339, UTC. pub created_at: String, + /// RFC 3339, UTC. Last change of severity, acknowledgement, resolution or CISA KEV status; equals the instant the service started tracking it for alerts older than that. + pub updated_at: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AlertsPagination { + /// The page that was served. + #[serde(default)] + pub page: Option, + /// Items per page in effect. + pub per_page: i64, + /// Total items matching the filters, across all pages. + #[serde(default)] + pub total: Option, + /// Number of pages at this `per_page`. + #[serde(default)] + pub total_pages: Option, + /// Pass as `cursor` for the next page; null on the last page. + #[serde(default)] + pub next_cursor: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ListAlertsResponse { pub data: Vec, - pub pagination: Pagination, + pub pagination: AlertsPagination, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -402,6 +422,29 @@ pub struct WhoAmIOrganization { pub r#type: String, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Beta { + /// The beta status of the tenant’s own organisation, or `active` while the beta of its parent MSSP runs and lifts its access. Open vocabulary: `active`, `ended` or `converted`. + pub status: String, + /// True while the beta runs: status `active` and `ends_at` in the future. While it runs the organisation has Enterprise access. + pub running: bool, + /// When the own organisation’s beta ends or ended, ISO 8601 UTC. Null when no end is recorded, and always null for a beta inherited from the parent MSSP. + #[serde(default)] + pub ends_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct User { + /// The user the access token belongs to (lowercase). + pub id: String, + /// The user’s display name. Untrusted text. + #[serde(default)] + pub name: Option, + /// The user’s email address. Present only when the access token carries the `email` scope; omitted otherwise. + #[serde(default)] + pub email: Option, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Portfolio { /// How many tenants this key can select, right now. @@ -420,6 +463,12 @@ pub struct WhoAmI { pub tenant: Option, /// The organisation of the tenant (tenant key, OAuth token) or the MSSP the key belongs to (portfolio key). pub organization: WhoAmIOrganization, + /// The beta of the tenant’s own organisation; while it does not run and the parent MSSP’s beta does, `active` and running with no date. Nothing else about the parent’s beta is described: not its end, nor whether it ended or converted. Null when the own organisation never joined the beta and no parent beta runs, and for a portfolio key. + #[serde(default)] + pub beta: Option, + /// The signed-in user of an OAuth access token (`hs_at_…`): the CALLER’S OWN id, name and, with the `email` scope, email; never another user’s. Null for a tenant API key and for a portfolio key, which act for no user, and for a token whose user row is no longer visible in the token’s tenant. + #[serde(default)] + pub user: Option, /// Portfolio keys only. Null for a tenant key or an OAuth token. #[serde(default)] pub portfolio: Option, @@ -619,17 +668,20 @@ impl std::str::FromStr for BusinessCriticality { pub enum AlertSort { /// `created_at` CreatedAt, + /// `updated_at` + UpdatedAt, } impl AlertSort { /// Every accepted value, as sent on the wire. - pub const VALUES: &'static [&'static str] = &["created_at"]; + pub const VALUES: &'static [&'static str] = &["created_at", "updated_at"]; /// The value as sent on the wire. #[must_use] pub fn as_str(self) -> &'static str { match self { Self::CreatedAt => "created_at", + Self::UpdatedAt => "updated_at", } } } @@ -646,6 +698,7 @@ impl std::str::FromStr for AlertSort { fn from_str(value: &str) -> Result { match value { "created_at" => Ok(Self::CreatedAt), + "updated_at" => Ok(Self::UpdatedAt), _ => Err(crate::ParseEnumError::new(value, Self::VALUES)), } } @@ -1155,11 +1208,13 @@ impl ListVendorsParams { /// Query parameters of `listAlerts`. #[derive(Debug, Clone, Default, PartialEq)] pub struct ListAlertsParams { - /// 1-based page number. Defaults to 1. + /// 1-based page number. Defaults to 1. Cannot be combined with `cursor`. pub page: Option, /// Items per page, 1..200. Defaults to 50. pub per_page: Option, - /// Field to order by. One of: created_at. + /// Opaque keyset cursor: pass the `next_cursor` of the previous page, with the same `sort`, `order` and filters. Cannot be combined with `page`. + pub cursor: Option, + /// Field to order by. One of: created_at, updated_at. pub sort: Option, /// Sort direction. Defaults to desc. pub order: Option, @@ -1171,6 +1226,8 @@ pub struct ListAlertsParams { pub vendor_id: Option, /// Only alerts created at or after this RFC 3339 instant. pub since: Option, + /// Only alerts last changed at or after this RFC 3339 instant (`updated_at`), including ones created long before it. Poll changes with `sort=updated_at&order=asc`. + pub updated_since: Option, } impl ListAlertsParams { @@ -1184,6 +1241,9 @@ impl ListAlertsParams { if let Some(value) = &self.per_page { pairs.push(("per_page", value.to_string())); } + if let Some(value) = &self.cursor { + pairs.push(("cursor", value.clone())); + } if let Some(value) = &self.sort { pairs.push(("sort", value.as_str().to_owned())); } @@ -1202,6 +1262,9 @@ impl ListAlertsParams { if let Some(value) = &self.since { pairs.push(("since", value.clone())); } + if let Some(value) = &self.updated_since { + pairs.push(("updated_since", value.clone())); + } pairs } } @@ -1827,7 +1890,7 @@ impl crate::Client { /// List supply-chain alerts. /// - /// Alerts raised against the tenant vendors. Poll incrementally with `since` plus `order=asc`. + /// Alerts raised against the tenant vendors. Poll new alerts with `since` plus `order=asc`, changed ones with `updated_since` plus `sort=updated_at&order=asc`. /// /// Required scope: `supply_risk:read`. Published in `/v1` since 2026-09-20. /// @@ -2037,7 +2100,7 @@ impl crate::Client { /// 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`. + /// Who the presented credential is: its type and scopes, and the tenant and organisation it reads (a tenant API key or an OAuth token), for an OAuth token also the signed-in user (`user`: its own id and name, and its email only when the token carries the `email` scope; never another user’s; null for any key), 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. /// diff --git a/crates/hodeishield-cli/src/auth/file_store.rs b/crates/hodeishield-cli/src/auth/file_store.rs index 1262784..e8695f0 100644 --- a/crates/hodeishield-cli/src/auth/file_store.rs +++ b/crates/hodeishield-cli/src/auth/file_store.rs @@ -14,6 +14,12 @@ //! 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. +//! +//! On macOS the mode bits do not cover extended (NFSv4-style) ACLs, which a folder can pass on to +//! what is created in it. There the directory and the file are stripped of any ACL when the CLI +//! creates them, and one that has an ACL is refused like one open to others. On Linux a POSIX ACL +//! cannot widen access: `chmod 600` sets the ACL mask to no access, which limits every named +//! entry. use super::store::{Payload, StoredToken, TokenStore}; use crate::failure::{Failure, Result}; @@ -193,6 +199,71 @@ mod unix { std::io::Error::from(errno) } + /// Whether `ls -le` output lists ACL entries, which it prints as ` 0: user:name allow read` + /// lines after the entry. (The `+` in the mode column is hidden by `@` when there are extended + /// attributes, so it is not used.) + #[cfg(any(test, target_os = "macos"))] + pub(super) fn lists_acl_entries(output: &str) -> bool { + output.lines().any(|line| { + let line = line.trim_start(); + let digits = line.bytes().take_while(u8::is_ascii_digit).count(); + digits > 0 && line[digits..].starts_with(": ") + }) + } + + /// macOS: refuses a path that carries an extended ACL. Fails closed when `ls` cannot say. + #[cfg(target_os = "macos")] + fn refuse_acl(path: &Path) -> Result<()> { + let output = std::process::Command::new("/bin/ls") + .args(["-lde"]) + .arg(path) + .output() + .map_err(|e| io_failure("Cannot look at the ACL of", path, &e))?; + if !output.status.success() { + return Err(refused( + path, + "has an access control list that cannot be read", + None, + )); + } + if lists_acl_entries(&String::from_utf8_lossy(&output.stdout)) { + return Err(refused( + path, + "has an access control list (ACL), which can give other accounts access", + Some("If nobody else read it, `chmod -N` removes the ACL; a token that may have been read should be revoked from the app.".into()), + )); + } + Ok(()) + } + + /// macOS: removes any ACL, inherited or not, from something this process just created. + #[cfg(target_os = "macos")] + fn strip_acl(path: &Path) -> Result<()> { + let status = std::process::Command::new("/bin/chmod") + .arg("-N") + .arg(path) + .status() + .map_err(|e| io_failure("Cannot remove the ACL of", path, &e))?; + if !status.success() { + return Err(refused( + path, + "has an access control list that cannot be removed", + None, + )); + } + refuse_acl(path) + } + + #[cfg(not(target_os = "macos"))] + fn refuse_acl(_: &Path) -> Result<()> { + Ok(()) + } + + #[cfg(not(target_os = "macos"))] + fn strip_acl(_: &Path) -> Result<()> { + Ok(()) + } + /// 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)> { @@ -239,7 +310,8 @@ mod unix { match problem(stat, want_dir, euid()) { None => Ok(()), Some((reason, fix)) => Err(refused(path, &reason, Some(fix).filter(|f| !f.is_empty()))), - } + }?; + refuse_acl(path) } /// The sessions directory as an open handle, checked with `fstat` on the handle itself. Every @@ -262,6 +334,8 @@ mod unix { .mode(0o700) .create(&store.dir) .map_err(|e| io_failure("Cannot create", &store.dir, &e))?; + // A folder's inheritable ACL may have been passed on to the new directory. + strip_acl(&store.dir)?; created = true; } Err(Errno::NOENT) => return Ok(None), @@ -417,9 +491,12 @@ mod unix { 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) + let file = rustix::fs::openat(dir, name, flags, Mode::RUSR | Mode::WUSR) .map(File::from) - .map_err(|e| io_failure("Cannot create", path, &io_error(e))) + .map_err(|e| io_failure("Cannot create", path, &io_error(e)))?; + // Before anything is written to it, so a token never sits under an inherited ACL. + strip_acl(path)?; + Ok(file) } /// Writes `bytes` to a new 0600 file in the directory and renames it over `name`. @@ -851,6 +928,18 @@ mod tests { assert!(!store.dir.exists()); } + #[test] + fn ls_output_with_acl_entries_is_recognised() { + let with = + "drwx------+ 2 me staff 64 Oct 21 10:00 sessions\n 0: user:other allow list,search\n"; + let without = "drwx------@ 2 me staff 64 Oct 21 10:00 sessions\n"; + assert!(unix::lists_acl_entries(with)); + assert!(!unix::lists_acl_entries(without)); + assert!(!unix::lists_acl_entries( + "-rw------- 1 me staff 9 Oct 21 10:00 a: 1 file\n" + )); + } + #[test] fn network_file_systems_are_recognised_by_their_magic_number() { assert_eq!(network_file_system_for(0x6969), Some("NFS")); diff --git a/crates/hodeishield-cli/src/auth/oauth.rs b/crates/hodeishield-cli/src/auth/oauth.rs index 7159e4e..d6ecd21 100644 --- a/crates/hodeishield-cli/src/auth/oauth.rs +++ b/crates/hodeishield-cli/src/auth/oauth.rs @@ -40,9 +40,14 @@ pub struct Metadata { /// Asks for a refresh token, so a sign-in outlives its short access token. const OFFLINE_ACCESS: &str = "offline_access"; +/// Lets the user's email address show in `whoami`. Asked for only when the app lists it. +const EMAIL: &str = "email"; + /// The scopes to ask for when the profile names none: the ones the `/v1` operations this CLI calls /// need, with a template such as `compliance.:read` standing for every scope of that -/// shape the app offers, plus a refresh token. An app that publishes no list gets the fixed ones. +/// shape the app offers, plus a refresh token, plus `email` when the app lists it (so `whoami` can +/// show the address). An app that publishes no list gets the fixed ones: it may reject a scope it +/// does not know. /// Asking explicitly matters: an app may grant a request without scopes nothing at all. pub fn default_scopes(metadata: &Metadata) -> Vec { let offered = metadata.scopes_supported.as_deref(); @@ -84,6 +89,9 @@ pub fn default_scopes(metadata: &Metadata) -> Vec { } } } + if offered.is_some_and(|o| o.iter().any(|s| s == EMAIL)) { + scopes.push(EMAIL.to_owned()); + } scopes } @@ -890,6 +898,28 @@ mod tests { ); assert_eq!(default_scopes(&hostile), ["compliance.ok_1-2:read"]); + // `email` is asked for only when the app lists it. + let mut with_email = metadata("https://app.example.test"); + with_email.scopes_supported = Some( + ["supply_risk:read", "offline_access", "email"] + .map(str::to_owned) + .to_vec(), + ); + assert_eq!( + default_scopes(&with_email), + ["supply_risk:read", "offline_access", "email"] + ); + let mut without_email = metadata("https://app.example.test"); + without_email.scopes_supported = Some( + ["supply_risk:read", "offline_access", "emails"] + .map(str::to_owned) + .to_vec(), + ); + assert_eq!( + default_scopes(&without_email), + ["supply_risk:read", "offline_access"] + ); + // An app that does not publish its scopes gets the fixed ones; a template stands for none. assert_eq!( default_scopes(&metadata("https://app.example.test")), diff --git a/crates/hodeishield-cli/src/commands/resources.rs b/crates/hodeishield-cli/src/commands/resources.rs index c0c03a6..d4efbf5 100644 --- a/crates/hodeishield-cli/src/commands/resources.rs +++ b/crates/hodeishield-cli/src/commands/resources.rs @@ -19,9 +19,10 @@ use crate::output::{ use hodeishield_api::Client; use hodeishield_api::portfolio::Tenant; use hodeishield_api::v1::{ - Alert, ComplianceControl, DownloadReportParams, Endpoint, Evidence, Format, ListAlertsParams, - ListComplianceControlsParams, ListEndpointsParams, ListEvidenceParams, ListReportsParams, - ListRisksParams, ListVendorsParams, Pagination, Report, Risk, Vendor, operations, + Alert, AlertsPagination, 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; @@ -44,6 +45,58 @@ const MAX_PAGES: i64 = 1_000; /// hundred MiB at most. const MAX_ITEMS: usize = 100_000; +/// What the CLI needs from a list's `pagination`. Alerts may omit the page counts (`page`, `total`, +/// `total_pages`) and report `next_cursor` instead; every other list always sends the counts. +pub(super) struct PageInfo { + pub page: Option, + pub per_page: i64, + pub total: Option, + pub total_pages: Option, + pub next_cursor: Option, +} + +impl From for PageInfo { + fn from(p: Pagination) -> Self { + Self { + page: Some(p.page), + per_page: p.per_page, + total: Some(p.total), + total_pages: Some(p.total_pages), + next_cursor: None, + } + } +} + +impl From for PageInfo { + fn from(p: AlertsPagination) -> Self { + Self { + page: p.page, + per_page: p.per_page, + total: p.total, + total_pages: p.total_pages, + next_cursor: p.next_cursor, + } + } +} + +impl PageInfo { + /// True when `batch_len` items asked for as page `page` were the last ones. Uses the counts when + /// the server sent them; without them, a short page or a null `next_cursor` ends the walk. + fn is_last(&self, page: i64, batch_len: usize) -> bool { + match (self.total_pages, self.total) { + (Some(total_pages), Some(total)) => { + page >= total_pages + || self.page != Some(page) + || page.saturating_mul(self.per_page.max(1)) >= total + } + _ => { + self.next_cursor.is_none() + || i64::try_from(batch_len).is_ok_and(|n| n < self.per_page) + } + } + } +} + /// Prints a list for the selected client, or for every client with `--all-tenants`. #[allow(clippy::too_many_arguments)] fn list( @@ -54,7 +107,7 @@ fn list( api: ApiContext<'_>, targets: &[Target], fetch: &mut FetchFrom<'_, R>, - split: fn(R) -> (Vec, Pagination), + split: fn(R) -> (Vec, PageInfo), render: fn(&[T]) -> Rows, ) -> Result<()> { if matches!(ctx.selection, Selection::All) { @@ -82,7 +135,7 @@ pub(super) fn list_one( noun: &str, api: ApiContext<'_>, fetch: &mut Fetch<'_, R>, - split: fn(R) -> (Vec, Pagination), + split: fn(R) -> (Vec, PageInfo), render: fn(&[T]) -> Rows, ) -> Result<()> { if !paging.all { @@ -101,16 +154,17 @@ pub(super) fn list_one( } else { print_table(out, &render(&items).into_table())?; } - if pagination.total_pages > 1 { - let next = if pagination.page < pagination.total_pages { - format!(" Next: --page {}, or --all.", pagination.page + 1) + // Without the page counts there is no footer to print: only `--all` knows where it ends. + if let (Some(page), Some(total_pages), Some(total)) = + (pagination.page, pagination.total_pages, pagination.total) + && total_pages > 1 + { + let next = if page < total_pages { + format!(" Next: --page {}, or --all.", page + 1) } else { String::new() }; - eprintln!( - "Page {} of {} ({} {noun}).{next}", - pagination.page, pagination.total_pages, pagination.total - ); + eprintln!("Page {page} of {total_pages} ({total} {noun}).{next}"); } return Ok(()); } @@ -149,7 +203,7 @@ fn list_across( api: ApiContext<'_>, targets: &[Target], fetch: &mut FetchFrom<'_, R>, - split: fn(R) -> (Vec, Pagination), + split: fn(R) -> (Vec, PageInfo), render: fn(&[T]) -> Rows, ) -> Result<()> { let per_page = paging.per_page.or(Some(200)); @@ -275,7 +329,7 @@ pub(super) fn collect_pages( per_page: Option, max_items: usize, fetch: &mut Fetch<'_, R>, - split: fn(R) -> (Vec, Pagination), + split: fn(R) -> (Vec, PageInfo), ) -> Result<(Vec, Vec)> { let mut raw_items = Vec::new(); let mut items = Vec::new(); @@ -287,10 +341,7 @@ pub(super) fn collect_pages( } let (mut batch, pagination) = split(response.data); // Stop on whichever says "last page" first, and never trust the server to end the walk. - let done = batch.is_empty() - || page >= pagination.total_pages - || pagination.page != page - || page.saturating_mul(pagination.per_page.max(1)) >= pagination.total; + let done = batch.is_empty() || pagination.is_last(page, batch.len()); items.append(&mut batch); if items.len() > max_items { return Err(crate::failure::Failure::general(format!( @@ -363,7 +414,7 @@ pub fn vendors(ctx: &Context, command: VendorsCommand, out: &mut dyn Write) -> R ..base.clone() }) }, - |r| (r.data, r.pagination), + |r| (r.data, r.pagination.into()), vendor_table, ) } @@ -427,7 +478,7 @@ pub fn alerts(ctx: &Context, command: AlertsCommand, out: &mut dyn Write) -> Res ..base.clone() }) }, - |r| (r.data, r.pagination), + |r| (r.data, r.pagination.into()), alert_table, ) } @@ -494,7 +545,7 @@ pub fn risks(ctx: &Context, command: RisksCommand, out: &mut dyn Write) -> Resul ..base.clone() }) }, - |r| (r.data, r.pagination), + |r| (r.data, r.pagination.into()), risk_table, ) } @@ -555,7 +606,7 @@ pub fn compliance(ctx: &Context, command: ComplianceCommand, out: &mut dyn Write ..base.clone() }) }, - |r| (r.data, r.pagination), + |r| (r.data, r.pagination.into()), control_table, ) } @@ -636,7 +687,7 @@ pub fn evidence(ctx: &Context, command: EvidenceCommand, out: &mut dyn Write) -> ..base.clone() }) }, - |r| (r.data, r.pagination), + |r| (r.data, r.pagination.into()), evidence_table, ) } @@ -697,7 +748,7 @@ pub fn endpoints(ctx: &Context, command: EndpointsCommand, out: &mut dyn Write) ..base.clone() }) }, - |r| (r.data, r.pagination), + |r| (r.data, r.pagination.into()), endpoint_table, ) } @@ -764,7 +815,7 @@ pub fn reports(ctx: &Context, command: ReportsCommand, out: &mut dyn Write) -> R ..base.clone() }) }, - |r| (r.data, r.pagination), + |r| (r.data, r.pagination.into()), report_table, ) } @@ -910,20 +961,35 @@ mod tests { raw: serde_json::json!({ "data": batch }), data: ( batch, - Pagination { - page, + PageInfo { + page: Some(page), per_page, - total, - total_pages, + total: Some(total), + total_pages: Some(total_pages), + next_cursor: None, }, ), meta: Default::default(), }) }, - |r: (Vec, Pagination)| r, + |r: (Vec, PageInfo)| r, ) } + #[test] + fn without_page_counts_a_short_page_or_a_null_cursor_ends_the_walk() { + let info = |per_page, next_cursor: Option<&str>| PageInfo { + page: None, + per_page, + total: None, + total_pages: None, + next_cursor: next_cursor.map(str::to_owned), + }; + assert!(!info(2, Some("c")).is_last(1, 2)); + assert!(info(2, Some("c")).is_last(1, 1)); + assert!(info(2, None).is_last(1, 2)); + } + #[test] fn a_walk_within_the_item_limit_collects_everything() { let (raw, items) = walk(10, 4, 10).expect("within the limit"); diff --git a/crates/hodeishield-cli/src/commands/session.rs b/crates/hodeishield-cli/src/commands/session.rs index f9591ea..4254591 100644 --- a/crates/hodeishield-cli/src/commands/session.rs +++ b/crates/hodeishield-cli/src/commands/session.rs @@ -243,8 +243,8 @@ pub fn whoami(ctx: &Context, out: &mut dyn Write) -> Result<()> { ) } -/// 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. +/// What `GET /v1/me` says about the credential: the generated type, and the signed-in user, read +/// again from the body as received so that a user with missing fields still shows what it has. struct Me { who: WhoAmI, user: Option, @@ -985,8 +985,8 @@ mod tests { #[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("{}")] { + // An answer with no `user` field, and `user: null` (what an API key gets). + for user in [None, Some("null")] { let (text, printed) = whoami_of_a_sign_in(user, Some("Old Way ")); assert!( text.contains("User Old Way \n"), @@ -998,7 +998,7 @@ mod tests { #[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("{}")] { + for user in [None, Some("null")] { let (text, printed) = whoami_of_a_sign_in(user, None); assert!(!text.contains("User"), "{user:?}: {text}"); assert_eq!(printed["user"], Value::Null, "{user:?}"); @@ -1020,8 +1020,30 @@ mod tests { #[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); + // `email` is optional in the contract (absent without the `email` scope); `name` may be null. + let user = MeUser::from_raw(&json!({"id": "u-1", "name": null})).expect("a user"); + assert_eq!(user.label().as_deref(), Some("u-1")); + } + + #[test] + fn whoami_renders_a_user_with_no_email_key() { + let (text, printed) = whoami_of_a_sign_in(Some(r#"{"id":"u-1","name":"Ana"}"#), None); + assert!(text.contains("User Ana\n"), "{text}"); + assert!( + !text.contains("User \n") && !text.contains('<'), + "{text}" + ); + assert_eq!(printed["user"], "Ana"); + let (text, printed) = whoami_of_a_sign_in(Some(r#"{"id":"u-1"}"#), None); assert!(text.contains("User u-1\n"), "{text}"); + assert_eq!(printed["user"], "u-1"); + } + + #[test] + fn a_user_that_is_not_an_object_with_a_field_is_no_user() { + for raw in [json!("x"), json!([]), json!(42), json!({}), Value::Null] { + assert!(MeUser::from_raw(&raw).is_none(), "{raw}"); + } } #[test] diff --git a/crates/hodeishield-cli/src/commands/tenants.rs b/crates/hodeishield-cli/src/commands/tenants.rs index 2fccf4e..0bb8b34 100644 --- a/crates/hodeishield-cli/src/commands/tenants.rs +++ b/crates/hodeishield-cli/src/commands/tenants.rs @@ -144,7 +144,7 @@ fn list_portfolio( totals.push(response.data.pagination.total); Ok(response) }, - |r: ListTenantsResponse| (r.data, r.pagination), + |r: ListTenantsResponse| (r.data, r.pagination.into()), ) .map_err(|failure| { if failure.kind == Kind::Forbidden { @@ -218,7 +218,7 @@ pub fn run(ctx: &Context, command: TenantsCommand, out: &mut dyn Write) -> Resul is_portfolio_key: is_portfolio, }, &mut |page, per_page| client.list_tenants(&ListTenantsParams::alphabetical(page, per_page)), - |r| (r.data, r.pagination), + |r| (r.data, r.pagination.into()), tenant_rows, ) .map_err(|failure| { diff --git a/crates/hodeishield-cli/tests/cli.rs b/crates/hodeishield-cli/tests/cli.rs index fbd1223..f311895 100644 --- a/crates/hodeishield-cli/tests/cli.rs +++ b/crates/hodeishield-cli/tests/cli.rs @@ -237,7 +237,7 @@ fn all_walks_every_page() { mockito::Matcher::UrlEncoded("per_page".into(), "200".into()), ])) .with_body(format!( - r#"{{"data":[{{"id":"a{n}","vendor_id":"v","type":"cve","severity":null,"title":"{name}","description":null,"source_url":null,"economic_impact":null,"status":"open","acknowledged_at":null,"resolved_at":null,"resolution_reason":null,"created_at":"2026-01-01T00:00:00Z"}}],"pagination":{{"page":{n},"per_page":1,"total":2,"total_pages":2}}}}"# + r#"{{"data":[{{"id":"a{n}","vendor_id":"v","type":"cve","severity":null,"title":"{name}","description":null,"source_url":null,"economic_impact":null,"status":"open","acknowledged_at":null,"resolved_at":null,"resolution_reason":null,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"}}],"pagination":{{"page":{n},"per_page":1,"total":2,"total_pages":2}}}}"# )) .expect(1) .create(); @@ -258,6 +258,56 @@ fn all_walks_every_page() { assert_eq!(titles, ["First", "Second"]); } +/// An alerts answer in the 2026-10-25 shape: no `page`, `total` or `total_pages`, only a cursor. +fn cursor_page(n: i64, next_cursor: Option<&str>) -> String { + let next = next_cursor.map_or("null".to_owned(), |c| format!(r#""{c}""#)); + format!( + r#"{{"data":[{{"id":"a{n}","vendor_id":"v","type":"cve","severity":null,"title":"T{n}","description":null,"source_url":null,"economic_impact":null,"status":"open","acknowledged_at":null,"resolved_at":null,"resolution_reason":null,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"}}],"pagination":{{"per_page":1,"next_cursor":{next}}}}}"# + ) +} + +#[test] +fn alerts_without_page_counts_list_without_a_footer() { + let env = Env::new(); + let mut server = mockito::Server::new(); + server + .mock("GET", "/v1/alerts") + .match_query(mockito::Matcher::Any) + .with_body(cursor_page(1, Some("c1"))) + .create(); + let output = env + .api(&server) + .args(["alerts", "list"]) + .output() + .expect("run"); + assert!(output.status.success(), "{output:?}"); + assert!(String::from_utf8_lossy(&output.stdout).contains("T1")); + assert!(!String::from_utf8_lossy(&output.stderr).contains("Page ")); +} + +#[test] +fn all_without_page_counts_stops_at_a_null_cursor() { + let env = Env::new(); + let mut server = mockito::Server::new(); + for (n, next) in [(1, Some("c1")), (2, None)] { + server + .mock("GET", "/v1/alerts") + .match_query(mockito::Matcher::UrlEncoded("page".into(), n.to_string())) + .with_body(cursor_page(n, next)) + .expect(1) + .create(); + } + let output = env + .api(&server) + .args(["alerts", "list", "--all", "--json"]) + .output() + .expect("run"); + assert!(output.status.success(), "{output:?}"); + let items: serde_json::Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(items.as_array().expect("array").len(), 2); + assert_eq!(items[1]["updated_at"], "2026-01-02T00:00:00Z"); +} + #[test] fn a_missing_scope_exits_4_and_names_the_framework_scope() { let env = Env::new(); @@ -724,7 +774,7 @@ fn csv_with_all_prints_one_header_and_every_page() { .mock("GET", "/v1/alerts") .match_query(mockito::Matcher::UrlEncoded("page".into(), n.to_string())) .with_body(format!( - r#"{{"data":[{{"id":"a{n}","vendor_id":"v","type":"cve","severity":null,"title":"{title}","description":null,"source_url":null,"economic_impact":-5,"status":"open","acknowledged_at":null,"resolved_at":null,"resolution_reason":null,"created_at":"2026-01-01T00:00:00Z"}}],"pagination":{{"page":{n},"per_page":1,"total":2,"total_pages":2}}}}"# + r#"{{"data":[{{"id":"a{n}","vendor_id":"v","type":"cve","severity":null,"title":"{title}","description":null,"source_url":null,"economic_impact":-5,"status":"open","acknowledged_at":null,"resolved_at":null,"resolution_reason":null,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"}}],"pagination":{{"page":{n},"per_page":1,"total":2,"total_pages":2}}}}"# )) .expect(1) .create(); @@ -738,9 +788,9 @@ fn csv_with_all_prints_one_header_and_every_page() { assert_eq!( String::from_utf8_lossy(&output.stdout), "id,vendor_id,type,severity,title,description,source_url,economic_impact,status,\ - acknowledged_at,resolved_at,resolution_reason,created_at\r\n\ - a1,v,cve,,'=cmd(),,,-5,open,,,,2026-01-01T00:00:00Z\r\n\ - a2,v,cve,,\"Second, with a comma\",,,-5,open,,,,2026-01-01T00:00:00Z\r\n" + acknowledged_at,resolved_at,resolution_reason,created_at,updated_at\r\n\ + a1,v,cve,,'=cmd(),,,-5,open,,,,2026-01-01T00:00:00Z,2026-01-02T00:00:00Z\r\n\ + a2,v,cve,,\"Second, with a comma\",,,-5,open,,,,2026-01-01T00:00:00Z,2026-01-02T00:00:00Z\r\n" ); assert!(String::from_utf8_lossy(&output.stderr).contains("2 alerts.")); } diff --git a/crates/hodeishield-cli/tests/contract.rs b/crates/hodeishield-cli/tests/contract.rs index 3f2974b..059541a 100644 --- a/crates/hodeishield-cli/tests/contract.rs +++ b/crates/hodeishield-cli/tests/contract.rs @@ -259,8 +259,8 @@ const RESOURCES: [Resource; 6] = [ command: &["alerts"], list_args: &[], path: "/v1/alerts", - item: r#"{"id":"a1","vendor_id":"v1","type":"cve","severity":"high","title":"Example alert","description":null,"source_url":null,"economic_impact":null,"status":"open","acknowledged_at":null,"resolved_at":null,"resolution_reason":null,"created_at":"2026-01-01T00:00:00Z","added_later":"x"}"#, - csv_header: "id,vendor_id,type,severity,title,description,source_url,economic_impact,status,acknowledged_at,resolved_at,resolution_reason,created_at,added_later", + item: r#"{"id":"a1","vendor_id":"v1","type":"cve","severity":"high","title":"Example alert","description":null,"source_url":null,"economic_impact":null,"status":"open","acknowledged_at":null,"resolved_at":null,"resolution_reason":null,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z","added_later":"x"}"#, + csv_header: "id,vendor_id,type,severity,title,description,source_url,economic_impact,status,acknowledged_at,resolved_at,resolution_reason,created_at,updated_at,added_later", }, Resource { command: &["risks"], @@ -634,8 +634,8 @@ fn csv_document_snapshot() { .match_query(mockito::Matcher::Any) .with_body( r#"{"data":[ -{"id":"a1","vendor_id":"v","type":"cve","severity":"+high","title":"=1+1","description":"-x","source_url":"@SUM(A1)","economic_impact":-5,"status":"open","acknowledged_at":null,"resolved_at":null,"resolution_reason":null,"created_at":"2026-01-01T00:00:00Z"}, -{"id":"a2","vendor_id":"v","type":"cve","severity":"say \"hi\"","title":"plain, with comma","description":"two\nlines","source_url":" padded ","economic_impact":2.5,"status":"a=b","acknowledged_at":null,"resolved_at":null,"resolution_reason":null,"created_at":"2026-01-01T00:00:00Z"} +{"id":"a1","vendor_id":"v","type":"cve","severity":"+high","title":"=1+1","description":"-x","source_url":"@SUM(A1)","economic_impact":-5,"status":"open","acknowledged_at":null,"resolved_at":null,"resolution_reason":null,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"}, +{"id":"a2","vendor_id":"v","type":"cve","severity":"say \"hi\"","title":"plain, with comma","description":"two\nlines","source_url":" padded ","economic_impact":2.5,"status":"a=b","acknowledged_at":null,"resolved_at":null,"resolution_reason":null,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"} ],"pagination":{"page":1,"per_page":50,"total":2,"total_pages":1}}"#, ) .create(); @@ -644,9 +644,9 @@ fn csv_document_snapshot() { assert_eq!( out(&output), "id,vendor_id,type,severity,title,description,source_url,economic_impact,status,\ - acknowledged_at,resolved_at,resolution_reason,created_at\r\n\ - a1,v,cve,'+high,'=1+1,'-x,'@SUM(A1),-5,open,,,,2026-01-01T00:00:00Z\r\n\ - a2,v,cve,\"say \"\"hi\"\"\",\"plain, with comma\",\"two\nlines\",\" padded \",2.5,a=b,,,,2026-01-01T00:00:00Z\r\n" + acknowledged_at,resolved_at,resolution_reason,created_at,updated_at\r\n\ + a1,v,cve,'+high,'=1+1,'-x,'@SUM(A1),-5,open,,,,2026-01-01T00:00:00Z,2026-01-02T00:00:00Z\r\n\ + a2,v,cve,\"say \"\"hi\"\"\",\"plain, with comma\",\"two\nlines\",\" padded \",2.5,a=b,,,,2026-01-01T00:00:00Z,2026-01-02T00:00:00Z\r\n" ); } diff --git a/deny.toml b/deny.toml index c7b30e4..07f38d0 100644 --- a/deny.toml +++ b/deny.toml @@ -40,6 +40,9 @@ allow = [ # License Agreement; it is a non-code data license, OSI/FSF-listed as free, # and widely allowed alongside MIT/Apache-2.0 in the Rust TLS ecosystem. "CDLA-Permissive-2.0", + # foldhash (a dependency of serde_json from 1.0.154) is Zlib-licensed; Zlib is a + # permissive OSI-approved license compatible with Apache-2.0. + "Zlib", ] confidence-threshold = 0.8 exceptions = [] diff --git a/docs/decisions/0001-sign-in-without-a-keychain.md b/docs/decisions/0001-sign-in-without-a-keychain.md index 6de0252..048383c 100644 --- a/docs/decisions/0001-sign-in-without-a-keychain.md +++ b/docs/decisions/0001-sign-in-without-a-keychain.md @@ -36,7 +36,7 @@ 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 | +| Other users | No | No, if owner and mode are right (on macOS also no ACL: the CLI strips ACLs from what it creates and refuses one that has them) | 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` | diff --git a/docs/release-signing.md b/docs/release-signing.md index 8b2e917..1d18d03 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.5.1) carry: +The signatures of every signed release so far (0.2.0 to 0.6.0) carry: | Field | Value | |---|---| @@ -100,6 +100,12 @@ checks the binary's notarization online on first run. See - **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. +- **Expected Team ID.** The Team ID is public and is kept in the repository, not in a secret: the + `MACOS_TEAM_ID` constant at the top of `release.yml` and `macos-signing-check.yml` (change both + in one reviewed pull request). The signing steps compare it exactly with the `TeamIdentifier` + of the binary's signature and with the Team ID of the installer certificate, and fail if either + differs or if the constant is empty or not ten characters of `A-Z0-9`. Valid Apple credentials of + another team therefore cannot pass, whatever the environment holds. - **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 @@ -124,15 +130,19 @@ checks the binary's notarization online on first run. See - **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 + listing the names, if a secret is missing, and if the certificates are not those of + `MACOS_TEAM_ID`. 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. sets the repository variable `MACOS_SIGNING_ENABLED` to `true`. +1. sets `MACOS_TEAM_ID` in `release.yml` and `macos-signing-check.yml` to the Team ID of the + Hodeitek S.L. Apple Developer account, through a reviewed pull request merged to `main` + (while it is empty, the signing job fails at its first step); +2. stores the nine secrets in the `release-signing` environment; +3. 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 diff --git a/docs/verifying-releases.md b/docs/verifying-releases.md index 38344ed..5317c74 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.5.1` and `hodeishield-0.5.1-x86_64-unknown-linux-musl.tar.gz` +The steps below use `v0.6.0` and `hodeishield-0.6.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.5.1-x86_64-unknown-linux-musl.tar.gz`) +- the archive you want (e.g. `hodeishield-0.6.0-x86_64-unknown-linux-musl.tar.gz`) - `SHA256SUMS` - the matching `.sigstore.json` - `SHA256SUMS.sigstore.json` @@ -46,7 +46,7 @@ From the release page, download: ## 2. Check the checksum ```sh -grep hodeishield-0.5.1-x86_64-unknown-linux-musl.tar.gz SHA256SUMS | sha256sum -c - +grep hodeishield-0.6.0-x86_64-unknown-linux-musl.tar.gz SHA256SUMS | sha256sum -c - ``` Checking only the line of your archive works with GNU `sha256sum` and with the BusyBox one on @@ -65,7 +65,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.5.1 \ + --certificate-identity https://github.com/Hodeitek/hodeishield-cli/.github/workflows/release.yml@refs/tags/v0.6.0 \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-github-workflow-trigger push \ SHA256SUMS @@ -75,11 +75,11 @@ Repeat for the archive itself, using its own `.sigstore.json`: ```sh cosign verify-blob \ - --bundle hodeishield-0.5.1-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.1 \ + --bundle hodeishield-0.6.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.6.0 \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-github-workflow-trigger push \ - hodeishield-0.5.1-x86_64-unknown-linux-musl.tar.gz + hodeishield-0.6.0-x86_64-unknown-linux-musl.tar.gz ``` A successful verification confirms the file was signed by the @@ -92,11 +92,11 @@ The SBOM is verified the same way, with its own bundle: ```sh cosign verify-blob \ - --bundle hodeishield-0.5.1-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.1 \ + --bundle hodeishield-0.6.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.6.0 \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-github-workflow-trigger push \ - hodeishield-0.5.1-x86_64-unknown-linux-musl.cdx.json + hodeishield-0.6.0-x86_64-unknown-linux-musl.cdx.json ``` ## 4. Verify SLSA build provenance @@ -105,10 +105,10 @@ Requires [`slsa-verifier`](https://github.com/slsa-framework/slsa-verifier). ```sh slsa-verifier verify-artifact \ - hodeishield-0.5.1-x86_64-unknown-linux-musl.tar.gz \ + hodeishield-0.6.0-x86_64-unknown-linux-musl.tar.gz \ --provenance-path hodeishield.intoto.jsonl \ --source-uri github.com/Hodeitek/hodeishield-cli \ - --source-tag v0.5.1 + --source-tag v0.6.0 ``` `hodeishield.intoto.jsonl` is attached to the release and covers every diff --git a/openapi/README.md b/openapi/README.md index 6d094cb..81eec22 100644 --- a/openapi/README.md +++ b/openapi/README.md @@ -6,8 +6,8 @@ from it; nothing in that client is written by hand from memory. | Field | Value | |---|---| -| `info.version` | `2026-10-19` | -| SHA-256 | `8c9a44bc696584105f10b67d490a8816dabc26b07b80ed18369e6d3e498359c4` | +| `info.version` | `2026-10-26` | +| SHA-256 | `5c556941cddd90d4ed6e9c017e80639849b81f7e91f5ea1465fb45d8d6c99a3c` | | Operations | 16, all `GET` | ## Updating it diff --git a/openapi/v1.json b/openapi/v1.json index 3dda78e..8802f57 100644 --- a/openapi/v1.json +++ b/openapi/v1.json @@ -1 +1,6183 @@ -{"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 +{ + "openapi": "3.1.0", + "info": { + "title": "Hodei-Shield API", + "version": "2026-10-26", + "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-26 — change: `user.email` on `GET /v1/me` needs the `email` scope.**\n The OAuth scope `email` is new. `user.email` is returned only to an access\n token that carries it, and is omitted otherwise: `user` still names the\n signed-in user by `id` and `name`. A client that shows the email asks for\n `email` at authorization; the consent screen lists it. The call is never refused\n for a missing `email` scope. A client that decoded `user.email` as required\n must treat it as optional.\n\n- **2026-10-24 — correction: `beta` on `GET /v1/me` is the tenant's own.** For\n a client tenant of an MSSP whose own organisation is not in a running beta, `beta`\n is `active`, `running: true` and `ends_at: null` while the MSSP's beta runs\n (it is what lifts the client's access), and `null` otherwise: the MSSP's end date\n and whether its beta ended or converted are not the client's to read. The tenant's\n own organisation's beta is described as before. No field was added or removed.\n\n- **2026-10-21 — addition: `user` on `GET /v1/me`.** The `whoAmI` answer gains\n a nullable `user` object (`id`, `name`, `email`): the signed-in user of an\n OAuth access token (`hs_at_…`), so a client can show who is logged in. It is the\n caller's own data only, never another user's. It is `null` for a tenant API key\n and for a portfolio key. Nothing existing was removed, renamed or re-statused.\n\n- **2026-10-20 — addition: `beta` on `GET /v1/me`.** The `whoAmI` answer gains\n a nullable `beta` object (`status`, `running`, `ends_at`): the beta of the\n tenant's own organisation, so a client can show when it ends. It is `null` for an\n organisation that never joined the beta and for a portfolio key; a beta inherited\n from a parent MSSP is not described. Nothing existing was removed, renamed or\n re-statused.\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 new alerts with `since` plus `order=asc`, changed ones with `updated_since` plus `sort=updated_at&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. Cannot be combined with `cursor`.", + "schema": { + "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": "cursor", + "in": "query", + "required": false, + "description": "Opaque keyset cursor: pass the `next_cursor` of the previous page, with the same `sort`, `order` and filters. Cannot be combined with `page`.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + { + "name": "sort", + "in": "query", + "required": false, + "description": "Field to order by. One of: created_at, updated_at.", + "schema": { + "default": "created_at", + "type": "string", + "enum": [ + "created_at", + "updated_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": "updated_since", + "in": "query", + "required": false, + "description": "Only alerts last changed at or after this RFC 3339 instant (`updated_at`), including ones created long before it. Poll changes with `sort=updated_at&order=asc`.", + "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." + }, + "updated_at": { + "type": "string", + "description": "RFC 3339, UTC. Last change of severity, acknowledgement, resolution or CISA KEV status; equals the instant the service started tracking it for alerts older than that." + } + }, + "required": [ + "id", + "vendor_id", + "type", + "severity", + "title", + "description", + "source_url", + "economic_impact", + "status", + "acknowledged_at", + "resolved_at", + "resolution_reason", + "created_at", + "updated_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`." + }, + "next_cursor": { + "description": "Pass as `cursor` for the next page; null on the last page.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "per_page", + "next_cursor" + ], + "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." + }, + "updated_at": { + "type": "string", + "description": "RFC 3339, UTC. Last change of severity, acknowledgement, resolution or CISA KEV status; equals the instant the service started tracking it for alerts older than that." + } + }, + "required": [ + "id", + "vendor_id", + "type", + "severity", + "title", + "description", + "source_url", + "economic_impact", + "status", + "acknowledged_at", + "resolved_at", + "resolution_reason", + "created_at", + "updated_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), for an OAuth token also the signed-in user (`user`: its own id and name, and its email only when the token carries the `email` scope; never another user’s; null for any key), 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)." + }, + "beta": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "The beta status of the tenant’s own organisation, or `active` while the beta of its parent MSSP runs and lifts its access. Open vocabulary: `active`, `ended` or `converted`.", + "x-hs-known-values": [ + "active", + "ended", + "converted" + ] + }, + "running": { + "type": "boolean", + "description": "True while the beta runs: status `active` and `ends_at` in the future. While it runs the organisation has Enterprise access." + }, + "ends_at": { + "description": "When the own organisation’s beta ends or ended, ISO 8601 UTC. Null when no end is recorded, and always null for a beta inherited from the parent MSSP.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status", + "running", + "ends_at" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "description": "The beta of the tenant’s own organisation; while it does not run and the parent MSSP’s beta does, `active` and running with no date. Nothing else about the parent’s beta is described: not its end, nor whether it ended or converted. Null when the own organisation never joined the beta and no parent beta runs, and for a portfolio key." + }, + "user": { + "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 user the access token belongs to (lowercase)." + }, + "name": { + "description": "The user’s display name. Untrusted text.", + "type": [ + "string", + "null" + ] + }, + "email": { + "description": "The user’s email address. Present only when the access token carries the `email` scope; omitted otherwise.", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ], + "description": "The signed-in user of an OAuth access token (`hs_at_…`): the CALLER’S OWN id, name and, with the `email` scope, email; never another user’s. Null for a tenant API key and for a portfolio key, which act for no user, and for a token whose user row is no longer visible in the token’s tenant." + }, + "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", + "beta", + "user", + "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.", + "email": "Read the email address of the signed-in user (`user.email` on `GET /v1/me`).", + "offline_access": "Issue a refresh token (30 days, rotated on every use)." + } + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "oauth2": [] + }, + { + "portfolioKey": [] + } + ] +} diff --git a/packaging/gpg.key b/packaging/gpg.key new file mode 100644 index 0000000..dc66114 --- /dev/null +++ b/packaging/gpg.key @@ -0,0 +1,64 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGrKmf8BEADESHwqoUyUMVhrhZgOUBnDqAztBS6yz4eNaB/i/ABUfzVPH4Yl +CySnyH4qTeqpyaYP+KbegBC9sAhVp96xy066scFeCzUu8J3vVgpEmWz4hovjgBMI +R1ao/NQ/TIEg2ep6ioYIjbopUluP70Jog4R7C9LPsKBkj9QtDXX1PhNG2ZQF8lXJ +SqErfaaE45g1bEh6Qndy9S4mraFYf5yYk/pu55Yr1lorUXZgq6RA7EWSJrncFU7M +le05+4xAQCMnwx2cLQ49miFbdpEYbn3F9j2nLTxd4TwBi5B5bBqgXQYQEIddgu3I +dgzL6RGbwI+GGpYaJHMoieIEg74RWrwspmn13K9CUfDyKZIPR49cZYC2nmD/uX7C +Wlv1h3rgOBFHbrX/NjMKJ1FXm8et4vaj01RKILndPv05dn/53IFm1xPl+VJI3e1m +L+P5hSaLLKfDoqo/5fpk7MxKxsSi/1hfTb7nZ+B91v9T0+3wab+BQ5eIlrSOek9f +AokYEb3+WyryJACPnrTuXN+asAtWS37JVyZSSQ6asfUP1nniGfGAj0aq3UY/Y9+I +iYGdw6kMe2HkAvfo7OUyBJAsHpdG7MWQm+kVq6GVwU8IUVUezCk3pkXa8ldP4r+u +Fu9LyRDnevp27edcDLGu0jEVCJFAPLL4xjmNeThD/31dhFVB0DCeniQidwARAQAB +tCxIb2RlaVNoaWVsZCBQYWNrYWdlcyA8c2VjdXJpdHlAaG9kZWl0ZWsuY29tPokC +VAQTAQoAPhYhBKoq8hKFqauZCDOQiTXV+UFxwc0LBQJqypn/AhsBBQkDwmcABQsJ +CAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEDXV+UFxwc0LZ9wP/RsyJO219FJnDlfh +nz3nIlGyCaXm5p3NVEfuxG1BvkvVbfqUZA1u7YMR+Wsp97NPiLKnfzya2gvqDOFH +7xcYsaKuVZKvXciFC3F4aQX9qp1qSWClw5vJQO/Uud0r2weCM4uYuqZ7mrI8pamM +CJxNc0TVxov+8pGD3h9eOZ2/PhhoLnbeolEyZKoc9tVu/3lnBq+42PZN7xQPbfvj +lBWFOVAmduyo0K+axqpl6W+ekKrs75YASwWxl/5nACB2ok2+VSazfBASo8Vg2Y4/ +K13NfqCsWENGbf6PFWiNWNVnR5tHYo1tmddskWfSjBTBs0viXeALQjNfwA2snNEU +/77+DnL9TPGGlb9JdLevs1MhPe5VGnEElYgQPyx8/5cbYPfjR61rrCq7FY92bYQh +uqayEmOR3hqVMSBxMAQgWaTCYuUBlswJ5GIADlCsAOGFjJXZpbUmNtHKCETTRh05 +uznUGyEEv65hJsPWPUoet453Mtae9KHUqXNh6RYdaI26OX5P/0jnXc32Uq1WDSAu +RPxqi109Yq8FyBhEsummB0pV+Jl4mmsRmmyoW5YWpoFYKFXNiPJdX6JdCX86H/VM +tx68ASKuqeqrpZ7lcqpMbrx4BS4Rj6eNKyzkHlZSfUmuT0gBPBKBBw2G1wA6Fi8m +qd/s4jTBye9hKz9GSqrZWvcx9gWluQINBGrKmgIBEACxtm4lcjBR3V+0nJHdT3Hg +XS33kf5UlEqp3vPwLQnCVDu2ZOE+tQ2HDk8z58q9cJEAGPB6Mn71kNOAdceBlQkG +KcDcgW4dijfywhkgVJLFX0MJ14HwodfKv1GcGyg0WYwvYjsgG1Lv/eI81Shr4npX +MdK2+CTxraMcQTUDnXHFXLpJdBzkD5IMpKkmoO81nqDB2XcgH5l+j4OlPV73abKd +imhY7HP68H9GH4mQZrrJI7YoaNStrVJAtAOHlslKIkgW767lD3NGe830WY0qF6rk +K840is19bvLx+6+qQJ4jAZQHZZgym4KWP1GYxW+YuoRQAHze2jmedMXuydcSQfPO +6wvyC5xz0+sMC1mdARIEt+4yiUPGs19hNATuHDM2p6BUtHxN/WtXZMhH7yTspr0x +9IFCe3xho55z7Fgd7mujNKa1fLEjWnKgtd3BLYjbnR1wfaowCZ9Dm25Hw3pObgvl ++x4qpCyCRatzh3mx02+FQ8kazr9psiM9ysWEC+g8mGEn8MohWmX2kEN8/SYWbWKv +8FFuJK6ceh2VXVm66jqqVUADx8fdnxrp60/6INBSXZwN71Q01VXIgXEyMGcctmdr +7Snh/cJ/DhLKER4E+jz0SZKXQCr/J9/LlZg1E7BwB7PMMt5rDc6M0ozccZoeAlE8 ++cH4ThfIkORwlEblpRNakwARAQABiQRyBBgBCgAmFiEEqiryEoWpq5kIM5CJNdX5 +QXHBzQsFAmrKmgICGwIFCQPCZwACQAkQNdX5QXHBzQvBdCAEGQEKAB0WIQTcfiQD +KHzbCL2ErX3X5y4bY1ZmsAUCasqaAgAKCRDX5y4bY1ZmsEV3D/44Gyo6hBdILJ9v +jgDfLUMbH0hV9ajto5vFcZJQRbWRpP6T52OnmQCq1FDfb0pmt9PxpeU9u7D+ZHPD +rIloce4BhntIC0i5TggKL0fv2FVXUCyKq25/adRmAc7AeQW+lOpgSgTXJ6A65Y/i +1q9Ebwogxidk+LZAU/2xaaNbWpnqYmb3SDJ9atZqHa/K4iSueCody/SavNmH1qmt +6pQfWbcwiYLTHCQpLvWG+R+BbIMjf4hMJh4ytqtMelxq9ulrnXWMtcO4hXfx41Fe +Cs1nAhYCB2gq7dvRdt9Hy7znPpe0m94GoOenY65xtw0DWeLCQHKUCXguq/eIdO0b +ITYDhB2M/x4m6DXSRVKqVJPMl8Kjc3sDMe3oh/T5rl7h4a4zbYntIX215IrV8j3Z +qR/8fZT2e+kS82nbBFBLiYbyK9Rf/MOF3CTXnXoSMhGFRYLBY6aovW2YzvYojzyJ +GnBRzoqEXiiva7WGIqgK/fRGM4NTdt5OA6Lqbz1SwVHDvBrTdNBb3+y8RfeZ+R8x +uR2kzpBZescMaskeV5hw8xLqjPVOuKRMdCk09Q7V9U8552IXZQS77zDDbdbcdfVO +DXEuzDM4Q21RsaTH6KIIvAxu7gOfCreUuNXlWV4UnJ7zPIiY8q5BhK9FwEaMo9ld +64YfzZxZ2hbFNx9cExgRnRaH63JG2FeLD/9nc9awt7XrcFAm2EXh38FUWPhB8Sxt +rnuXiy055kzhIBzVDLDIYWWjy4BCchT0BPU9QxGqDN1N1GL8BoMFfrhIkouw3Qhg +bHBMSaGX1TfkBRMTL6peh0XONi3GQPxMhtdqZqoutcYI0UUhsCJYhdYiYVchB6eS +De0UkpWrHHgjKJzTK/bMMdUF8vZKYr1DH43NdER/2VYFxgLtVPETtSLbX/AvOSQ2 +z17aV73PnSBGYM8TV3nS2l6K8Kd4Sv52LciaCyiOE+MGo6H6GVRjSQxUjO2IYBIg +Ewt0e9Ot/Vz3uMswoiujdhKDJjOpfZ4xaVSqpXpFLciPEbd908T9ADfukXSNocy8 +Das+xwX1sOYff7dzwq1cz6GLTJUFCD74M29SZlyyOSqZxCUA39MjN0HLetVKyICV +LTcmY+OWkq/NMYF0YLDpfvR0SwWotzN9+woW7yWcorIJo+DI4gH1PpVltOqnSphE +8yine7lh4dFHsxwYUs8eZPyB5plsbdSh+ZOfyc5GMgxz5b5jVb/l2uPFykCpJ8+t +a+MH0VS4n1mKRqxQX6VmZgsrP6yzPXHN67Tf/xkn/+GBtSRi5743s5atJppjPTYs +XWDToxN/mPsbKAEk7Wm/geDjSqPkanXOn1lkEn6j7a92lRKYsvM7PpBkOJSmkmio +cJ3nnumCHfrmVg== +=JlAi +-----END PGP PUBLIC KEY BLOCK----- diff --git a/packaging/repo/build.sh b/packaging/repo/build.sh index 482c857..fc6b659 100644 --- a/packaging/repo/build.sh +++ b/packaging/repo/build.sh @@ -41,12 +41,17 @@ check=$(mktemp -d) trap 'rm -rf "$rpmdb" "$check"' EXIT rpm --dbpath "$rpmdb" --import "$PUBLIC_KEY" gpg --batch --quiet --dearmor --output "$check/key.gpg" "$PUBLIC_KEY" +primary=$(gpg --batch --with-colons --show-keys "$PUBLIC_KEY" | awk -F: '$1 == "fpr" { print $10; exit }') +# shellcheck source=/dev/null +. "$here/sigcheck.sh" die() { printf 'build.sh: %s\n' "$*" >&2 exit 1 } +[ -n "$primary" ] || die "cannot read the fingerprint of the public key $PUBLIC_KEY" + # Copies a new package into place, refusing to change a package that is already published. place() { src=$1 dest=$2 @@ -116,7 +121,7 @@ if [ -e "$published/InRelease" ]; then }; then die "the published InRelease is not a single signed message" fi - gpgv --quiet --keyring "$check/key.gpg" --output "$check/Release" "$published/InRelease" 2> /dev/null || + sig_ok "$check/key.gpg" "$primary" --output "$check/Release" "$published/InRelease" || die "the published InRelease does not verify" for arch in amd64 arm64; do packages=$published/main/binary-$arch/Packages @@ -244,8 +249,8 @@ fingerprint=$(gpg --batch --with-colons --show-keys "$PUBLIC_KEY" | awk -F: '$1 cp "$here/index.html" "$repo/index.html" # --- Self-check before anything is uploaded --------------------------------------------------------- -gpgv --quiet --keyring "$check/key.gpg" "$dists/InRelease" 2> /dev/null || die "InRelease does not verify" -gpgv --quiet --keyring "$check/key.gpg" "$dists/Release.gpg" "$dists/Release" 2> /dev/null || +sig_ok "$check/key.gpg" "$primary" "$dists/InRelease" || die "InRelease does not verify" +sig_ok "$check/key.gpg" "$primary" "$dists/Release.gpg" "$dists/Release" || die "Release.gpg does not verify" # Every index that Release lists must be there, with its by-hash copies (apt-ftparchive makes none for # the per-architecture Release files, whose content never changes). apt asks for the strongest hash. @@ -270,8 +275,8 @@ for arch in amd64 arm64; do fi done for arch in x86_64 aarch64; do - gpgv --quiet --keyring "$check/key.gpg" "$repo/rpm/$arch/repodata/repomd.xml.asc" \ - "$repo/rpm/$arch/repodata/repomd.xml" 2> /dev/null || die "repomd.xml ($arch) does not verify" + sig_ok "$check/key.gpg" "$primary" "$repo/rpm/$arch/repodata/repomd.xml.asc" \ + "$repo/rpm/$arch/repodata/repomd.xml" || die "repomd.xml ($arch) does not verify" for rpm in "$repo/rpm/$arch"/*.rpm; do [ -e "$rpm" ] || continue rpm_signed "$rpm" || die "$(basename "$rpm") has no valid signature" diff --git a/packaging/repo/freshness-test.sh b/packaging/repo/freshness-test.sh index b07533d..33f47a3 100644 --- a/packaging/repo/freshness-test.sh +++ b/packaging/repo/freshness-test.sh @@ -2,13 +2,19 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Hodeitek S.L. # -# Proves that freshness.sh goes red: builds small APT and DNF repositories with a throwaway key made +# Proves that freshness.sh goes red, and that live.sh (which decides whether there is anything to +# check) is inert only when the domain serves nothing: builds small APT and DNF repositories with a throwaway key made # here (nothing secret, nothing real), serves them from localhost and runs freshness.sh against them. # A repository that is up to date must pass, and every case in which it is stale, altered, unsigned, # missing or unreachable must fail, with the status expected for it (3 for stale, 1 for the rest). +# For live.sh: no index at all (every entry point a clean 404) is inert (10); a fetch error that is not +# a 404 (500, a redirect, a refused connection) and a served index without the key are red (1). # # freshness-test.sh # +# The signature cases use throwaway keys made with gpg's faked system time: one whose signing subkey +# has expired, one whose signing subkey is revoked, and two that expire within 60 days (a warning, 5). +# # Needs curl, gpg, gpgv, gzip, python3 and the tools freshness.sh needs. The indexes are written by # hand in the structure that apt-ftparchive and createrepo_c produce (the repository builder's own # output is checked by build.sh when the repositories are published). @@ -23,9 +29,11 @@ mkdir -m 700 "$GNUPGHOME" www=$work/www mkdir "$www" server= +servers= cleanup() { [ -z "$server" ] || kill "$server" 2> /dev/null || true + for pid in $servers; do kill "$pid" 2> /dev/null || true; done gpgconf --homedir "$GNUPGHOME" --kill all 2> /dev/null || true # The throwaway secret keys are overwritten before they are removed. find "$GNUPGHOME" -type f -exec shred -u {} + 2> /dev/null || true @@ -49,6 +57,45 @@ other_fpr=$(gpg --batch --with-colons --list-keys other@example.invalid | awk -F gpg --batch --quiet --armor --export "$other_fpr" > "$work/other.asc" signer=$repo_sub +# gpg with a faked clock when $faked is set (a time as YYYYMMDDTHHMMSS), for keys that have to be +# expired now but valid when they signed. +faked= +gpgt() { + if [ -n "$faked" ]; then gpg --faked-system-time "$faked" "$@"; else gpg "$@"; fi +} +# newkey