Skip to content

Commit 540ac80

Browse files
committed
Merge branch 'main' into noWriteModeV2
2 parents 7ba0f1a + da11b86 commit 540ac80

5 files changed

Lines changed: 140 additions & 10 deletions

File tree

.github/workflows/sf_cli_integration.yml

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ jobs:
1717
- name: Checkout code
1818
uses: actions/checkout@v4
1919

20+
- name: Set mock server TLS cert paths
21+
run: |
22+
echo "MOCK_SF_CERT_FILE=$RUNNER_TEMP/mock_sf_cert.pem" >> "$GITHUB_ENV"
23+
echo "MOCK_SF_KEY_FILE=$RUNNER_TEMP/mock_sf_key.pem" >> "$GITHUB_ENV"
24+
25+
- name: Generate mock server TLS cert
26+
run: |
27+
openssl req -x509 -newkey rsa:2048 -nodes \
28+
-keyout "$MOCK_SF_KEY_FILE" -out "$MOCK_SF_CERT_FILE" \
29+
-days 1 -subj "/CN=localhost" \
30+
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
31+
2032
- name: Set up Python 3.11
2133
uses: actions/setup-python@v5
2234
with:
@@ -73,7 +85,7 @@ jobs:
7385
sfdx_dir.mkdir(exist_ok=True)
7486
auth = {
7587
"accessToken": "00D000000000001AAA!fakeTokenForCITesting",
76-
"instanceUrl": "http://localhost:8888",
88+
"instanceUrl": "https://localhost:8888",
7789
"loginUrl": "https://login.salesforce.com",
7890
"orgId": "00D000000000001AAA",
7991
"userId": "005000000000001AAA",
@@ -164,6 +176,9 @@ jobs:
164176
# ── Script: run ───────────────────────────────────────────────────────────
165177

166178
- name: '[script] run — sf data-code-extension script run --entrypoint testScript/payload/entrypoint.py -o dev1'
179+
env:
180+
NODE_EXTRA_CA_CERTS: ${{ env.MOCK_SF_CERT_FILE }}
181+
REQUESTS_CA_BUNDLE: ${{ env.MOCK_SF_CERT_FILE }}
167182
run: |
168183
sf data-code-extension script run \
169184
--entrypoint testScript/payload/entrypoint.py \
@@ -175,6 +190,9 @@ jobs:
175190
# ── Script: deploy ───────────────────────────────────────────────────────
176191

177192
- name: '[script] deploy — sf data-code-extension script deploy'
193+
env:
194+
NODE_EXTRA_CA_CERTS: ${{ env.MOCK_SF_CERT_FILE }}
195+
REQUESTS_CA_BUNDLE: ${{ env.MOCK_SF_CERT_FILE }}
178196
run: |
179197
sf data-code-extension script deploy \
180198
--name test-script-deploy \
@@ -262,6 +280,9 @@ jobs:
262280
# ── Function: run ─────────────────────────────────────────────────────────
263281

264282
- name: '[function] run — sf data-code-extension function run --entrypoint testFunction/payload/entrypoint.py --test-with testFunction/payload/tests/test.json -o dev1'
283+
env:
284+
NODE_EXTRA_CA_CERTS: ${{ env.MOCK_SF_CERT_FILE }}
285+
REQUESTS_CA_BUNDLE: ${{ env.MOCK_SF_CERT_FILE }}
265286
run: |
266287
sf data-code-extension function run \
267288
--entrypoint testFunction/payload/entrypoint.py \
@@ -273,6 +294,9 @@ jobs:
273294
# ── Function: deploy ─────────────────────────────────────────────────────
274295

275296
- name: '[function] deploy — sf data-code-extension function deploy'
297+
env:
298+
NODE_EXTRA_CA_CERTS: ${{ env.MOCK_SF_CERT_FILE }}
299+
REQUESTS_CA_BUNDLE: ${{ env.MOCK_SF_CERT_FILE }}
276300
run: |
277301
sf data-code-extension function deploy \
278302
--name test-function-deploy \

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ exploration. Instead of running an entire script, one can run one code cell at
509509

510510
You can read more about Jupyter Notebooks here: https://jupyter.org/
511511

512-
1. Within the root project of your package folder, run `./jupyterlab.sh start`
512+
1. Within the root project of your package folder, run `./jupyterlab.sh start`. This prints an access token and opens an already-authenticated JupyterLab session in your browser. If the browser doesn't open automatically, copy the printed `http://localhost:8888/?token=...` URL into your browser.
513513
1. Double-click on "account.ipynb" file, which provides a starting point for a notebook
514514
1. Use shift+enter to execute each cell within the notebook. Add/edit/delete cells of code as needed for your data exploration.
515515
1. Don't forget to run `./jupyterlab.sh stop` to stop the docker container
@@ -600,4 +600,4 @@ If you're using OAuth Tokens authentication, the initial configure will retrieve
600600
## Other docs
601601

602602
- [Troubleshooting](./docs/troubleshooting.md)
603-
- [For Contributors](./FOR_CONTRIBUTORS.md)
603+
- [Contributing](./CONTRIBUTING.md)

scripts/mock_sf_server.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,25 @@
4040
python scripts/mock_sf_server.py # listens on port 8888
4141
MOCK_SF_PORT=9000 python scripts/mock_sf_server.py
4242
python scripts/mock_sf_server.py 9000
43+
44+
Serves TLS (the deploy path requires an HTTPS upload URL) using a pre-generated
45+
cert/key pair — this script does not generate one. Set ``MOCK_SF_CERT_FILE`` /
46+
``MOCK_SF_KEY_FILE`` to the pair's paths; generate a throwaway one with:
47+
48+
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem \\
49+
-days 1 -subj "/CN=localhost" \\
50+
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
51+
52+
Point clients at the cert so they trust it: ``NODE_EXTRA_CA_CERTS`` (CLI) and
53+
``REQUESTS_CA_BUNDLE`` (SDK).
4354
"""
4455

4556
from __future__ import annotations
4657

4758
from http.server import BaseHTTPRequestHandler, HTTPServer
4859
import json
4960
import os
61+
import ssl
5062
import sys
5163

5264
PORT = (
@@ -68,7 +80,7 @@
6880

6981
_TOKEN_RESPONSE = {
7082
"access_token": "00D000000000001AAA!fakeAccessTokenForCITesting",
71-
"instance_url": f"http://localhost:{PORT}",
83+
"instance_url": f"https://localhost:{PORT}",
7284
"token_type": "Bearer",
7385
"scope": "api",
7486
}
@@ -135,7 +147,9 @@ def do_POST(self) -> None:
135147
elif path == _DATA_CUSTOM_CODE_PATH:
136148
# create_deployment() — return a presigned upload URL
137149
self._send_json(
138-
{"fileUploadUrl": f"http://localhost:{PORT}/upload/fake-deployment.zip"}
150+
{
151+
"fileUploadUrl": f"https://localhost:{PORT}/upload/fake-deployment.zip"
152+
}
139153
)
140154
elif path == _DATA_TRANSFORMS_PATH:
141155
# create_data_transform() — script packages only
@@ -150,7 +164,18 @@ def do_PUT(self) -> None:
150164

151165

152166
if __name__ == "__main__":
167+
cert_path = os.environ.get("MOCK_SF_CERT_FILE")
168+
key_path = os.environ.get("MOCK_SF_KEY_FILE")
169+
if not cert_path or not key_path:
170+
sys.exit(
171+
"MOCK_SF_CERT_FILE and MOCK_SF_KEY_FILE must both be set to an "
172+
"existing TLS cert/key pair — see the module docstring."
173+
)
174+
153175
server = HTTPServer(("localhost", PORT), MockSFHandler)
154176
server.allow_reuse_address = True
155-
print(f"[MOCK SF] Listening on http://localhost:{PORT}", flush=True)
177+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
178+
ctx.load_cert_chain(cert_path, key_path)
179+
server.socket = ctx.wrap_socket(server.socket, server_side=True)
180+
print(f"[MOCK SF] Listening on https://localhost:{PORT}", flush=True)
156181
server.serve_forever()

src/datacustomcode/templates/script/jupyterlab.sh

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,26 +45,39 @@ check_docker() {
4545
echo "Docker daemon is running"
4646
}
4747

48+
# Function to check if openssl is installed
49+
check_openssl() {
50+
if ! command -v openssl &> /dev/null; then
51+
echo "openssl is not installed. It is required to generate a secure JupyterLab access token."
52+
exit 1
53+
fi
54+
}
55+
4856
# Function to start Jupyter server
4957
start_jupyter() {
5058
echo "Building the docker image"
5159
docker build -t datacloud-customcode .
5260

61+
local TOKEN
62+
TOKEN=$(openssl rand -hex 32)
63+
5364
echo "Running the docker container"
54-
docker run -d --rm -p 8888:8888 \
65+
docker run -d --rm -p 127.0.0.1:8888:8888 \
5566
-v $(pwd):/workspace \
5667
--name jupyter-server \
5768
datacloud-customcode jupyter lab \
5869
--ip=0.0.0.0 \
5970
--port=8888 \
6071
--no-browser \
6172
--allow-root \
62-
--NotebookApp.token='' \
63-
--NotebookApp.password='' \
73+
--NotebookApp.token="$TOKEN" \
6474
--notebook-dir=/workspace
6575

6676
sleep 3 # Wait for server to start
67-
open_browser "http://localhost:8888"
77+
local URL
78+
URL="http://localhost:8888/?token=$TOKEN"
79+
echo "Opening $URL"
80+
open_browser $URL
6881
}
6982

7083
# Function to stop Jupyter server
@@ -82,6 +95,7 @@ stop_jupyter() {
8295
case "$1" in
8396
"start")
8497
check_docker
98+
check_openssl
8599
start_jupyter
86100
;;
87101
"stop")

tests/test_jupyterlab_script.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import subprocess
5+
6+
from datacustomcode.template import script_template_dir
7+
8+
JUPYTERLAB_SH = os.path.join(script_template_dir, "jupyterlab.sh")
9+
10+
# These tests don't actually run the jupyter script. They simply verify
11+
# certain specific configurations of the script for things like syntax
12+
# and security correctness.
13+
#
14+
# These were added when fixing a bug that could have allowed for RCE
15+
# over the local network on the user's device due to previous insufficient
16+
# network config. While not perfect, they do offer a bit of assurance that
17+
# the script is configured correctly.
18+
19+
20+
class TestJupyterlabScript:
21+
def _read(self) -> str:
22+
with open(JUPYTERLAB_SH) as f:
23+
return f.read()
24+
25+
def test_jupyterlab_sh_syntax_is_valid(self):
26+
"""`bash -n` should accept the script without syntax errors."""
27+
result = subprocess.run(
28+
["bash", "-n", JUPYTERLAB_SH],
29+
capture_output=True,
30+
text=True,
31+
check=False,
32+
)
33+
assert result.returncode == 0, result.stderr
34+
35+
def test_start_jupyter_binds_loopback_host_port(self):
36+
content = self._read()
37+
assert "-p 127.0.0.1:8888:8888" in content
38+
assert "-p 8888:8888" not in content
39+
40+
def test_start_jupyter_binds_container_to_all_interfaces(self):
41+
content = self._read()
42+
assert "--ip=0.0.0.0" in content
43+
assert "--ip=127.0.0.1" not in content
44+
45+
def test_start_jupyter_generates_token_not_empty_auth(self):
46+
content = self._read()
47+
assert "--NotebookApp.token=''" not in content
48+
assert "--NotebookApp.password=''" not in content
49+
assert "openssl rand -hex 32" in content
50+
51+
def test_start_jupyter_uses_dynamic_token_variable(self):
52+
content = self._read()
53+
assert "local TOKEN" in content
54+
assert "TOKEN=$(openssl rand -hex 32)" in content
55+
assert '--NotebookApp.token="$TOKEN"' in content
56+
57+
def test_open_browser_url_includes_token_param(self):
58+
content = self._read()
59+
assert 'URL="http://localhost:8888/?token=$TOKEN"' in content
60+
assert "open_browser $URL" in content
61+
62+
def test_token_never_written_to_file(self):
63+
content = self._read()
64+
assert "credentials.ini" not in content
65+
for line in content.splitlines():
66+
if "TOKEN" in line:
67+
assert ">" not in line, f"Line writes TOKEN to a file: {line!r}"

0 commit comments

Comments
 (0)