Skip to content

Commit 071c2c3

Browse files
authored
API bug fixes and security issue resolution (#1)
* auth bug fix * added release notes * security updates * security fix * ignore mitmproxy CA * support for custom CA * support for custom CA * redact auth token from logs
1 parent 3571995 commit 071c2c3

File tree

9 files changed

+183
-43
lines changed

9 files changed

+183
-43
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ run/*
44
**/cxone_oauth_client_secret
55
**/cxone_tenant
66
env
7-
7+
mitm*
88

99
# Byte-compiled / optimized / DLL files
1010
__pycache__/

Dockerfile

+10-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
FROM python:3.12
1+
FROM ubuntu:24.04
22
LABEL org.opencontainers.image.source https://github.com/checkmarx-ts/cxone-scan-scheduler
33
LABEL org.opencontainers.image.vendor Checkmarx Professional Services
44
LABEL org.opencontainers.image.title Checkmarx One Scan Scheduler
55
LABEL org.opencontainers.image.description Schedules scans for projects in Checkmarx One
66

7+
USER root
78

8-
RUN apt-get update && apt-get install -y cron && apt-get clean && \
9+
RUN apt-get update && \
10+
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends tzdata && \
11+
apt-get install -y cron python3.12 python3-pip python3-debugpy bash && \
912
usermod -s /bin/bash nobody && \
1013
mkdir -p /opt/cxone && \
1114
mkfifo /opt/cxone/logfifo && \
@@ -14,8 +17,12 @@ RUN apt-get update && apt-get install -y cron && apt-get clean && \
1417

1518
WORKDIR /opt/cxone
1619
COPY *.txt /opt/cxone
17-
RUN pip install debugpy && pip install -r requirements.txt
1820

21+
RUN pip install -r requirements.txt --no-cache-dir --break-system-packages && \
22+
apt-get remove -y perl && \
23+
apt-get autoremove -y && \
24+
apt-get clean && \
25+
dpkg --purge $(dpkg --get-selections | grep deinstall | cut -f1)
1926

2027
COPY cxone_api /opt/cxone/cxone_api
2128
COPY logic /opt/cxone/logic
@@ -28,6 +35,5 @@ COPY *.json /opt/cxone
2835
RUN ln -s scheduler.py scheduler && \
2936
ln -s scheduler.py audit
3037

31-
# ENTRYPOINT ["python", "-Xfrozen_modules=off", "-m", "debugpy", "--listen", "0.0.0.0:5678", "--wait-for-client"]
3238
CMD ["scheduler"]
3339
ENTRYPOINT ["/opt/cxone/entrypoint.sh"]

README.md

+19
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,25 @@ The Scan Scheduler runs as a container. At startup, it crawls the tenant's proj
144144
checks periodically for any schedule changes and updates
145145
the scan schedules accordingly.
146146

147+
### Add Optional Trusted CA Certificates
148+
149+
While the CheckmarxOne system uses TLS certificates signed by a public CA, it is possible that corporate
150+
proxies use certificates signed by a private CA. If so, it is possible to import custom CA certificates
151+
when the scheduler starts.
152+
153+
The custom certificates must meet the following criteria:
154+
155+
* Must be in the PEM format.
156+
* Must be in a file ending with the extension `.crt`.
157+
* Only one certificate is in the file.
158+
* Must be mapped to the container path `/usr/local/share/ca-certificates`.
159+
160+
As an example, if using Docker, it is possible to map a local file to a file in the container with this
161+
mapping option added to the container execution command line:
162+
163+
`-v $(pwd)/custom-ca.pem:/usr/local/share/ca-certificates/custom-ca.crt`
164+
165+
147166
### Required Secrets
148167

149168
Docker secrets are used to securely store secrets needed during runtime.

RELEASE_NOTES.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Release Notes
2+
3+
## v1.1
4+
5+
* Bug fix for auth token not refreshing properly.
6+
* Added log output to indicate number of scheduled scans on start and schedule update.
7+
* Added support for loading custom CA certificates at startup.
8+
9+
## v1.0
10+
11+
Initial release
12+
13+

cxone_api/__init__.py

+124-31
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import asyncio, uuid, requests, urllib, datetime
1+
import asyncio, uuid, requests, urllib, datetime, re
22
from requests.compat import urljoin
33
from pathlib import Path
44

@@ -8,8 +8,25 @@ class AuthException(BaseException):
88
pass
99

1010
class CommunicationException(BaseException):
11-
pass
1211

12+
@staticmethod
13+
def __clean(content):
14+
if type(content) is list:
15+
return [CommunicationException.__clean(x) for x in content]
16+
elif type(content) is tuple:
17+
return (CommunicationException.__clean(x) for x in content)
18+
elif type(content) is dict:
19+
return {k:CommunicationException.__clean(v) for k,v in content.items()}
20+
elif type(content) is str:
21+
if re.match("^Bearer.*", content):
22+
return "REDACTED"
23+
else:
24+
return content
25+
else:
26+
return content
27+
28+
def __init__(self, op, *args, **kwargs):
29+
BaseException.__init__(self, f"Operation: {op.__name__} args: [{CommunicationException.__clean(args)}] kwargs: [{CommunicationException.__clean(kwargs)}]")
1330

1431

1532
class CxOneAuthEndpoint:
@@ -172,37 +189,61 @@ async def paged_api(coro, array_element, offset_field='offset', **kwargs):
172189
yield buf.pop()
173190

174191

175-
176-
177192
class CxOneClient:
178193
__AGENT_NAME = 'CxOne PyClient'
179194

180-
def __init__(self, oauth_id, oauth_secret, agent_name, agent_version, tenant_auth_endpoint, api_endpoint, timeout=60, retries=3, proxy=None, ssl_verify=True):
181195

196+
def __common__init(self, agent_name, agent_version, tenant_auth_endpoint, api_endpoint, timeout, retries, proxy, ssl_verify):
182197
with open(Path(__file__).parent / "version.txt", "rt") as version:
183198
self.__version = version.readline().rstrip()
184199

185200
self.__agent = f"{agent_name}/{agent_version}/({CxOneClient.__AGENT_NAME}/{self.__version})"
186201
self.__proxy = proxy
187202
self.__ssl_verify = ssl_verify
188-
189203
self.__auth_lock = asyncio.Lock()
190204
self.__corelation_id = str(uuid.uuid4())
191205

192206
self.__auth_endpoint = tenant_auth_endpoint
193207
self.__api_endpoint = api_endpoint
194208
self.__timeout = timeout
195209
self.__retries = retries
196-
197-
198-
self.__auth_content = urllib.parse.urlencode( {
210+
211+
self.__auth_endpoint = tenant_auth_endpoint
212+
self.__api_endpoint = api_endpoint
213+
self.__timeout = timeout
214+
self.__retries = retries
215+
216+
self.__auth_result = None
217+
218+
219+
220+
221+
222+
@staticmethod
223+
def create_with_oauth(oauth_id, oauth_secret, agent_name, agent_version, tenant_auth_endpoint, api_endpoint, timeout=60, retries=3, proxy=None, ssl_verify=True):
224+
inst = CxOneClient()
225+
inst.__common__init(agent_name, agent_version, tenant_auth_endpoint, api_endpoint, timeout, retries, proxy, ssl_verify)
226+
227+
inst.__auth_content = urllib.parse.urlencode( {
199228
"grant_type" : "client_credentials",
200229
"client_id" : oauth_id,
201230
"client_secret" : oauth_secret
202231
})
203-
204-
self.__auth_result = None
205232

233+
return inst
234+
235+
@staticmethod
236+
def create_with_api_key(api_key, agent_name, agent_version, tenant_auth_endpoint, api_endpoint, timeout=60, retries=3, proxy=None, ssl_verify=True):
237+
inst = CxOneClient()
238+
inst.__common__init(agent_name, agent_version, tenant_auth_endpoint, api_endpoint, timeout, retries, proxy, ssl_verify)
239+
240+
inst.__auth_content = urllib.parse.urlencode( {
241+
"grant_type" : "refresh_token",
242+
"client_id" : "ast-app",
243+
"refresh_token" : api_key
244+
})
245+
246+
return inst
206247

207248
@property
208249
def auth_endpoint(self):
@@ -259,14 +300,22 @@ async def __exec_request(self, op, *args, **kwargs):
259300
kwargs['verify'] = self.__ssl_verify
260301

261302
for _ in range(0, self.__retries):
303+
auth_headers = await self.__get_request_headers()
304+
305+
if 'headers' in kwargs.keys():
306+
for h in auth_headers.keys():
307+
kwargs['headers'][h] = auth_headers[h]
308+
else:
309+
kwargs['headers'] = auth_headers
310+
262311
response = await asyncio.to_thread(op, *args, **kwargs)
263312

264313
if response.status_code == 401:
265314
await self.__do_auth()
266315
else:
267316
return response
268317

269-
raise CommunicationException(f"{str(op)}{str(args)}{str(kwargs)}")
318+
raise CommunicationException(op, *args, **kwargs)
270319

271320

272321
@staticmethod
@@ -288,47 +337,91 @@ def __join_query_dict(url, querydict):
288337

289338
return urljoin(url, f"?{'&'.join(query)}" if len(query) > 0 else '')
290339

291-
340+
@dashargs("tags-keys", "tags-values")
341+
async def get_applications(self, **kwargs):
342+
url = urljoin(self.api_endpoint, "applications")
343+
url = CxOneClient.__join_query_dict(url, kwargs)
344+
return await self.__exec_request(requests.get, url)
345+
346+
async def get_application(self, id, **kwargs):
347+
url = urljoin(self.api_endpoint, f"applications/{id}")
348+
url = CxOneClient.__join_query_dict(url, kwargs)
349+
return await self.__exec_request(requests.get, url)
350+
292351
@dashargs("repo-url", "name-regex", "tags-keys", "tags-values")
293352
async def get_projects(self, **kwargs):
294353
url = urljoin(self.api_endpoint, "projects")
295-
296354
url = CxOneClient.__join_query_dict(url, kwargs)
355+
return await self.__exec_request(requests.get, url)
356+
297357

298-
return await self.__exec_request(requests.get, url, headers=await self.__get_request_headers() )
358+
async def get_project(self, projectid):
359+
url = urljoin(self.api_endpoint, f"projects/{projectid}")
360+
return await self.__exec_request(requests.get, url)
299361

300-
async def get_project(self, id):
301-
url = urljoin(self.api_endpoint, f"projects/{id}")
302-
return await self.__exec_request(requests.get, url, headers=await self.__get_request_headers() )
362+
async def get_project_configuration(self, projectid):
363+
url = urljoin(self.api_endpoint, f"configuration/project?project-id={projectid}")
364+
return await self.__exec_request(requests.get, url)
303365

304-
async def get_project_configuration(self, id):
305-
url = urljoin(self.api_endpoint, f"configuration/project?project-id={id}")
306-
return await self.__exec_request(requests.get, url, headers=await self.__get_request_headers() )
366+
async def get_tenant_configuration(self):
367+
url = urljoin(self.api_endpoint, f"configuration/tenant")
368+
return await self.__exec_request(requests.get, url)
307369

308370
@dashargs("from-date", "project-id", "project-ids", "scan-ids", "project-names", "source-origin", "source-type", "tags-keys", "tags-values", "to-date")
309371
async def get_scans(self, **kwargs):
310372
url = urljoin(self.api_endpoint, "scans")
311-
312373
url = CxOneClient.__join_query_dict(url, kwargs)
313-
314-
return await self.__exec_request(requests.get, url, headers=await self.__get_request_headers() )
374+
return await self.__exec_request(requests.get, url)
315375

376+
@dashargs("scan-ids")
377+
async def get_sast_scans_metadata(self, **kwargs):
378+
url = urljoin(self.api_endpoint, "sast-metadata")
379+
url = CxOneClient.__join_query_dict(url, kwargs)
380+
return await self.__exec_request(requests.get, url)
316381

317-
async def execute_scan(self, payload, **kwargs):
382+
async def get_sast_scan_metadata(self, scanid, **kwargs):
383+
url = urljoin(self.api_endpoint, f"sast-metadata/{scanid}")
384+
url = CxOneClient.__join_query_dict(url, kwargs)
385+
return await self.__exec_request(requests.get, url)
386+
387+
@dashargs("source-node-operation", "source-node", "source-line-operation", "source-line", "source-file-operation", "source-file", \
388+
"sink-node-operation", "sink-node", "sink-line-operation", "sink-line", "sink-file-operation", \
389+
"sink-file", "result-ids", "preset-id", "number-of-nodes-operation", "number-of-nodes", "notes-operation", \
390+
"first-found-at-operation", "first-found-at", "apply-predicates")
391+
async def get_sast_scan_aggregate_results(self, scanid, groupby_field=['SEVERITY'], **kwargs):
392+
url = urljoin(self.api_endpoint, f"sast-scan-summary/aggregate")
393+
url = CxOneClient.__join_query_dict(url, kwargs | {
394+
'scan-id' : scanid,
395+
'group-by-field' : groupby_field
396+
})
397+
return await self.__exec_request(requests.get, url)
318398

399+
async def execute_scan(self, payload, **kwargs):
319400
url = urljoin(self.api_endpoint, "scans")
320401
url = CxOneClient.__join_query_dict(url, kwargs)
402+
return await self.__exec_request(requests.post, url, json=payload)
321403

322-
return await self.__exec_request(requests.post, url, json=payload, headers=await self.__get_request_headers() )
323-
324-
325404
async def get_sast_scan_log(self, scanid, stream=False):
326405
url = urljoin(self.api_endpoint, f"logs/{scanid}/sast")
327-
return await self.__exec_request(requests.get, url, stream=stream, headers=await self.__get_request_headers() )
406+
response = await self.__exec_request(requests.get, url)
407+
408+
if response.ok and response.status_code == 307:
409+
response = await self.__exec_request(requests.get, response.headers['Location'], stream=stream)
410+
411+
return response
412+
413+
async def get_groups(self, **kwargs):
414+
url = CxOneClient.__join_query_dict(urljoin(self.admin_endpoint, "groups"), kwargs)
415+
return await self.__exec_request(requests.get, url)
328416

329417
async def get_groups(self, **kwargs):
330418
url = CxOneClient.__join_query_dict(urljoin(self.admin_endpoint, "groups"), kwargs)
331-
return await self.__exec_request(requests.get, url, headers=await self.__get_request_headers() )
419+
return await self.__exec_request(requests.get, url)
420+
421+
async def get_scan_workflow(self, scanid, **kwargs):
422+
url = urljoin(self.api_endpoint, f"scans/{scanid}/workflow")
423+
url = CxOneClient.__join_query_dict(url, kwargs)
424+
return await self.__exec_request(requests.get, url)
332425

333426
class ProjectRepoConfig:
334427

@@ -360,4 +453,4 @@ async def primary_branch(self):
360453
@property
361454
async def repo_url(self):
362455
url = await self.__get_logical_repo_url()
363-
return url if len(url) > 0 else None
456+
return url if len(url) > 0 else None

entrypoint.sh

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
#!/bin/bash
22

3+
update-ca-certificates
4+
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
5+
echo "export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt" >> /etc/environment
6+
37
[ -n "$CXONE_REGION" ] && echo "export CXONE_REGION=$CXONE_REGION" >> /etc/environment
48
[ -n "$SINGLE_TENANT_AUTH" ] && echo "export SINGLE_TENANT_AUTH=$SINGLE_TENANT_AUTH" >> /etc/environment
59
[ -n "$SINGLE_TENANT_API" ] && echo "export SINGLE_TENANT_API=$SINGLE_TENANT_API" >> /etc/environment
@@ -13,4 +17,4 @@ fi
1317

1418
service cron start > /dev/null 2>&1
1519

16-
python $@
20+
python3 $@

logic/__init__.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import logging, asyncio, utils
22
from cxone_api import paged_api, ProjectRepoConfig
33

4-
54
class Scheduler:
65
__log = logging.getLogger("Scheduler")
76

@@ -177,6 +176,10 @@ async def refresh_schedule(self):
177176

178177
return len(new_scheduled_projects), len(removed_projects), len(changed_schedule.keys())
179178

179+
@property
180+
def scheduled_scans(self):
181+
return len(self.__the_schedule.keys())
182+
180183

181184
async def __load_schedule(self, bad_cb = None):
182185
tagged, grouped = await asyncio.gather(self.__get_tagged_project_schedule(bad_cb), self.__get_untagged_project_schedule(bad_cb))

scanner.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/usr/local/bin/python
1+
#!/usr/bin/python3
22
import logging, argparse, utils, asyncio
33
from cxone_api import CxOneClient
44
from posix_ipc import Semaphore, BusyError, O_CREAT
@@ -38,7 +38,7 @@ async def main():
3838
with open("version.txt", "rt") as ver:
3939
version = ver.readline().strip()
4040

41-
client = CxOneClient(oauth_id, oauth_secret, agent, version, auth_endpoint,
41+
client = CxOneClient.create_with_oauth(oauth_id, oauth_secret, agent, version, auth_endpoint,
4242
api_endpoint, ssl_verify=ssl_verify, proxy=proxy)
4343

4444

0 commit comments

Comments
 (0)