banshee is under active development: collectors will keep changing, and a version you publish will likely need adjustments. For stable use, wait for version 1.0.0.

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.

  • cloud
  • azure
  • key-vault
  • secrets
  • certificates
  • expiry
  • https
  • api

by banshee1 installation1 version

v1Latest

Published on October 27, 2026. First version.

Keen 1.0.0 · 127 lines
// 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']));
    }
}