reflex-hosting-cli 0.1.65a1__tar.gz → 0.1.67__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.
Files changed (32) hide show
  1. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/.gitignore +9 -1
  2. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/PKG-INFO +1 -1
  3. reflex_hosting_cli-0.1.67/news/.gitkeep +0 -0
  4. reflex_hosting_cli-0.1.67/news/6556.feature.md +1 -0
  5. reflex_hosting_cli-0.1.67/news/6557.feature.md +1 -0
  6. reflex_hosting_cli-0.1.67/news/6632.feature.md +1 -0
  7. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/hosting.py +129 -0
  8. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/deployments.py +5 -0
  9. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/gcp.py +206 -6
  10. reflex_hosting_cli-0.1.67/src/reflex_cli/v2/scan.py +262 -0
  11. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/README.md +0 -0
  12. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/pyproject.toml +0 -0
  13. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/__init__.py +0 -0
  14. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/cli.py +0 -0
  15. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/__init__.py +0 -0
  16. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/base.py +0 -0
  17. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/compiler.py +0 -0
  18. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/constants/hosting.py +0 -0
  19. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/core/__init__.py +0 -0
  20. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/core/config.py +0 -0
  21. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/deployments.py +0 -0
  22. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/__init__.py +0 -0
  23. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/console.py +0 -0
  24. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/dependency.py +0 -0
  25. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/utils/exceptions.py +0 -0
  26. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/__init__.py +0 -0
  27. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/apps.py +0 -0
  28. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/cli.py +0 -0
  29. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/project.py +0 -0
  30. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/secrets.py +0 -0
  31. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/utils.py +0 -0
  32. {reflex_hosting_cli-0.1.65a1 → reflex_hosting_cli-0.1.67}/src/reflex_cli/v2/vmtypes_regions.py +0 -0
@@ -1,5 +1,10 @@
1
1
  **/.DS_Store
2
2
  **/*.pyc
3
+ **/__pycache__/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ **/.ruff_cache/
7
+ .mypy_cache/
3
8
  assets/external/*
4
9
  dist/*
5
10
  examples/
@@ -21,4 +26,7 @@ reflex.db
21
26
  node_modules
22
27
  package-lock.json
23
28
  *.pyi
24
- .pre-commit-config.yaml
29
+ .pre-commit-config.yaml
30
+ .claude/.worktrees
31
+ .claude/settings.local.json
32
+ CLAUDE.local.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reflex-hosting-cli
3
- Version: 0.1.65a1
3
+ Version: 0.1.67
4
4
  Summary: Reflex Hosting CLI
5
5
  Author-email: Nikhil Rao <nikhil@reflex.dev>, Alek Petuskey <alek@reflex.dev>
6
6
  Maintainer-email: Simon Young <simon@reflex.dev>, Khaleel Al-Adhami <khaleel@reflex.dev>
File without changes
@@ -0,0 +1 @@
1
+ Added a `--service-account` flag to `reflex deploy --gcp`, letting Cloud Run services run as a least-privilege per-service service account instead of the project's default compute SA.
@@ -0,0 +1 @@
1
+ Added `--max-instances`, `--allow-unauthenticated/--no-allow-unauthenticated`, `--env KEY=VALUE`, and `--envfile` flags to `reflex deploy --gcp`, letting you cap Cloud Run autoscaling, deploy a private service, and set environment variables at deploy time.
@@ -0,0 +1 @@
1
+ Added `reflex cloud scan`, which uploads your app source for a Reflex-aware security review and reports security and logic flaws. Supports `--json` output and a `--fail-on` severity gate for CI.
@@ -1456,6 +1456,135 @@ def create_deployment(
1456
1456
  return response.json()
1457
1457
 
1458
1458
 
1459
+ class SecurityReviewError(ResponseError):
1460
+ """Raised when a security review request fails."""
1461
+
1462
+
1463
+ _SECURITY_REVIEW_PREFIX = "/api/v1/agents/security-review"
1464
+
1465
+
1466
+ def _security_review_detail(response: Any) -> str:
1467
+ """Extract a human-readable ``detail`` from a failed review response.
1468
+
1469
+ Args:
1470
+ response: The error response from the security review API.
1471
+
1472
+ Returns:
1473
+ The server-provided detail, or a generic fallback if the body is not
1474
+ a JSON object with a ``detail`` field.
1475
+
1476
+ """
1477
+ try:
1478
+ return str(response.json()["detail"])
1479
+ except (ValueError, TypeError, KeyError):
1480
+ return "internal server error"
1481
+
1482
+
1483
+ def submit_security_review(zip_bytes: bytes, client: AuthenticatedClient) -> str:
1484
+ """Submit a zipped app for security review.
1485
+
1486
+ Uploads the archive straight to object storage via a presigned URL, then
1487
+ submits the stored object for review.
1488
+
1489
+ Args:
1490
+ zip_bytes: The zipped app source to review.
1491
+ client: The authenticated client.
1492
+
1493
+ Returns:
1494
+ The id of the submitted job, to be polled with ``get_security_review``.
1495
+
1496
+ Raises:
1497
+ NotAuthenticatedError: If the token is not valid.
1498
+ SecurityReviewError: If any step of the submission fails.
1499
+
1500
+ """
1501
+ import httpx
1502
+
1503
+ if not isinstance(client, AuthenticatedClient):
1504
+ raise NotAuthenticatedError("not authenticated")
1505
+
1506
+ auth = authorization_header(client.token)
1507
+
1508
+ # 1. Ask the API for a presigned URL to upload the archive directly.
1509
+ upload_url_response = httpx.post(
1510
+ urljoin(
1511
+ constants.Hosting.HOSTING_SERVICE,
1512
+ f"{_SECURITY_REVIEW_PREFIX}/jobs/upload-url",
1513
+ ),
1514
+ json={"content_length": len(zip_bytes), "content_type": "application/zip"},
1515
+ headers=auth,
1516
+ timeout=constants.Hosting.TIMEOUT,
1517
+ )
1518
+ try:
1519
+ upload_url_response.raise_for_status()
1520
+ except httpx.HTTPStatusError as ex:
1521
+ raise SecurityReviewError(_security_review_detail(ex.response)) from ex
1522
+ upload = upload_url_response.json()
1523
+
1524
+ # 2. Upload the bytes to storage. The presigned URL pins the content length
1525
+ # and type, so send the returned headers verbatim and let httpx derive
1526
+ # Content-Length from the body — setting it manually breaks the signature.
1527
+ put_response = httpx.put(
1528
+ upload["url"],
1529
+ content=zip_bytes,
1530
+ headers=upload.get("headers", {}),
1531
+ timeout=120,
1532
+ )
1533
+ try:
1534
+ put_response.raise_for_status()
1535
+ except httpx.HTTPStatusError as ex:
1536
+ raise SecurityReviewError("failed to upload app source for review") from ex
1537
+
1538
+ # 3. Submit the uploaded object for review.
1539
+ response = httpx.post(
1540
+ urljoin(constants.Hosting.HOSTING_SERVICE, f"{_SECURITY_REVIEW_PREFIX}/jobs"),
1541
+ json={"key": upload["key"]},
1542
+ headers=auth,
1543
+ timeout=constants.Hosting.TIMEOUT,
1544
+ )
1545
+ try:
1546
+ response.raise_for_status()
1547
+ except httpx.HTTPStatusError as ex:
1548
+ raise SecurityReviewError(_security_review_detail(ex.response)) from ex
1549
+ return response.json()["job_id"]
1550
+
1551
+
1552
+ def get_security_review(job_id: str, client: AuthenticatedClient) -> dict[str, Any]:
1553
+ """Poll a previously submitted security review job.
1554
+
1555
+ Args:
1556
+ job_id: The id returned by ``submit_security_review``.
1557
+ client: The authenticated client.
1558
+
1559
+ Returns:
1560
+ The job status payload: ``status`` is one of ``pending``, ``complete``
1561
+ or ``error``; ``result`` holds the review once ``complete``.
1562
+
1563
+ Raises:
1564
+ NotAuthenticatedError: If the token is not valid.
1565
+ SecurityReviewError: If the server returns an error.
1566
+
1567
+ """
1568
+ import httpx
1569
+
1570
+ if not isinstance(client, AuthenticatedClient):
1571
+ raise NotAuthenticatedError("not authenticated")
1572
+
1573
+ response = httpx.get(
1574
+ urljoin(
1575
+ constants.Hosting.HOSTING_SERVICE,
1576
+ f"{_SECURITY_REVIEW_PREFIX}/jobs/{job_id}",
1577
+ ),
1578
+ headers=authorization_header(client.token),
1579
+ timeout=constants.Hosting.TIMEOUT,
1580
+ )
1581
+ try:
1582
+ response.raise_for_status()
1583
+ except httpx.HTTPStatusError as ex:
1584
+ raise SecurityReviewError(_security_review_detail(ex.response)) from ex
1585
+ return response.json()
1586
+
1587
+
1459
1588
  def stop_app(app_id: str, client: AuthenticatedClient):
1460
1589
  """Stop a running application.
1461
1590
 
@@ -14,6 +14,7 @@ from reflex_cli.utils import console
14
14
  from reflex_cli.v2.apps import apps_cli
15
15
  from reflex_cli.v2.gcp import deploy_command as gcp_deploy_command
16
16
  from reflex_cli.v2.project import project_cli
17
+ from reflex_cli.v2.scan import scan_command
17
18
  from reflex_cli.v2.secrets import secrets_cli
18
19
  from reflex_cli.v2.vmtypes_regions import vm_types_regions_cli
19
20
 
@@ -69,6 +70,10 @@ hosting_cli.add_command(
69
70
  gcp_deploy_command,
70
71
  name="deploy",
71
72
  )
73
+ hosting_cli.add_command(
74
+ scan_command,
75
+ name="scan",
76
+ )
72
77
  for name, command in vm_types_regions_cli.commands.items():
73
78
  # Add the command to the hosting CLI
74
79
  hosting_cli.add_command(command, name=name)
@@ -6,12 +6,16 @@ a Cloud Build job (via a ``cloudbuild.yaml`` written to a tempfile and
6
6
  referenced with ``gcloud builds submit --config=...``) — the user's project
7
7
  tree is never modified. The script reads its parameters from environment
8
8
  variables (GCP_PROJECT, GCP_REGION, SERVICE_NAME, AR_REPO, VERSION,
9
- REFLEX_CLOUDBUILD_YAML).
9
+ CLOUD_RUN_CPU, CLOUD_RUN_MEMORY, CLOUD_RUN_MIN_INSTANCES,
10
+ CLOUD_RUN_MAX_INSTANCES, CLOUD_RUN_ALLOW_UNAUTHENTICATED,
11
+ CLOUD_RUN_SERVICE_ACCOUNT, REFLEX_CLOUDBUILD_YAML,
12
+ REFLEX_ENV_VARS_FILE).
10
13
  """
11
14
 
12
15
  from __future__ import annotations
13
16
 
14
17
  import contextlib
18
+ import json
15
19
  import os
16
20
  import re
17
21
  import shutil
@@ -37,9 +41,18 @@ ENV_GCP_REGION = "GCP_REGION"
37
41
  ENV_SERVICE_NAME = "SERVICE_NAME"
38
42
  ENV_AR_REPO = "AR_REPO"
39
43
  ENV_VERSION = "VERSION"
44
+ ENV_CPU = "CLOUD_RUN_CPU"
45
+ ENV_MEMORY = "CLOUD_RUN_MEMORY"
46
+ ENV_MIN_INSTANCES = "CLOUD_RUN_MIN_INSTANCES"
47
+ ENV_MAX_INSTANCES = "CLOUD_RUN_MAX_INSTANCES"
48
+ ENV_ALLOW_UNAUTHENTICATED = "CLOUD_RUN_ALLOW_UNAUTHENTICATED"
49
+ ENV_SERVICE_ACCOUNT = "CLOUD_RUN_SERVICE_ACCOUNT"
40
50
  # Path to the Cloud Build config file written by the CLI. The rewritten
41
51
  # deploy script references it as ``--config="${REFLEX_CLOUDBUILD_YAML}"``.
42
52
  ENV_REFLEX_CLOUDBUILD_YAML = "REFLEX_CLOUDBUILD_YAML"
53
+ # Path to a YAML file with user-supplied env vars. When set, the deploy
54
+ # script passes it to ``gcloud run deploy --env-vars-file=...``.
55
+ ENV_REFLEX_ENV_VARS_FILE = "REFLEX_ENV_VARS_FILE"
43
56
 
44
57
  # Pattern for the start of the `gcloud builds submit` invocation in the
45
58
  # Reflex deploy script. We rewrite that whole multi-line command to use
@@ -136,6 +149,60 @@ DEPLOY_ENV_ALLOWLIST = frozenset({
136
149
  default=None,
137
150
  help="The image version tag (sets VERSION). Defaults to a UTC timestamp.",
138
151
  )
152
+ @click.option(
153
+ "--cpu",
154
+ "cpu",
155
+ default="1",
156
+ show_default=True,
157
+ help="Cloud Run CPU allocation, e.g. '1', '2', '4' (sets CLOUD_RUN_CPU).",
158
+ )
159
+ @click.option(
160
+ "--memory",
161
+ "memory",
162
+ default="1Gi",
163
+ show_default=True,
164
+ help="Cloud Run memory allocation, e.g. '512Mi', '1Gi', '2Gi' (sets CLOUD_RUN_MEMORY).",
165
+ )
166
+ @click.option(
167
+ "--min-instances",
168
+ "min_instances",
169
+ default=1,
170
+ show_default=True,
171
+ type=click.IntRange(min=0),
172
+ help="Minimum number of Cloud Run instances to keep warm (sets CLOUD_RUN_MIN_INSTANCES). Set to 0 to scale to zero.",
173
+ )
174
+ @click.option(
175
+ "--max-instances",
176
+ "max_instances",
177
+ default=100,
178
+ show_default=True,
179
+ type=click.IntRange(min=1),
180
+ help="Maximum number of Cloud Run instances during autoscaling (sets CLOUD_RUN_MAX_INSTANCES). Caps cost under traffic spikes.",
181
+ )
182
+ @click.option(
183
+ "--allow-unauthenticated/--no-allow-unauthenticated",
184
+ "allow_unauthenticated",
185
+ default=True,
186
+ show_default=True,
187
+ help="Whether to make the Cloud Run service publicly reachable (sets CLOUD_RUN_ALLOW_UNAUTHENTICATED). Use --no-allow-unauthenticated for internal / IAP-fronted services; callers will then need a roles/run.invoker IAM binding.",
188
+ )
189
+ @click.option(
190
+ "--service-account",
191
+ "service_account",
192
+ default=None,
193
+ help="IAM service account email the Cloud Run service runs as (sets CLOUD_RUN_SERVICE_ACCOUNT). If omitted, Cloud Run uses the project's default compute SA. The deploying principal needs roles/iam.serviceAccountUser on the target SA.",
194
+ )
195
+ @click.option(
196
+ "--envfile",
197
+ default=None,
198
+ help="Path to a .env file. Loaded into the Cloud Run service as env vars. Takes precedence over --env.",
199
+ )
200
+ @click.option(
201
+ "--env",
202
+ "envs",
203
+ multiple=True,
204
+ help="Environment variable to set on the Cloud Run service: <key>=<value>. Repeat for multiple, e.g. --env K1=V1 --env K2=V2. Plain Cloud Run env vars — visible to anyone with roles/run.viewer; for sensitive values use Secret Manager separately.",
205
+ )
139
206
  @click.option(
140
207
  "--source",
141
208
  "source_dir",
@@ -170,6 +237,14 @@ def deploy_command(
170
237
  service_name: str,
171
238
  ar_repo: str,
172
239
  version_tag: str | None,
240
+ cpu: str,
241
+ memory: str,
242
+ min_instances: int,
243
+ max_instances: int,
244
+ allow_unauthenticated: bool,
245
+ service_account: str | None,
246
+ envfile: str | None,
247
+ envs: tuple[str, ...],
173
248
  source_dir: str,
174
249
  token: str | None,
175
250
  interactive: bool,
@@ -197,6 +272,13 @@ def deploy_command(
197
272
  if not gcp_project:
198
273
  console.error("--gcp-project is required when using --gcp.")
199
274
  raise click.exceptions.Exit(2)
275
+ if max_instances < min_instances:
276
+ console.error(
277
+ f"--max-instances ({max_instances}) must be >= --min-instances ({min_instances})."
278
+ )
279
+ raise click.exceptions.Exit(2)
280
+
281
+ parsed_envs = _parse_envs(envfile=envfile, envs=envs)
200
282
 
201
283
  authenticated_client = hosting.get_authenticated_client(
202
284
  token=token, interactive=interactive
@@ -233,6 +315,21 @@ def deploy_command(
233
315
 
234
316
  dockerfile, deploy_script = _request_manifest(authenticated_client.token)
235
317
 
318
+ # If the user asks for a private service, abort when the fetched script
319
+ # doesn't reference CLOUD_RUN_ALLOW_UNAUTHENTICATED. Without that backend
320
+ # support the deploy would silently use the script's hard-coded
321
+ # --allow-unauthenticated, producing a public service when the user
322
+ # explicitly asked for a private one — a silent privacy flip we'd rather
323
+ # fail loud on.
324
+ if not allow_unauthenticated and ENV_ALLOW_UNAUTHENTICATED not in deploy_script:
325
+ console.error(
326
+ "The Reflex backend's deploy script doesn't yet recognize "
327
+ f"{ENV_ALLOW_UNAUTHENTICATED} — without it, --no-allow-unauthenticated "
328
+ "would be silently ignored and the service would deploy as PUBLIC. "
329
+ "Upgrade the Reflex backend, or remove --no-allow-unauthenticated."
330
+ )
331
+ raise click.exceptions.Exit(1)
332
+
236
333
  source_path = Path(source_dir).resolve()
237
334
  if not source_path.is_dir():
238
335
  console.error(f"Source directory does not exist: {source_path}")
@@ -252,7 +349,17 @@ def deploy_command(
252
349
  ENV_SERVICE_NAME: service_name,
253
350
  ENV_AR_REPO: ar_repo,
254
351
  ENV_VERSION: version_value,
352
+ ENV_CPU: cpu,
353
+ ENV_MEMORY: memory,
354
+ ENV_MIN_INSTANCES: str(min_instances),
355
+ ENV_MAX_INSTANCES: str(max_instances),
356
+ ENV_ALLOW_UNAUTHENTICATED: "true" if allow_unauthenticated else "false",
255
357
  }
358
+ if service_account is not None:
359
+ if not service_account:
360
+ console.error("--service-account cannot be an empty string.")
361
+ raise click.exceptions.Exit(2)
362
+ deploy_env[ENV_SERVICE_ACCOUNT] = service_account
256
363
 
257
364
  console.info("Received deploy manifest from Reflex.")
258
365
  console.print("")
@@ -274,6 +381,8 @@ def deploy_command(
274
381
  "tempfile; your source directory is not modified."
275
382
  )
276
383
 
384
+ env_vars_yaml = _format_env_vars_yaml(parsed_envs) if parsed_envs else None
385
+
277
386
  if dry_run:
278
387
  console.print("")
279
388
  console.print("cloudbuild.yaml contents:")
@@ -285,6 +394,12 @@ def deploy_command(
285
394
  console.print("─" * 60)
286
395
  console.print(dockerfile)
287
396
  console.print("─" * 60)
397
+ if env_vars_yaml is not None:
398
+ console.print("")
399
+ console.print(f"env-vars file contents ({len(parsed_envs)} variable(s)):")
400
+ console.print("─" * 60)
401
+ console.print(env_vars_yaml)
402
+ console.print("─" * 60)
288
403
  console.info("Dry run — nothing staged or executed.")
289
404
  return
290
405
 
@@ -296,15 +411,21 @@ def deploy_command(
296
411
  console.warn("Aborted by user.")
297
412
  raise click.exceptions.Exit(1)
298
413
 
299
- with _temp_cloudbuild_yaml(cloudbuild_yaml) as cloudbuild_path:
414
+ with contextlib.ExitStack() as stack:
415
+ cloudbuild_path = stack.enter_context(_temp_cloudbuild_yaml(cloudbuild_yaml))
416
+ env_overrides = {
417
+ **deploy_env,
418
+ ENV_REFLEX_CLOUDBUILD_YAML: str(cloudbuild_path),
419
+ }
420
+ if env_vars_yaml is not None:
421
+ env_vars_path = stack.enter_context(_temp_env_vars_yaml(env_vars_yaml))
422
+ env_overrides[ENV_REFLEX_ENV_VARS_FILE] = str(env_vars_path)
423
+
300
424
  exit_code = _run_deploy_script(
301
425
  bash_path=bash_path,
302
426
  script=deploy_script,
303
427
  cwd=source_path,
304
- env_overrides={
305
- **deploy_env,
306
- ENV_REFLEX_CLOUDBUILD_YAML: str(cloudbuild_path),
307
- },
428
+ env_overrides=env_overrides,
308
429
  )
309
430
  if exit_code != 0:
310
431
  console.error(f"Deploy script exited with status {exit_code}.")
@@ -531,6 +652,85 @@ def _temp_cloudbuild_yaml(contents: str):
531
652
  path.unlink()
532
653
 
533
654
 
655
+ @contextlib.contextmanager
656
+ def _temp_env_vars_yaml(contents: str):
657
+ """Write the env-vars YAML to a tempfile and yield its path; always clean up.
658
+
659
+ Args:
660
+ contents: The YAML body to write (one ``KEY: "value"`` line per env var).
661
+
662
+ Yields:
663
+ The path to the written tempfile.
664
+
665
+ """
666
+ fd, path_str = tempfile.mkstemp(prefix="reflex-env-vars-", suffix=".yaml")
667
+ path = Path(path_str)
668
+ try:
669
+ with os.fdopen(fd, "w") as fh:
670
+ fh.write(contents)
671
+ yield path
672
+ finally:
673
+ with contextlib.suppress(FileNotFoundError):
674
+ path.unlink()
675
+
676
+
677
+ def _parse_envs(envfile: str | None, envs: tuple[str, ...]) -> dict[str, str]:
678
+ """Resolve --envfile + --env into a single dict of env vars.
679
+
680
+ Mirrors the precedence of the existing `reflex deploy` / `reflex secrets
681
+ update` flow: when both are provided, --envfile wins and --env is
682
+ discarded with a warning. Empty values are preserved as empty strings;
683
+ keys defined without a value in the envfile (``FOO`` with no ``=``)
684
+ become empty strings rather than ``None``.
685
+
686
+ Args:
687
+ envfile: Path to a .env file, or None.
688
+ envs: Tuple of ``KEY=VALUE`` strings from repeated --env flags.
689
+
690
+ Returns:
691
+ Dict of env var name → string value. Empty when neither input is set.
692
+
693
+ """
694
+ from reflex_cli.utils import hosting
695
+
696
+ if envfile and envs:
697
+ console.warn("--envfile is set; ignoring --env")
698
+
699
+ if envfile:
700
+ try:
701
+ from dotenv import dotenv_values # pyright: ignore[reportMissingImports]
702
+ except ImportError:
703
+ console.error(
704
+ 'The `python-dotenv` package is required for --envfile. Run `pip install "python-dotenv>=1.0.1"`.'
705
+ )
706
+ raise click.exceptions.Exit(1) from None
707
+ return {
708
+ k: (v if v is not None else "") for k, v in dotenv_values(envfile).items()
709
+ }
710
+
711
+ if envs:
712
+ return hosting.process_envs(list(envs))
713
+
714
+ return {}
715
+
716
+
717
+ def _format_env_vars_yaml(envs: dict[str, str]) -> str:
718
+ """Format env vars as YAML for gcloud ``--env-vars-file``.
719
+
720
+ Uses ``json.dumps`` per value so any string — quotes, backslashes,
721
+ newlines, unicode — is encoded safely. JSON strings are valid YAML, so
722
+ the output round-trips through gcloud's YAML loader.
723
+
724
+ Args:
725
+ envs: Dict of env var name → string value.
726
+
727
+ Returns:
728
+ YAML body with one ``KEY: "value"`` line per env var.
729
+
730
+ """
731
+ return "".join(f"{k}: {json.dumps(v)}\n" for k, v in envs.items())
732
+
733
+
534
734
  def _run_deploy_script(
535
735
  bash_path: str,
536
736
  script: str,
@@ -0,0 +1,262 @@
1
+ """The `reflex cloud scan` command: a Reflex-aware security review."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import os
8
+ import time
9
+ import zipfile
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import click
14
+
15
+ from reflex_cli import constants
16
+ from reflex_cli.utils import console
17
+ from reflex_cli.utils.exceptions import NotAuthenticatedError
18
+
19
+ # Directory names whose contents are dependencies or build artifacts, never
20
+ # app source. Mirrors the server-side code-map loader so the upload stays
21
+ # small instead of shipping bytes the reviewer will discard.
22
+ _SKIP_DIRS = frozenset({
23
+ ".git",
24
+ ".mypy_cache",
25
+ ".next",
26
+ ".pytest_cache",
27
+ ".ruff_cache",
28
+ ".venv",
29
+ ".web",
30
+ "__pycache__",
31
+ "build",
32
+ "dist",
33
+ "node_modules",
34
+ "venv",
35
+ })
36
+
37
+ # The reviewer skips files larger than this, so there is no point uploading
38
+ # them. Matches the server's per-file cap.
39
+ _MAX_FILE_BYTES = 1_000_000
40
+ # The submission endpoint rejects archives above this size.
41
+ _MAX_ZIP_BYTES = 50 * 1024 * 1024
42
+
43
+ _POLL_INTERVAL_SECONDS = 3.0
44
+ _POLL_TIMEOUT_SECONDS = 600.0
45
+
46
+ # Severities ordered from most to least serious, for sorting and gating.
47
+ _SEVERITY_ORDER = ("critical", "high", "medium", "low")
48
+ _SEVERITY_RANK = {severity: rank for rank, severity in enumerate(_SEVERITY_ORDER)}
49
+ # Rich styles for the severity badge — a bold, padded, reverse-video label so
50
+ # findings stand out at a glance.
51
+ _SEVERITY_STYLE = {
52
+ "critical": "bold white on red",
53
+ "high": "bold red",
54
+ "medium": "bold yellow",
55
+ "low": "bold cyan",
56
+ }
57
+
58
+
59
+ def _zip_app_source(directory: Path) -> bytes:
60
+ """Zip the app source under ``directory`` for security review.
61
+
62
+ Args:
63
+ directory: The app root to scan.
64
+
65
+ Returns:
66
+ The zipped source as bytes.
67
+
68
+ Raises:
69
+ Exit: If no reviewable files are found or the archive is too large.
70
+
71
+ """
72
+ buffer = io.BytesIO()
73
+ file_count = 0
74
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
75
+ for root, dirs, files in os.walk(directory):
76
+ # Prune skip dirs in place so os.walk never descends into them —
77
+ # node_modules/.web alone hold tens of thousands of files.
78
+ dirs[:] = [name for name in dirs if name not in _SKIP_DIRS]
79
+ root_path = Path(root)
80
+ for name in files:
81
+ path = root_path / name
82
+ if not path.is_file():
83
+ continue
84
+ if path.stat().st_size > _MAX_FILE_BYTES:
85
+ continue
86
+ archive.write(path, path.relative_to(directory).as_posix())
87
+ file_count += 1
88
+
89
+ if not file_count:
90
+ console.error(f"No reviewable source files found in {directory}.")
91
+ raise click.exceptions.Exit(1)
92
+
93
+ data = buffer.getvalue()
94
+ if len(data) > _MAX_ZIP_BYTES:
95
+ console.error(
96
+ f"Project source is too large to scan (over {_MAX_ZIP_BYTES // (1024 * 1024)} MB). "
97
+ "Remove large files or move them into an ignored directory."
98
+ )
99
+ raise click.exceptions.Exit(1)
100
+ return data
101
+
102
+
103
+ def _print_violations(result: dict[str, Any]) -> None:
104
+ """Render a security review result to the console.
105
+
106
+ Args:
107
+ result: The ``SecurityReviewResult`` payload from the server.
108
+
109
+ """
110
+ from rich.markup import escape
111
+
112
+ # Disable Rich's auto-highlighter throughout so only the explicit styles
113
+ # below apply — otherwise it recolours line numbers, paths and punctuation.
114
+ violations = result.get("violations", [])
115
+ summary = result.get("summary")
116
+ if summary:
117
+ console.print(f"[bold]Summary:[/bold] {escape(summary)}", highlight=False)
118
+
119
+ if not violations:
120
+ console.success("No issues found.")
121
+ return
122
+
123
+ console.rule("[bold]Security review findings[/bold]")
124
+ for violation in sorted(
125
+ violations,
126
+ key=lambda item: _SEVERITY_RANK.get(
127
+ item.get("severity", "low"), len(_SEVERITY_ORDER)
128
+ ),
129
+ ):
130
+ severity = violation.get("severity", "low")
131
+ style = _SEVERITY_STYLE.get(severity, "bold")
132
+ location = violation.get("file_path", "?")
133
+ if violation.get("line") is not None:
134
+ location = f"{location}:{violation['line']}"
135
+ console.print(
136
+ f"[{style}] {escape(severity.upper())} [/] "
137
+ f"[bold cyan]{escape(violation.get('rule_id', ''))}[/bold cyan] "
138
+ f"[dim]({escape(violation.get('category', ''))})[/dim] "
139
+ f"[dim underline]{escape(location)}[/dim underline]",
140
+ highlight=False,
141
+ )
142
+ console.print(f" {escape(violation.get('message', ''))}", highlight=False)
143
+ if recommendation := violation.get("recommendation"):
144
+ console.print(
145
+ f" [green]Fix:[/green] {escape(recommendation)}", highlight=False
146
+ )
147
+ console.print("")
148
+
149
+ # Colour the tally by the worst severity present (there is at least one).
150
+ worst = min(
151
+ _SEVERITY_RANK.get(v.get("severity", "low"), len(_SEVERITY_ORDER) - 1)
152
+ for v in violations
153
+ )
154
+ count_style = _SEVERITY_STYLE[_SEVERITY_ORDER[worst]]
155
+ console.print(
156
+ f"[{count_style}] Found {len(violations)} issue(s). [/]", highlight=False
157
+ )
158
+
159
+
160
+ @click.command(name="scan")
161
+ @click.argument(
162
+ "directory",
163
+ required=False,
164
+ default=".",
165
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
166
+ )
167
+ @click.option("--token", help="The authentication token.")
168
+ @click.option(
169
+ "--fail-on",
170
+ type=click.Choice([*_SEVERITY_ORDER, "none"]),
171
+ default="low",
172
+ help="Exit non-zero if a violation at or above this severity is found. "
173
+ "Use 'none' to always exit 0.",
174
+ )
175
+ @click.option(
176
+ "--loglevel",
177
+ type=click.Choice([level.value for level in constants.LogLevel]),
178
+ default=constants.LogLevel.INFO.value,
179
+ help="The log level to use.",
180
+ )
181
+ @click.option(
182
+ "--json/--no-json",
183
+ "-j",
184
+ "as_json",
185
+ is_flag=True,
186
+ help="Whether to output the result in JSON format.",
187
+ )
188
+ @click.option(
189
+ "--interactive/--no-interactive",
190
+ "-i",
191
+ is_flag=True,
192
+ default=True,
193
+ help="Whether to use interactive mode.",
194
+ )
195
+ def scan_command(
196
+ directory: Path,
197
+ token: str | None,
198
+ fail_on: str,
199
+ loglevel: str,
200
+ as_json: bool,
201
+ interactive: bool,
202
+ ):
203
+ """Run a Reflex-aware security review over your app source.
204
+
205
+ Uploads the app source under DIRECTORY (the current directory by default)
206
+ and reports security and logic flaws specific to Reflex apps.
207
+ """
208
+ from reflex_cli.utils import hosting
209
+
210
+ console.set_log_level(loglevel)
211
+
212
+ try:
213
+ authenticated_client = hosting.get_authenticated_client(
214
+ token=token, interactive=interactive
215
+ )
216
+
217
+ zip_bytes = _zip_app_source(directory)
218
+
219
+ with console.status("Uploading app source..."):
220
+ job_id = hosting.submit_security_review(
221
+ zip_bytes=zip_bytes, client=authenticated_client
222
+ )
223
+
224
+ deadline = time.monotonic() + _POLL_TIMEOUT_SECONDS
225
+ payload: dict[str, Any] = {}
226
+ with console.status("Scanning..."):
227
+ while True:
228
+ payload = hosting.get_security_review(
229
+ job_id=job_id, client=authenticated_client
230
+ )
231
+ if payload.get("status") != "pending":
232
+ break
233
+ if time.monotonic() >= deadline:
234
+ console.error("Security review timed out. Try again later.")
235
+ raise click.exceptions.Exit(1)
236
+ time.sleep(_POLL_INTERVAL_SECONDS)
237
+ except NotAuthenticatedError as err:
238
+ console.error("You are not authenticated. Run `reflex login` to authenticate.")
239
+ raise click.exceptions.Exit(1) from err
240
+ except hosting.SecurityReviewError as err:
241
+ console.error(f"Security review failed: {err}")
242
+ raise click.exceptions.Exit(1) from err
243
+
244
+ if payload.get("status") == "error":
245
+ console.error(f"Security review failed: {payload.get('error')}")
246
+ raise click.exceptions.Exit(1)
247
+
248
+ result = payload.get("result") or {}
249
+
250
+ if as_json:
251
+ console.print(json.dumps(result))
252
+ else:
253
+ _print_violations(result)
254
+
255
+ if fail_on != "none":
256
+ threshold = _SEVERITY_RANK[fail_on]
257
+ if any(
258
+ _SEVERITY_RANK.get(violation.get("severity", "low"), len(_SEVERITY_ORDER))
259
+ <= threshold
260
+ for violation in result.get("violations", [])
261
+ ):
262
+ raise click.exceptions.Exit(1)