mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 19:50:54 +08:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1810a82187 | ||
|
|
34cbbeadb7 | ||
|
|
a1426bbd35 | ||
|
|
0a740f0894 | ||
|
|
f938ba00d9 | ||
|
|
f5db962eeb | ||
|
|
62571c9b34 | ||
|
|
4fe80b860c | ||
|
|
fbe78395d5 | ||
|
|
6ce50b7d13 | ||
|
|
9c2b56001d | ||
|
|
9481b8e962 | ||
|
|
f90e8d2bcb |
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -1088,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()
|
||||
@@ -1102,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
|
||||
@@ -1171,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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
+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).
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user