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.
- bevor_cli/__init__.py +0 -0
- bevor_cli/annotations/__init__.py +0 -0
- bevor_cli/annotations/confirmations.py +16 -0
- bevor_cli/annotations/query.py +25 -0
- bevor_cli/apps/agent.py +10 -0
- bevor_cli/apps/analyses.py +47 -0
- bevor_cli/apps/chat.py +16 -0
- bevor_cli/apps/codes.py +18 -0
- bevor_cli/apps/findings.py +22 -0
- bevor_cli/apps/main.py +61 -0
- bevor_cli/apps/nodes.py +17 -0
- bevor_cli/apps/projects.py +14 -0
- bevor_cli/apps/sessions.py +13 -0
- bevor_cli/apps/workspaces.py +16 -0
- bevor_cli/client.py +157 -0
- bevor_cli/commands/__init__.py +0 -0
- bevor_cli/commands/agent/__init__.py +0 -0
- bevor_cli/commands/agent/materialze_cmd.py +65 -0
- bevor_cli/commands/agent/setup_cmd.py +18 -0
- bevor_cli/commands/analyses/__init__.py +0 -0
- bevor_cli/commands/analyses/active.py +42 -0
- bevor_cli/commands/analyses/chats/__init__.py +0 -0
- bevor_cli/commands/analyses/chats/get.py +39 -0
- bevor_cli/commands/analyses/chats/list.py +64 -0
- bevor_cli/commands/analyses/chats/messages.py +41 -0
- bevor_cli/commands/analyses/chats/new.py +44 -0
- bevor_cli/commands/analyses/chats/resume.py +43 -0
- bevor_cli/commands/analyses/chats/session.py +295 -0
- bevor_cli/commands/analyses/child.py +81 -0
- bevor_cli/commands/analyses/commit.py +49 -0
- bevor_cli/commands/analyses/create.py +105 -0
- bevor_cli/commands/analyses/delete.py +65 -0
- bevor_cli/commands/analyses/diff.py +87 -0
- bevor_cli/commands/analyses/entrypoints.py +56 -0
- bevor_cli/commands/analyses/findings.py +129 -0
- bevor_cli/commands/analyses/fork.py +47 -0
- bevor_cli/commands/analyses/list.py +85 -0
- bevor_cli/commands/analyses/merge.py +46 -0
- bevor_cli/commands/analyses/retry.py +77 -0
- bevor_cli/commands/analyses/run.py +87 -0
- bevor_cli/commands/analyses/select.py +85 -0
- bevor_cli/commands/analyses/set_head.py +45 -0
- bevor_cli/commands/analyses/transitions.py +90 -0
- bevor_cli/commands/analyses/view.py +94 -0
- bevor_cli/commands/auth/__init__.py +7 -0
- bevor_cli/commands/auth/login.py +135 -0
- bevor_cli/commands/auth/logout.py +23 -0
- bevor_cli/commands/auth/whoami.py +21 -0
- bevor_cli/commands/codes/__init__.py +0 -0
- bevor_cli/commands/codes/list.py +90 -0
- bevor_cli/commands/codes/push.py +76 -0
- bevor_cli/commands/codes/select.py +93 -0
- bevor_cli/commands/codes/view.py +80 -0
- bevor_cli/commands/context/__init__.py +92 -0
- bevor_cli/commands/findings/__init__.py +0 -0
- bevor_cli/commands/findings/acknowledge.py +28 -0
- bevor_cli/commands/findings/add.py +236 -0
- bevor_cli/commands/findings/bulk.py +110 -0
- bevor_cli/commands/findings/delete.py +28 -0
- bevor_cli/commands/findings/edit.py +109 -0
- bevor_cli/commands/findings/get.py +62 -0
- bevor_cli/commands/findings/interactive.py +329 -0
- bevor_cli/commands/findings/list.py +108 -0
- bevor_cli/commands/findings/revert.py +28 -0
- bevor_cli/commands/graph/__init__.py +0 -0
- bevor_cli/commands/graph/call_chain.py +48 -0
- bevor_cli/commands/graph/edges.py +59 -0
- bevor_cli/commands/graph/interactive.py +207 -0
- bevor_cli/commands/graph/renderer.py +135 -0
- bevor_cli/commands/link/__init__.py +52 -0
- bevor_cli/commands/link/helpers.py +26 -0
- bevor_cli/commands/nodes/__init__.py +0 -0
- bevor_cli/commands/nodes/get.py +52 -0
- bevor_cli/commands/nodes/list.py +75 -0
- bevor_cli/commands/nodes/source.py +40 -0
- bevor_cli/commands/projects/__init__.py +0 -0
- bevor_cli/commands/projects/create.py +54 -0
- bevor_cli/commands/projects/list.py +57 -0
- bevor_cli/commands/projects/select.py +75 -0
- bevor_cli/commands/projects/view.py +49 -0
- bevor_cli/commands/sessions/__init__.py +0 -0
- bevor_cli/commands/sessions/list.py +36 -0
- bevor_cli/commands/sessions/revoke.py +35 -0
- bevor_cli/commands/workspaces/__init__.py +0 -0
- bevor_cli/commands/workspaces/list.py +50 -0
- bevor_cli/commands/workspaces/pick.py +20 -0
- bevor_cli/commands/workspaces/select.py +68 -0
- bevor_cli/commands/workspaces/view.py +48 -0
- bevor_cli/configuration/creds.py +52 -0
- bevor_cli/configuration/settings.py +54 -0
- bevor_cli/configuration/state.py +113 -0
- bevor_cli/findings_yaml.py +291 -0
- bevor_cli/main.py +33 -0
- bevor_cli/output.py +69 -0
- bevor_cli/select.py +64 -0
- bevor_cli/settings.py +4 -0
- bevor_cli-0.1.0.dist-info/METADATA +14 -0
- bevor_cli-0.1.0.dist-info/RECORD +100 -0
- bevor_cli-0.1.0.dist-info/WHEEL +4 -0
- bevor_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""``bevor findings bulk`` / ``bevor agent submit``."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional, cast
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from bevor_sdk.helpers.converters.sarif import sarif_to_bevor
|
|
9
|
+
from bevor_sdk.types import ID
|
|
10
|
+
from bevor_sdk.types.requests.security import FindingBodyDict
|
|
11
|
+
from bevor_sdk.types.requests.security.findings import AddFindingBodyDict
|
|
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.state import State
|
|
17
|
+
from bevor_cli.findings_yaml import FindingYamlParser
|
|
18
|
+
|
|
19
|
+
_YAML_SUFFIXES = frozenset({".yaml", ".yml"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read(path: Path) -> str:
|
|
23
|
+
try:
|
|
24
|
+
return path.read_text(encoding="utf-8")
|
|
25
|
+
except OSError as exc:
|
|
26
|
+
output.error(f"Cannot read file: {exc}")
|
|
27
|
+
raise typer.Exit(1) from exc
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def bulk_findings(
|
|
31
|
+
source: Path = typer.Argument(
|
|
32
|
+
..., help="A .json file, .sarif file, .yaml/.yml file, or a directory of YAML files."
|
|
33
|
+
),
|
|
34
|
+
version: Optional[str] = typer.Option(
|
|
35
|
+
None,
|
|
36
|
+
"--version",
|
|
37
|
+
help="Version id to stage against (default: configured analysis head).",
|
|
38
|
+
),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Import many findings at once to the configured analysis head, or ``--version`` if given
|
|
41
|
+
|
|
42
|
+
``source`` is dispatched by type:
|
|
43
|
+
|
|
44
|
+
- a directory: every ``.yaml``/``.yml`` file in it is parsed, each of which
|
|
45
|
+
may itself contain multiple findings (``---``-separated documents).
|
|
46
|
+
- a ``.yaml``/``.yml`` file: parsed the same way, as a single source.
|
|
47
|
+
- a ``.sarif`` file: converted from SARIF into Bevor findings.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
creds = Credentials.load()
|
|
51
|
+
creds.require_token()
|
|
52
|
+
|
|
53
|
+
state = State.load()
|
|
54
|
+
workspace_id = state.validate_workspace()
|
|
55
|
+
|
|
56
|
+
client = BevorInternalClient(
|
|
57
|
+
creds=creds, workspace_id=workspace_id, project_id=state.validate_project()
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
body = AddFindingBodyDict(findings=[])
|
|
61
|
+
version_id: ID | None = None
|
|
62
|
+
if version is None:
|
|
63
|
+
analysis_id = state.validate_analysis()
|
|
64
|
+
else:
|
|
65
|
+
version_id = ID(version)
|
|
66
|
+
v_ = client.analysis_versions.get(version_id)
|
|
67
|
+
analysis_id = v_.analysis_meta.id
|
|
68
|
+
body["version_id"] = version_id
|
|
69
|
+
|
|
70
|
+
if source.is_dir():
|
|
71
|
+
findings = FindingYamlParser.from_directory(source).parse_many()
|
|
72
|
+
elif source.suffix.lower() in _YAML_SUFFIXES:
|
|
73
|
+
if not source.is_file():
|
|
74
|
+
output.error(f"File does not exist: {source}")
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
findings = FindingYamlParser.from_file(source).parse_many()
|
|
77
|
+
elif source.suffix.lower() == ".sarif":
|
|
78
|
+
if not source.is_file():
|
|
79
|
+
output.error(f"File does not exist: {source}")
|
|
80
|
+
raise typer.Exit(1)
|
|
81
|
+
try:
|
|
82
|
+
sarif_raw = json.loads(_read(source))
|
|
83
|
+
except json.JSONDecodeError as exc:
|
|
84
|
+
output.error(f"Invalid JSON in {source.name}: {exc}")
|
|
85
|
+
raise typer.Exit(1) from exc
|
|
86
|
+
try:
|
|
87
|
+
findings = sarif_to_bevor(sarif_raw)
|
|
88
|
+
except ValueError as exc:
|
|
89
|
+
output.error(f"Invalid sarif format {exc.args}")
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
elif source.suffix.lower() == ".json":
|
|
92
|
+
if not source.is_file():
|
|
93
|
+
output.error(f"File does not exist: {source}")
|
|
94
|
+
raise typer.Exit(1)
|
|
95
|
+
try:
|
|
96
|
+
raw = json.loads(_read(source))
|
|
97
|
+
except json.JSONDecodeError as exc:
|
|
98
|
+
output.error(f"Could not read JSON: {exc}")
|
|
99
|
+
raise typer.Exit(1) from exc
|
|
100
|
+
findings = cast(List[FindingBodyDict], raw["findings"])
|
|
101
|
+
else:
|
|
102
|
+
output.error(
|
|
103
|
+
"Unsupported source (use a .json file, .sarif file, .yaml/.yml file, or a directory of YAML files)."
|
|
104
|
+
)
|
|
105
|
+
raise typer.Exit(1)
|
|
106
|
+
|
|
107
|
+
body["findings"] = findings
|
|
108
|
+
|
|
109
|
+
findings_added = client.analyses.add_findings(analysis_id=analysis_id, body=body)
|
|
110
|
+
output.success(f"Imported {len(findings_added.results)} finding(s) onto version {version_id or 'head'}.")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""``bevor analyses findings delete``."""
|
|
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 delete_finding(
|
|
13
|
+
finding_id: str = typer.Argument(..., help="Finding id to delete."),
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Stage a finding deletion."""
|
|
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.delete(ID(finding_id))
|
|
27
|
+
|
|
28
|
+
output.success("Finding deletion staged.")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""``bevor analyses findings edit``."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import typer
|
|
8
|
+
from bevor_sdk.types import ID
|
|
9
|
+
from bevor_sdk.types.requests.security import EditFindingBodyDict
|
|
10
|
+
|
|
11
|
+
from bevor_cli import output
|
|
12
|
+
from bevor_cli.client import BevorInternalClient
|
|
13
|
+
from bevor_cli.configuration.creds import Credentials
|
|
14
|
+
from bevor_cli.configuration.state import State
|
|
15
|
+
from bevor_cli.findings_yaml import FindingFormatError, FindingYamlParser, ParsedFindingDoc
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _interactive_edit_doc(client: BevorInternalClient, *, finding_id: str) -> ParsedFindingDoc:
|
|
19
|
+
existing = client.findings.get(ID(finding_id))
|
|
20
|
+
|
|
21
|
+
ftype = typer.prompt("Type", default=str(existing.type)).strip()
|
|
22
|
+
lvl = typer.prompt("Level", default=str(existing.level)).strip()
|
|
23
|
+
fname = typer.prompt("Name", default=existing.name).strip()
|
|
24
|
+
|
|
25
|
+
typer.echo("", err=True)
|
|
26
|
+
typer.echo(
|
|
27
|
+
"Opening your $EDITOR — edit explanation (Markdown ok). Save and exit to continue.",
|
|
28
|
+
err=True,
|
|
29
|
+
)
|
|
30
|
+
explanation = click.edit(existing.explanation)
|
|
31
|
+
if explanation is None:
|
|
32
|
+
output.error("Aborted.")
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
body_st = explanation.strip()
|
|
35
|
+
if not body_st:
|
|
36
|
+
output.error("Explanation cannot be empty.")
|
|
37
|
+
raise typer.Exit(1)
|
|
38
|
+
|
|
39
|
+
rec_raw = typer.prompt(
|
|
40
|
+
"Recommendation (optional, empty to clear)",
|
|
41
|
+
default=existing.recommendation or "",
|
|
42
|
+
show_default=False,
|
|
43
|
+
).strip()
|
|
44
|
+
|
|
45
|
+
yaml_doc: dict[str, Any] = {
|
|
46
|
+
"type": ftype,
|
|
47
|
+
"level": lvl,
|
|
48
|
+
"name": fname,
|
|
49
|
+
"explanation": body_st,
|
|
50
|
+
}
|
|
51
|
+
if rec_raw:
|
|
52
|
+
yaml_doc["recommendation"] = rec_raw
|
|
53
|
+
|
|
54
|
+
return ParsedFindingDoc(index=1, yaml=yaml_doc)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def edit_finding(
|
|
58
|
+
finding_id: str = typer.Argument(..., help="Finding id to edit."),
|
|
59
|
+
file: Path | None = typer.Option(
|
|
60
|
+
None,
|
|
61
|
+
"--file",
|
|
62
|
+
"-f",
|
|
63
|
+
help="YAML with one mapping: ``type``, ``level``, ``name``, ``explanation``; optional "
|
|
64
|
+
"``recommendation`` / ``reference``.",
|
|
65
|
+
),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Stage an edit to a finding (`--file` YAML or interactive matching current metadata)."""
|
|
68
|
+
|
|
69
|
+
creds = Credentials.load()
|
|
70
|
+
creds.require_token()
|
|
71
|
+
|
|
72
|
+
state = State.load()
|
|
73
|
+
workspace_id = state.validate_workspace()
|
|
74
|
+
|
|
75
|
+
client = BevorInternalClient(
|
|
76
|
+
creds=creds, workspace_id=workspace_id, project_id=state.validate_project()
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if file is not None:
|
|
80
|
+
try:
|
|
81
|
+
payload, err = FindingYamlParser.from_file(file).parse_edit()
|
|
82
|
+
except FindingFormatError as exc:
|
|
83
|
+
output.error(
|
|
84
|
+
str(exc), hint="One YAML mapping per file (same layout as findings add ``--file``)."
|
|
85
|
+
)
|
|
86
|
+
raise typer.Exit(1) from exc
|
|
87
|
+
else:
|
|
88
|
+
payload, err = FindingYamlParser.validate_edit_doc(
|
|
89
|
+
_interactive_edit_doc(client, finding_id=finding_id)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if err:
|
|
93
|
+
output.error(err)
|
|
94
|
+
raise typer.Exit(1)
|
|
95
|
+
assert payload is not None
|
|
96
|
+
|
|
97
|
+
client.findings.edit_metadata(
|
|
98
|
+
ID(finding_id),
|
|
99
|
+
EditFindingBodyDict(
|
|
100
|
+
type=payload["type"],
|
|
101
|
+
level=payload["level"],
|
|
102
|
+
name=str(payload["name"]),
|
|
103
|
+
explanation=str(payload["explanation"]),
|
|
104
|
+
recommendation=payload.get("recommendation"),
|
|
105
|
+
poc=payload.get("poc"),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
output.success("Finding edit staged.")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""``bevor analyses findings get`` — same lookup as ``bevor findings get``."""
|
|
2
|
+
|
|
3
|
+
from typing import Mapping
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from bevor_sdk.types import ID
|
|
7
|
+
from bevor_sdk.types.literals import FindingLevel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from bevor_cli.annotations.confirmations import JSON_OUT
|
|
12
|
+
from bevor_cli.client import BevorInternalClient
|
|
13
|
+
from bevor_cli.configuration.creds import Credentials
|
|
14
|
+
from bevor_cli.configuration.state import State
|
|
15
|
+
from bevor_cli.output import console
|
|
16
|
+
|
|
17
|
+
LEVEL_COLORS: Mapping[FindingLevel, str] = {
|
|
18
|
+
"critical": "red",
|
|
19
|
+
"high": "bright_red",
|
|
20
|
+
"medium": "yellow",
|
|
21
|
+
"low": "blue",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_finding(
|
|
26
|
+
finding_id: str = typer.Argument(..., help="Finding ID."), json_out: JSON_OUT = False
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Get the finding details."""
|
|
29
|
+
creds = Credentials.load()
|
|
30
|
+
creds.require_token()
|
|
31
|
+
|
|
32
|
+
state = State.load()
|
|
33
|
+
workspace_id = state.validate_workspace()
|
|
34
|
+
|
|
35
|
+
client = BevorInternalClient(
|
|
36
|
+
creds=creds, workspace_id=workspace_id, project_id=state.validate_project()
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
finding = client.findings.get(ID(finding_id))
|
|
40
|
+
|
|
41
|
+
if json_out:
|
|
42
|
+
typer.echo(finding.model_dump_json(indent=2))
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
level_style = LEVEL_COLORS[finding.level]
|
|
46
|
+
|
|
47
|
+
table = Table(show_header=False, box=None, padding=(0, 1))
|
|
48
|
+
table.add_column(style="bold")
|
|
49
|
+
table.add_column()
|
|
50
|
+
|
|
51
|
+
table.add_row("ID", str(finding.id))
|
|
52
|
+
table.add_row("Code", str(finding.code_id))
|
|
53
|
+
table.add_row("Name", finding.name)
|
|
54
|
+
table.add_row("Type", str(finding.type))
|
|
55
|
+
table.add_row("Level", Text(finding.level, style=level_style))
|
|
56
|
+
table.add_row("Explanation", finding.explanation)
|
|
57
|
+
if finding.recommendation:
|
|
58
|
+
table.add_row("Recommendation", finding.recommendation)
|
|
59
|
+
if finding.poc:
|
|
60
|
+
table.add_row("POC (unverified)", finding.poc)
|
|
61
|
+
|
|
62
|
+
console.print(table)
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Interactive + static Rich display for findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textwrap import wrap
|
|
6
|
+
from typing import Callable, List
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import typer
|
|
10
|
+
from bevor_sdk.types.literals import FindingDraftOperation
|
|
11
|
+
from bevor_sdk.types.responses.security import FindingSchema
|
|
12
|
+
from rich.console import Group
|
|
13
|
+
from rich.live import Live
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.rule import Rule
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from bevor_cli import output
|
|
20
|
+
from bevor_cli.client import BevorInternalClient
|
|
21
|
+
from bevor_cli.commands.findings.get import LEVEL_COLORS
|
|
22
|
+
|
|
23
|
+
_LIST_WINDOW = 8
|
|
24
|
+
_MIN_DETAIL_LINES = 6
|
|
25
|
+
|
|
26
|
+
_OP_COLORS = {"add": "green", "edit": "yellow", "delete": "red"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _op_badge(operation: FindingDraftOperation | None) -> Text:
|
|
30
|
+
if not operation:
|
|
31
|
+
return Text("—", style="dim")
|
|
32
|
+
return Text(operation.upper(), style=f"bold {_OP_COLORS.get(operation, 'white')}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _nav_delta(key: str) -> int:
|
|
36
|
+
if not key:
|
|
37
|
+
return 0
|
|
38
|
+
if len(key) == 1 and key.lower() in ("j", "l"):
|
|
39
|
+
return 1
|
|
40
|
+
if len(key) == 1 and key.lower() in ("k", "h"):
|
|
41
|
+
return -1
|
|
42
|
+
if key.endswith("B"): # down arrow
|
|
43
|
+
return 1
|
|
44
|
+
if key.endswith("A"): # up arrow
|
|
45
|
+
return -1
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _action_hints(finding: FindingSchema) -> str:
|
|
50
|
+
parts = ["↑↓/j/k move", "q quit"]
|
|
51
|
+
if finding.actions.edit.is_allowed:
|
|
52
|
+
parts.append("e edit")
|
|
53
|
+
if finding.actions.revert.is_allowed:
|
|
54
|
+
parts.append("r revert")
|
|
55
|
+
if finding.actions.delete.is_allowed:
|
|
56
|
+
parts.append("x delete")
|
|
57
|
+
return " ".join(parts)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _fit_text(text: str, *, max_lines: int, width: int) -> Text:
|
|
61
|
+
if not text:
|
|
62
|
+
return Text("—")
|
|
63
|
+
lines = wrap(text, width=width)
|
|
64
|
+
if len(lines) <= max_lines:
|
|
65
|
+
return Text("\n".join(lines))
|
|
66
|
+
clipped = lines[:max_lines]
|
|
67
|
+
clipped[-1] = clipped[-1][: max(0, width - 1)] + "…"
|
|
68
|
+
return Text("\n".join(clipped))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _detail_text_budget() -> int:
|
|
72
|
+
height = output.console.size.height
|
|
73
|
+
list_rows = _LIST_WINDOW + 5 # header, caption, borders
|
|
74
|
+
overhead = list_rows + 14 # context panel, rule, hints
|
|
75
|
+
return max(_MIN_DETAIL_LINES, height - overhead)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _findings_table(
|
|
79
|
+
rows: List[FindingSchema],
|
|
80
|
+
focus: int | None = None,
|
|
81
|
+
*,
|
|
82
|
+
windowed: bool = False,
|
|
83
|
+
) -> Group | Table:
|
|
84
|
+
if not windowed or focus is None:
|
|
85
|
+
table = Table("Level", "Type", "Name", "Original", "ID")
|
|
86
|
+
for i, finding in enumerate(rows):
|
|
87
|
+
style = "reverse" if focus is not None and i == focus else ""
|
|
88
|
+
table.add_row(
|
|
89
|
+
Text(finding.level, style=LEVEL_COLORS[finding.level]),
|
|
90
|
+
finding.type,
|
|
91
|
+
finding.name,
|
|
92
|
+
str(finding.origin_finding_id == finding.id),
|
|
93
|
+
str(finding.id),
|
|
94
|
+
style=style,
|
|
95
|
+
)
|
|
96
|
+
return table
|
|
97
|
+
|
|
98
|
+
start = max(0, min(focus - _LIST_WINDOW // 2, len(rows) - _LIST_WINDOW))
|
|
99
|
+
end = min(len(rows), start + _LIST_WINDOW)
|
|
100
|
+
start = max(0, end - _LIST_WINDOW)
|
|
101
|
+
|
|
102
|
+
above = start
|
|
103
|
+
below = len(rows) - end
|
|
104
|
+
table = Table(
|
|
105
|
+
"Level",
|
|
106
|
+
"Type",
|
|
107
|
+
"Name",
|
|
108
|
+
"Original",
|
|
109
|
+
"ID",
|
|
110
|
+
caption=f"↑ {above} {focus + 1}/{len(rows)} ↓ {below}",
|
|
111
|
+
caption_justify="center",
|
|
112
|
+
)
|
|
113
|
+
for i in range(start, end):
|
|
114
|
+
finding = rows[i]
|
|
115
|
+
table.add_row(
|
|
116
|
+
Text(finding.level, style=LEVEL_COLORS[finding.level]),
|
|
117
|
+
finding.type,
|
|
118
|
+
finding.name,
|
|
119
|
+
str(finding.origin_finding_id == finding.id),
|
|
120
|
+
str(finding.id),
|
|
121
|
+
style="reverse" if i == focus else "",
|
|
122
|
+
)
|
|
123
|
+
for _ in range(_LIST_WINDOW - (end - start)):
|
|
124
|
+
table.add_row("", "", "", "", "", style="dim")
|
|
125
|
+
|
|
126
|
+
return table
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _findings_table_with_ops(
|
|
130
|
+
rows: List[FindingSchema],
|
|
131
|
+
focus: int | None = None,
|
|
132
|
+
*,
|
|
133
|
+
windowed: bool = False,
|
|
134
|
+
) -> Group | Table:
|
|
135
|
+
if not windowed or focus is None:
|
|
136
|
+
table = Table("Op", "Level", "Type", "Name", "Original", "ID")
|
|
137
|
+
for i, finding in enumerate(rows):
|
|
138
|
+
style = "reverse" if focus is not None and i == focus else ""
|
|
139
|
+
table.add_row(
|
|
140
|
+
_op_badge(finding.operation),
|
|
141
|
+
Text(finding.level, style=LEVEL_COLORS[finding.level]),
|
|
142
|
+
finding.type,
|
|
143
|
+
finding.name,
|
|
144
|
+
str(finding.origin_finding_id == finding.id),
|
|
145
|
+
str(finding.id),
|
|
146
|
+
style=style,
|
|
147
|
+
)
|
|
148
|
+
return table
|
|
149
|
+
|
|
150
|
+
start = max(0, min(focus - _LIST_WINDOW // 2, len(rows) - _LIST_WINDOW))
|
|
151
|
+
end = min(len(rows), start + _LIST_WINDOW)
|
|
152
|
+
start = max(0, end - _LIST_WINDOW)
|
|
153
|
+
|
|
154
|
+
above = start
|
|
155
|
+
below = len(rows) - end
|
|
156
|
+
table = Table(
|
|
157
|
+
"Op",
|
|
158
|
+
"Level",
|
|
159
|
+
"Type",
|
|
160
|
+
"Name",
|
|
161
|
+
"Original",
|
|
162
|
+
"ID",
|
|
163
|
+
caption=f"↑ {above} {focus + 1}/{len(rows)} ↓ {below}",
|
|
164
|
+
caption_justify="center",
|
|
165
|
+
)
|
|
166
|
+
for i in range(start, end):
|
|
167
|
+
finding = rows[i]
|
|
168
|
+
table.add_row(
|
|
169
|
+
_op_badge(finding.operation),
|
|
170
|
+
Text(finding.level, style=LEVEL_COLORS[finding.level]),
|
|
171
|
+
finding.type,
|
|
172
|
+
finding.name,
|
|
173
|
+
str(finding.origin_finding_id == finding.id),
|
|
174
|
+
str(finding.id),
|
|
175
|
+
style="reverse" if i == focus else "",
|
|
176
|
+
)
|
|
177
|
+
for _ in range(_LIST_WINDOW - (end - start)):
|
|
178
|
+
table.add_row("", "", "", "", "", "", style="dim")
|
|
179
|
+
|
|
180
|
+
return table
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _kv_table(*, dim: bool = False) -> Table:
|
|
184
|
+
table = Table(show_header=False, box=None, padding=(0, 1))
|
|
185
|
+
label_style = "dim bold" if dim else "bold"
|
|
186
|
+
value_style = "dim" if dim else ""
|
|
187
|
+
table.add_column(style=label_style, no_wrap=True)
|
|
188
|
+
table.add_column(style=value_style)
|
|
189
|
+
return table
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _context_panel(finding: FindingSchema) -> Panel:
|
|
193
|
+
author = finding.created_by_user.username or str(finding.created_by_user.id)
|
|
194
|
+
meta = _kv_table(dim=True)
|
|
195
|
+
meta.add_row("ID", str(finding.id))
|
|
196
|
+
meta.add_row("Analysis", str(finding.version.analysis_id))
|
|
197
|
+
meta.add_row("Project", str(finding.code_id))
|
|
198
|
+
meta.add_row("Code version", str(finding.code_id))
|
|
199
|
+
meta.add_row("Created", str(finding.created_at.date()))
|
|
200
|
+
meta.add_row("Created by", author)
|
|
201
|
+
meta.add_row("Node", str(finding.node_id))
|
|
202
|
+
return Panel(meta, title="Context", border_style="dim", padding=(0, 1))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _finding_panel(finding: FindingSchema, *, text_budget: int) -> Panel:
|
|
206
|
+
width = max(40, output.console.size.width - 8)
|
|
207
|
+
header = _kv_table()
|
|
208
|
+
header.add_row("Level", Text(finding.level, style=LEVEL_COLORS[finding.level]))
|
|
209
|
+
header.add_row("Type", str(finding.type))
|
|
210
|
+
header.add_row("Acknowledged", str(finding.is_acknowledged))
|
|
211
|
+
|
|
212
|
+
sections = 1 + bool(finding.recommendation) + bool(finding.poc)
|
|
213
|
+
body_lines = max(2, text_budget - sections * 2 - 4)
|
|
214
|
+
|
|
215
|
+
body: list[Text | Table] = [header, Text("")]
|
|
216
|
+
body.append(Text("Explanation", style="bold"))
|
|
217
|
+
body.append(_fit_text(finding.explanation or "", max_lines=body_lines, width=width))
|
|
218
|
+
if finding.recommendation:
|
|
219
|
+
body.extend(
|
|
220
|
+
[
|
|
221
|
+
Text(""),
|
|
222
|
+
Text("Recommendation", style="bold"),
|
|
223
|
+
_fit_text(finding.recommendation, max_lines=3, width=width),
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
if finding.poc:
|
|
227
|
+
body.extend(
|
|
228
|
+
[
|
|
229
|
+
Text(""),
|
|
230
|
+
Text("POC", style="bold"),
|
|
231
|
+
_fit_text(finding.poc, max_lines=2, width=width),
|
|
232
|
+
]
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return Panel(Group(*body), title=finding.name, border_style="cyan", padding=(0, 1))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _detail_panel(finding: FindingSchema) -> Group:
|
|
239
|
+
return Group(
|
|
240
|
+
_context_panel(finding),
|
|
241
|
+
Rule(style="dim"),
|
|
242
|
+
_finding_panel(finding, text_budget=_detail_text_budget()),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _render(rows: List[FindingSchema], focus: int) -> Group:
|
|
247
|
+
return Group(
|
|
248
|
+
_findings_table(rows, focus=focus, windowed=True),
|
|
249
|
+
_detail_panel(rows[focus]),
|
|
250
|
+
Text("↑↓ or j/k move q quit", style="dim"),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def print_findings_table(rows: List[FindingSchema]) -> None:
|
|
255
|
+
table = _findings_table(rows)
|
|
256
|
+
assert isinstance(table, Table)
|
|
257
|
+
output.console.print(table)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def print_findings_table_with_ops(rows: List[FindingSchema]) -> None:
|
|
261
|
+
table = _findings_table_with_ops(rows)
|
|
262
|
+
assert isinstance(table, Table)
|
|
263
|
+
output.console.print(table)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def browse_findings_with_actions(
|
|
267
|
+
client: BevorInternalClient,
|
|
268
|
+
refetch: Callable[[], List[FindingSchema]],
|
|
269
|
+
rows: List[FindingSchema],
|
|
270
|
+
) -> None:
|
|
271
|
+
focus = 0
|
|
272
|
+
|
|
273
|
+
rows = list(rows)
|
|
274
|
+
|
|
275
|
+
def render() -> Group:
|
|
276
|
+
f = rows[focus]
|
|
277
|
+
return Group(
|
|
278
|
+
_findings_table_with_ops(rows, focus=focus, windowed=True),
|
|
279
|
+
_detail_panel(f),
|
|
280
|
+
Text(_action_hints(f), style="dim"),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
with Live(
|
|
284
|
+
render(),
|
|
285
|
+
console=output.console,
|
|
286
|
+
transient=True,
|
|
287
|
+
auto_refresh=False,
|
|
288
|
+
vertical_overflow="visible",
|
|
289
|
+
) as live:
|
|
290
|
+
while rows:
|
|
291
|
+
try:
|
|
292
|
+
ch = click.getchar()
|
|
293
|
+
except (EOFError, KeyboardInterrupt):
|
|
294
|
+
raise typer.Abort from None
|
|
295
|
+
|
|
296
|
+
key = ch if isinstance(ch, str) else ch.decode("utf-8", "replace")
|
|
297
|
+
if key.lower() == "q":
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
f = rows[focus]
|
|
301
|
+
|
|
302
|
+
if key.lower() == "r" and f.actions.revert.is_allowed:
|
|
303
|
+
live.stop()
|
|
304
|
+
if typer.confirm(f"Revert staged {f.operation} on {f.name!r}?"):
|
|
305
|
+
client.findings.revert(f.id)
|
|
306
|
+
rows = refetch()
|
|
307
|
+
focus = min(focus, len(rows) - 1)
|
|
308
|
+
live.start()
|
|
309
|
+
if not rows:
|
|
310
|
+
return
|
|
311
|
+
live.update(render(), refresh=True)
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
if key.lower() == "x" and f.actions.delete.is_allowed:
|
|
315
|
+
live.stop()
|
|
316
|
+
if typer.confirm(f"Stage a deletion for {f.name!r}?"):
|
|
317
|
+
client.findings.delete(f.id)
|
|
318
|
+
rows = refetch()
|
|
319
|
+
focus = min(focus, len(rows) - 1)
|
|
320
|
+
live.start()
|
|
321
|
+
if not rows:
|
|
322
|
+
return
|
|
323
|
+
live.update(render(), refresh=True)
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
delta = _nav_delta(key)
|
|
327
|
+
if delta:
|
|
328
|
+
focus = (focus + delta) % len(rows)
|
|
329
|
+
live.update(render(), refresh=True)
|