Cloud
banshee/cloud-collection/cloud/azure/https/
key-vault-expiry
The days left before expiry of each enabled secret and certificate of an Azure Key Vault, read by their attributes, never their value.
by banshee1 installation1 version
Published on October 27, 2026. First version.
// The secrets and certificates of an Azure Key Vault that expire, by the vault's own API, logged in as a service
// principal: for each one enabled that has an expiry date, the days left before it. The lists give the
// attributes only; no value of a secret is ever read, and the role given cannot read one.
// Reads POST https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token, then GET
// https://<vault>.vault.azure.net/secrets and /certificates, page after page.
need('protocol:http');
$client = need('protocol:http:user');
$secret = need('protocol:http:password');
$tenant = variable('azure:tenant', 'text', array(
'label' => 'Tenant',
'help' => 'The directory (tenant) ID of the service principal, or one of the tenant\'s domains.',
));
$vault = variable('azure:vault', 'text', array(
'label' => 'Key Vault',
'help' => 'The name of the vault, the first part of its address https://<name>.vault.azure.net.',
));
per('item', 'Secrets and certificates');
output('days-left', 'number', array('label' => 'Days before expiry', 'unit' => 'd', 'per' => 'item'));
watch.below('expiring', 'Expires soon', 'days-left', array('warning' => 30, 'critical' => 7));
watch.missing('missing', 'Secret or certificate missing', 'days-left', array('enabled' => false));
// Log in as the service principal, by the client credentials grant, for the vaults' own API. The secret goes in
// the form, which makes the answer secret, and its access token with it. The token is kept in the cache,
// encrypted on the poller, thirty minutes, under the hour or so Microsoft gives it.
function login($tenant, $client, $secret, $scope) {
$body = url.query(array('grant_type' => 'client_credentials', 'client_id' => $client,
'client_secret' => $secret, 'scope' => $scope));
$answer = http.post('https://login.microsoftonline.com/' . url.encode($tenant) . '/oauth2/v2.0/token', $body,
headers=array('Content-Type' => 'application/x-www-form-urlencoded'));
if ($answer['status'] == 400 || $answer['status'] == 401) {
fail('misconfigured', 'Microsoft Entra ID refuses the service principal, code ' . $answer['status']);
}
if ($answer['status'] != 200) {
fail('unreachable', 'Microsoft Entra ID answers the login with code ' . $answer['status']);
}
$session = json.read($answer['body']);
cache.set('token', $session['access_token'], 1800);
return $session['access_token'];
}
// One GET on the vault with the token kept in the cache; a token the vault no longer takes answers 401, and
// the collector logs in again, once.
function vault_get($url, $what, $tenant, $client, $secret) {
if (cache.has('token')) {
$token = cache.get('token');
} else {
$token = login($tenant, $client, $secret, 'https://vault.azure.net/.default');
}
$answer = http.get($url, headers=array('Authorization' => 'Bearer ' . $token));
if ($answer['status'] == 401) {
$token = login($tenant, $client, $secret, 'https://vault.azure.net/.default');
$answer = http.get($url, headers=array('Authorization' => 'Bearer ' . $token));
}
if ($answer['status'] == 403) {
fail('misconfigured', 'The vault refuses to list ' . $what . ', code 403',
'The role is missing, or the firewall of the vault does not let the poller in.');
}
if ($answer['status'] != 200) {
fail('unreachable', 'The vault answers the list of ' . $what . ' with code ' . $answer['status']);
}
return json.read($answer['body']);
}
// The items of a list, page after page: the vault gives the address of the next page under nextLink, null
// on the last. A next page is only followed on the vault itself, where the token may go; the vault writes
// it with or without its port, 443.
function vault_list($base, $path, $what, $tenant, $client, $secret) {
$items = array();
$url = $base . $path;
while ($url != '') {
$lower = text.lower($url);
if (!text.like($lower, $base . '/*') && !text.like($lower, $base . ':443/*')) {
fail('unreachable', 'The vault gives the next page of ' . $what . ' outside ' . $base);
}
$page = vault_get($url, $what, $tenant, $client, $secret);
foreach (list.get($page, 'value', array()) as $item) {
$items = list.add($items, $item);
}
$url = list.get($page, 'nextLink', '');
}
return $items;
}
// The name of an item, the last part of its id: https://<vault>.vault.azure.net/secrets/<name>.
function item_name($id) {
$parts = text.split($id, '/');
return $parts[list.count($parts) - 1];
}
// The name of the vault goes into the address of a host: it must be one.
if (list.count(text.match($vault, '^[A-Za-z0-9-]+$')) == 0) {
fail('misconfigured', 'The name of the vault holds other characters than letters, digits and hyphens');
}
$base = 'https://' . text.lower($vault) . '.vault.azure.net';
$now = time.now();
// {"value": [{"id": "https://<vault>.vault.azure.net/secrets/db-password", "contentType": "…", "managed": true,
// "attributes": {"enabled": true, "exp": 1798761600, "created": …, "updated": …}}], "nextLink": "…"}: the
// dates in seconds since 1970, UTC. A secret managed by the vault is the key of one of its certificates,
// which the list of certificates gives.
$secrets = vault_list($base, '/secrets?api-version=2025-07-01&maxresults=25', 'the secrets', $tenant, $client, $secret);
foreach ($secrets as $item) {
$attributes = list.get($item, 'attributes', array());
if (list.get($item, 'managed', false) || !list.get($attributes, 'enabled', true)) {
continue;
}
$expiry = list.get($attributes, 'exp', '');
if ($expiry != '') {
value('days-left', number.round(($expiry - $now) / 86400, 1), 'secret/' . item_name($item['id']));
}
}
// {"value": [{"id": "https://<vault>.vault.azure.net/certificates/www", "x5t": "…", "attributes": {"enabled":
// true, "nbf": …, "exp": 1798761600, …}}], "nextLink": null}.
$certificates = vault_list($base, '/certificates?api-version=2025-07-01&maxresults=25', 'the certificates', $tenant, $client, $secret);
foreach ($certificates as $item) {
$attributes = list.get($item, 'attributes', array());
if (!list.get($attributes, 'enabled', true)) {
continue;
}
$expiry = list.get($attributes, 'exp', '');
if ($expiry != '') {
value('days-left', number.round(($expiry - $now) / 86400, 1), 'certificate/' . item_name($item['id']));
}
}