mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-14 04:00:54 +08:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1810a82187 | ||
|
|
34cbbeadb7 | ||
|
|
a1426bbd35 | ||
|
|
0a740f0894 | ||
|
|
f938ba00d9 | ||
|
|
f5db962eeb | ||
|
|
62571c9b34 | ||
|
|
4fe80b860c | ||
|
|
fbe78395d5 | ||
|
|
6ce50b7d13 | ||
|
|
9c2b56001d | ||
|
|
9481b8e962 | ||
|
|
f90e8d2bcb | ||
|
|
cab0a10c6f | ||
|
|
180538e463 | ||
|
|
a7da0559f3 | ||
|
|
9bf5d816ae | ||
|
|
4acdf3bb26 | ||
|
|
13dde2da15 | ||
|
|
a908e1ec17 | ||
|
|
9784bb192c | ||
|
|
44d755fb2e | ||
|
|
ce279d77d3 | ||
|
|
41e10456d7 | ||
|
|
43424404ee | ||
|
|
cb13a22673 | ||
|
|
8beb9c50d6 | ||
|
|
4be2fc5c9a | ||
|
|
d0d153b768 | ||
|
|
2419038884 | ||
|
|
e6e9b2f978 | ||
|
|
248d9d9673 | ||
|
|
f6583b4bad | ||
|
|
934568f336 |
@@ -6,6 +6,10 @@ server/webui/dist
|
||||
/artex
|
||||
data
|
||||
keys
|
||||
backups
|
||||
.env
|
||||
config.json
|
||||
jwt.key
|
||||
*.db
|
||||
transcripts
|
||||
bench/kv
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
# Address the web UI is published on. 127.0.0.1 keeps it host-local; set 0.0.0.0
|
||||
# to reach it from another machine or from Windows when ARTEX runs inside WSL.
|
||||
ARTEX_BIND_ADDR=127.0.0.1
|
||||
# Reverse proxies (comma-separated CIDRs) whose X-Forwarded-For the login rate
|
||||
# limit trusts. Empty: the header is ignored. See "Reverse proxy deployment" in README.md.
|
||||
ARTEX_TRUSTED_PROXIES=
|
||||
POSTGRES_USER=artex
|
||||
POSTGRES_PASSWORD=change-me-please
|
||||
POSTGRES_DB=artex
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
- run: go build ./...
|
||||
- run: go vet ./...
|
||||
# Skipped tests, tracked in issue #9:
|
||||
# - TestInterceptFilterHTTP, TestGraphOverviewExpandsAssociatedCompanyScope,
|
||||
# - TestGraphOverviewExpandsAssociatedCompanyScope,
|
||||
# TestCaptureApprovalLifecycle: fail on unmodified upstream code with a
|
||||
# fresh database.
|
||||
# - TestSideUsageRecordedOnceOnConsumerCancellation: concurrency/cancellation
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
# shellcheck is preinstalled on the ubuntu runners. -S warning: the scripts
|
||||
# currently have only info-level notes (intentional A && B || C chains in
|
||||
# update.sh, an unquoted glob in backup.sh's prune), so fail on warnings.
|
||||
- run: shellcheck -S warning install.sh update.sh start.sh scripts/backup.sh
|
||||
- run: shellcheck -S warning install.sh update.sh start.sh scripts/backup.sh reset-password.sh
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+24
-1
@@ -4,6 +4,23 @@ All notable changes to this project are documented in this file. The format is b
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Optional custom User-Agent for LLM requests**: an LLM profile can set a `User-Agent` value that overrides the header on every outbound LLM request; left empty, the default User-Agent is unchanged. The value is trimmed and rejected if it contains control characters or exceeds 256 bytes. Part of #154 (the fixed-full-URL mode is not included).
|
||||
|
||||
### Docs
|
||||
|
||||
- **LLM base URL note in the README**: for the Anthropic format, enter the host without a `/v1` suffix (the provider appends `/v1/messages`, so a `/v1` base becomes `/v1/v1/messages`); OpenAI-format providers append `/chat/completions` (or `/responses` for the `openai-responses` format), so their base does end in `/v1`. Follow-up to #154.
|
||||
|
||||
## [0.5.1] - 2026-11-05
|
||||
|
||||
Login rate limiting per client IP with trusted-proxy support, a 12-character minimum for new passwords, a loopback-only default for the binary, and the JWT signing key in its own Docker volume.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The binary listens on `127.0.0.1:8787` by default**: the `-addr` default was `:8787`, every interface. Running `artex` or `./start.sh` without `-addr` now accepts connections from the local machine only; pass `-addr :8787` to listen on every interface. Docker is unchanged: the image starts the server with `-addr :8787` inside the container, and `docker-compose.yml` already publishes it on `127.0.0.1` by default (`ARTEX_BIND_ADDR`).
|
||||
- **New passwords need at least 12 characters** (was 8), at first-run setup (`POST /api/auth/init` and the `/setup` page) and in `POST /api/auth/change-password`. Login does not check length, so a password set under the old minimum keeps working until it is changed. `reset-password.sh` enforces the same minimum.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The JWT signing key survives container rebuilds**: the server wrote `jwt.key` to the executable's directory, `/app` in the image, so the key lived in the container's writable layer and recreating the container (for example `docker compose up -d --build`) generated a new key and signed every user out. The key directory is now set by `ARTEX_JWT_KEY_DIR` (default: the executable's directory, unchanged for non-Docker installs). `docker-compose.yml` sets it to `/app/keys` and bind-mounts `./keys` there, outside `./data`, which the file manager browses. On start, when `<key dir>/jwt.key` is missing, a key at `data/jwt.key`, then at the executable's directory, is copied there with mode 0600 and the old file is removed; otherwise a new key is generated. An existing key is never overwritten. Upgrading a Docker install signs every user out once, because the old key is in the container being replaced; to keep sessions, copy it out before recreating the container:
|
||||
@@ -13,6 +30,11 @@ All notable changes to this project are documented in this file. The format is b
|
||||
docker cp artex-artex-1:/app/jwt.key ./keys/jwt.key
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
- **Login rate limit**: `POST /api/auth/login` allows 10 attempts per minute per client IP. The next attempt gets 429 with a `Retry-After` header (seconds) and a JSON error, and the IP is blocked for 1 minute. Each attempt while blocked, and each later block, doubles the wait, up to 15 minutes. A successful login clears the IP's count. There is no per-account lockout, so failing logins cannot lock the admin out from other addresses. The state is in memory: a restart clears it, an IP idle for 15 minutes is dropped, and at most 10,000 IPs are tracked (the least recently seen is dropped first). The IP is the TCP peer address, and `X-Forwarded-For` is ignored, unless the peer is listed in the new `ARTEX_TRUSTED_PROXIES` (comma-separated CIDRs, empty by default, passed through by `docker-compose.yml`). For a listed peer the header is read from the right and the first address that is not a trusted proxy is the client; entries the client added further left are never used. Behind a reverse proxy, set it: otherwise every client has the proxy's address and shares one budget, and an attacker failing logins through the proxy blocks the real users, admin included, for up to 15 minutes. The README nginx example now sends `X-Forwarded-For`.
|
||||
- **`GET /api/health` hides the version from anonymous callers**: without a valid token it answers `{"ok":true}` only. With a token (the web UI sends one for the version in its top bar) the answer is unchanged: `ok`, `service` and `version`.
|
||||
|
||||
## [0.5.0] - 2026-11-04
|
||||
|
||||
Ollama support, a setup token for the first-run claim, secret scrubbing for child processes, a Docker image that builds from a fresh clone, Go 1.26.10 and Node 22, and race and shellcheck runs in CI. Entries marked "direct commit" landed on `main` without a pull request.
|
||||
@@ -978,7 +1000,8 @@ First release of this fork. The engine underneath is still upstream's, but this
|
||||
- [@Autumn-27](https://github.com/Autumn-27)
|
||||
- [@neouks](https://github.com/neouks)
|
||||
|
||||
[Unreleased]: https://github.com/BaaRai/ARTEX/compare/v0.5.0...HEAD
|
||||
[Unreleased]: https://github.com/BaaRai/ARTEX/compare/v0.5.1...HEAD
|
||||
[0.5.1]: https://github.com/BaaRai/ARTEX/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/BaaRai/ARTEX/compare/v0.4.0...v0.5.0
|
||||
[0.4.0]: https://github.com/BaaRai/ARTEX/compare/v0.3.15...v0.4.0
|
||||
[0.3.10]: https://github.com/Autumn-27/ARTEX/compare/v0.3.9...v0.3.10
|
||||
|
||||
@@ -9,6 +9,8 @@ AI autonomous penetration testing system (Go backend + Next.js frontend)
|
||||
|
||||
</div>
|
||||
|
||||
<a href="https://trendshift.io/repositories/316805?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-316805" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/316805/daily?language=Go" alt="BaaRai%2FARTEX | Trendshift" width="250" height="55"/></a>
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
@@ -98,7 +100,6 @@ The web UI is published on `127.0.0.1` by default. To reach it from another mach
|
||||
git clone https://github.com/BaaRai/ARTEX.git
|
||||
cd ARTEX
|
||||
cp .env.example .env # set POSTGRES_PASSWORD, optionally ANTHROPIC_API_KEY
|
||||
# build the single binary first (frontend, then go build -tags embedui), see the Dockerfile header
|
||||
docker compose up -d --build # builds artex from this checkout + starts postgres
|
||||
# → http://localhost:8787
|
||||
```
|
||||
@@ -255,13 +256,15 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
|
||||
**LLM**: `export ANTHROPIC_API_KEY=sk-...` (or `OPENAI_API_KEY`); you can also fill it in on the UI's "LLM Configuration" page — which can add a local Ollama server as a keyless provider (format "Ollama", base URL default `http://127.0.0.1:11434/v1`, models loaded from the server).
|
||||
Optional: `ARTEX_LLM_PROVIDER` (anthropic | openai | openai-responses | ollama) / `ARTEX_LLM_MODEL` / `ARTEX_LLM_BASE_URL` / `ARTEX_LLM_PROXY`.
|
||||
|
||||
**Base URL**: for the Anthropic format, enter the host **without** a `/v1` suffix (e.g. `https://api.anthropic.com`) — the provider appends `/v1/messages` itself, so a base ending in `/v1` becomes `https://api.anthropic.com/v1/v1/messages`. OpenAI-format providers append `/chat/completions` (or `/responses` for the `openai-responses` format), so their base **does** end in `/v1` (e.g. `https://api.openai.com/v1`).
|
||||
|
||||
**Concurrency**: the number of work agents per task is configured in "System Settings" (default 3).
|
||||
|
||||
**Common flags**: `./start.sh -addr :8787 -proxy :8788` (`-addr` frontend+API, `-proxy` traffic recording proxy). The startup script passes arguments through to `artex` verbatim.
|
||||
|
||||
### Reverse proxy deployment (HTTPS / only exposing 443)
|
||||
|
||||
The frontend and API/SSE are served by the same backend port (default `:8787`), and the realtime activity stream uses the **same-origin** address by default, so **there is no need to configure `NEXT_PUBLIC_SSE_BASE`**: just expose 443 publicly and keep 8787 on the internal network.
|
||||
The frontend and API/SSE are served by the same backend port (8787; the binary listens on `127.0.0.1:8787` by default, the Docker container on `:8787` published on `127.0.0.1`), and the realtime activity stream uses the **same-origin** address by default, so **there is no need to configure `NEXT_PUBLIC_SSE_BASE`**: just expose 443 publicly and keep 8787 on the internal network.
|
||||
|
||||
SSE is a long-lived connection with continuous pushes, so the reverse proxy **must disable buffering**, otherwise the browser connects but never receives events (the activity stream spins forever). Nginx example:
|
||||
|
||||
@@ -275,6 +278,7 @@ server {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# SSE essentials: buffering off, long timeouts, HTTP/1.1
|
||||
proxy_buffering off;
|
||||
@@ -286,6 +290,13 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
**Login rate limit behind a proxy**: login allows 10 attempts per minute per client IP. Behind a proxy every request comes from the proxy's address, so without further setup all users share one budget and an attacker who fails 10 logins blocks everyone, admin included, for up to 15 minutes. To avoid that, keep the `X-Forwarded-For` line above and set `ARTEX_TRUSTED_PROXIES` (comma-separated CIDRs, empty by default) to the address ARTEX sees the proxy connect from:
|
||||
|
||||
- **nginx and ARTEX both on the host** (binary, `./start.sh`): the peer is `127.0.0.1`. Start ARTEX with `ARTEX_TRUSTED_PROXIES=127.0.0.1/32`.
|
||||
- **nginx on the host, ARTEX in Docker** (`docker-compose.yml`): the connection reaches the container from the Docker bridge gateway (a `172.x` address), not from `127.0.0.1`. Find it with `docker network inspect artex_default --format '{{(index .IPAM.Config 0).Gateway}}'` (the network is `<project directory>_default`; `docker network ls` lists it), put `ARTEX_TRUSTED_PROXIES=<gateway>/32` in `.env`, and run `docker compose up -d`.
|
||||
|
||||
For a peer in that list, ARTEX reads `X-Forwarded-For` from the right and takes the first address that is not a trusted proxy; from any other peer the header is ignored. Trust only addresses of proxies you run: a trusted peer can claim any client address.
|
||||
|
||||
> Only when SSE needs a different origin from the page (e.g. a dedicated subdomain) should you set `NEXT_PUBLIC_SSE_BASE` **at build time** (this variable is baked into the static bundle during `next build`; setting it at container runtime has no effect).
|
||||
|
||||
---
|
||||
|
||||
@@ -76,6 +76,9 @@ func findingWorkflowTools(agentKey string, tools []actool.CoreTool) ([]actool.Co
|
||||
if has["update_finding_report"] && has["bind_finding_traffic"] && has["get_finding_traffic"] {
|
||||
guidance += "\n\n**Auto-link traffic before reporting (enabled)**: you are responsible for verifying and binding traffic for the vulnerability that triggered this run, then writing the report. First obtain the unambiguous finding_id and finding_node_id from the report_finding return JSON or get_task_node_detail / list_task_findings. Read the vulnerability details, the corresponding intent's execution records, and the existing evidence list, preferring the real IDs handed off by the reporter. If this verification is HTTP and the traffic tools are available, use traffic_search to filter candidates, then traffic_get to verify item by item that the request/response truly supports the vulnerability; domains and times are only for filtering and do not prove attribution. Bind the confirmed evidence in reproduction order via bind_finding_traffic(finding_id, traffic_refs), choosing baseline / proof / verification / supporting and stating the purpose. Operate only on this vulnerability — do not recreate vulnerabilities or re-probe the target. After a successful bind, call get_finding_traffic again for the latest version, read the bodies you need, then pass the version you actually read as evidence_version to update_finding_report (whose finding_id parameter still takes finding_node_id). Do not re-append existing bindings. For TCP, no-capture, unavailable tools, or no exact match, skip auto-binding, write the report from textual/command evidence with the reason stated, and never guess to force traffic in. Do not claim success when binding fails; keep the existing evidence and explain in the report why nothing was bound."
|
||||
}
|
||||
if has["update_finding_report"] {
|
||||
guidance += findingVerificationGuidance
|
||||
}
|
||||
if guidance != "" || has["get_finding_traffic"] || has["update_finding_report"] {
|
||||
guidance += findingIDGuidance
|
||||
}
|
||||
@@ -126,3 +129,8 @@ func (t *ToolSet) findingRefsFromHint(hintID int64, explicit []db.TrafficRef) ([
|
||||
}
|
||||
return db.NormalizeTrafficRefs(append(append([]db.TrafficRef{}, explicit...), payload.Refs...))
|
||||
}
|
||||
|
||||
// findingVerificationGuidance tells the report writer how to declare proof
|
||||
// (S1). The server downgrades any finding whose declaration does not hold up,
|
||||
// so the agent must never state a level or quote it cannot read in the bound body.
|
||||
const findingVerificationGuidance = "\n\n**Verification (S1)**: when you call update_finding_report, also declare poe_level (0-4) and evidence_quotes. poe_level 1 = error, timing or response variation; 2 = structural manipulation (boolean, UNION, parser difference); 3 = actual data extracted; 4 = critical impact (admin credentials, code execution). L3 is the minimum for a verified finding. evidence_quotes must be exact lines copied from a bound proof or verification response body (read it with get_finding_traffic first), never from memory or from the report text; the server checks each quote verbatim after whitespace normalization. If you cannot quote the proof, omit the quotes: the finding then stays unverified, which is acceptable. Do not raise poe_level to get a verified status."
|
||||
|
||||
+39
-3
@@ -86,6 +86,10 @@ type Config struct {
|
||||
// context via transcript.WithSessionID and read by the RoundTripper, so even a
|
||||
// shared provider emits different header values per session.
|
||||
SessionHeaderKey string
|
||||
// UserAgent, when non-empty, overrides the User-Agent header on every outbound
|
||||
// LLM request built from this config. Empty = don't set it, leaving the Go/Norma
|
||||
// default UA untouched (the historical behavior). Validated by ValidateUserAgent.
|
||||
UserAgent string
|
||||
// Retry holds the retry parameters resolved for this config (profile override →
|
||||
// global policy → built-in default, resolved server-side). See RetryConfig for
|
||||
// the per-layer semantics; zero values = fully inherit the built-in defaults.
|
||||
@@ -341,7 +345,7 @@ func (c Config) Provider() string {
|
||||
// limiter lives on the single provider instance — so planner + all workers +
|
||||
// main agent (which share this provider) are bounded by one shared rate limit.
|
||||
func (c Config) NewProvider() (llm.Provider, error) {
|
||||
client, err := quotaAwareHTTPClient(c.Proxy, c.SessionHeaderKey)
|
||||
client, err := quotaAwareHTTPClient(c.Proxy, c.SessionHeaderKey, c.UserAgent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -440,6 +444,9 @@ type quotaAwareTransport struct {
|
||||
// carries; its value is the session id read from the request context. Empty
|
||||
// disables it. See Config.SessionHeaderKey.
|
||||
sessionHeaderKey string
|
||||
// userAgent, when non-empty, is set as the User-Agent header on every request.
|
||||
// Empty leaves the default UA untouched. See Config.UserAgent.
|
||||
userAgent string
|
||||
}
|
||||
|
||||
func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
@@ -452,6 +459,11 @@ func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
req.Header.Set(t.sessionHeaderKey, sid)
|
||||
}
|
||||
}
|
||||
// Custom User-Agent: override only when configured, so an empty value keeps the
|
||||
// underlying transport's default UA exactly as before.
|
||||
if t.userAgent != "" {
|
||||
req.Header.Set("User-Agent", t.userAgent)
|
||||
}
|
||||
// When LLM recording is on, the Recorder puts a Capture on the context so the
|
||||
// raw wire bodies can be persisted. This is the only layer that still sees
|
||||
// them: norma builds the request body internally and decodes the SSE response
|
||||
@@ -504,7 +516,7 @@ func requestBodySnapshot(req *http.Request) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func quotaAwareHTTPClient(proxy, sessionHeaderKey string) (*http.Client, error) {
|
||||
func quotaAwareHTTPClient(proxy, sessionHeaderKey, userAgent string) (*http.Client, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
if proxy == "" {
|
||||
@@ -523,7 +535,31 @@ func quotaAwareHTTPClient(proxy, sessionHeaderKey string) (*http.Client, error)
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
return &http.Client{Transport: quotaAwareTransport{base: transport, sessionHeaderKey: strings.TrimSpace(sessionHeaderKey)}}, nil
|
||||
return &http.Client{Transport: quotaAwareTransport{base: transport, sessionHeaderKey: strings.TrimSpace(sessionHeaderKey), userAgent: strings.TrimSpace(userAgent)}}, nil
|
||||
}
|
||||
|
||||
// MaxUserAgentLen bounds a custom User-Agent value. Generous for real UA strings
|
||||
// while keeping a pathological value out of every outbound request.
|
||||
const MaxUserAgentLen = 256
|
||||
|
||||
// ValidateUserAgent trims a custom User-Agent and returns the cleaned value. An
|
||||
// empty/blank input is valid and means "no override" (keep the default UA). A
|
||||
// non-empty value is rejected when it exceeds MaxUserAgentLen or contains any
|
||||
// control character (including CR/LF, which would allow header injection).
|
||||
func ValidateUserAgent(raw string) (string, error) {
|
||||
ua := strings.TrimSpace(raw)
|
||||
if ua == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(ua) > MaxUserAgentLen {
|
||||
return "", fmt.Errorf("user agent too long: %d bytes (max %d)", len(ua), MaxUserAgentLen)
|
||||
}
|
||||
for _, r := range ua {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return "", fmt.Errorf("user agent contains a control character")
|
||||
}
|
||||
}
|
||||
return ua, nil
|
||||
}
|
||||
|
||||
// logTestConnection prints the raw HTTP status code(s) and response body of a
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestRoundTripCapturesRawBodies(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("", "")
|
||||
client, err := quotaAwareHTTPClient("", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestRoundTripCaptures429BodyAlongsideQuotaRewrite(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("", "")
|
||||
client, err := quotaAwareHTTPClient("", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestRoundTripWithoutCaptureIsUnchanged(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("", "")
|
||||
client, err := quotaAwareHTTPClient("", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// serveUA spins up an httptest server that records the User-Agent of the request
|
||||
// it receives, drives one request through a quotaAwareHTTPClient built with the
|
||||
// given userAgent, and returns what actually reached the wire.
|
||||
func serveUA(t *testing.T, userAgent string) string {
|
||||
t.Helper()
|
||||
got := make(chan string, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got <- r.Header.Get("User-Agent")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("", "", userAgent)
|
||||
if err != nil {
|
||||
t.Fatalf("quotaAwareHTTPClient: %v", err)
|
||||
}
|
||||
resp, err := client.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return <-got
|
||||
}
|
||||
|
||||
func TestUserAgentHeaderSetWhenConfigured(t *testing.T) {
|
||||
const want = "artex-test-agent/1.2.3"
|
||||
if got := serveUA(t, want); got != want {
|
||||
t.Fatalf("User-Agent = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAgentHeaderDefaultWhenEmpty(t *testing.T) {
|
||||
// Empty = no override: the Go client's default UA must reach the server unchanged.
|
||||
got := serveUA(t, "")
|
||||
if !strings.HasPrefix(got, "Go-http-client/") {
|
||||
t.Fatalf("User-Agent = %q, want the default Go-http-client UA when unset", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUserAgentRejectsNewline(t *testing.T) {
|
||||
if _, err := ValidateUserAgent("good/1.0\r\nX-Injected: 1"); err == nil {
|
||||
t.Fatalf("expected a newline-containing user agent to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// The rule is "no control character at all" (r < 0x20 or r == 0x7f), so the
|
||||
// non-newline controls are rejected too, TAB included: a header value is one
|
||||
// printable token, not something to lay out with tabs.
|
||||
func TestValidateUserAgentRejectsControlCharacters(t *testing.T) {
|
||||
for name, in := range map[string]string{
|
||||
"NUL": "good/1.0\x00",
|
||||
"US": "good/1.0\x1f",
|
||||
"DEL": "good/1.0\x7f",
|
||||
"TAB": "good/1.0\tX",
|
||||
} {
|
||||
if _, err := ValidateUserAgent(in); err == nil {
|
||||
t.Errorf("%s: expected a user agent containing %q to be rejected", name, in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUserAgentAcceptsPrintableToken(t *testing.T) {
|
||||
const want = "MyTool/1.0 (contact-free)"
|
||||
if ua, err := ValidateUserAgent(" " + want + " "); err != nil || ua != want {
|
||||
t.Fatalf("printable value: got (%q, %v), want (%q, nil)", ua, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUserAgentRejectsTooLong(t *testing.T) {
|
||||
if _, err := ValidateUserAgent(strings.Repeat("a", MaxUserAgentLen+1)); err == nil {
|
||||
t.Fatalf("expected a user agent longer than %d to be rejected", MaxUserAgentLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUserAgentTrimsAndAllowsEmpty(t *testing.T) {
|
||||
if ua, err := ValidateUserAgent(" "); err != nil || ua != "" {
|
||||
t.Fatalf("blank user agent: got (%q, %v), want (\"\", nil)", ua, err)
|
||||
}
|
||||
if ua, err := ValidateUserAgent(" my-app/1.0 "); err != nil || ua != "my-app/1.0" {
|
||||
t.Fatalf("trim: got (%q, %v), want (\"my-app/1.0\", nil)", ua, err)
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -50,7 +50,7 @@ func main() {
|
||||
|
||||
func run() int {
|
||||
var (
|
||||
addr = flag.String("addr", ":8787", "HTTP listen address")
|
||||
addr = flag.String("addr", "127.0.0.1:8787", "HTTP listen address")
|
||||
dataDir = flag.String("data", filepath.Join(config.BaseDir(), "data"), "data directory for SQLite stores (default: data/ next to the executable)")
|
||||
proxy = flag.String("proxy", "127.0.0.1:8788", "traffic recording proxy address (empty to disable)")
|
||||
)
|
||||
@@ -108,7 +108,16 @@ func run() int {
|
||||
keyDir = abs
|
||||
}
|
||||
log.Printf("[config] JWT key directory: %s", keyDir)
|
||||
srv := server.New(ctx, mgr, skillDir, *dataDir, keyDir, server.WithSecretScrub())
|
||||
// Proxies whose X-Forwarded-For the login rate limit reads; empty trusts none.
|
||||
trusted, err := server.ParseTrustedProxies(os.Getenv("ARTEX_TRUSTED_PROXIES"))
|
||||
if err != nil {
|
||||
log.Printf("[config] ARTEX_TRUSTED_PROXIES: %v", err)
|
||||
return 1
|
||||
}
|
||||
if len(trusted) > 0 {
|
||||
log.Printf("[config] trusted proxies: %v", trusted)
|
||||
}
|
||||
srv := server.New(ctx, mgr, skillDir, *dataDir, keyDir, server.WithSecretScrub(), server.WithTrustedProxies(trusted))
|
||||
httpSrv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: srv.Handler(),
|
||||
|
||||
+15
-10
@@ -64,6 +64,10 @@ type LLMProfile struct {
|
||||
// or sticky routing off a session-id header. "" = not sent. Maps to
|
||||
// agent.Config.SessionHeaderKey.
|
||||
SessionHeaderKey string `json:"session_header_key"`
|
||||
// UserAgent, when non-empty, overrides the User-Agent header on every outbound
|
||||
// request built from this profile. "" = don't override (keep the default UA);
|
||||
// old profiles behave exactly as before. Maps to agent.Config.UserAgent.
|
||||
UserAgent string `json:"user_agent"`
|
||||
// Cost ledger: EUR per 1M tokens (0 = unset; cost is computed only when a
|
||||
// price is set). Set through the same profile API/SaveProfile path.
|
||||
PriceInputPerM float64 `json:"price_input_per_m,omitempty"`
|
||||
@@ -89,8 +93,8 @@ type RetryOverride struct {
|
||||
// profileCols is the read column list (hint variant, no api key) shared by the
|
||||
// list query; profileColsKey is the same with api_key for the single-row loads.
|
||||
const profileRetryCols = `COALESCE(retry_connect_attempts,0),COALESCE(retry_connect_interval_ms,0),COALESCE(retry_empty_attempts,0),COALESCE(retry_empty_interval_ms,0),COALESCE(retry_stream_attempts,0),COALESCE(retry_stream_interval_ms,0)`
|
||||
const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0)`
|
||||
const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0)`
|
||||
const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0),COALESCE(user_agent,'')`
|
||||
const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0),COALESCE(user_agent,'')`
|
||||
|
||||
// scanProfile reads one row in the profileCols / profileColsKey column order. The
|
||||
// 7th column lands in APIKeyHint or APIKey depending on which list the caller used.
|
||||
@@ -101,7 +105,8 @@ func scanProfile(sc interface{ Scan(...any) error }, into *string, p *LLMProfile
|
||||
&p.Retry.Connect.Attempts, &p.Retry.Connect.IntervalMS,
|
||||
&p.Retry.Empty.Attempts, &p.Retry.Empty.IntervalMS,
|
||||
&p.Retry.Stream.Attempts, &p.Retry.Stream.IntervalMS,
|
||||
&p.PriceInputPerM, &p.PriceOutputPerM, &p.PriceCacheReadPerM, &p.PriceCacheWritePerM)
|
||||
&p.PriceInputPerM, &p.PriceOutputPerM, &p.PriceCacheReadPerM, &p.PriceCacheWritePerM,
|
||||
&p.UserAgent)
|
||||
}
|
||||
|
||||
func (d *DB) ListProfiles() ([]*LLMProfile, error) {
|
||||
@@ -180,27 +185,27 @@ func (d *DB) SaveProfile(p *LLMProfile) (int64, error) {
|
||||
r := p.Retry.Clamped()
|
||||
if p.ID == 0 {
|
||||
var id int64
|
||||
err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type,streaming,max_tokens,max_tokens_field,session_header_key,retry_connect_attempts,retry_connect_interval_ms,retry_empty_attempts,retry_empty_interval_ms,retry_stream_attempts,retry_stream_interval_ms)
|
||||
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24) RETURNING id`,
|
||||
err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type,streaming,max_tokens,max_tokens_field,session_header_key,retry_connect_attempts,retry_connect_interval_ms,retry_empty_attempts,retry_empty_interval_ms,retry_stream_attempts,retry_stream_interval_ms,user_agent)
|
||||
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25) RETURNING id`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey,
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS).Scan(&id)
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.UserAgent).Scan(&id)
|
||||
if err == nil {
|
||||
_, err = d.saveProfilePrices(id, p)
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
if p.APIKey == "" {
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12,streaming=$13,max_tokens=$14,max_tokens_field=$15,session_header_key=$16,retry_connect_attempts=$17,retry_connect_interval_ms=$18,retry_empty_attempts=$19,retry_empty_interval_ms=$20,retry_stream_attempts=$21,retry_stream_interval_ms=$22 WHERE id=$23`,
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12,streaming=$13,max_tokens=$14,max_tokens_field=$15,session_header_key=$16,retry_connect_attempts=$17,retry_connect_interval_ms=$18,retry_empty_attempts=$19,retry_empty_interval_ms=$20,retry_stream_attempts=$21,retry_stream_interval_ms=$22,user_agent=$23 WHERE id=$24`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey,
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.ID)
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.UserAgent, p.ID)
|
||||
if err == nil {
|
||||
_, err = d.saveProfilePrices(p.ID, p)
|
||||
}
|
||||
return p.ID, err
|
||||
}
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14,streaming=$15,max_tokens=$16,max_tokens_field=$17,session_header_key=$18,retry_connect_attempts=$19,retry_connect_interval_ms=$20,retry_empty_attempts=$21,retry_empty_interval_ms=$22,retry_stream_attempts=$23,retry_stream_interval_ms=$24 WHERE id=$25`,
|
||||
_, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14,streaming=$15,max_tokens=$16,max_tokens_field=$17,session_header_key=$18,retry_connect_attempts=$19,retry_connect_interval_ms=$20,retry_empty_attempts=$21,retry_empty_interval_ms=$22,retry_stream_attempts=$23,retry_stream_interval_ms=$24,user_agent=$25 WHERE id=$26`,
|
||||
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey,
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.ID)
|
||||
r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.UserAgent, p.ID)
|
||||
if err == nil {
|
||||
_, err = d.saveProfilePrices(p.ID, p)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package db
|
||||
|
||||
// InterceptSource exposes interceptSource to external (db_test) tests.
|
||||
var InterceptSource = interceptSource
|
||||
@@ -0,0 +1,50 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Machine-checked verification state of a finding (S1). Kept apart from
|
||||
// findings.status, which is the human disposition. Values are validated here
|
||||
// (server-side), not by a CHECK constraint, like the other findings columns.
|
||||
const (
|
||||
VerificationVerified = "verified"
|
||||
VerificationUnverified = "unverified"
|
||||
VerificationBlocked = "blocked"
|
||||
VerificationNotAssessed = "not_assessed"
|
||||
VerificationFalsePositive = "false_positive"
|
||||
)
|
||||
|
||||
// ValidVerificationStatus reports whether s is a known verification state.
|
||||
func ValidVerificationStatus(s string) bool {
|
||||
switch s {
|
||||
case VerificationVerified, VerificationUnverified, VerificationBlocked,
|
||||
VerificationNotAssessed, VerificationFalsePositive:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetFindingVerification records the verification state, POE level and note on
|
||||
// the standalone findings row. It does not touch status, report or evidence.
|
||||
func (d *DB) SetFindingVerification(findingID int64, status string, poe int, note string) error {
|
||||
if !ValidVerificationStatus(status) {
|
||||
return fmt.Errorf("invalid verification status %q", status)
|
||||
}
|
||||
if poe < 0 || poe > 4 {
|
||||
return fmt.Errorf("invalid POE level %d (want 0-4)", poe)
|
||||
}
|
||||
res, err := d.Exec(`UPDATE findings SET verification_status=$2, poe_level=$3, verification_note=$4 WHERE id=$1`,
|
||||
findingID, status, poe, note)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrFindingNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+29
-23
@@ -20,20 +20,23 @@ type DBFinding struct {
|
||||
ReportEvidenceVersion int64
|
||||
TrafficBindings []FindingTrafficBinding // populated only for export
|
||||
|
||||
ID int64
|
||||
TaskID *int64
|
||||
NodeID *int64
|
||||
VulnClass string
|
||||
Name string // vulnerability name (readable title); when empty the frontend falls back to VulnClass
|
||||
Severity string
|
||||
Summary string
|
||||
Evidence string
|
||||
Worker string
|
||||
AssetIDs []int64
|
||||
Status string
|
||||
Report string // detailed report (Markdown); only populated by GetFinding, not by list queries
|
||||
CreatedAt time.Time
|
||||
TaskDescription string // populated via LEFT JOIN on tasks
|
||||
ID int64
|
||||
TaskID *int64
|
||||
NodeID *int64
|
||||
VulnClass string
|
||||
Name string // vulnerability name (readable title); when empty the frontend falls back to VulnClass
|
||||
Severity string
|
||||
Summary string
|
||||
Evidence string
|
||||
Worker string
|
||||
AssetIDs []int64
|
||||
Status string
|
||||
PoELevel int // proof-of-exploitation level 0-4 (S1); 0 = none declared
|
||||
VerificationStatus string // verified / unverified / blocked / not_assessed / false_positive
|
||||
VerificationNote string // why the status was reached; empty when verified
|
||||
Report string // detailed report (Markdown); only populated by GetFinding, not by list queries
|
||||
CreatedAt time.Time
|
||||
TaskDescription string // populated via LEFT JOIN on tasks
|
||||
}
|
||||
|
||||
// Finding triage states (findings.status).
|
||||
@@ -105,6 +108,7 @@ func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, name, severity, summary
|
||||
const findingSelectCols = `f.id, f.task_id, f.node_id, f.vulnclass, COALESCE(f.name, ''), f.severity, f.summary,
|
||||
f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at,
|
||||
COALESCE(t.description, '') AS task_description, f.evidence_version, f.report_evidence_version,
|
||||
f.poe_level, COALESCE(f.verification_status, 'unverified'), COALESCE(f.verification_note, ''),
|
||||
(SELECT count(*) FROM finding_traffic_bindings b WHERE b.finding_id=f.id)`
|
||||
|
||||
// scanFindings materializes rows selected via findingSelectCols.
|
||||
@@ -118,7 +122,7 @@ func scanFindings(rows interface {
|
||||
f := &DBFinding{}
|
||||
var aidsJSON string
|
||||
if err := rows.Scan(&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
|
||||
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt, &f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount); err != nil {
|
||||
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt, &f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.PoELevel, &f.VerificationStatus, &f.VerificationNote, &f.TrafficCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs)
|
||||
@@ -150,12 +154,13 @@ func (d *DB) ListFindings(limit int) ([]*DBFinding, error) {
|
||||
// filter on that column". Sort is "severity" (severity desc, then newest) or
|
||||
// anything else (newest first).
|
||||
type FindingFilter struct {
|
||||
Severity string // high | medium | low
|
||||
Status string // pending | false_positive | ignored | resolved
|
||||
VulnClass string
|
||||
TaskID string // task id (in string form; empty/invalid = do not filter by task)
|
||||
Query string // fuzzy search keyword over name/class/summary/evidence/report body
|
||||
Sort string // "severity" | "time"
|
||||
Severity string // high | medium | low
|
||||
Status string // pending | false_positive | ignored | resolved
|
||||
VerificationStatus string // verified | unverified | blocked | not_assessed | false_positive
|
||||
VulnClass string
|
||||
TaskID string // task id (in string form; empty/invalid = do not filter by task)
|
||||
Query string // fuzzy search keyword over name/class/summary/evidence/report body
|
||||
Sort string // "severity" | "time"
|
||||
// AssetScope is a node key of the asset tree (a:<id> / c:<id> / r:<domain> /
|
||||
// __none__); selecting a node means selecting its whole subtree.
|
||||
// Empty = do not filter by asset.
|
||||
@@ -188,6 +193,7 @@ func (f FindingFilter) where() (string, []any) {
|
||||
}
|
||||
add("severity", f.Severity)
|
||||
add("status", f.Status)
|
||||
add("verification_status", f.VerificationStatus)
|
||||
add("vulnclass", f.VulnClass)
|
||||
// task_id is a bigint column, compared as an integer (it cannot go through
|
||||
// the text add above); empty/invalid values are ignored.
|
||||
@@ -519,7 +525,7 @@ func (d *DB) ListFindingsForExport(f FindingFilter, ids []int64) ([]*DBFinding,
|
||||
var aidsJSON string
|
||||
if err := rows.Scan(&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
|
||||
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt,
|
||||
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report); err != nil {
|
||||
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.PoELevel, &f.VerificationStatus, &f.VerificationNote, &f.TrafficCount, &f.Report); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs)
|
||||
@@ -649,7 +655,7 @@ func (d *DB) GetFinding(id int64) (*DBFinding, error) {
|
||||
WHERE f.id = $1`, id).Scan(
|
||||
&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
|
||||
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt,
|
||||
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report)
|
||||
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.PoELevel, &f.VerificationStatus, &f.VerificationNote, &f.TrafficCount, &f.Report)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
+19
-3
@@ -37,7 +37,7 @@ type InterceptPending struct {
|
||||
ToolInput json.RawMessage `json:"tool_input"`
|
||||
Status string `json:"status"`
|
||||
DecisionSource string `json:"decision_source"`
|
||||
Reason string `json:"reason"` // rule message or model decision reason (prefixed with [Model])
|
||||
Reason string `json:"reason"` // rule message or model decision reason (prefixed with ModelReasonPrefix)
|
||||
DecidedAt *time.Time `json:"decided_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -225,10 +225,26 @@ func scanInterceptApprovalRow(rows interface{ Scan(...any) error }, r *Intercept
|
||||
)
|
||||
}
|
||||
|
||||
// ModelReasonPrefix starts every reason written by the LLM fallback judge
|
||||
// (intercept.JudgeReason); readers derive decision_source='model' from it.
|
||||
const ModelReasonPrefix = "[model]"
|
||||
|
||||
// legacyModelReasonPrefix is what the judge wrote before the UI translation (e233c02).
|
||||
const legacyModelReasonPrefix = "[模型]"
|
||||
|
||||
// isModelReason reports whether reason was written by the model judge. The
|
||||
// comparison ignores case: readers used to expect "[Model]" while the writer
|
||||
// produced "[model]", so stored rows may carry either spelling.
|
||||
func isModelReason(reason string) bool {
|
||||
return strings.HasPrefix(strings.ToLower(reason), ModelReasonPrefix) ||
|
||||
strings.HasPrefix(reason, legacyModelReasonPrefix)
|
||||
}
|
||||
|
||||
// Keep legacy rows without decision_source consistent with their displayed source.
|
||||
// Mirrors isModelReason; PostgreSQL LIKE is case-sensitive and '[' is literal.
|
||||
const approvalDecisionSource = `COALESCE(NULLIF(ip.decision_source,''), CASE
|
||||
WHEN ip.rule_id IS NOT NULL THEN 'rule'
|
||||
WHEN ip.reason LIKE '[Model]%' THEN 'model' ELSE 'unknown' END)`
|
||||
WHEN lower(ip.reason) LIKE '` + ModelReasonPrefix + `%' OR ip.reason LIKE '` + legacyModelReasonPrefix + `%' THEN 'model' ELSE 'unknown' END)`
|
||||
|
||||
const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name,
|
||||
ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at, ` + approvalDecisionSource + `,
|
||||
@@ -248,7 +264,7 @@ func interceptSource(ruleID int64, reason string) string {
|
||||
if ruleID != 0 {
|
||||
return "rule"
|
||||
}
|
||||
if strings.HasPrefix(reason, "[Model]") {
|
||||
if isModelReason(reason) {
|
||||
return "model"
|
||||
}
|
||||
return "unknown"
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestInterceptApprovalFilters(t *testing.T) {
|
||||
ruleID = rule.ID
|
||||
}
|
||||
if source == "model" {
|
||||
reason = "[Model] fixture"
|
||||
reason = ModelReasonPrefix + " fixture"
|
||||
}
|
||||
id, err := d.CreateDecidedIntercept(ruleID, 0, task, "test", "Bash", []byte(`{"command":"fixture"}`), status, reason)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
// The judge writer (package intercept) and the decision_source readers (package
|
||||
// db) must agree on the reason prefix; this pins them together without Postgres.
|
||||
func TestInterceptSourceMatchesJudgeWriter(t *testing.T) {
|
||||
for _, tc := range []struct{ action, message string }{
|
||||
{"allow", ""},
|
||||
{"deny", "destructive command outside the workspace"},
|
||||
{"ask", db.ModelReasonPrefix + " already prefixed"},
|
||||
} {
|
||||
reason := intercept.JudgeReason(tc.action, tc.message)
|
||||
if got := db.InterceptSource(0, reason); got != "model" {
|
||||
t.Fatalf("JudgeReason(%q, %q) = %q classified as %q, want model", tc.action, tc.message, reason, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptSourceLegacyModelPrefix(t *testing.T) {
|
||||
for _, reason := range []string{"[模型] 允许", "[Model] fixture"} {
|
||||
if got := db.InterceptSource(0, reason); got != "model" {
|
||||
t.Fatalf("%q classified as %q, want model", reason, got)
|
||||
}
|
||||
}
|
||||
if got := db.InterceptSource(0, "legacy reason"); got != "unknown" {
|
||||
t.Fatalf("plain reason classified as %q, want unknown", got)
|
||||
}
|
||||
if got := db.InterceptSource(7, "[model] rule wins"); got != "rule" {
|
||||
t.Fatalf("rule row classified as %q, want rule", got)
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -396,6 +396,8 @@ CREATE TABLE IF NOT EXISTS llm_profiles (
|
||||
-- Custom session header: when non-empty, every request carries an HTTP header of this name whose value is the current session id
|
||||
-- (chat session/worker intent). For gateways that do prompt caching/sticky routing by a session-id header. ''=do not send.
|
||||
session_header_key TEXT NOT NULL DEFAULT '',
|
||||
-- Custom User-Agent: when non-empty, every outbound LLM request overrides the User-Agent header with this value. ''=do not override (keep the default UA).
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
-- Retry overrides: attempts 0=use global default / -1=disable / >0=use this value; interval 0=use default exponential backoff / >0=fixed milliseconds.
|
||||
-- The three groups correspond to connect retry, empty-response retry, and same-provider safe-window retry; see the ALTER comment below.
|
||||
retry_connect_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -436,6 +438,8 @@ ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_check
|
||||
CHECK (max_tokens >= 0);
|
||||
-- Custom session header name; backfills old databases. Default '' = do not send; old configs behave unchanged.
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS session_header_key TEXT NOT NULL DEFAULT '';
|
||||
-- Custom User-Agent; backfills old databases. Default '' = do not override (keep the default UA); old configs behave unchanged.
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS user_agent TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Per-profile retry overrides (see the LLM retry design doc). Each of the three groups is a pair of
|
||||
-- "attempts + fixed interval" with unified semantics: attempts 0=use the global default, -1=disable this layer's
|
||||
@@ -1084,7 +1088,7 @@ CREATE TABLE IF NOT EXISTS intercept_pending (
|
||||
tool_input JSONB NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'allowed', 'denied', 'timeout')),
|
||||
-- Decision reason: the rule message when a rule hits; the model's short reason (with the [Model] prefix) for an LLM fallback decision.
|
||||
-- Decision reason: the rule message when a rule hits; the model's short reason (with the [model] prefix) for an LLM fallback decision.
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
decided_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
@@ -1098,6 +1102,12 @@ ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS audit JSONB;
|
||||
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS decision_source TEXT NOT NULL DEFAULT '';
|
||||
UPDATE intercept_pending SET decision_source=CASE WHEN rule_id IS NOT NULL THEN 'rule'
|
||||
WHEN reason LIKE '[Model]%' THEN 'model' ELSE 'unknown' END WHERE decision_source='';
|
||||
-- Judge decisions were stored as 'unknown' because the readers expected a capitalized
|
||||
-- prefix while the judge wrote '[model]' (and '[模型]' before e233c02). Idempotent:
|
||||
-- once corrected no row matches the WHERE clause again.
|
||||
UPDATE intercept_pending SET decision_source='model'
|
||||
WHERE decision_source='unknown' AND rule_id IS NULL
|
||||
AND (reason LIKE '[model]%' OR reason LIKE '[模型]%');
|
||||
|
||||
-- =====================================================================
|
||||
-- L. Finding persistence
|
||||
@@ -1167,6 +1177,12 @@ CREATE TRIGGER trg_conversation_retest_delete BEFORE DELETE ON conversations
|
||||
|
||||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS evidence_version BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS report_evidence_version BIGINT NOT NULL DEFAULT 0;
|
||||
-- Machine-checked proof state (S1). Separate from status (human disposition). Existing rows stay
|
||||
-- unverified / POE 0: history is never auto-verified. Values validated server-side, no CHECK.
|
||||
-- verification_status: verified / unverified / blocked / not_assessed / false_positive.
|
||||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS poe_level SMALLINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS verification_status TEXT NOT NULL DEFAULT 'unverified';
|
||||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS verification_note TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS traffic_evidence_snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -677,7 +677,7 @@ func restoreInterceptRows(tx *sql.Tx, raw json.RawMessage) error {
|
||||
source = "unknown"
|
||||
if row["rule_id"] != nil {
|
||||
source = "rule"
|
||||
} else if reason, _ := row["reason"].(string); strings.HasPrefix(reason, "[Model]") {
|
||||
} else if reason, _ := row["reason"].(string); isModelReason(reason) {
|
||||
source = "model"
|
||||
}
|
||||
row["decision_source"] = source
|
||||
|
||||
@@ -35,6 +35,7 @@ services:
|
||||
ARTEX_LLM_BASE_URL: ${ARTEX_LLM_BASE_URL:-}
|
||||
ARTEX_LLM_PROXY: ${ARTEX_LLM_PROXY:-}
|
||||
ARTEX_JWT_KEY_DIR: /app/keys
|
||||
ARTEX_TRUSTED_PROXIES: ${ARTEX_TRUSTED_PROXIES:-}
|
||||
ports:
|
||||
- "${ARTEX_BIND_ADDR:-127.0.0.1}:8787:8787"
|
||||
- "127.0.0.1:8788:8788"
|
||||
|
||||
@@ -61,4 +61,4 @@ require (
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/Autumn-27/norma => github.com/BaaRai/Norma v0.4.4
|
||||
replace github.com/Autumn-27/norma => github.com/BaaRai/Norma v0.4.6
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
github.com/BaaRai/Norma v0.4.4 h1:x5UAeaTuhREI1GiIYuwIEybRxr/pLCeCUvXmUbwv5Is=
|
||||
github.com/BaaRai/Norma v0.4.4/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw=
|
||||
github.com/BaaRai/Norma v0.4.6 h1:sEm58Gx7gw+UU/05aHZBZ+Xqm5dMYiuSQpc1aXnbElc=
|
||||
github.com/BaaRai/Norma v0.4.6/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
|
||||
+14
-5
@@ -476,11 +476,7 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw
|
||||
out.ProfileID = cfg.ProfileID
|
||||
configJSON, _ := json.Marshal(cfg)
|
||||
out.ConfigDigest = digestInput(configJSON)
|
||||
if out.Message == "" {
|
||||
out.Message = "[model] " + judgeActionLabel(out.Action)
|
||||
} else if !strings.HasPrefix(out.Message, "[model]") {
|
||||
out.Message = "[model] " + out.Message
|
||||
}
|
||||
out.Message = JudgeReason(out.Action, out.Message)
|
||||
if out.Action == "ask" {
|
||||
out.TimeoutEnabled = true
|
||||
out.TimeoutSeconds = cfg.AskTimeoutSeconds
|
||||
@@ -489,6 +485,19 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw
|
||||
return out, true
|
||||
}
|
||||
|
||||
// JudgeReason is the reason stored for a judge verdict: the model's message, or
|
||||
// a label for the action when it gave none, carrying db.ModelReasonPrefix so
|
||||
// the db readers record decision_source='model'.
|
||||
func JudgeReason(action, message string) string {
|
||||
if message == "" {
|
||||
return db.ModelReasonPrefix + " " + judgeActionLabel(action)
|
||||
}
|
||||
if !strings.HasPrefix(message, db.ModelReasonPrefix) {
|
||||
return db.ModelReasonPrefix + " " + message
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func judgeActionLabel(action string) string {
|
||||
switch action {
|
||||
case "allow":
|
||||
|
||||
+51
-18
@@ -28,7 +28,7 @@ import (
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
const protocolVersion = "2025-06-18"
|
||||
const protocolVersion = "2025-11-25"
|
||||
const legacySSEProtocolVersion = "2024-11-05"
|
||||
|
||||
// ToolName mirrors mcp.ToolName so remote tools share the mcp__server__tool scheme.
|
||||
@@ -66,6 +66,11 @@ type Client struct {
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
sessionID string
|
||||
// initialized is set once initialize succeeds. Until then the
|
||||
// MCP-Protocol-Version header is omitted: the spec requires it on requests
|
||||
// after initialization, and the offered version already travels in the
|
||||
// initialize body.
|
||||
initialized bool
|
||||
|
||||
// legacySSE is the pre-2025 MCP SSE transport used by servers such as GSL5.
|
||||
legacySSE bool
|
||||
@@ -247,18 +252,47 @@ func (c *Client) readLegacySSE(r *bufio.Reader) {
|
||||
}
|
||||
|
||||
func (c *Client) initialize(ctx context.Context) error {
|
||||
version := c.protocol
|
||||
if version == "" {
|
||||
version = protocolVersion
|
||||
versions := []string{protocolVersion, "2025-06-18"}
|
||||
if c.legacySSE {
|
||||
versions = []string{legacySSEProtocolVersion}
|
||||
}
|
||||
if _, err := c.call(ctx, "initialize", map[string]any{
|
||||
"protocolVersion": version,
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{"name": "artex", "version": "0.2"},
|
||||
}); err != nil {
|
||||
return err
|
||||
for i, version := range versions {
|
||||
c.mu.Lock()
|
||||
c.protocol = version
|
||||
c.mu.Unlock()
|
||||
result, err := c.call(ctx, "initialize", map[string]any{
|
||||
"protocolVersion": version,
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{"name": "artex", "version": "0.2"},
|
||||
})
|
||||
if err != nil {
|
||||
if i+1 < len(versions) && isUnsupportedVersion(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
var negotiated struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
}
|
||||
c.mu.Lock()
|
||||
if json.Unmarshal(result, &negotiated) == nil && negotiated.ProtocolVersion != "" {
|
||||
c.protocol = negotiated.ProtocolVersion
|
||||
}
|
||||
c.initialized = true
|
||||
c.mu.Unlock()
|
||||
return c.notify(ctx, "notifications/initialized", map[string]any{})
|
||||
}
|
||||
return c.notify(ctx, "notifications/initialized", map[string]any{})
|
||||
return fmt.Errorf("mcp: no supported protocol version")
|
||||
}
|
||||
|
||||
// isUnsupportedVersion reports whether err is a server rejecting the offered
|
||||
// protocol version: the spec's "Unsupported protocol version" (JSON-RPC -32602,
|
||||
// or an HTTP 400 on the MCP-Protocol-Version header) or the
|
||||
// UNSUPPORTED_PROTOCOL_VERSION code some servers return instead.
|
||||
func isUnsupportedVersion(err error) bool {
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "unsupported protocol version") ||
|
||||
strings.Contains(msg, "unsupported_protocol_version")
|
||||
}
|
||||
|
||||
// Tools lists the server's tools and adapts them to CoreTools.
|
||||
@@ -438,17 +472,16 @@ func (c *Client) roundTrip(ctx context.Context, body rpcRequest, expectResp bool
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
version := c.protocol
|
||||
if version == "" {
|
||||
version = protocolVersion
|
||||
c.mu.Lock()
|
||||
sid := c.sessionID
|
||||
initialized, version := c.initialized, c.protocol
|
||||
c.mu.Unlock()
|
||||
if initialized && version != "" {
|
||||
req.Header.Set("MCP-Protocol-Version", version)
|
||||
}
|
||||
req.Header.Set("MCP-Protocol-Version", version)
|
||||
for k, v := range c.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
c.mu.Lock()
|
||||
sid := c.sessionID
|
||||
c.mu.Unlock()
|
||||
if sid != "" {
|
||||
req.Header.Set("Mcp-Session-Id", sid)
|
||||
}
|
||||
|
||||
@@ -73,3 +73,163 @@ func TestStreamableClientRequestTimeout(t *testing.T) {
|
||||
}
|
||||
_ = c3.Close()
|
||||
}
|
||||
|
||||
// initRequest captures what the client sent for one MCP request.
|
||||
type initRequest struct {
|
||||
method, header, bodyVersion string
|
||||
}
|
||||
|
||||
func recordRequest(r *http.Request) (rpcRequest, initRequest) {
|
||||
var req rpcRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
rec := initRequest{method: req.Method, header: r.Header.Get("MCP-Protocol-Version")}
|
||||
if p, ok := req.Params.(map[string]any); ok {
|
||||
rec.bodyVersion, _ = p["protocolVersion"].(string)
|
||||
}
|
||||
return req, rec
|
||||
}
|
||||
|
||||
// TestStreamableClientProtocolVersions uses spec-shaped servers that each support
|
||||
// one version: they answer initialize with their own version, accept a request
|
||||
// without MCP-Protocol-Version, and reject any other header value. The client
|
||||
// must send no version header on initialize and the negotiated one afterwards.
|
||||
func TestStreamableClientProtocolVersions(t *testing.T) {
|
||||
for _, supported := range []string{"2025-11-25", "2025-06-18", "2025-03-26"} {
|
||||
t.Run(supported, func(t *testing.T) {
|
||||
var seen []initRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
req, rec := recordRequest(r)
|
||||
seen = append(seen, rec)
|
||||
if rec.header != "" && rec.header != supported {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"code":"UNSUPPORTED_PROTOCOL_VERSION"}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"protocolVersion":%q,"capabilities":{},"serverInfo":{"name":"t","version":"0"}}}`, req.ID, supported)
|
||||
case "notifications/initialized":
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case "tools/list":
|
||||
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"tools":[]}}`, req.ID)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(context.Background(), "t", srv.URL, nil, false, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if _, err := c.Tools(context.Background()); err != nil {
|
||||
t.Fatalf("tools/list: %v", err)
|
||||
}
|
||||
if c.protocol != supported {
|
||||
t.Fatalf("negotiated protocol = %q", c.protocol)
|
||||
}
|
||||
var inits int
|
||||
for _, rec := range seen {
|
||||
if rec.method == "initialize" {
|
||||
inits++
|
||||
if rec.header != "" {
|
||||
t.Fatalf("initialize carried MCP-Protocol-Version %q", rec.header)
|
||||
}
|
||||
if rec.bodyVersion != protocolVersion {
|
||||
t.Fatalf("initialize offered %q, want %q", rec.bodyVersion, protocolVersion)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rec.header != supported {
|
||||
t.Fatalf("%s carried MCP-Protocol-Version %q, want %q", rec.method, rec.header, supported)
|
||||
}
|
||||
}
|
||||
if inits != 1 {
|
||||
t.Fatalf("initialize attempts = %d, want 1", inits)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamableClientVersionRejectionForms keeps the fallback loop honest for
|
||||
// servers that reject the initialize body version instead of negotiating: an
|
||||
// HTTP 400 "Unsupported protocol version", the JSON-RPC -32602 error from the
|
||||
// spec's example, and an unrelated failure that must not trigger a retry.
|
||||
func TestStreamableClientVersionRejectionForms(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
reject func(w http.ResponseWriter, id int)
|
||||
fallback bool
|
||||
}{
|
||||
{"http 400", func(w http.ResponseWriter, _ int) {
|
||||
http.Error(w, "Bad Request: Unsupported protocol version", http.StatusBadRequest)
|
||||
}, true},
|
||||
{"json-rpc -32602", func(w http.ResponseWriter, id int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"error":{"code":-32602,"message":"Unsupported protocol version","data":{"supported":["2025-06-18"],"requested":"2025-11-25"}}}`, id)
|
||||
}, true},
|
||||
{"unrelated 401", func(w http.ResponseWriter, _ int) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var inits []initRequest
|
||||
var after []initRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
req, rec := recordRequest(r)
|
||||
if req.Method == "initialize" {
|
||||
inits = append(inits, rec)
|
||||
if rec.bodyVersion != "2025-06-18" {
|
||||
tc.reject(w, req.ID)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
after = append(after, rec)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"t","version":"0"}}}`, req.ID)
|
||||
case "notifications/initialized":
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(context.Background(), "t", srv.URL, nil, false, 0)
|
||||
for _, rec := range inits {
|
||||
if rec.header != "" {
|
||||
t.Fatalf("initialize carried MCP-Protocol-Version %q", rec.header)
|
||||
}
|
||||
}
|
||||
if !tc.fallback {
|
||||
if err == nil {
|
||||
_ = c.Close()
|
||||
t.Fatal("expected connect error")
|
||||
}
|
||||
if len(inits) != 1 {
|
||||
t.Fatalf("unrelated error retried: %d initialize attempts", len(inits))
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if len(inits) != 2 || inits[0].bodyVersion != protocolVersion || inits[1].bodyVersion != "2025-06-18" {
|
||||
t.Fatalf("fallback attempts = %+v", inits)
|
||||
}
|
||||
if c.protocol != "2025-06-18" {
|
||||
t.Fatalf("negotiated protocol = %q", c.protocol)
|
||||
}
|
||||
if len(after) == 0 || after[0].method != "notifications/initialized" || after[0].header != "2025-06-18" {
|
||||
t.Fatalf("requests after initialize = %+v", after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -14,7 +14,7 @@
|
||||
#
|
||||
# Usage examples:
|
||||
# ./reset-password.sh # local, auto-reads config.json/env, prompts for the new password
|
||||
# ./reset-password.sh -p 'NewPass!' # local, new password given directly
|
||||
# ./reset-password.sh -p 'New-Passphrase!' # local, new password given directly (at least 12 characters)
|
||||
# ./reset-password.sh --dsn postgres://u:p@h:5432/artex
|
||||
# ./reset-password.sh -H 127.0.0.1 -P 5433 -U autopentest -W pass -d artex
|
||||
# ./reset-password.sh -m docker # docker deployment (reads POSTGRES_* from .env)
|
||||
@@ -95,7 +95,7 @@ PY
|
||||
}
|
||||
|
||||
apply_config_fields() {
|
||||
local line key val
|
||||
local key val
|
||||
while IFS=$'\t' read -r key val; do
|
||||
[[ -z "$key" ]] && continue
|
||||
case "$key" in
|
||||
@@ -130,6 +130,10 @@ if [[ -z "$NEWPASS" ]]; then
|
||||
[[ "$NEWPASS" == "$NEWPASS2" ]] || die "the two entries do not match"
|
||||
fi
|
||||
[[ -n "$NEWPASS" ]] || die "password cannot be empty"
|
||||
# Same minimum as the server (minPasswordRunes in server/auth.go), counted in
|
||||
# characters like the server: UTF-8 bytes minus continuation bytes, whatever the locale.
|
||||
NEWPASS_CHARS=$(printf '%s' "$NEWPASS" | LC_ALL=C tr -d '\200-\277' | wc -c)
|
||||
(( NEWPASS_CHARS >= 12 )) || die "password must be at least 12 characters"
|
||||
|
||||
# Hand the password to psql via environment variable (read with \getenv, never in argv/ps)
|
||||
export ARTEX_RESET_NEWPASS="$NEWPASS"
|
||||
|
||||
+21
-2
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -29,7 +30,9 @@ const (
|
||||
// The lower bound matches the setup page's frontend validation — validation that
|
||||
// lives only in the frontend is as good as none: hitting the API directly bypasses
|
||||
// it. The upper bound is bcrypt's hard limit: past 72 bytes GenerateFromPassword returns ErrPasswordTooLong; rejecting early beats a cryptic "password encryption failed".
|
||||
minPasswordRunes = 8
|
||||
// Both apply to new passwords only (init, change-password); login never checks
|
||||
// length, so passwords set under the old 8-character minimum still log in.
|
||||
minPasswordRunes = 12
|
||||
maxPasswordBytes = 72
|
||||
)
|
||||
|
||||
@@ -372,6 +375,21 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
s.login(w, r, pg.GetSetting)
|
||||
}
|
||||
|
||||
// login is authLogin past the data-source check; getSetting is pg.GetSetting,
|
||||
// a parameter so tests can run it without PostgreSQL. Every attempt counts
|
||||
// against the caller's IP (s.loginLimit, keyed by clientIP) and a successful
|
||||
// one clears it. There is no per-account lockout and no password length check here.
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request, getSetting func(string) (string, bool, error)) {
|
||||
ip := clientIP(r, s.trustedProxies)
|
||||
if wait := s.loginLimit.allow(ip); wait > 0 {
|
||||
secs := int((wait + time.Second - 1) / time.Second)
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
writeErr(w, 429, fmt.Sprintf("Too many login attempts, try again in %d seconds", secs))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -384,7 +402,7 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 401, "Incorrect username or password")
|
||||
return
|
||||
}
|
||||
hash, ok, err := pg.GetSetting(authPassKey)
|
||||
hash, ok, err := getSetting(authPassKey)
|
||||
if err != nil {
|
||||
writeErr(w, 503, errDataSourceUnavailable)
|
||||
return
|
||||
@@ -397,6 +415,7 @@ func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 401, "Incorrect username or password")
|
||||
return
|
||||
}
|
||||
s.loginLimit.reset(ip)
|
||||
tok, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "token generation failed")
|
||||
|
||||
+5
-4
@@ -302,10 +302,11 @@ func TestValidatePassword(t *testing.T) {
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", true},
|
||||
{"seven chars", "1234567", true},
|
||||
{"eight chars", "12345678", false},
|
||||
{"eight multibyte chars counted as runes not bytes", strings.Repeat("€", 8), false},
|
||||
{"three chars reach 9 bytes but only 3 runes", strings.Repeat("€", 3), true},
|
||||
{"eight chars, the old minimum", "12345678", true},
|
||||
{"eleven chars", "12345678901", true},
|
||||
{"twelve chars", "123456789012", false},
|
||||
{"twelve multibyte chars counted as runes not bytes", strings.Repeat("€", 12), false},
|
||||
{"four chars reach 12 bytes but only 4 runes", strings.Repeat("€", 4), true},
|
||||
{"72 bytes", strings.Repeat("a", 72), false},
|
||||
{"73 bytes exceeds the bcrypt cap", strings.Repeat("a", 73), true},
|
||||
} {
|
||||
|
||||
@@ -312,6 +312,9 @@ type FindingDTO struct {
|
||||
ReportEvidenceVersion int64 `json:"report_evidence_version"`
|
||||
ReportStale bool `json:"report_stale"`
|
||||
TrafficBindings []db.FindingTrafficBinding `json:"traffic_bindings,omitempty"`
|
||||
PoELevel int `json:"poe_level"`
|
||||
VerificationStatus string `json:"verification_status"`
|
||||
VerificationNote string `json:"verification_note"`
|
||||
|
||||
ID string `json:"id"`
|
||||
FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates
|
||||
@@ -452,6 +455,7 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
|
||||
FindingID: i64s(f.ID),
|
||||
TrafficCount: f.TrafficCount, EvidenceVersion: f.EvidenceVersion, ReportEvidenceVersion: f.ReportEvidenceVersion,
|
||||
ReportStale: f.Report != "" && f.EvidenceVersion != f.ReportEvidenceVersion, TrafficBindings: f.TrafficBindings,
|
||||
PoELevel: f.PoELevel, VerificationStatus: f.VerificationStatus, VerificationNote: f.VerificationNote,
|
||||
VulnClass: f.VulnClass,
|
||||
Name: f.Name,
|
||||
Severity: f.Severity,
|
||||
@@ -617,6 +621,9 @@ type LLMProfileDTO struct {
|
||||
// Custom session header name: when non-empty, every request carries this HTTP header,
|
||||
// its value = the current session/intent's session id. '' = do not send. For gateways that key prompt caching/sticky routing off a session-id header.
|
||||
SessionHeaderKey string `json:"session_header_key"`
|
||||
// Custom User-Agent: when non-empty, every outbound LLM request overrides its
|
||||
// User-Agent header with this value. '' = do not override (keep the default UA).
|
||||
UserAgent string `json:"user_agent"`
|
||||
// This profile's retry overrides (connect / empty response / same-provider safe
|
||||
// window). Per item attempts: 0=inherit the global policy | -1=disable that retry
|
||||
// layer | >0=count; interval_ms: 0=default exponential backoff | >0=use that fixed millisecond interval. All zero = fully follow the global policy, i.e. the historical behavior.
|
||||
@@ -644,6 +651,7 @@ func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
|
||||
MaxTokens: p.MaxTokens,
|
||||
MaxTokensField: p.MaxTokensField,
|
||||
SessionHeaderKey: p.SessionHeaderKey,
|
||||
UserAgent: p.UserAgent,
|
||||
Retry: p.Retry,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/evidence"
|
||||
)
|
||||
|
||||
// S1 receipt check. A finding is verified only when a proof/verification
|
||||
// traffic body is bound, the agent declared POE L3 or higher, and every quote
|
||||
// appears verbatim (after whitespace normalization) in one of those response
|
||||
// bodies. Anything else is unverified, blocked or not_assessed, with a note.
|
||||
|
||||
const (
|
||||
verificationMinPOE = 3
|
||||
verificationMinQuoteRunes = 8 // shorter normalized quotes are ignored: "x7" proves nothing
|
||||
verificationReadCap = 4 << 20 // bytes read per bound body; a quote beyond this cannot be confirmed
|
||||
verificationNoteQuoteLen = 60
|
||||
)
|
||||
|
||||
// boundBody is one bound response body, read through the evidence store and
|
||||
// normalized for the substring check.
|
||||
type boundBody struct {
|
||||
text string // whitespace-normalized text (only when !nonText)
|
||||
truncated bool // body was longer than verificationReadCap
|
||||
nonText bool // binary or non-UTF-8 content: never claimed as a match
|
||||
}
|
||||
|
||||
// normalizeEvidenceText trims and collapses every run of whitespace to one space.
|
||||
// Case is kept.
|
||||
func normalizeEvidenceText(s string) string {
|
||||
return strings.Join(strings.Fields(s), " ")
|
||||
}
|
||||
|
||||
// readBoundResponse reads the response side of a bound snapshot through the
|
||||
// evidence store (same bytes the UI shows), capped at verificationReadCap.
|
||||
func readBoundResponse(store *evidence.Store, snap db.TrafficEvidenceSnapshot) (boundBody, error) {
|
||||
f, _, err := store.OpenBody(snap, "response")
|
||||
if err != nil {
|
||||
return boundBody{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(f, verificationReadCap+1))
|
||||
if err != nil {
|
||||
return boundBody{}, err
|
||||
}
|
||||
var bb boundBody
|
||||
if len(raw) > verificationReadCap {
|
||||
bb.truncated = true
|
||||
raw = raw[:verificationReadCap]
|
||||
// The cap may cut a multi-byte rune: drop at most UTFMax-1 trailing bytes.
|
||||
for i := 0; i < utf8.UTFMax-1 && !utf8.Valid(raw) && len(raw) > 0; i++ {
|
||||
raw = raw[:len(raw)-1]
|
||||
}
|
||||
}
|
||||
if bytes.IndexByte(raw, 0) >= 0 || !utf8.Valid(raw) {
|
||||
bb.nonText = true
|
||||
return bb, nil
|
||||
}
|
||||
bb.text = normalizeEvidenceText(string(raw))
|
||||
return bb, nil
|
||||
}
|
||||
|
||||
// assessFindingVerification computes the verification state of a finding from
|
||||
// its bound traffic and the quotes the agent supplied. poe is the declared POE
|
||||
// level (0 when absent). It never returns "verified" unless every rule passes.
|
||||
func (s *Server) assessFindingVerification(ctx context.Context, findingID int64, poe int, quotes []string) (status, note string) {
|
||||
tr, err := s.m.pg.GetFindingTraffic(ctx, findingID)
|
||||
if err != nil {
|
||||
return db.VerificationNotAssessed, "binding read error: " + err.Error()
|
||||
}
|
||||
var proofs []db.FindingTrafficBinding
|
||||
for _, b := range tr.Bindings {
|
||||
if b.Role == "proof" || b.Role == "verification" {
|
||||
proofs = append(proofs, b)
|
||||
}
|
||||
}
|
||||
if len(proofs) == 0 {
|
||||
return db.VerificationUnverified, "no proof traffic bound"
|
||||
}
|
||||
if poe < verificationMinPOE {
|
||||
return db.VerificationUnverified, fmt.Sprintf("POE L%d below L3", poe)
|
||||
}
|
||||
if len(quotes) == 0 {
|
||||
return db.VerificationUnverified, "no evidence quotes provided"
|
||||
}
|
||||
var checked []string
|
||||
for _, q := range quotes {
|
||||
if utf8.RuneCountInString(normalizeEvidenceText(q)) >= verificationMinQuoteRunes {
|
||||
checked = append(checked, q)
|
||||
}
|
||||
}
|
||||
if len(checked) == 0 {
|
||||
return db.VerificationUnverified, "evidence quote too short"
|
||||
}
|
||||
store := s.evidenceStore()
|
||||
bodies := make([]boundBody, 0, len(proofs))
|
||||
for _, b := range proofs {
|
||||
bb, err := readBoundResponse(store, b.Snapshot)
|
||||
if err != nil {
|
||||
// Fail closed: an unreadable body is never an "unverified" verdict.
|
||||
return db.VerificationNotAssessed, "evidence body unreadable: " + err.Error()
|
||||
}
|
||||
bodies = append(bodies, bb)
|
||||
}
|
||||
for _, q := range checked {
|
||||
nq := normalizeEvidenceText(q)
|
||||
if nq != "" && quoteInBodies(nq, bodies) {
|
||||
continue
|
||||
}
|
||||
for _, bb := range bodies {
|
||||
if bb.nonText {
|
||||
return db.VerificationNotAssessed, "non-text evidence"
|
||||
}
|
||||
}
|
||||
for _, bb := range bodies {
|
||||
if bb.truncated {
|
||||
return db.VerificationNotAssessed, "quote not found within read cap of bound evidence"
|
||||
}
|
||||
}
|
||||
return db.VerificationUnverified, "quote not found in bound evidence: " + shortQuote(q)
|
||||
}
|
||||
return db.VerificationVerified, ""
|
||||
}
|
||||
|
||||
func quoteInBodies(nq string, bodies []boundBody) bool {
|
||||
for _, bb := range bodies {
|
||||
if !bb.nonText && strings.Contains(bb.text, nq) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shortQuote returns the first verificationNoteQuoteLen runes of the trimmed
|
||||
// quote, for use in a note.
|
||||
func shortQuote(q string) string {
|
||||
r := []rune(strings.TrimSpace(q))
|
||||
if len(r) > verificationNoteQuoteLen {
|
||||
r = r[:verificationNoteQuoteLen]
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
// seedFindingVerificationSchema adds the S1 optional properties to the
|
||||
// update_finding_report schema of existing databases. Idempotent: a property is
|
||||
// added only when the stored schema lacks it, and user edits are preserved.
|
||||
func (s *Server) seedFindingVerificationSchema() {
|
||||
raw, err := json.Marshal(s.toolUpdateFindingReport().InputSchema())
|
||||
if err != nil {
|
||||
log.Printf("[verification] tool schema: %v", err)
|
||||
return
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return
|
||||
}
|
||||
var properties map[string]json.RawMessage
|
||||
if err := json.Unmarshal(obj["properties"], &properties); err != nil {
|
||||
return
|
||||
}
|
||||
for _, name := range []string{"poe_level", "evidence_quotes"} {
|
||||
if len(properties[name]) == 0 {
|
||||
continue
|
||||
}
|
||||
_, err := s.m.pg.Exec(`UPDATE tools SET schema=jsonb_set(schema,ARRAY['properties',$2::text],$3::jsonb,true),updated_at=now()
|
||||
WHERE key=$1 AND system AND NOT(COALESCE(schema->'properties','{}'::jsonb) ? $2)`, "update_finding_report", name, string(properties[name]))
|
||||
if err != nil {
|
||||
log.Printf("[verification] upgrade update_finding_report.%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
const verificationBody = "HTTP/1.1 200 OK\nuser admin:\tx7\nend of dump"
|
||||
|
||||
func TestNormalizeEvidenceText(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
" a\t\tb\r\n c ": "a b c",
|
||||
"Admin User": "Admin User", // case is kept
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeEvidenceText(in); got != want {
|
||||
t.Fatalf("normalize(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortQuoteCapsAtSixtyRunes(t *testing.T) {
|
||||
long := strings.Repeat("é", 100)
|
||||
if got := []rune(shortQuote(long)); len(got) != verificationNoteQuoteLen {
|
||||
t.Fatalf("shortQuote kept %d runes, want %d", len(got), verificationNoteQuoteLen)
|
||||
}
|
||||
}
|
||||
|
||||
// verificationFixture returns a server with one finding whose traffic "a" is
|
||||
// bound with the given role and whose response body is verificationBody.
|
||||
func verificationFixture(t *testing.T, role string) (*Server, *db.RecordedFinding, func(string, string, string) *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
s, f, req := trafficEvidenceServer(t)
|
||||
seedServerEvidenceFlow(t, s, "a", []byte(verificationBody))
|
||||
if role != "" {
|
||||
base := fmt.Sprintf("/api/exploration/findings/%d/traffic", f.FindingID)
|
||||
w := req("POST", base, fmt.Sprintf(`{"traffic_refs":[{"traffic_id":"a","role":%q}]}`, role))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("bind: %d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
return s, f, req
|
||||
}
|
||||
|
||||
// updateReport calls update_finding_report with the node id, as the agent does.
|
||||
func updateReport(t *testing.T, s *Server, nodeID int64, extra string) (string, bool) {
|
||||
t.Helper()
|
||||
in := fmt.Sprintf(`{"finding_id":%d,"report":"# report","evidence_version":null%s}`, nodeID, extra)
|
||||
res, err := s.toolUpdateFindingReport().Call(context.Background(), json.RawMessage(in), &actool.ToolContext{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return res.Flatten(), res.IsError
|
||||
}
|
||||
|
||||
func storedVerification(t *testing.T, s *Server, findingID int64) (status string, poe int, note, report string) {
|
||||
t.Helper()
|
||||
if err := s.m.pg.QueryRow(`SELECT verification_status, poe_level, verification_note, report FROM findings WHERE id=$1`, findingID).
|
||||
Scan(&status, &poe, ¬e, &report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func TestVerificationNoBindingPOE4Unverified(t *testing.T) {
|
||||
s, f, _ := verificationFixture(t, "")
|
||||
msg, isErr := updateReport(t, s, f.NodeID, `,"poe_level":4,"evidence_quotes":["x7"]`)
|
||||
if isErr || !strings.Contains(msg, "verification: unverified - no proof traffic bound") {
|
||||
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
|
||||
}
|
||||
status, poe, note, report := storedVerification(t, s, f.FindingID)
|
||||
if status != db.VerificationUnverified || poe != 4 || note != "no proof traffic bound" || report == "" {
|
||||
t.Fatalf("stored: status=%s poe=%d note=%q report=%q", status, poe, note, report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationProofPOE3MissingQuoteUnverified(t *testing.T) {
|
||||
s, f, _ := verificationFixture(t, "proof")
|
||||
msg, isErr := updateReport(t, s, f.NodeID, `,"poe_level":3,"evidence_quotes":["secret=999"]`)
|
||||
if isErr || !strings.Contains(msg, "warning:") {
|
||||
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
|
||||
}
|
||||
status, _, note, report := storedVerification(t, s, f.FindingID)
|
||||
if status != db.VerificationUnverified || !strings.HasPrefix(note, "quote not found in bound evidence: secret=999") || report == "" {
|
||||
t.Fatalf("stored: status=%s note=%q report=%q", status, note, report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationProofPOE3ExactQuoteVerified(t *testing.T) {
|
||||
s, f, _ := verificationFixture(t, "verification")
|
||||
// The quote differs from the body only in whitespace runs: it must still match.
|
||||
msg, isErr := updateReport(t, s, f.NodeID, `,"poe_level":3,"evidence_quotes":["user admin: x7"]`)
|
||||
if isErr || !strings.Contains(msg, "verification: verified (POE L3)") {
|
||||
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
|
||||
}
|
||||
status, poe, note, _ := storedVerification(t, s, f.FindingID)
|
||||
if status != db.VerificationVerified || poe != 3 || note != "" {
|
||||
t.Fatalf("stored: status=%s poe=%d note=%q", status, poe, note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationShortQuoteUnverified(t *testing.T) {
|
||||
s, f, _ := verificationFixture(t, "proof")
|
||||
// "x7" is present in the body but below verificationMinQuoteRunes: it must not count.
|
||||
msg, isErr := updateReport(t, s, f.NodeID, `,"poe_level":3,"evidence_quotes":["x7"]`)
|
||||
if isErr || !strings.Contains(msg, "verification: unverified - evidence quote too short") {
|
||||
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
|
||||
}
|
||||
status, _, note, _ := storedVerification(t, s, f.FindingID)
|
||||
if status != db.VerificationUnverified || note != "evidence quote too short" {
|
||||
t.Fatalf("stored: status=%s note=%q", status, note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationOldStyleCallUnverifiedWithoutError(t *testing.T) {
|
||||
s, f, _ := verificationFixture(t, "proof")
|
||||
msg, isErr := updateReport(t, s, f.NodeID, "")
|
||||
if isErr || !strings.Contains(msg, "verification: unverified") {
|
||||
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
|
||||
}
|
||||
status, poe, _, report := storedVerification(t, s, f.FindingID)
|
||||
if status != db.VerificationUnverified || poe != 0 || report == "" {
|
||||
t.Fatalf("stored: status=%s poe=%d report=%q", status, poe, report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationUnreadableStoreNotAssessedReportSaved(t *testing.T) {
|
||||
s, f, _ := verificationFixture(t, "proof")
|
||||
// Remove the blobs behind the bound snapshot: the evidence store cannot open the body.
|
||||
if err := os.RemoveAll(filepath.Join(s.m.dir, "evidence")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg, isErr := updateReport(t, s, f.NodeID, `,"poe_level":3,"evidence_quotes":["user admin: x7"]`)
|
||||
if isErr || !strings.Contains(msg, "verification: not_assessed") {
|
||||
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
|
||||
}
|
||||
status, _, note, report := storedVerification(t, s, f.FindingID)
|
||||
if status != db.VerificationNotAssessed || !strings.HasPrefix(note, "evidence body unreadable") || report == "" {
|
||||
t.Fatalf("stored: status=%s note=%q report=%q", status, note, report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRejectsOutOfRangePOE(t *testing.T) {
|
||||
s, f, _ := verificationFixture(t, "proof")
|
||||
msg, isErr := updateReport(t, s, f.NodeID, `,"poe_level":9`)
|
||||
if !isErr || !strings.Contains(msg, "invalid poe_level 9") {
|
||||
t.Fatalf("unexpected result: err=%v %q", isErr, msg)
|
||||
}
|
||||
if _, _, _, report := storedVerification(t, s, f.FindingID); report != "" {
|
||||
t.Fatal("report must not be written when the input is rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationColumnsExistWithHonestDefaults(t *testing.T) {
|
||||
s, f, _ := trafficEvidenceServer(t)
|
||||
var n int
|
||||
if err := s.m.pg.QueryRow(`SELECT count(*) FROM information_schema.columns
|
||||
WHERE table_name='findings' AND column_name IN ('poe_level','verification_status','verification_note')`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Fatalf("expected 3 verification columns, found %d", n)
|
||||
}
|
||||
status, poe, note, _ := storedVerification(t, s, f.FindingID)
|
||||
if status != db.VerificationUnverified || poe != 0 || note != "" {
|
||||
t.Fatalf("new row: status=%s poe=%d note=%q", status, poe, note)
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,10 @@ func findingPaginationParam(raw string, fallback, upperBound int) int {
|
||||
// query string. The list / grouping / asset tree / export all share one parsing path; new filter options only change here.
|
||||
func findingFilterFromQuery(q url.Values) db.FindingFilter {
|
||||
return db.FindingFilter{
|
||||
Severity: normFilter(q.Get("severity")),
|
||||
Status: normFilter(q.Get("status")),
|
||||
VulnClass: normFilter(q.Get("vulnclass")),
|
||||
Severity: normFilter(q.Get("severity")),
|
||||
Status: normFilter(q.Get("status")),
|
||||
VerificationStatus: normFilter(q.Get("verification_status")),
|
||||
VulnClass: normFilter(q.Get("vulnclass")),
|
||||
// task_id (independent of the task param, which switches to the per-task-node branch): filters the global table by task.
|
||||
TaskID: normFilter(q.Get("task_id")),
|
||||
Query: q.Get("q"),
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
)
|
||||
|
||||
func TestInterceptFilterHTTP(t *testing.T) {
|
||||
t.Skip("known failure with a fresh database, pre-existing on unmodified upstream; tracked in BaaRai/ARTEX#9")
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Skip("no test database configured")
|
||||
|
||||
@@ -81,6 +81,44 @@ func TestLLMTestGlobalKeyOnlyForGlobalEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The connection test applies the same User-Agent rule as saving a profile: a
|
||||
// value with a control character is a 400 before any request leaves, and a
|
||||
// printable value reaches the wire exactly as given.
|
||||
func TestLLMTestValidatesUserAgent(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
var gotUA atomic.Value
|
||||
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
gotUA.Store(r.Header.Get("User-Agent"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"m","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"OK"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`))
|
||||
}))
|
||||
t.Cleanup(fake.Close)
|
||||
s := &Server{}
|
||||
post := func(ua string) *httptest.ResponseRecorder {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"provider": "anthropic", "model": "m", "base_url": fake.URL, "api_key": "k", "streaming": false, "user_agent": ua,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/llm/test", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
s.testLLM(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
if rec := post("good/1.0\r\nX-Injected: 1"); rec.Code != http.StatusBadRequest || hits.Load() != 0 {
|
||||
t.Fatalf("control character in user_agent: status %d hits=%d body=%s", rec.Code, hits.Load(), rec.Body.String())
|
||||
}
|
||||
|
||||
const want = "MyTool/1.0 (contact-free)"
|
||||
if rec := post(" " + want + " "); rec.Code != http.StatusOK {
|
||||
t.Fatalf("printable user_agent: status %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ua, _ := gotUA.Load().(string); hits.Load() != 1 || ua != want {
|
||||
t.Fatalf("test request User-Agent = %q (hits=%d), want %q", ua, hits.Load(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// A stored profile's key goes only to that profile's base URL, for both the
|
||||
// connection test and the model listing.
|
||||
func TestStoredProfileKeyOnlyForStoredEndpoint(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Login attempts are limited per client IP, never per account: a per-account
|
||||
// lockout would let anyone lock the admin out by failing on purpose.
|
||||
const (
|
||||
loginMaxAttempts = 10 // attempts allowed per IP per window
|
||||
loginWindow = time.Minute // the counting window
|
||||
loginBaseBlock = time.Minute // length of the first block
|
||||
loginMaxBlock = 15 * time.Minute // cap on the block; an entry idle this long is dropped
|
||||
loginSweepEvery = time.Minute // how often stale entries are swept
|
||||
loginMaxEntries = 10000 // hard cap on tracked IPs
|
||||
)
|
||||
|
||||
type loginEntry struct {
|
||||
attempts int
|
||||
windowStart time.Time
|
||||
blockedUntil time.Time
|
||||
block time.Duration // current block length; doubles on every new block or blocked attempt
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// loginLimiter counts login attempts per IP. Past loginMaxAttempts in one
|
||||
// loginWindow the IP is blocked for loginBaseBlock; every attempt while blocked,
|
||||
// and every later block, doubles the block up to loginMaxBlock. State is in
|
||||
// memory only and swept of idle entries, so a restart clears it.
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
entries map[string]*loginEntry
|
||||
lastSweep time.Time
|
||||
}
|
||||
|
||||
func newLoginLimiter(now func() time.Time) *loginLimiter {
|
||||
return &loginLimiter{now: now, entries: map[string]*loginEntry{}}
|
||||
}
|
||||
|
||||
// allow records one attempt from ip. It returns 0 when the attempt may go on,
|
||||
// otherwise how long ip must wait.
|
||||
func (l *loginLimiter) allow(ip string) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
l.sweep(now)
|
||||
e := l.entries[ip]
|
||||
if e == nil {
|
||||
if len(l.entries) >= loginMaxEntries {
|
||||
l.evictOldest()
|
||||
}
|
||||
e = &loginEntry{windowStart: now}
|
||||
l.entries[ip] = e
|
||||
}
|
||||
e.lastSeen = now
|
||||
if now.Before(e.blockedUntil) {
|
||||
e.block = min(2*e.block, loginMaxBlock)
|
||||
e.blockedUntil = now.Add(e.block)
|
||||
return e.block
|
||||
}
|
||||
if now.Sub(e.windowStart) >= loginWindow {
|
||||
e.attempts, e.windowStart = 0, now
|
||||
}
|
||||
if e.attempts >= loginMaxAttempts {
|
||||
if e.block == 0 {
|
||||
e.block = loginBaseBlock
|
||||
} else {
|
||||
e.block = min(2*e.block, loginMaxBlock)
|
||||
}
|
||||
e.blockedUntil = now.Add(e.block)
|
||||
e.attempts, e.windowStart = 0, now
|
||||
return e.block
|
||||
}
|
||||
e.attempts++
|
||||
return 0
|
||||
}
|
||||
|
||||
// reset forgets ip, after a successful login. A blocked IP never reaches the
|
||||
// password check, so this only runs outside a block.
|
||||
func (l *loginLimiter) reset(ip string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.entries, ip)
|
||||
}
|
||||
|
||||
// sweep drops entries idle for loginMaxBlock, at most once per loginSweepEvery.
|
||||
// Such an entry is never blocked: a block ends at most loginMaxBlock after the
|
||||
// attempt that set it.
|
||||
func (l *loginLimiter) sweep(now time.Time) {
|
||||
if now.Sub(l.lastSweep) < loginSweepEvery {
|
||||
return
|
||||
}
|
||||
l.lastSweep = now
|
||||
for ip, e := range l.entries {
|
||||
if now.Sub(e.lastSeen) >= loginMaxBlock {
|
||||
delete(l.entries, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evictOldest drops the least recently seen entry to stay under loginMaxEntries.
|
||||
func (l *loginLimiter) evictOldest() {
|
||||
var oldest string
|
||||
var at time.Time
|
||||
for ip, e := range l.entries {
|
||||
if oldest == "" || e.lastSeen.Before(at) {
|
||||
oldest, at = ip, e.lastSeen
|
||||
}
|
||||
}
|
||||
delete(l.entries, oldest)
|
||||
}
|
||||
|
||||
// peerIP is the host part of the TCP peer address.
|
||||
func peerIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// clientIP is the address login attempts are counted against. It is the TCP
|
||||
// peer unless the peer is in trusted: then X-Forwarded-For is read from the
|
||||
// right, past trusted hops, and the first untrusted hop is the client. Entries
|
||||
// left of that hop come from the client itself and are never used, so a client
|
||||
// cannot pick its own bucket. With trusted empty the header is ignored.
|
||||
func clientIP(r *http.Request, trusted []netip.Prefix) string {
|
||||
peer := peerIP(r)
|
||||
addr, err := netip.ParseAddr(peer)
|
||||
if err != nil || !isTrusted(addr, trusted) {
|
||||
return peer
|
||||
}
|
||||
hops := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",")
|
||||
for i := len(hops) - 1; i >= 0; i-- {
|
||||
hop, err := netip.ParseAddr(strings.TrimSpace(hops[i]))
|
||||
if err != nil {
|
||||
break // keep the last address a trusted proxy reported
|
||||
}
|
||||
addr = hop
|
||||
if !isTrusted(addr, trusted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return addr.Unmap().WithZone("").String()
|
||||
}
|
||||
|
||||
func isTrusted(a netip.Addr, trusted []netip.Prefix) bool {
|
||||
a = a.Unmap().WithZone("")
|
||||
for _, p := range trusted {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseTrustedProxies parses ARTEX_TRUSTED_PROXIES: comma-separated CIDRs (a
|
||||
// bare address means that one host). Empty means no trusted proxy.
|
||||
func ParseTrustedProxies(s string) ([]netip.Prefix, error) {
|
||||
var out []netip.Prefix
|
||||
for _, f := range strings.Split(s, ",") {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if p, err := netip.ParsePrefix(f); err == nil {
|
||||
out = append(out, p.Masked())
|
||||
continue
|
||||
}
|
||||
a, err := netip.ParseAddr(f)
|
||||
if err != nil || a.Zone() != "" {
|
||||
return nil, fmt.Errorf("%q is not a CIDR or an IP address", f)
|
||||
}
|
||||
a = a.Unmap()
|
||||
out = append(out, netip.PrefixFrom(a, a.BitLen()))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WithTrustedProxies sets the proxies whose X-Forwarded-For the login rate
|
||||
// limit reads (see clientIP).
|
||||
func WithTrustedProxies(p []netip.Prefix) Option {
|
||||
return func(s *Server) { s.trustedProxies = p }
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type fakeClock struct{ t time.Time }
|
||||
|
||||
func (c *fakeClock) now() time.Time { return c.t }
|
||||
func (c *fakeClock) advance(d time.Duration) { c.t = c.t.Add(d) }
|
||||
|
||||
func newFakeClock() *fakeClock { return &fakeClock{t: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} }
|
||||
|
||||
// oldPassword is 8 characters: valid under the old minimum, too short for a new password now.
|
||||
const oldPassword = "abcd1234"
|
||||
|
||||
// loginServer returns a Server whose login reads a stored hash of oldPassword
|
||||
// from a stub instead of PostgreSQL.
|
||||
func loginServer(t *testing.T, clk *fakeClock) (*Server, func(remote, password string, hdr map[string]string) *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(oldPassword), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
getSetting := func(key string) (string, bool, error) {
|
||||
if key != authPassKey {
|
||||
return "", false, nil
|
||||
}
|
||||
return string(hash), true, nil
|
||||
}
|
||||
s := &Server{jwtKey: []byte("0123456789abcdef0123456789abcdef"), loginLimit: newLoginLimiter(clk.now)}
|
||||
post := func(remote, password string, hdr map[string]string) *httptest.ResponseRecorder {
|
||||
body := fmt.Sprintf(`{"username":"ARTEX","password":%q}`, password)
|
||||
r := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||
r.RemoteAddr = remote
|
||||
for k, v := range hdr {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.login(w, r, getSetting)
|
||||
return w
|
||||
}
|
||||
return s, post
|
||||
}
|
||||
|
||||
func failN(t *testing.T, post func(string, string, map[string]string) *httptest.ResponseRecorder, remote string, n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
if w := post(remote, "wrong-password", nil); w.Code != 401 {
|
||||
t.Fatalf("attempt %d from %s: status=%d want 401; body=%s", i+1, remote, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitReturns429WithRetryAfter(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
_, post := loginServer(t, clk)
|
||||
const ip = "192.0.2.10:40000"
|
||||
failN(t, post, ip, loginMaxAttempts)
|
||||
|
||||
w := post(ip, oldPassword, nil) // even the right password is refused while blocked
|
||||
if w.Code != 429 {
|
||||
t.Fatalf("status=%d want 429; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Header().Get("Retry-After"); got != "60" {
|
||||
t.Fatalf("Retry-After=%q want 60", got)
|
||||
}
|
||||
var body struct {
|
||||
Error string `json:"error"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body.Error == "" || body.Token != "" {
|
||||
t.Fatalf("want a JSON error body without a token, got %s (err=%v)", w.Body.String(), err)
|
||||
}
|
||||
|
||||
// Each attempt while blocked doubles the wait, capped at loginMaxBlock.
|
||||
for _, want := range []string{"120", "240", "480", "900", "900"} {
|
||||
if got := post(ip, "wrong-password", nil).Header().Get("Retry-After"); got != want {
|
||||
t.Fatalf("Retry-After=%q want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Once the block runs out the IP gets a fresh window.
|
||||
clk.advance(loginMaxBlock)
|
||||
if w := post(ip, oldPassword, nil); w.Code != 200 {
|
||||
t.Fatalf("after the block: status=%d want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginSuccessResetsCounter(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
_, post := loginServer(t, clk)
|
||||
const ip = "192.0.2.11:40000"
|
||||
failN(t, post, ip, loginMaxAttempts-1)
|
||||
if w := post(ip, oldPassword, nil); w.Code != 200 {
|
||||
t.Fatalf("status=%d want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// The counter starts over: a full budget of failures before the next block.
|
||||
failN(t, post, ip, loginMaxAttempts)
|
||||
if w := post(ip, "wrong-password", nil); w.Code != 429 {
|
||||
t.Fatalf("status=%d want 429", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitIsPerIP(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
_, post := loginServer(t, clk)
|
||||
failN(t, post, "192.0.2.12:40000", loginMaxAttempts)
|
||||
if w := post("192.0.2.12:40001", "wrong-password", nil); w.Code != 429 {
|
||||
t.Fatalf("same IP, other port: status=%d want 429", w.Code)
|
||||
}
|
||||
if w := post("198.51.100.7:40000", "wrong-password", nil); w.Code != 401 {
|
||||
t.Fatalf("other IP: status=%d want 401", w.Code)
|
||||
}
|
||||
if w := post("[2001:db8::1]:40000", oldPassword, nil); w.Code != 200 {
|
||||
t.Fatalf("other IP: status=%d want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitIgnoresForwardedFor(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
_, post := loginServer(t, clk)
|
||||
const blocked = "192.0.2.13"
|
||||
failN(t, post, blocked+":40000", loginMaxAttempts)
|
||||
|
||||
// A blocked peer cannot escape by claiming another address.
|
||||
for _, h := range []string{"X-Forwarded-For", "X-Real-IP"} {
|
||||
if w := post(blocked+":40000", oldPassword, map[string]string{h: "203.0.113.9"}); w.Code != 429 {
|
||||
t.Fatalf("%s from blocked peer: status=%d want 429", h, w.Code)
|
||||
}
|
||||
}
|
||||
// And another peer naming the blocked address is not blocked.
|
||||
if w := post("198.51.100.8:40000", oldPassword, map[string]string{"X-Forwarded-For": blocked}); w.Code != 200 {
|
||||
t.Fatalf("X-Forwarded-For naming the blocked IP: status=%d want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func mustTrusted(t *testing.T, s string) []netip.Prefix {
|
||||
t.Helper()
|
||||
p, err := ParseTrustedProxies(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestParseTrustedProxies(t *testing.T) {
|
||||
if p, err := ParseTrustedProxies(" "); err != nil || len(p) != 0 {
|
||||
t.Fatalf("empty: %v, %v; want no proxies", p, err)
|
||||
}
|
||||
p, err := ParseTrustedProxies(" 127.0.0.1/32, 10.1.2.3/8 ,::1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fmt.Sprint(p); got != "[127.0.0.1/32 10.0.0.0/8 ::1/128]" {
|
||||
t.Fatalf("got %s", got)
|
||||
}
|
||||
for _, bad := range []string{"proxy.local", "10.0.0.0/33", "127.0.0.1,nope"} {
|
||||
if _, err := ParseTrustedProxies(bad); err == nil {
|
||||
t.Fatalf("%q: want an error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
trusted := mustTrusted(t, "127.0.0.1/32,10.0.0.0/8")
|
||||
for _, tc := range []struct {
|
||||
name, remote string
|
||||
xff []string
|
||||
trusted []netip.Prefix
|
||||
want string
|
||||
}{
|
||||
{"direct client, no proxies configured", "198.51.100.9:5000", nil, nil, "198.51.100.9"},
|
||||
{"direct client, proxies configured", "198.51.100.9:5000", nil, trusted, "198.51.100.9"},
|
||||
{"spoofed header, no proxies configured", "198.51.100.9:5000", []string{"203.0.113.1"}, nil, "198.51.100.9"},
|
||||
{"spoofed header from an untrusted peer", "198.51.100.9:5000", []string{"203.0.113.1"}, trusted, "198.51.100.9"},
|
||||
{"spoofed header claiming a trusted address", "198.51.100.9:5000", []string{"127.0.0.1"}, trusted, "198.51.100.9"},
|
||||
{"one trusted proxy", "127.0.0.1:5000", []string{"198.51.100.7"}, trusted, "198.51.100.7"},
|
||||
{"client-supplied entries left of the real hop", "127.0.0.1:5000", []string{"203.0.113.1, 10.0.0.9, 198.51.100.7"}, trusted, "198.51.100.7"},
|
||||
{"chain of trusted proxies", "127.0.0.1:5000", []string{"203.0.113.1, 198.51.100.7, 10.0.0.5"}, trusted, "198.51.100.7"},
|
||||
{"chain split over header lines", "127.0.0.1:5000", []string{"203.0.113.1", "198.51.100.7, 10.0.0.5"}, trusted, "198.51.100.7"},
|
||||
{"trusted proxy without the header", "127.0.0.1:5000", nil, trusted, "127.0.0.1"},
|
||||
{"unparsable hop stops the walk", "127.0.0.1:5000", []string{"198.51.100.7, garbage, 10.0.0.5"}, trusted, "10.0.0.5"},
|
||||
{"IPv6 client behind the proxy", "127.0.0.1:5000", []string{"2001:db8::7"}, trusted, "2001:db8::7"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.RemoteAddr = tc.remote
|
||||
for _, v := range tc.xff {
|
||||
r.Header.Add("X-Forwarded-For", v)
|
||||
}
|
||||
if got := clientIP(r, tc.trusted); got != tc.want {
|
||||
t.Fatalf("clientIP=%s want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Behind a trusted reverse proxy every request arrives from the proxy's address;
|
||||
// the limit must still apply per real client.
|
||||
func TestLoginRateLimitBehindTrustedProxy(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
s, post := loginServer(t, clk)
|
||||
s.trustedProxies = mustTrusted(t, "127.0.0.1/32")
|
||||
const proxy = "127.0.0.1:50000"
|
||||
via := func(xff string) map[string]string { return map[string]string{"X-Forwarded-For": xff} }
|
||||
|
||||
for i := 0; i < loginMaxAttempts; i++ {
|
||||
if w := post(proxy, "wrong-password", via("198.51.100.20")); w.Code != 401 {
|
||||
t.Fatalf("attempt %d: status=%d want 401", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
if w := post(proxy, oldPassword, via("198.51.100.20")); w.Code != 429 {
|
||||
t.Fatalf("attacker: status=%d want 429", w.Code)
|
||||
}
|
||||
// What nginx's $proxy_add_x_forwarded_for sends when the attacker adds its own header.
|
||||
if w := post(proxy, oldPassword, via("203.0.113.50, 198.51.100.20")); w.Code != 429 {
|
||||
t.Fatalf("attacker with a forged entry: status=%d want 429", w.Code)
|
||||
}
|
||||
// Another user behind the same proxy is not affected.
|
||||
if w := post(proxy, oldPassword, via("198.51.100.21")); w.Code != 200 {
|
||||
t.Fatalf("other user via the proxy: status=%d want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The 12-character minimum applies to new passwords only. A hash stored under the
|
||||
// old 8-character minimum must still log in.
|
||||
func TestEightCharPasswordLogsInButIsRejectedAsNew(t *testing.T) {
|
||||
if n := len([]rune(oldPassword)); n != 8 {
|
||||
t.Fatalf("oldPassword has %d characters, want 8", n)
|
||||
}
|
||||
_, post := loginServer(t, newFakeClock())
|
||||
w := post("192.0.2.14:40000", oldPassword, nil)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"token"`) {
|
||||
t.Fatalf("login with an existing 8-character password: status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// authInit and authChangePassword both refuse a new password through validatePassword.
|
||||
if msg := validatePassword(oldPassword); msg == "" {
|
||||
t.Fatal("validatePassword accepted an 8-character new password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterSweepsIdleEntries(t *testing.T) {
|
||||
clk := newFakeClock()
|
||||
l := newLoginLimiter(clk.now)
|
||||
l.allow("192.0.2.20")
|
||||
clk.advance(loginMaxBlock - time.Second)
|
||||
l.allow("192.0.2.21") // sweeps, but .20 is not idle long enough yet
|
||||
if len(l.entries) != 2 {
|
||||
t.Fatalf("entries=%d want 2 before either is idle for %v", len(l.entries), loginMaxBlock)
|
||||
}
|
||||
clk.advance(loginSweepEvery)
|
||||
l.allow("192.0.2.22") // next sweep drops .20
|
||||
if _, ok := l.entries["192.0.2.20"]; ok || len(l.entries) != 2 {
|
||||
t.Fatalf("idle entry not swept: %d entries", len(l.entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterCapsEntries(t *testing.T) {
|
||||
l := newLoginLimiter(newFakeClock().now)
|
||||
for i := 0; i <= loginMaxEntries; i++ {
|
||||
l.allow(fmt.Sprintf("10.%d.%d.%d", i>>16&255, i>>8&255, i&255))
|
||||
}
|
||||
if len(l.entries) != loginMaxEntries {
|
||||
t.Fatalf("entries=%d want the cap %d", len(l.entries), loginMaxEntries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthHidesVersionWithoutToken(t *testing.T) {
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
s := &Server{jwtKey: key}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
s.health(w, httptest.NewRequest("GET", "/api/health", nil))
|
||||
if got := strings.TrimSpace(w.Body.String()); w.Code != 200 || got != `{"ok":true}` {
|
||||
t.Fatalf("no token: status=%d body=%s want {\"ok\":true}", w.Code, got)
|
||||
}
|
||||
|
||||
tok, err := signJWT(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest("GET", "/api/health", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+tok)
|
||||
w = httptest.NewRecorder()
|
||||
s.health(w, r)
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body["version"] != BuildVersion || body["service"] != "artex" {
|
||||
t.Fatalf("valid token: body=%s want service and version", w.Body.String())
|
||||
}
|
||||
}
|
||||
+36
-2
@@ -2,7 +2,9 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
@@ -430,26 +432,57 @@ func (s *Server) toolUpdateFindingReport() actool.CoreTool {
|
||||
"finding_id": map[string]any{"type": "integer", "description": "target vulnerability id (the id returned by report_finding)"},
|
||||
"report": strParam("full report text, Markdown format"),
|
||||
"evidence_version": map[string]any{"type": "integer", "description": "evidence version returned by get_finding_traffic; prevents the report from overwriting newer evidence changes"},
|
||||
"poe_level": map[string]any{"type": "integer", "description": "proof-of-exploitation level 0-4: 1 error/timing/response variation, 2 structural manipulation, 3 actual data extracted, 4 critical impact. L3 is the minimum for verified. Omit or 0 when not proven."},
|
||||
"evidence_quotes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "exact lines copied from a bound proof/verification response body (get_finding_traffic). Never from memory; the server checks each one verbatim after whitespace normalization."},
|
||||
}, "finding_id", "report"),
|
||||
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a struct {
|
||||
EvidenceVersion *int64 `json:"evidence_version"`
|
||||
FindingID json.RawMessage `json:"finding_id"`
|
||||
Report string `json:"report"`
|
||||
PoELevel *int `json:"poe_level"`
|
||||
EvidenceQuotes []string `json:"evidence_quotes"`
|
||||
}
|
||||
_ = json.Unmarshal(in, &a)
|
||||
nodeID := parseProfileID(a.FindingID) // reuses the "number or numeric string" parser
|
||||
if nodeID <= 0 {
|
||||
return actool.Errorf("invalid finding_id"), nil
|
||||
}
|
||||
poe := 0
|
||||
if a.PoELevel != nil {
|
||||
poe = *a.PoELevel
|
||||
}
|
||||
if poe < 0 || poe > 4 {
|
||||
return actool.Errorf(fmt.Sprintf("invalid poe_level %d (want 0-4)", poe)), nil
|
||||
}
|
||||
notFound := actool.Errorf(fmt.Sprintf("no vulnerability record found for finding_id=%d (register it with report_finding first)", nodeID))
|
||||
findingID, err := s.m.pg.FindingIDByNodeID(nodeID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return notFound, nil
|
||||
}
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
n, err := s.m.pg.SetFindingReportVersionByNodeID(ctx, nodeID, a.Report, a.EvidenceVersion)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
if n == 0 {
|
||||
return actool.Errorf(fmt.Sprintf("no vulnerability record found for finding_id=%d (register it with report_finding first)", nodeID)), nil
|
||||
return notFound, nil
|
||||
}
|
||||
return actool.Text(fmt.Sprintf("finding %d report updated (%d chars)", nodeID, len(a.Report))), nil
|
||||
// The report is saved above; verification only decides the status. A
|
||||
// downgrade is recorded and reported, never used to drop the report.
|
||||
status, note := s.assessFindingVerification(ctx, findingID, poe, a.EvidenceQuotes)
|
||||
if err := s.m.pg.SetFindingVerification(findingID, status, poe, note); err != nil {
|
||||
return actool.Errorf("report saved, but verification not recorded: " + err.Error()), nil
|
||||
}
|
||||
msg := fmt.Sprintf("finding %d report updated (%d chars)\nverification: %s", nodeID, len(a.Report), status)
|
||||
if status == db.VerificationVerified {
|
||||
msg += fmt.Sprintf(" (POE L%d)", poe)
|
||||
} else {
|
||||
msg += " - " + note + "\nwarning: the report is saved but the finding is not verified. To reach verified, declare poe_level 3 or 4 and pass exact quotes copied from a bound proof/verification response body."
|
||||
}
|
||||
return actool.Text(msg), nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -513,6 +546,7 @@ func (s *Server) seedOrchestrationTools() {
|
||||
s.upgradeReporterTriggerMessage() // backfill migration for old DBs: makes the reporter send evidence_version back (one-time)
|
||||
s.seedFindingTrafficTools() // adds optional evidence params and read-only evidence tools, preserving user config
|
||||
s.seedFindingWorkflowTools()
|
||||
s.seedFindingVerificationSchema() // S1: poe_level / evidence_quotes on update_finding_report, added once per DB
|
||||
// Note: pentest's default tool bindings need no migration — BuiltinToolSeeds already seeds
|
||||
// list_assets/insert_assets/report_finding/list_findings/list_companies together with
|
||||
// pentest at fresh initialization (no old DBs exist in this project, so no migration).
|
||||
|
||||
+33
-2
@@ -9,6 +9,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -51,6 +52,9 @@ type Server struct {
|
||||
setupMu sync.Mutex
|
||||
setupToken string // one-time token POST /api/auth/init requires; empty when none is active
|
||||
|
||||
loginLimit *loginLimiter // per-IP attempt limit on POST /api/auth/login
|
||||
trustedProxies []netip.Prefix // ARTEX_TRUSTED_PROXIES; see clientIP
|
||||
|
||||
// concMu serializes concurrency-cap decisions (admission + reconcile) so a
|
||||
// scheduler tick and an HTTP settings change / task creation can't both count
|
||||
// free slots off the same snapshot and over-promote past the limit.
|
||||
@@ -155,7 +159,8 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
|
||||
profChatAgents: map[int64]*agent.ChatAgent{},
|
||||
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
|
||||
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)}
|
||||
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1),
|
||||
loginLimit: newLoginLimiter(time.Now)}
|
||||
for _, o := range opts {
|
||||
o(s)
|
||||
}
|
||||
@@ -350,6 +355,7 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
|
||||
cfg.Stream = p.Streaming
|
||||
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
|
||||
cfg.SessionHeaderKey = p.SessionHeaderKey
|
||||
cfg.UserAgent = p.UserAgent
|
||||
s.applyProfileRetry(&cfg, p)
|
||||
// Ollama profiles run keyless (local server); the other formats need a key.
|
||||
if cfg.APIKey == "" && cfg.NeedsAPIKey() {
|
||||
@@ -376,6 +382,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
|
||||
var maxTokens int
|
||||
var maxTokensField string
|
||||
var sessionHeaderKey string
|
||||
var userAgent string
|
||||
var retry db.RetryOverride
|
||||
if profs, _ := s.m.pg.ListProfiles(); profs != nil {
|
||||
for _, p := range profs {
|
||||
@@ -383,6 +390,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
|
||||
id, priority, poolExclude, streaming = p.ID, p.Priority, p.PoolExclude, p.Streaming
|
||||
maxTokens, maxTokensField = p.MaxTokens, p.MaxTokensField
|
||||
sessionHeaderKey = p.SessionHeaderKey
|
||||
userAgent = p.UserAgent
|
||||
retry = p.Retry
|
||||
break
|
||||
}
|
||||
@@ -393,7 +401,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
|
||||
APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute,
|
||||
ContextWindowK: cfg.ContextWindowK, ThinkingType: cfg.ThinkingType, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
|
||||
Priority: priority, PoolExclude: poolExclude, Streaming: streaming,
|
||||
MaxTokens: maxTokens, MaxTokensField: maxTokensField, SessionHeaderKey: sessionHeaderKey,
|
||||
MaxTokens: maxTokens, MaxTokensField: maxTokensField, SessionHeaderKey: sessionHeaderKey, UserAgent: userAgent,
|
||||
Retry: retry,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -523,6 +531,7 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
|
||||
cfg.Stream = p.Streaming
|
||||
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
|
||||
cfg.SessionHeaderKey = p.SessionHeaderKey
|
||||
cfg.UserAgent = p.UserAgent
|
||||
s.applyProfileRetry(&cfg, p)
|
||||
if cfg.APIKey == "" && cfg.NeedsAPIKey() {
|
||||
return cfg, false
|
||||
@@ -964,7 +973,14 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
// health answers without a token (requireAuth exempts it), but only a caller
|
||||
// with a valid token gets the service name and version; the web UI's top bar
|
||||
// reads the version, and sends its token.
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
if !verifyJWT(extractToken(r), s.jwtKey) {
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "service": "artex", "version": BuildVersion})
|
||||
}
|
||||
|
||||
@@ -1406,6 +1422,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
ProfileID *int64 `json:"profile_id"` // passed when testing a stored profile: if api_key is empty, its stored key is used
|
||||
Streaming *bool `json:"streaming"` // omitted = streaming, the same defaults as when saving a profile
|
||||
SessionHeaderKey string `json:"session_header_key"` // non-empty = the test also sends this custom session header, with a one-off random session id as the value
|
||||
UserAgent string `json:"user_agent"` // non-empty = the test overrides the User-Agent header with this value (mirrors production)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
@@ -1465,6 +1482,20 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
if stored != nil && cfg.SessionHeaderKey == "" {
|
||||
cfg.SessionHeaderKey = strings.TrimSpace(stored.SessionHeaderKey)
|
||||
}
|
||||
// The custom User-Agent follows the profile too, so the test request uses the
|
||||
// exact UA a real conversation would; the form value wins, else the stored one.
|
||||
cfg.UserAgent = strings.TrimSpace(req.UserAgent)
|
||||
if stored != nil && cfg.UserAgent == "" {
|
||||
cfg.UserAgent = strings.TrimSpace(stored.UserAgent)
|
||||
}
|
||||
// Same rule as saving a profile: a value with control characters or over the
|
||||
// length cap never reaches the wire, not even from the test button.
|
||||
ua, err := agent.ValidateUserAgent(cfg.UserAgent)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
cfg.UserAgent = ua
|
||||
// Keyless formats (Ollama) skip the key resolution entirely.
|
||||
if cfg.NeedsAPIKey() {
|
||||
// API key resolution precedence: form input > the named profile's stored key > the global config's key.
|
||||
|
||||
@@ -1882,6 +1882,14 @@ func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if p.Format != "openai" || p.MaxTokensField != llm.MaxTokensFieldCompletion {
|
||||
p.MaxTokensField = ""
|
||||
}
|
||||
// Custom User-Agent: trim and reject control characters / over-long values so a
|
||||
// bad value can never inject a header or bloat every request. "" = no override.
|
||||
ua, err := agent.ValidateUserAgent(p.UserAgent)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
p.UserAgent = ua
|
||||
id, err := pg.SaveProfile(&p)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
|
||||
@@ -41,7 +41,7 @@ type sideQuestionState struct {
|
||||
}
|
||||
|
||||
func sideModel(cfg agent.Config, id int64, name string) sidequestion.Model {
|
||||
b, _ := json.Marshal([]any{cfg.Format, cfg.BaseURL, cfg.Model, cfg.ThinkingType, cfg.ReasoningEffort, cfg.Stream, cfg.MaxTokensField, cfg.SessionHeaderKey})
|
||||
b, _ := json.Marshal([]any{cfg.Format, cfg.BaseURL, cfg.Model, cfg.ThinkingType, cfg.ReasoningEffort, cfg.Stream, cfg.MaxTokensField, cfg.SessionHeaderKey, cfg.UserAgent})
|
||||
h := sha256.Sum256(b)
|
||||
return sidequestion.Model{ProfileID: id, Name: name, Format: string(cfg.Format), Model: cfg.Model, Identity: hex.EncodeToString(h[:]), Streaming: cfg.Stream, WindowTokens: cfg.CompactionWindow()}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ export default function SetupPage() {
|
||||
setError("The two passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
if (password.length < 12) {
|
||||
setError("Password must be at least 12 characters");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -81,7 +81,7 @@ export default function SetupPage() {
|
||||
<p className="mx-auto max-w-xl text-muted-foreground">
|
||||
{unavailable
|
||||
? "The backend or database is temporarily unavailable. To avoid overwriting a password already set on this instance, setup is temporarily closed — restore the service and try again."
|
||||
: "First time using ARTEX? Enter the setup token from the server log, then set a login password for the account (at least 8 characters)"}
|
||||
: "First time using ARTEX? Enter the setup token from the server log, then set a login password for the account (at least 12 characters)"}
|
||||
</p>
|
||||
</div>
|
||||
{unavailable ? (
|
||||
@@ -111,7 +111,7 @@ export default function SetupPage() {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
placeholder="At least 12 characters"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -36,8 +36,8 @@ import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
import type { ActiveFindingRetest, Finding, FindingStatus, Severity } from "@/lib/types";
|
||||
import { statusMeta, type Tone, toneClasses } from "@/lib/status";
|
||||
import type { ActiveFindingRetest, Finding, FindingStatus, Severity, VerificationStatus } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
|
||||
@@ -54,6 +54,45 @@ export const FINDING_STATUSES: FindingStatus[] = [
|
||||
"risk_accepted",
|
||||
];
|
||||
|
||||
export const VERIFICATION_STATUSES: VerificationStatus[] = [
|
||||
"verified",
|
||||
"unverified",
|
||||
"blocked",
|
||||
"not_assessed",
|
||||
"false_positive",
|
||||
];
|
||||
|
||||
// Verification outcome → label/tone, on the same tone tokens StatusBadge uses.
|
||||
export const VERIFICATION_META: Record<VerificationStatus, { label: string; tone: Tone }> = {
|
||||
verified: { label: "Verified", tone: "green" },
|
||||
unverified: { label: "Unverified", tone: "slate" },
|
||||
blocked: { label: "Blocked", tone: "amber" },
|
||||
not_assessed: { label: "Not assessed", tone: "red" },
|
||||
false_positive: { label: "False positive", tone: "neutral" },
|
||||
};
|
||||
|
||||
// VerificationBadge shows the automated verification outcome ("Verified L3" when a
|
||||
// proof-of-exploitation level is declared). It renders nothing when the finding carries
|
||||
// no verification status (in-task nodes, payloads from before the column existed).
|
||||
export function VerificationBadge({ finding }: { finding: Pick<Finding, "verification_status" | "poe_level"> }) {
|
||||
const meta = finding.verification_status ? VERIFICATION_META[finding.verification_status] : undefined;
|
||||
if (!meta) return null;
|
||||
const label =
|
||||
finding.verification_status === "verified" && finding.poe_level
|
||||
? `${meta.label} L${finding.poe_level}`
|
||||
: meta.label;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex w-fit items-center whitespace-nowrap rounded-md border px-2 py-0.5 font-medium text-xs",
|
||||
toneClasses[meta.tone],
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const UNASSIGNED_TASK = "__unassigned__";
|
||||
|
||||
// Inline edit buffer: the expanded row's name/category/severity.
|
||||
@@ -213,7 +252,10 @@ export function FindingsTable({
|
||||
<span className="truncate font-medium">{f.name || f.vulnclass || "Uncategorized"}</span>
|
||||
)}
|
||||
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
|
||||
<Badge variant="outline">Traffic evidence: {f.traffic_count ?? 0}</Badge>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Badge variant="outline">Traffic evidence: {f.traffic_count ?? 0}</Badge>
|
||||
<VerificationBadge finding={f} />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { api } from "@/lib/api";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
import type { Finding, FindingStatus, Severity } from "@/lib/types";
|
||||
|
||||
import { VerificationBadge } from "../_components/findings-table";
|
||||
import { FindingLineageView } from "./lineage";
|
||||
|
||||
const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
|
||||
@@ -147,6 +148,7 @@ function FindingDetailInner() {
|
||||
<Separator orientation="vertical" className="mx-1 h-4" />
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
<VerificationBadge finding={finding} />
|
||||
{finding.inherited && finding.source_task_id && (
|
||||
<Badge variant="outline">Source task #{finding.source_task_id} · read-only</Badge>
|
||||
)}
|
||||
@@ -272,6 +274,18 @@ function FindingDetailInner() {
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* Verification: automated outcome; the note explains any state other than verified */}
|
||||
{finding.verification_status && (
|
||||
<FieldRow label="Verification">
|
||||
<VerificationBadge finding={finding} />
|
||||
{finding.verification_status !== "verified" && finding.verification_note?.trim() && (
|
||||
<p className="whitespace-pre-wrap break-words text-muted-foreground text-xs">
|
||||
{finding.verification_note}
|
||||
</p>
|
||||
)}
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
{/* Vulnerability type */}
|
||||
<FieldRow label="Vulnerability type">
|
||||
{finding.vulnclass ? (
|
||||
|
||||
@@ -49,6 +49,7 @@ import type {
|
||||
FindingStats,
|
||||
FindingStatus,
|
||||
Severity,
|
||||
VerificationStatus,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -63,6 +64,8 @@ import {
|
||||
isSameFinding,
|
||||
SEVERITIES,
|
||||
UNASSIGNED_TASK,
|
||||
VERIFICATION_META,
|
||||
VERIFICATION_STATUSES,
|
||||
} from "./_components/findings-table";
|
||||
|
||||
const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences";
|
||||
@@ -132,6 +135,7 @@ export default function FindingsPage() {
|
||||
const [view, setView] = React.useState<FindingView>("flat");
|
||||
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
|
||||
const [status, setStatus] = React.useState<"all" | FindingStatus>("all");
|
||||
const [verificationStatus, setVerificationStatus] = React.useState<"all" | VerificationStatus>("all");
|
||||
const [vulnclass, setVulnclass] = React.useState<string>("all");
|
||||
const [task, setTask] = React.useState<string>("all");
|
||||
const [sort, setSort] = React.useState<"severity" | "time">("severity");
|
||||
@@ -162,7 +166,7 @@ export default function FindingsPage() {
|
||||
const retestRefreshVersion = React.useRef(0);
|
||||
const [deepenDescription, setDeepenDescription] = React.useState("");
|
||||
const [deepening, setDeepening] = React.useState(false);
|
||||
const filterFingerprint = JSON.stringify([severity, status, vulnclass, task, sort, query]);
|
||||
const filterFingerprint = JSON.stringify([severity, status, verificationStatus, vulnclass, task, sort, query]);
|
||||
const activeFilterFingerprint = React.useRef(filterFingerprint);
|
||||
activeFilterFingerprint.current = filterFingerprint;
|
||||
|
||||
@@ -200,6 +204,7 @@ export default function FindingsPage() {
|
||||
view?: unknown;
|
||||
severity?: unknown;
|
||||
status?: unknown;
|
||||
verificationStatus?: unknown;
|
||||
vulnclass?: unknown;
|
||||
task?: unknown;
|
||||
sort?: unknown;
|
||||
@@ -211,6 +216,12 @@ export default function FindingsPage() {
|
||||
if (parsed.status === "all" || FINDING_STATUSES.includes(parsed.status as FindingStatus)) {
|
||||
setStatus(parsed.status as "all" | FindingStatus);
|
||||
}
|
||||
if (
|
||||
parsed.verificationStatus === "all" ||
|
||||
VERIFICATION_STATUSES.includes(parsed.verificationStatus as VerificationStatus)
|
||||
) {
|
||||
setVerificationStatus(parsed.verificationStatus as "all" | VerificationStatus);
|
||||
}
|
||||
if (typeof parsed.vulnclass === "string" && parsed.vulnclass) setVulnclass(parsed.vulnclass);
|
||||
if (typeof parsed.task === "string" && parsed.task) setTask(parsed.task);
|
||||
if (parsed.sort === "severity" || parsed.sort === "time") setSort(parsed.sort);
|
||||
@@ -225,9 +236,9 @@ export default function FindingsPage() {
|
||||
if (!preferencesHydrated) return;
|
||||
setLocalStorageValue(
|
||||
FINDING_LIST_PREFERENCE_KEY,
|
||||
JSON.stringify({ view, severity, status, vulnclass, task, sort }),
|
||||
JSON.stringify({ view, severity, status, verificationStatus, vulnclass, task, sort }),
|
||||
);
|
||||
}, [preferencesHydrated, severity, sort, status, task, view, vulnclass]);
|
||||
}, [preferencesHydrated, severity, sort, status, task, verificationStatus, view, vulnclass]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = window.setTimeout(() => setQuery(search.trim()), 300);
|
||||
@@ -286,7 +297,7 @@ export default function FindingsPage() {
|
||||
await api.exportFindings({
|
||||
format: exportFormat,
|
||||
scope: exportScope,
|
||||
filters: { severity, status, vulnclass, task, query, sort },
|
||||
filters: { severity, status, verificationStatus, vulnclass, task, query, sort },
|
||||
ids: [...selectedIds],
|
||||
});
|
||||
setExportOpen(false);
|
||||
@@ -326,6 +337,7 @@ export default function FindingsPage() {
|
||||
pageSize: flatPageSize,
|
||||
severity,
|
||||
status,
|
||||
verificationStatus,
|
||||
vulnclass,
|
||||
task,
|
||||
query,
|
||||
@@ -339,7 +351,19 @@ export default function FindingsPage() {
|
||||
// Polling keeps the last successful snapshot visible.
|
||||
setFlat((current) => ({ ...current, loading: false }));
|
||||
}
|
||||
}, [activeAssetScope, filterFingerprint, flatPage, flatPageSize, severity, status, vulnclass, task, query, sort]);
|
||||
}, [
|
||||
activeAssetScope,
|
||||
filterFingerprint,
|
||||
flatPage,
|
||||
flatPageSize,
|
||||
severity,
|
||||
status,
|
||||
verificationStatus,
|
||||
vulnclass,
|
||||
task,
|
||||
query,
|
||||
sort,
|
||||
]);
|
||||
|
||||
// loadAssetTree fetches the whole asset tree. The tree doesn't change with the selected node (otherwise
|
||||
// picking one would collapse it into a single chain), so no assetScope here.
|
||||
@@ -349,7 +373,7 @@ export default function FindingsPage() {
|
||||
const request = ++assetTreeRequest.current;
|
||||
setAssetTree((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const result = await api.findingAssetTree({ severity, status, vulnclass, task, query, sort });
|
||||
const result = await api.findingAssetTree({ severity, status, verificationStatus, vulnclass, task, query, sort });
|
||||
if (request !== assetTreeRequest.current || activeFilterFingerprint.current !== requestFilter) return;
|
||||
setAssetTree({
|
||||
nodes: result.nodes ?? [],
|
||||
@@ -364,7 +388,7 @@ export default function FindingsPage() {
|
||||
setAssetTree((current) => ({ ...current, loading: false }));
|
||||
toast.error(`Failed to load the asset tree: ${(e as Error).message}`);
|
||||
}
|
||||
}, [filterFingerprint, severity, status, vulnclass, task, query, sort]);
|
||||
}, [filterFingerprint, severity, status, verificationStatus, vulnclass, task, query, sort]);
|
||||
|
||||
const refreshGroups = React.useCallback(async () => {
|
||||
const requestFilter = filterFingerprint;
|
||||
@@ -376,6 +400,7 @@ export default function FindingsPage() {
|
||||
pageSize,
|
||||
severity,
|
||||
status,
|
||||
verificationStatus,
|
||||
vulnclass,
|
||||
task,
|
||||
query,
|
||||
@@ -388,7 +413,7 @@ export default function FindingsPage() {
|
||||
} catch {
|
||||
// Polling keeps the last successful snapshot visible.
|
||||
}
|
||||
}, [filterFingerprint, page, pageSize, severity, status, vulnclass, task, query, sort]);
|
||||
}, [filterFingerprint, page, pageSize, severity, status, verificationStatus, vulnclass, task, query, sort]);
|
||||
|
||||
const loadGroup = React.useCallback(
|
||||
async (key: string, groupPage: number, groupPageSize: number) => {
|
||||
@@ -413,6 +438,7 @@ export default function FindingsPage() {
|
||||
pageSize: groupPageSize,
|
||||
severity,
|
||||
status,
|
||||
verificationStatus,
|
||||
vulnclass,
|
||||
task: key,
|
||||
query,
|
||||
@@ -447,7 +473,7 @@ export default function FindingsPage() {
|
||||
}));
|
||||
}
|
||||
},
|
||||
[filterFingerprint, severity, status, vulnclass, query, sort],
|
||||
[filterFingerprint, severity, status, verificationStatus, vulnclass, query, sort],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -898,6 +924,23 @@ export default function FindingsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={verificationStatus}
|
||||
onValueChange={(v) => setVerificationStatus(v as "all" | VerificationStatus)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-48">
|
||||
<SelectValue placeholder="Verification" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All verification states</SelectItem>
|
||||
{VERIFICATION_STATUSES.map((st) => (
|
||||
<SelectItem key={st} value={st}>
|
||||
{VERIFICATION_META[st].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={vulnclass} onValueChange={setVulnclass}>
|
||||
<SelectTrigger size="sm" className="w-40">
|
||||
<SelectValue placeholder="Vulnerability type" />
|
||||
|
||||
@@ -335,6 +335,7 @@ function ProfileSheet({
|
||||
const [maxTokens, setMaxTokens] = React.useState("0"); // per-reply output cap; 0 = not sent
|
||||
const [maxTokensField, setMaxTokensField] = React.useState(NONE); // which field name carries the cap; NONE = max_tokens
|
||||
const [sessionHeaderKey, setSessionHeaderKey] = React.useState(""); // custom session header name; empty = not sent
|
||||
const [userAgent, setUserAgent] = React.useState(""); // custom User-Agent; empty = keep the default UA
|
||||
const [retry, setRetry] = React.useState<LLMRetryOverride>(ZERO_OVERRIDE); // retry override for this profile; all 0 = follow the global
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
@@ -366,6 +367,7 @@ function ProfileSheet({
|
||||
setMaxTokens(String(profile?.max_tokens ?? 0));
|
||||
setMaxTokensField(fromStore(profile?.max_tokens_field));
|
||||
setSessionHeaderKey(profile?.session_header_key ?? "");
|
||||
setUserAgent(profile?.user_agent ?? "");
|
||||
setRetry(profile?.retry ?? ZERO_OVERRIDE);
|
||||
setApiKey("");
|
||||
setKeyHint(profile?.api_key_hint ?? "");
|
||||
@@ -412,6 +414,7 @@ function ProfileSheet({
|
||||
profileId,
|
||||
streaming,
|
||||
sessionHeaderKey.trim(),
|
||||
userAgent.trim(),
|
||||
);
|
||||
// Show the reply too: only when the model demonstrably spoke is this the same as working inside a session.
|
||||
if (r.ok)
|
||||
@@ -455,6 +458,7 @@ function ProfileSheet({
|
||||
// and the backend re-applies the same normalization; this just keeps the UI from sending contradictory values.
|
||||
max_tokens_field: format === "openai" ? toStore(maxTokensField) : "",
|
||||
session_header_key: sessionHeaderKey.trim(),
|
||||
user_agent: userAgent.trim(),
|
||||
retry,
|
||||
});
|
||||
if (isNew) toast.success(`Created: ${name.trim()} (use "Set as active" on its card to enable it)`);
|
||||
@@ -613,6 +617,21 @@ function ProfileSheet({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-user-agent">Custom User-Agent (optional)</Label>
|
||||
<Input
|
||||
id="p-user-agent"
|
||||
className="font-mono"
|
||||
placeholder="e.g. my-app/1.0 (empty = default UA)"
|
||||
value={userAgent}
|
||||
onChange={(e) => setUserAgent(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Overrides the <b>User-Agent</b> header on every outbound LLM request. Useful for gateways that route or allow-list by
|
||||
User-Agent. Leave empty to keep the default User-Agent unchanged.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-api-key">
|
||||
{format === "ollama" ? "API Key (optional for Ollama)" : "API Key"}
|
||||
|
||||
@@ -218,6 +218,7 @@ function findingFilterParams(q: Omit<FindingQuery, "page" | "pageSize">): URLSea
|
||||
const p = new URLSearchParams();
|
||||
if (q.severity && q.severity !== "all") p.set("severity", q.severity);
|
||||
if (q.status && q.status !== "all") p.set("status", q.status);
|
||||
if (q.verificationStatus && q.verificationStatus !== "all") p.set("verification_status", q.verificationStatus);
|
||||
if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass);
|
||||
if (q.task && q.task !== "all") p.set("task_id", q.task);
|
||||
if (q.query?.trim()) p.set("q", q.query.trim());
|
||||
@@ -949,6 +950,7 @@ export const api = {
|
||||
profile_id?: number,
|
||||
streaming = true, // test with the profile's real streaming mode so "streaming works, non-streaming does not" never leaks into conversations
|
||||
session_header_key = "", // non-empty = the test request also carries this custom session header (value is a one-off session id)
|
||||
user_agent = "", // non-empty = the test overrides the User-Agent header with this value
|
||||
) =>
|
||||
// reply = the model's actual reply (truncated); the backend marks a profile that replies with nothing as failed
|
||||
post<{ ok: boolean; error?: string; latency_ms?: number; model?: string; reply?: string }>("/llm/test", {
|
||||
@@ -962,6 +964,7 @@ export const api = {
|
||||
profile_id,
|
||||
streaming,
|
||||
session_header_key,
|
||||
user_agent,
|
||||
}),
|
||||
llmProfiles: () => get<{ profiles: LLMProfile[] }>("/llm/profiles").then((r) => arr(r.profiles)),
|
||||
saveLLMProfile: (p: {
|
||||
@@ -983,6 +986,7 @@ export const api = {
|
||||
max_tokens?: number; // output cap per reply; 0 = not sent, the server default decides
|
||||
max_tokens_field?: string; // ""=max_tokens (default) | "max_completion_tokens" (openai format only)
|
||||
session_header_key?: string; // non-empty = send this HTTP header on every request, value = the current session id; "" = not sent
|
||||
user_agent?: string; // non-empty = override the User-Agent header on every request; "" = keep the default UA
|
||||
retry?: LLMRetryOverride; // this profile's retry override; all zeros = follow the global retry policy
|
||||
}) => post<{ id: number }>("/llm/profiles", p),
|
||||
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
|
||||
|
||||
@@ -481,6 +481,10 @@ export type FindingStatus =
|
||||
| "duplicate"
|
||||
| "risk_accepted";
|
||||
|
||||
// Verification status (S1): the automated proof-of-exploitation outcome, distinct from the
|
||||
// human triage status above. "blocked" is produced by no path yet but is displayed when present.
|
||||
export type VerificationStatus = "verified" | "unverified" | "blocked" | "not_assessed" | "false_positive";
|
||||
|
||||
// FindingAsset is an asset bound to a vulnerability (label pre-rendered by the backend).
|
||||
export interface FindingAsset {
|
||||
id: string;
|
||||
@@ -493,6 +497,9 @@ export interface Finding {
|
||||
evidence_version?: number;
|
||||
report_evidence_version?: number;
|
||||
report_stale?: boolean;
|
||||
poe_level?: number; // proof-of-exploitation level (3 or 4 when verified); 0 = none declared
|
||||
verification_status?: VerificationStatus; // automated verification outcome (S1)
|
||||
verification_note?: string; // why the status was reached; empty when verified
|
||||
id: string;
|
||||
finding_id?: string; // row id in the standalone findings table, the handle for status updates (may be missing for old in-task nodes)
|
||||
vulnclass: string;
|
||||
@@ -575,6 +582,7 @@ export interface FindingQuery {
|
||||
pageSize: number;
|
||||
severity?: "all" | Severity;
|
||||
status?: "all" | FindingStatus;
|
||||
verificationStatus?: "all" | VerificationStatus;
|
||||
vulnclass?: string;
|
||||
task?: string; // task id; "all"/empty = no task filter
|
||||
query?: string;
|
||||
@@ -1061,6 +1069,8 @@ export interface LLMProfile {
|
||||
// Custom session header name: when non-empty every request carries this HTTP header, value = the current session/intent session id.
|
||||
// "" = not sent. For gateways that do prompt caching/sticky routing on a session-id header.
|
||||
session_header_key?: string;
|
||||
// Custom User-Agent: when non-empty, every outbound LLM request overrides its User-Agent header with this value. "" = do not override (keep the default UA).
|
||||
user_agent?: string;
|
||||
// This profile's retry overrides (connect / empty response / same-provider safety window). Empty / all zeros = follow the global policy.
|
||||
retry?: LLMRetryOverride;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user