bevor-cli 0.1.0__py3-none-any.whl

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 (100) hide show
  1. bevor_cli/__init__.py +0 -0
  2. bevor_cli/annotations/__init__.py +0 -0
  3. bevor_cli/annotations/confirmations.py +16 -0
  4. bevor_cli/annotations/query.py +25 -0
  5. bevor_cli/apps/agent.py +10 -0
  6. bevor_cli/apps/analyses.py +47 -0
  7. bevor_cli/apps/chat.py +16 -0
  8. bevor_cli/apps/codes.py +18 -0
  9. bevor_cli/apps/findings.py +22 -0
  10. bevor_cli/apps/main.py +61 -0
  11. bevor_cli/apps/nodes.py +17 -0
  12. bevor_cli/apps/projects.py +14 -0
  13. bevor_cli/apps/sessions.py +13 -0
  14. bevor_cli/apps/workspaces.py +16 -0
  15. bevor_cli/client.py +157 -0
  16. bevor_cli/commands/__init__.py +0 -0
  17. bevor_cli/commands/agent/__init__.py +0 -0
  18. bevor_cli/commands/agent/materialze_cmd.py +65 -0
  19. bevor_cli/commands/agent/setup_cmd.py +18 -0
  20. bevor_cli/commands/analyses/__init__.py +0 -0
  21. bevor_cli/commands/analyses/active.py +42 -0
  22. bevor_cli/commands/analyses/chats/__init__.py +0 -0
  23. bevor_cli/commands/analyses/chats/get.py +39 -0
  24. bevor_cli/commands/analyses/chats/list.py +64 -0
  25. bevor_cli/commands/analyses/chats/messages.py +41 -0
  26. bevor_cli/commands/analyses/chats/new.py +44 -0
  27. bevor_cli/commands/analyses/chats/resume.py +43 -0
  28. bevor_cli/commands/analyses/chats/session.py +295 -0
  29. bevor_cli/commands/analyses/child.py +81 -0
  30. bevor_cli/commands/analyses/commit.py +49 -0
  31. bevor_cli/commands/analyses/create.py +105 -0
  32. bevor_cli/commands/analyses/delete.py +65 -0
  33. bevor_cli/commands/analyses/diff.py +87 -0
  34. bevor_cli/commands/analyses/entrypoints.py +56 -0
  35. bevor_cli/commands/analyses/findings.py +129 -0
  36. bevor_cli/commands/analyses/fork.py +47 -0
  37. bevor_cli/commands/analyses/list.py +85 -0
  38. bevor_cli/commands/analyses/merge.py +46 -0
  39. bevor_cli/commands/analyses/retry.py +77 -0
  40. bevor_cli/commands/analyses/run.py +87 -0
  41. bevor_cli/commands/analyses/select.py +85 -0
  42. bevor_cli/commands/analyses/set_head.py +45 -0
  43. bevor_cli/commands/analyses/transitions.py +90 -0
  44. bevor_cli/commands/analyses/view.py +94 -0
  45. bevor_cli/commands/auth/__init__.py +7 -0
  46. bevor_cli/commands/auth/login.py +135 -0
  47. bevor_cli/commands/auth/logout.py +23 -0
  48. bevor_cli/commands/auth/whoami.py +21 -0
  49. bevor_cli/commands/codes/__init__.py +0 -0
  50. bevor_cli/commands/codes/list.py +90 -0
  51. bevor_cli/commands/codes/push.py +76 -0
  52. bevor_cli/commands/codes/select.py +93 -0
  53. bevor_cli/commands/codes/view.py +80 -0
  54. bevor_cli/commands/context/__init__.py +92 -0
  55. bevor_cli/commands/findings/__init__.py +0 -0
  56. bevor_cli/commands/findings/acknowledge.py +28 -0
  57. bevor_cli/commands/findings/add.py +236 -0
  58. bevor_cli/commands/findings/bulk.py +110 -0
  59. bevor_cli/commands/findings/delete.py +28 -0
  60. bevor_cli/commands/findings/edit.py +109 -0
  61. bevor_cli/commands/findings/get.py +62 -0
  62. bevor_cli/commands/findings/interactive.py +329 -0
  63. bevor_cli/commands/findings/list.py +108 -0
  64. bevor_cli/commands/findings/revert.py +28 -0
  65. bevor_cli/commands/graph/__init__.py +0 -0
  66. bevor_cli/commands/graph/call_chain.py +48 -0
  67. bevor_cli/commands/graph/edges.py +59 -0
  68. bevor_cli/commands/graph/interactive.py +207 -0
  69. bevor_cli/commands/graph/renderer.py +135 -0
  70. bevor_cli/commands/link/__init__.py +52 -0
  71. bevor_cli/commands/link/helpers.py +26 -0
  72. bevor_cli/commands/nodes/__init__.py +0 -0
  73. bevor_cli/commands/nodes/get.py +52 -0
  74. bevor_cli/commands/nodes/list.py +75 -0
  75. bevor_cli/commands/nodes/source.py +40 -0
  76. bevor_cli/commands/projects/__init__.py +0 -0
  77. bevor_cli/commands/projects/create.py +54 -0
  78. bevor_cli/commands/projects/list.py +57 -0
  79. bevor_cli/commands/projects/select.py +75 -0
  80. bevor_cli/commands/projects/view.py +49 -0
  81. bevor_cli/commands/sessions/__init__.py +0 -0
  82. bevor_cli/commands/sessions/list.py +36 -0
  83. bevor_cli/commands/sessions/revoke.py +35 -0
  84. bevor_cli/commands/workspaces/__init__.py +0 -0
  85. bevor_cli/commands/workspaces/list.py +50 -0
  86. bevor_cli/commands/workspaces/pick.py +20 -0
  87. bevor_cli/commands/workspaces/select.py +68 -0
  88. bevor_cli/commands/workspaces/view.py +48 -0
  89. bevor_cli/configuration/creds.py +52 -0
  90. bevor_cli/configuration/settings.py +54 -0
  91. bevor_cli/configuration/state.py +113 -0
  92. bevor_cli/findings_yaml.py +291 -0
  93. bevor_cli/main.py +33 -0
  94. bevor_cli/output.py +69 -0
  95. bevor_cli/select.py +64 -0
  96. bevor_cli/settings.py +4 -0
  97. bevor_cli-0.1.0.dist-info/METADATA +14 -0
  98. bevor_cli-0.1.0.dist-info/RECORD +100 -0
  99. bevor_cli-0.1.0.dist-info/WHEEL +4 -0
  100. bevor_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,90 @@
1
+ """``bevor code list`` — list code versions."""
2
+
3
+ from typing import List, Optional
4
+
5
+ import typer
6
+ from bevor_sdk.types.literals import CodeVersionStatus, Network
7
+ from bevor_sdk.types.requests.graph import CodeQueryParams
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from bevor_cli import output
12
+ from bevor_cli.annotations.confirmations import JSON_OUT
13
+ from bevor_cli.annotations.query import ORDER, ORDER_BY, PAGE, PAGE_SIZE
14
+ from bevor_cli.client import BevorInternalClient
15
+ from bevor_cli.configuration.creds import Credentials
16
+ from bevor_cli.configuration.settings import Settings
17
+ from bevor_cli.configuration.state import State
18
+ from bevor_cli.output import console
19
+
20
+
21
+ def list_code_versions(
22
+ page: PAGE = None,
23
+ page_size: PAGE_SIZE = None,
24
+ order: ORDER = None,
25
+ order_by: ORDER_BY = None,
26
+ project: Optional[str] = typer.Option(
27
+ None, "--project", help="Project to list for. Defaults to current configuration"
28
+ ),
29
+ network: Optional[Network] = typer.Option(
30
+ None,
31
+ "--network",
32
+ help="Network to filter by",
33
+ ),
34
+ identifier: Optional[str] = typer.Option(
35
+ None, "--identifier", help="Identifier (commit, hash, address) to filter by"
36
+ ),
37
+ status: Optional[CodeVersionStatus] = typer.Option(
38
+ None, "--status", help="Code status to filter by"
39
+ ),
40
+ json_out: JSON_OUT = False,
41
+ ) -> None:
42
+ """List code versions for the active project, or the passed --project."""
43
+ creds = Credentials.load()
44
+ creds.require_token()
45
+
46
+ state = State.load()
47
+ workspace_id = state.validate_workspace()
48
+
49
+ settings = Settings.load()
50
+
51
+ query_params = CodeQueryParams(
52
+ page=page,
53
+ page_size=page_size or settings.default_page_size,
54
+ order=order,
55
+ order_by=order_by,
56
+ network=network,
57
+ identifier=identifier,
58
+ status=status,
59
+ )
60
+ client = BevorInternalClient(
61
+ creds=creds, workspace_id=workspace_id, project_id=state.validate_project(priority=project)
62
+ )
63
+
64
+ codes = client.codes.list(query_params)
65
+
66
+ if json_out:
67
+ console.print_json(codes.model_dump_json(indent=2))
68
+ return
69
+
70
+ if not codes.results:
71
+ console.print("No code versions found.")
72
+ return
73
+
74
+ table = Table("ID", "STATUS", "CREATED", "METHOD")
75
+
76
+ for v in codes.results:
77
+ args: List[str | Text] = [
78
+ v.id,
79
+ v.status,
80
+ str(v.created_at.date()),
81
+ v.version_method,
82
+ ]
83
+ if state.code_id == v.id:
84
+ args.append(Text("●", style="bold green"))
85
+ table.add_row(*args)
86
+
87
+ console.print(table)
88
+
89
+ if codes.more:
90
+ output.info(f"\nShowing {codes.page} of {codes.total_pages}. Increase with --page-size/-n.")
@@ -0,0 +1,76 @@
1
+ """``bevor code push`` — upload a directory as a code version."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from bevor_sdk.exceptions import NotFoundError
8
+ from bevor_sdk.helpers.source_fingerprint import zip_source_tree
9
+ from bevor_sdk.types import ID
10
+
11
+ from bevor_cli import output
12
+ from bevor_cli.annotations.confirmations import NO_WAIT, YES
13
+ from bevor_cli.client import BevorInternalClient
14
+ from bevor_cli.configuration.creds import Credentials
15
+ from bevor_cli.configuration.state import State
16
+
17
+
18
+ def push_code(
19
+ path: Optional[Path] = typer.Option(None, "--path", help="Source tree root (default: cwd)."),
20
+ no_wait: NO_WAIT = False,
21
+ yes: YES = False,
22
+ ) -> None:
23
+ """Upload your current working directory to produce a new code version. Automatically updates your configured code version."""
24
+
25
+ creds = Credentials.load()
26
+ creds.require_token()
27
+
28
+ state = State.load()
29
+ workspace_id = state.validate_workspace()
30
+ project_id = state.validate_project()
31
+
32
+ client = BevorInternalClient(creds=creds, workspace_id=workspace_id, project_id=project_id)
33
+
34
+ if not yes:
35
+ project = client.projects.get()
36
+ if not typer.confirm(f"Create code within project '{project.name}'?"):
37
+ raise typer.Exit(0)
38
+
39
+ root = path or Path.cwd()
40
+ try:
41
+ with output.spinner("Packaging source tree..."):
42
+ zip_bytes, n_files, content_hash = zip_source_tree(root)
43
+ except ValueError as exc:
44
+ output.error(str(exc), hint="Make sure you're in the project root directory.")
45
+ raise typer.Exit(1)
46
+ size_kb = len(zip_bytes) // 1024
47
+ output.info(f"Compressed {n_files} files → {size_kb} KB")
48
+
49
+ if state.code_id and state.code_fingerprint == content_hash:
50
+ # requests are project-scoped, so a code that still resolves belongs to the linked project.
51
+ try:
52
+ client.codes.get(ID(state.code_id))
53
+ output.info("No changes detected from the current code version")
54
+ return
55
+ except NotFoundError:
56
+ pass
57
+
58
+ with output.spinner("Uploading..."):
59
+ # no harm in this being called regardless.
60
+ signing_key = client.codes.generate_signing_key()
61
+ result = client.codes.upload_folder_zip(
62
+ signing_key=signing_key, zip_bytes=zip_bytes, with_streaming=True
63
+ )
64
+
65
+ if result.data.status == "failed":
66
+ output.error("Code processing failed on the server.")
67
+ raise typer.Exit(1)
68
+
69
+ state.set_code(result.data)
70
+ state.save()
71
+
72
+ if result.data.status in ("processing", "queued") and not no_wait:
73
+ with output.spinner("Processing code..."):
74
+ result.stream_completion()
75
+
76
+ output.success(f"Code version: {result.data.id}")
@@ -0,0 +1,93 @@
1
+ """``bevor codes select`` — set the selected code version."""
2
+
3
+ from typing import Optional
4
+
5
+ import questionary
6
+ import typer
7
+ from bevor_sdk.exceptions import NotFoundError
8
+ from bevor_sdk.types import ID
9
+ from bevor_sdk.types.literals import CodeVersionStatus
10
+ from bevor_sdk.types.requests.graph import CodeQueryParams
11
+ from bevor_sdk.types.responses.graph import CodeMappingSchema
12
+
13
+ from bevor_cli import output
14
+ from bevor_cli.client import BevorInternalClient
15
+ from bevor_cli.configuration.creds import Credentials
16
+ from bevor_cli.configuration.settings import Settings
17
+ from bevor_cli.configuration.state import State
18
+ from bevor_cli.output import console
19
+ from bevor_cli.select import _CANCEL, _select
20
+
21
+
22
+ def select_code(
23
+ code_id: Optional[str] = typer.Argument(
24
+ None, help="Code version id to select. Prompts with a list when omitted."
25
+ ),
26
+ ) -> None:
27
+ """Select a code version in the selected project by id, or pick one from a list."""
28
+ if code_id is None:
29
+ pick_code()
30
+ return
31
+
32
+ creds = Credentials.load()
33
+ creds.require_token()
34
+
35
+ state = State.load()
36
+ workspace_id = state.validate_workspace()
37
+ client = BevorInternalClient(
38
+ creds=creds, workspace_id=workspace_id, project_id=state.validate_project()
39
+ )
40
+
41
+ try:
42
+ code = client.codes.get(ID(code_id))
43
+ except NotFoundError:
44
+ output.error("code not found in the configured project")
45
+ raise typer.Exit(1)
46
+
47
+ state.set_code(code)
48
+ state.save()
49
+ output.info("Updated code configuration")
50
+
51
+
52
+ def pick_code(
53
+ *,
54
+ page: Optional[int] = None,
55
+ status: Optional[CodeVersionStatus] = None,
56
+ page_size: Optional[int] = None,
57
+ ) -> None:
58
+ """Interactive picker for code versions in the configured project."""
59
+ creds = Credentials.load()
60
+ creds.require_token()
61
+
62
+ state = State.load()
63
+ workspace_id = state.validate_workspace()
64
+
65
+ client = BevorInternalClient(
66
+ creds=creds, workspace_id=workspace_id, project_id=state.validate_project()
67
+ )
68
+
69
+ settings = Settings.load()
70
+
71
+ codes = client.codes.list(
72
+ CodeQueryParams(page=page, status=status, page_size=page_size or settings.default_page_size)
73
+ )
74
+
75
+ if not codes.results:
76
+ console.print("No code versions found.")
77
+ return
78
+
79
+ choices = [
80
+ questionary.Choice(
81
+ title=f"{c.status} · {c.id} {c.created_at.date()}",
82
+ value=c,
83
+ )
84
+ for c in codes.results
85
+ ] + [questionary.Choice(title="↵ Cancel", value=_CANCEL)]
86
+
87
+ code: CodeMappingSchema | None = _select("Select a code version:", choices)
88
+ if code is None:
89
+ raise typer.Exit(0)
90
+
91
+ state.set_code(code)
92
+ state.save()
93
+ output.info("Updated code configuration")
@@ -0,0 +1,80 @@
1
+ """``bevor codes view`` — show a code version (the selected one by default)."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from bevor_sdk.helpers.source_fingerprint import zip_source_tree
8
+ from bevor_sdk.types import ID
9
+ from rich.table import Table
10
+
11
+ from bevor_cli import output
12
+ from bevor_cli.annotations.confirmations import JSON_OUT
13
+ from bevor_cli.client import BevorInternalClient
14
+ from bevor_cli.configuration.creds import Credentials
15
+ from bevor_cli.configuration.state import State
16
+ from bevor_cli.output import console
17
+
18
+
19
+ def view_code(
20
+ code_id: Optional[str] = typer.Argument(
21
+ None, help="Code version id. Defaults to the selected code version."
22
+ ),
23
+ json_out: JSON_OUT = False,
24
+ ) -> None:
25
+ """Print details for a code version, or the selected one."""
26
+ creds = Credentials.load()
27
+ creds.require_token()
28
+
29
+ state = State.load()
30
+ workspace_id = state.validate_workspace()
31
+
32
+ code_id = code_id or state.code_id
33
+ if not code_id:
34
+ output.error(
35
+ "No code version is configured.",
36
+ hint="Run `bevor codes push` or `bevor codes select`.",
37
+ )
38
+ raise typer.Exit(1)
39
+
40
+ client = BevorInternalClient(
41
+ creds=creds, workspace_id=workspace_id, project_id=state.validate_project()
42
+ )
43
+ code = client.codes.get(ID(code_id))
44
+
45
+ if json_out:
46
+ console.print_json(code.model_dump_json(indent=2))
47
+ return
48
+
49
+ root = Path.cwd()
50
+ _, _, content_hash = zip_source_tree(root)
51
+
52
+ table = Table(show_header=False, box=None, padding=(0, 1))
53
+ table.add_column(style="bold")
54
+ table.add_column()
55
+
56
+ table.add_row("ID", code.id)
57
+ table.add_row("Status", code.status)
58
+ table.add_row("Version method", code.version_method)
59
+ table.add_row("Fingerprint", code.version_fingerprint)
60
+ table.add_row("In sync (cwd)", str(content_hash == code.version_fingerprint))
61
+ table.add_row("Created", str(code.created_at.date()))
62
+
63
+ if code.network:
64
+ table.add_row("Network", code.network)
65
+
66
+ if code.branch:
67
+ table.add_row("Branch", code.branch)
68
+
69
+ if code.commit:
70
+ c = code.commit
71
+ sha_short = c.sha[:7] if len(c.sha) >= 7 else c.sha
72
+ msg = c.message.split("\n", 1)[0][:80]
73
+ table.add_row("Commit", f"{sha_short} {msg}")
74
+
75
+ if code.repository:
76
+ r = code.repository
77
+ table.add_row("GitHub", r.full_name)
78
+ table.add_row("Repo URL", r.url)
79
+
80
+ console.print(table)
@@ -0,0 +1,92 @@
1
+ """bevor context — show `.bevor` context for this directory."""
2
+
3
+ import json
4
+ from typing import Any, Dict
5
+
6
+ import typer
7
+ from bevor_sdk.types import ID
8
+
9
+ from bevor_cli import output
10
+ from bevor_cli.annotations.confirmations import JSON_OUT
11
+ from bevor_cli.client import BevorInternalBusinessClient, BevorInternalClient
12
+ from bevor_cli.configuration.creds import Credentials
13
+ from bevor_cli.configuration.state import State
14
+ from bevor_cli.output import console
15
+
16
+
17
+ def run_context(
18
+ json_out: JSON_OUT = False,
19
+ ) -> None:
20
+ """Show workspace, project, code, and analysis from `.bevor` (and live labels where available)."""
21
+
22
+ creds = Credentials.load()
23
+ creds.require_token()
24
+
25
+ state = State.load()
26
+ workspace_id = state.validate_workspace()
27
+
28
+ payload: Dict[str, Any] = {}
29
+
30
+ try:
31
+ workspace = BevorInternalBusinessClient(
32
+ creds=creds, workspace_id=workspace_id
33
+ ).get_workspace()
34
+ except Exception:
35
+ output.error(
36
+ f"Workspace {state.workspace_id} no longer exists (deleted on dashboard?).",
37
+ hint="Run `bevor link` to reconfigure.",
38
+ )
39
+ raise typer.Exit(1)
40
+
41
+ lines = [f"Workspace : {workspace.name} [{workspace.id}]"]
42
+ payload["workspace"] = workspace.model_dump(mode="json")
43
+ payload["project"] = None
44
+ payload["code"] = None
45
+ payload["analysis"] = None
46
+
47
+ if not state.project_id:
48
+ lines += ["Project : (not set)", "Code : (not set)", "Analysis : (not set)"]
49
+ else:
50
+ client = BevorInternalClient(
51
+ creds=creds, workspace_id=workspace_id, project_id=state.project_id
52
+ )
53
+
54
+ try:
55
+ project = client.projects.get()
56
+ lines.append(f"Project : {project.name} [{project.id}]")
57
+ payload["project"] = project.model_dump(mode="json")
58
+ except Exception:
59
+ lines.append(f"Project : (stale — {state.project_id} not found)")
60
+
61
+ if state.code_id:
62
+ try:
63
+ code = client.codes.get(ID(state.code_id))
64
+ lines.append(f"Code : {code.id}")
65
+ payload["code"] = code.model_dump(mode="json")
66
+ except Exception:
67
+ lines.append(f"Code : (stale — {state.code_id} not found)")
68
+ else:
69
+ lines.append("Code : (not set)")
70
+
71
+ if state.analysis_id:
72
+ try:
73
+ analysis = client.analyses.get(ID(state.analysis_id))
74
+ lines.append(
75
+ f"Analysis : {analysis.name} {analysis.version.code_version.id} [{analysis.id}]"
76
+ )
77
+ payload["analysis"] = analysis.model_dump(mode="json")
78
+ except Exception:
79
+ lines.append(f"Analysis : (stale — {state.analysis_id} not found)")
80
+ else:
81
+ lines.append("Analysis : (not set)")
82
+
83
+ payload["config"] = state.model_dump(mode="json")
84
+
85
+ if json_out:
86
+ console.print_json(json.dumps(payload, indent=2, default=str))
87
+ return
88
+
89
+ for line in lines:
90
+ print(line)
91
+
92
+ output.info("\nChange any of these with `bevor \\[resource] select [id]`")
File without changes
@@ -0,0 +1,28 @@
1
+ """``bevor findings validate``."""
2
+
3
+ import typer
4
+ from bevor_sdk.types import ID
5
+
6
+ from bevor_cli import output
7
+ from bevor_cli.client import BevorInternalClient
8
+ from bevor_cli.configuration.creds import Credentials
9
+ from bevor_cli.configuration.state import State
10
+
11
+
12
+ def acknowledge_finding(
13
+ finding_id: str = typer.Argument(..., help="Finding id to toggle validation for."),
14
+ ) -> None:
15
+ """Toggle acknowledgement status for a finding."""
16
+ creds = Credentials.load()
17
+ creds.require_token()
18
+
19
+ state = State.load()
20
+ workspace_id = state.validate_workspace()
21
+
22
+ client = BevorInternalClient(
23
+ creds=creds, workspace_id=workspace_id, project_id=state.validate_project()
24
+ )
25
+
26
+ client.findings.toggle_acknowledgement(ID(finding_id))
27
+
28
+ output.success("Finding validation toggled.")
@@ -0,0 +1,236 @@
1
+ from pathlib import Path
2
+ from typing import Optional, cast, get_args
3
+
4
+ import typer
5
+ from bevor_sdk.types import ID
6
+ from bevor_sdk.types.literals import FindingLevel, FindingType
7
+ from bevor_sdk.types.requests.security import (
8
+ FindingBodyDict,
9
+ FindingLocationLogicalDict,
10
+ FindingLocationNodeIdDict,
11
+ FindingLocationPhysicalDict,
12
+ FindingMetadataDict,
13
+ )
14
+ from bevor_sdk.types.requests.security.findings import AddFindingBodyDict
15
+ from rich.prompt import Confirm, Prompt
16
+
17
+ from bevor_cli import output
18
+ from bevor_cli.client import BevorInternalClient
19
+ from bevor_cli.configuration.creds import Credentials
20
+ from bevor_cli.configuration.state import State
21
+ from bevor_cli.findings_yaml import (
22
+ FindingFormatError,
23
+ FindingYamlParser,
24
+ )
25
+
26
+
27
+ def _pick_from_list(options: list[str], title: str) -> str:
28
+ typer.echo(title)
29
+ for i, x in enumerate(options, 1):
30
+ typer.echo(f" [{i}] {x}")
31
+ raw = typer.prompt(f"Number 1–{len(options)}, or type the value", default="1").strip()
32
+ try:
33
+ idx = int(raw)
34
+ if 1 <= idx <= len(options):
35
+ return options[idx - 1]
36
+ except ValueError:
37
+ pass
38
+ if raw in options:
39
+ return raw
40
+ output.error(f"Unknown choice: {raw!r}")
41
+ raise typer.Exit(1)
42
+
43
+
44
+ def _interactive_parsed_doc(
45
+ tool: str | None = None, node_id: Optional[str] = None
46
+ ) -> FindingBodyDict:
47
+ if node_id:
48
+ scope_raw = node_id
49
+ typer.echo(f"Entry point node id: {scope_raw}")
50
+ else:
51
+ scope_raw = ""
52
+ while not scope_raw:
53
+ scope_raw = Prompt.ask("Entry point node id", show_default=False).strip()
54
+ if not scope_raw:
55
+ output.info("Scope is required.")
56
+
57
+ location = FindingLocationNodeIdDict(strategy="node_id", node_id=scope_raw)
58
+
59
+ finding_body = FindingBodyDict(tool=tool, location=location)
60
+
61
+ has_finding = Confirm.ask(
62
+ "Is there a finding associated with this node? ('n' if you just want to mark the node as analyzed)"
63
+ )
64
+
65
+ if has_finding:
66
+ ftype = Prompt.ask("Finding type:")
67
+ lvl = _pick_from_list(list(get_args(FindingLevel)), "Severity level:")
68
+ name = ""
69
+ while not name:
70
+ name = Prompt.ask("Finding title / name")
71
+ if not name:
72
+ output.info("Name is required.")
73
+
74
+ explanation = ""
75
+ while not explanation:
76
+ explanation = Prompt.ask("Explanation").strip()
77
+ if not explanation:
78
+ output.info("Explanation is required.")
79
+
80
+ rec = Prompt.ask("Recommendation (optional)", default="", show_default=False).strip()
81
+ poc = Prompt.ask("POC (optional)", default="", show_default=False).strip()
82
+
83
+ finding_body["metadata"] = FindingMetadataDict(
84
+ type=cast(FindingType, ftype),
85
+ level=cast(FindingLevel, lvl),
86
+ name=name.strip(),
87
+ explanation=explanation,
88
+ recommendation=rec,
89
+ poc=poc,
90
+ )
91
+
92
+ return finding_body
93
+
94
+
95
+ def add_finding(
96
+ version: Optional[str] = typer.Option(
97
+ None,
98
+ "--version",
99
+ help="Version id to stage against (default: configured analysis head).",
100
+ ),
101
+ file: Path | None = typer.Option(
102
+ None, "--file", help="YAML file containing exactly one finding mapping."
103
+ ),
104
+ interactive: bool = typer.Option(False, "--interactive", help="Force interactive prompts."),
105
+ # finding fields
106
+ type_: Optional[str] = typer.Option(None, "--type"),
107
+ level: Optional[FindingLevel] = typer.Option(None, "--level"),
108
+ name: Optional[str] = typer.Option(None, "--name"),
109
+ explanation: Optional[str] = typer.Option(None, "--explanation"),
110
+ recommendation: Optional[str] = typer.Option(None, "--recommendation"),
111
+ poc: Optional[str] = typer.Option(None, "--poc"),
112
+ tool: Optional[str] = typer.Option(None, "--tool"),
113
+ # node_id
114
+ node_id: Optional[str] = typer.Option(None, "--node"),
115
+ location_id: Optional[str] = typer.Option(None, "--location"),
116
+ # physical
117
+ uri: Optional[str] = typer.Option(None, "--uri"),
118
+ start_line: Optional[int] = typer.Option(None, "--start-line"),
119
+ start_column: Optional[int] = typer.Option(None, "--start-column"),
120
+ end_line: Optional[int] = typer.Option(None, "--end-line"),
121
+ end_column: Optional[int] = typer.Option(None, "--end-column"),
122
+ char_offset: Optional[int] = typer.Option(None, "--char-offset"),
123
+ char_length: Optional[int] = typer.Option(None, "--char-length"),
124
+ byte_offset: Optional[int] = typer.Option(None, "--byte-offset"),
125
+ byte_length: Optional[int] = typer.Option(None, "--byte-length"),
126
+ # logical
127
+ fully_qualified_name: Optional[str] = typer.Option(None, "--fqn"),
128
+ ) -> None:
129
+ """Stage a new finding to your currently configured analysis, or to the --version.
130
+
131
+ Provide input one of three ways:
132
+
133
+ - ``--file``: a YAML file with exactly one finding mapping.
134
+ - ``--interactive``: prompts for each field.
135
+ - Flags directly, as described below.
136
+
137
+ When passing flags, specify a location, which specifies the code node that the finding is associated with, using exactly one of:
138
+
139
+ - ``--node`` and/or ``--location`` (a Bevor node id; ``--location`` alone
140
+ fans the finding out to every entrypoint that reaches it)
141
+ - ``--uri`` plus one full pair of ``--start-line``/``--end-line``,
142
+ ``--char-offset``/``--char-length``, or ``--byte-offset``/``--byte-length``
143
+ - ``--fqn``
144
+
145
+ Metadata (``--type``, ``--level``, ``--name``, ``--explanation``) is all or
146
+ nothing: provide all four to record a finding, or omit all four to mark the
147
+ location as analyzed with no finding. ``--recommendation``, ``--reference``,
148
+ and ``--tool`` are always optional.
149
+ """
150
+
151
+ creds = Credentials.load()
152
+ creds.require_token()
153
+
154
+ state = State.load()
155
+ workspace_id = state.validate_workspace()
156
+ client = BevorInternalClient(
157
+ creds=creds, workspace_id=workspace_id, project_id=state.validate_project()
158
+ )
159
+
160
+ body = AddFindingBodyDict(findings=[])
161
+ if version is None:
162
+ analysis_id = state.validate_analysis()
163
+ else:
164
+ version_id = ID(version)
165
+ v_ = client.analysis_versions.get(version_id)
166
+ analysis_id = v_.analysis_meta.id
167
+ body["version_id"] = version_id
168
+
169
+ finding_body = None
170
+ location = None
171
+ if interactive:
172
+ finding_body = _interactive_parsed_doc(node_id=node_id, tool=tool)
173
+ elif file is not None:
174
+ try:
175
+ finding_yaml = FindingYamlParser.from_file(path=file)
176
+ finding_body = finding_yaml.parse()
177
+ except OSError as exc:
178
+ output.error(f"Cannot read file: {exc}")
179
+ raise typer.Exit(1) from exc
180
+ except FindingFormatError as exc:
181
+ output.error(
182
+ str(exc),
183
+ hint=(
184
+ "One YAML mapping per file. For many findings use "
185
+ "``bevor agent submit`` "
186
+ "(one ``.yaml`` per finding under ``--dir``)."
187
+ ),
188
+ )
189
+ raise typer.Exit(1) from exc
190
+ elif node_id:
191
+ location = FindingLocationNodeIdDict(
192
+ strategy="node_id", node_id=node_id, location_id=location_id
193
+ )
194
+ elif uri:
195
+ location = FindingLocationPhysicalDict(
196
+ strategy="physical",
197
+ uri=uri,
198
+ startLine=start_line,
199
+ startColumn=start_column,
200
+ endLine=end_line,
201
+ endColumn=end_column,
202
+ charOffset=char_offset,
203
+ charLength=char_length,
204
+ byteOffset=byte_offset,
205
+ byteLength=byte_length,
206
+ )
207
+ elif fully_qualified_name:
208
+ location = FindingLocationLogicalDict(
209
+ strategy="logical", fully_qualified_name=fully_qualified_name
210
+ )
211
+ else:
212
+ output.error(
213
+ "--interactive mode, a file path, or location by node, physical, or logical is required"
214
+ )
215
+ raise typer.Exit(1)
216
+
217
+ if not finding_body:
218
+ assert location is not None
219
+
220
+ finding_body = FindingBodyDict(tool=tool, location=location)
221
+
222
+ if type_ and level and name and explanation:
223
+ finding_body["metadata"] = FindingMetadataDict(
224
+ type=type_,
225
+ level=level,
226
+ name=name,
227
+ explanation=explanation,
228
+ recommendation=recommendation,
229
+ poc=poc,
230
+ )
231
+
232
+ body["findings"] = [finding_body]
233
+
234
+ findings_added = client.analyses.add_findings(analysis_id=analysis_id, body=body)
235
+
236
+ output.success(f"{len(findings_added.results)} Finding staged.")