libre-devops-helpers 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. libre_devops_helpers/__init__.py +23 -0
  2. libre_devops_helpers/__main__.py +5 -0
  3. libre_devops_helpers/cli/__init__.py +5 -0
  4. libre_devops_helpers/cli/app.py +147 -0
  5. libre_devops_helpers/cli/commands/__init__.py +1 -0
  6. libre_devops_helpers/cli/commands/automation.py +321 -0
  7. libre_devops_helpers/cli/commands/az.py +93 -0
  8. libre_devops_helpers/cli/commands/azure.py +258 -0
  9. libre_devops_helpers/cli/commands/config.py +54 -0
  10. libre_devops_helpers/cli/commands/devices.py +548 -0
  11. libre_devops_helpers/cli/commands/entra.py +554 -0
  12. libre_devops_helpers/cli/commands/graph.py +358 -0
  13. libre_devops_helpers/cli/commands/incidents.py +420 -0
  14. libre_devops_helpers/cli/commands/intune.py +79 -0
  15. libre_devops_helpers/cli/commands/keyvault.py +142 -0
  16. libre_devops_helpers/cli/commands/logicapp.py +489 -0
  17. libre_devops_helpers/cli/commands/logs.py +69 -0
  18. libre_devops_helpers/cli/commands/pim.py +381 -0
  19. libre_devops_helpers/cli/commands/pretty.py +141 -0
  20. libre_devops_helpers/cli/commands/profiles.py +153 -0
  21. libre_devops_helpers/cli/commands/snow.py +268 -0
  22. libre_devops_helpers/cli/commands/token.py +222 -0
  23. libre_devops_helpers/cli/commands/welcome.py +43 -0
  24. libre_devops_helpers/cli/commands/xdr.py +353 -0
  25. libre_devops_helpers/cli/exits.py +12 -0
  26. libre_devops_helpers/cli/options.py +146 -0
  27. libre_devops_helpers/cli/render.py +360 -0
  28. libre_devops_helpers/cli/runtime.py +348 -0
  29. libre_devops_helpers/cli/servicenow_runtime.py +170 -0
  30. libre_devops_helpers/core/__init__.py +94 -0
  31. libre_devops_helpers/core/auth.py +103 -0
  32. libre_devops_helpers/core/brand.py +67 -0
  33. libre_devops_helpers/core/browser.py +21 -0
  34. libre_devops_helpers/core/config.py +159 -0
  35. libre_devops_helpers/core/dpapi.py +60 -0
  36. libre_devops_helpers/core/errors.py +90 -0
  37. libre_devops_helpers/core/http.py +412 -0
  38. libre_devops_helpers/core/inputs.py +193 -0
  39. libre_devops_helpers/core/log.py +246 -0
  40. libre_devops_helpers/core/poll.py +88 -0
  41. libre_devops_helpers/core/process.py +106 -0
  42. libre_devops_helpers/core/sheets.py +330 -0
  43. libre_devops_helpers/core/tables.py +49 -0
  44. libre_devops_helpers/core/timewindow.py +127 -0
  45. libre_devops_helpers/core/token_store.py +266 -0
  46. libre_devops_helpers/core/util.py +120 -0
  47. libre_devops_helpers/core/yaml_text.py +142 -0
  48. libre_devops_helpers/microsoft/__init__.py +94 -0
  49. libre_devops_helpers/microsoft/auth/__init__.py +43 -0
  50. libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
  51. libre_devops_helpers/microsoft/auth/delegated.py +391 -0
  52. libre_devops_helpers/microsoft/auth/entra.py +241 -0
  53. libre_devops_helpers/microsoft/auth/factory.py +114 -0
  54. libre_devops_helpers/microsoft/auth/lapse.py +42 -0
  55. libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
  56. libre_devops_helpers/microsoft/automation/__init__.py +24 -0
  57. libre_devops_helpers/microsoft/automation/client.py +241 -0
  58. libre_devops_helpers/microsoft/automation/models.py +131 -0
  59. libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
  60. libre_devops_helpers/microsoft/azcli/client.py +81 -0
  61. libre_devops_helpers/microsoft/azcli/context.py +95 -0
  62. libre_devops_helpers/microsoft/azure/__init__.py +34 -0
  63. libre_devops_helpers/microsoft/azure/client.py +260 -0
  64. libre_devops_helpers/microsoft/azure/models.py +198 -0
  65. libre_devops_helpers/microsoft/clouds.py +83 -0
  66. libre_devops_helpers/microsoft/config.py +244 -0
  67. libre_devops_helpers/microsoft/devices/__init__.py +45 -0
  68. libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
  69. libre_devops_helpers/microsoft/devices/check.py +286 -0
  70. libre_devops_helpers/microsoft/devices/inspect.py +146 -0
  71. libre_devops_helpers/microsoft/devices/models.py +148 -0
  72. libre_devops_helpers/microsoft/entra/__init__.py +41 -0
  73. libre_devops_helpers/microsoft/entra/client.py +422 -0
  74. libre_devops_helpers/microsoft/entra/models.py +334 -0
  75. libre_devops_helpers/microsoft/entra/permissions.py +51 -0
  76. libre_devops_helpers/microsoft/graph/__init__.py +38 -0
  77. libre_devops_helpers/microsoft/graph/client.py +292 -0
  78. libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
  79. libre_devops_helpers/microsoft/incidents/client.py +217 -0
  80. libre_devops_helpers/microsoft/incidents/models.py +165 -0
  81. libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
  82. libre_devops_helpers/microsoft/intune/__init__.py +18 -0
  83. libre_devops_helpers/microsoft/intune/client.py +94 -0
  84. libre_devops_helpers/microsoft/intune/models.py +63 -0
  85. libre_devops_helpers/microsoft/intune/permissions.py +16 -0
  86. libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
  87. libre_devops_helpers/microsoft/keyvault/client.py +185 -0
  88. libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
  89. libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
  90. libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
  91. libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
  92. libre_devops_helpers/microsoft/logicapps/client.py +162 -0
  93. libre_devops_helpers/microsoft/logicapps/document.py +202 -0
  94. libre_devops_helpers/microsoft/pim/__init__.py +39 -0
  95. libre_devops_helpers/microsoft/pim/azure.py +238 -0
  96. libre_devops_helpers/microsoft/pim/entra.py +294 -0
  97. libre_devops_helpers/microsoft/pim/models.py +93 -0
  98. libre_devops_helpers/microsoft/pim/permissions.py +98 -0
  99. libre_devops_helpers/microsoft/pim/rules.py +70 -0
  100. libre_devops_helpers/microsoft/process.py +70 -0
  101. libre_devops_helpers/microsoft/resources.py +137 -0
  102. libre_devops_helpers/microsoft/tokens.py +269 -0
  103. libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
  104. libre_devops_helpers/microsoft/xdr/client.py +246 -0
  105. libre_devops_helpers/microsoft/xdr/models.py +181 -0
  106. libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
  107. libre_devops_helpers/py.typed +0 -0
  108. libre_devops_helpers/servicenow/__init__.py +50 -0
  109. libre_devops_helpers/servicenow/auth.py +409 -0
  110. libre_devops_helpers/servicenow/config.py +261 -0
  111. libre_devops_helpers/servicenow/instance/__init__.py +32 -0
  112. libre_devops_helpers/servicenow/instance/client.py +91 -0
  113. libre_devops_helpers/servicenow/instance/models.py +131 -0
  114. libre_devops_helpers/servicenow/roles.py +23 -0
  115. libre_devops_helpers/servicenow/tables.py +133 -0
  116. libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
  117. libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
  118. libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
  119. libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
  120. libre_devops_helpers-0.4.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,181 @@
1
+ """Defender for Endpoint records (machines, alerts, vulnerabilities, indicators), trimmed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from typing import Any
9
+
10
+ from libre_devops_helpers.core.util import parse_datetime
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Machine:
15
+ """One MDE machine record. ``raw`` keeps the full API record for JSON output."""
16
+
17
+ id: str
18
+ computer_dns_name: str
19
+ onboarding_status: str
20
+ health_status: str
21
+ last_seen: datetime | None
22
+ first_seen: datetime | None
23
+ os_platform: str
24
+ os_version: str
25
+ agent_version: str
26
+ machine_tags: tuple[str, ...]
27
+ risk_score: str
28
+ exposure_level: str
29
+ last_ip_address: str
30
+ aad_device_id: str
31
+ # The Defender device group (set up under Settings > Endpoints > Device groups) the
32
+ # machine falls in; "UnassignedGroup" when it matches none.
33
+ device_group: str
34
+ raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
35
+
36
+ @classmethod
37
+ def from_json(cls, data: Mapping[str, Any]) -> Machine:
38
+ tags = data.get("machineTags")
39
+ return cls(
40
+ id=str(data.get("id", "")),
41
+ computer_dns_name=str(data.get("computerDnsName") or ""),
42
+ onboarding_status=str(data.get("onboardingStatus") or ""),
43
+ health_status=str(data.get("healthStatus") or ""),
44
+ last_seen=parse_datetime(data.get("lastSeen")),
45
+ first_seen=parse_datetime(data.get("firstSeen")),
46
+ os_platform=str(data.get("osPlatform") or ""),
47
+ os_version=str(data.get("osVersion") or ""),
48
+ agent_version=str(data.get("version") or ""),
49
+ machine_tags=tuple(str(tag) for tag in tags) if isinstance(tags, list) else (),
50
+ risk_score=str(data.get("riskScore") or ""),
51
+ exposure_level=str(data.get("exposureLevel") or ""),
52
+ last_ip_address=str(data.get("lastIpAddress") or ""),
53
+ aad_device_id=str(data.get("aadDeviceId") or ""),
54
+ device_group=str(data.get("rbacGroupName") or ""),
55
+ raw=dict(data),
56
+ )
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class MachineLookup:
61
+ """The result of looking one device up by name.
62
+
63
+ ``matched_name`` is the name that found records (the FQDN, or the short
64
+ hostname fallback), or None. ``records`` is newest ``lastSeen`` first;
65
+ more than one means MDE holds duplicate records for the device.
66
+ """
67
+
68
+ query: str
69
+ matched_name: str | None
70
+ records: tuple[Machine, ...] = ()
71
+
72
+ @property
73
+ def found(self) -> bool:
74
+ return bool(self.records)
75
+
76
+ @property
77
+ def machine(self) -> Machine | None:
78
+ """The newest record, which is the one to trust."""
79
+ return self.records[0] if self.records else None
80
+
81
+
82
+ def _text(data: Mapping[str, Any], key: str) -> str:
83
+ return str(data.get(key) or "")
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class Alert:
88
+ """A Defender for Endpoint alert."""
89
+
90
+ id: str
91
+ title: str
92
+ severity: str
93
+ status: str
94
+ category: str
95
+ detection_source: str
96
+ machine_id: str
97
+ computer_dns_name: str
98
+ incident_id: str
99
+ created: datetime | None
100
+ last_activity: datetime | None
101
+ raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
102
+
103
+ @property
104
+ def resolved(self) -> bool:
105
+ return self.status.casefold() == "resolved"
106
+
107
+ @classmethod
108
+ def from_json(cls, data: Mapping[str, Any]) -> Alert:
109
+ return cls(
110
+ id=_text(data, "id"),
111
+ title=_text(data, "title"),
112
+ severity=_text(data, "severity"),
113
+ status=_text(data, "status"),
114
+ category=_text(data, "category"),
115
+ detection_source=_text(data, "detectionSource"),
116
+ machine_id=_text(data, "machineId"),
117
+ computer_dns_name=_text(data, "computerDnsName"),
118
+ incident_id=_text(data, "incidentId"),
119
+ created=parse_datetime(data.get("alertCreationTime")),
120
+ last_activity=parse_datetime(data.get("lastEventTime") or data.get("lastUpdateTime")),
121
+ raw=dict(data),
122
+ )
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class Vulnerability:
127
+ """A vulnerability (CVE) Defender reports on a machine."""
128
+
129
+ id: str
130
+ name: str
131
+ severity: str
132
+ cvss: float | None
133
+ exploit_verified: bool
134
+ public_exploit: bool
135
+ published: datetime | None
136
+ raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
137
+
138
+ @classmethod
139
+ def from_json(cls, data: Mapping[str, Any]) -> Vulnerability:
140
+ cvss = data.get("cvssV3")
141
+ return cls(
142
+ id=_text(data, "id"),
143
+ name=_text(data, "name"),
144
+ severity=_text(data, "severity"),
145
+ cvss=float(cvss)
146
+ if isinstance(cvss, int | float) and not isinstance(cvss, bool)
147
+ else None,
148
+ exploit_verified=data.get("exploitVerified") is True,
149
+ public_exploit=data.get("publicExploit") is True,
150
+ published=parse_datetime(data.get("publishedOn")),
151
+ raw=dict(data),
152
+ )
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class Indicator:
157
+ """A custom indicator of compromise (file hash, IP, URL, domain or certificate)."""
158
+
159
+ id: str
160
+ value: str
161
+ indicator_type: str
162
+ action: str
163
+ title: str
164
+ severity: str
165
+ expires: datetime | None
166
+ created_by: str
167
+ raw: Mapping[str, Any] = field(default_factory=dict, compare=False, repr=False)
168
+
169
+ @classmethod
170
+ def from_json(cls, data: Mapping[str, Any]) -> Indicator:
171
+ return cls(
172
+ id=_text(data, "id"),
173
+ value=_text(data, "indicatorValue"),
174
+ indicator_type=_text(data, "indicatorType"),
175
+ action=_text(data, "action"),
176
+ title=_text(data, "title"),
177
+ severity=_text(data, "severity"),
178
+ expires=parse_datetime(data.get("expirationTime")),
179
+ created_by=_text(data, "createdBy"),
180
+ raw=dict(data),
181
+ )
@@ -0,0 +1,24 @@
1
+ """What each Defender for Endpoint feature needs from a token.
2
+
3
+ These are the application permissions and granular delegated scopes. The Azure CLI's
4
+ Defender token carries only ``user_impersonation``, in which case the token checks say
5
+ access rests on the user's Defender role instead of listing these.
6
+ """
7
+
8
+ from libre_devops_helpers.microsoft.resources import Requirement
9
+
10
+ REQUIREMENTS = (
11
+ Requirement(
12
+ "xdr machines",
13
+ "mde",
14
+ (("Machine.Read.All", "Machine.ReadWrite.All", "Machine.Read", "Machine.ReadWrite"),),
15
+ ),
16
+ Requirement(
17
+ "xdr alerts",
18
+ "mde",
19
+ (("Alert.Read.All", "Alert.ReadWrite.All", "Alert.Read", "Alert.ReadWrite"),),
20
+ ),
21
+ Requirement("xdr vulnerabilities", "mde", (("Vulnerability.Read.All", "Vulnerability.Read"),)),
22
+ Requirement("xdr indicators", "mde", (("Ti.Read.All", "Ti.ReadWrite", "Ti.ReadWrite.All"),)),
23
+ Requirement("xdr hunting", "mde", (("AdvancedQuery.Read.All", "AdvancedQuery.Read"),)),
24
+ )
File without changes
@@ -0,0 +1,50 @@
1
+ """ServiceNow: sign-in, the Table API, and features built on them.
2
+
3
+ Laid out like the Microsoft package: this shared layer (``config``, ``auth``, ``tables``,
4
+ ``roles``) depends on ``core`` only, and each feature (``instance``) on this layer.
5
+
6
+ Public API::
7
+
8
+ from libre_devops_helpers.servicenow import TableClient, credential_for, load_config
9
+ """
10
+
11
+ from libre_devops_helpers.servicenow.auth import (
12
+ BasicCredential,
13
+ Credential,
14
+ OAuthApp,
15
+ OAuthCredential,
16
+ credential_for,
17
+ )
18
+ from libre_devops_helpers.servicenow.config import (
19
+ AUTH_METHODS,
20
+ CONFIG_TEMPLATE,
21
+ SECTION,
22
+ SIGN_INS,
23
+ Profile,
24
+ ServiceNowConfig,
25
+ from_file,
26
+ load_config,
27
+ profile_from_env,
28
+ )
29
+ from libre_devops_helpers.servicenow.roles import RoleRequirement
30
+ from libre_devops_helpers.servicenow.tables import TableClient, condition
31
+
32
+ __all__ = [
33
+ "AUTH_METHODS",
34
+ "CONFIG_TEMPLATE",
35
+ "SECTION",
36
+ "SIGN_INS",
37
+ "BasicCredential",
38
+ "Credential",
39
+ "OAuthApp",
40
+ "OAuthCredential",
41
+ "Profile",
42
+ "RoleRequirement",
43
+ "ServiceNowConfig",
44
+ "TableClient",
45
+ "condition",
46
+ "credential_for",
47
+ "from_file",
48
+ "load_config",
49
+ "profile_from_env",
50
+ ]
@@ -0,0 +1,409 @@
1
+ """Signing in to a ServiceNow instance as yourself: OAuth with a kept sign-in, or basic.
2
+
3
+ ``OAuthCredential`` signs in through an OAuth application registry entry on the
4
+ instance (its client id and secret), and keeps the refresh token it gets, so later
5
+ commands sign in by themselves until it expires (100 days by default) or is revoked:
6
+
7
+ - ``browser``: the authorisation code flow with PKCE. You open a link in any browser and
8
+ sign in as you would to the instance (single sign-on and MFA included), then paste
9
+ back the address the browser lands on. Nothing has to listen there, so this works on
10
+ a headless machine, and at a workplace that will not allow passwords on the API.
11
+ - ``password``: the username and password, once, for the tokens.
12
+
13
+ ``BasicCredential`` sends the username and password with every request. ServiceNow now
14
+ refuses that for ordinary interactive accounts unless they hold the
15
+ ``snc_basic_auth_api_access`` role.
16
+
17
+ Access tokens are never kept. A client secret typed in at sign-in (rather than set in
18
+ the environment) is kept with the refresh token, so it is not asked for again. Nothing
19
+ is logged.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import hashlib
26
+ import json
27
+ import logging
28
+ import os
29
+ import secrets
30
+ import threading
31
+ import webbrowser
32
+ from collections.abc import Callable, Mapping
33
+ from dataclasses import dataclass
34
+ from datetime import datetime, timedelta
35
+ from typing import Any
36
+ from urllib.parse import parse_qs, quote, urlencode, urlsplit
37
+
38
+ import requests
39
+
40
+ from libre_devops_helpers.core.auth import AccessToken, utc_now
41
+ from libre_devops_helpers.core.browser import can_launch_browser
42
+ from libre_devops_helpers.core.errors import ApiError, AuthError, LdoError, ReauthRequired
43
+ from libre_devops_helpers.core.http import ApiClient
44
+ from libre_devops_helpers.core.token_store import MemoryStore, TokenStore, open_store
45
+ from libre_devops_helpers.servicenow.config import (
46
+ CLIENT_ID_ENV,
47
+ USERNAME_ENV,
48
+ Profile,
49
+ )
50
+
51
+ log = logging.getLogger(__name__)
52
+
53
+ # (question, hide the answer) -> the answer. The CLI asks on a terminal; without one,
54
+ # nothing is asked and a missing sign-in is an error saying how to sign in.
55
+ Ask = Callable[[str, bool], str]
56
+ DEFAULT_ACCESS_LIFETIME = 1800.0 # ServiceNow's default: 30 minutes
57
+
58
+
59
+ class BasicCredential:
60
+ """Your username and password, sent with each request (HTTP Basic)."""
61
+
62
+ scheme = "Basic"
63
+
64
+ def __init__(self, username: str, password: str) -> None:
65
+ if not username:
66
+ raise AuthError("basic sign-in needs a username")
67
+ if not password:
68
+ raise AuthError("basic sign-in needs a password")
69
+ self.username = username
70
+ self._encoded = base64.b64encode(f"{username}:{password}".encode()).decode()
71
+
72
+ def __repr__(self) -> str:
73
+ return f"BasicCredential(username={self.username!r})"
74
+
75
+ def authorization(self) -> str:
76
+ """The value that follows ``Basic`` in the Authorization header."""
77
+ return self._encoded
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class OAuthApp:
82
+ """The OAuth application registry entry: where to sign in, and as which client."""
83
+
84
+ instance: str
85
+ client_id: str
86
+ client_secret: str | None # None: from the kept sign-in, or asked for
87
+ redirect_uri: str
88
+
89
+
90
+ class OAuthCredential:
91
+ """OAuth 2.0 against the instance itself, with the sign-in kept in ``store``.
92
+
93
+ It satisfies ``core.auth.TokenProvider`` (``resource`` and ``tenant_id`` are not
94
+ needed: one credential is one instance and one account), so ``CachingTokenProvider``
95
+ and ``BearerToken`` work as they do for Entra ID.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ app: OAuthApp,
101
+ *,
102
+ key: str,
103
+ sign_in: str = "browser",
104
+ username: str | None = None,
105
+ password: Callable[[], str | None] = lambda: None,
106
+ ask: Ask | None = None,
107
+ store: TokenStore | None = None,
108
+ session: requests.Session | None = None,
109
+ verify: bool | str = True,
110
+ clock: Callable[[], datetime] = utc_now,
111
+ notify: Callable[[str], None] | None = None,
112
+ open_browser: Callable[[str], object] = webbrowser.open,
113
+ has_browser: Callable[[], bool] = can_launch_browser,
114
+ sign_in_hint: str = "sign in again",
115
+ ) -> None:
116
+ if not app.client_id:
117
+ raise AuthError("OAuth sign-in needs the application's client id")
118
+ if sign_in == "password" and not username:
119
+ raise AuthError('sign_in = "password" needs a username')
120
+ self.app = app
121
+ self.key = key
122
+ self.sign_in_method = sign_in
123
+ self.username = username
124
+ self._password = password
125
+ self._ask = ask
126
+ self._store = store or MemoryStore()
127
+ self._clock = clock
128
+ self._notify = notify or (lambda message: log.warning("%s", message))
129
+ self._open_browser = open_browser
130
+ self._has_browser = has_browser
131
+ self._sign_in_hint = sign_in_hint
132
+ self._endpoint = ApiClient(
133
+ app.instance, None, name="ServiceNow sign-in", session=session, verify=verify
134
+ )
135
+ self._lock = threading.Lock()
136
+
137
+ def __repr__(self) -> str:
138
+ return (
139
+ f"OAuthCredential(instance={self.app.instance!r}, "
140
+ f"client_id={self.app.client_id!r}, sign_in={self.sign_in_method!r})"
141
+ )
142
+
143
+ def get_token(self, resource: str = "", tenant_id: str = "") -> AccessToken:
144
+ with self._lock:
145
+ kept = self._recall()
146
+ refresh = kept.get("refresh_token")
147
+ if refresh:
148
+ try:
149
+ return self._grant(
150
+ {"grant_type": "refresh_token", "refresh_token": refresh}, kept
151
+ )
152
+ except AuthError:
153
+ self._forget()
154
+ self._notify("The kept ServiceNow sign-in was refused; signing in again.")
155
+ return self._sign_in(kept)
156
+
157
+ def sign_in(self) -> AccessToken:
158
+ """Sign in afresh, ignoring any kept sign-in, and keep the new one."""
159
+ with self._lock:
160
+ kept = self._recall()
161
+ kept.pop("refresh_token", None)
162
+ return self._sign_in(kept)
163
+
164
+ def sign_out(self) -> bool:
165
+ """Forget the kept sign-in. True when there was one."""
166
+ with self._lock:
167
+ return self._store.delete(self.key)
168
+
169
+ def has_kept_sign_in(self) -> bool:
170
+ try:
171
+ return bool(self._decode(self._store.load(self.key)).get("refresh_token"))
172
+ except LdoError:
173
+ return False
174
+
175
+ # Signing in ----------------------------------------------------------------------
176
+
177
+ def _sign_in(self, kept: dict[str, str]) -> AccessToken:
178
+ if self.sign_in_method == "password":
179
+ password = self._password()
180
+ if not password and self._ask is not None:
181
+ password = self._ask(f"ServiceNow password for {self.username}", True)
182
+ if not password:
183
+ raise self._needs_sign_in("no password to sign in with")
184
+ form = {"grant_type": "password", "username": self.username or "", "password": password}
185
+ return self._grant(form, kept)
186
+ if self._ask is None:
187
+ raise self._needs_sign_in("signing in needs a browser and someone to paste back")
188
+ return self._browser_sign_in(kept)
189
+
190
+ def _browser_sign_in(self, kept: dict[str, str]) -> AccessToken:
191
+ verifier = secrets.token_urlsafe(64)
192
+ challenge = (
193
+ base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
194
+ .rstrip(b"=")
195
+ .decode()
196
+ )
197
+ state = secrets.token_urlsafe(24)
198
+ params = {
199
+ "response_type": "code",
200
+ "client_id": self.app.client_id,
201
+ "redirect_uri": self.app.redirect_uri,
202
+ "state": state,
203
+ "code_challenge": challenge,
204
+ "code_challenge_method": "S256",
205
+ }
206
+ url = f"{self.app.instance}/oauth_auth.do?" + urlencode(params, quote_via=quote)
207
+ self._notify(
208
+ "Open this link in a browser and sign in to ServiceNow. You will land on an "
209
+ f"address starting {self.app.redirect_uri} (the page may not load: that is "
210
+ f"fine). Copy that whole address and paste it here.\n\n{url}\n"
211
+ )
212
+ if self._has_browser():
213
+ try:
214
+ self._open_browser(url)
215
+ except Exception:
216
+ log.debug("could not open a browser", exc_info=True)
217
+ assert self._ask is not None
218
+ pasted = self._ask("The address you landed on", False).strip()
219
+ query = {key: values[0] for key, values in parse_qs(urlsplit(pasted).query).items()}
220
+ if "error" in query:
221
+ detail = query.get("error_description") or query["error"]
222
+ raise AuthError(f"ServiceNow sign-in failed: {detail}")
223
+ if query.get("state") != state:
224
+ raise AuthError(
225
+ "that address is not from this sign-in (its state does not match)",
226
+ hint="paste the whole address the browser landed on, from this sign-in",
227
+ )
228
+ code = query.get("code")
229
+ if not code:
230
+ raise AuthError("that address carries no authorisation code")
231
+ form = {
232
+ "grant_type": "authorization_code",
233
+ "code": code,
234
+ "redirect_uri": self.app.redirect_uri,
235
+ "code_verifier": verifier,
236
+ }
237
+ return self._grant(form, kept)
238
+
239
+ def _needs_sign_in(self, why: str) -> ReauthRequired:
240
+ return ReauthRequired(
241
+ f"there is no kept sign-in to {self.app.instance}, and {why}",
242
+ hint=self._sign_in_hint,
243
+ reason="no kept sign-in",
244
+ )
245
+
246
+ # The token endpoint --------------------------------------------------------------
247
+
248
+ def _grant(self, form: Mapping[str, str], kept: dict[str, str]) -> AccessToken:
249
+ secret, typed = self._client_secret(kept)
250
+ now = self._clock()
251
+ try:
252
+ data = self._endpoint.request(
253
+ "POST",
254
+ "/oauth_token.do",
255
+ form={"client_id": self.app.client_id, "client_secret": secret, **form},
256
+ )
257
+ except ApiError as exc:
258
+ raise AuthError(str(exc), hint=_hint(exc)) from None
259
+ token = data.get("access_token")
260
+ if not isinstance(token, str) or not token:
261
+ raise AuthError("the ServiceNow token response has no access_token")
262
+ refresh = data.get("refresh_token")
263
+ if isinstance(refresh, str) and refresh:
264
+ record = {"refresh_token": refresh}
265
+ if typed or ("client_secret" in kept and self.app.client_secret is None):
266
+ record["client_secret"] = secret
267
+ self._remember(record)
268
+ lifetime = _number(data.get("expires_in")) or DEFAULT_ACCESS_LIFETIME
269
+ return AccessToken(
270
+ token=token,
271
+ expires_on=now + timedelta(seconds=lifetime),
272
+ tenant_id=self.username or "",
273
+ resource=self.app.instance,
274
+ )
275
+
276
+ def _client_secret(self, kept: Mapping[str, str]) -> tuple[str, bool]:
277
+ """The client secret, and whether it was typed in just now (so worth keeping)."""
278
+ if self.app.client_secret:
279
+ return self.app.client_secret, False
280
+ if kept.get("client_secret"):
281
+ return kept["client_secret"], False
282
+ if self._ask is not None:
283
+ typed = self._ask("Client secret of the OAuth application", True).strip()
284
+ if typed:
285
+ return typed, True
286
+ raise self._needs_sign_in("no client secret")
287
+
288
+ # The kept sign-in ----------------------------------------------------------------
289
+
290
+ def _recall(self) -> dict[str, str]:
291
+ try:
292
+ return self._decode(self._store.load(self.key))
293
+ except LdoError as exc:
294
+ self._notify(f"The kept ServiceNow sign-in cannot be used ({exc}); signing in afresh.")
295
+ return {}
296
+
297
+ def _remember(self, record: Mapping[str, str]) -> None:
298
+ try:
299
+ self._store.save(self.key, json.dumps(dict(record), sort_keys=True))
300
+ except LdoError as exc:
301
+ self._notify(f"The ServiceNow sign-in could not be kept for the next command: {exc}")
302
+
303
+ def _forget(self) -> None:
304
+ try:
305
+ self._store.delete(self.key)
306
+ except LdoError as exc:
307
+ log.warning("could not forget a refused ServiceNow sign-in: %s", exc)
308
+
309
+ @staticmethod
310
+ def _decode(value: str | None) -> dict[str, str]:
311
+ if not value:
312
+ return {}
313
+ try:
314
+ data = json.loads(value)
315
+ except ValueError:
316
+ return {}
317
+ if not isinstance(data, dict):
318
+ return {}
319
+ return {key: item for key, item in data.items() if isinstance(item, str) and item}
320
+
321
+
322
+ Credential = BasicCredential | OAuthCredential
323
+
324
+
325
+ def credential_for(
326
+ profile: Profile,
327
+ *,
328
+ environ: Mapping[str, str] = os.environ,
329
+ store: TokenStore | None = None,
330
+ ask: Ask | None = None,
331
+ session: requests.Session | None = None,
332
+ verify: bool | str = True,
333
+ notify: Callable[[str], None] | None = None,
334
+ sign_in_hint: str = "sign in again",
335
+ has_browser: Callable[[], bool] = can_launch_browser,
336
+ open_browser: Callable[[str], object] = webbrowser.open,
337
+ ) -> Credential:
338
+ """The credential ``profile`` describes, with its secrets from ``environ`` (or asked)."""
339
+ username = profile.username or environ.get(USERNAME_ENV, "").strip() or None
340
+ if profile.auth == "basic":
341
+ password = environ.get(profile.password_env, "")
342
+ if not username:
343
+ raise AuthError(
344
+ f"ServiceNow profile {profile.name!r} has no username",
345
+ hint=f"set username on the profile, or {USERNAME_ENV}",
346
+ )
347
+ if not password:
348
+ raise AuthError(
349
+ f"ServiceNow profile {profile.name!r} needs a password",
350
+ hint=f"set {profile.password_env}; the config file never holds passwords",
351
+ )
352
+ return BasicCredential(username, password)
353
+ client_id = profile.client_id or environ.get(CLIENT_ID_ENV, "").strip()
354
+ if not client_id:
355
+ raise AuthError(
356
+ f"ServiceNow profile {profile.name!r} has no OAuth client id",
357
+ hint=(
358
+ f"set client_id on the profile, or {CLIENT_ID_ENV}: the client id of an "
359
+ "entry in System OAuth > Application Registry"
360
+ ),
361
+ )
362
+ if profile.sign_in == "password" and not username:
363
+ raise AuthError(
364
+ f"ServiceNow profile {profile.name!r} signs in with a password but has no username",
365
+ hint=f"set username on the profile, or {USERNAME_ENV}",
366
+ )
367
+ return OAuthCredential(
368
+ OAuthApp(
369
+ instance=profile.instance,
370
+ client_id=client_id,
371
+ client_secret=environ.get(profile.client_secret_env) or None,
372
+ redirect_uri=profile.redirect_uri,
373
+ ),
374
+ key=f"servicenow|{profile.host}|{client_id}|{profile.name}",
375
+ sign_in=profile.sign_in,
376
+ username=username,
377
+ password=lambda: environ.get(profile.password_env) or None,
378
+ ask=ask,
379
+ store=store or open_store(profile.token_cache, environ=environ),
380
+ session=session,
381
+ verify=verify,
382
+ notify=notify,
383
+ sign_in_hint=sign_in_hint,
384
+ has_browser=has_browser,
385
+ open_browser=open_browser,
386
+ )
387
+
388
+
389
+ def _number(value: Any) -> float | None:
390
+ if isinstance(value, bool):
391
+ return None
392
+ if isinstance(value, int | float):
393
+ return float(value)
394
+ if isinstance(value, str) and value.strip().isdigit():
395
+ return float(value)
396
+ return None
397
+
398
+
399
+ def _hint(exc: ApiError) -> str:
400
+ text = str(exc).lower()
401
+ if "invalid_client" in text:
402
+ return "check the client id and secret against the application registry entry"
403
+ if "invalid_grant" in text or "access_denied" in text:
404
+ return (
405
+ "the instance refused the sign-in: check the username and password (or sign in "
406
+ "again), and that the application registry entry is active; repeated failures "
407
+ "lock the account for a while"
408
+ )
409
+ return "check the application registry entry is active, and its client id and secret"