pjdev-sn-sdk 4.6.4__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.
- pjdev_sn_sdk/__about__.py +4 -0
- pjdev_sn_sdk/__init__.py +3 -0
- pjdev_sn_sdk/api_utilities.py +161 -0
- pjdev_sn_sdk/config_service.py +19 -0
- pjdev_sn_sdk/control_parsing_service.py +98 -0
- pjdev_sn_sdk/models.py +123 -0
- pjdev_sn_sdk/sn_api_service.py +628 -0
- pjdev_sn_sdk/sn_api_utilities.py +188 -0
- pjdev_sn_sdk-4.6.4.dist-info/METADATA +48 -0
- pjdev_sn_sdk-4.6.4.dist-info/RECORD +12 -0
- pjdev_sn_sdk-4.6.4.dist-info/WHEEL +4 -0
- pjdev_sn_sdk-4.6.4.dist-info/licenses/LICENSE.txt +9 -0
pjdev_sn_sdk/__init__.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Optional, List, Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from httpx import HTTPStatusError
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def log_server_addr(response):
|
|
12
|
+
network_stream_ext = response.extensions["network_stream"]
|
|
13
|
+
logger.debug(f"Client IP, Port: {network_stream_ext.get_extra_info('client_addr')}")
|
|
14
|
+
logger.debug(f"Server IP, Port: {network_stream_ext.get_extra_info('server_addr')}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def log_request_headers(request):
|
|
18
|
+
header_keys = [k for k in request.headers.keys()]
|
|
19
|
+
logger.debug("Request event hook - Headers: " + " | ".join(header_keys))
|
|
20
|
+
logger.debug(
|
|
21
|
+
f"Request event hook: {request.method} {request.url} - Waiting for response"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def log_response_headers(response):
|
|
26
|
+
request = response.request
|
|
27
|
+
header_keys = [k for k in response.headers.keys()]
|
|
28
|
+
logger.debug("Request event hook - Headers: " + " | ".join(header_keys))
|
|
29
|
+
logger.debug(
|
|
30
|
+
f"Response event hook: Content-Type -> {response.headers.get('content-type')}"
|
|
31
|
+
)
|
|
32
|
+
logger.debug(
|
|
33
|
+
f"Response event hook: Content-encoding -> {response.headers.get('content-encoding')}"
|
|
34
|
+
)
|
|
35
|
+
logger.debug(
|
|
36
|
+
f"Response event hook: transfer-encoding -> {response.headers.get('transfer-encoding')}"
|
|
37
|
+
)
|
|
38
|
+
logger.debug(f"Response event hook: Server -> {response.headers.get('server')}")
|
|
39
|
+
logger.debug(
|
|
40
|
+
f"Response event hook: {request.method} {request.url} - Status {response.status_code}"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_error_message(eg: ExceptionGroup) -> str:
|
|
45
|
+
error_messages = []
|
|
46
|
+
for e in eg.exceptions:
|
|
47
|
+
if isinstance(e, httpx.HTTPStatusError):
|
|
48
|
+
error_messages.append(f"{e.response.status_code} -> " + e.response.text)
|
|
49
|
+
else:
|
|
50
|
+
error_messages.append(str(e))
|
|
51
|
+
error = " | ".join(f"{e}" for e in error_messages)
|
|
52
|
+
logger.error(error)
|
|
53
|
+
|
|
54
|
+
return error
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def retry_http(max_attempts: int, delay_seconds: int):
|
|
58
|
+
def decorator(func):
|
|
59
|
+
@wraps(func)
|
|
60
|
+
def wrapper(*args, **kwargs):
|
|
61
|
+
attempts = 0
|
|
62
|
+
while attempts < max_attempts:
|
|
63
|
+
try:
|
|
64
|
+
return func(*args, **kwargs)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
attempts += 1
|
|
67
|
+
logger.warning(e)
|
|
68
|
+
logger.warning(f"Attempt {attempts}/{max_attempts} failed: {e}")
|
|
69
|
+
time.sleep(delay_seconds**attempts)
|
|
70
|
+
raise Exception(f"Failed after {max_attempts} attempts")
|
|
71
|
+
|
|
72
|
+
return wrapper
|
|
73
|
+
|
|
74
|
+
return decorator
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def async_retry_http(
|
|
78
|
+
max_attempts: int,
|
|
79
|
+
delay_seconds: int,
|
|
80
|
+
default_value: Optional[Any] = None,
|
|
81
|
+
status_codes_to_ignore: Optional[List[int]] = None,
|
|
82
|
+
):
|
|
83
|
+
def decorator(func):
|
|
84
|
+
@wraps(func)
|
|
85
|
+
async def wrapper(*args, **kwargs):
|
|
86
|
+
attempts = 0
|
|
87
|
+
exceptions = []
|
|
88
|
+
|
|
89
|
+
while attempts < max_attempts:
|
|
90
|
+
try:
|
|
91
|
+
return await func(*args, **kwargs)
|
|
92
|
+
except HTTPStatusError as e:
|
|
93
|
+
logger.warning(e)
|
|
94
|
+
logger.warning(
|
|
95
|
+
f"{e.response.status_code}: {e.response.reason_phrase}"
|
|
96
|
+
)
|
|
97
|
+
logger.warning(f"{e.response.text}")
|
|
98
|
+
exceptions.append(e)
|
|
99
|
+
if (
|
|
100
|
+
status_codes_to_ignore
|
|
101
|
+
and e.response.status_code in status_codes_to_ignore
|
|
102
|
+
):
|
|
103
|
+
break
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.warning("unexpected exception")
|
|
106
|
+
logger.warning(e)
|
|
107
|
+
exceptions.append(e)
|
|
108
|
+
|
|
109
|
+
attempts += 1
|
|
110
|
+
if attempts == max_attempts:
|
|
111
|
+
break
|
|
112
|
+
logger.warning(f"Attempt {attempts}/{max_attempts} failed")
|
|
113
|
+
total_delay = delay_seconds**attempts
|
|
114
|
+
logger.warning(f"Retrying in {total_delay} seconds...")
|
|
115
|
+
await asyncio.sleep(total_delay)
|
|
116
|
+
if default_value is None:
|
|
117
|
+
raise ExceptionGroup(
|
|
118
|
+
f"Failed after {max_attempts} attempts", exceptions
|
|
119
|
+
)
|
|
120
|
+
logger.error(f"Failed after {max_attempts} attempts")
|
|
121
|
+
return default_value
|
|
122
|
+
|
|
123
|
+
return wrapper
|
|
124
|
+
|
|
125
|
+
return decorator
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def record_time(log: bool = False):
|
|
129
|
+
def decorator(func):
|
|
130
|
+
if asyncio.iscoroutinefunction(func):
|
|
131
|
+
|
|
132
|
+
@wraps(func)
|
|
133
|
+
async def wrap_func(*args, **kwargs):
|
|
134
|
+
t1 = time.time()
|
|
135
|
+
result = await func(*args, **kwargs)
|
|
136
|
+
t2 = time.time()
|
|
137
|
+
if log:
|
|
138
|
+
logger.info(
|
|
139
|
+
f"Function {func.__name__!r} executed in {(t2 - t1):.2f}s"
|
|
140
|
+
)
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
return wrap_func
|
|
144
|
+
|
|
145
|
+
else:
|
|
146
|
+
|
|
147
|
+
@wraps(func)
|
|
148
|
+
def wrap_func(*args, **kwargs):
|
|
149
|
+
t1 = time.time()
|
|
150
|
+
result = func(*args, **kwargs)
|
|
151
|
+
t2 = time.time()
|
|
152
|
+
if log:
|
|
153
|
+
logger.info(
|
|
154
|
+
f"Function {func.__name__!r} executed in {(t2 - t1):.2f}s"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
return wrap_func
|
|
160
|
+
|
|
161
|
+
return decorator
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from pjdev_sn_sdk.models import Config
|
|
5
|
+
|
|
6
|
+
__ctx = {}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_config() -> Config:
|
|
10
|
+
return __ctx["config"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def init(env_path: Optional[Path] = None) -> None:
|
|
14
|
+
Config.model_config.update(env_file=env_path)
|
|
15
|
+
__ctx["config"] = Config()
|
|
16
|
+
|
|
17
|
+
if __ctx["config"].output_path is None:
|
|
18
|
+
__ctx["config"].output_path = env_path / "output"
|
|
19
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from loguru import logger
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def extract_control(ap: Optional[str]) -> Optional[str]:
|
|
8
|
+
"""
|
|
9
|
+
Extract the NIST 800-53 control_id from an assessment procedure (ap) value.
|
|
10
|
+
|
|
11
|
+
What it does:
|
|
12
|
+
- Accepts a wide variety of AP formats (case-insensitive).
|
|
13
|
+
- Removes [r5] revision markers (wherever they appear).
|
|
14
|
+
- Parses the control family (e.g., AC) and control number (e.g., 2), removing leading zeros.
|
|
15
|
+
- Captures only the immediate numeric enhancement in parentheses (e.g., (3)).
|
|
16
|
+
- Ignores objective letters like (a), (b), roman numerals, dot suffixes like .01, and other notes.
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
- "AC-2(3)(a)" -> "AC-02(03)"
|
|
20
|
+
- "[r5] ac-02 (03)" -> "AC-02(03)"
|
|
21
|
+
- "PM-16 (01) (b)" -> "PM-16(01)"
|
|
22
|
+
- "RA-5(5)(A)(1)" -> "RA-05(05)"
|
|
23
|
+
- "SC-7 (12).01(a)" -> "SC-07(12)"
|
|
24
|
+
- "AU-3" -> "AU-03"
|
|
25
|
+
- "invalid" -> None
|
|
26
|
+
|
|
27
|
+
:param ap: The assessment procedure string.
|
|
28
|
+
:return: The related control_id (e.g., 'AC-2' or 'AC-2(3)') or None if it can't be parsed.
|
|
29
|
+
"""
|
|
30
|
+
if not ap:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
s = ap.strip()
|
|
34
|
+
if not s:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
# Remove revision markers like [r5] anywhere in the string (case-insensitive).
|
|
38
|
+
s = re.sub(r"\[\s*r?\s*5\s*\]", "", s, flags=re.IGNORECASE)
|
|
39
|
+
|
|
40
|
+
# Match the base control family and number (e.g., AC-2, CM-06, etc.)
|
|
41
|
+
# NOTE: Use a negative lookahead to avoid requiring a word boundary after the number.
|
|
42
|
+
# This allows inputs like "CA-05b" to match the base "CA-05" while excluding trailing digits.
|
|
43
|
+
base_match = re.search(r"(?i)\b([A-Z]{2})\s*-\s*0*([0-9]{1,3})(?!\d)", s)
|
|
44
|
+
|
|
45
|
+
if not base_match:
|
|
46
|
+
logger.debug(f"Could not find control family and number in AP: {ap}")
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
family = base_match.group(1).upper()
|
|
50
|
+
number = str(int(base_match.group(2))) # strip any leading zeros by int conversion
|
|
51
|
+
control_id = f"{family}-{'0' if len(number) == 1 else ''}{number}"
|
|
52
|
+
|
|
53
|
+
# Starting right after the base match, capture immediate numeric parentheses like (03) -> (3).
|
|
54
|
+
idx = base_match.end()
|
|
55
|
+
paren_token = re.compile(r"\s*\(\s*([^)]+)\s*\)")
|
|
56
|
+
|
|
57
|
+
while True:
|
|
58
|
+
m = paren_token.match(s, idx)
|
|
59
|
+
if not m:
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
token = m.group(1)
|
|
63
|
+
|
|
64
|
+
# Only accept purely numeric tokens as part of the control identifier.
|
|
65
|
+
# Stop at the first non-numeric token (e.g., letters like (a), roman numerals, etc.).
|
|
66
|
+
if re.fullmatch(r"0*\d+", token):
|
|
67
|
+
sub_number = f"{int(token)}"
|
|
68
|
+
control_id += f"({'0' if len(sub_number) == 1 else ''}{sub_number})" # normalize 0-padded numbers
|
|
69
|
+
idx = m.end()
|
|
70
|
+
# Continue in case there is another numeric enhancement directly chained.
|
|
71
|
+
continue
|
|
72
|
+
else:
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
return control_id
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def strip_leading_zeros_after_dash_or_dot(s: str) -> str:
|
|
79
|
+
return re.sub(r"(?<=[-.(])0+(?=\d)", "", s)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def extract_control_requirement(ap: Optional[str]) -> Optional[str]:
|
|
83
|
+
if not ap:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
control_id = extract_control(ap=ap)
|
|
87
|
+
if not control_id:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
sn_control_id = strip_leading_zeros_after_dash_or_dot(control_id)
|
|
91
|
+
stripped_ap = strip_leading_zeros_after_dash_or_dot(ap).upper()
|
|
92
|
+
|
|
93
|
+
part = stripped_ap.removeprefix(sn_control_id).removeprefix(".").split(".")[0]
|
|
94
|
+
|
|
95
|
+
if not part:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
return f"{sn_control_id}.{part.strip().lower()}"
|
pjdev_sn_sdk/models.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, computed_field, BeforeValidator, Field, ConfigDict
|
|
5
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Dict, List, Optional, Annotated
|
|
8
|
+
|
|
9
|
+
class ServiceNowEnvironment(enum.StrEnum):
|
|
10
|
+
PJ = "pj"
|
|
11
|
+
SubProd1 = "subprod1"
|
|
12
|
+
Dev = "dev"
|
|
13
|
+
Test = "test"
|
|
14
|
+
PreProd = "preprod"
|
|
15
|
+
Prod = "prod"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SnowTables(BaseModel):
|
|
19
|
+
auth_boundary_table_name: str = "sn_irm_cont_auth_authorization_boundary"
|
|
20
|
+
auth_pack_table_name: str = "sn_irm_cont_auth_pack"
|
|
21
|
+
info_type_definition_table_name: str = (
|
|
22
|
+
"sn_irm_cont_auth_information_type_definition"
|
|
23
|
+
)
|
|
24
|
+
info_type_table_name: str = "sn_irm_cont_auth_information_type"
|
|
25
|
+
baseline_control_table_name: str = "sn_irm_cont_auth_baseline_control_objective"
|
|
26
|
+
nist_control_table_name: str = "sn_grc_content"
|
|
27
|
+
control_table_name: str = "sn_compliance_control"
|
|
28
|
+
control_overlay_table_name: str = "sn_compliance_policy"
|
|
29
|
+
control_objective_table_name: str = "sn_compliance_policy_statement"
|
|
30
|
+
poam_issue_rating_table_name: str = "sn_grc_issue_rating"
|
|
31
|
+
poam_table_name: str = "sn_grc_issue"
|
|
32
|
+
poam_control_m2m_table_name: str = "sn_grc_m2m_issue_item"
|
|
33
|
+
auth_pack_poam_m2m_table_name: str = "sn_irm_cont_auth_m2m_issue_auth_pack"
|
|
34
|
+
milestone_table_name: str = "sn_irm_cont_auth_milestone_task"
|
|
35
|
+
cmdb_ci_table_name: str = "cmdb_ci"
|
|
36
|
+
cmdb_hardware: str = "cmdb_ci_hardware"
|
|
37
|
+
control_parts_table: str = "sn_compliance_policy_stmt_requirement"
|
|
38
|
+
control_objective_control_part_m2m_table: str = (
|
|
39
|
+
"sn_compliance_m2m_policy_stmt_policy_stmt_rqmt"
|
|
40
|
+
)
|
|
41
|
+
item_generation_action_event_queue_table: str = "sn_grc_item_generation_action_event_queue"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
snow_tables = SnowTables()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Config(BaseSettings):
|
|
48
|
+
scripted_api_base_path: str = "/api/sn_irm_cont_auth/auth_pack"
|
|
49
|
+
http_retry_max_count = 1000
|
|
50
|
+
environment: ServiceNowEnvironment = ServiceNowEnvironment.SubProd1
|
|
51
|
+
api_key: Optional[str] = None
|
|
52
|
+
api_username: Optional[str] = None
|
|
53
|
+
api_password: Optional[str] = None
|
|
54
|
+
output_path: Optional[Path] = None
|
|
55
|
+
|
|
56
|
+
model_config = SettingsConfigDict(
|
|
57
|
+
env_prefix="SN_",
|
|
58
|
+
case_sensitive=False,
|
|
59
|
+
extra="ignore",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@computed_field
|
|
63
|
+
def sn_api_url(self) -> str:
|
|
64
|
+
match self.environment:
|
|
65
|
+
case ServiceNowEnvironment.PreProd:
|
|
66
|
+
return "https://vaoitpreprod.servicenowservices.com"
|
|
67
|
+
case ServiceNowEnvironment.SubProd1:
|
|
68
|
+
return "https://vasubprod1.servicenowservices.com"
|
|
69
|
+
case ServiceNowEnvironment.Prod:
|
|
70
|
+
return "https://yourit.va.gov"
|
|
71
|
+
case ServiceNowEnvironment.Dev:
|
|
72
|
+
return "https://yourit-dev.va.gov"
|
|
73
|
+
case ServiceNowEnvironment.Test:
|
|
74
|
+
return "https://vaoittest.servicenowservices.com"
|
|
75
|
+
case ServiceNowEnvironment.PJ:
|
|
76
|
+
return "https://dev190624.service-now.com"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RMFStep(enum.Enum):
|
|
80
|
+
Prepare = 1
|
|
81
|
+
Categorize = 8
|
|
82
|
+
Select = 9
|
|
83
|
+
Implement = 10
|
|
84
|
+
Assess = 11
|
|
85
|
+
Authorize = 12
|
|
86
|
+
Monitor = 13
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SnowBase(BaseModel):
|
|
90
|
+
sys_id: str
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def get_fields(cls) -> List[str]:
|
|
94
|
+
fields = []
|
|
95
|
+
for k, fi in cls.model_fields.items():
|
|
96
|
+
if fi.alias:
|
|
97
|
+
fields.append(fi.alias)
|
|
98
|
+
else:
|
|
99
|
+
fields.append(k)
|
|
100
|
+
return fields
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def get_fields_query_param_value(cls) -> str:
|
|
104
|
+
return ",".join(cls.get_fields())
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def get_df_column_rename_map(cls) -> Dict[str, str]:
|
|
108
|
+
original_cols = cls.get_fields()
|
|
109
|
+
cols = [k for k in cls.model_fields.keys()]
|
|
110
|
+
return dict(zip(original_cols, cols))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class SnowAuthPack(SnowBase):
|
|
114
|
+
name: str
|
|
115
|
+
number: str
|
|
116
|
+
step: int
|
|
117
|
+
authorization_date: Optional[str] = None
|
|
118
|
+
next_authorization_date: Optional[str] = None
|
|
119
|
+
authorization_comments: Optional[str] = None
|
|
120
|
+
authorization_decision: Optional[str] = None
|
|
121
|
+
ongoing_authorization: Optional[str] = None
|
|
122
|
+
|
|
123
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True, strict=False)
|
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
from contextlib import asynccontextmanager
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import ssl
|
|
4
|
+
from typing import (
|
|
5
|
+
Any,
|
|
6
|
+
Callable,
|
|
7
|
+
Dict,
|
|
8
|
+
List,
|
|
9
|
+
Tuple,
|
|
10
|
+
Optional,
|
|
11
|
+
AsyncIterator,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from httpx import AsyncClient
|
|
15
|
+
from loguru import logger
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
import asyncio
|
|
19
|
+
|
|
20
|
+
from pjdev_sn_sdk.api_utilities import async_retry_http, log_server_addr, log_response_headers, log_request_headers
|
|
21
|
+
from pjdev_sn_sdk.config_service import get_config
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def check_for_202(r: httpx.Response) -> None:
|
|
25
|
+
if r.status_code == 202:
|
|
26
|
+
logger.warning(
|
|
27
|
+
f"202 status code recieved for {r.request.method.upper()} {r.url.path}"
|
|
28
|
+
)
|
|
29
|
+
await asyncio.sleep(5)
|
|
30
|
+
raise httpx.HTTPError("Too many requests")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SnApiKeyAuth(httpx.Auth):
|
|
34
|
+
def __init__(self, token):
|
|
35
|
+
self.token = token
|
|
36
|
+
|
|
37
|
+
def auth_flow(self, request):
|
|
38
|
+
request.headers["x-sn-apikey"] = self.token
|
|
39
|
+
yield request
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_http_client() -> httpx.AsyncClient:
|
|
43
|
+
config = get_config()
|
|
44
|
+
if not config.api_key:
|
|
45
|
+
if not config.api_username or not config.api_password:
|
|
46
|
+
raise ValueError("Must set username and password")
|
|
47
|
+
auth = httpx.BasicAuth(
|
|
48
|
+
username=config.api_username, password=config.api_password
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
auth = SnApiKeyAuth(config.api_key)
|
|
52
|
+
|
|
53
|
+
return httpx.AsyncClient(
|
|
54
|
+
base_url=config.sn_api_url,
|
|
55
|
+
auth=auth,
|
|
56
|
+
verify=ssl.create_default_context(),
|
|
57
|
+
timeout=None,
|
|
58
|
+
event_hooks={
|
|
59
|
+
"response": [log_server_addr, log_response_headers],
|
|
60
|
+
"request": [log_request_headers],
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@asynccontextmanager
|
|
66
|
+
async def http_client() -> AsyncIterator[httpx.AsyncClient]:
|
|
67
|
+
async with get_http_client() as _client:
|
|
68
|
+
yield _client
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@async_retry_http(
|
|
72
|
+
max_attempts=get_config().http_retry_max_count,
|
|
73
|
+
delay_seconds=1,
|
|
74
|
+
status_codes_to_ignore=[400, 401, 403, 405, 404],
|
|
75
|
+
)
|
|
76
|
+
async def add_table_row(
|
|
77
|
+
table_name: str,
|
|
78
|
+
payload: Dict[str, Any],
|
|
79
|
+
fields: Optional[str] = None,
|
|
80
|
+
client: Optional[AsyncClient] = None,
|
|
81
|
+
) -> Dict[str, Any]:
|
|
82
|
+
async def _exec(_client: AsyncClient) -> Dict[str, Any]:
|
|
83
|
+
params = {}
|
|
84
|
+
if fields:
|
|
85
|
+
params["sysparm_fields"] = fields
|
|
86
|
+
r = await _client.post(
|
|
87
|
+
f"/api/now/table/{table_name}", json=payload, params=params
|
|
88
|
+
)
|
|
89
|
+
r.raise_for_status()
|
|
90
|
+
await check_for_202(r=r)
|
|
91
|
+
|
|
92
|
+
json_data = r.json()
|
|
93
|
+
logger.info(f"Added row to {table_name}")
|
|
94
|
+
return json_data["result"]
|
|
95
|
+
|
|
96
|
+
if not client:
|
|
97
|
+
async with http_client() as _client:
|
|
98
|
+
return await _exec(_client)
|
|
99
|
+
else:
|
|
100
|
+
return await _exec(client)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@async_retry_http(
|
|
104
|
+
max_attempts=get_config().http_retry_max_count,
|
|
105
|
+
delay_seconds=1,
|
|
106
|
+
status_codes_to_ignore=[400, 401, 403, 405, 404],
|
|
107
|
+
)
|
|
108
|
+
async def delete_table_row(
|
|
109
|
+
table_name: str, sys_id: str, client: Optional[AsyncClient] = None
|
|
110
|
+
) -> None:
|
|
111
|
+
async def _exec(_client: AsyncClient) -> None:
|
|
112
|
+
r = await _client.delete(f"/api/now/table/{table_name}/{sys_id}")
|
|
113
|
+
if not r.is_success and r.status_code != 404:
|
|
114
|
+
r.raise_for_status()
|
|
115
|
+
await check_for_202(r=r)
|
|
116
|
+
if r.status_code != 404:
|
|
117
|
+
logger.info(f"Removed row {sys_id} from {table_name}")
|
|
118
|
+
|
|
119
|
+
if not client:
|
|
120
|
+
async with http_client() as _client:
|
|
121
|
+
await _exec(_client)
|
|
122
|
+
else:
|
|
123
|
+
await _exec(client)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def get_table_rows(
|
|
127
|
+
table_name: str,
|
|
128
|
+
query: Optional[str] = None,
|
|
129
|
+
fields: Optional[str] = None,
|
|
130
|
+
order_by_fields: Optional[List[str]] = None,
|
|
131
|
+
sysparm_limit: Optional[int] = None,
|
|
132
|
+
paginate: bool = False,
|
|
133
|
+
extra_parms: Optional[Dict] = None,
|
|
134
|
+
concurrency: Optional[int] = None,
|
|
135
|
+
process_page_data_func: Optional[Callable[[List[Any]], None]] = None,
|
|
136
|
+
get_display_values: Optional[bool] = False,
|
|
137
|
+
) -> Tuple[List[Any], int]:
|
|
138
|
+
url = f"/api/now/table/{table_name}"
|
|
139
|
+
params: Dict[str, Any] = {}
|
|
140
|
+
|
|
141
|
+
# Build query locally (avoid confusing in-place += on the function arg).
|
|
142
|
+
full_query = query
|
|
143
|
+
if order_by_fields:
|
|
144
|
+
order_by_query = "^".join(f"ORDERBY{f}" for f in order_by_fields)
|
|
145
|
+
full_query = f"{full_query}^{order_by_query}" if full_query else order_by_query
|
|
146
|
+
|
|
147
|
+
if full_query:
|
|
148
|
+
params["sysparm_query"] = full_query
|
|
149
|
+
|
|
150
|
+
if fields:
|
|
151
|
+
params["sysparm_fields"] = fields
|
|
152
|
+
|
|
153
|
+
if get_display_values:
|
|
154
|
+
params["sysparm_display_value"] = True
|
|
155
|
+
|
|
156
|
+
if extra_parms:
|
|
157
|
+
params = {**params, **extra_parms}
|
|
158
|
+
|
|
159
|
+
if sysparm_limit is not None:
|
|
160
|
+
page_size = sysparm_limit
|
|
161
|
+
elif paginate:
|
|
162
|
+
page_size = 5000
|
|
163
|
+
else:
|
|
164
|
+
page_size = 5000
|
|
165
|
+
|
|
166
|
+
params["sysparm_limit"] = page_size
|
|
167
|
+
|
|
168
|
+
async with http_client() as client:
|
|
169
|
+
first_result = await client.get(url, params=params)
|
|
170
|
+
first_result.raise_for_status()
|
|
171
|
+
await check_for_202(r=first_result)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
first_rows = first_result.json()["result"]
|
|
175
|
+
except Exception:
|
|
176
|
+
logger.exception(f"Unexpected response payload for table: {table_name}")
|
|
177
|
+
raise
|
|
178
|
+
|
|
179
|
+
total_count_str = first_result.headers.get("X-Total-Count")
|
|
180
|
+
if not total_count_str:
|
|
181
|
+
logger.warning(
|
|
182
|
+
f"X-Total-Count header was not present in request to table: {table_name}"
|
|
183
|
+
)
|
|
184
|
+
# Best-effort: if header is missing, keep count consistent with returned rows.
|
|
185
|
+
return first_rows, len(first_rows)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
total_count = int(total_count_str.strip())
|
|
189
|
+
except (ValueError, AttributeError):
|
|
190
|
+
logger.warning(
|
|
191
|
+
f"Could not parse X-Total-Count={total_count_str!r} for table: {table_name}"
|
|
192
|
+
)
|
|
193
|
+
return first_rows, len(first_rows)
|
|
194
|
+
|
|
195
|
+
if total_count <= 0:
|
|
196
|
+
return [], 0
|
|
197
|
+
|
|
198
|
+
if not paginate:
|
|
199
|
+
return first_rows, total_count
|
|
200
|
+
|
|
201
|
+
# Paginate remaining pages.
|
|
202
|
+
concurrency_value = concurrency or 1
|
|
203
|
+
|
|
204
|
+
sem = asyncio.Semaphore(value=concurrency_value)
|
|
205
|
+
logger.info(
|
|
206
|
+
f"total records for {table_name}: {total_count} (page_size={page_size}, concurrency={concurrency_value})"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if process_page_data_func:
|
|
210
|
+
process_page_data_func(first_rows)
|
|
211
|
+
logger.info("Finished processing data for initial page")
|
|
212
|
+
|
|
213
|
+
@async_retry_http(
|
|
214
|
+
max_attempts=get_config().http_retry_max_count,
|
|
215
|
+
delay_seconds=1,
|
|
216
|
+
status_codes_to_ignore=[400, 401, 403, 405, 404],
|
|
217
|
+
)
|
|
218
|
+
async def __run(offset: int) -> List[Any]:
|
|
219
|
+
async with sem:
|
|
220
|
+
local_params = {**params, "sysparm_offset": offset}
|
|
221
|
+
logger.info(f"Offset: {offset}")
|
|
222
|
+
async with get_http_client() as _client:
|
|
223
|
+
r = await _client.get(url, params=local_params)
|
|
224
|
+
r.raise_for_status()
|
|
225
|
+
await check_for_202(r=r)
|
|
226
|
+
offset_data = r.json()["result"]
|
|
227
|
+
|
|
228
|
+
if total_count - offset >= page_size and len(offset_data) < page_size:
|
|
229
|
+
raise Exception(
|
|
230
|
+
f"Did not receive entire payload: {len(offset_data)} rows"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if process_page_data_func:
|
|
234
|
+
process_page_data_func(offset_data)
|
|
235
|
+
logger.info(f"Finished processing data for offset {offset} ")
|
|
236
|
+
|
|
237
|
+
return offset_data
|
|
238
|
+
|
|
239
|
+
# We already fetched the first page (offset 0). Start from the next offset.
|
|
240
|
+
offsets = range(page_size, total_count, page_size)
|
|
241
|
+
results_list = await asyncio.gather(*(__run(off) for off in offsets))
|
|
242
|
+
|
|
243
|
+
all_rows = [*first_rows, *(item for sub in results_list for item in sub)]
|
|
244
|
+
return all_rows, total_count
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@async_retry_http(
|
|
248
|
+
max_attempts=get_config().http_retry_max_count,
|
|
249
|
+
delay_seconds=1,
|
|
250
|
+
status_codes_to_ignore=[400, 401, 403, 405, 404],
|
|
251
|
+
)
|
|
252
|
+
async def patch_table_row(
|
|
253
|
+
table_name: str,
|
|
254
|
+
sys_id: str,
|
|
255
|
+
payload: Dict[str, Any],
|
|
256
|
+
fields: Optional[List[str]] = None,
|
|
257
|
+
client: Optional[AsyncClient] = None,
|
|
258
|
+
) -> Dict[str, Any]:
|
|
259
|
+
async def _exec(_client: AsyncClient) -> Dict[str, Any]:
|
|
260
|
+
params = {}
|
|
261
|
+
|
|
262
|
+
if fields:
|
|
263
|
+
params["sysparm_fields"] = ",".join(fields)
|
|
264
|
+
|
|
265
|
+
r = await _client.patch(
|
|
266
|
+
f"/api/now/table/{table_name}/{sys_id}", json=payload, params=params
|
|
267
|
+
)
|
|
268
|
+
r.raise_for_status()
|
|
269
|
+
await check_for_202(r=r)
|
|
270
|
+
|
|
271
|
+
logger.info(f"Updated row to {table_name}")
|
|
272
|
+
|
|
273
|
+
return r.json()["result"]
|
|
274
|
+
|
|
275
|
+
if not client:
|
|
276
|
+
async with http_client() as _client:
|
|
277
|
+
return await _exec(_client)
|
|
278
|
+
else:
|
|
279
|
+
return await _exec(client)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@async_retry_http(
|
|
283
|
+
max_attempts=get_config().http_retry_max_count,
|
|
284
|
+
delay_seconds=1,
|
|
285
|
+
status_codes_to_ignore=[400, 401, 403, 405, 404],
|
|
286
|
+
)
|
|
287
|
+
async def move_auth_package_to_step(
|
|
288
|
+
target_step: int, auth_package_sys_id: str, client: Optional[AsyncClient] = None
|
|
289
|
+
) -> Dict:
|
|
290
|
+
scripted_api_base_path = get_config().scripted_api_base_path
|
|
291
|
+
|
|
292
|
+
async def _exec(_client: AsyncClient) -> Dict:
|
|
293
|
+
r = await _client.patch(
|
|
294
|
+
f"{scripted_api_base_path}/{auth_package_sys_id}",
|
|
295
|
+
json={"target_step": target_step},
|
|
296
|
+
)
|
|
297
|
+
r.raise_for_status()
|
|
298
|
+
await check_for_202(r=r)
|
|
299
|
+
return r.json()["result"]
|
|
300
|
+
|
|
301
|
+
if not client:
|
|
302
|
+
async with http_client() as _client:
|
|
303
|
+
return await _exec(_client)
|
|
304
|
+
else:
|
|
305
|
+
return await _exec(client)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def update_poam(
|
|
309
|
+
auth_package_sys_id: str,
|
|
310
|
+
poam_sys_id: str,
|
|
311
|
+
payload: Dict[str, Any],
|
|
312
|
+
client: Optional[AsyncClient] = None,
|
|
313
|
+
) -> Dict:
|
|
314
|
+
config = get_config()
|
|
315
|
+
|
|
316
|
+
async def _exec(_client: AsyncClient) -> Dict:
|
|
317
|
+
r = await _client.patch(
|
|
318
|
+
f"{config.scripted_api_base_path}/{auth_package_sys_id}/poam/{poam_sys_id}",
|
|
319
|
+
json=payload,
|
|
320
|
+
)
|
|
321
|
+
r.raise_for_status()
|
|
322
|
+
await check_for_202(r=r)
|
|
323
|
+
return r.json()["result"]
|
|
324
|
+
|
|
325
|
+
if not client:
|
|
326
|
+
async with http_client() as _client:
|
|
327
|
+
return await _exec(_client)
|
|
328
|
+
else:
|
|
329
|
+
return await _exec(client)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@async_retry_http(
|
|
333
|
+
max_attempts=get_config().http_retry_max_count,
|
|
334
|
+
delay_seconds=1,
|
|
335
|
+
status_codes_to_ignore=[400, 401, 403, 405, 404],
|
|
336
|
+
)
|
|
337
|
+
async def create_poam(
|
|
338
|
+
auth_package_sys_id: str, payload: Dict, client: Optional[AsyncClient] = None
|
|
339
|
+
) -> Dict:
|
|
340
|
+
config = get_config()
|
|
341
|
+
|
|
342
|
+
async def _exec(_client: AsyncClient) -> Dict:
|
|
343
|
+
r = await _client.post(
|
|
344
|
+
f"{config.scripted_api_base_path}/{auth_package_sys_id}/poam",
|
|
345
|
+
json=payload,
|
|
346
|
+
)
|
|
347
|
+
r.raise_for_status()
|
|
348
|
+
await check_for_202(r=r)
|
|
349
|
+
return r.json()["result"]
|
|
350
|
+
|
|
351
|
+
if not client:
|
|
352
|
+
async with http_client() as _client:
|
|
353
|
+
return await _exec(_client)
|
|
354
|
+
else:
|
|
355
|
+
return await _exec(client)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@async_retry_http(
|
|
359
|
+
max_attempts=get_config().http_retry_max_count,
|
|
360
|
+
delay_seconds=1,
|
|
361
|
+
status_codes_to_ignore=[400, 401, 403, 405, 404],
|
|
362
|
+
)
|
|
363
|
+
async def update_auth_package_authorize_info(
|
|
364
|
+
auth_package_sys_id: str,
|
|
365
|
+
payload: Dict[str, Any],
|
|
366
|
+
client: Optional[AsyncClient] = None,
|
|
367
|
+
) -> Dict:
|
|
368
|
+
config = get_config()
|
|
369
|
+
|
|
370
|
+
async def _exec(_client: AsyncClient) -> Dict:
|
|
371
|
+
r = await _client.patch(
|
|
372
|
+
f"{config.scripted_api_base_path}/{auth_package_sys_id}/authorize",
|
|
373
|
+
json=payload,
|
|
374
|
+
)
|
|
375
|
+
r.raise_for_status()
|
|
376
|
+
await check_for_202(r=r)
|
|
377
|
+
return r.json()["result"]
|
|
378
|
+
|
|
379
|
+
if not client:
|
|
380
|
+
async with http_client() as _client:
|
|
381
|
+
return await _exec(_client)
|
|
382
|
+
else:
|
|
383
|
+
return await _exec(client)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@async_retry_http(
|
|
387
|
+
max_attempts=get_config().http_retry_max_count,
|
|
388
|
+
delay_seconds=1,
|
|
389
|
+
status_codes_to_ignore=[400, 401, 403, 405, 404],
|
|
390
|
+
)
|
|
391
|
+
async def update_control_status(
|
|
392
|
+
auth_pack_sys_id: str,
|
|
393
|
+
compliance_control_sys_id: str,
|
|
394
|
+
payload: Dict[str, Any],
|
|
395
|
+
client: Optional[AsyncClient] = None,
|
|
396
|
+
) -> Dict:
|
|
397
|
+
config = get_config()
|
|
398
|
+
|
|
399
|
+
async def _exec(_client: AsyncClient) -> Dict:
|
|
400
|
+
r = await _client.patch(
|
|
401
|
+
f"{config.scripted_api_base_path}/{auth_pack_sys_id}/control/{compliance_control_sys_id}",
|
|
402
|
+
json=payload,
|
|
403
|
+
)
|
|
404
|
+
r.raise_for_status()
|
|
405
|
+
await check_for_202(r=r)
|
|
406
|
+
return r.json()["result"]
|
|
407
|
+
|
|
408
|
+
if not client:
|
|
409
|
+
async with http_client() as _client:
|
|
410
|
+
return await _exec(_client)
|
|
411
|
+
else:
|
|
412
|
+
return await _exec(client)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
__sem = asyncio.Semaphore(10)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@async_retry_http(
|
|
419
|
+
max_attempts=get_config().http_retry_max_count,
|
|
420
|
+
delay_seconds=1,
|
|
421
|
+
status_codes_to_ignore=[400, 401, 403, 404],
|
|
422
|
+
)
|
|
423
|
+
async def associate_file_reference_to_compliance_control(
|
|
424
|
+
compliance_control_sys_id: str, text: str, client: Optional[AsyncClient] = None
|
|
425
|
+
) -> Dict[str, Any]:
|
|
426
|
+
config = get_config()
|
|
427
|
+
|
|
428
|
+
async def _exec(_client: AsyncClient) -> Dict[str, Any]:
|
|
429
|
+
payload = {"u_associated_artifacts": text}
|
|
430
|
+
|
|
431
|
+
async with __sem:
|
|
432
|
+
r = await _client.patch(
|
|
433
|
+
f"{config.scripted_api_base_path}/control_artifact_association/{compliance_control_sys_id}",
|
|
434
|
+
json=payload,
|
|
435
|
+
)
|
|
436
|
+
r.raise_for_status()
|
|
437
|
+
await check_for_202(r=r)
|
|
438
|
+
updated_control = r.json()["result"]
|
|
439
|
+
|
|
440
|
+
return updated_control["current_values"]
|
|
441
|
+
|
|
442
|
+
if not client:
|
|
443
|
+
async with http_client() as _client:
|
|
444
|
+
return await _exec(_client)
|
|
445
|
+
else:
|
|
446
|
+
return await _exec(client)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@async_retry_http(
|
|
450
|
+
max_attempts=get_config().http_retry_max_count,
|
|
451
|
+
delay_seconds=1,
|
|
452
|
+
status_codes_to_ignore=[400, 401, 403, 404],
|
|
453
|
+
)
|
|
454
|
+
async def get_attachment_sys_id(
|
|
455
|
+
table_name: str,
|
|
456
|
+
table_row_sys_id: str,
|
|
457
|
+
snow_filename: str,
|
|
458
|
+
client: Optional[AsyncClient] = None,
|
|
459
|
+
) -> Optional[str]:
|
|
460
|
+
async def _exec(_client: AsyncClient) -> Optional[str]:
|
|
461
|
+
query_params = {
|
|
462
|
+
"table_name": table_name,
|
|
463
|
+
"table_sys_id": table_row_sys_id,
|
|
464
|
+
"file_name": snow_filename,
|
|
465
|
+
}
|
|
466
|
+
async with __sem:
|
|
467
|
+
r = await _client.get(
|
|
468
|
+
"api/now/attachment",
|
|
469
|
+
params=query_params,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
r.raise_for_status()
|
|
473
|
+
await check_for_202(r=r)
|
|
474
|
+
json_data = r.json()["result"]
|
|
475
|
+
if len(json_data) > 0:
|
|
476
|
+
return json_data[0].get("sys_id")
|
|
477
|
+
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
if not client:
|
|
481
|
+
async with http_client() as _client:
|
|
482
|
+
return await _exec(_client)
|
|
483
|
+
else:
|
|
484
|
+
return await _exec(client)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@async_retry_http(
|
|
488
|
+
max_attempts=get_config().http_retry_max_count,
|
|
489
|
+
delay_seconds=1,
|
|
490
|
+
status_codes_to_ignore=[400, 401, 403, 404],
|
|
491
|
+
)
|
|
492
|
+
async def get_attachments(
|
|
493
|
+
table_name: str,
|
|
494
|
+
table_row_sys_id: str,
|
|
495
|
+
client: Optional[AsyncClient] = None,
|
|
496
|
+
) -> List[Dict[str, Any]]:
|
|
497
|
+
async def _exec(_client: AsyncClient) -> List[Dict[str, Any]]:
|
|
498
|
+
query = f'table_name="{table_name}"^table_sys_id="{table_row_sys_id}"'
|
|
499
|
+
async with __sem:
|
|
500
|
+
r = await _client.get(
|
|
501
|
+
"api/now/attachment",
|
|
502
|
+
params=f"sysparm_query={query}",
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
r.raise_for_status()
|
|
506
|
+
await check_for_202(r=r)
|
|
507
|
+
return r.json()["result"]
|
|
508
|
+
|
|
509
|
+
if not client:
|
|
510
|
+
async with http_client() as _client:
|
|
511
|
+
return await _exec(_client)
|
|
512
|
+
else:
|
|
513
|
+
return await _exec(client)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
@async_retry_http(
|
|
517
|
+
max_attempts=get_config().get_config().http_retry_max_count,
|
|
518
|
+
delay_seconds=1,
|
|
519
|
+
status_codes_to_ignore=[400, 401, 403, 404],
|
|
520
|
+
)
|
|
521
|
+
async def delete_attachment(
|
|
522
|
+
attachment_id: str, client: Optional[AsyncClient] = None
|
|
523
|
+
) -> None:
|
|
524
|
+
async def _exec(_client: AsyncClient) -> None:
|
|
525
|
+
r = await _client.delete(f"api/now/attachment/{attachment_id}")
|
|
526
|
+
|
|
527
|
+
r.raise_for_status()
|
|
528
|
+
await check_for_202(r=r)
|
|
529
|
+
|
|
530
|
+
if not client:
|
|
531
|
+
async with http_client() as _client:
|
|
532
|
+
await _exec(_client)
|
|
533
|
+
else:
|
|
534
|
+
await _exec(client)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
@async_retry_http(
|
|
538
|
+
max_attempts=3,
|
|
539
|
+
delay_seconds=1,
|
|
540
|
+
status_codes_to_ignore=[400, 401, 403, 404],
|
|
541
|
+
)
|
|
542
|
+
async def upload_artifact(
|
|
543
|
+
table_name: str,
|
|
544
|
+
table_row_sys_id: str,
|
|
545
|
+
filepath: Path,
|
|
546
|
+
snow_filename: str,
|
|
547
|
+
client: Optional[AsyncClient] = None,
|
|
548
|
+
) -> Tuple[str, str]:
|
|
549
|
+
async def _exec(_client: AsyncClient) -> Tuple[str, str]:
|
|
550
|
+
generic_type = "application/octet-stream"
|
|
551
|
+
headers = {"Content-Type": generic_type, "Accept": "application/json"}
|
|
552
|
+
query_params = {
|
|
553
|
+
"table_name": table_name,
|
|
554
|
+
"table_sys_id": table_row_sys_id,
|
|
555
|
+
"file_name": snow_filename,
|
|
556
|
+
}
|
|
557
|
+
async with __sem:
|
|
558
|
+
with open(filepath, mode="rb") as file:
|
|
559
|
+
r = await _client.post(
|
|
560
|
+
"api/now/attachment/file",
|
|
561
|
+
headers=headers,
|
|
562
|
+
params=query_params,
|
|
563
|
+
content=file.read(),
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
if not r.is_success:
|
|
567
|
+
logger.warning(r.text)
|
|
568
|
+
|
|
569
|
+
r.raise_for_status()
|
|
570
|
+
await check_for_202(r=r)
|
|
571
|
+
|
|
572
|
+
json_data = r.json()["result"]
|
|
573
|
+
filename = json_data.get("file_name")
|
|
574
|
+
attachment_id = json_data.get("sys_id")
|
|
575
|
+
|
|
576
|
+
return f"{attachment_id}", filename
|
|
577
|
+
|
|
578
|
+
if not client:
|
|
579
|
+
async with http_client() as _client:
|
|
580
|
+
return await _exec(_client)
|
|
581
|
+
else:
|
|
582
|
+
return await _exec(client)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@async_retry_http(
|
|
586
|
+
max_attempts=get_config().http_retry_max_count,
|
|
587
|
+
delay_seconds=1,
|
|
588
|
+
status_codes_to_ignore=[400, 401, 403, 404],
|
|
589
|
+
)
|
|
590
|
+
async def download_artifact(
|
|
591
|
+
attachment_id: str, filename: str, client: Optional[AsyncClient] = None
|
|
592
|
+
) -> Tuple[str, str]:
|
|
593
|
+
config = get_config()
|
|
594
|
+
|
|
595
|
+
async def _exec(_client: AsyncClient) -> Tuple[str, str]:
|
|
596
|
+
r = await _client.get(f"api/now/attachment/{attachment_id}/file")
|
|
597
|
+
|
|
598
|
+
if not r.is_success:
|
|
599
|
+
logger.warning(r.text)
|
|
600
|
+
|
|
601
|
+
r.raise_for_status()
|
|
602
|
+
await check_for_202(r=r)
|
|
603
|
+
|
|
604
|
+
with open(config.output_path / filename, mode="wb") as file:
|
|
605
|
+
for chunk in r.iter_bytes():
|
|
606
|
+
file.write(chunk)
|
|
607
|
+
|
|
608
|
+
return config.output_path / filename
|
|
609
|
+
|
|
610
|
+
if not client:
|
|
611
|
+
async with http_client() as _client:
|
|
612
|
+
return await _exec(_client)
|
|
613
|
+
else:
|
|
614
|
+
return await _exec(client)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def get_attachment_url(attachment_id: str) -> str:
|
|
618
|
+
config = get_config()
|
|
619
|
+
return f"{config.sn_api_url}/nav_to.do?uri=sys_attachment.do?sys_id={attachment_id}"
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
if __name__ == "__main__":
|
|
623
|
+
async def __test() -> None:
|
|
624
|
+
_data = await get_table_rows("sn_irm_cont_auth_pack", sysparm_limit=1)
|
|
625
|
+
logger.info(_data)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
asyncio.run(__test())
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import datetime, UTC
|
|
3
|
+
from typing import List, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from pjdev_sn_sdk import sn_api_service, control_parsing_service
|
|
8
|
+
from pjdev_sn_sdk.config_service import get_config
|
|
9
|
+
from pjdev_sn_sdk.models import snow_tables, SnowAuthPack
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def watch_for_queued_task_to_complete(auth_pack_sys_id: str) -> None:
|
|
13
|
+
table_name = snow_tables.item_generation_action_event_queue_table
|
|
14
|
+
fields = ["sys_id", "state", "source.name", "action", "table", "sys_created_on"]
|
|
15
|
+
current_state = None
|
|
16
|
+
max_retries = 1080 # 5400 total seconds (1.5 hours) in 5-second intervals
|
|
17
|
+
retry_count = 0
|
|
18
|
+
utc_timestamp = datetime.now(tz=UTC)
|
|
19
|
+
while current_state != "processed":
|
|
20
|
+
if retry_count >= max_retries:
|
|
21
|
+
raise Exception(
|
|
22
|
+
f"Timed out waiting for task to complete for {auth_pack_sys_id}"
|
|
23
|
+
)
|
|
24
|
+
retry_count += 1
|
|
25
|
+
await asyncio.sleep(5)
|
|
26
|
+
response, count = await sn_api_service.get_table_rows(
|
|
27
|
+
table_name=table_name,
|
|
28
|
+
query=f"source={auth_pack_sys_id}^ORDERBYDESCsys_created_on",
|
|
29
|
+
fields=",".join(fields),
|
|
30
|
+
)
|
|
31
|
+
if len(response) == 0:
|
|
32
|
+
continue
|
|
33
|
+
data = response[0]
|
|
34
|
+
created_on_str = data["sys_created_on"] + " +0000"
|
|
35
|
+
created_on_dt = datetime.strptime(created_on_str, "%Y-%m-%d %H:%M:%S %z")
|
|
36
|
+
current_state = data["state"]
|
|
37
|
+
# ensure we are not looking at a job that was processed by a previous run
|
|
38
|
+
if (
|
|
39
|
+
abs((utc_timestamp - created_on_dt).total_seconds()) > 60 * 3
|
|
40
|
+
and current_state == "processed"
|
|
41
|
+
):
|
|
42
|
+
current_state = ""
|
|
43
|
+
utc_timestamp = datetime.now(tz=UTC)
|
|
44
|
+
continue
|
|
45
|
+
auth_pack_name = data["source.name"]
|
|
46
|
+
logger.info(f"State of task for {auth_pack_name}: {current_state}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def mark_control_ids_as_common(
|
|
50
|
+
auth_pack_sys_id: str, control_ids: List[str]
|
|
51
|
+
) -> None:
|
|
52
|
+
baseline_controls, baseline_count = await sn_api_service.get_table_rows(
|
|
53
|
+
table_name=snow_tables.baseline_control_table_name,
|
|
54
|
+
query=f"authorization_package.sys_id={auth_pack_sys_id}",
|
|
55
|
+
fields="sys_id,control_objective.reference,common_control,implementation_status,inherited_from,justification",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
for control_id in control_ids:
|
|
59
|
+
b_control = next(
|
|
60
|
+
(
|
|
61
|
+
b
|
|
62
|
+
for b in baseline_controls
|
|
63
|
+
if control_parsing_service.extract_control(
|
|
64
|
+
b["control_objective.reference"]
|
|
65
|
+
)
|
|
66
|
+
== control_parsing_service.extract_control(control_id)
|
|
67
|
+
),
|
|
68
|
+
None,
|
|
69
|
+
)
|
|
70
|
+
if not b_control:
|
|
71
|
+
logger.warning(f"{control_id} not found in baseline")
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
updated_sn_row = await sn_api_service.patch_table_row(
|
|
75
|
+
table_name=snow_tables.baseline_control_table_name,
|
|
76
|
+
payload={
|
|
77
|
+
"implementation_status": "need_to_implement",
|
|
78
|
+
"common_control": True,
|
|
79
|
+
},
|
|
80
|
+
fields=[
|
|
81
|
+
"implementation_status",
|
|
82
|
+
"sys_id",
|
|
83
|
+
"common_control",
|
|
84
|
+
"control_objective.reference",
|
|
85
|
+
],
|
|
86
|
+
sys_id=b_control["sys_id"],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if (
|
|
90
|
+
updated_sn_row["implementation_status"] != "need_to_implement"
|
|
91
|
+
or updated_sn_row["common_control"] != "true"
|
|
92
|
+
):
|
|
93
|
+
logger.error(f"{control_id} was not marked as n/a")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def associate_controls_to_poam(
|
|
97
|
+
auth_pack_sys_id: str,
|
|
98
|
+
poam_sys_id: str,
|
|
99
|
+
display_poam_id: str,
|
|
100
|
+
control_ids: List[str],
|
|
101
|
+
) -> None:
|
|
102
|
+
controls, count = await sn_api_service.get_table_rows(
|
|
103
|
+
table_name=snow_tables.control_table_name,
|
|
104
|
+
paginate=True,
|
|
105
|
+
fields="sys_id,content.reference",
|
|
106
|
+
query=f"authorization_package.sys_id={auth_pack_sys_id}",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
for control_id in control_ids:
|
|
110
|
+
sn_c = next(
|
|
111
|
+
(
|
|
112
|
+
c
|
|
113
|
+
for c in controls
|
|
114
|
+
if c["content.reference"].lower() == control_id.lower()
|
|
115
|
+
),
|
|
116
|
+
None,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if not sn_c:
|
|
120
|
+
logger.warning(
|
|
121
|
+
f"Could not find control {control_id} in auth pack {auth_pack_sys_id}"
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
logger.info(
|
|
126
|
+
f"Associating control {control_id} to poam {display_poam_id} ({poam_sys_id})"
|
|
127
|
+
)
|
|
128
|
+
payload = {"sn_grc_issue": poam_sys_id, "sn_grc_item": sn_c["sys_id"]}
|
|
129
|
+
existing_link, link_count = await sn_api_service.get_table_rows(
|
|
130
|
+
table_name=snow_tables.poam_control_m2m_table_name,
|
|
131
|
+
query=f"sn_grc_issue.sys_id={poam_sys_id}^sn_grc_item.sys_id={sn_c['sys_id']}",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if link_count == 0:
|
|
135
|
+
await sn_api_service.add_table_row(
|
|
136
|
+
table_name=snow_tables.poam_control_m2m_table_name,
|
|
137
|
+
payload=payload,
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
logger.info(
|
|
141
|
+
f"Poam Control Link already exists for {display_poam_id} - {control_id}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
logger.info(f"Finished associating controls for {display_poam_id}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def save_existing_auth_pack_data(sn_auth_pack: Dict) -> SnowAuthPack:
|
|
148
|
+
auth_pack = SnowAuthPack.model_validate(sn_auth_pack)
|
|
149
|
+
config = get_config()
|
|
150
|
+
|
|
151
|
+
with open(
|
|
152
|
+
config.output_path / f"existing_data_{auth_pack.number}.json",
|
|
153
|
+
"w",
|
|
154
|
+
encoding="utf-8",
|
|
155
|
+
) as f:
|
|
156
|
+
f.write(auth_pack.model_dump_json(indent=2))
|
|
157
|
+
|
|
158
|
+
return auth_pack
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def read_existing_auth_pack_data(sn_auth_pack_number: str) -> Optional[SnowAuthPack]:
|
|
162
|
+
config = get_config()
|
|
163
|
+
filepath = config.output_path / f"existing_data_{sn_auth_pack_number}.json"
|
|
164
|
+
if not filepath.exists():
|
|
165
|
+
return None
|
|
166
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
167
|
+
json_str = f.read()
|
|
168
|
+
|
|
169
|
+
auth_pack = SnowAuthPack.model_validate_json(json_str)
|
|
170
|
+
|
|
171
|
+
return auth_pack
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
ap_number = "testing"
|
|
176
|
+
test = {
|
|
177
|
+
"sys_id": "test sys id",
|
|
178
|
+
"name": "test",
|
|
179
|
+
"number": ap_number,
|
|
180
|
+
"step": 9,
|
|
181
|
+
"authorization_comments": "auth_pack.authorization_comments",
|
|
182
|
+
"next_authorization_date": "auth_pack.next_authorization_date",
|
|
183
|
+
"authorization_date": "auth_pack.authorization_date",
|
|
184
|
+
"authorization_decision": "auth_pack.authorization_decision",
|
|
185
|
+
"ongoing_authorization": "auth_pack.ongoing_authorization",
|
|
186
|
+
}
|
|
187
|
+
save_existing_auth_pack_data(test)
|
|
188
|
+
print(read_existing_auth_pack_data(ap_number))
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pjdev-sn-sdk
|
|
3
|
+
Version: 4.6.4
|
|
4
|
+
Project-URL: Documentation, https://gitlab.purplejay.io/keystone/python/-/tree/main/pjdev-pydantic/README.md
|
|
5
|
+
Project-URL: Issues, https://gitlab.purplejay.io/keystone/python/-/issues
|
|
6
|
+
Project-URL: Source, https://gitlab.purplejay.io/keystone/python
|
|
7
|
+
Author-email: Purple Jay LLC <developers@purplejay.io>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE.txt
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
14
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: httpx
|
|
17
|
+
Requires-Dist: loguru
|
|
18
|
+
Requires-Dist: pydantic-settings>=2.13.1
|
|
19
|
+
Requires-Dist: pydantic>=2.12.5
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: coverage; extra == 'test'
|
|
24
|
+
Requires-Dist: pytest; extra == 'test'
|
|
25
|
+
Requires-Dist: pytz; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# pjdev-sn-sdk
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/pjdev-sn-sdk)
|
|
31
|
+
[](https://pypi.org/project/pjdev-sn-sdk)
|
|
32
|
+
|
|
33
|
+
-----
|
|
34
|
+
|
|
35
|
+
## Table of Contents
|
|
36
|
+
|
|
37
|
+
- [Installation](#installation)
|
|
38
|
+
- [License](#license)
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```console
|
|
43
|
+
pip install pjdev-sn-sdk
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
`pjdev-sn-sdk` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pjdev_sn_sdk/__about__.py,sha256=opYe2fsq9dxRdB87CIMtUyN0XYxM8oLw0JewyGRCu-8,129
|
|
2
|
+
pjdev_sn_sdk/__init__.py,sha256=2UETghiXy-pnzHKsOef2J4B_g7SSzVWGI-dVTed7FYo,107
|
|
3
|
+
pjdev_sn_sdk/api_utilities.py,sha256=XIy_cJWswaeacjEU5iHq0eD6w4N7GXklUQewEgmESRk,5294
|
|
4
|
+
pjdev_sn_sdk/config_service.py,sha256=ZHTlyLCgvXZaLX7NWyt0Tvn6WsGBk3dwwkbeZXRRYRw,406
|
|
5
|
+
pjdev_sn_sdk/control_parsing_service.py,sha256=fPi9QXhsMQT57XAhqtGyoxB-1-CpAD2jHUPd04KzDHM,3448
|
|
6
|
+
pjdev_sn_sdk/models.py,sha256=o3kOKU0LORjCbuybhq7tc1IR0qZoCyeQzISn7SC1-kQ,4177
|
|
7
|
+
pjdev_sn_sdk/sn_api_service.py,sha256=A_un_RsALpkVuxCWnquv_3mApjfucLUzyOj1wPpx7lI,18617
|
|
8
|
+
pjdev_sn_sdk/sn_api_utilities.py,sha256=Cy6pTEvzWgw2EQi17Y6LE7Yo7fcnDrqs2SIBerLEFP0,6570
|
|
9
|
+
pjdev_sn_sdk-4.6.4.dist-info/METADATA,sha256=5vGmsx4Vyim9aniWxJalNcQn1oQ8KoiA7-73qZi66GU,1567
|
|
10
|
+
pjdev_sn_sdk-4.6.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
pjdev_sn_sdk-4.6.4.dist-info/licenses/LICENSE.txt,sha256=lsBCQyCWJBNssHxGJFROYAUl6CVjKJ52uHHydxCxr4c,1099
|
|
12
|
+
pjdev_sn_sdk-4.6.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present Chris O'Neill <chris@purplejay.io>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|