socketsecurity 0.0.40__tar.gz → 0.0.42__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: socketsecurity
3
- Version: 0.0.40
3
+ Version: 0.0.42
4
4
  Requires-Dist: click
5
5
  Requires-Dist: mdutils
6
6
  Requires-Dist: requests
@@ -1,7 +1,7 @@
1
1
  from setuptools import setup, find_packages
2
2
  setup(
3
3
  name='socketsecurity',
4
- version='0.0.40',
4
+ version='0.0.42',
5
5
  packages=find_packages(),
6
6
  install_requires=[
7
7
  'click',
@@ -0,0 +1 @@
1
+ package_data = {'core': ['core/issue.json']}
@@ -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
+