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.
- libre_devops_helpers/__init__.py +23 -0
- libre_devops_helpers/__main__.py +5 -0
- libre_devops_helpers/cli/__init__.py +5 -0
- libre_devops_helpers/cli/app.py +147 -0
- libre_devops_helpers/cli/commands/__init__.py +1 -0
- libre_devops_helpers/cli/commands/automation.py +321 -0
- libre_devops_helpers/cli/commands/az.py +93 -0
- libre_devops_helpers/cli/commands/azure.py +258 -0
- libre_devops_helpers/cli/commands/config.py +54 -0
- libre_devops_helpers/cli/commands/devices.py +548 -0
- libre_devops_helpers/cli/commands/entra.py +554 -0
- libre_devops_helpers/cli/commands/graph.py +358 -0
- libre_devops_helpers/cli/commands/incidents.py +420 -0
- libre_devops_helpers/cli/commands/intune.py +79 -0
- libre_devops_helpers/cli/commands/keyvault.py +142 -0
- libre_devops_helpers/cli/commands/logicapp.py +489 -0
- libre_devops_helpers/cli/commands/logs.py +69 -0
- libre_devops_helpers/cli/commands/pim.py +381 -0
- libre_devops_helpers/cli/commands/pretty.py +141 -0
- libre_devops_helpers/cli/commands/profiles.py +153 -0
- libre_devops_helpers/cli/commands/snow.py +268 -0
- libre_devops_helpers/cli/commands/token.py +222 -0
- libre_devops_helpers/cli/commands/welcome.py +43 -0
- libre_devops_helpers/cli/commands/xdr.py +353 -0
- libre_devops_helpers/cli/exits.py +12 -0
- libre_devops_helpers/cli/options.py +146 -0
- libre_devops_helpers/cli/render.py +360 -0
- libre_devops_helpers/cli/runtime.py +348 -0
- libre_devops_helpers/cli/servicenow_runtime.py +170 -0
- libre_devops_helpers/core/__init__.py +94 -0
- libre_devops_helpers/core/auth.py +103 -0
- libre_devops_helpers/core/brand.py +67 -0
- libre_devops_helpers/core/browser.py +21 -0
- libre_devops_helpers/core/config.py +159 -0
- libre_devops_helpers/core/dpapi.py +60 -0
- libre_devops_helpers/core/errors.py +90 -0
- libre_devops_helpers/core/http.py +412 -0
- libre_devops_helpers/core/inputs.py +193 -0
- libre_devops_helpers/core/log.py +246 -0
- libre_devops_helpers/core/poll.py +88 -0
- libre_devops_helpers/core/process.py +106 -0
- libre_devops_helpers/core/sheets.py +330 -0
- libre_devops_helpers/core/tables.py +49 -0
- libre_devops_helpers/core/timewindow.py +127 -0
- libre_devops_helpers/core/token_store.py +266 -0
- libre_devops_helpers/core/util.py +120 -0
- libre_devops_helpers/core/yaml_text.py +142 -0
- libre_devops_helpers/microsoft/__init__.py +94 -0
- libre_devops_helpers/microsoft/auth/__init__.py +43 -0
- libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
- libre_devops_helpers/microsoft/auth/delegated.py +391 -0
- libre_devops_helpers/microsoft/auth/entra.py +241 -0
- libre_devops_helpers/microsoft/auth/factory.py +114 -0
- libre_devops_helpers/microsoft/auth/lapse.py +42 -0
- libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
- libre_devops_helpers/microsoft/automation/__init__.py +24 -0
- libre_devops_helpers/microsoft/automation/client.py +241 -0
- libre_devops_helpers/microsoft/automation/models.py +131 -0
- libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
- libre_devops_helpers/microsoft/azcli/client.py +81 -0
- libre_devops_helpers/microsoft/azcli/context.py +95 -0
- libre_devops_helpers/microsoft/azure/__init__.py +34 -0
- libre_devops_helpers/microsoft/azure/client.py +260 -0
- libre_devops_helpers/microsoft/azure/models.py +198 -0
- libre_devops_helpers/microsoft/clouds.py +83 -0
- libre_devops_helpers/microsoft/config.py +244 -0
- libre_devops_helpers/microsoft/devices/__init__.py +45 -0
- libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
- libre_devops_helpers/microsoft/devices/check.py +286 -0
- libre_devops_helpers/microsoft/devices/inspect.py +146 -0
- libre_devops_helpers/microsoft/devices/models.py +148 -0
- libre_devops_helpers/microsoft/entra/__init__.py +41 -0
- libre_devops_helpers/microsoft/entra/client.py +422 -0
- libre_devops_helpers/microsoft/entra/models.py +334 -0
- libre_devops_helpers/microsoft/entra/permissions.py +51 -0
- libre_devops_helpers/microsoft/graph/__init__.py +38 -0
- libre_devops_helpers/microsoft/graph/client.py +292 -0
- libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
- libre_devops_helpers/microsoft/incidents/client.py +217 -0
- libre_devops_helpers/microsoft/incidents/models.py +165 -0
- libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
- libre_devops_helpers/microsoft/intune/__init__.py +18 -0
- libre_devops_helpers/microsoft/intune/client.py +94 -0
- libre_devops_helpers/microsoft/intune/models.py +63 -0
- libre_devops_helpers/microsoft/intune/permissions.py +16 -0
- libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
- libre_devops_helpers/microsoft/keyvault/client.py +185 -0
- libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
- libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
- libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
- libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
- libre_devops_helpers/microsoft/logicapps/client.py +162 -0
- libre_devops_helpers/microsoft/logicapps/document.py +202 -0
- libre_devops_helpers/microsoft/pim/__init__.py +39 -0
- libre_devops_helpers/microsoft/pim/azure.py +238 -0
- libre_devops_helpers/microsoft/pim/entra.py +294 -0
- libre_devops_helpers/microsoft/pim/models.py +93 -0
- libre_devops_helpers/microsoft/pim/permissions.py +98 -0
- libre_devops_helpers/microsoft/pim/rules.py +70 -0
- libre_devops_helpers/microsoft/process.py +70 -0
- libre_devops_helpers/microsoft/resources.py +137 -0
- libre_devops_helpers/microsoft/tokens.py +269 -0
- libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
- libre_devops_helpers/microsoft/xdr/client.py +246 -0
- libre_devops_helpers/microsoft/xdr/models.py +181 -0
- libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
- libre_devops_helpers/py.typed +0 -0
- libre_devops_helpers/servicenow/__init__.py +50 -0
- libre_devops_helpers/servicenow/auth.py +409 -0
- libre_devops_helpers/servicenow/config.py +261 -0
- libre_devops_helpers/servicenow/instance/__init__.py +32 -0
- libre_devops_helpers/servicenow/instance/client.py +91 -0
- libre_devops_helpers/servicenow/instance/models.py +131 -0
- libre_devops_helpers/servicenow/roles.py +23 -0
- libre_devops_helpers/servicenow/tables.py +133 -0
- libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
- libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
- libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
- libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
- 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"
|