virgilhq 0.3.0__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.
- virgilhq-0.3.0/.gitignore +65 -0
- virgilhq-0.3.0/PKG-INFO +164 -0
- virgilhq-0.3.0/README.md +129 -0
- virgilhq-0.3.0/cli/__init__.py +10 -0
- virgilhq-0.3.0/cli/client.py +196 -0
- virgilhq-0.3.0/cli/config.py +77 -0
- virgilhq-0.3.0/cli/main.py +791 -0
- virgilhq-0.3.0/cli/render.py +428 -0
- virgilhq-0.3.0/pyproject.toml +73 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
.env
|
|
3
|
+
.env.local
|
|
4
|
+
.env.*.local
|
|
5
|
+
|
|
6
|
+
# Python
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*$py.class
|
|
10
|
+
*.egg-info/
|
|
11
|
+
.eggs/
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.mypy_cache/
|
|
14
|
+
.tox/
|
|
15
|
+
.coverage
|
|
16
|
+
.coverage.*
|
|
17
|
+
htmlcov/
|
|
18
|
+
*.pid
|
|
19
|
+
*.log
|
|
20
|
+
build/
|
|
21
|
+
dist/
|
|
22
|
+
venv/
|
|
23
|
+
.venv/
|
|
24
|
+
env/
|
|
25
|
+
|
|
26
|
+
# Node / Next.js
|
|
27
|
+
node_modules/
|
|
28
|
+
.next/
|
|
29
|
+
out/
|
|
30
|
+
*.tsbuildinfo
|
|
31
|
+
next-env.d.ts
|
|
32
|
+
.npm
|
|
33
|
+
.eslintcache
|
|
34
|
+
yarn-error.log
|
|
35
|
+
npm-debug.log*
|
|
36
|
+
|
|
37
|
+
# Editors / OS
|
|
38
|
+
.vscode/
|
|
39
|
+
.idea/
|
|
40
|
+
*.swp
|
|
41
|
+
*.swo
|
|
42
|
+
.DS_Store
|
|
43
|
+
Thumbs.db
|
|
44
|
+
|
|
45
|
+
# Audit runtime artifacts
|
|
46
|
+
/var/
|
|
47
|
+
audit-*.pdf
|
|
48
|
+
audit-*.md
|
|
49
|
+
audit-*.json
|
|
50
|
+
audit-*.sarif
|
|
51
|
+
audit-*.csv
|
|
52
|
+
audit-*.xlsx
|
|
53
|
+
|
|
54
|
+
# Local docker / state
|
|
55
|
+
docker/data/
|
|
56
|
+
*.sqlite
|
|
57
|
+
*.sqlite3
|
|
58
|
+
|
|
59
|
+
# Secrets — defense in depth (we never want a real .env or token file
|
|
60
|
+
# committed; .env.example is the only env file that should be tracked).
|
|
61
|
+
*.pem
|
|
62
|
+
*.key
|
|
63
|
+
*.p12
|
|
64
|
+
.github-token
|
|
65
|
+
secrets/
|
virgilhq-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: virgilhq
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: CLI for Virgil — self-hosted security audit with the triage built in. Real scanners + clustering + LLM priority queue + code-grounded chat. Installs as `virgil` on your PATH.
|
|
5
|
+
Project-URL: Homepage, https://virgilhq.app
|
|
6
|
+
Project-URL: Repository, https://github.com/ayaanmaliksgithub/virgil
|
|
7
|
+
Project-URL: Issues, https://github.com/ayaanmaliksgithub/virgil/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/ayaanmaliksgithub/virgil#readme
|
|
9
|
+
Project-URL: Changelog, https://github.com/ayaanmaliksgithub/virgil/releases
|
|
10
|
+
Author: Virgil contributors
|
|
11
|
+
License: Apache-2.0
|
|
12
|
+
Keywords: ai,audit,cli,code-audit,gitleaks,llm,sast,sca,security,self-hosted,semgrep,trivy,vulnerability
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Information Technology
|
|
17
|
+
Classifier: Intended Audience :: System Administrators
|
|
18
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Classifier: Topic :: Security
|
|
27
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
28
|
+
Classifier: Topic :: System :: Monitoring
|
|
29
|
+
Classifier: Typing :: Typed
|
|
30
|
+
Requires-Python: >=3.10
|
|
31
|
+
Requires-Dist: click>=8.1
|
|
32
|
+
Requires-Dist: requests>=2.31
|
|
33
|
+
Requires-Dist: rich>=13.7
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# virgil
|
|
37
|
+
|
|
38
|
+
> Terminal client for [Virgil](https://github.com/ayaanmaliksgithub/virgil) —
|
|
39
|
+
> self-hosted security audit with the triage built in. Real scanners +
|
|
40
|
+
> clustering + LLM priority queue + code-grounded chat.
|
|
41
|
+
|
|
42
|
+
The CLI is a thin shell over a running Virgil API. It bundles your working
|
|
43
|
+
directory, submits a scan, streams progress, and prints the ranked findings
|
|
44
|
+
with CI-friendly exit codes.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install virgilhq # or: pipx install virgilhq
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The PyPI package is `virgilhq` (the bare `virgil` name was already taken).
|
|
53
|
+
The command on your `$PATH` is still just `virgil`.
|
|
54
|
+
|
|
55
|
+
You'll also need a running Virgil instance. The standard self-hosted setup
|
|
56
|
+
is `docker compose up` from the [main repo](https://github.com/ayaanmaliksgithub/virgil)
|
|
57
|
+
— takes about a minute the first time.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Scan and land on triage (counts → ranked clusters → next-steps hint).
|
|
63
|
+
virgil scan .
|
|
64
|
+
|
|
65
|
+
# Or land on a different surface after the scan finishes.
|
|
66
|
+
virgil scan . --show report # exec narrative
|
|
67
|
+
virgil scan . --show surface # languages / frameworks / IaC profile
|
|
68
|
+
virgil scan . --show ask_virgil # drop into the chat REPL pre-flighted
|
|
69
|
+
|
|
70
|
+
# Scan a GitHub URL instead of a local path.
|
|
71
|
+
virgil scan --url https://github.com/OWASP/NodeGoat
|
|
72
|
+
|
|
73
|
+
# PR mode — only flag findings on lines changed between two SHAs.
|
|
74
|
+
virgil scan . --base-sha abc1234 --head-sha def5678
|
|
75
|
+
|
|
76
|
+
# Don't wait for the scan to finish; print the audit ID and return.
|
|
77
|
+
virgil scan . --no-wait
|
|
78
|
+
|
|
79
|
+
# After a scan, drill in:
|
|
80
|
+
virgil clusters <audit-id> # every cluster, sorted by severity
|
|
81
|
+
virgil clusters <audit-id> --sev high # filter
|
|
82
|
+
virgil cluster <audit-id> <key> # one cluster in detail (prefix match ok)
|
|
83
|
+
virgil findings <audit-id> # raw findings table
|
|
84
|
+
virgil chat <audit-id> # interactive Q&A grounded in this audit
|
|
85
|
+
virgil chat <audit-id> -m "what's the worst finding?" # one-shot
|
|
86
|
+
virgil open <audit-id> # launch the web app on the triage tab
|
|
87
|
+
virgil open <audit-id> --page chat # …or chat / findings / report / attack-surface
|
|
88
|
+
virgil status <audit-id>
|
|
89
|
+
|
|
90
|
+
# Reports in any supported format.
|
|
91
|
+
virgil report <audit-id> --format md
|
|
92
|
+
virgil report <audit-id> --format sarif -o findings.sarif
|
|
93
|
+
virgil report <audit-id> --format json
|
|
94
|
+
virgil report <audit-id> --format pdf
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Config
|
|
98
|
+
|
|
99
|
+
Persistent settings live in `~/.config/virgil/config.json`:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
virgil config show
|
|
103
|
+
virgil config set api_url=https://virgil.example.com/api
|
|
104
|
+
virgil config set web_url=https://virgil.example.com
|
|
105
|
+
virgil config set default_fail_on=high
|
|
106
|
+
virgil config set default_post_scan_view=ask_virgil # triage | report | surface | ask_virgil
|
|
107
|
+
virgil config unset default_fail_on
|
|
108
|
+
virgil config path
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Resolution order for each setting: **env var → config file → built-in default.**
|
|
112
|
+
|
|
113
|
+
## CI integration
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
virgil scan . --fail-on critical # exits 1 on any Critical
|
|
117
|
+
virgil scan . --fail-on high # exits 1 on Critical or High
|
|
118
|
+
virgil scan . --fail-on never # always exits 0
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Exit codes:
|
|
122
|
+
|
|
123
|
+
| Code | Meaning |
|
|
124
|
+
| ---: | --- |
|
|
125
|
+
| `0` | scan finished, no findings exceeded `--fail-on` |
|
|
126
|
+
| `1` | scan finished, findings exceed the configured threshold |
|
|
127
|
+
| `2` | the audit itself failed (clone error, scanner crash, etc.) |
|
|
128
|
+
| `3` | could not reach the Virgil API |
|
|
129
|
+
|
|
130
|
+
## Environment
|
|
131
|
+
|
|
132
|
+
| Variable | Default | What it does |
|
|
133
|
+
| --- | --- | --- |
|
|
134
|
+
| `VIRGIL_API` | `http://localhost:8000` | API base URL. |
|
|
135
|
+
| `VIRGIL_WEB` | `http://localhost:3000` | Web app base URL used by `virgil open`. |
|
|
136
|
+
| `VIRGIL_FAIL_ON` | `critical` | Default `--fail-on` threshold for `virgil scan`. |
|
|
137
|
+
| `VIRGIL_SHOW` | `triage` | Default `--show` surface after `virgil scan` (`triage` / `report` / `surface` / `ask_virgil`). |
|
|
138
|
+
| `VIRGIL_CONFIG_DIR` | `~/.config/virgil` | Override the config directory. |
|
|
139
|
+
|
|
140
|
+
## What the output looks like
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
$ virgil scan .
|
|
144
|
+
bundle /work/myrepo → zip → submit
|
|
145
|
+
┌─ [ virgil ] ───────────────────────────────────────────────────────────────┐
|
|
146
|
+
│ audit_id c9b1… │
|
|
147
|
+
│ source scan.zip │
|
|
148
|
+
│ state succeeded phase=completed │
|
|
149
|
+
└────────────────────────────────────────────────────────────────────────────┘
|
|
150
|
+
|
|
151
|
+
CRIT HIGH MED LOW INFO KEV unreach
|
|
152
|
+
2 7 14 6 3 1 19
|
|
153
|
+
|
|
154
|
+
╭─ [ fix.this_week() · ranked ] ─────────────────────────────────────────────╮
|
|
155
|
+
│ #01 [ CRIT ] Hard-coded AWS access key in source ×3 │
|
|
156
|
+
│ Critical credential exposure with CISA-KEV-adjacent risk profile… │
|
|
157
|
+
│ #02 [ HIGH ] SQL injection via raw query helper ×12 │
|
|
158
|
+
│ 12 callsites share src/db/query.py — fix the helper, not callsites. │
|
|
159
|
+
╰────────────────────────────────────────────────────────────────────────────╯
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
Apache-2.0. See [LICENSE](https://github.com/ayaanmaliksgithub/virgil/blob/main/LICENSE).
|
virgilhq-0.3.0/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# virgil
|
|
2
|
+
|
|
3
|
+
> Terminal client for [Virgil](https://github.com/ayaanmaliksgithub/virgil) —
|
|
4
|
+
> self-hosted security audit with the triage built in. Real scanners +
|
|
5
|
+
> clustering + LLM priority queue + code-grounded chat.
|
|
6
|
+
|
|
7
|
+
The CLI is a thin shell over a running Virgil API. It bundles your working
|
|
8
|
+
directory, submits a scan, streams progress, and prints the ranked findings
|
|
9
|
+
with CI-friendly exit codes.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install virgilhq # or: pipx install virgilhq
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The PyPI package is `virgilhq` (the bare `virgil` name was already taken).
|
|
18
|
+
The command on your `$PATH` is still just `virgil`.
|
|
19
|
+
|
|
20
|
+
You'll also need a running Virgil instance. The standard self-hosted setup
|
|
21
|
+
is `docker compose up` from the [main repo](https://github.com/ayaanmaliksgithub/virgil)
|
|
22
|
+
— takes about a minute the first time.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Scan and land on triage (counts → ranked clusters → next-steps hint).
|
|
28
|
+
virgil scan .
|
|
29
|
+
|
|
30
|
+
# Or land on a different surface after the scan finishes.
|
|
31
|
+
virgil scan . --show report # exec narrative
|
|
32
|
+
virgil scan . --show surface # languages / frameworks / IaC profile
|
|
33
|
+
virgil scan . --show ask_virgil # drop into the chat REPL pre-flighted
|
|
34
|
+
|
|
35
|
+
# Scan a GitHub URL instead of a local path.
|
|
36
|
+
virgil scan --url https://github.com/OWASP/NodeGoat
|
|
37
|
+
|
|
38
|
+
# PR mode — only flag findings on lines changed between two SHAs.
|
|
39
|
+
virgil scan . --base-sha abc1234 --head-sha def5678
|
|
40
|
+
|
|
41
|
+
# Don't wait for the scan to finish; print the audit ID and return.
|
|
42
|
+
virgil scan . --no-wait
|
|
43
|
+
|
|
44
|
+
# After a scan, drill in:
|
|
45
|
+
virgil clusters <audit-id> # every cluster, sorted by severity
|
|
46
|
+
virgil clusters <audit-id> --sev high # filter
|
|
47
|
+
virgil cluster <audit-id> <key> # one cluster in detail (prefix match ok)
|
|
48
|
+
virgil findings <audit-id> # raw findings table
|
|
49
|
+
virgil chat <audit-id> # interactive Q&A grounded in this audit
|
|
50
|
+
virgil chat <audit-id> -m "what's the worst finding?" # one-shot
|
|
51
|
+
virgil open <audit-id> # launch the web app on the triage tab
|
|
52
|
+
virgil open <audit-id> --page chat # …or chat / findings / report / attack-surface
|
|
53
|
+
virgil status <audit-id>
|
|
54
|
+
|
|
55
|
+
# Reports in any supported format.
|
|
56
|
+
virgil report <audit-id> --format md
|
|
57
|
+
virgil report <audit-id> --format sarif -o findings.sarif
|
|
58
|
+
virgil report <audit-id> --format json
|
|
59
|
+
virgil report <audit-id> --format pdf
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Config
|
|
63
|
+
|
|
64
|
+
Persistent settings live in `~/.config/virgil/config.json`:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
virgil config show
|
|
68
|
+
virgil config set api_url=https://virgil.example.com/api
|
|
69
|
+
virgil config set web_url=https://virgil.example.com
|
|
70
|
+
virgil config set default_fail_on=high
|
|
71
|
+
virgil config set default_post_scan_view=ask_virgil # triage | report | surface | ask_virgil
|
|
72
|
+
virgil config unset default_fail_on
|
|
73
|
+
virgil config path
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Resolution order for each setting: **env var → config file → built-in default.**
|
|
77
|
+
|
|
78
|
+
## CI integration
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
virgil scan . --fail-on critical # exits 1 on any Critical
|
|
82
|
+
virgil scan . --fail-on high # exits 1 on Critical or High
|
|
83
|
+
virgil scan . --fail-on never # always exits 0
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Exit codes:
|
|
87
|
+
|
|
88
|
+
| Code | Meaning |
|
|
89
|
+
| ---: | --- |
|
|
90
|
+
| `0` | scan finished, no findings exceeded `--fail-on` |
|
|
91
|
+
| `1` | scan finished, findings exceed the configured threshold |
|
|
92
|
+
| `2` | the audit itself failed (clone error, scanner crash, etc.) |
|
|
93
|
+
| `3` | could not reach the Virgil API |
|
|
94
|
+
|
|
95
|
+
## Environment
|
|
96
|
+
|
|
97
|
+
| Variable | Default | What it does |
|
|
98
|
+
| --- | --- | --- |
|
|
99
|
+
| `VIRGIL_API` | `http://localhost:8000` | API base URL. |
|
|
100
|
+
| `VIRGIL_WEB` | `http://localhost:3000` | Web app base URL used by `virgil open`. |
|
|
101
|
+
| `VIRGIL_FAIL_ON` | `critical` | Default `--fail-on` threshold for `virgil scan`. |
|
|
102
|
+
| `VIRGIL_SHOW` | `triage` | Default `--show` surface after `virgil scan` (`triage` / `report` / `surface` / `ask_virgil`). |
|
|
103
|
+
| `VIRGIL_CONFIG_DIR` | `~/.config/virgil` | Override the config directory. |
|
|
104
|
+
|
|
105
|
+
## What the output looks like
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
$ virgil scan .
|
|
109
|
+
bundle /work/myrepo → zip → submit
|
|
110
|
+
┌─ [ virgil ] ───────────────────────────────────────────────────────────────┐
|
|
111
|
+
│ audit_id c9b1… │
|
|
112
|
+
│ source scan.zip │
|
|
113
|
+
│ state succeeded phase=completed │
|
|
114
|
+
└────────────────────────────────────────────────────────────────────────────┘
|
|
115
|
+
|
|
116
|
+
CRIT HIGH MED LOW INFO KEV unreach
|
|
117
|
+
2 7 14 6 3 1 19
|
|
118
|
+
|
|
119
|
+
╭─ [ fix.this_week() · ranked ] ─────────────────────────────────────────────╮
|
|
120
|
+
│ #01 [ CRIT ] Hard-coded AWS access key in source ×3 │
|
|
121
|
+
│ Critical credential exposure with CISA-KEV-adjacent risk profile… │
|
|
122
|
+
│ #02 [ HIGH ] SQL injection via raw query helper ×12 │
|
|
123
|
+
│ 12 callsites share src/db/query.py — fix the helper, not callsites. │
|
|
124
|
+
╰────────────────────────────────────────────────────────────────────────────╯
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
Apache-2.0. See [LICENSE](https://github.com/ayaanmaliksgithub/virgil/blob/main/LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Virgil CLI.
|
|
2
|
+
|
|
3
|
+
Thin terminal client for Virgil — the security audit platform. Submits
|
|
4
|
+
scans, streams audit progress, prints findings, fetches reports — talks
|
|
5
|
+
to a running API instance (default `http://localhost:8000`). The CLI
|
|
6
|
+
never runs scanners itself; that work belongs in the sandboxed worker.
|
|
7
|
+
|
|
8
|
+
Distribution: `pip install virgil` (or via `pipx`).
|
|
9
|
+
"""
|
|
10
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""HTTP client for the audit API.
|
|
2
|
+
|
|
3
|
+
Thin wrapper over `requests`. Centralizes the base URL + error handling so
|
|
4
|
+
the command modules stay readable. Does NOT carry retry logic — a CLI
|
|
5
|
+
session is interactive, and silent retries on top of `requests` calls
|
|
6
|
+
hide signal a user wants to see (network down, server hung).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterator
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from cli import config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
HTTP_TIMEOUT = 30
|
|
20
|
+
CHAT_TIMEOUT = 120 # LLM round-trips can sit well past the default.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApiError(RuntimeError):
|
|
24
|
+
def __init__(self, status: int, detail: str = ""):
|
|
25
|
+
super().__init__(f"API {status}: {detail}".rstrip(": "))
|
|
26
|
+
self.status = status
|
|
27
|
+
self.detail = detail
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ApiUnreachable(RuntimeError):
|
|
31
|
+
"""Network failure — distinct from a 5xx so the CLI can suggest
|
|
32
|
+
`docker compose up` vs. "check API logs"."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _base_url() -> str:
|
|
36
|
+
return config.api_url().rstrip("/")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _request(method: str, path: str, **kwargs) -> requests.Response:
|
|
40
|
+
url = _base_url() + path
|
|
41
|
+
kwargs.setdefault("timeout", HTTP_TIMEOUT)
|
|
42
|
+
try:
|
|
43
|
+
res = requests.request(method, url, **kwargs)
|
|
44
|
+
except requests.exceptions.ConnectionError as e:
|
|
45
|
+
raise ApiUnreachable(str(e)) from e
|
|
46
|
+
except requests.exceptions.Timeout as e:
|
|
47
|
+
raise ApiUnreachable(f"timeout after {HTTP_TIMEOUT}s") from e
|
|
48
|
+
if not res.ok:
|
|
49
|
+
raise ApiError(res.status_code, res.text[:500])
|
|
50
|
+
return res
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def submit_zip(zip_path: Path) -> dict:
|
|
54
|
+
with zip_path.open("rb") as f:
|
|
55
|
+
files = {"file": (zip_path.name, f, "application/zip")}
|
|
56
|
+
res = _request("POST", "/v1/audits", files=files)
|
|
57
|
+
return res.json()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def submit_url(repo_url: str, *, base_sha: str | None = None, head_sha: str | None = None) -> dict:
|
|
61
|
+
body: dict = {"repo_url": repo_url}
|
|
62
|
+
if base_sha and head_sha:
|
|
63
|
+
body["base_sha"] = base_sha
|
|
64
|
+
body["head_sha"] = head_sha
|
|
65
|
+
res = _request("POST", "/v1/audits/json", json=body)
|
|
66
|
+
return res.json()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_audit(audit_id: str) -> dict:
|
|
70
|
+
return _request("GET", f"/v1/audits/{audit_id}").json()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def list_findings(audit_id: str, *, include_suppressed: bool = False) -> list[dict]:
|
|
74
|
+
params = {}
|
|
75
|
+
if include_suppressed:
|
|
76
|
+
params["include_suppressed"] = "true"
|
|
77
|
+
return _request("GET", f"/v1/audits/{audit_id}/findings", params=params).json()["items"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_clusters(audit_id: str, *, include_unreachable: bool = False) -> dict:
|
|
81
|
+
params = {"include_unreachable": "true"} if include_unreachable else {}
|
|
82
|
+
return _request("GET", f"/v1/audits/{audit_id}/findings/clusters", params=params).json()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_finding(finding_id: str) -> dict:
|
|
86
|
+
return _request("GET", f"/v1/findings/{finding_id}").json()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_suggested_questions(audit_id: str) -> list[str]:
|
|
90
|
+
return _request("GET", f"/v1/audits/{audit_id}/chat/suggested").json().get("items", [])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def post_chat(audit_id: str, message: str, *, session_id: str | None = None) -> dict:
|
|
94
|
+
body: dict = {"message": message}
|
|
95
|
+
if session_id:
|
|
96
|
+
body["session_id"] = session_id
|
|
97
|
+
res = _request("POST", f"/v1/audits/{audit_id}/chat", json=body, timeout=CHAT_TIMEOUT)
|
|
98
|
+
return res.json()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def post_chat_stream(audit_id: str, message: str, *, session_id: str | None = None) -> Iterator[dict]:
|
|
102
|
+
"""Stream chat tokens as SSE.
|
|
103
|
+
|
|
104
|
+
Yields decoded events: `{"event": "session"|"token"|"done"|"error", "data": ...}`
|
|
105
|
+
where `data` is the already-JSON-decoded payload from the SSE frame.
|
|
106
|
+
|
|
107
|
+
On `done` the caller should replace whatever tokens were rendered with the
|
|
108
|
+
final `message.content`: the safety validator runs at end-of-stream and
|
|
109
|
+
may refuse — in which case the visible tokens are stale.
|
|
110
|
+
"""
|
|
111
|
+
body: dict = {"message": message}
|
|
112
|
+
if session_id:
|
|
113
|
+
body["session_id"] = session_id
|
|
114
|
+
url = _base_url() + f"/v1/audits/{audit_id}/chat/stream"
|
|
115
|
+
try:
|
|
116
|
+
with requests.post(url, json=body, stream=True, timeout=CHAT_TIMEOUT) as res:
|
|
117
|
+
if not res.ok:
|
|
118
|
+
raise ApiError(res.status_code, res.text[:500])
|
|
119
|
+
event = "message"
|
|
120
|
+
data_lines: list[str] = []
|
|
121
|
+
for raw in res.iter_lines(decode_unicode=True):
|
|
122
|
+
if raw is None:
|
|
123
|
+
continue
|
|
124
|
+
if raw == "":
|
|
125
|
+
if data_lines:
|
|
126
|
+
import json as _json
|
|
127
|
+
joined = "\n".join(data_lines)
|
|
128
|
+
try:
|
|
129
|
+
payload = _json.loads(joined)
|
|
130
|
+
except _json.JSONDecodeError:
|
|
131
|
+
payload = {"raw": joined}
|
|
132
|
+
yield {"event": event, "data": payload}
|
|
133
|
+
event, data_lines = "message", []
|
|
134
|
+
continue
|
|
135
|
+
if raw.startswith(":"):
|
|
136
|
+
continue
|
|
137
|
+
if raw.startswith("event:"):
|
|
138
|
+
event = raw[6:].strip()
|
|
139
|
+
elif raw.startswith("data:"):
|
|
140
|
+
data_lines.append(raw[5:].lstrip(" "))
|
|
141
|
+
except requests.exceptions.ConnectionError as e:
|
|
142
|
+
raise ApiUnreachable(str(e)) from e
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_chat_session(audit_id: str, session_id: str) -> dict:
|
|
146
|
+
return _request("GET", f"/v1/audits/{audit_id}/chat/{session_id}").json()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_report(audit_id: str, *, view: str = "technical", format: str = "json") -> bytes:
|
|
150
|
+
res = _request(
|
|
151
|
+
"GET",
|
|
152
|
+
f"/v1/audits/{audit_id}/report",
|
|
153
|
+
params={"view": view, "format": format},
|
|
154
|
+
)
|
|
155
|
+
return res.content
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def stream_events(audit_id: str) -> Iterator[dict]:
|
|
159
|
+
"""Yield decoded SSE event dicts until the stream ends.
|
|
160
|
+
|
|
161
|
+
Each event looks like `{"event": "log"|"done", "phase": str, "message": str}`.
|
|
162
|
+
"""
|
|
163
|
+
url = _base_url() + f"/v1/audits/{audit_id}/events"
|
|
164
|
+
try:
|
|
165
|
+
with requests.get(url, stream=True, timeout=None) as res:
|
|
166
|
+
if not res.ok:
|
|
167
|
+
raise ApiError(res.status_code, res.text[:500])
|
|
168
|
+
event = "message"
|
|
169
|
+
data_lines: list[str] = []
|
|
170
|
+
for raw in res.iter_lines(decode_unicode=True):
|
|
171
|
+
if raw is None:
|
|
172
|
+
continue
|
|
173
|
+
if raw == "":
|
|
174
|
+
if data_lines:
|
|
175
|
+
yield {"event": event, "data": "\n".join(data_lines)}
|
|
176
|
+
event, data_lines = "message", []
|
|
177
|
+
continue
|
|
178
|
+
if raw.startswith(":"):
|
|
179
|
+
continue
|
|
180
|
+
if raw.startswith("event:"):
|
|
181
|
+
event = raw[6:].strip()
|
|
182
|
+
elif raw.startswith("data:"):
|
|
183
|
+
data_lines.append(raw[5:].lstrip(" "))
|
|
184
|
+
except requests.exceptions.ConnectionError as e:
|
|
185
|
+
raise ApiUnreachable(str(e)) from e
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def poll_until_terminal(audit_id: str, *, interval: float = 1.5, max_seconds: float = 1800) -> dict:
|
|
189
|
+
"""Polling fallback when SSE is unavailable. Returns the final audit dict."""
|
|
190
|
+
deadline = time.time() + max_seconds
|
|
191
|
+
while time.time() < deadline:
|
|
192
|
+
audit = get_audit(audit_id)
|
|
193
|
+
if audit["state"] in ("succeeded", "failed"):
|
|
194
|
+
return audit
|
|
195
|
+
time.sleep(interval)
|
|
196
|
+
raise TimeoutError(f"audit {audit_id} did not finish within {max_seconds}s")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Persisted CLI config — `~/.config/virgil/config.json`.
|
|
2
|
+
|
|
3
|
+
Precedence for each setting: env var > config file > built-in default.
|
|
4
|
+
JSON over TOML to avoid a stdlib gap on Python 3.10 (`tomllib` is 3.11+).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
CONFIG_DIR = Path(os.environ.get("VIRGIL_CONFIG_DIR", str(Path.home() / ".config" / "virgil")))
|
|
15
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
16
|
+
|
|
17
|
+
DEFAULT_API_URL = "http://localhost:8000"
|
|
18
|
+
DEFAULT_WEB_URL = "http://localhost:3000"
|
|
19
|
+
DEFAULT_FAIL_ON = "critical"
|
|
20
|
+
DEFAULT_POST_SCAN_VIEW = "triage"
|
|
21
|
+
|
|
22
|
+
# Keys we know about — used by `virgil config set` to reject typos before
|
|
23
|
+
# they end up silently ignored on disk.
|
|
24
|
+
KNOWN_KEYS = {"api_url", "web_url", "default_fail_on", "default_post_scan_view"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load() -> dict[str, Any]:
|
|
28
|
+
if not CONFIG_PATH.exists():
|
|
29
|
+
return {}
|
|
30
|
+
try:
|
|
31
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
32
|
+
except (OSError, json.JSONDecodeError):
|
|
33
|
+
return {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save(data: dict[str, Any]) -> None:
|
|
37
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
CONFIG_PATH.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get(key: str, default: Any = None) -> Any:
|
|
42
|
+
return load().get(key, default)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def set_(key: str, value: str) -> None:
|
|
46
|
+
data = load()
|
|
47
|
+
data[key] = value
|
|
48
|
+
save(data)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def unset(key: str) -> bool:
|
|
52
|
+
data = load()
|
|
53
|
+
if key not in data:
|
|
54
|
+
return False
|
|
55
|
+
del data[key]
|
|
56
|
+
save(data)
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def api_url() -> str:
|
|
61
|
+
return os.environ.get("VIRGIL_API") or get("api_url") or DEFAULT_API_URL
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def web_url() -> str:
|
|
65
|
+
return os.environ.get("VIRGIL_WEB") or get("web_url") or DEFAULT_WEB_URL
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def default_fail_on() -> str:
|
|
69
|
+
return os.environ.get("VIRGIL_FAIL_ON") or get("default_fail_on") or DEFAULT_FAIL_ON
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def default_post_scan_view() -> str:
|
|
73
|
+
return (
|
|
74
|
+
os.environ.get("VIRGIL_SHOW")
|
|
75
|
+
or get("default_post_scan_view")
|
|
76
|
+
or DEFAULT_POST_SCAN_VIEW
|
|
77
|
+
)
|