= $level; } /** One credential by id with its client, contact and asset - raw, or null. */ function credentialRow($credential_id) { global $mysqli; $credential_id = intval($credential_id); if (!$credential_id) { return null; } $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT credentials.*, client_name, contact_id, contact_name, contact_title, contact_photo, contact_archived_at, asset_id, asset_name, asset_type, asset_archived_at FROM credentials LEFT JOIN clients ON client_id = credential_client_id LEFT JOIN contacts ON contact_id = credential_contact_id AND contact_client_id = credential_client_id LEFT JOIN assets ON asset_id = credential_asset_id AND asset_client_id = credential_client_id WHERE credential_id = $credential_id LIMIT 1")); return $row ?: null; } /** * What kind of login it is, from where it opens and what it is called - an * icon and a tone for its tile: an admin account, Wi-Fi, a remote desktop, * a shell, a database, a mailbox, a web console, or a plain key. */ function credentialKind($row) { $uri = strtolower(trim((string) ($row['credential_uri'] ?? ''))); $text = strtolower(($row['credential_name'] ?? '') . ' ' . ($row['credential_description'] ?? '')); $kinds = [ ['/wi-?fi|wireless|ssid|psk/', '', 'fa-wifi', 'wireless'], ['/vpn/', '', 'fa-shield-alt', 'firewall'], ['/\b(admin|administrator|root|break ?glass|domain admin)\b/', '', 'fa-user-shield', 'firewall'], ['/database|\bsql\b|mysql|postgres/', '', 'fa-database', 'virtual'], ['/', '#^(rdp|vnc|rustdesk|anydesk|splashtop|connectwise)://#', 'fa-desktop', 'workstation'], ['/', '#^(ssh|telnet|sftp)://#', 'fa-terminal', 'server'], ['/', '#^(smb|file|dav|webdav|ftp|ftps)://#', 'fa-folder-open', 'peripheral'], ['/\b(mail|email|mailbox|365|exchange|workspace)\b/', '', 'fa-envelope', 'phone'], ['/', '#^https?://#', 'fa-globe', 'network'], ]; foreach ($kinds as [$name_match, $uri_match, $icon, $tone]) { if ($uri_match !== '' && preg_match($uri_match, $uri)) { return ['icon' => $icon, 'tone' => $tone]; } if ($uri_match === '' && $name_match !== '/' && preg_match($name_match, $text)) { return ['icon' => $icon, 'tone' => $tone]; } } return ['icon' => 'fa-key', 'tone' => 'other']; } /** The credential's tile - its kind's icon on its tone. $size '' | 'sm' | 'lg'. */ function credentialTile($row, $size = '') { $kind = credentialKind($row); $size_class = in_array($size, ['sm', 'lg'], true) ? " itflow-asset-icon-$size" : ''; return ''; } /** * How old the password is: 'fresh' (changed in the last 90 days), 'due' * (90 days to a year) or 'overdue' (over a year), with the days and a line * to show. Null when it has never been recorded. */ function credentialAge($changed_at) { $changed_at = trim((string) $changed_at); if ($changed_at === '' || strpos($changed_at, '0000-00-00') === 0) { return null; } $changed = strtotime($changed_at); if (!$changed) { return null; } $days = max(0, intval(floor((time() - $changed) / 86400))); if ($days >= CREDENTIAL_OVERDUE_DAYS) { $state = 'overdue'; $tone = 'danger'; } elseif ($days >= CREDENTIAL_ROTATE_DAYS) { $state = 'due'; $tone = 'warning'; } else { $state = 'fresh'; $tone = 'success'; } $text = $days === 0 ? 'Changed today' : ($days === 1 ? 'Changed yesterday' : ($days < 60 ? "Changed $days days ago" : 'Changed ' . timeAgo(date('Y-m-d', $changed)))); return ['state' => $state, 'tone' => $tone, 'days' => $days, 'text' => $text, 'date' => date('Y-m-d', $changed)]; } /** The age as a soft pill - green, amber when due for rotation, red when overdue. '' when unknown. */ function credentialAgePill($age, $short = false) { if (!$age) { return ''; } $tone = $age['tone']; $label = $short ? ($age['days'] < 60 ? $age['days'] . 'd' : ($age['days'] < 730 ? round($age['days'] / 30) . 'mo' : round($age['days'] / 365) . 'y')) : $age['text']; $title = $age['text'] . ($age['state'] === 'fresh' ? '' : ' - due for rotation'); return '' . '' . escapeHtml($label) . ''; } /** * A 2FA seed as it is stored - the base32 secret, uppercase, without spaces * or dashes - from what was pasted: the secret as an app shows it ("abcd efgh * ...") or a whole otpauth:// link from the QR code. '' for nothing, false * when it is not a seed. */ function credentialOtpSeed($input) { $input = trim((string) $input); if ($input === '') { return ''; } if (stripos($input, 'otpauth://') === 0) { $query = []; parse_str((string) parse_url($input, PHP_URL_QUERY), $query); $input = (string) ($query['secret'] ?? ''); } $seed = strtoupper(preg_replace('/[\s\-]+/', '', $input)); $seed = rtrim($seed, '='); if ($seed === '' || !preg_match('/^[A-Z2-7]+$/', $seed) || strlen($seed) < 16) { return false; } return $seed; } /** * The credential fields a New / Edit form posts, as a SQL SET list - the * username and password encrypted, the 2FA seed tidied, only the client's * own contact and asset. Null with $error set when it cannot be saved: no * name, a field too long, a 2FA seed that is not one, or a vault that did * not open with this session (encrypting would otherwise write nothing over * the password). */ function credentialFormSet($post, $client_id, &$error = '') { $error = ''; if (trim((string) ($post['name'] ?? '')) === '') { $error = 'A credential needs a name.'; return null; } if ($too_long = checkCredentialLengths($post)) { $error = "The $too_long is too long to store."; return null; } $otp = credentialOtpSeed($post['otp_secret'] ?? ''); if ($otp === false) { $error = "That 2FA secret is not one - paste the key the site shows under \"can't scan the code\", or the otpauth:// link."; return null; } $username = encryptCredentialEntry(trim((string) ($post['username'] ?? ''))); $password = encryptCredentialEntry(trim((string) ($post['password'] ?? ''))); if ($username === false || $password === false) { $error = 'Your vault session has expired, so the login could not be encrypted - sign out and back in, then try again.'; return null; } $text = function ($field, $length) use ($post) { return "'" . escapeSql(mb_substr(trim((string) ($post[$field] ?? '')), 0, $length)) . "'"; }; $fields = [ 'credential_name' => $text('name', 200), 'credential_description' => $text('description', 500), 'credential_uri' => $text('uri', 500), 'credential_uri_2' => $text('uri_2', 500), 'credential_username' => "'" . escapeSql($username) . "'", 'credential_password' => "'" . escapeSql($password) . "'", 'credential_otp_secret' => "'" . escapeSql($otp) . "'", 'credential_note' => "'" . escapeSql(trim((string) ($post['note'] ?? ''))) . "'", 'credential_favorite' => intval($post['favorite'] ?? 0) ? 1 : 0, 'credential_contact_id' => assetClientOwnedId('contact', $post['contact'] ?? 0, $client_id), 'credential_asset_id' => assetClientOwnedId('asset', $post['asset'] ?? 0, $client_id), ]; $set = []; foreach ($fields as $column => $value) { $set[] = "$column = $value"; } return implode(', ', $set); } /** Replace a credential's tags with these - credential tags (type 4) only. */ function credentialSetTags($credential_id, $tag_ids) { global $mysqli; $credential_id = intval($credential_id); mysqli_query($mysqli, "DELETE FROM credential_tags WHERE credential_id = $credential_id"); $tag_ids = array_values(array_unique(array_filter(array_map('intval', (array) $tag_ids)))); if (!$tag_ids) { return; } mysqli_query($mysqli, "INSERT IGNORE INTO credential_tags (credential_id, tag_id) SELECT $credential_id, tag_id FROM tags WHERE tag_type = 4 AND tag_id IN (" . implode(',', $tag_ids) . ")"); } /** The username as stored, decrypted - '' when there is none or the vault is closed. */ function credentialUsername($row) { $ciphertext = (string) ($row['credential_username'] ?? ''); if ($ciphertext === '') { return ''; } $plain = decryptCredentialEntry($ciphertext); return $plain === false ? '' : (string) $plain; } /** The username with its copy button, or a dash. Raw in. */ function credentialUsernameHtml($username, $empty = '-') { $username = escapeHtml($username); if ($username === '') { return '' . $empty . ''; } return '' . $username . ''; } /** * The password as dots, with an eye that fetches and shows it for a little * while and a copy button that fetches and copies it - both logged as a view * by agent/ajax.php. Nothing secret is in the page until asked for. */ function credentialPasswordHtml($credential_id) { $credential_id = intval($credential_id); // Copying is the point here, so its copy button shows all the time (.itflow-copy-always) return '' . '' . '' . '' . '' . ''; } /** * The current 2FA code, fetched when clicked, with how long it has left - it * follows on to the next code while it is on show, and a second click * copies it. '' when the credential has no 2FA. */ function credentialOtpHtml($credential_id, $has_otp) { if (!$has_otp) { return ''; } $credential_id = intval($credential_id); return ''; } /** Where the login is used - the link, opening in a new tab, and a copy button. '' without one. Raw in. */ function credentialUriHtml($uri, $max = 34) { $uri = trim((string) $uri); if ($uri === '') { return ''; } $href = escapeUrl($uri); $label = escapeHtml(truncate(preg_replace('#^https?://#i', '', $uri), $max)); return '' . $label . '' . ''; } /** A credential in a picker - its kind's icon and a quieter line under it (functions/format.php selectOptionData). */ function selectOptionCredential($row, $description = '') { return selectOptionData(['icon' => 'fas ' . credentialKind($row)['icon'], 'description' => $description, 'keywords' => $row['credential_uri'] ?? '']); } /** * Credentials to list somewhere - an asset's, a contact's, a vendor's or a * license's - each with its client, contact and asset like credentialRow(), * starred first then by name, keyed by id. $where is SQL the caller built * from intval'd ids. Archived ones are left out unless asked for. */ function credentialRows($where, $include_archived = false) { global $mysqli; $sql = mysqli_query($mysqli, "SELECT credentials.*, client_name, contact_id, contact_name, contact_title, contact_photo, contact_archived_at, asset_id, asset_name, asset_type, asset_archived_at FROM credentials LEFT JOIN clients ON client_id = credential_client_id LEFT JOIN contacts ON contact_id = credential_contact_id AND contact_client_id = credential_client_id LEFT JOIN assets ON asset_id = credential_asset_id AND asset_client_id = credential_client_id WHERE ($where)" . ($include_archived ? '' : ' AND credential_archived_at IS NULL') . " ORDER BY credential_favorite DESC, credential_name ASC LIMIT 500"); $rows = []; while ($sql && $row = mysqli_fetch_assoc($sql)) { $rows[intval($row['credential_id'])] = $row; } return $rows; } /** The asset and contact a credential belongs to, each as a small link - HTML pieces, maybe none. */ function credentialOwnersHtml($row) { $pieces = []; if (!empty($row['asset_id'])) { $pieces[] = '' . '' . escapeHtml($row['asset_name']) . ''; } if (!empty($row['contact_id'])) { $pieces[] = contactLink(intval($row['credential_client_id']), $row['contact_id'], $row['contact_name'], $row['contact_photo'], ['archived' => !empty($row['contact_archived_at'])]); } return $pieces; } /** * Credentials as a table, the same wherever they are listed outside the * credentials page - an asset's or contact's tab, a vendor's logins, a * license's: the credential (opening its quick look), the login with the * password and 2FA code on asking, how old the password is, and the actions. * Options: 'for' adds the asset and contact it belongs to under its name * (on a vendor's or license's list, where that differs); 'unlink' is a function * of the row giving the URL that unlinks it from this page's record ('' for * none). */ function credentialTableHtml(array $rows, array $options = []) { $can_edit = credentialCan(2); $can_share = shareCanMake('Credential'); $show_for = !empty($options['for']); $unlink = $options['unlink'] ?? null; $csrf = $_SESSION['csrf_token'] ?? ''; $html = '
| Credential | Login | Changed | |
|---|---|---|---|
' . credentialTile($row, 'sm') . ' '
. '' . $name . ''
. (intval($row['credential_favorite']) ? '' : '')
. ($description !== '' ? ' ' . $description . ' ' : '');
$owners = $show_for ? credentialOwnersHtml($row) : [];
if ($owners) {
$html .= '' . implode('', $owners) . ' ';
}
$html .= ' | ';
$html .= '' . credentialUsernameHtml(credentialUsername($row)) . ' '
. '' . credentialPasswordHtml($id) . credentialOtpHtml($id, $has_otp) . ' ';
foreach (array_filter([$row['credential_uri'] ?? '', $row['credential_uri_2'] ?? '']) as $uri) {
$html .= '' . credentialUriHtml($uri, 34) . ' ';
}
$html .= ' | ';
$html .= '' . (credentialAgePill(credentialAge($row['credential_password_changed_at']), true) ?: '-') . ' | '; $actions = 'Quick look'; if ($can_edit && !$archived) { $actions .= 'Edit' . 'Change password'; } if ($can_share && !$archived) { $actions .= 'Share'; } $unlink_url = $unlink ? (string) $unlink($row) : ''; if ($unlink_url !== '' || ($can_edit && !$archived)) { $actions .= ''; } if ($unlink_url !== '') { $actions .= 'Unlink'; } if ($can_edit && !$archived) { $actions .= 'Archive'; } $html .= ''
. ' |