reflex-hosting-cli 0.1.66__tar.gz → 0.1.67a2__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.66 → reflex_hosting_cli-0.1.67a2}/.gitignore +9 -1
  2. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/PKG-INFO +1 -1
  3. reflex_hosting_cli-0.1.67a2/news/.gitkeep +0 -0
  4. reflex_hosting_cli-0.1.67a2/news/6556.feature.md +1 -0
  5. reflex_hosting_cli-0.1.67a2/news/6557.feature.md +1 -0
  6. reflex_hosting_cli-0.1.67a2/news/6632.feature.md +1 -0
  7. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/utils/hosting.py +129 -0
  8. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/v2/deployments.py +5 -0
  9. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/v2/gcp.py +174 -6
  10. reflex_hosting_cli-0.1.67a2/src/reflex_cli/v2/scan.py +262 -0
  11. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/README.md +0 -0
  12. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/pyproject.toml +0 -0
  13. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/__init__.py +0 -0
  14. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/cli.py +0 -0
  15. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/constants/__init__.py +0 -0
  16. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/constants/base.py +0 -0
  17. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/constants/compiler.py +0 -0
  18. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/constants/hosting.py +0 -0
  19. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/core/__init__.py +0 -0
  20. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/core/config.py +0 -0
  21. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/deployments.py +0 -0
  22. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/utils/__init__.py +0 -0
  23. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/utils/console.py +0 -0
  24. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/utils/dependency.py +0 -0
  25. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/utils/exceptions.py +0 -0
  26. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/v2/__init__.py +0 -0
  27. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/v2/apps.py +0 -0
  28. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/v2/cli.py +0 -0
  29. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/v2/project.py +0 -0
  30. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/v2/secrets.py +0 -0
  31. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/src/reflex_cli/v2/utils.py +0 -0
  32. {reflex_hosting_cli-0.1.66 → reflex_hosting_cli-0.1.67a2}/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.66
3
+ Version: 0.1.67a2
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)
@@ -7,12 +7,15 @@ 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
9
  CLOUD_RUN_CPU, CLOUD_RUN_MEMORY, CLOUD_RUN_MIN_INSTANCES,
10
- REFLEX_CLOUDBUILD_YAML).
10
+ CLOUD_RUN_MAX_INSTANCES, CLOUD_RUN_ALLOW_UNAUTHENTICATED,
11
+ CLOUD_RUN_SERVICE_ACCOUNT, REFLEX_CLOUDBUILD_YAML,
12
+ REFLEX_ENV_VARS_FILE).
11
13
  """
12
14
 
13
15
  from __future__ import annotations
14
16
 
15
17
  import contextlib
18
+ import json
16
19
  import os
17
20
  import re
18
21
  import shutil
@@ -41,9 +44,15 @@ ENV_VERSION = "VERSION"
41
44
  ENV_CPU = "CLOUD_RUN_CPU"
42
45
  ENV_MEMORY = "CLOUD_RUN_MEMORY"
43
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"
44
50
  # Path to the Cloud Build config file written by the CLI. The rewritten
45
51
  # deploy script references it as ``--config="${REFLEX_CLOUDBUILD_YAML}"``.
46
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"
47
56
 
48
57
  # Pattern for the start of the `gcloud builds submit` invocation in the
49
58
  # Reflex deploy script. We rewrite that whole multi-line command to use
@@ -162,6 +171,38 @@ DEPLOY_ENV_ALLOWLIST = frozenset({
162
171
  type=click.IntRange(min=0),
163
172
  help="Minimum number of Cloud Run instances to keep warm (sets CLOUD_RUN_MIN_INSTANCES). Set to 0 to scale to zero.",
164
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
+ )
165
206
  @click.option(
166
207
  "--source",
167
208
  "source_dir",
@@ -199,6 +240,11 @@ def deploy_command(
199
240
  cpu: str,
200
241
  memory: str,
201
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, ...],
202
248
  source_dir: str,
203
249
  token: str | None,
204
250
  interactive: bool,
@@ -226,6 +272,13 @@ def deploy_command(
226
272
  if not gcp_project:
227
273
  console.error("--gcp-project is required when using --gcp.")
228
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)
229
282
 
230
283
  authenticated_client = hosting.get_authenticated_client(
231
284
  token=token, interactive=interactive
@@ -262,6 +315,21 @@ def deploy_command(
262
315
 
263
316
  dockerfile, deploy_script = _request_manifest(authenticated_client.token)
264
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
+
265
333
  source_path = Path(source_dir).resolve()
266
334
  if not source_path.is_dir():
267
335
  console.error(f"Source directory does not exist: {source_path}")
@@ -284,7 +352,14 @@ def deploy_command(
284
352
  ENV_CPU: cpu,
285
353
  ENV_MEMORY: memory,
286
354
  ENV_MIN_INSTANCES: str(min_instances),
355
+ ENV_MAX_INSTANCES: str(max_instances),
356
+ ENV_ALLOW_UNAUTHENTICATED: "true" if allow_unauthenticated else "false",
287
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
288
363
 
289
364
  console.info("Received deploy manifest from Reflex.")
290
365
  console.print("")
@@ -306,6 +381,8 @@ def deploy_command(
306
381
  "tempfile; your source directory is not modified."
307
382
  )
308
383
 
384
+ env_vars_yaml = _format_env_vars_yaml(parsed_envs) if parsed_envs else None
385
+
309
386
  if dry_run:
310
387
  console.print("")
311
388
  console.print("cloudbuild.yaml contents:")
@@ -317,6 +394,12 @@ def deploy_command(
317
394
  console.print("─" * 60)
318
395
  console.print(dockerfile)
319
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)
320
403
  console.info("Dry run — nothing staged or executed.")
321
404
  return
322
405
 
@@ -328,15 +411,21 @@ def deploy_command(
328
411
  console.warn("Aborted by user.")
329
412
  raise click.exceptions.Exit(1)
330
413
 
331
- 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
+
332
424
  exit_code = _run_deploy_script(
333
425
  bash_path=bash_path,
334
426
  script=deploy_script,
335
427
  cwd=source_path,
336
- env_overrides={
337
- **deploy_env,
338
- ENV_REFLEX_CLOUDBUILD_YAML: str(cloudbuild_path),
339
- },
428
+ env_overrides=env_overrides,
340
429
  )
341
430
  if exit_code != 0:
342
431
  console.error(f"Deploy script exited with status {exit_code}.")
@@ -563,6 +652,85 @@ def _temp_cloudbuild_yaml(contents: str):
563
652
  path.unlink()
564
653
 
565
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
+
566
734
  def _run_deploy_script(
567
735
  bash_path: str,
568
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)