socketsecurity 0.0.40__tar.gz → 0.0.41__tar.gz
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.
- {socketsecurity-0.0.40 → socketsecurity-0.0.41}/PKG-INFO +1 -1
- {socketsecurity-0.0.40 → socketsecurity-0.0.41}/setup.py +1 -1
- socketsecurity-0.0.41/socketsecurity/__init__.py +0 -0
- socketsecurity-0.0.41/socketsecurity/core/__init__.py +644 -0
- socketsecurity-0.0.41/socketsecurity/core/classes.py +351 -0
- socketsecurity-0.0.41/socketsecurity/core/exceptions.py +31 -0
- socketsecurity-0.0.41/socketsecurity/core/messages.py +301 -0
- socketsecurity-0.0.41/socketsecurity/socketcli.py +50 -0
- {socketsecurity-0.0.40 → socketsecurity-0.0.41}/socketsecurity.egg-info/PKG-INFO +1 -1
- socketsecurity-0.0.41/socketsecurity.egg-info/SOURCES.txt +13 -0
- socketsecurity-0.0.41/socketsecurity.egg-info/top_level.txt +1 -0
- socketsecurity-0.0.40/socketsecurity.egg-info/SOURCES.txt +0 -7
- socketsecurity-0.0.40/socketsecurity.egg-info/top_level.txt +0 -1
- {socketsecurity-0.0.40 → socketsecurity-0.0.41}/setup.cfg +0 -0
- {socketsecurity-0.0.40 → socketsecurity-0.0.41}/socketsecurity.egg-info/dependency_links.txt +0 -0
- {socketsecurity-0.0.40 → socketsecurity-0.0.41}/socketsecurity.egg-info/entry_points.txt +0 -0
- {socketsecurity-0.0.40 → socketsecurity-0.0.41}/socketsecurity.egg-info/requires.txt +0 -0
|
File without changes
|
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import requests
|
|
3
|
+
from urllib.parse import urlencode
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
from core.exceptions import APIFailure, APIKeyMissing, APIAccessDenied, APIInsufficientQuota, APIResourceNotFound
|
|
7
|
+
from core.classes import (
|
|
8
|
+
Report,
|
|
9
|
+
Issue,
|
|
10
|
+
Package,
|
|
11
|
+
Alert,
|
|
12
|
+
FullScan,
|
|
13
|
+
FullScanParams,
|
|
14
|
+
Repository,
|
|
15
|
+
Diff,
|
|
16
|
+
Purl
|
|
17
|
+
)
|
|
18
|
+
import platform
|
|
19
|
+
from glob import glob
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__author__ = 'socket.dev'
|
|
24
|
+
__version__ = '0.0.38'
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Core",
|
|
27
|
+
"log"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
global encoded_key
|
|
32
|
+
version = __version__
|
|
33
|
+
api_url = "https://api.socket.dev/v0"
|
|
34
|
+
timeout = 30
|
|
35
|
+
full_scan_path = ""
|
|
36
|
+
repository_path = ""
|
|
37
|
+
org_id = None
|
|
38
|
+
org_slug = None
|
|
39
|
+
all_new_alerts = False
|
|
40
|
+
issue_file = "core/issues.json"
|
|
41
|
+
issue_text = {}
|
|
42
|
+
security_policy = {}
|
|
43
|
+
log = logging.getLogger("socketdev")
|
|
44
|
+
log.addHandler(logging.NullHandler())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_issue_data() -> dict:
|
|
48
|
+
"""
|
|
49
|
+
Loads the issue data json and returns it as a dictionary. This data is used in the alert messages.
|
|
50
|
+
:return:
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
file = open(issue_file, 'r')
|
|
54
|
+
data = json.load(file)
|
|
55
|
+
return data
|
|
56
|
+
except Exception as error:
|
|
57
|
+
print("Unable to load issue data")
|
|
58
|
+
print(error)
|
|
59
|
+
exit(1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def encode_key(token: str) -> None:
|
|
63
|
+
"""
|
|
64
|
+
encode_key takes passed token string and does a base64 encoding. It sets this as a global variable
|
|
65
|
+
:param token: str of the Socket API Security Token
|
|
66
|
+
:return:
|
|
67
|
+
"""
|
|
68
|
+
global encoded_key
|
|
69
|
+
encoded_key = base64.b64encode(token.encode()).decode('ascii')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def do_request(
|
|
73
|
+
path: str,
|
|
74
|
+
headers: dict = None,
|
|
75
|
+
payload: [dict, str] = None,
|
|
76
|
+
files: list = None,
|
|
77
|
+
method: str = "GET",
|
|
78
|
+
) -> requests.request:
|
|
79
|
+
"""
|
|
80
|
+
do_requests is the shared function for making HTTP calls
|
|
81
|
+
|
|
82
|
+
:param path: Required path for the request
|
|
83
|
+
:param headers: Optional dictionary of headers. If not set will use a default set
|
|
84
|
+
:param payload: Optional dictionary or string of the payload to pass
|
|
85
|
+
:param files: Optional list of files to upload
|
|
86
|
+
:param method: Optional method to use, defaults to GET
|
|
87
|
+
:return:
|
|
88
|
+
"""
|
|
89
|
+
if encoded_key is None or encoded_key == "":
|
|
90
|
+
raise APIKeyMissing
|
|
91
|
+
|
|
92
|
+
if headers is None:
|
|
93
|
+
headers = {
|
|
94
|
+
'Authorization': f"Basic {encoded_key}",
|
|
95
|
+
'User-Agent': 'SocketPythonScript/0.0.1',
|
|
96
|
+
"accept": "application/json"
|
|
97
|
+
}
|
|
98
|
+
url = f"{api_url}/{path}"
|
|
99
|
+
response = requests.request(
|
|
100
|
+
method.upper(),
|
|
101
|
+
url,
|
|
102
|
+
headers=headers,
|
|
103
|
+
data=payload,
|
|
104
|
+
files=files,
|
|
105
|
+
timeout=timeout
|
|
106
|
+
)
|
|
107
|
+
if response.status_code <= 399:
|
|
108
|
+
return response
|
|
109
|
+
elif response.status_code == 400:
|
|
110
|
+
print(f"url={url}")
|
|
111
|
+
print(f"payload={payload}")
|
|
112
|
+
print(f"files={files}")
|
|
113
|
+
error = {
|
|
114
|
+
"msg": "bad request",
|
|
115
|
+
"error": response.text
|
|
116
|
+
}
|
|
117
|
+
raise APIFailure(error)
|
|
118
|
+
elif response.status_code == 401:
|
|
119
|
+
raise APIAccessDenied("Unauthorized")
|
|
120
|
+
elif response.status_code == 403:
|
|
121
|
+
raise APIInsufficientQuota("Insufficient max_quota for API method")
|
|
122
|
+
elif response.status_code == 404:
|
|
123
|
+
raise APIResourceNotFound(f"Path not found {path}")
|
|
124
|
+
elif response.status_code == 429:
|
|
125
|
+
raise APIInsufficientQuota("Insufficient quota for API route")
|
|
126
|
+
else:
|
|
127
|
+
msg = {
|
|
128
|
+
"status_code": 524,
|
|
129
|
+
"UnexpectedError": "There was an unexpected error using the API"
|
|
130
|
+
}
|
|
131
|
+
raise APIFailure(msg)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Core:
|
|
135
|
+
token: str
|
|
136
|
+
base_api_url: str
|
|
137
|
+
request_timeout: int
|
|
138
|
+
reports: list
|
|
139
|
+
|
|
140
|
+
def __init__(self, token: str, base_api_url=None, request_timeout=None, enable_all_alerts=False):
|
|
141
|
+
self.token = token + ":"
|
|
142
|
+
encode_key(self.token)
|
|
143
|
+
global issue_text
|
|
144
|
+
issue_text = load_issue_data()
|
|
145
|
+
self.socket_date_format = "%Y-%m-%dT%H:%M:%S.%fZ"
|
|
146
|
+
self.base_api_url = base_api_url
|
|
147
|
+
if self.base_api_url is not None:
|
|
148
|
+
Core.set_api_url(self.base_api_url)
|
|
149
|
+
self.request_timeout = request_timeout
|
|
150
|
+
if self.request_timeout is not None:
|
|
151
|
+
Core.set_timeout(self.request_timeout)
|
|
152
|
+
if enable_all_alerts:
|
|
153
|
+
global all_new_alerts
|
|
154
|
+
all_new_alerts = True
|
|
155
|
+
Core.set_org_vars()
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def set_org_vars() -> None:
|
|
159
|
+
"""
|
|
160
|
+
Sets the main shared global variables
|
|
161
|
+
:return:
|
|
162
|
+
"""
|
|
163
|
+
global org_id, org_slug, full_scan_path, repository_path, security_policy
|
|
164
|
+
org_id, org_slug = Core.get_org_id_slug()
|
|
165
|
+
base_path = f"orgs/{org_slug}"
|
|
166
|
+
full_scan_path = f"{base_path}/full-scans"
|
|
167
|
+
repository_path = f"{base_path}/repos"
|
|
168
|
+
security_policy = Core.get_security_policy()
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def set_api_url(base_url: str):
|
|
172
|
+
"""
|
|
173
|
+
Set the global API URl if provided
|
|
174
|
+
:param base_url:
|
|
175
|
+
:return:
|
|
176
|
+
"""
|
|
177
|
+
global api_url
|
|
178
|
+
api_url = base_url
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def set_timeout(request_timeout: int):
|
|
182
|
+
"""
|
|
183
|
+
Set the global Requests timeout
|
|
184
|
+
:param request_timeout:
|
|
185
|
+
:return:
|
|
186
|
+
"""
|
|
187
|
+
global timeout
|
|
188
|
+
timeout = request_timeout
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def get_org_id_slug() -> (str, str):
|
|
192
|
+
"""
|
|
193
|
+
Gets the Org ID and Org Slug for the API Token
|
|
194
|
+
:return:
|
|
195
|
+
"""
|
|
196
|
+
path = "organizations"
|
|
197
|
+
response = do_request(path)
|
|
198
|
+
data = response.json()
|
|
199
|
+
organizations = data.get("organizations")
|
|
200
|
+
new_org_id = None
|
|
201
|
+
new_org_slug = None
|
|
202
|
+
if len(organizations) == 1:
|
|
203
|
+
for key in organizations:
|
|
204
|
+
new_org_id = key
|
|
205
|
+
new_org_slug = organizations[key].get('slug')
|
|
206
|
+
return new_org_id, new_org_slug
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def get_sbom_data(full_scan_id: str) -> list:
|
|
210
|
+
path = f"orgs/{org_slug}/full-scans/{full_scan_id}"
|
|
211
|
+
response = do_request(path)
|
|
212
|
+
results = []
|
|
213
|
+
try:
|
|
214
|
+
data = response.json()
|
|
215
|
+
results = data.get("sbom_artifacts") or []
|
|
216
|
+
except Exception as error:
|
|
217
|
+
log.info("Failed with old style full-scan API using new format")
|
|
218
|
+
log.info(error)
|
|
219
|
+
data = response.text
|
|
220
|
+
data.strip('"')
|
|
221
|
+
data.strip()
|
|
222
|
+
for line in data.split("\n"):
|
|
223
|
+
if line != '"' and line != "" and line is not None:
|
|
224
|
+
item = json.loads(line)
|
|
225
|
+
results.append(item)
|
|
226
|
+
return results
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def get_security_policy() -> dict:
|
|
230
|
+
"""
|
|
231
|
+
Get the Security policy and determine the effective Org security policy
|
|
232
|
+
:return:
|
|
233
|
+
"""
|
|
234
|
+
path = "settings"
|
|
235
|
+
payload = [
|
|
236
|
+
{
|
|
237
|
+
"organization": org_id
|
|
238
|
+
}
|
|
239
|
+
]
|
|
240
|
+
response = do_request(path, payload=json.dumps(payload), method="POST")
|
|
241
|
+
data = response.json()
|
|
242
|
+
defaults = data.get("defaults")
|
|
243
|
+
default_rules = defaults.get("issueRules")
|
|
244
|
+
entries = data.get("entries")
|
|
245
|
+
org_rules = {}
|
|
246
|
+
for org_set in entries:
|
|
247
|
+
settings = org_set.get("settings")
|
|
248
|
+
if settings is not None:
|
|
249
|
+
org_details = settings.get("organization")
|
|
250
|
+
org_rules = org_details.get("issueRules")
|
|
251
|
+
for default in default_rules:
|
|
252
|
+
if default not in org_rules:
|
|
253
|
+
action = default_rules[default]["action"]
|
|
254
|
+
org_rules[default] = {
|
|
255
|
+
"action": action
|
|
256
|
+
}
|
|
257
|
+
return org_rules
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def find_files(path: str) -> list:
|
|
261
|
+
"""
|
|
262
|
+
Globs the path for supported manifest files.
|
|
263
|
+
Note: Might move the source to a JSON file
|
|
264
|
+
:param path: Str - path to where the manifest files are located
|
|
265
|
+
:param workspace: str - workspace path to be stripped from the name
|
|
266
|
+
:return:
|
|
267
|
+
"""
|
|
268
|
+
socket_globs = {
|
|
269
|
+
"general": {
|
|
270
|
+
"readme": {
|
|
271
|
+
"pattern": "*readme*"
|
|
272
|
+
},
|
|
273
|
+
"notice": {
|
|
274
|
+
"pattern": "*notice*"
|
|
275
|
+
},
|
|
276
|
+
"license": {
|
|
277
|
+
"pattern": "{licen{s,c}e{,-*},copying}"
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
"npm": {
|
|
281
|
+
"package.json": {
|
|
282
|
+
"pattern": "package.json"
|
|
283
|
+
},
|
|
284
|
+
"package-lock.json": {
|
|
285
|
+
"pattern": "package-lock.json"
|
|
286
|
+
},
|
|
287
|
+
"npm-shrinkwrap.json": {
|
|
288
|
+
"pattern": "npm-shrinkwrap.json"
|
|
289
|
+
},
|
|
290
|
+
"yarn.lock": {
|
|
291
|
+
"pattern": "yarn.lock"
|
|
292
|
+
},
|
|
293
|
+
"pnpm-lock.yaml": {
|
|
294
|
+
"pattern": "pnpm-lock.yaml"
|
|
295
|
+
},
|
|
296
|
+
"pnpm-lock.yml": {
|
|
297
|
+
"pattern": "pnpm-lock.yml"
|
|
298
|
+
},
|
|
299
|
+
"pnpm-workspace.yaml": {
|
|
300
|
+
"pattern": "pnpm-workspace.yaml"
|
|
301
|
+
},
|
|
302
|
+
"pnpm-workspace.yml": {
|
|
303
|
+
"pattern": "pnpm-workspace.yml"
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
"pypi": {
|
|
307
|
+
"pipfile": {
|
|
308
|
+
"pattern": "pipfile"
|
|
309
|
+
},
|
|
310
|
+
"pyproject.toml": {
|
|
311
|
+
"pattern": "pyproject.toml"
|
|
312
|
+
},
|
|
313
|
+
"requirements.txt": {
|
|
314
|
+
"pattern": "*requirements.txt"
|
|
315
|
+
},
|
|
316
|
+
"requirements": {
|
|
317
|
+
"pattern": "requirements/*.txt"
|
|
318
|
+
},
|
|
319
|
+
"requirements-*.txt": {
|
|
320
|
+
"pattern": "requirements-*.txt"
|
|
321
|
+
},
|
|
322
|
+
"requirements_*.txt": {
|
|
323
|
+
"pattern": "requirements_*.txt"
|
|
324
|
+
},
|
|
325
|
+
"requirements.frozen": {
|
|
326
|
+
"pattern": "requirements.frozen"
|
|
327
|
+
},
|
|
328
|
+
"setup.py": {
|
|
329
|
+
"pattern": "setup.py"
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
"golang": {
|
|
333
|
+
"go.mod": {
|
|
334
|
+
"pattern": "go.mod"
|
|
335
|
+
},
|
|
336
|
+
"go.sum": {
|
|
337
|
+
"pattern": "go.sum"
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
all_files = []
|
|
342
|
+
for ecosystem in socket_globs:
|
|
343
|
+
patterns = socket_globs[ecosystem]
|
|
344
|
+
for file_name in patterns:
|
|
345
|
+
pattern = patterns[file_name]["pattern"]
|
|
346
|
+
file_path = f"{path}/**/{pattern}"
|
|
347
|
+
files = glob(file_path, recursive=True)
|
|
348
|
+
for file in files:
|
|
349
|
+
if platform.system() == "Windows":
|
|
350
|
+
file = file.replace("\\", "/")
|
|
351
|
+
found_path, file_name = file.rsplit("/", 1)
|
|
352
|
+
details = (found_path, file_name)
|
|
353
|
+
all_files.append(details)
|
|
354
|
+
return all_files
|
|
355
|
+
|
|
356
|
+
@staticmethod
|
|
357
|
+
def create_full_scan(files: list, params: FullScanParams, workspace: str) -> FullScan:
|
|
358
|
+
"""
|
|
359
|
+
Calls the full scan API to create a new Full Scan
|
|
360
|
+
:param files: list - Globbed files of manifest files
|
|
361
|
+
:param params: FullScanParams - Set of query params to pass to the endpoint
|
|
362
|
+
:param workspace: str - Path of workspace
|
|
363
|
+
:return:
|
|
364
|
+
"""
|
|
365
|
+
send_files = []
|
|
366
|
+
for path, name in files:
|
|
367
|
+
full_path = f"{path}/{name}"
|
|
368
|
+
if full_path.startswith(workspace):
|
|
369
|
+
key = full_path[len(workspace):]
|
|
370
|
+
else:
|
|
371
|
+
key = full_path
|
|
372
|
+
key = key.lstrip("/")
|
|
373
|
+
key = key.lstrip("./")
|
|
374
|
+
payload = (
|
|
375
|
+
key,
|
|
376
|
+
(
|
|
377
|
+
name,
|
|
378
|
+
open(full_path, 'rb')
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
send_files.append(payload)
|
|
382
|
+
query_params = urlencode(params.__dict__)
|
|
383
|
+
full_uri = f"{full_scan_path}?{query_params}"
|
|
384
|
+
response = do_request(full_uri, method="POST", files=send_files)
|
|
385
|
+
results = response.json()
|
|
386
|
+
full_scan = FullScan(**results)
|
|
387
|
+
full_scan.sbom_artifacts = Core.get_sbom_data(full_scan.id)
|
|
388
|
+
return full_scan
|
|
389
|
+
|
|
390
|
+
@staticmethod
|
|
391
|
+
def get_head_scan_for_repo(repo_slug: str):
|
|
392
|
+
"""
|
|
393
|
+
Get the head scan ID for a repository to use for the diff
|
|
394
|
+
:param repo_slug: Str - Repo slug for the repository that is being diffed
|
|
395
|
+
:return:
|
|
396
|
+
"""
|
|
397
|
+
repo_path = f"{repository_path}/{repo_slug}"
|
|
398
|
+
response = do_request(repo_path)
|
|
399
|
+
results = response.json()
|
|
400
|
+
repository = Repository(**results)
|
|
401
|
+
return repository.head_full_scan_id
|
|
402
|
+
|
|
403
|
+
@staticmethod
|
|
404
|
+
def get_full_scan(full_scan_id: str) -> FullScan:
|
|
405
|
+
"""
|
|
406
|
+
Get the specified full scan and return a FullScan object
|
|
407
|
+
:param full_scan_id: str - ID of the full scan to pull
|
|
408
|
+
:return:
|
|
409
|
+
"""
|
|
410
|
+
full_scan_url = f"{full_scan_path}/{full_scan_id}"
|
|
411
|
+
response = do_request(full_scan_url)
|
|
412
|
+
results = response.json()
|
|
413
|
+
full_scan = FullScan(**results)
|
|
414
|
+
full_scan.sbom_artifacts = Core.get_sbom_data(full_scan.id)
|
|
415
|
+
return full_scan
|
|
416
|
+
|
|
417
|
+
@staticmethod
|
|
418
|
+
def create_new_diff(path: str, params: FullScanParams, workspace: str) -> Diff:
|
|
419
|
+
"""
|
|
420
|
+
1. Get the head full scan. If it isn't present because this repo doesn't exist yet return an Empty full scan.
|
|
421
|
+
2. Create a new Full scan for the current run
|
|
422
|
+
3. Compare the head and new Full scan
|
|
423
|
+
4. Return a Diff report
|
|
424
|
+
:param path: Str - path of where to look for manifest files for the new Full Scan
|
|
425
|
+
:param params: FullScanParams - Query params for the Full Scan endpoint
|
|
426
|
+
:param workspace: str - Path for workspace
|
|
427
|
+
:return:
|
|
428
|
+
"""
|
|
429
|
+
files = Core.find_files(path)
|
|
430
|
+
try:
|
|
431
|
+
head_full_scan_id = Core.get_head_scan_for_repo(params.repo)
|
|
432
|
+
if head_full_scan_id is None or head_full_scan_id == "":
|
|
433
|
+
head_full_scan = []
|
|
434
|
+
else:
|
|
435
|
+
head_start = time.time()
|
|
436
|
+
head_full_scan = Core.get_sbom_data(head_full_scan_id)
|
|
437
|
+
head_end = time.time()
|
|
438
|
+
total_head_time = head_end - head_start
|
|
439
|
+
print(f"Total time to get head sbom {total_head_time: .2f}")
|
|
440
|
+
except APIResourceNotFound:
|
|
441
|
+
head_full_scan = []
|
|
442
|
+
if files is not None and len(files) > 0:
|
|
443
|
+
new_scan_start = time.time()
|
|
444
|
+
new_full_scan = Core.create_full_scan(files, params, workspace)
|
|
445
|
+
new_scan_end = time.time()
|
|
446
|
+
total_new_time = new_scan_end - new_scan_start
|
|
447
|
+
print(f"Total time to get new sbom {total_new_time: .2f}")
|
|
448
|
+
diff_report = Core.compare_sboms(new_full_scan.sbom_artifacts, head_full_scan)
|
|
449
|
+
else:
|
|
450
|
+
diff_report = Diff()
|
|
451
|
+
return diff_report
|
|
452
|
+
|
|
453
|
+
@staticmethod
|
|
454
|
+
def compare_sboms(new_scan: list, head_scan: list) -> Diff:
|
|
455
|
+
"""
|
|
456
|
+
compare the SBOMs of the new full Scan and the head full scan. Return a Diff report with new packages,
|
|
457
|
+
removed packages, and new alerts for the new full scan compared to the head.
|
|
458
|
+
:param new_scan: FullScan - Newly created FullScan for this execution
|
|
459
|
+
:param head_scan: FullScan - Current head FullScan for the repository
|
|
460
|
+
:return:
|
|
461
|
+
"""
|
|
462
|
+
diff: Diff
|
|
463
|
+
diff = Diff()
|
|
464
|
+
new_packages = Core.create_sbom_dict(new_scan)
|
|
465
|
+
head_packages = Core.create_sbom_dict(head_scan)
|
|
466
|
+
new_scan_alerts = {}
|
|
467
|
+
head_scan_alerts = {}
|
|
468
|
+
|
|
469
|
+
for package_id in new_packages:
|
|
470
|
+
purl, package = Core.create_purl(package_id, new_packages)
|
|
471
|
+
if package_id not in head_packages:
|
|
472
|
+
diff.new_packages.append(purl)
|
|
473
|
+
new_scan_alerts = Core.create_issue_alerts(package, new_scan_alerts, new_packages)
|
|
474
|
+
for package_id in head_packages:
|
|
475
|
+
purl, package = Core.create_purl(package_id, head_packages)
|
|
476
|
+
if package_id not in new_packages:
|
|
477
|
+
diff.removed_packages.append(purl)
|
|
478
|
+
head_scan_alerts = Core.create_issue_alerts(package, head_scan_alerts, head_packages)
|
|
479
|
+
diff.new_alerts = Core.compare_issue_alerts(new_scan_alerts, head_scan_alerts, diff.new_alerts)
|
|
480
|
+
return diff
|
|
481
|
+
|
|
482
|
+
@staticmethod
|
|
483
|
+
def compare_issue_alerts(new_scan_alerts: dict, head_scan_alerts: dict, alerts: list) -> list:
|
|
484
|
+
"""
|
|
485
|
+
Compare the issue alerts from the new full scan and the head full scans. Return a list of new alerts that
|
|
486
|
+
are in the new full scan and not in the head full scan
|
|
487
|
+
:param new_scan_alerts: dictionary of alerts from the new full scan
|
|
488
|
+
:param head_scan_alerts: dictionary of alerts from the new head scan
|
|
489
|
+
:param alerts: List of new alerts that are only in the new Full Scan
|
|
490
|
+
:return:
|
|
491
|
+
"""
|
|
492
|
+
for alert_key in new_scan_alerts:
|
|
493
|
+
if alert_key not in head_scan_alerts:
|
|
494
|
+
new_alerts = new_scan_alerts[alert_key]
|
|
495
|
+
for alert in new_alerts:
|
|
496
|
+
if Core.is_error(alert):
|
|
497
|
+
alerts.append(alert)
|
|
498
|
+
else:
|
|
499
|
+
new_alerts = new_scan_alerts[alert_key]
|
|
500
|
+
head_alerts = head_scan_alerts[alert_key]
|
|
501
|
+
for alert in new_alerts:
|
|
502
|
+
if alert not in head_alerts and Core.is_error(alert):
|
|
503
|
+
alerts.append(alert)
|
|
504
|
+
return alerts
|
|
505
|
+
|
|
506
|
+
@staticmethod
|
|
507
|
+
def is_error(alert: Alert):
|
|
508
|
+
"""
|
|
509
|
+
Compare the current alert against the Security Policy to determine if it should be included. Can be overridden
|
|
510
|
+
with all_new_alerts Global setting if desired to return all alerts and not just the error category from the
|
|
511
|
+
security policy.
|
|
512
|
+
:param alert:
|
|
513
|
+
:return:
|
|
514
|
+
"""
|
|
515
|
+
if all_new_alerts or (alert.type in security_policy and security_policy[alert.type]['action'] == "error"):
|
|
516
|
+
return True
|
|
517
|
+
else:
|
|
518
|
+
return False
|
|
519
|
+
|
|
520
|
+
@staticmethod
|
|
521
|
+
def create_issue_alerts(package: Package, alerts: dict, packages: dict) -> dict:
|
|
522
|
+
"""
|
|
523
|
+
Create the Issue Alerts from the package and base alert data.
|
|
524
|
+
:param package: Package - Current package that is being looked at for Alerts
|
|
525
|
+
:param alerts: Dict - All found Issue Alerts across all packages
|
|
526
|
+
:param packages: Dict - All packages detected in the SBOM and needed to find top level packages
|
|
527
|
+
:return:
|
|
528
|
+
"""
|
|
529
|
+
for item in package.alerts:
|
|
530
|
+
alert = Alert(**item)
|
|
531
|
+
props = issue_text.get(alert.type)
|
|
532
|
+
if props is not None:
|
|
533
|
+
description = props.get("description")
|
|
534
|
+
title = props.get("title")
|
|
535
|
+
suggestion = props.get("suggestion")
|
|
536
|
+
next_step_title = props.get("nextStepTitle")
|
|
537
|
+
else:
|
|
538
|
+
description = ""
|
|
539
|
+
title = ""
|
|
540
|
+
suggestion = ""
|
|
541
|
+
next_step_title = ""
|
|
542
|
+
introduced_by = Core.get_source_data(package, packages)
|
|
543
|
+
issue_alert = Issue(
|
|
544
|
+
pkg_type=package.type,
|
|
545
|
+
pkg_name=package.name,
|
|
546
|
+
pkg_version=package.version,
|
|
547
|
+
pkg_id=package.id,
|
|
548
|
+
type=alert.type,
|
|
549
|
+
severity=alert.severity,
|
|
550
|
+
key=alert.key,
|
|
551
|
+
props=alert.props,
|
|
552
|
+
description=description,
|
|
553
|
+
title=title,
|
|
554
|
+
suggestion=suggestion,
|
|
555
|
+
next_step_title=next_step_title,
|
|
556
|
+
introduced_by=introduced_by
|
|
557
|
+
)
|
|
558
|
+
if issue_alert.key not in alerts:
|
|
559
|
+
alerts[issue_alert.key] = [issue_alert]
|
|
560
|
+
else:
|
|
561
|
+
alerts[issue_alert.key].append(issue_alert)
|
|
562
|
+
return alerts
|
|
563
|
+
|
|
564
|
+
@staticmethod
|
|
565
|
+
def get_source_data(package: Package, packages: dict) -> list:
|
|
566
|
+
"""
|
|
567
|
+
Creates the properties for source data of the source manifest file(s) and top level packages.
|
|
568
|
+
:param package: Package - Current package being evaluated
|
|
569
|
+
:param packages: Dict - All packages, used to determine top level package information for transitive packages
|
|
570
|
+
:return:
|
|
571
|
+
"""
|
|
572
|
+
introduced_by = []
|
|
573
|
+
if package.direct:
|
|
574
|
+
manifests = ""
|
|
575
|
+
for manifest_data in package.manifestFiles:
|
|
576
|
+
manifest_file = manifest_data.get("file")
|
|
577
|
+
manifests += f"{manifest_file};"
|
|
578
|
+
manifests = manifests.rstrip(";")
|
|
579
|
+
source = ("direct", manifests)
|
|
580
|
+
introduced_by.append(source)
|
|
581
|
+
else:
|
|
582
|
+
for top_id in package.topLevelAncestors:
|
|
583
|
+
top_package: Package
|
|
584
|
+
top_package = packages[top_id]
|
|
585
|
+
manifests = ""
|
|
586
|
+
top_purl = f"{top_package.type}/{top_package.name}@{top_package.version}"
|
|
587
|
+
for manifest_data in top_package.manifestFiles:
|
|
588
|
+
manifest_file = manifest_data.get("file")
|
|
589
|
+
manifests += f"{manifest_file};"
|
|
590
|
+
manifests = manifests.rstrip(";")
|
|
591
|
+
source = (top_purl, manifests)
|
|
592
|
+
introduced_by.append(source)
|
|
593
|
+
return introduced_by
|
|
594
|
+
|
|
595
|
+
@staticmethod
|
|
596
|
+
def create_purl(package_id: str, packages: dict) -> (Purl, Package):
|
|
597
|
+
"""
|
|
598
|
+
Creates the extended PURL data to use in the added or removed package details. Primarily used for outputting
|
|
599
|
+
data in the results for detections.
|
|
600
|
+
:param package_id: Str - Package ID of the package to create the PURL data
|
|
601
|
+
:param packages: dict - All packages to use for look up from transitive packages
|
|
602
|
+
:return:
|
|
603
|
+
"""
|
|
604
|
+
package: Package
|
|
605
|
+
package = packages[package_id]
|
|
606
|
+
introduced_by = Core.get_source_data(package, packages)
|
|
607
|
+
purl = Purl(
|
|
608
|
+
id=package.id,
|
|
609
|
+
name=package.name,
|
|
610
|
+
version=package.version,
|
|
611
|
+
ecosystem=package.type,
|
|
612
|
+
direct=package.direct,
|
|
613
|
+
introduced_by=introduced_by,
|
|
614
|
+
author=package.author or '',
|
|
615
|
+
size=package.size,
|
|
616
|
+
transitives=package.transitives
|
|
617
|
+
)
|
|
618
|
+
return purl, package
|
|
619
|
+
|
|
620
|
+
@staticmethod
|
|
621
|
+
def create_sbom_dict(sbom: list) -> dict:
|
|
622
|
+
"""
|
|
623
|
+
Converts the SBOM Artifacts from the FulLScan into a Dictionary for parsing
|
|
624
|
+
:param sbom: list - Raw artifacts for the SBOM
|
|
625
|
+
:return:
|
|
626
|
+
"""
|
|
627
|
+
packages = {}
|
|
628
|
+
top_level_count = {}
|
|
629
|
+
for item in sbom:
|
|
630
|
+
package = Package(**item)
|
|
631
|
+
if package.id in packages:
|
|
632
|
+
print("Duplicate package?")
|
|
633
|
+
else:
|
|
634
|
+
packages[package.id] = package
|
|
635
|
+
for top_id in package.topLevelAncestors:
|
|
636
|
+
if top_id not in top_level_count:
|
|
637
|
+
top_level_count[top_id] = 1
|
|
638
|
+
else:
|
|
639
|
+
top_level_count[top_id] += 1
|
|
640
|
+
if len(top_level_count) > 0:
|
|
641
|
+
for package_id in top_level_count:
|
|
642
|
+
packages[package_id].transitives = top_level_count[package_id]
|
|
643
|
+
return packages
|
|
644
|
+
|