mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-14 04:00:54 +08:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fd83ef070 | ||
|
|
bba7989903 | ||
|
|
89ece8eb8d | ||
|
|
623e5e58a7 | ||
|
|
0777edfb8f | ||
|
|
67971ef7da | ||
|
|
57cac93d49 | ||
|
|
58b221bfe1 | ||
|
|
5d72de4ef7 | ||
|
|
19e929c224 | ||
|
|
d2f4697b2b | ||
|
|
b269d3e590 | ||
|
|
908832ae1b | ||
|
|
9f03b8c16a | ||
|
|
8d841a6011 | ||
|
|
35bd98561c | ||
|
|
d0ffd96960 | ||
|
|
ac8491d666 | ||
|
|
aaf966872a | ||
|
|
f57cf659b5 | ||
|
|
ff2e0bf338 | ||
|
|
53bda9fe37 | ||
|
|
a11c2ed10c | ||
|
|
04e8b37368 | ||
|
|
2cdedd8eee | ||
|
|
2f80ffd979 | ||
|
|
6b1f04b1ba | ||
|
|
c96c3ea3a9 | ||
|
|
e36d0730da | ||
|
|
a183c4aad5 | ||
|
|
7644ae2a2f |
@@ -0,0 +1,114 @@
|
||||
# Recursive self-improvement in ARTEX
|
||||
|
||||
Status: measurement and shadow memory only. There is no harness search, no
|
||||
automatic adoption, and no self-modifying code in this repository.
|
||||
|
||||
This file defines the contract any future harness-optimization work must meet,
|
||||
so the first experiment cannot quietly become an unguarded rewrite loop.
|
||||
|
||||
## The three loops
|
||||
|
||||
| loop | object | state |
|
||||
|---|---|---|
|
||||
| A. within a task | planner, worker, retester, adversarial gate | exists |
|
||||
| B. across tasks | evidence-gated lessons from closed tasks | shadow mode, PR #104 |
|
||||
| C. on the harness | prompts, skills, tool guidance | **not built**; this is its contract |
|
||||
|
||||
Loop A produces task gain. Loop B is meant to produce retained gain: a lesson
|
||||
must survive a closed task and change a later one. Loop C is the only loop that
|
||||
can produce improver gain, and it is the only one that can silently damage the
|
||||
product, which is why it goes last.
|
||||
|
||||
## Loop C contract
|
||||
|
||||
Any PR that proposes, probes, or adopts harness edits must satisfy all of:
|
||||
|
||||
1. **Live tasks propose, benchmarks adopt.** A successful bounty task can
|
||||
generate a candidate lesson or edit, but it can never be the evidence for
|
||||
adoption. Adoption requires held-out benchmark runs, because a live task is
|
||||
unreproducible and contaminated by its target.
|
||||
2. **Owner approval per adoption.** The loop may prepare a proposal branch and
|
||||
show the diff. It may not change the active prompt. Adoption is one explicit
|
||||
owner action, like a baseline pin.
|
||||
3. **Immutable surfaces.** The following are never in the search space:
|
||||
the adversarial and quality gates, scope and authorization checks,
|
||||
prompt-injection defenses, tool allowlists, and the code-owned prompt tails
|
||||
(traffic tools, artifact rules, lesson block). Only the editable prompt body,
|
||||
skill markdown, and tool descriptions can be proposed.
|
||||
4. **One-step rollback.** Every adopted prompt change is a new
|
||||
`agent_prompts` version, so rollback is a pointer change. Skills are files
|
||||
under git; no adoption may bypass review.
|
||||
5. **Flag off by default.** The loop runs behind a feature flag. Turning it on
|
||||
requires an owner decision, and the flag is checked per run.
|
||||
6. **Bounded edit budget.** A round may propose a bounded number of edits
|
||||
(annealed over rounds). A critic proposes only edits that are expected to
|
||||
transfer; a pruner rejects edits that only fit the probe task. This follows
|
||||
RRSI (arXiv 2609.24972).
|
||||
7. **Attribution.** Probe results are compared against an unchanged baseline on
|
||||
the same task and seed. Regressions are attributed to a specific module
|
||||
rather than to the loop as a whole; the modular fault-localization idea is
|
||||
from ModularRSI (arXiv 2609.14857). A paired evolving/non-evolving comparison
|
||||
guards against misattributed safety regressions, following SEABench
|
||||
(arXiv 2609.35596).
|
||||
8. **No self-approval and no silent state.** The loop cannot set its own status,
|
||||
edit its own acceptance criteria, or write to the lesson library as
|
||||
`adopted`. Lesson status changes stay owner actions.
|
||||
|
||||
A future Loop C PR is acceptable only with:
|
||||
|
||||
- a benchmark adapter and a held-out task set, independent of the tasks used to
|
||||
propose the edit;
|
||||
- a baseline run and a candidate run with the same model, seed, budget, and
|
||||
judge;
|
||||
- the judge independent of the subject model, with a falsification battery
|
||||
(the existing L3 runner already enforces this);
|
||||
- per-gate results with denominators, not a single aggregate score;
|
||||
- a written rollback path and the flag name.
|
||||
|
||||
## What the current layers do
|
||||
|
||||
- `GET /api/tasks/{id}/outcome` (PR #102) records what a task produced and what
|
||||
it cost: findings by disposition and severity, retest verdicts, tokens, tool
|
||||
calls, skill misses, wall time.
|
||||
- `ARTEX_RSI_LESSONS=off|shadow|inject` (PR #104) extracts evidence-gated
|
||||
lessons from terminal tasks and, in `inject` mode, appends a code-owned
|
||||
data-not-instructions block after the planner and worker system prompts.
|
||||
Status stays `candidate`.
|
||||
- `GET /api/skill-gaps` (PR #106) turns the existing miss ledger into a
|
||||
capability backlog.
|
||||
|
||||
None of these changes an agent's instructions on its own. Shadow mode exists to
|
||||
measure whether retrieval helps before any harness edit is attempted.
|
||||
|
||||
## Credits
|
||||
|
||||
The admission rule (no lesson without cue, action, and evidence) and the
|
||||
propose/probe/compose shape come from:
|
||||
|
||||
> Harness^2: Recursive Agent Harnessing for an Open World.
|
||||
> Ruiyao Xu, Yanfei Chen, Zhongying CuiZhu, Bhavana Dalvi Mishra, Yifei Ming,
|
||||
> Han Yu, Rujun Han, Chen-Yu Lee, Tomas Pfister. arXiv:2610.14224, 2026.
|
||||
> Google Research. Apache-2.0. https://github.com/google-research/harness2
|
||||
|
||||
The evidence taxonomy used in the design (task gain, retained gain, improver
|
||||
gain) and the implementation pointers come from:
|
||||
|
||||
> awesome-rsi, lobehub. https://github.com/lobehub/awesome-rsi
|
||||
|
||||
Specific ideas referenced above:
|
||||
|
||||
- RRSI: Regularized Recursive Self-Improvement of Agent Harnesses,
|
||||
arXiv:2609.24972 (annealed edit budget, critic and pruner).
|
||||
- ModularRSI: Modular and Generalizable Recursive Harness Self-Improvement,
|
||||
arXiv:2609.14857 (module-level fault localization from success/failure
|
||||
contrast).
|
||||
- SEABench: Benchmarking Endogenous Misalignment in Self-Evolving Agents,
|
||||
arXiv:2609.35596 (paired evolving/non-evolving attribution).
|
||||
- Agentic Harness Engineering: Observability-Driven Automatic Evolution of
|
||||
Coding-Agent Harnesses, arXiv:2604.25850 (observable edits with predictions
|
||||
verified later, the same discipline as the benchmark gate here).
|
||||
- A Safety in Self-Evolving Agents survey, arXiv:2610.00093 (why properties
|
||||
must survive accumulation and cross-context reuse, not just entry screening).
|
||||
|
||||
Only ideas are borrowed. ARTEX code is independent Go and Python; no source
|
||||
from harness2 or the referenced projects is copied.
|
||||
@@ -13,6 +13,10 @@ import (
|
||||
// agent test suite so cross-package DELETE cleanup races with db/server
|
||||
// packages are avoided when running `go test ./...`.
|
||||
func TestMain(m *testing.M) {
|
||||
// Own database per package (#80): with separate databases, packages stop
|
||||
// contending on shared tables and the advisory lock below stops
|
||||
// serializing go test ./... across packages.
|
||||
db.SuffixTestDatabase("agent")
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
os.Exit(m.Run())
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -96,6 +97,26 @@ func DSN() (dsn, source string, err error) {
|
||||
return config.PostgresDSN()
|
||||
}
|
||||
|
||||
// SuffixTestDatabase rewrites the ARTEX_PG_DSN environment variable so its
|
||||
// database name carries the given suffix (artex -> artex_agent). Called from
|
||||
// package TestMain functions so every go test package runs against its own
|
||||
// database: parallel package processes then cannot contend on the same tables
|
||||
// (the under-load deadlock family, #80). No-op when ARTEX_PG_DSN is unset —
|
||||
// config-file and production paths are untouched. Open creates the suffixed
|
||||
// database automatically (ensureDatabase).
|
||||
func SuffixTestDatabase(suffix string) {
|
||||
v := strings.TrimSpace(os.Getenv("ARTEX_PG_DSN"))
|
||||
if v == "" {
|
||||
return
|
||||
}
|
||||
u, err := url.Parse(v)
|
||||
if err != nil || u.Path == "" || u.Path == "/" {
|
||||
return
|
||||
}
|
||||
u.Path = "/" + strings.TrimPrefix(u.Path, "/") + "_" + suffix
|
||||
_ = os.Setenv("ARTEX_PG_DSN", u.String())
|
||||
}
|
||||
|
||||
// DB wraps the shared *sql.DB. PG handles its own connection pool + concurrency
|
||||
// (MVCC), so unlike the old SQLite store there is no process-wide write mutex.
|
||||
type DB struct{ *sql.DB }
|
||||
|
||||
@@ -1192,6 +1192,28 @@ WHERE exploration_id=$1 AND kind='goal' AND state='open')`, s.expID).Scan(&exist
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// OpenGoalFingerprint returns a stable fingerprint of the open-goal set: the
|
||||
// sorted goal ids joined by commas, "" when none. Planner quiet-mode (#79) uses
|
||||
// it to tell "the goal set changed since the last planning round" apart from
|
||||
// "nothing the plan depends on changed".
|
||||
func (s *ExplorationStore) OpenGoalFingerprint() (string, error) {
|
||||
rows, err := s.db.Query(`SELECT id FROM exploration_nodes
|
||||
WHERE exploration_id=$1 AND kind='goal' AND state='open' ORDER BY id`, s.expID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ids = append(ids, strconv.FormatInt(id, 10))
|
||||
}
|
||||
return strings.Join(ids, ","), rows.Err()
|
||||
}
|
||||
|
||||
// ClaimIntent atomically moves an open intent to running. Returns true if claimed.
|
||||
func (s *ExplorationStore) ClaimIntent(id int64, owner string) (bool, error) {
|
||||
res, err := s.db.Exec(`UPDATE exploration_nodes SET state='running', owner=$1
|
||||
|
||||
+9
-1
@@ -371,7 +371,15 @@ func applyCostEur(d *DB, out []ProfileUsage) error {
|
||||
if !ok || (pr.in == 0 && pr.outp == 0 && pr.cr == 0 && pr.cw == 0) {
|
||||
continue
|
||||
}
|
||||
cost := float64(out[i].InputTokens)/1e6*pr.in + float64(out[i].OutputTokens)/1e6*pr.outp +
|
||||
// InputTokens includes the cached subset (Anthropic and OpenAI usage
|
||||
// semantics), so the fresh share is input minus cache reads; charging
|
||||
// both buckets at full input price double-counts the cache. Clamped at
|
||||
// zero for gateways that report the buckets disjointly.
|
||||
fresh := out[i].InputTokens - out[i].CacheReadTokens
|
||||
if fresh < 0 {
|
||||
fresh = 0
|
||||
}
|
||||
cost := float64(fresh)/1e6*pr.in + float64(out[i].OutputTokens)/1e6*pr.outp +
|
||||
float64(out[i].CacheReadTokens)/1e6*pr.cr + float64(out[i].CacheWriteTokens)/1e6*pr.cw
|
||||
c := cost
|
||||
out[i].CostEur = &c
|
||||
|
||||
@@ -526,6 +526,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
parent_ref TEXT,
|
||||
timeout_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
plan_quiet_mode BOOLEAN NOT NULL DEFAULT true,
|
||||
coverage_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
pinned_at TIMESTAMPTZ,
|
||||
first_run_at TIMESTAMPTZ,
|
||||
@@ -543,6 +544,7 @@ CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
-- planner heartbeat trigger interval (seconds); backfills old databases. Default 300s (5min). See docs/planner-trigger-impl-plan.md
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_quiet_mode BOOLEAN NOT NULL DEFAULT true;
|
||||
-- Concurrency-limit hold state; backfills old databases. true=queued due to the concurrency limit, auto-starts when a slot frees up.
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false;
|
||||
-- Asset coverage feature switch; backfills old databases. true (default)=compute/show test coverage, auto-accumulate test scope,
|
||||
|
||||
+20
-7
@@ -46,6 +46,9 @@ type Task struct {
|
||||
// last plan round ended / the task started, with no trigger in between → trigger a round.
|
||||
// Floor = default = 300 (5min); anything lower is raised to 300 (normalized in CreateTask). See docs/planner-trigger-impl-plan.md
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"`
|
||||
// PlanQuietMode gates the planner quiet-mode (#86): edge-triggered rounds
|
||||
// are skipped while work is in flight and the open-goal set is unchanged.
|
||||
PlanQuietMode bool `json:"plan_quiet_mode"`
|
||||
// CoverageEnabled is the master switch of the "asset coverage" feature (default true). When false:
|
||||
// do not compute/show test coverage, do not auto-accumulate task_scope (source=auto), do not open
|
||||
// add_task_scope/list_untested_assets to the agent, and do not inject the coverage block into the
|
||||
@@ -154,6 +157,8 @@ type TaskCreateOptions struct {
|
||||
LLMProfileIDs []int64
|
||||
TimeoutSeconds int
|
||||
PlanHeartbeatSeconds int
|
||||
// PlanQuietMode is the per-task quiet-mode switch; nil = default on (#86).
|
||||
PlanQuietMode *bool
|
||||
// CoverageEnabled is the "asset coverage" feature switch; nil=default on (true), letting
|
||||
// creation paths that do not care about the switch (orchestration spawn, old API) keep the
|
||||
// original behavior. Only web task creation can explicitly pass false to turn it off.
|
||||
@@ -219,6 +224,10 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
|
||||
id := opts.LLMProfileIDs[0]
|
||||
active = &id
|
||||
}
|
||||
quietMode := true
|
||||
if opts.PlanQuietMode != nil {
|
||||
quietMode = *opts.PlanQuietMode
|
||||
}
|
||||
t := &Task{
|
||||
Name: opts.Name, CategoryID: opts.CategoryID, CategoryName: categoryName,
|
||||
Description: description, Goal: goal, ExplorationID: expID,
|
||||
@@ -227,12 +236,13 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
|
||||
SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...),
|
||||
CompanyIDs: append([]int64(nil), opts.CompanyIDs...),
|
||||
TimeoutSeconds: opts.TimeoutSeconds, PlanHeartbeatSeconds: opts.PlanHeartbeatSeconds,
|
||||
PlanQuietMode: quietMode,
|
||||
CoverageEnabled: coverageEnabled,
|
||||
}
|
||||
if err := tx.QueryRow(`
|
||||
INSERT INTO tasks(name, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$6,$7,$8,$9)
|
||||
RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
|
||||
INSERT INTO tasks(name, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, plan_quiet_mode, coverage_enabled)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$6,$7,$8,$9,$10)
|
||||
RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, quietMode, coverageEnabled).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil {
|
||||
@@ -348,11 +358,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
|
||||
|
||||
const taskCols = `id, COALESCE(name,''), category_id,
|
||||
COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''),
|
||||
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
|
||||
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(plan_quiet_mode,true), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
|
||||
|
||||
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
|
||||
var t Task
|
||||
if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
|
||||
if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.PlanQuietMode, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Pinned = t.PinnedAt != nil
|
||||
@@ -396,6 +406,8 @@ ORDER BY (pinned_at IS NOT NULL) DESC, pinned_at DESC NULLS LAST, id DESC`)
|
||||
type TaskPatch struct {
|
||||
Name *string
|
||||
Pinned *bool
|
||||
// PlanQuietMode flips the per-task quiet-mode switch (#86); nil = leave unchanged.
|
||||
PlanQuietMode *bool
|
||||
}
|
||||
|
||||
// UpdateTask applies a partial task name/pin mutation and returns the updated row.
|
||||
@@ -407,9 +419,10 @@ func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) {
|
||||
WHEN $4::boolean IS NULL THEN pinned_at
|
||||
WHEN $4::boolean THEN COALESCE(pinned_at, now())
|
||||
ELSE NULL
|
||||
END
|
||||
END,
|
||||
plan_quiet_mode = CASE WHEN $5::boolean THEN $6 ELSE plan_quiet_mode END
|
||||
WHERE id=$1 AND deleted_at IS NULL
|
||||
RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned))
|
||||
RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned, patch.PlanQuietMode != nil, patch.PlanQuietMode))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
// avoid cross-package cleanup races (e.g. DELETE FROM assets WHERE id > X
|
||||
// from one package deleting assets created by another).
|
||||
func TestMain(m *testing.M) {
|
||||
// Own database per package (#80): with separate databases, packages stop
|
||||
// contending on shared tables and the advisory lock below stops
|
||||
// serializing go test ./... across packages.
|
||||
SuffixTestDatabase("db")
|
||||
dsn, _, err := DSN()
|
||||
if err != nil {
|
||||
// No DB configured — tests that need PG will skip themselves.
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
// Initialize an explicitly configured fresh database before taking the same
|
||||
// suite lock as db, agent and server. Hold it on one pinned connection.
|
||||
func TestMain(m *testing.M) {
|
||||
// Own database per package (#80): stop contending on the shared tables of
|
||||
// a single test database during parallel ./... runs.
|
||||
db.SuffixTestDatabase("evidence")
|
||||
if os.Getenv("ARTEX_PG_DSN") == "" {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package llmrec
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// Run against this package's own database (db.SuffixTestDatabase, #80): the
|
||||
// recorder tests require an isolated ARTEX_PG_DSN, and the suffix gives them
|
||||
// one even inside a full parallel ./... run.
|
||||
func TestMain(m *testing.M) {
|
||||
db.SuffixTestDatabase("llmrec")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -71,6 +71,8 @@ Environment contract:
|
||||
| `ARTEX_TOKEN` or `ARTEX_JWT_KEY` | API bearer token, or the server's jwt.key to mint one |
|
||||
| `JUDGE_BASE_URL`, `JUDGE_MODEL`, `JUDGE_API_KEY` | OpenAI-compatible judge endpoint (independent of the subject) |
|
||||
| `--subject-model` | model of the task's active profile, for the self-judge guard |
|
||||
| `--llm-profiles` | comma-separated profile ids for the task chain, head first (e.g. `3,2`); stamped into the result |
|
||||
| `--target-host` | host the task should target, as reachable from ARTEX (use `172.17.0.1` when ARTEX runs in a container and the corpus runs on the host) |
|
||||
| `--image` / `--port` / `--corpus-url` | corpus container image, local port, or an existing deployment |
|
||||
| `--task-timeout` | task `timeout_seconds` (default 5400) |
|
||||
| `--keep` | keep the corpus container after the run |
|
||||
@@ -86,6 +88,48 @@ field semantics) and a `task_goal_template`. Pin the image tag in the file's
|
||||
`version` note. Entries stay coarse on purpose: component + class + mechanism.
|
||||
Upgrading the corpus image is a ground-truth version bump, never a silent edit.
|
||||
|
||||
## DrostBench corpus (drost mode)
|
||||
|
||||
[DrostBench v1](https://github.com/drost-ai/drost-benchmarks) ships 100
|
||||
docker-compose web applications, each with one flag-bearing vulnerability and
|
||||
3-5 decoy weaknesses that do not reach the flag. The win condition is reaching
|
||||
the flag, which makes it an objective oracle: no judge takes part in that
|
||||
decision. It complements image mode (gate scoring by an independent judge) and
|
||||
answers the diversity gap of a single corpus: the suite is fresh and was a
|
||||
private holdout before release.
|
||||
|
||||
Pin a checkout of the suite, then run one challenge at a time:
|
||||
|
||||
```bash
|
||||
DROST_REPO=/path/to/drost-benchmarks python3 run_bench.py \
|
||||
--corpus-type drost --drost-id DBEN-v1-001 \
|
||||
--llm-profiles 3,2 --target-host 172.17.0.1 \
|
||||
--subject-model deepseek-chat
|
||||
```
|
||||
|
||||
- `--target-host 172.17.0.1` when ARTEX runs in a container: the challenge is
|
||||
published on the host and the task target must be the host gateway (same
|
||||
pattern as the MCP server URLs). `--llm-profiles` picks the task's model
|
||||
chain, head first.
|
||||
- Black-box web only: this measures the recon/pentest lane, not codeaudit.
|
||||
- Every build gets a per-build random flag (`flag{<uuid4>}`) unless
|
||||
`--drost-flag` overrides it. The suite's deterministic sha256 default is
|
||||
never used, so a memorized or trained-on flag cannot be replayed as a
|
||||
capture.
|
||||
- The flag is searched in the already-fetched findings first; only on a miss
|
||||
does the deep scan run (full finding details, the task's fact nodes, the
|
||||
generated report). Declaring a miss is a claim about all of those.
|
||||
- Ground truth is derived at run time from the challenge `benchmark.json`
|
||||
(name, description, tags). With a judge configured, the class gate runs off
|
||||
the tags and the proof gate off the description; without one, the run is
|
||||
flag-only and the gates are reported as not measured, never as zero.
|
||||
- Teardown removes the containers, network and locally built images
|
||||
(`down --rmi local`) unless `--keep`. Results are stamped with the challenge
|
||||
id and the suite's commit sha.
|
||||
|
||||
`--deploy-only` brings a challenge up and prints its URL and flag without
|
||||
creating a task, to validate an install.
|
||||
|
||||
## What this does not measure
|
||||
|
||||
Single-run results are noisy (the fork of VulnHunter measured a 14x spread in
|
||||
|
||||
Executable
+213
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
"""DrostBench v1 corpus adapter for the ARTEX L3 benchmark.
|
||||
|
||||
DrostBench (github.com/drost-ai/drost-benchmarks, Apache-2.0) ships 100
|
||||
docker-compose web applications. Each carries one flag-bearing vulnerability
|
||||
plus 3-5 decoy weaknesses that do not reach the flag, so reaching the flag is
|
||||
an objective win condition: no LLM judge takes part in the decision.
|
||||
|
||||
This module drives one challenge end to end:
|
||||
|
||||
- lifecycle through docker compose directly (build with an explicit flag,
|
||||
up --wait, host-port discovery, down --rmi local), never through the
|
||||
challenge Makefile, so the flag override and teardown stay under our control
|
||||
- ALWAYS a per-build randomized flag (flag{<uuid4>}) unless one is passed
|
||||
explicitly: the suite's deterministic sha256 default is never used, so a
|
||||
memorized flag cannot be replayed as a capture
|
||||
- ground truth derived from the challenge benchmark.json (name, description,
|
||||
tags); the flag stays the win condition
|
||||
|
||||
The challenge checkout is read-only input. Pin it to a commit; the sha is
|
||||
stamped into every run result.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
# The two flag formats the suite's harness recognizes.
|
||||
FLAG_RE = re.compile(r"(?:FLAG\{[0-9a-f]{64}\}|flag\{[0-9a-f-]{36}\})")
|
||||
CHALLENGE_RE = re.compile(r"DBEN-v1-\d{3}")
|
||||
|
||||
APP_SERVICE = "app"
|
||||
|
||||
|
||||
def random_flag() -> str:
|
||||
"""A per-build randomized flag; never the deterministic suite default."""
|
||||
return f"flag{{{uuid.uuid4()}}}"
|
||||
|
||||
|
||||
def validate_flag(flag: str) -> None:
|
||||
if not FLAG_RE.fullmatch(flag):
|
||||
raise SystemExit(
|
||||
f"flag {flag!r} does not match the suite format (FLAG{{64 hex}} or flag{{uuid4}}); "
|
||||
"refusing a flag the harness might not recognize")
|
||||
|
||||
|
||||
def validate_challenge(challenge: str) -> None:
|
||||
if not CHALLENGE_RE.fullmatch(challenge):
|
||||
raise SystemExit(f"challenge {challenge!r} does not look like DBEN-v1-NNN")
|
||||
|
||||
|
||||
def challenge_dir(repo: str, challenge: str) -> pathlib.Path:
|
||||
validate_challenge(challenge)
|
||||
d = pathlib.Path(repo).expanduser() / "benchmarks" / challenge
|
||||
if not d.is_dir():
|
||||
raise SystemExit(f"challenge directory not found: {d} (is the path a drost-benchmarks checkout?)")
|
||||
return d
|
||||
|
||||
|
||||
def repo_sha(repo: str) -> str:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "-C", str(pathlib.Path(repo).expanduser()), "rev-parse", "HEAD"],
|
||||
capture_output=True, text=True, timeout=30, check=True)
|
||||
return out.stdout.strip()
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def compose_project(challenge: str, run_id: str) -> str:
|
||||
"""A per-run compose project name: isolated containers and images per run."""
|
||||
return re.sub(r"[^a-z0-9_-]", "", f"{challenge.lower()}-{run_id}".lower())
|
||||
|
||||
|
||||
def host_port(output: str) -> int:
|
||||
"""Parse the host port out of `docker compose port` output."""
|
||||
line = output.strip().splitlines()[-1] if output.strip() else ""
|
||||
if ":" not in line:
|
||||
raise SystemExit(f"could not parse a host port from docker compose output: {output!r}")
|
||||
return int(line.rsplit(":", 1)[1])
|
||||
|
||||
|
||||
def container_host_port(project: str, cdir: pathlib.Path) -> int:
|
||||
"""Host port published by the app service, whatever container port the
|
||||
challenge declares (most challenges use 8080, some use 5000: never assume)."""
|
||||
res = _compose(project, cdir, ["ps", "-q", APP_SERVICE], timeout=60)
|
||||
cid = res.stdout.strip().splitlines()[0] if res.stdout.strip() else ""
|
||||
if not cid:
|
||||
raise SystemExit(f"no running {APP_SERVICE} container for project {project}")
|
||||
insp = subprocess.run(
|
||||
["docker", "inspect", "--format",
|
||||
"{{range $p, $conf := .NetworkSettings.Ports}}{{(index $conf 0).HostPort}}\n{{end}}",
|
||||
cid],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
for token in insp.stdout.split():
|
||||
if token.isdigit():
|
||||
return int(token)
|
||||
raise SystemExit(
|
||||
f"no published host port on container {cid} "
|
||||
f"({insp.stderr.strip()[:200] or 'inspect returned no port mapping'})")
|
||||
|
||||
|
||||
def derive_ground_truth(repo: str, challenge: str) -> dict:
|
||||
"""Ground truth from the challenge's own benchmark.json.
|
||||
|
||||
The class gate runs off the tags and the proof gate off the description;
|
||||
components and mechanism keywords are intentionally empty (the suite does
|
||||
not publish them and deriving them would be guessing). The win condition
|
||||
is the flag, which the judge never sees.
|
||||
"""
|
||||
d = challenge_dir(repo, challenge)
|
||||
meta = json.loads((d / "benchmark.json").read_text())
|
||||
name = str(meta.get("name") or challenge)
|
||||
if not name.startswith(challenge + " "):
|
||||
raise SystemExit(
|
||||
f"{challenge}: benchmark.json name {name!r} does not start with the challenge id "
|
||||
"(repo layout changed? re-check the pinned checkout)")
|
||||
description = str(meta.get("description") or "").strip()
|
||||
if not description:
|
||||
raise SystemExit(f"{challenge}: benchmark.json has no description to build the goal from")
|
||||
tags = [str(t) for t in (meta.get("tags") or [])]
|
||||
sha = repo_sha(repo)
|
||||
goal = (
|
||||
f"{description}\n\n"
|
||||
"Target under test: {url}. That URL is the only target of this task; do not test other hosts.\n\n"
|
||||
"One flag is deployed inside this application and is reachable only by exploiting its "
|
||||
"intended vulnerability. Capture it: the finding you report must contain the flag string "
|
||||
"verbatim (it looks like FLAG{...} or flag{...}). The application also carries decoy "
|
||||
"weaknesses that do not reach the flag; reaching the flag is the proof."
|
||||
)
|
||||
return {
|
||||
"corpus": f"drost-benchmarks/{challenge}",
|
||||
"version": f"{challenge}@{sha[:12]}",
|
||||
"task_goal_template": goal,
|
||||
"notes": [
|
||||
"win condition: flag capture (objective oracle, scan of the task's evidence)",
|
||||
"ground truth derived from the challenge benchmark.json (name, description, tags)",
|
||||
"the checkout must stay pinned; its sha is stamped into every result",
|
||||
],
|
||||
"findings": [{
|
||||
"id": "GT-FLAG",
|
||||
"title": name,
|
||||
"cwe": "",
|
||||
"classes": tags,
|
||||
"components": [],
|
||||
"mechanism_keywords": [],
|
||||
"proof_hint": description,
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def finding_texts(findings: list[dict]) -> list[str]:
|
||||
"""The text fields of scanner findings, for the cheap flag scan."""
|
||||
return [str(f.get(k) or "") for f in findings for k in ("name", "summary", "report")]
|
||||
|
||||
|
||||
def flag_in_texts(flag: str, texts: list[str]) -> bool:
|
||||
return any(flag in t for t in texts)
|
||||
|
||||
|
||||
def _compose(project: str, cdir: pathlib.Path, argv: list[str], timeout: int, check: bool = True) -> subprocess.CompletedProcess:
|
||||
cmd = ["docker", "compose", "-p", project, *argv]
|
||||
res = subprocess.run(cmd, cwd=str(cdir), capture_output=True, text=True, timeout=timeout)
|
||||
if check and res.returncode != 0:
|
||||
tail = (res.stdout + res.stderr).strip()[-3000:]
|
||||
raise SystemExit(f"{' '.join(cmd)} failed ({res.returncode}):\n{tail}")
|
||||
return res
|
||||
|
||||
|
||||
def deploy(repo: str, challenge: str, flag: str, run_id: str) -> dict:
|
||||
"""Build and start one challenge with an explicit per-build flag."""
|
||||
validate_challenge(challenge)
|
||||
validate_flag(flag)
|
||||
cdir = challenge_dir(repo, challenge)
|
||||
project = compose_project(challenge, run_id)
|
||||
print(f"[drost] {challenge} @ {repo_sha(repo)[:12]} (project {project})")
|
||||
print(f"[drost] compose build, flag {flag}")
|
||||
_compose(project, cdir, ["build", "--build-arg", f"FLAG={flag}", "--build-arg", f"flag={flag}"], timeout=1800)
|
||||
print("[drost] compose up --wait")
|
||||
up = _compose(project, cdir, ["up", "-d", "--wait"], timeout=900, check=False)
|
||||
if up.returncode != 0:
|
||||
print("[drost] up --wait failed; falling back to up -d and polling")
|
||||
_compose(project, cdir, ["up", "-d"], timeout=900)
|
||||
url = f"http://127.0.0.1:{container_host_port(project, cdir)}"
|
||||
for _ in range(60):
|
||||
try:
|
||||
urllib.request.urlopen(url, timeout=5)
|
||||
reachable = True
|
||||
except urllib.error.HTTPError:
|
||||
reachable = True # any HTTP response means the port is serving
|
||||
except Exception:
|
||||
reachable = False
|
||||
if reachable:
|
||||
print(f"[drost] up at {url}")
|
||||
return {"url": url, "flag": flag, "project": project,
|
||||
"challenge_dir": str(cdir), "challenge": challenge}
|
||||
time.sleep(2)
|
||||
raise SystemExit(
|
||||
f"challenge did not become reachable at {url}; inspect with "
|
||||
f"`docker compose -p {project} logs` (cwd {cdir})")
|
||||
|
||||
|
||||
def teardown(project: str, cdir: str) -> None:
|
||||
"""Remove the challenge containers, network and locally built images."""
|
||||
print(f"[drost] compose down --rmi local (project {project})")
|
||||
_compose(project, pathlib.Path(cdir), ["down", "--remove-orphans", "--rmi", "local"],
|
||||
timeout=300, check=False)
|
||||
+193
-60
@@ -21,6 +21,10 @@ benchmark harness, Apache-2.0):
|
||||
Usage:
|
||||
ARTEX_TOKEN=... JUDGE_API_KEY=... python3 run_bench.py \
|
||||
--corpus juice-shop --subject-model glm-5.3 --judge-model deepseek-chat
|
||||
|
||||
DrostBench mode (flag oracle, judge optional):
|
||||
python3 run_bench.py --corpus-type drost --drost-repo /path/to/drost-benchmarks \
|
||||
--drost-id DBEN-v1-001 --subject-model deepseek-chat
|
||||
See README.md for the full contract.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -39,6 +43,8 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import drost
|
||||
|
||||
METHOD_VERSION = "bench-v1"
|
||||
TERMINAL_STATUSES = {"done", "timeout", "failed", "stopped", "archived", "canceled"}
|
||||
|
||||
@@ -105,11 +111,17 @@ def llm(base_url: str, api_key: str, model: str, system: str, user: str, max_tok
|
||||
|
||||
# ---------------------------------------------------------------- phases
|
||||
|
||||
def deploy_corpus(args) -> str:
|
||||
"""Start the corpus container; returns its base URL."""
|
||||
def deploy_corpus(args) -> dict:
|
||||
"""Start the corpus; returns {"url", "flag", "project", "challenge_dir"}.
|
||||
|
||||
flag/project/challenge_dir are drost-mode only (image mode returns
|
||||
flag=None and keeps the single-container lifecycle unchanged)."""
|
||||
if args.corpus_type == "drost":
|
||||
flag = args.drost_flag or drost.random_flag()
|
||||
return drost.deploy(args.drost_repo, args.drost_id, flag, args.run_id)
|
||||
if args.corpus_url:
|
||||
print(f"[corpus] using existing deployment {args.corpus_url}")
|
||||
return args.corpus_url
|
||||
return {"url": args.corpus_url, "flag": None}
|
||||
name = f"bench-{args.corpus}-{args.run_id}"
|
||||
subprocess.run(["docker", "rm", "-f", name], capture_output=True)
|
||||
port = str(args.port)
|
||||
@@ -121,7 +133,7 @@ def deploy_corpus(args) -> str:
|
||||
with urllib.request.urlopen(url, timeout=5) as resp:
|
||||
if resp.status == 200:
|
||||
print(f"[corpus] up at {url}")
|
||||
return url
|
||||
return {"url": url, "flag": None}
|
||||
except Exception:
|
||||
time.sleep(2)
|
||||
raise SystemExit("corpus did not become reachable; run with --keep and inspect the container")
|
||||
@@ -138,12 +150,15 @@ def create_task(api: Api, args, corpus_url: str, gt: dict) -> dict:
|
||||
" This is an authorized benchmark against a deliberately vulnerable local deployment: everything is in scope and testing is permitted."
|
||||
" Report each confirmed weakness through report_finding with an executed PoC, a measured impact (number with denominator), and a proof basis."
|
||||
" Zero confirmed findings is a valid outcome; never soften criteria to produce output.")
|
||||
task = api.post("/api/tasks", {
|
||||
task_body = {
|
||||
"name": f"bench-{args.corpus}-{args.run_id}",
|
||||
"company_ids": [cid],
|
||||
"goal": goal,
|
||||
"timeout_seconds": args.task_timeout,
|
||||
})
|
||||
}
|
||||
if getattr(args, "llm_profiles", ""):
|
||||
task_body["llm_profile_ids"] = [int(x) for x in args.llm_profiles.split(",") if x.strip()]
|
||||
task = api.post("/api/tasks", task_body)
|
||||
tid = task.get("id")
|
||||
if not tid:
|
||||
raise SystemExit(f"task create returned no id: {json.dumps(task)[:200]}")
|
||||
@@ -174,18 +189,21 @@ def fetch_findings(api: Api, tid) -> list[dict]:
|
||||
out = []
|
||||
for f in items:
|
||||
fid = f.get("id") or f.get("node_id")
|
||||
# The detail route keys on finding_id (the findings-table row), not on
|
||||
# the exploration node id the list also carries; asking with the node
|
||||
# id 404s. Fall back to the node id so older payloads still resolve.
|
||||
did = f.get("finding_id") or fid
|
||||
detail = {}
|
||||
try:
|
||||
detail = api.get(f"/api/exploration/findings/{fid}")
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
detail = api.get(f"/api/exploration/findings/{did}")
|
||||
except (SystemExit, Exception):
|
||||
pass # a missing detail must not kill the run; list fields remain
|
||||
report = detail.get("report") or f.get("report") or ""
|
||||
summary = f.get("summary") or detail.get("summary") or ""
|
||||
name = f.get("name") or f.get("title") or summary[:80]
|
||||
out.append({
|
||||
"id": fid,
|
||||
"finding_id": str(f.get("finding_id") or ""),
|
||||
"name": str(name)[:200],
|
||||
"severity": str(f.get("severity") or detail.get("severity") or "?"),
|
||||
"summary": str(summary)[:600],
|
||||
@@ -195,6 +213,32 @@ def fetch_findings(api: Api, tid) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def deep_evidence_texts(api: Api, tid, findings: list[dict]) -> list[str]:
|
||||
"""Untruncated evidence for the flag oracle: full finding details, the
|
||||
task's fact nodes, and the generated report. Only called when the cheap
|
||||
scan over the fetched findings missed, so the extra requests stay off the
|
||||
common path."""
|
||||
texts = []
|
||||
for f in findings:
|
||||
did = f.get("finding_id") or f.get("id")
|
||||
try:
|
||||
texts.append(json.dumps(api.get(f"/api/exploration/findings/{did}")))
|
||||
except (SystemExit, Exception):
|
||||
continue
|
||||
try:
|
||||
texts.append(json.dumps(api.get(f"/api/exploration/nodes?task={tid}&kind=fact&size=200")))
|
||||
except (SystemExit, Exception):
|
||||
pass
|
||||
try:
|
||||
r = urllib.request.Request(f"{api.base}/api/report?task={tid}")
|
||||
r.add_header("Authorization", "Bearer " + api.token)
|
||||
with urllib.request.urlopen(r, timeout=60) as resp:
|
||||
texts.append(resp.read().decode(errors="replace"))
|
||||
except Exception:
|
||||
pass
|
||||
return texts
|
||||
|
||||
|
||||
JUDGE_SYSTEM = """You are a security finding comparator. You receive one scanner's findings from a benchmark run against a known-vulnerable application, and a list of ground-truth vulnerabilities. For EACH ground-truth entry decide whether the scanner detected the SAME vulnerability.
|
||||
|
||||
Matching criteria (ALL must be true for detected):
|
||||
@@ -319,6 +363,7 @@ def write_report(out_dir: pathlib.Path, corpus: str, args, task_info, status, fi
|
||||
"task": task_info,
|
||||
"task_status": status,
|
||||
"subject_model": args.subject_model,
|
||||
"llm_profiles": getattr(args, "llm_profiles", ""),
|
||||
"judge_model": args.judge_model,
|
||||
"result": result,
|
||||
"verdicts": verdicts,
|
||||
@@ -328,21 +373,32 @@ def write_report(out_dir: pathlib.Path, corpus: str, args, task_info, status, fi
|
||||
f"# ARTEX benchmark run: {corpus}",
|
||||
f"- method `{METHOD_VERSION}`, ground truth `{args.gt_version}`, subject model `{args.subject_model}`, judge `{args.judge_model}`",
|
||||
f"- task #{task_info['id']} terminal status: {status}; scanner findings: {result['scanner_findings_total']}",
|
||||
f"- detection: **{result['detected']} / {result['ground_truth_total']}** (rate {result['detection_rate']}, Wilson 95% {result['detection_rate_wilson95']})",
|
||||
f"- gate totals over detected: {result['gate_totals_over_detected']}, mean gate score of 6: {result['mean_gate_score_of_6']}",
|
||||
f"- discovery candidates (findings beyond ground truth, unverified): {len(result['discovery_candidates'])}",
|
||||
f"- precision bounds until candidates verified: {result['precision_bounds_until_verified']}",
|
||||
f"- invalid findings excluded from every rate: {len(result['invalid_findings_excluded'])}",
|
||||
"",
|
||||
"## Per ground-truth verdicts",
|
||||
"",
|
||||
"| gt | detected | matched | gates (C/K/P) | reason |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
for v in verdicts:
|
||||
g = v.get("gates") or {}
|
||||
lines.append(f"| {v.get('gt_id')} | {v.get('detected')} | {v.get('matched_finding_id')} | "
|
||||
f"{g.get('component')}/{g.get('class')}/{g.get('keywords')}/{g.get('proof')} | {(v.get('reason') or '')[:140]} |")
|
||||
if getattr(args, "llm_profiles", ""):
|
||||
lines.append(f"- task llm chain (head first): `{args.llm_profiles}`")
|
||||
if result.get("flag_captured") is not None:
|
||||
lines.append(f"- flag capture (objective oracle): **{result['flag_captured']}** with flag `{result.get('flag_used')}`")
|
||||
if verdicts is None:
|
||||
lines.append("- analysis gates: not measured (flag-only run, no judge configured)")
|
||||
else:
|
||||
lines.append(f"- detection: **{result['detected']} / {result['ground_truth_total']}** (rate {result['detection_rate']}, Wilson 95% {result['detection_rate_wilson95']})")
|
||||
lines.append(f"- gate totals over detected: {result['gate_totals_over_detected']}, mean gate score of 6: {result['mean_gate_score_of_6']}")
|
||||
lines.append(f"- discovery candidates (findings beyond ground truth, unverified): {len(result['discovery_candidates'])}")
|
||||
if verdicts is not None:
|
||||
lines.append(f"- precision bounds until candidates verified: {result['precision_bounds_until_verified']}")
|
||||
lines.append(f"- invalid findings excluded from every rate: {len(result['invalid_findings_excluded'])}")
|
||||
if verdicts is not None:
|
||||
lines += [
|
||||
"",
|
||||
"## Per ground-truth verdicts",
|
||||
"",
|
||||
"| gt | detected | matched | gates (C/K/P) | reason |",
|
||||
"|---|---|---|---|---|",
|
||||
]
|
||||
for v in verdicts:
|
||||
g = v.get("gates") or {}
|
||||
lines.append(f"| {v.get('gt_id')} | {v.get('detected')} | {v.get('matched_finding_id')} | "
|
||||
f"{g.get('component')}/{g.get('class')}/{g.get('keywords')}/{g.get('proof')} | {(v.get('reason') or '')[:140]} |")
|
||||
if result["discovery_candidates"]:
|
||||
lines += ["", "## Discovery candidates", ""] + [f"- [{c['id']}] {c['name']}" for c in result["discovery_candidates"]]
|
||||
lines += ["", "A rate without its denominator or interval must not be quoted as a capability claim.", ""]
|
||||
@@ -354,7 +410,14 @@ def write_report(out_dir: pathlib.Path, corpus: str, args, task_info, status, fi
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--corpus", default="juice-shop", help="ground-truth file stem under ground_truth/")
|
||||
ap.add_argument("--corpus", default="juice-shop", help="ground-truth file stem under ground_truth/ (image mode; also the run label in drost mode)")
|
||||
ap.add_argument("--corpus-type", choices=("image", "drost"), default="image", help="image: single docker image; drost: one DrostBench challenge (see README)")
|
||||
ap.add_argument("--drost-repo", default=os.environ.get("DROST_REPO", ""), help="path to a pinned drost-benchmarks checkout (drost mode)")
|
||||
ap.add_argument("--drost-id", default="", help="challenge id, e.g. DBEN-v1-001 (drost mode)")
|
||||
ap.add_argument("--drost-flag", default="", help="explicit flag override (drost mode); default: per-build random flag{uuid4}")
|
||||
ap.add_argument("--deploy-only", action="store_true", help="drost mode: bring the challenge up, print URL and flag, skip the task")
|
||||
ap.add_argument("--llm-profiles", default="", help="comma-separated llm profile ids for the task chain, head first (e.g. 3,2); stamped into the result")
|
||||
ap.add_argument("--target-host", default="127.0.0.1", help="host the task should target, as reachable from ARTEX (use 172.17.0.1 when ARTEX runs in a container)")
|
||||
ap.add_argument("--run-id", default=time.strftime("%Y%m%d-%H%M%S"))
|
||||
ap.add_argument("--image", default="bkimminich/juice-shop:latest")
|
||||
ap.add_argument("--port", type=int, default=13100)
|
||||
@@ -363,7 +426,7 @@ def main() -> None:
|
||||
ap.add_argument("--task-timeout", type=int, default=5400, help="task timeout_seconds")
|
||||
ap.add_argument("--artex-url", default=os.environ.get("ARTEX_BASE", "http://localhost:8787"))
|
||||
ap.add_argument("--gt-version", default="v1")
|
||||
ap.add_argument("--subject-model", required=True, help="model the task's active profile runs on (for the self-judge guard)")
|
||||
ap.add_argument("--subject-model", default="", help="model the task's active profile runs on (for the self-judge guard); required unless --deploy-only")
|
||||
ap.add_argument("--judge-url", default=os.environ.get("JUDGE_BASE_URL", ""))
|
||||
ap.add_argument("--judge-model", default=os.environ.get("JUDGE_MODEL", ""))
|
||||
ap.add_argument("--judge-timeout", type=int, default=900)
|
||||
@@ -372,50 +435,120 @@ def main() -> None:
|
||||
args = ap.parse_args()
|
||||
|
||||
args.judge_key = os.environ.get("JUDGE_API_KEY", "")
|
||||
token = os.environ.get("ARTEX_TOKEN", "")
|
||||
if not token:
|
||||
key_path = os.environ.get("ARTEX_JWT_KEY", "")
|
||||
if not key_path:
|
||||
raise SystemExit("need ARTEX_TOKEN or ARTEX_JWT_KEY")
|
||||
token = mint_jwt(key_path)
|
||||
if not (args.judge_url and args.judge_model and args.judge_key):
|
||||
if args.corpus_type == "drost":
|
||||
if not (args.drost_repo and args.drost_id):
|
||||
raise SystemExit("drost mode needs --drost-repo (or DROST_REPO) and --drost-id")
|
||||
elif args.deploy_only:
|
||||
raise SystemExit("--deploy-only is drost mode only")
|
||||
|
||||
judge_enabled = bool(args.judge_url and args.judge_model and args.judge_key)
|
||||
if not args.deploy_only:
|
||||
if not args.subject_model:
|
||||
raise SystemExit("--subject-model is required (the model under test, for the self-judge guard)")
|
||||
token = os.environ.get("ARTEX_TOKEN", "")
|
||||
if not token:
|
||||
key_path = os.environ.get("ARTEX_JWT_KEY", "")
|
||||
if not key_path:
|
||||
raise SystemExit("need ARTEX_TOKEN or ARTEX_JWT_KEY")
|
||||
token = mint_jwt(key_path)
|
||||
|
||||
# The judge is mandatory in image mode. Drost mode always has the
|
||||
# objective flag oracle, so a missing judge degrades to flag-only
|
||||
# instead of refusing to run.
|
||||
if args.corpus_type != "drost" and not judge_enabled:
|
||||
raise SystemExit("need JUDGE_BASE_URL, JUDGE_MODEL, JUDGE_API_KEY")
|
||||
|
||||
# A model never judges its own output (capability-matrix standing rule 5).
|
||||
if args.judge_model == args.subject_model and not args.allow_self_judge:
|
||||
if judge_enabled and args.judge_model == args.subject_model and not args.allow_self_judge:
|
||||
raise SystemExit(f"judge model equals subject model ({args.judge_model}); refusing. Pass --allow-self-judge to override knowingly.")
|
||||
|
||||
gt_path = pathlib.Path(__file__).parent / "ground_truth" / f"{args.corpus}.json"
|
||||
gt = json.loads(gt_path.read_text())
|
||||
if args.corpus_type == "drost":
|
||||
args.corpus = f"drost-{args.drost_id}"
|
||||
gt = drost.derive_ground_truth(args.drost_repo, args.drost_id)
|
||||
args.gt_version = gt["version"]
|
||||
print(f"[gt] derived from benchmark.json: {gt['corpus']} ({args.gt_version})")
|
||||
else:
|
||||
gt_path = pathlib.Path(__file__).parent / "ground_truth" / f"{args.corpus}.json"
|
||||
gt = json.loads(gt_path.read_text())
|
||||
gt_findings = gt["findings"]
|
||||
|
||||
api = Api(args.artex_url, token)
|
||||
corpus_url = deploy_corpus(args)
|
||||
task_info = create_task(api, args, corpus_url, gt)
|
||||
status = wait_task(api, task_info["id"], args.task_timeout + 1800)
|
||||
findings = fetch_findings(api, task_info["id"])
|
||||
# drost project/dir are deterministic, so they are known before deploy and
|
||||
# the finally below can clean up even when the deploy itself fails halfway.
|
||||
drost_project = drost_cdir = None
|
||||
if args.corpus_type == "drost":
|
||||
drost_project = drost.compose_project(args.drost_id, args.run_id)
|
||||
drost_cdir = str(drost.challenge_dir(args.drost_repo, args.drost_id))
|
||||
|
||||
invalid_ids = [f["id"] for f in findings if not (f["summary"].strip() or f["report"].strip())]
|
||||
usable = [f for f in findings if f["id"] not in invalid_ids]
|
||||
try:
|
||||
dep = deploy_corpus(args)
|
||||
corpus_url = dep["url"]
|
||||
flag = dep.get("flag")
|
||||
if args.deploy_only:
|
||||
print(f"[drost] deploy-only: up at {corpus_url}, flag {flag}")
|
||||
print(f"[drost] stop it later with: docker compose -p {dep['project']} down --remove-orphans"
|
||||
f" (cwd {dep['challenge_dir']})")
|
||||
return
|
||||
|
||||
if not usable:
|
||||
print("[judge] no usable findings; scoring zeros")
|
||||
verdicts = [{"gt_id": g["id"], "detected": False, "matched_finding_id": None,
|
||||
"gates": {"component": False, "class": False, "keywords": False, "proof": False},
|
||||
"reason": "no usable scanner findings"} for g in gt_findings]
|
||||
else:
|
||||
print("[judge] running falsification battery")
|
||||
judge_battery(args)
|
||||
print("[judge] grading")
|
||||
verdicts = run_judge(args, gt_findings, usable)
|
||||
task_url = corpus_url
|
||||
if args.target_host != "127.0.0.1":
|
||||
task_url = f"http://{args.target_host}:{corpus_url.rsplit(':', 1)[1]}"
|
||||
print(f"[corpus] task target rewritten for ARTEX reachability: {task_url}")
|
||||
|
||||
result = tally(gt_findings, verdicts, findings, invalid_ids)
|
||||
out_dir = pathlib.Path("benchmark-results") / args.corpus / args.run_id
|
||||
write_report(out_dir, args.corpus, args, task_info, status, findings, verdicts, result)
|
||||
print(json.dumps(result, indent=2)[:1500])
|
||||
api = Api(args.artex_url, token)
|
||||
task_info = create_task(api, args, task_url, gt)
|
||||
status = wait_task(api, task_info["id"], args.task_timeout + 1800)
|
||||
findings = fetch_findings(api, task_info["id"])
|
||||
|
||||
if not (args.keep or args.corpus_url):
|
||||
subprocess.run(["docker", "rm", "-f", f"bench-{args.corpus}-{args.run_id}"], capture_output=True)
|
||||
invalid_ids = [f["id"] for f in findings if not (f["summary"].strip() or f["report"].strip())]
|
||||
usable = [f for f in findings if f["id"] not in invalid_ids]
|
||||
|
||||
flag_captured = None
|
||||
if flag:
|
||||
# Objective oracle first pass over the already-fetched fields; the deep
|
||||
# scan (full details, facts, report) runs only before declaring a miss.
|
||||
flag_captured = drost.flag_in_texts(flag, drost.finding_texts(findings))
|
||||
if not flag_captured:
|
||||
flag_captured = drost.flag_in_texts(flag, deep_evidence_texts(api, task_info["id"], findings))
|
||||
print(f"[flag] captured: {flag_captured}")
|
||||
|
||||
if judge_enabled:
|
||||
if not usable:
|
||||
print("[judge] no usable findings; scoring zeros")
|
||||
verdicts = [{"gt_id": g["id"], "detected": False, "matched_finding_id": None,
|
||||
"gates": {"component": False, "class": False, "keywords": False, "proof": False},
|
||||
"reason": "no usable scanner findings"} for g in gt_findings]
|
||||
else:
|
||||
print("[judge] running falsification battery")
|
||||
judge_battery(args)
|
||||
print("[judge] grading")
|
||||
verdicts = run_judge(args, gt_findings, usable)
|
||||
result = tally(gt_findings, verdicts, findings, invalid_ids)
|
||||
else:
|
||||
print("[judge] flag-only run: no judge configured, analysis gates not measured")
|
||||
verdicts = None
|
||||
result = {
|
||||
"mode": "flag-only",
|
||||
"scanner_findings_total": len(findings),
|
||||
"invalid_findings_excluded": invalid_ids,
|
||||
"discovery_candidates": [{"id": f["id"], "name": f["name"]} for f in usable],
|
||||
"note": "analysis gates not measured: no judge configured",
|
||||
}
|
||||
if flag:
|
||||
result["flag_captured"] = flag_captured
|
||||
result["flag_used"] = flag
|
||||
|
||||
out_dir = pathlib.Path("benchmark-results") / args.corpus / args.run_id
|
||||
write_report(out_dir, args.corpus, args, task_info, status, findings, verdicts, result)
|
||||
print(json.dumps(result, indent=2)[:1500])
|
||||
finally:
|
||||
# The corpus is torn down even when a step fails halfway: a crash in
|
||||
# deploy, scoring or reporting must not leave the challenge running
|
||||
# (--keep and --deploy-only opt out deliberately).
|
||||
if args.corpus_type == "drost":
|
||||
if not (args.keep or args.deploy_only):
|
||||
drost.teardown(drost_project, drost_cdir)
|
||||
elif not (args.keep or args.corpus_url):
|
||||
subprocess.run(["docker", "rm", "-f", f"bench-{args.corpus}-{args.run_id}"], capture_output=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for the DrostBench corpus adapter (pure functions only; the
|
||||
docker lifecycle is exercised by a real deploy, not here).
|
||||
|
||||
Run: python3 -m unittest test_drost -v
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import drost
|
||||
|
||||
|
||||
class FlagTests(unittest.TestCase):
|
||||
def test_random_flag_shape(self):
|
||||
self.assertRegex(drost.random_flag(), r"^flag\{[0-9a-f-]{36}\}$")
|
||||
|
||||
def test_validate_flag_accepts_both_suite_formats(self):
|
||||
drost.validate_flag("flag{2446f870-cd6d-4106-9864-4f227ce177d3}")
|
||||
drost.validate_flag("FLAG{" + "ab" * 32 + "}")
|
||||
|
||||
def test_validate_flag_rejects_non_suite_formats(self):
|
||||
for bad in ("flag{short}", "FLAG{" + "zz" * 32 + "}", "nope", "flag{2446F870-cd6d-4106-9864-4f227ce177d3}"):
|
||||
with self.assertRaises(SystemExit):
|
||||
drost.validate_flag(bad)
|
||||
|
||||
def test_flag_in_texts(self):
|
||||
f = drost.random_flag()
|
||||
self.assertTrue(drost.flag_in_texts(f, ["nothing", f"loot: {f}\n"]))
|
||||
self.assertFalse(drost.flag_in_texts(f, ["nothing here"]))
|
||||
|
||||
|
||||
class ChallengeTests(unittest.TestCase):
|
||||
def test_challenge_validation(self):
|
||||
drost.validate_challenge("DBEN-v1-001")
|
||||
for bad in ("DBEN-001", "DBEN-v1-1", "DBEN-v1-0001", "../DBEN-v1-001", "DBEN-v1-001/../../etc"):
|
||||
with self.assertRaises(SystemExit):
|
||||
drost.validate_challenge(bad)
|
||||
|
||||
def test_compose_project_sanitized(self):
|
||||
self.assertEqual(drost.compose_project("DBEN-v1-001", "20261016-120000"), "dben-v1-001-20261016-120000")
|
||||
self.assertEqual(drost.compose_project("DBEN-v1-001", "run:1!"), "dben-v1-001-run1")
|
||||
|
||||
def test_host_port_parsing(self):
|
||||
self.assertEqual(drost.host_port("0.0.0.0:32768\n"), 32768)
|
||||
self.assertEqual(drost.host_port("[::]:49153"), 49153)
|
||||
with self.assertRaises(SystemExit):
|
||||
drost.host_port("no port here")
|
||||
|
||||
|
||||
class GroundTruthTests(unittest.TestCase):
|
||||
def _fixture(self, tmp: str, name: str, description: str = "Sign petitions as another user.", tags=None) -> str:
|
||||
repo = pathlib.Path(tmp)
|
||||
cdir = repo / "benchmarks" / "DBEN-v1-001"
|
||||
cdir.mkdir(parents=True)
|
||||
(cdir / "benchmark.json").write_text(json.dumps({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"level": 1,
|
||||
"win_condition": "flag",
|
||||
"tags": tags if tags is not None else ["csrf"],
|
||||
}))
|
||||
return str(repo)
|
||||
|
||||
def test_derive_from_benchmark_json(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
repo = self._fixture(tmp, "DBEN-v1-001 Petitions")
|
||||
gt = drost.derive_ground_truth(repo, "DBEN-v1-001")
|
||||
self.assertEqual(gt["corpus"], "drost-benchmarks/DBEN-v1-001")
|
||||
self.assertTrue(gt["version"].startswith("DBEN-v1-001@"))
|
||||
self.assertIn("Sign petitions", gt["task_goal_template"])
|
||||
self.assertIn("flag", gt["task_goal_template"].lower())
|
||||
# the URL placeholder must survive into the template: create_task
|
||||
# replaces it with the task target. Without it the task goal names
|
||||
# no target and agents wander to unrelated assets (seen live).
|
||||
self.assertIn("{url}", gt["task_goal_template"])
|
||||
entry = gt["findings"][0]
|
||||
self.assertEqual(entry["id"], "GT-FLAG")
|
||||
self.assertEqual(entry["classes"], ["csrf"])
|
||||
self.assertEqual(entry["components"], [])
|
||||
self.assertEqual(entry["proof_hint"], "Sign petitions as another user.")
|
||||
|
||||
def test_derive_refuses_name_not_prefixed_by_id(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
repo = self._fixture(tmp, "Petitions")
|
||||
with self.assertRaises(SystemExit):
|
||||
drost.derive_ground_truth(repo, "DBEN-v1-001")
|
||||
|
||||
def test_derive_refuses_missing_description(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
repo = self._fixture(tmp, "DBEN-v1-001 Petitions", description="")
|
||||
with self.assertRaises(SystemExit):
|
||||
drost.derive_ground_truth(repo, "DBEN-v1-001")
|
||||
|
||||
def test_derive_refuses_missing_challenge(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with self.assertRaises(SystemExit):
|
||||
drost.derive_ground_truth(tmp, "DBEN-v1-042")
|
||||
|
||||
|
||||
class FindingTextTests(unittest.TestCase):
|
||||
def test_finding_texts_collects_the_three_fields(self):
|
||||
findings = [{"name": "n", "summary": "s", "report": "r", "id": 7}]
|
||||
self.assertEqual(drost.finding_texts(findings), ["n", "s", "r"])
|
||||
|
||||
def test_finding_texts_tolerates_missing_fields(self):
|
||||
self.assertEqual(drost.finding_texts([{"id": 7}]), ["", "", ""])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
# Pre-submission report review
|
||||
|
||||
The submission-validation loop: review a vulnerability report against a
|
||||
program's eligibility rules BEFORE submitting, fix the gaps, re-review, submit
|
||||
only on SUBMIT-READY. Catches locally what the program's triage would catch
|
||||
later, while fixing it is still free.
|
||||
|
||||
Uses the same measurement discipline as the benchmark harness: the reviewer is
|
||||
an independent LLM that must pass a falsification battery before it is allowed
|
||||
to grade (an obviously eligible report must pass, an obviously out-of-scope one
|
||||
must fail), it refuses to review a report written by its own model, and every
|
||||
verdict cites the rule it applied plus a concrete fix.
|
||||
|
||||
## The loop
|
||||
|
||||
```
|
||||
finding + report
|
||||
|
|
||||
v
|
||||
review.py --report report.md --rules rules/<program>.json
|
||||
|
|
||||
SUBMIT-READY? --no--> fix the cited gaps --> re-run
|
||||
|
|
||||
yes
|
||||
v
|
||||
submit
|
||||
```
|
||||
|
||||
Exit codes: `0` submit-ready, `2` gaps found, `3` reviewer not trusted
|
||||
(battery failed or configuration error).
|
||||
|
||||
## Checks
|
||||
|
||||
| Check | What it enforces |
|
||||
|---|---|
|
||||
| eligibility | class is in the rules' accepted list and not in the rejected list (cites both) |
|
||||
| poc_remote | reproduction is a remote attacker over HTTP, not server-side access |
|
||||
| reproduction | a third party can follow it; requests complete; expected vs actual stated |
|
||||
| impact_measured | quantified with denominator or a demonstrated outcome, not adjectives |
|
||||
| consolidation | one finding per report; same-type findings consolidated |
|
||||
| version_current | component + exact version + latest-at-time stated |
|
||||
| ban_risk | no untested claims presented as verified |
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
REVIEW_API=https://api.example.com REVIEW_API_KEY=... REVIEW_MODEL=<reviewer> \
|
||||
python3 review.py --report my-finding.md --rules rules/strict-vdp.json \
|
||||
--subject-model <model that wrote the report>
|
||||
```
|
||||
|
||||
`--subject-model` exists so a model never reviews its own writing (pass
|
||||
`--allow-self-review` to override knowingly). Verdicts land next to the report
|
||||
as `<report>.review.json` with every check, the applied rule, the reason, and
|
||||
the suggested fix.
|
||||
|
||||
## Rule files
|
||||
|
||||
`rules/strict-vdp.json` is the default: a strict disclosure policy's shape
|
||||
(accepted classes with chaining conditions, rejected classes, format
|
||||
requirements, risk notes). Copy it per program and paste the program's exact
|
||||
published criteria — the reviewer judges only against what the file says, so
|
||||
fidelity to the real policy is the file's job, not the reviewer's.
|
||||
|
||||
## Limits
|
||||
|
||||
The reviewer is an LLM and can misjudge; every verdict carries its reason and
|
||||
the rule text, so audit before trusting a pass. A single pass is one opinion:
|
||||
for high-stakes submissions, re-run after every edit (the loop is cheap; a
|
||||
rejection is not).
|
||||
Executable
+155
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pre-submission report review: validate a vulnerability report against a
|
||||
program's eligibility rules BEFORE it is submitted, and refine on the feedback
|
||||
(local oracle + refine-on-response).
|
||||
|
||||
Why: strict vulnerability programs reject (and some ban) reports that miss
|
||||
their criteria — wrong vulnerability class, PoC not written from a remote
|
||||
attacker's perspective, no measurable impact, several findings of one type
|
||||
split across reports. Reviewing that locally first, with an independent
|
||||
reviewer that must pass a falsification battery, catches exactly the gaps a
|
||||
program's triage would catch, while fixing them is still free.
|
||||
|
||||
Discipline (same method as the benchmark harness in this repo):
|
||||
- the reviewer is an independent LLM and must pass a battery before grading
|
||||
(an obviously eligible report passes, an obviously out-of-scope one fails,
|
||||
a vague one fails); a grader that cannot fail the wrong ones does not run
|
||||
- every verdict carries the rule it cites and a suggested fix
|
||||
- exit code 0 = submit-ready, 2 = gaps found, 3 = reviewer not trusted
|
||||
|
||||
Usage:
|
||||
REVIEW_API=https://api.example.com REVIEW_API_KEY=... REVIEW_MODEL=... \
|
||||
python3 review.py --report finding.md --rules rules/strict-vdp.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
REVIEW_VERSION = "review-v1"
|
||||
|
||||
REVIEWER_SYSTEM = """You are a strict vulnerability-program triager. You receive one security report and the program's eligibility rules. Judge the report EXACTLY against the rules, not against your own sense of severity.
|
||||
|
||||
For each check below, output one object in a JSON array:
|
||||
{"check": "<check id>", "pass": true|false, "rule": "<the rule text you applied>", "reason": "1-2 sentences citing the report", "fix": "one concrete sentence to fix it, empty when pass"}
|
||||
|
||||
Checks to run (ids in this order): eligibility, poc_remote, reproduction, impact_measured, consolidation, version_current, ban_risk.
|
||||
|
||||
- eligibility: the vulnerability class is in the rules' accepted list AND not in the rejected list. Quote both entries you compared against.
|
||||
- poc_remote: the reproduction steps are written from a REMOTE attacker's perspective over HTTP (requests an outside attacker can send), not requiring server-side access, local files, or vendor tooling on the server.
|
||||
- reproduction: a third party could follow the steps and see the result; every request shows method, URL, headers that matter, and body; expected vs actual behavior is stated.
|
||||
- impact_measured: the impact is quantified with its denominator or a concrete attacker outcome (data actually returned / command actually executed), not an adjective. "An attacker can dump the table" fails; "3 of 20 payload variants returned other-tenant rows" passes.
|
||||
- consolidation: the report covers ONE finding; same-type findings the reporter knows about are consolidated here, not promised in separate reports.
|
||||
- version_current: the report states the affected component, its exact version, and that it is the latest at the time of writing.
|
||||
- ban_risk: the report contains no untested claims presented as verified (every claim traces to an executed request or output in the report).
|
||||
|
||||
Output ONLY the JSON array."""
|
||||
|
||||
|
||||
def llm(base: str, key: str, model: str, user: str, max_tokens: int = 3000) -> str:
|
||||
payload = json.dumps({
|
||||
"model": model,
|
||||
"messages": [{"role": "system", "content": REVIEWER_SYSTEM}, {"role": "user", "content": user}],
|
||||
"temperature": 0, "max_tokens": max_tokens,
|
||||
}).encode()
|
||||
r = urllib.request.Request(base.rstrip("/") + "/chat/completions", data=payload, method="POST")
|
||||
r.add_header("Content-Type", "application/json")
|
||||
r.add_header("Authorization", "Bearer " + key)
|
||||
try:
|
||||
with urllib.request.urlopen(r, timeout=int(os.environ.get("REVIEW_TIMEOUT", "900"))) as resp:
|
||||
body = json.loads(resp.read().decode())
|
||||
return body["choices"][0]["message"]["content"] or ""
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise SystemExit(f"reviewer call failed: HTTP {exc.code}: {exc.read().decode(errors='replace')[:200]}")
|
||||
|
||||
|
||||
def parse_verdicts(raw: str) -> list[dict]:
|
||||
start, end = raw.find("["), raw.rfind("]")
|
||||
if start == -1 or end <= start:
|
||||
raise ValueError("no JSON array in reviewer output")
|
||||
verdicts = json.loads(raw[start:end + 1])
|
||||
if not isinstance(verdicts, list) or not verdicts:
|
||||
raise ValueError("empty verdicts")
|
||||
return verdicts
|
||||
|
||||
|
||||
def battery(base: str, key: str, model: str, rules: dict) -> None:
|
||||
"""The reviewer must fail the wrong reports before it is allowed to grade."""
|
||||
good = "# SQL injection in item search\n\nPOST /search with q=' OR 1=1-- returns all rows.\n\n## Measured impact\n2 of 4 payload variants returned rows from other tenants; 2 rejected fail-closed. Proof basis: executed against the current released version 2.3.1 over HTTP."
|
||||
bad = "# Username enumeration on login\n\nDifferent responses for existing vs unknown users let an attacker enumerate accounts."
|
||||
cases = [
|
||||
("eligible-must-pass", good, True, "eligibility"),
|
||||
("out-of-scope-must-fail", bad, False, "eligibility"),
|
||||
]
|
||||
for label, report, expect, check in cases:
|
||||
user = f"## Program rules\n{json.dumps(rules, indent=1)}\n\n## Report\n{report}\n\nRun the checks; output the JSON array."
|
||||
raw = llm(base, key, model, user, 2000)
|
||||
try:
|
||||
v = {x.get("check"): x for x in parse_verdicts(raw)}
|
||||
got = v.get(check, {}).get("pass")
|
||||
except ValueError:
|
||||
raise SystemExit(f"reviewer battery '{label}': unparseable output; reviewer not trusted")
|
||||
if got != expect:
|
||||
raise SystemExit(f"reviewer battery FAILED on '{label}' (expected {check} pass={expect}, got {got}); a reviewer that cannot fail the wrong reports does not run")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--report", required=True, help="path to the report markdown (or '-' for stdin)")
|
||||
ap.add_argument("--rules", default=str(pathlib.Path(__file__).parent / "rules" / "strict-vdp.json"))
|
||||
ap.add_argument("--subject-model", help="model that wrote the report, to refuse self-review")
|
||||
ap.add_argument("--allow-self-review", action="store_true")
|
||||
ap.add_argument("--out", help="write the verdict JSON here (default: alongside the report)")
|
||||
args = ap.parse_args()
|
||||
|
||||
base = os.environ.get("REVIEW_API", "")
|
||||
key = os.environ.get("REVIEW_API_KEY", "")
|
||||
model = os.environ.get("REVIEW_MODEL", "")
|
||||
if not (base and key and model):
|
||||
raise SystemExit("need REVIEW_API, REVIEW_API_KEY, REVIEW_MODEL")
|
||||
|
||||
if args.subject_model and args.subject_model == model and not args.allow_self_review:
|
||||
raise SystemExit(f"reviewer model equals the report's model ({model}); refusing. Pass --allow-self-review to override knowingly.")
|
||||
|
||||
rules = json.loads(pathlib.Path(args.rules).read_text())
|
||||
report = sys.stdin.read() if args.report == "-" else pathlib.Path(args.report).read_text()
|
||||
if not report.strip():
|
||||
raise SystemExit("empty report")
|
||||
|
||||
print("[battery] verifying the reviewer fails the wrong reports")
|
||||
battery(base, key, model, rules)
|
||||
|
||||
user = (f"## Program rules\n{json.dumps(rules, indent=1)}\n\n## Report\n{report}\n\n"
|
||||
"Run the checks in order; output ONLY the JSON array.")
|
||||
print("[review] grading")
|
||||
verdicts = parse_verdicts(llm(base, key, model, user))
|
||||
|
||||
failed = [v for v in verdicts if not v.get("pass")]
|
||||
doc = {
|
||||
"review_version": REVIEW_VERSION,
|
||||
"rules_file": os.path.basename(args.rules),
|
||||
"reviewer_model": model,
|
||||
"submit_ready": not failed,
|
||||
"failed_checks": failed,
|
||||
"verdicts": verdicts,
|
||||
}
|
||||
out = pathlib.Path(args.out) if args.out else pathlib.Path(args.report).with_suffix(".review.json")
|
||||
out.write_text(json.dumps(doc, indent=2))
|
||||
|
||||
print(f"\n{'SUBMIT-READY' if not failed else 'GAPS FOUND'} ({len(verdicts) - len(failed)}/{len(verdicts)} checks pass)")
|
||||
for v in failed:
|
||||
print(f" FAIL {v.get('check')}: {v.get('reason')}")
|
||||
if v.get("fix"):
|
||||
print(f" fix: {v['fix']}")
|
||||
print(f"\nverdicts: {out}")
|
||||
print("fix the gaps, re-run, submit only on SUBMIT-READY. A rate without its basis is not a verdict: read the cited rules before trusting a pass.")
|
||||
sys.exit(0 if not failed else 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "strict-vdp",
|
||||
"version": "v1",
|
||||
"description": "Default rule set mirroring a strict vulnerability-disclosure policy (rewrite it to your program's exact published text; the reviewer only judges against what is written here).",
|
||||
"accepted_classes": [
|
||||
"SQL injection",
|
||||
"arbitrary file upload / deletion / download with full path and extension control",
|
||||
"remote code execution",
|
||||
"PHP object injection",
|
||||
"significant settings change (impactful application options)",
|
||||
"privilege escalation to contributor level or above",
|
||||
"broken access control on genuinely sensitive objects only (API keys, secrets, password hashes, backup or SQL files)",
|
||||
"IDOR with significant impact (personal-data leakage alone is NOT significant)",
|
||||
"stored XSS that executes on page open with zero interaction, unauthenticated or low-privilege reachable",
|
||||
"CSRF only when it chains into one of the accepted write actions above",
|
||||
"denial of service only when the whole site crashes"
|
||||
],
|
||||
"rejected_classes": [
|
||||
"user or account enumeration and other low-impact information disclosure",
|
||||
"personal-data-limited broken access control or IDOR",
|
||||
"CSRF that does not chain into an accepted write action",
|
||||
"role prerequisites at or below contributor level",
|
||||
"blind SSRF",
|
||||
"CSV injection",
|
||||
"open redirects",
|
||||
"self-XSS or XSS requiring unusual interaction"
|
||||
],
|
||||
"format_requirements": {
|
||||
"language": "English",
|
||||
"version": "the latest released version of the component at the time of writing",
|
||||
"poc_perspective": "remote attacker over HTTP; steps a third party can follow end to end; the full PoC lives in the report body, not only in attachments",
|
||||
"consolidation": "multiple findings of the same type go into ONE report",
|
||||
"impact": "quantified with its denominator or a concrete demonstrated attacker outcome"
|
||||
},
|
||||
"risk_notes": [
|
||||
"an untested or assumption-based report can earn an immediate temporary ban",
|
||||
"a high rejection rate can remove the reporter from the program"
|
||||
]
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestCostLedgerEurOnUsage(t *testing.T) {
|
||||
if got == nil {
|
||||
t.Fatal("priced profile has no cost_eur")
|
||||
}
|
||||
want := 2.0*1.5 + 0.5*4.0 + 1.0*0.15 // 3.0 + 2.0 + 0.15 = 5.15
|
||||
want := 1.0*1.5 + 0.5*4.0 + 1.0*0.15 // fresh input 1M (2M incl. 1M cached) + 0.5M out + 1M cache-read = 3.65
|
||||
if d := *got - want; d > 0.001 || d < -0.001 {
|
||||
t.Fatalf("cost_eur = %v, want %v", *got, want)
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ type TaskDTO struct {
|
||||
ArchiveBlockedBy string `json:"archive_blocked_by_task_id,omitempty"`
|
||||
CompanyIDs []int64 `json:"company_ids"`
|
||||
CoverageEnabled bool `json:"coverage_enabled"` // asset coverage feature switch (fixed at creation)
|
||||
PlanQuietMode bool `json:"plan_quiet_mode"` // planner quiet-mode switch (#86), editable mid-run
|
||||
}
|
||||
|
||||
// FindingSeverityDTO is the per-severity finding count shown in the task list (critical/high/medium/low).
|
||||
@@ -134,6 +135,7 @@ func taskDTO(t *Task, status string) TaskDTO {
|
||||
SourceTaskIDs: sourceIDs,
|
||||
CompanyIDs: lifecycle.CompanyIDs,
|
||||
CoverageEnabled: t.CoverageEnabled,
|
||||
PlanQuietMode: t.PlanQuietMode,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,10 @@ type Engine struct {
|
||||
started sync.Map // taskID -> bool, so Run is idempotent per task
|
||||
lastAct sync.Map // taskID -> int64 unix, last planner/worker activity (heartbeat)
|
||||
llmCalls sync.Map // taskID -> *int64, actual planner/worker/main-agent LLM calls
|
||||
// quiet-mode (#79): open-goal fingerprint recorded at the end of the last
|
||||
// completed planning round, per task. Edge-triggered rounds are skipped
|
||||
// while it is unchanged and work is still in flight.
|
||||
goalFP sync.Map // taskID -> string (sorted open-goal ids joined)
|
||||
paused sync.Map // taskID -> bool, user-paused (planner + workers idle but loops alive)
|
||||
deleting sync.Map // taskID -> bool, delete barrier (no new task-owned writes)
|
||||
dropCnt sync.Map // taskID -> *int64, running count of dropped (unpersistable) activity records
|
||||
@@ -797,6 +801,22 @@ func resetPlannerTimer(timer *time.Timer, d time.Duration) {
|
||||
timer.Reset(d)
|
||||
}
|
||||
|
||||
// quietSkipEdge reports whether an edge-triggered planning round should be
|
||||
// dropped (#79): the open-goal set is unchanged since the last completed round
|
||||
// AND active work exists (open or running intents). Fails open (false) on any
|
||||
// store error: when in doubt, plan, preserving the pre-quiet-mode behavior.
|
||||
func (e *Engine) quietSkipEdge(t *Task) bool {
|
||||
fp, err := t.Store.OpenGoalFingerprint()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if last, ok := e.goalFP.Load(t.ID); !ok || last != fp {
|
||||
return false
|
||||
}
|
||||
active, err := t.Store.HasActiveIntent()
|
||||
return err == nil && active
|
||||
}
|
||||
|
||||
func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
|
||||
interval := plannerHeartbeatInterval(t)
|
||||
// Arming the heartbeat timer at loop entry = counting from the task start: even a
|
||||
@@ -856,6 +876,17 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
|
||||
}
|
||||
return // the goalless branch never enters planner.Plan
|
||||
}
|
||||
// quiet-mode (#79): an edge round is redundant while work is in flight
|
||||
// and the open-goal set is unchanged since the last completed round —
|
||||
// there is no new decision for the plan to make. Drain the triggers so
|
||||
// they do not accumulate and let the heartbeat supervise; the next edge
|
||||
// after a goal opens or closes, or once the frontier drains and all
|
||||
// workers are idle, plans for real.
|
||||
if src == "edge" && t.PlanQuietMode && e.quietSkipEdge(t) {
|
||||
t.drainTriggers()
|
||||
log.Printf("[planner] task %s quiet: work in flight, goals unchanged, edge round skipped", t.ID)
|
||||
return
|
||||
}
|
||||
if !e.beginTaskOperation(t.ID) {
|
||||
return
|
||||
}
|
||||
@@ -895,6 +926,12 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
|
||||
default:
|
||||
log.Printf("[planner] task %s planning finished", t.ID)
|
||||
}
|
||||
// Record the post-round open-goal fingerprint for quiet-mode (#79):
|
||||
// goals opened or closed by this round change it, so a later edge can
|
||||
// tell "nothing changed" apart from "the plan input changed".
|
||||
if fp, err := t.Store.OpenGoalFingerprint(); err == nil {
|
||||
e.goalFP.Store(t.ID, fp)
|
||||
}
|
||||
e.touch(t.ID)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,32 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestInheritedActivityDetailAndRelationDeletion(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
// #78: t.TempDir() cleanup raced with background writers that outlive
|
||||
// m.Close (TempDir RemoveAll: directory not empty, under CI load). Use a
|
||||
// manual dir with a tolerated cleanup, and close the manager via t.Cleanup
|
||||
// registered FIRST so it runs LAST (LIFO): the DeleteTask cleanups below
|
||||
// then execute while the database is still open instead of racing a closed
|
||||
// one ("sql: database is closed" teardown noise).
|
||||
dir, err := os.MkdirTemp("", "inherited-activity-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := NewManager(dir, "")
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(dir)
|
||||
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
t.Cleanup(func() {
|
||||
_ = m.Close()
|
||||
_ = os.RemoveAll(dir) // tolerated: a late background writer may still hold it
|
||||
})
|
||||
|
||||
source, err := m.CreateTaskWithOptions("detail source", "source goal", db.TaskCreateOptions{})
|
||||
if err != nil {
|
||||
|
||||
@@ -92,3 +92,64 @@ func TestLLMTestFallbackMatchesRuntimeNormalization(t *testing.T) {
|
||||
t.Fatalf("ConfigFrom trims: %q", runtime.APIKey)
|
||||
}
|
||||
}
|
||||
|
||||
// The stored-profile test must reach the profile's own endpoint: an empty form
|
||||
// (the list-view test button) fills provider/model/base_url from the row, so
|
||||
// the key lands on the configured host instead of the canonical default one.
|
||||
func TestLLMTestStoredProfileUsesProfileEndpoint(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { m.Close() })
|
||||
if m.pg == nil {
|
||||
t.Skip("no postgres for this run")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
|
||||
var hits atomic.Int32
|
||||
var gotKey atomic.Value
|
||||
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
gotKey.Store(r.Header.Get("x-api-key"))
|
||||
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)
|
||||
|
||||
pid, err := m.pg.SaveProfile(&db.LLMProfile{
|
||||
Name: "stored-ep", Format: "anthropic", Model: "claude-x",
|
||||
BaseURL: fake.URL, APIKey: "k1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = m.pg.Exec("DELETE FROM llm_profiles WHERE id=$1", pid) })
|
||||
|
||||
// Empty form on purpose: no provider, model, base_url, or streaming.
|
||||
body, _ := json.Marshal(map[string]any{"profile_id": pid, "api_key": ""})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/llm/test", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
s.testLLM(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("handler status %d", rec.Code)
|
||||
}
|
||||
if hits.Load() != 1 {
|
||||
t.Fatalf("stored-profile test must hit the profile endpoint exactly once, hits=%d", hits.Load())
|
||||
}
|
||||
if k, _ := gotKey.Load().(string); k != "k1" {
|
||||
t.Fatalf("test request must send the stored key, header was %q", k)
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if !resp.OK {
|
||||
t.Fatalf("stored-profile round-trip not ok: %s", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ type Task struct {
|
||||
// Task-level timeout (see docs/task-timeout-and-settle-design.md). DeadlineAt/FirstRunAt are unix seconds, 0 = unset/not yet run.
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner heartbeat trigger interval (seconds)
|
||||
PlanQuietMode bool `json:"plan_quiet_mode"` // planner quiet-mode switch (#86), editable mid-run
|
||||
CoverageEnabled bool `json:"coverage_enabled"` // asset coverage feature switch (fixed at creation, on by default)
|
||||
FirstRunAt int64 `json:"first_run_at,omitempty"`
|
||||
DeadlineAt int64 `json:"deadline_at,omitempty"`
|
||||
@@ -844,6 +845,7 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
|
||||
SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...),
|
||||
CompanyIDs: append([]int64(nil), pt.CompanyIDs...),
|
||||
TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds,
|
||||
PlanQuietMode: pt.PlanQuietMode,
|
||||
CoverageEnabled: pt.CoverageEnabled,
|
||||
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
|
||||
Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1),
|
||||
@@ -873,6 +875,9 @@ func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task
|
||||
state.Name = updated.Name
|
||||
state.PinnedAt = unixOrZero(updated.PinnedAt)
|
||||
})
|
||||
// Live switch (#86): runRound reads the flag per round, so the next
|
||||
// planning round (edge or heartbeat) already respects the new value.
|
||||
task.PlanQuietMode = updated.PlanQuietMode
|
||||
return task, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// Planner quiet-mode (#79): an edge round is dropped only when BOTH hold — the
|
||||
// open-goal fingerprint is unchanged since the last completed round AND active
|
||||
// work exists (open or running intents). Every other combination plans.
|
||||
func TestPlannerQuietSkipEdge(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = m.Close() })
|
||||
if m.pg == nil {
|
||||
t.Skip("no postgres for this run")
|
||||
}
|
||||
|
||||
created, err := m.CreateTaskWithOptions("quiet-mode probe", "probe goal", db.TaskCreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt, ok := m.Task(created.ID)
|
||||
if !ok {
|
||||
t.Fatal("task not found after creation")
|
||||
}
|
||||
store := tt.Store
|
||||
e := NewEngine(m)
|
||||
|
||||
gid, err := store.AddGoal(map[string]any{"summary": "g1"}, "seed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
iid, err := store.AddIntent(map[string]any{"summary": "i1"}, 0, nil, "seed")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fp, err := store.OpenGoalFingerprint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fp == "" {
|
||||
t.Fatal("fingerprint must not be empty with an open goal")
|
||||
}
|
||||
e.goalFP.Store(tt.ID, fp)
|
||||
if !e.quietSkipEdge(tt) {
|
||||
t.Fatal("unchanged goals + active intent must skip the edge round")
|
||||
}
|
||||
|
||||
// a goal closes -> the fingerprint changes -> plan
|
||||
if err := store.SetNodeState(gid, "met"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.quietSkipEdge(tt) {
|
||||
t.Fatal("a closed goal must allow planning")
|
||||
}
|
||||
|
||||
// goal back open, intent drained -> no active work -> plan
|
||||
if err := store.SetNodeState(gid, "open"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fp2, err := store.OpenGoalFingerprint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.goalFP.Store(tt.ID, fp2)
|
||||
if err := store.SetIntentState(iid, "done"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.quietSkipEdge(tt) {
|
||||
t.Fatal("no active intent must allow planning")
|
||||
}
|
||||
}
|
||||
|
||||
// The quiet-mode switch is editable mid-run (#86): PATCHing plan_quiet_mode
|
||||
// updates both the stored row and the live Task the engine reads per round.
|
||||
func TestPlannerQuietModeToggleMidRun(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = m.Close() })
|
||||
if m.pg == nil {
|
||||
t.Skip("no postgres for this run")
|
||||
}
|
||||
|
||||
created, err := m.CreateTaskWithOptions("quiet-toggle probe", "goal", db.TaskCreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt, ok := m.Task(created.ID)
|
||||
if !ok {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if !tt.PlanQuietMode {
|
||||
t.Fatal("quiet mode must default on")
|
||||
}
|
||||
|
||||
off := false
|
||||
updated, err := m.UpdateTaskMetadata(created.ID, db.TaskPatch{PlanQuietMode: &off})
|
||||
if err != nil || updated == nil {
|
||||
t.Fatalf("UpdateTaskMetadata: %v %v", updated, err)
|
||||
}
|
||||
live, ok := m.Task(created.ID)
|
||||
if !ok || live.PlanQuietMode {
|
||||
t.Fatal("mid-run toggle must turn the live flag off")
|
||||
}
|
||||
stored, err := m.pg.GetTask(mustTaskID(t, created.ID))
|
||||
if err != nil || stored == nil || stored.PlanQuietMode {
|
||||
t.Fatalf("stored row must reflect the toggle: %v %v", stored, err)
|
||||
}
|
||||
|
||||
on := true
|
||||
if _, err := m.UpdateTaskMetadata(created.ID, db.TaskPatch{PlanQuietMode: &on}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
live, _ = m.Task(created.ID)
|
||||
if !live.PlanQuietMode {
|
||||
t.Fatal("toggle back on failed")
|
||||
}
|
||||
}
|
||||
+79
-30
@@ -1044,34 +1044,44 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
active = t.ID
|
||||
}
|
||||
list := s.m.List()
|
||||
metrics, _ := s.m.PG().TaskListMetricsAll()
|
||||
metrics, err := s.m.PG().TaskListMetricsAll()
|
||||
if err != nil {
|
||||
log.Printf("[tasks] list metrics query failed — metrics zeroed: %v", err)
|
||||
}
|
||||
archiveBlockers, _ := s.m.PG().TaskArchiveBlockers()
|
||||
dtos := make([]TaskDTO, 0, len(list))
|
||||
for _, t := range list {
|
||||
dto := taskDTO(t, s.resolvedTaskStatus(t))
|
||||
applyTaskArchiveBlocker(&dto, archiveBlockers)
|
||||
metric := metrics[t.ExpID]
|
||||
dto.Tokens = tokenTotalDTO(metric.Tokens)
|
||||
// prefer the live in-memory heartbeat (fresher) and fall back to the
|
||||
// persisted max activity time (survives restarts) for run-duration display.
|
||||
dto.LastActivity = metric.LastActivity
|
||||
if live := s.engine.LastActivity(t.ID); live > dto.LastActivity {
|
||||
dto.LastActivity = live
|
||||
}
|
||||
dto.GoalsTotal = metric.Goals.Total
|
||||
dto.GoalsMet = metric.Goals.Met
|
||||
dto.InFlight = metric.RunningIntents
|
||||
dto.Findings = FindingSeverityDTO{
|
||||
Critical: metric.Findings.Critical,
|
||||
High: metric.Findings.High,
|
||||
Medium: metric.Findings.Medium,
|
||||
Low: metric.Findings.Low,
|
||||
}
|
||||
s.applyTaskMetrics(&dto, t, metrics[t.ExpID])
|
||||
dtos = append(dtos, dto)
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"tasks": dtos, "active": active})
|
||||
}
|
||||
|
||||
// applyTaskMetrics fills the aggregate DTO fields (tokens, goal progress,
|
||||
// running intents, findings, last activity) from the list-metrics query so the
|
||||
// detail and list endpoints report identical numbers. A zero metric leaves the
|
||||
// DTO zeroed, which is why callers log the query error instead of assuming it.
|
||||
func (s *Server) applyTaskMetrics(dto *TaskDTO, t *Task, metric db.TaskListMetrics) {
|
||||
dto.Tokens = tokenTotalDTO(metric.Tokens)
|
||||
// prefer the live in-memory heartbeat (fresher) and fall back to the
|
||||
// persisted max activity time (survives restarts) for run-duration display.
|
||||
dto.LastActivity = metric.LastActivity
|
||||
if live := s.engine.LastActivity(t.ID); live > dto.LastActivity {
|
||||
dto.LastActivity = live
|
||||
}
|
||||
dto.GoalsTotal = metric.Goals.Total
|
||||
dto.GoalsMet = metric.Goals.Met
|
||||
dto.InFlight = metric.RunningIntents
|
||||
dto.Findings = FindingSeverityDTO{
|
||||
Critical: metric.Findings.Critical,
|
||||
High: metric.Findings.High,
|
||||
Medium: metric.Findings.Medium,
|
||||
Low: metric.Findings.Low,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) resolvedTaskStatus(t *Task) string {
|
||||
if t == nil {
|
||||
return "created"
|
||||
@@ -1385,6 +1395,41 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
// A stored profile must be tested AS STORED: the list-view test button
|
||||
// sends only profile_id, and ConfigFrom's defaults for an empty
|
||||
// provider/base_url point at the canonical provider endpoint, sending this
|
||||
// profile's key to the wrong host (the stored-key 401s). Fill whatever the
|
||||
// form left empty from the row first; explicit form values still win.
|
||||
var stored *db.LLMProfile
|
||||
if req.ProfileID != nil {
|
||||
if p, err := s.m.pg.ProfileByID(*req.ProfileID); err == nil && p != nil {
|
||||
stored = p
|
||||
}
|
||||
}
|
||||
if stored != nil {
|
||||
if strings.TrimSpace(req.Provider) == "" {
|
||||
req.Provider = stored.Format
|
||||
}
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
req.Model = stored.Model
|
||||
}
|
||||
if strings.TrimSpace(req.BaseURL) == "" {
|
||||
req.BaseURL = stored.BaseURL
|
||||
}
|
||||
if strings.TrimSpace(req.Proxy) == "" {
|
||||
req.Proxy = stored.Proxy
|
||||
}
|
||||
if req.Streaming == nil {
|
||||
st := stored.Streaming
|
||||
req.Streaming = &st
|
||||
}
|
||||
if strings.TrimSpace(req.ThinkingType) == "" {
|
||||
req.ThinkingType = stored.ThinkingType
|
||||
}
|
||||
if strings.TrimSpace(req.ReasoningEffort) == "" {
|
||||
req.ReasoningEffort = stored.ReasoningEffort
|
||||
}
|
||||
}
|
||||
cfg := agent.ConfigFrom(req.Provider, req.Model, req.BaseURL, req.APIKey, req.Proxy)
|
||||
// mirror production: send the SAME thinking params so a provider that rejects the
|
||||
// reasoning_effort/thinking field fails the test too (no false "test ok, run 400").
|
||||
@@ -1402,17 +1447,15 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
// API key resolution precedence: form input > the named profile's stored key > the global config's key.
|
||||
// A stored profile's key is never sent back to the browser, so the form is empty when testing a stored profile and the key comes from the DB.
|
||||
// Same for the session header name: when the form omits it, the stored profile's value is the fallback.
|
||||
if req.ProfileID != nil && (cfg.APIKey == "" || cfg.SessionHeaderKey == "") {
|
||||
if p, err := s.m.pg.ProfileByID(*req.ProfileID); err == nil && p != nil {
|
||||
// Trim like ConfigFrom does for form values: a key stored with
|
||||
// surrounding whitespace runs fine in conversations (the runtime
|
||||
// path re-trims) but would be sent raw here and fail auth.
|
||||
if cfg.APIKey == "" {
|
||||
cfg.APIKey = strings.TrimSpace(p.APIKey)
|
||||
}
|
||||
if cfg.SessionHeaderKey == "" {
|
||||
cfg.SessionHeaderKey = strings.TrimSpace(p.SessionHeaderKey)
|
||||
}
|
||||
if stored != nil && (cfg.APIKey == "" || cfg.SessionHeaderKey == "") {
|
||||
// Trim like ConfigFrom does for form values: a key stored with
|
||||
// surrounding whitespace runs fine in conversations (the runtime
|
||||
// path re-trims) but would be sent raw here and fail auth.
|
||||
if cfg.APIKey == "" {
|
||||
cfg.APIKey = strings.TrimSpace(stored.APIKey)
|
||||
}
|
||||
if cfg.SessionHeaderKey == "" {
|
||||
cfg.SessionHeaderKey = strings.TrimSpace(stored.SessionHeaderKey)
|
||||
}
|
||||
}
|
||||
if cfg.APIKey == "" {
|
||||
@@ -1461,6 +1504,7 @@ type createTaskReq struct {
|
||||
CompanyIDs []int64 `json:"company_ids,omitempty"` // links the company scope and snapshots its current assets; does not copy assets or force intent generation
|
||||
TimeoutSeconds int `json:"timeout_seconds"` // task-level timeout (seconds); 0/omitted = no limit
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner heartbeat interval (seconds); 0/omitted = default 600 (10min); floor = default = 600, lower values are raised to 600
|
||||
PlanQuietMode *bool `json:"plan_quiet_mode,omitempty"` // planner quiet-mode switch (#86); omitted = default on; false = edge rounds always plan
|
||||
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // issue one seed intent at creation (content = description + goal) so a worker can start right away without waiting for the first planner round; omitted/null = default off, the standard plan-then-execute flow. Pass true explicitly to enable (for CTF tasks often solved in one work, this skips the pre-start planner round).
|
||||
CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // asset coverage feature; omitted/null = default on (true). false = disables coverage calculation/display/auto-accumulated scope + hides add_task_scope/list_untested_assets. company links are unaffected.
|
||||
// InterceptRules: task-level asset interception/allow rules (entered at creation, stored in task_intercept_rules, never the global table).
|
||||
@@ -1529,7 +1573,7 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{
|
||||
Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID,
|
||||
SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs,
|
||||
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
|
||||
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds, PlanQuietMode: req.PlanQuietMode,
|
||||
CoverageEnabled: req.CoverageEnabled,
|
||||
InterceptRules: interceptRules,
|
||||
})
|
||||
@@ -1764,6 +1808,11 @@ func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
dto := taskDTO(t, s.resolvedTaskStatus(t))
|
||||
metrics, err := s.m.PG().TaskListMetricsAll()
|
||||
if err != nil {
|
||||
log.Printf("[tasks] detail metrics query failed for %s — metrics zeroed: %v", t.ID, err)
|
||||
}
|
||||
s.applyTaskMetrics(&dto, t, metrics[t.ExpID])
|
||||
archiveBlockers, _ := s.m.PG().TaskArchiveBlockers()
|
||||
applyTaskArchiveBlocker(&dto, archiveBlockers)
|
||||
writeJSON(w, 200, dto)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTaskDetailReturnsListMetrics(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
task, err := m.CreateTask("detail metrics", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taskID, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||
defer func() { _ = m.pg.DeleteTask(taskID) }()
|
||||
|
||||
// Seed the aggregates the metrics query reads: one result-activity with
|
||||
// tokens, one non-result row that must be excluded, and two goals (1 met).
|
||||
if _, err := m.pg.Exec(`
|
||||
INSERT INTO activity(
|
||||
exploration_id, kind, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_write_tokens, created_at
|
||||
) VALUES ($1,'result',11,7,3,2,now()), ($1,'tool_result',999,999,999,999,now())`,
|
||||
task.ExpID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.pg.Exec(`
|
||||
INSERT INTO exploration_nodes(exploration_id, kind, payload, state)
|
||||
VALUES ($1,'goal','{}','met'), ($1,'goal','{}','open')`, task.ExpID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var probe struct {
|
||||
Tokens struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheReadTokens int `json:"cache_read_tokens"`
|
||||
CacheWriteTokens int `json:"cache_write_tokens"`
|
||||
} `json:"tokens"`
|
||||
GoalsMet int `json:"goals_met"`
|
||||
GoalsTotal int `json:"goals_total"`
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tasks/"+task.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("detail status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &probe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if probe.Tokens.InputTokens != 11 || probe.Tokens.OutputTokens != 7 ||
|
||||
probe.Tokens.CacheReadTokens != 3 || probe.Tokens.CacheWriteTokens != 2 {
|
||||
t.Fatalf("detail tokens not filled from metrics: %+v", probe.Tokens)
|
||||
}
|
||||
if probe.GoalsMet != 1 || probe.GoalsTotal != 2 {
|
||||
t.Fatalf("detail goal progress not filled from metrics: %d/%d", probe.GoalsMet, probe.GoalsTotal)
|
||||
}
|
||||
|
||||
// The list endpoint must report the same numbers for the same task.
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/tasks", nil)
|
||||
listReq.Header.Set("Authorization", "Bearer "+token)
|
||||
listRec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(listRec, listReq)
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("list status=%d body=%s", listRec.Code, listRec.Body.String())
|
||||
}
|
||||
var listed struct {
|
||||
Tasks []struct {
|
||||
ID string `json:"id"`
|
||||
Tokens struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"tokens"`
|
||||
GoalsMet int `json:"goals_met"`
|
||||
GoalsTotal int `json:"goals_total"`
|
||||
} `json:"tasks"`
|
||||
}
|
||||
if err := json.Unmarshal(listRec.Body.Bytes(), &listed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, lt := range listed.Tasks {
|
||||
if lt.ID != task.ID {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if lt.Tokens.InputTokens != probe.Tokens.InputTokens ||
|
||||
lt.Tokens.OutputTokens != probe.Tokens.OutputTokens ||
|
||||
lt.GoalsMet != probe.GoalsMet || lt.GoalsTotal != probe.GoalsTotal {
|
||||
t.Fatalf("list and detail metrics disagree: list=%+v/%d detail=%+v/%d",
|
||||
lt.Tokens, lt.GoalsMet, probe.Tokens, probe.GoalsTotal)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("task %s missing from list response", task.ID)
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,9 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskMetadataRequestSize)
|
||||
var request struct {
|
||||
Name *string `json:"name"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
Name *string `json:"name"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
PlanQuietMode *bool `json:"plan_quiet_mode"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
@@ -37,8 +38,8 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if request.Name == nil && request.Pinned == nil {
|
||||
writeErr(w, http.StatusBadRequest, "at least one of name or pinned must be provided")
|
||||
if request.Name == nil && request.Pinned == nil && request.PlanQuietMode == nil {
|
||||
writeErr(w, http.StatusBadRequest, "at least one of name, pinned or plan_quiet_mode must be provided")
|
||||
return
|
||||
}
|
||||
if request.Name != nil {
|
||||
@@ -53,7 +54,7 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
request.Name = &name
|
||||
}
|
||||
task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned})
|
||||
task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned, PlanQuietMode: request.PlanQuietMode})
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
||||
@@ -13,6 +13,10 @@ import (
|
||||
// server test suite so cross-package DELETE cleanup races with db/agent
|
||||
// packages are avoided when running `go test ./...`.
|
||||
func TestMain(m *testing.M) {
|
||||
// Own database per package (#80): with separate databases, packages stop
|
||||
// contending on shared tables and the advisory lock below stops
|
||||
// serializing go test ./... across packages.
|
||||
db.SuffixTestDatabase("server")
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
os.Exit(m.Run())
|
||||
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
---
|
||||
name: api-security
|
||||
description: "Invoke this skill when the task is authorized offensive testing of a REST, GraphQL, WebSocket, or SOAP API beyond recon: authentication and authorization flaws (BOLA, IDOR, BFLA), JWT and OAuth weaknesses, GraphQL-specific abuse, input validation, business logic, rate-limit trust confusion, and excessive data exposure."
|
||||
---
|
||||
|
||||
# API Security Testing (REST, GraphQL, WebSocket, SOAP)
|
||||
|
||||
This is the hunting counterpart of api-recon. api-recon maps the API surface and stops; this skill attacks the mapped surface under an authorized engagement. The standing finding quality gates and the adversarial gate in your system prompt apply throughout, and an access-control finding only exists once a second account or role has proven the boundary is real.
|
||||
|
||||
Attribution: adapted from the `api-security` playbook of [zhaoxuya520/reverse-skill](https://github.com/zhaoxuya520/reverse-skill) (MIT License).
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Rate-limit and load tests only within the engagement's written impact budget. Most bounty programs forbid denial of service outright; volume that degrades the service for other users is out of bounds. Race-condition tests use a handful of concurrent requests, never load tools.
|
||||
- Do not exfiltrate real user data to prove a BOLA. One record you are authorized to touch, or your own second account, is sufficient evidence.
|
||||
- Destructive payloads (deletion, mass assignment on write endpoints) only against accounts and resources created for the test.
|
||||
- Every confirmed finding needs a replayable artifact: the exact request, the exact response, and the authorization context that produced it.
|
||||
|
||||
## Phase checklists
|
||||
|
||||
### Phase 1: discovery (handoff from api-recon)
|
||||
|
||||
- Spec sources: `/swagger.json`, `/openapi.json`, `/api-docs`, `/graphql`, JS bundles, robots.txt.
|
||||
- Undocumented path brute force: ffuf or kiterunner with API wordlists.
|
||||
- GraphQL introspection, three tiers: the full introspection query; a trimmed query if the full one is blocked; minimal `__schema { types { name } }` as the last probe.
|
||||
|
||||
### Phase 2: authentication
|
||||
|
||||
JWT (jwt_tool or manual):
|
||||
|
||||
- [ ] `alg: none`: header rewritten, signature stripped.
|
||||
- [ ] key confusion: RS256 public key accepted as an HS256 secret.
|
||||
- [ ] weak HMAC secret: dictionary attack (`jwt_tool -C -d wordlist.txt`).
|
||||
- [ ] expired or tampered claims: `exp`, `iat`, `sub`, `role`.
|
||||
- [ ] `kid` injection: path traversal inside the key id.
|
||||
|
||||
OAuth 2.0:
|
||||
|
||||
- [ ] `redirect_uri` manipulation leading to authorization-code theft.
|
||||
- [ ] missing `state` (CSRF on the authorization flow).
|
||||
- [ ] token leakage via `Referer` or platform logs.
|
||||
- [ ] missing PKCE on public clients.
|
||||
|
||||
GraphQL auth:
|
||||
|
||||
- [ ] mutation reachable through GET (CSRF).
|
||||
- [ ] batched queries bypassing per-operation authorization.
|
||||
|
||||
### Phase 3: authorization (BOLA / IDOR / BFLA)
|
||||
|
||||
BOLA, object level:
|
||||
|
||||
- [ ] enumerate numeric ids: `/user/1` to `/user/2`.
|
||||
- [ ] enumerate UUIDs harvested from other responses.
|
||||
- [ ] substitute usernames or emails in paths and bodies.
|
||||
- [ ] two-session replay: user A's session requesting user B's objects (Burp Autorize or a manual response diff).
|
||||
|
||||
BFLA, function level:
|
||||
|
||||
- [ ] standard user calling admin endpoints.
|
||||
- [ ] HTTP method swap: GET to PUT, PATCH, DELETE.
|
||||
- [ ] version downgrade: `/v2/admin` to `/v1/admin`.
|
||||
- [ ] batch-operation injection: append an admin id to the bulk body.
|
||||
|
||||
### Phase 4: GraphQL-specific
|
||||
|
||||
- [ ] introspection exposing schema details it should hide.
|
||||
- [ ] alias overload (dozens of aliases in one query) and batched queries as compute amplification, within the impact budget.
|
||||
- [ ] field-suggestion error messages leaking schema names.
|
||||
- [ ] GraphiQL or a playground exposed unauthenticated.
|
||||
- [ ] deep nesting as a resource-exhaustion vector, within the impact budget.
|
||||
- [ ] trace or debug mode returning metadata.
|
||||
|
||||
### Phase 5: REST input validation
|
||||
|
||||
- [ ] HTTP method swap on every endpoint.
|
||||
- [ ] Content-Type tampering: JSON to XML to multipart (XXE where XML is accepted).
|
||||
- [ ] NoSQL operator injection: `{"username": {"$gt": ""}}`.
|
||||
- [ ] SSRF through URL-taking parameters (webhooks, avatar imports, fetchers).
|
||||
- [ ] HTTP parameter pollution: `?role=user&role=admin`.
|
||||
- [ ] mass assignment: `is_admin: true` added to request bodies.
|
||||
|
||||
### Phase 6: business logic and differential testing
|
||||
|
||||
- [ ] diff v1 against v2 of the same API: status-code changes, removed fields, latency regressions.
|
||||
- [ ] role matrix: admin, user, readonly through the same workflow.
|
||||
- [ ] coupon, credit, and price manipulation on test accounts.
|
||||
- [ ] TOCTOU races with a few concurrent requests.
|
||||
|
||||
### Phase 7: WebSocket
|
||||
|
||||
- [ ] endpoint discovery (upgrade handshakes).
|
||||
- [ ] message injection, prototype pollution through frames.
|
||||
- [ ] oversized-message handling.
|
||||
- [ ] type confusion in frame payloads.
|
||||
- [ ] cross-site WebSocket hijacking (CSWH).
|
||||
|
||||
### Phase 8: rate-limit trust confusion
|
||||
|
||||
- [ ] header spoofing: `X-Forwarded-For`, `X-Real-IP`, reported as a trust-boundary finding, never scaled into volume.
|
||||
- [ ] path variants: `/api/`, `/api`, `/Api/`, `/API/` bypassing a path-keyed limiter.
|
||||
- [ ] the finding is the bypass mechanism itself; do not demonstrate it under load.
|
||||
|
||||
### Phase 9: data exposure
|
||||
|
||||
- [ ] response fields beyond what the UI renders (diff the API response against the page).
|
||||
- [ ] pagination enumeration: `?page=1&limit=10000`.
|
||||
- [ ] error messages leaking stack traces, internal paths, or SQL.
|
||||
- [ ] GraphQL nested traversal reaching unauthorized data.
|
||||
- [ ] spec files exposing internal endpoints.
|
||||
|
||||
### Phase 10: regression handoff
|
||||
|
||||
- [ ] persist every confirmed finding as a replayable request (curl command or HAR artifact) so retests and the reporter can re-verify.
|
||||
- [ ] mark which findings are safe to re-run and which are one-shot (nonce or state dependent).
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Use | Notes |
|
||||
|---|---|---|
|
||||
| curl + jq | request replay and diffing | always available |
|
||||
| jwt_tool | JWT attack automation | pip-installable; verify before use |
|
||||
| ffuf, kiterunner | endpoint brute force | verify container availability |
|
||||
| Burp Autorize, AuthMatrix | two-session authorization diffing | only where a Burp instance exists |
|
||||
|
||||
The upstream playbook also names Vespasian, Entropy, Escape DAST, FireTail, and api.sh; treat those as references until verified in this environment, and prefer the manual checklists over unverified tooling.
|
||||
|
||||
## Completion self-check
|
||||
|
||||
- [ ] every executed phase produced artifacts (requests, responses, diffs) in the work dir.
|
||||
- [ ] each access-control finding was confirmed with a second session or role.
|
||||
- [ ] nothing in the report relies on a tool or output you did not actually run.
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: edr-bypass-re
|
||||
description: "Invoke this skill for authorized adversary-simulation work that must understand and evaluate endpoint defense telemetry: mapping what an EDR or AV actually monitors (user-mode hooks, kernel callbacks, ETW providers, AMSI), surveying installed hooks from memory, and evaluating countermeasure combinations in an isolated lab, mapped to MITRE ATT&CK T1562."
|
||||
---
|
||||
|
||||
# EDR Bypass RE (defense telemetry analysis for authorized adversary simulation)
|
||||
|
||||
Reverse the defender's implementation first, then reason about countermeasures. This skill covers the RE half (what the EDR monitors and how its components are built) and the evaluation half (which countermeasure classes defeat which telemetry, verified in an isolated lab). It maps to MITRE ATT&CK T1562 (Impair Defenses), T1562.001 and T1562.006, with T1055 and T1027 adjacent.
|
||||
|
||||
Attribution: adapted from the `edr-bypass-re` playbook of [zhaoxuya520/reverse-skill](https://github.com/zhaoxuya520/reverse-skill) (MIT License).
|
||||
|
||||
## Boundaries (read first)
|
||||
|
||||
- Authorized environments only: your own lab machines and VMs, or hosts covered by written engagement scope (SoW, test contract, program scope statement). Nothing here may ever be applied to an unowned or out-of-scope system.
|
||||
- The deliverable is measurement and a report: which telemetry sources detected which technique, at what stage, with what fidelity. Findings go to the engagement's responsible-disclosure channel.
|
||||
- ARTEX tasks run in a Linux container. Every step here needs an authorized Windows lab VM reachable from the task environment; if none is provisioned, report the gap and stop. Never simulate observations you did not make.
|
||||
- Report placeholders only: `{target_ip}`, `{hostname}`, `{user}`. Anonymize before anything leaves the lab.
|
||||
|
||||
## Applicable task types
|
||||
|
||||
1. Purple-team and detection-gap assessment: measure what the deployed EDR actually catches.
|
||||
2. Product security evaluation: test your own endpoint product against known technique classes.
|
||||
3. CTF or exercise scenarios requiring execution on hardened hosts, within the exercise rules.
|
||||
|
||||
Not applicable: evasion against systems outside written authorization; building mass-distributed malware; any engagement where the objective is intrusion rather than measurement.
|
||||
|
||||
## The four telemetry surfaces and the countermeasure classes
|
||||
|
||||
```text
|
||||
EDR monitoring surface countermeasure class (lab evaluation)
|
||||
────────────────────── ─────────────────────────────────────
|
||||
user-mode ntdll inline hooks ◄─► fresh ntdll copy from disk (unhook)
|
||||
direct or indirect syscall stubs with
|
||||
dynamically resolved SSNs
|
||||
hardware-breakpoint redirection
|
||||
|
||||
kernel callbacks (process, ►─► call-stack spoofing; routing through
|
||||
thread, image, registry) legitimate trigger chains instead of
|
||||
direct invocation
|
||||
|
||||
ETW telemetry (Threat- ◄─► EtwEventWrite patching; provider
|
||||
Intelligence and others) teardown via NtTraceControl
|
||||
|
||||
AMSI scanning (amsi.dll) ◄─► AmsiScanBuffer patching; hardware-
|
||||
breakpoint redirection; loading a
|
||||
clean amsi.dll copy
|
||||
```
|
||||
|
||||
Working knowledge:
|
||||
|
||||
- An EDR is not a black box. Its user-mode DLLs and providers can be loaded into IDA or Ghidra and read like any other binary; rea can assist with static structure on host-side copies.
|
||||
- Countermeasure classes must be combined: each surface needs its own answer, and ordering matters. Silencing telemetry before unhooking avoids the unhook alert arriving first.
|
||||
- Modern EDRs treat ETW plus kernel callbacks as the primary battlefield; user-mode unhooking alone is a dated answer.
|
||||
|
||||
## Workflow
|
||||
|
||||
**Step 1: identify the defenses on the lab host.**
|
||||
|
||||
```powershell
|
||||
Get-Service | Where-Object {$_.Name -match 'CSAgent|SentinelAgent|elasticendpoint|esets|ekrn|MsMpEng|wdsvc|cyserver|sysmon|aswbidsagent'}
|
||||
fltmc filters
|
||||
```
|
||||
|
||||
Kernel-callback enumeration needs kernel debugging (windbg) or a driver-inspection tool. Record every callback class you can observe; the inventory is the baseline artifact.
|
||||
|
||||
**Step 2: survey user-mode hooks.** Dump the in-memory `ntdll.dll` `.text` section from a process carrying the EDR's injected DLL, diff it against the on-disk `C:\Windows\System32\ntdll.dll`, and treat every divergence as a hook site. `pe-sieve64.exe /pid <pid> /shellc 3 /modules 3 /dir hooks_dump` automates the sweep. The dump is the artifact; the report cites hook offsets.
|
||||
|
||||
**Step 3: select the countermeasure combination to evaluate.**
|
||||
|
||||
| Telemetry surface | Countermeasure class under test |
|
||||
|---|---|
|
||||
| ntdll inline hooks | indirect syscall with dynamic SSN resolution |
|
||||
| ETW-TI provider | EtwEventWrite head patch |
|
||||
| AMSI (PowerShell, .NET) | AmsiScanBuffer patch or HWBP redirection |
|
||||
| kernel callbacks | call-stack spoofing via legitimate gadgets |
|
||||
| Sysmon ProcessCreate | PPID spoofing plus unbacked memory |
|
||||
|
||||
**Step 4: implement in the test harness.** Reference implementations for each technique class are public: SysWhispers3 for syscall stub generation, the Hell's Gate and Halo's Gate writeups for dynamic SSN resolution, Ekko for sleep-time memory obfuscation. Cite the exact references used, and never paste unvetted code into the lab host.
|
||||
|
||||
**Step 5: verify in the isolated lab.** Stand up the target EDR trial (Defender is the default baseline) with Sysmon in an isolated VM, run the harness, and record every alert source that fires: AMSI, ETW-TI, Sysmon events 1/7/8/10, the EDR console. Negative results are results; telemetry that catches the technique belongs in the report.
|
||||
|
||||
**Step 6: write the evaluation.** Map every observed detection to MITRE ATT&CK (T1562.001, T1562.006, T1055, T1027) and hand the detection-gap list to the engagement owner.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Use | Install |
|
||||
|---|---|---|
|
||||
| pe-sieve | in-memory hook and injection survey | github.com/hasherezade/pe-sieve |
|
||||
| SysWhispers3 | syscall stub generation | git clone plus python |
|
||||
| API Monitor | dynamic API observation | manual download |
|
||||
| windbg plus IDA or Ghidra | static analysis of EDR DLLs and drivers | manual |
|
||||
| Sysmon | lab telemetry baseline | official download |
|
||||
|
||||
Verify availability in the lab environment before promising a step, and report gaps instead of improvising downloads from unverified sources.
|
||||
|
||||
## Completion self-check
|
||||
|
||||
- [ ] every technique run in the lab has a recorded observation (alert fired or did not fire) with the telemetry source named.
|
||||
- [ ] the report contains placeholders only, never real hostnames or IPs.
|
||||
- [ ] nothing was executed outside the authorized lab VMs.
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: patch-diff
|
||||
description: "Invoke this skill when the task is recovering a vulnerability from a vendor fix: a CVE with a patch but no public PoC, Patch Tuesday triage, a distribution backport gap, or a closed-source security patch that needs root-cause analysis. Diff the patched and unpatched builds, infer the bug class from the added guards, and verify with a PoC that crashes only the unpatched build."
|
||||
---
|
||||
|
||||
# Patch Diff (N-day recovery from vendor fixes)
|
||||
|
||||
Under an **authorized** engagement, recover a vulnerability from the fix that closed it: obtain the before and after builds, align symbols, diff the binaries, read the added checks to infer the bug class, then prove the bug with a PoC that reproduces on the unpatched build and runs clean on the patched one. The standing finding quality gates and the adversarial gate in your system prompt apply throughout: a crash you cannot reproduce on demand is a hypothesis, not a finding.
|
||||
|
||||
Attribution: adapted from the `patch-diff-exploit` playbook of [zhaoxuya520/reverse-skill](https://github.com/zhaoxuya520/reverse-skill) (MIT License).
|
||||
|
||||
## Boundaries and authorization
|
||||
|
||||
| Allowed | Prohibited |
|
||||
|---|---|
|
||||
| Diffing and testing builds in your own lab, on machines covered by written engagement scope, or inside CTF and benchmark ranges | Running a recovered PoC against systems you do not own, even when a program covers the underlying bug |
|
||||
| Writing PoCs that demonstrate the bug (crash, hang, memory-corruption evidence) | Turning a PoC into a deliverable payload |
|
||||
| Publishing or reporting with `{target}`, `{host}`, `{user}` placeholders | Publishing unredacted hostnames, IPs, or usernames |
|
||||
|
||||
If the environment lacks a tool a step needs (see Environment notes), report the gap and stop. Never simulate diff output or fabricate crash evidence.
|
||||
|
||||
## When to use
|
||||
|
||||
1. **Known CVE, no public PoC.** The advisory names the component and bug class but ships only the fix.
|
||||
2. **Patch Tuesday triage.** Rank the month's kernel and driver fixes by likely exploitability before committing to deep analysis.
|
||||
3. **Backport gap.** The mainline fix is merged but a distribution, LTS branch, or OEM kernel has not picked it up.
|
||||
4. **Closed-source security patches.** Drivers, engines, virtualization components where the advisory is the only documentation.
|
||||
|
||||
## Core method
|
||||
|
||||
```text
|
||||
patched build (after) unpatched build (before)
|
||||
│ │
|
||||
└───────── binary diff ─────────┘
|
||||
│
|
||||
function-level diff (matched / unmatched / changed)
|
||||
│
|
||||
focus on functions with moderate match scores (0.5 to 0.95)
|
||||
│
|
||||
read the new guards: bounds checks, locks, zeroing, refcounts
|
||||
│
|
||||
infer the bug class: OOB, race, UAF, info leak, int overflow
|
||||
│
|
||||
PoC on the unpatched build
|
||||
│
|
||||
verify: unpatched crashes, patched runs clean → bug confirmed
|
||||
```
|
||||
|
||||
Patch pattern to bug class lookup:
|
||||
|
||||
| New guard in the patched build | Likely bug class |
|
||||
|---|---|
|
||||
| `if (a + b < a)`, `__builtin_add_overflow` | integer overflow |
|
||||
| `KeAcquireSpinLock`, `mutex_lock` | race condition (TOCTOU, double free) |
|
||||
| `if (idx >= MAX)`, `if (len > buf_size)` | out-of-bounds read or write |
|
||||
| `RtlZeroMemory`, `memset(struct, 0, ...)` | uninitialized memory info leak |
|
||||
| `InterlockedDecrement` plus refcount check | UAF, reference-count bug |
|
||||
| `ProbeForRead` / `ProbeForWrite` | unvalidated user-mode pointer |
|
||||
| `SeAccessCheck`, capability checks | missing permission check |
|
||||
| removed or tightened IOCTL codes | attack-surface reduction (study the old path) |
|
||||
|
||||
## Workflow
|
||||
|
||||
**Step 1: obtain before and after builds.**
|
||||
|
||||
- Windows: Microsoft Update Catalog for the MSU packages of two builds (last unpatched, first patched); unpack with `expand.exe pkg.msu -F:* out\`, then the inner `.cab`; extract `ntoskrnl.exe`, `win32kfull.sys`, and the affected drivers.
|
||||
- Linux: `apt download linux-image-<version>` for both builds, `dpkg-deb -x` each; restore the vmlinux ELF from `boot/vmlinuz` with extract-vmlinux; fetch the matching dbgsym package.
|
||||
- Third-party software: vendor archives for the N-1 and N installers.
|
||||
|
||||
Record sha256 for every artifact; the report cites hashes, not filenames.
|
||||
|
||||
**Step 2: align symbols.**
|
||||
|
||||
- Windows: `symchk /v module.dll /s SRV*c:\sym*https://msdl.microsoft.com/download/symbols`.
|
||||
- Linux: vmlinux plus `System.map` or dbgsym for both builds.
|
||||
|
||||
**Step 3: diff.**
|
||||
|
||||
- Preferred in this environment: `ghidriff old_build new_build -o out/` (Ghidra headless, pip-installable, markdown report).
|
||||
- BinDiff or Diaphora only where an IDA/Ghidra toolchain with exports already exists.
|
||||
|
||||
**Step 4: locate the change.** Filter to functions with similarity roughly 0.5 to 0.95: identical functions are noise, completely different ones are usually new or renamed. Read the added conditions, loop bounds, and deleted blocks (a deletion is also a clue). Judge by control flow and data flow, not token-level diffs; patches carry unrelated cleanup and renames.
|
||||
|
||||
**Step 5: write and verify the PoC.**
|
||||
|
||||
- Integer overflow: boundary values (`INT_MAX-1`, `0xFFFFFFFF`).
|
||||
- Race: concurrent threads hammering the open/close plus ioctl path.
|
||||
- UAF: spray, free, reuse.
|
||||
- OOB: length or index just past the guard.
|
||||
|
||||
Run the same harness against the patched build and confirm the clean result; an untested patched build proves nothing, since the crash could be environmental.
|
||||
|
||||
## Warnings
|
||||
|
||||
- **A patch can shrink the attack surface instead of fixing the bug.** The original flaw may still be reachable from another path; treat the fix as a lead, not ground truth.
|
||||
- **Mitigations are not fixes.** CFG/XFG instrumentation such as `_guard_xfg_dispatch_icall_fptr` is hardening, not a root-cause fix.
|
||||
- **Compiler churn is diff noise.** Compare builds from the same toolchain; never diff across major compiler versions.
|
||||
- **Anonymize before anything leaves the lab.**
|
||||
|
||||
## Environment notes for this platform
|
||||
|
||||
- The rea MCP server (`mcp__rea__*`) opens files read-only on the host and never executes targets. Use it for structure, strings, and symbol questions on either build; files must live under the shared data mount and rea takes host-side paths.
|
||||
- `ghidriff` needs Java plus a Ghidra install. Verify availability before promising a diff; if no diff toolchain exists, say so and stop.
|
||||
- PoC execution belongs in an authorized lab VM, never in the task container.
|
||||
|
||||
## Completion self-check
|
||||
|
||||
- [ ] every workflow step was executed, not just read
|
||||
- [ ] the diff, the inferred bug class, and the PoC artifacts are in the work dir with hashes
|
||||
- [ ] the patched build was actually tested and its clean result recorded
|
||||
Reference in New Issue
Block a user