@swestash/agent-lens 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-lens.js +801 -36
- package/dist/agent-lens.js.map +1 -1
- package/package.json +1 -1
- package/web/assets/{CopyButton-CDLZMZtV.js → CopyButton-WFGbfzdo.js} +1 -1
- package/web/assets/{Dashboard-BGVInKFn.js → Dashboard-B0wpOYPt.js} +36 -36
- package/web/assets/SecurityView-D9MKBBkp.js +1 -0
- package/web/assets/SessionView-BkOEhK9L.js +12 -0
- package/web/assets/{SkillView-z46KtQoA.js → SkillView-COo5sndg.js} +1 -1
- package/web/assets/{SkillsView-BZ4hNSbk.js → SkillsView-C6f11mVw.js} +1 -1
- package/web/assets/{WorkflowView-eiRtGE5c.js → WorkflowView-B8jF6aE7.js} +1 -1
- package/web/assets/{index-5gOslh3o.js → index-CnYz-f0y.js} +1 -1
- package/web/assets/{index-Twf3zCly.js → index-DTfBIYUK.js} +12 -12
- package/web/assets/{index-CXU0GJds.css → index-DfoRqKEF.css} +1 -1
- package/web/assets/severity-DoLHU1bt.js +1 -0
- package/web/index.html +2 -2
- package/web/assets/SessionView-C9Nz-Buq.js +0 -12
package/dist/agent-lens.js
CHANGED
|
@@ -591,7 +591,7 @@ var CAC = class extends EventEmitter {
|
|
|
591
591
|
var cac = (name = "") => new CAC(name);
|
|
592
592
|
|
|
593
593
|
// ../core/dist/schema.js
|
|
594
|
-
var SCHEMA_VERSION =
|
|
594
|
+
var SCHEMA_VERSION = 12;
|
|
595
595
|
var SCHEMA_SQL = (
|
|
596
596
|
/* sql */
|
|
597
597
|
`
|
|
@@ -781,6 +781,34 @@ CREATE TABLE IF NOT EXISTS classifications (
|
|
|
781
781
|
PRIMARY KEY (scope, target_id)
|
|
782
782
|
);
|
|
783
783
|
|
|
784
|
+
-- Security findings (ADR-017), re-runnable. A deterministic rule engine (detect.ts) scans each
|
|
785
|
+
-- tool call's verbatim input/result for risky operations \u2014 destructive/data-loss, credential access,
|
|
786
|
+
-- data exfiltration, privilege/guardrail bypass \u2014 and records one row per (tool_call, rule) match.
|
|
787
|
+
-- Findings are 0..N per tool call (unlike classifications, which are 1-per-session), so an incremental
|
|
788
|
+
-- re-run DELETEs the touched sessions' rows and re-INSERTs (delete-then-insert) rather than upserting.
|
|
789
|
+
-- Every input/modifier is captured in signals_json so a finding is fully explainable; framework_ref
|
|
790
|
+
-- anchors it to OWASP Agentic / MITRE ATLAS. detector_version lets a future engine supersede rows.
|
|
791
|
+
CREATE TABLE IF NOT EXISTS findings (
|
|
792
|
+
id TEXT PRIMARY KEY, -- deterministic hash(tool_call_id, rule_id)
|
|
793
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
794
|
+
tool_call_id TEXT REFERENCES tool_calls(id),
|
|
795
|
+
event_uuid TEXT, -- the tool call's event, for jump-to-transcript
|
|
796
|
+
turn_id TEXT,
|
|
797
|
+
rule_id TEXT NOT NULL, -- 'destructive.rm_rf'
|
|
798
|
+
category TEXT NOT NULL, -- destructive | credential-access | exfiltration | privilege-bypass
|
|
799
|
+
framework_ref TEXT, -- 'OWASP ASI02' | 'MITRE ATLAS AML.T0086' | ...
|
|
800
|
+
severity TEXT NOT NULL, -- info | low | medium | high | critical
|
|
801
|
+
title TEXT,
|
|
802
|
+
evidence TEXT, -- matched snippet, truncated (no wholesale secret dump)
|
|
803
|
+
signals_json TEXT, -- why: rule, matched pattern, context modifiers
|
|
804
|
+
detector_version INTEGER NOT NULL DEFAULT 1
|
|
805
|
+
);
|
|
806
|
+
CREATE INDEX IF NOT EXISTS idx_findings_session ON findings(session_id);
|
|
807
|
+
CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(severity);
|
|
808
|
+
CREATE INDEX IF NOT EXISTS idx_findings_category ON findings(category);
|
|
809
|
+
CREATE INDEX IF NOT EXISTS idx_findings_rule ON findings(rule_id);
|
|
810
|
+
CREATE INDEX IF NOT EXISTS idx_findings_tool_call ON findings(tool_call_id);
|
|
811
|
+
|
|
784
812
|
-- Workflow-tool run results, ingested from the sidecar JSON the runner writes next to the launching
|
|
785
813
|
-- session (\u2026/projects/<enc>/<sessionId>/workflows/wf_<id>.json). The transcript only carries the
|
|
786
814
|
-- "launched in background" ack for an async run; this sidecar is the authoritative record of how the
|
|
@@ -868,6 +896,55 @@ END;
|
|
|
868
896
|
`
|
|
869
897
|
);
|
|
870
898
|
|
|
899
|
+
// ../core/dist/security.js
|
|
900
|
+
var SEVERITY_ORDER = ["info", "low", "medium", "high", "critical"];
|
|
901
|
+
function severityRank(s) {
|
|
902
|
+
return SEVERITY_ORDER.indexOf(s);
|
|
903
|
+
}
|
|
904
|
+
function bumpSeverity(s, delta) {
|
|
905
|
+
const i = SEVERITY_ORDER.indexOf(s);
|
|
906
|
+
const j = Math.max(0, Math.min(SEVERITY_ORDER.length - 1, i + delta));
|
|
907
|
+
return SEVERITY_ORDER[j];
|
|
908
|
+
}
|
|
909
|
+
var SECURITY_CATEGORIES = [
|
|
910
|
+
{
|
|
911
|
+
key: "destructive",
|
|
912
|
+
title: "Destructive & data-loss operations",
|
|
913
|
+
framework_ref: "OWASP ASI02",
|
|
914
|
+
framework_url: "https://genai.owasp.org/",
|
|
915
|
+
what: "Commands or edits that irreversibly delete, overwrite, or truncate data \u2014 rm -rf, git reset --hard, force-pushes, DROP/TRUNCATE, disk writes (dd/mkfs/shred), and overwrites of critical project files (lockfiles, CI config).",
|
|
916
|
+
why: "An agent acting on a bad plan or a poisoned instruction can wipe work, history, or a database with a single tool call. Because agent-lens sees sessions after the fact, this is how you notice the damage \u2014 and the blast radius \u2014 even when nobody was watching live.",
|
|
917
|
+
remediation: "Open the flagged session, confirm the target was intended, and check the tool result for whether it succeeded. Restore from git/backups if needed, and consider a PreToolUse hook to block the pattern going forward."
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
key: "credential-access",
|
|
921
|
+
title: "Secret & credential access",
|
|
922
|
+
framework_ref: "MITRE ATLAS: Credential Access",
|
|
923
|
+
framework_url: "https://atlas.mitre.org/",
|
|
924
|
+
what: "Reads of secret-bearing files (.env, ~/.ssh keys, ~/.aws/credentials, .npmrc, .pem) or secret-looking values (private keys, AWS keys, GitHub/Slack tokens) appearing in a command or a captured tool result.",
|
|
925
|
+
why: "Credentials the agent touched may have been exposed to the model context, logs, or a downstream tool \u2014 and could be leaked or reused. Retrospective visibility tells you which secrets to rotate.",
|
|
926
|
+
remediation: "Treat any accessed secret as potentially exposed: rotate it. Verify the access was purposeful, and keep secrets out of the working tree the agent operates on."
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
key: "exfiltration",
|
|
930
|
+
title: "Data exfiltration",
|
|
931
|
+
framework_ref: "MITRE ATLAS AML.T0086",
|
|
932
|
+
framework_url: "https://atlas.mitre.org/techniques/AML.T0086",
|
|
933
|
+
what: "Local data flowing outbound: curl/wget uploads to external hosts, piping files into a network command, netcat/reverse-shell patterns, and other ways an agent's write-capable tools become an exfiltration channel.",
|
|
934
|
+
why: "Exfiltration via the agent's own tool calls (AML.T0086) is a primary agentic attack path \u2014 a prompt-injected agent can POST your files or secrets to an attacker with one command. These are the highest-signal findings to review.",
|
|
935
|
+
remediation: "Inspect the destination host and the data referenced in the command. If it was not you, treat it as an incident: rotate anything referenced, review what left the host, and audit the session for the injection source."
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
key: "privilege-bypass",
|
|
939
|
+
title: "Privilege escalation & guardrail bypass",
|
|
940
|
+
framework_ref: "OWASP LLM06",
|
|
941
|
+
framework_url: "https://genai.owasp.org/",
|
|
942
|
+
what: "Actions that widen the agent's authority or sidestep safety: sudo, chmod 777, curl | sh, --dangerously-skip-permissions, writes outside the working project, and persistence mechanisms (cron, systemd, shell rc files).",
|
|
943
|
+
why: "Excessive agency (LLM06) is the root cause behind most agent incidents \u2014 every disabled guardrail or out-of-scope write removes a barrier the design relied on. Persistence in particular outlives the session that created it.",
|
|
944
|
+
remediation: "Confirm the escalation was necessary and scoped. Remove any persistence the agent added, re-tighten permissions, and avoid running agents with skipped permission prompts on a trusted host."
|
|
945
|
+
}
|
|
946
|
+
];
|
|
947
|
+
|
|
871
948
|
// ../core/dist/pricing.js
|
|
872
949
|
var PRICE_TABLE = {
|
|
873
950
|
// Opus 4.0 / 4.1 launched at $15/$75; Opus 4.5 cut the Opus tier to $5/$25 and 4.6/4.7/4.8 kept it.
|
|
@@ -1717,6 +1794,7 @@ function resetSchema(db) {
|
|
|
1717
1794
|
DROP TRIGGER IF EXISTS events_ad;
|
|
1718
1795
|
DROP TRIGGER IF EXISTS events_ai;
|
|
1719
1796
|
DROP TABLE IF EXISTS events_fts;
|
|
1797
|
+
DROP TABLE IF EXISTS findings;
|
|
1720
1798
|
DROP TABLE IF EXISTS classifications;
|
|
1721
1799
|
DROP TABLE IF EXISTS token_usage;
|
|
1722
1800
|
DROP TABLE IF EXISTS tool_calls;
|
|
@@ -2239,8 +2317,414 @@ function classify(db, dirty) {
|
|
|
2239
2317
|
return { count: sessions.length, version: CLASSIFIER_VERSION };
|
|
2240
2318
|
}
|
|
2241
2319
|
|
|
2242
|
-
// ../ingest/dist/
|
|
2320
|
+
// ../ingest/dist/detect.js
|
|
2243
2321
|
import { createHash } from "crypto";
|
|
2322
|
+
var DETECTOR_VERSION = 3;
|
|
2323
|
+
function evidence(s, max = 200) {
|
|
2324
|
+
const one = s.replace(/\s+/g, " ").trim();
|
|
2325
|
+
return one.length > max ? one.slice(0, max - 1) + "\u2026" : one;
|
|
2326
|
+
}
|
|
2327
|
+
function findingId(toolCallId, ruleId) {
|
|
2328
|
+
return createHash("sha1").update(toolCallId).update("\0").update(ruleId).digest("hex").slice(0, 16);
|
|
2329
|
+
}
|
|
2330
|
+
var SYSTEM_PATH = /(^|\s)(\/etc\/|\/usr\/|\/bin\/|\/sbin\/|\/boot\/|\/var\/|\/root\/|~\/\.ssh\/|~\/\.aws\/)/;
|
|
2331
|
+
var HOME_OR_ROOT_TARGET = /(\s|^)(~\/?|\/|\/\*|\$HOME\/?|\*)(\s|$)/;
|
|
2332
|
+
var SECRET_FILE = /(^|\/|\s)(\.env(\.[\w.-]+)?|id_rsa|id_ed25519|id_ecdsa|\.ssh\/[\w.-]+|\.aws\/credentials|\.gcp\/|gcloud\/|\.npmrc|\.pypirc|\.netrc|\.pem|\.p12|\.pfx|secrets?\.(ya?ml|json|txt)|credentials(\.json)?)\b/i;
|
|
2333
|
+
var EXAMPLE_FILE = /\.(example|sample|template|dist)($|\.)/i;
|
|
2334
|
+
var CONTENT_READ_CMD = /\b(cat|less|more|head|tail|bat|xxd|strings|base64|grep|egrep|rg|awk|sed|nl|tac|od|hexdump|cp|scp|sort)\b/i;
|
|
2335
|
+
var SECRET_VALUE_PATTERNS = [
|
|
2336
|
+
{ re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/, label: "private-key", critical: true },
|
|
2337
|
+
{ re: /\bAKIA[0-9A-Z]{16}\b/, label: "aws-access-key-id" },
|
|
2338
|
+
{ re: /\bASIA[0-9A-Z]{16}\b/, label: "aws-temp-key-id" },
|
|
2339
|
+
{ re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/, label: "github-token" },
|
|
2340
|
+
{ re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, label: "slack-token" },
|
|
2341
|
+
{ re: /\bsk-[A-Za-z0-9]{20,}\b/, label: "api-secret-key" }
|
|
2342
|
+
];
|
|
2343
|
+
var isBash = (t) => t === "Bash";
|
|
2344
|
+
var AGENT_CONFIG_PATH = /(^|\/)\.claude\//;
|
|
2345
|
+
var TEMP_PATH = /^(\/tmp\/|\/var\/folders\/|\/private\/var\/folders\/)/;
|
|
2346
|
+
function isAgentOwnedPath(p) {
|
|
2347
|
+
if (AGENT_CONFIG_PATH.test(p) || TEMP_PATH.test(p))
|
|
2348
|
+
return true;
|
|
2349
|
+
const tmp = process.env.TMPDIR;
|
|
2350
|
+
return !!tmp && p.startsWith(tmp.replace(/\/$/, "") + "/");
|
|
2351
|
+
}
|
|
2352
|
+
function outsideProject(filePath, projectPath) {
|
|
2353
|
+
if (!filePath || !filePath.startsWith("/"))
|
|
2354
|
+
return false;
|
|
2355
|
+
if (isAgentOwnedPath(filePath))
|
|
2356
|
+
return false;
|
|
2357
|
+
if (!projectPath)
|
|
2358
|
+
return false;
|
|
2359
|
+
return filePath !== projectPath && !filePath.startsWith(projectPath.replace(/\/$/, "") + "/");
|
|
2360
|
+
}
|
|
2361
|
+
var RULES = [
|
|
2362
|
+
// --- Destructive / data-loss (OWASP ASI02 Tool Misuse) -------------------
|
|
2363
|
+
{
|
|
2364
|
+
id: "destructive.rm_rf",
|
|
2365
|
+
category: "destructive",
|
|
2366
|
+
framework_ref: "OWASP ASI02",
|
|
2367
|
+
title: "Recursive force delete (rm -rf)",
|
|
2368
|
+
tools: ["Bash"],
|
|
2369
|
+
baseSeverity: "high",
|
|
2370
|
+
test(ctx) {
|
|
2371
|
+
const c = ctx.command;
|
|
2372
|
+
if (!c)
|
|
2373
|
+
return null;
|
|
2374
|
+
const rf = /\brm\s+[^|;&\n]*(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r|--recursive[^|;&\n]*--force|--force[^|;&\n]*--recursive)/i;
|
|
2375
|
+
if (!rf.test(c))
|
|
2376
|
+
return null;
|
|
2377
|
+
const dangerousTarget = HOME_OR_ROOT_TARGET.test(c) || /\s\/(?:\s|$)/.test(c) || /\brm\b[^|;&\n]*\*/.test(c);
|
|
2378
|
+
return { evidence: evidence(c), severity: dangerousTarget ? "critical" : "high", mods: { dangerous_target: dangerousTarget } };
|
|
2379
|
+
}
|
|
2380
|
+
},
|
|
2381
|
+
{
|
|
2382
|
+
id: "destructive.git_reset_hard",
|
|
2383
|
+
category: "destructive",
|
|
2384
|
+
framework_ref: "OWASP ASI02",
|
|
2385
|
+
title: "Discards local changes (git reset --hard)",
|
|
2386
|
+
tools: ["Bash"],
|
|
2387
|
+
baseSeverity: "medium",
|
|
2388
|
+
test(ctx) {
|
|
2389
|
+
const c = ctx.command;
|
|
2390
|
+
if (!c || !/\bgit\s+reset\s+--hard\b/i.test(c))
|
|
2391
|
+
return null;
|
|
2392
|
+
return { evidence: evidence(c) };
|
|
2393
|
+
}
|
|
2394
|
+
},
|
|
2395
|
+
{
|
|
2396
|
+
id: "destructive.git_force_push",
|
|
2397
|
+
category: "destructive",
|
|
2398
|
+
framework_ref: "OWASP ASI02",
|
|
2399
|
+
title: "Force-push rewrites remote history",
|
|
2400
|
+
tools: ["Bash"],
|
|
2401
|
+
baseSeverity: "medium",
|
|
2402
|
+
test(ctx) {
|
|
2403
|
+
const c = ctx.command;
|
|
2404
|
+
if (!c || !/\bgit\s+push\b[^|;&\n]*(--force\b|--force-with-lease\b|\s-f\b)/i.test(c))
|
|
2405
|
+
return null;
|
|
2406
|
+
const mainBranch = /\b(main|master|release)\b/.test(c);
|
|
2407
|
+
return { evidence: evidence(c), severity: mainBranch ? "high" : "medium", mods: { protected_branch: mainBranch } };
|
|
2408
|
+
}
|
|
2409
|
+
},
|
|
2410
|
+
{
|
|
2411
|
+
id: "destructive.sql_drop",
|
|
2412
|
+
category: "destructive",
|
|
2413
|
+
framework_ref: "OWASP ASI02",
|
|
2414
|
+
title: "Drops or truncates a database object",
|
|
2415
|
+
tools: "*",
|
|
2416
|
+
baseSeverity: "high",
|
|
2417
|
+
test(ctx) {
|
|
2418
|
+
const hay = ctx.command ?? (typeof ctx.input?.query === "string" ? ctx.input.query : null);
|
|
2419
|
+
if (!hay || !/\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE(\s+TABLE)?)\b/i.test(hay))
|
|
2420
|
+
return null;
|
|
2421
|
+
return { evidence: evidence(hay) };
|
|
2422
|
+
}
|
|
2423
|
+
},
|
|
2424
|
+
{
|
|
2425
|
+
id: "destructive.disk_wipe",
|
|
2426
|
+
category: "destructive",
|
|
2427
|
+
framework_ref: "OWASP ASI02",
|
|
2428
|
+
title: "Low-level disk write / wipe (dd, mkfs, shred)",
|
|
2429
|
+
tools: ["Bash"],
|
|
2430
|
+
baseSeverity: "high",
|
|
2431
|
+
test(ctx) {
|
|
2432
|
+
const c = ctx.command;
|
|
2433
|
+
if (!c || !/(\bdd\s+if=|\bmkfs(\.\w+)?\b|\bshred\b)/i.test(c))
|
|
2434
|
+
return null;
|
|
2435
|
+
return { evidence: evidence(c) };
|
|
2436
|
+
}
|
|
2437
|
+
},
|
|
2438
|
+
{
|
|
2439
|
+
id: "destructive.overwrite_critical",
|
|
2440
|
+
category: "destructive",
|
|
2441
|
+
framework_ref: "OWASP ASI02",
|
|
2442
|
+
title: "Overwrites a critical project file",
|
|
2443
|
+
tools: ["Write", "Edit", "MultiEdit"],
|
|
2444
|
+
baseSeverity: "medium",
|
|
2445
|
+
test(ctx) {
|
|
2446
|
+
const f = ctx.filePath;
|
|
2447
|
+
if (!f)
|
|
2448
|
+
return null;
|
|
2449
|
+
if (!/(^|\/)(package-lock\.json|pnpm-lock\.ya?ml|yarn\.lock|Cargo\.lock|go\.sum|\.github\/workflows\/|\.gitlab-ci\.ya?ml)\b/.test(f))
|
|
2450
|
+
return null;
|
|
2451
|
+
return { evidence: evidence(f) };
|
|
2452
|
+
}
|
|
2453
|
+
},
|
|
2454
|
+
// --- Credential & secret access ------------------------------------------
|
|
2455
|
+
{
|
|
2456
|
+
id: "credential.secret_file_access",
|
|
2457
|
+
category: "credential-access",
|
|
2458
|
+
framework_ref: "MITRE ATLAS: Credential Access",
|
|
2459
|
+
title: "Reads a secret/credential file",
|
|
2460
|
+
tools: ["Read", "Bash"],
|
|
2461
|
+
baseSeverity: "high",
|
|
2462
|
+
test(ctx) {
|
|
2463
|
+
const hay = ctx.toolName === "Read" ? ctx.filePath : ctx.command;
|
|
2464
|
+
if (!hay || !SECRET_FILE.test(hay) || EXAMPLE_FILE.test(hay))
|
|
2465
|
+
return null;
|
|
2466
|
+
if (isBash(ctx.toolName) && !CONTENT_READ_CMD.test(hay))
|
|
2467
|
+
return null;
|
|
2468
|
+
return { evidence: evidence(hay) };
|
|
2469
|
+
}
|
|
2470
|
+
},
|
|
2471
|
+
{
|
|
2472
|
+
id: "credential.secret_in_data",
|
|
2473
|
+
category: "credential-access",
|
|
2474
|
+
framework_ref: "MITRE ATLAS: Credential Access",
|
|
2475
|
+
title: "Secret-looking value in command or output",
|
|
2476
|
+
tools: "*",
|
|
2477
|
+
baseSeverity: "high",
|
|
2478
|
+
test(ctx) {
|
|
2479
|
+
const hay = [ctx.command, ctx.resultSummary].filter(Boolean).join("\n");
|
|
2480
|
+
if (!hay)
|
|
2481
|
+
return null;
|
|
2482
|
+
for (const p of SECRET_VALUE_PATTERNS) {
|
|
2483
|
+
const m = p.re.exec(hay);
|
|
2484
|
+
if (m)
|
|
2485
|
+
return { evidence: `${p.label}: ${evidence(m[0], 40)}`, severity: p.critical ? "critical" : "high", mods: { secret_kind: p.label } };
|
|
2486
|
+
}
|
|
2487
|
+
return null;
|
|
2488
|
+
}
|
|
2489
|
+
},
|
|
2490
|
+
// --- Data exfiltration (MITRE ATLAS AML.T0086) ---------------------------
|
|
2491
|
+
{
|
|
2492
|
+
id: "exfil.pipe_to_network",
|
|
2493
|
+
category: "exfiltration",
|
|
2494
|
+
framework_ref: "MITRE ATLAS AML.T0086",
|
|
2495
|
+
title: "Pipes local data into a network command",
|
|
2496
|
+
tools: ["Bash"],
|
|
2497
|
+
baseSeverity: "critical",
|
|
2498
|
+
test(ctx) {
|
|
2499
|
+
const c = ctx.command;
|
|
2500
|
+
if (!c)
|
|
2501
|
+
return null;
|
|
2502
|
+
if (!/(\||<)\s*(sudo\s+)?(curl|wget|nc|ncat|netcat|ssh|scp)\b/i.test(c))
|
|
2503
|
+
return null;
|
|
2504
|
+
if (!/\b(cat|tar|zip|gzip|base64|env|printenv|cat\s|<)\b/i.test(c))
|
|
2505
|
+
return null;
|
|
2506
|
+
return { evidence: evidence(c) };
|
|
2507
|
+
}
|
|
2508
|
+
},
|
|
2509
|
+
{
|
|
2510
|
+
id: "exfil.network_upload",
|
|
2511
|
+
category: "exfiltration",
|
|
2512
|
+
framework_ref: "MITRE ATLAS AML.T0086",
|
|
2513
|
+
title: "Uploads data to an external host",
|
|
2514
|
+
tools: ["Bash"],
|
|
2515
|
+
baseSeverity: "high",
|
|
2516
|
+
test(ctx) {
|
|
2517
|
+
const c = ctx.command;
|
|
2518
|
+
if (!c)
|
|
2519
|
+
return null;
|
|
2520
|
+
const upload = /\b(curl|wget)\b[^|;&\n]*(-X\s*POST|--data\b|--data-\w+\b|\s-d\b|-T\b|--upload-file\b|-F\b|--form\b)/i.test(c);
|
|
2521
|
+
if (!upload)
|
|
2522
|
+
return null;
|
|
2523
|
+
const external = /https?:\/\/(?!(localhost|127\.0\.0\.1|0\.0\.0\.0))/i.test(c);
|
|
2524
|
+
const withFile = /(@[^\s'"]+|--upload-file|\s-T\b)/.test(c);
|
|
2525
|
+
return {
|
|
2526
|
+
evidence: evidence(c),
|
|
2527
|
+
severity: withFile && external ? "critical" : "high",
|
|
2528
|
+
mods: { external, with_file: withFile }
|
|
2529
|
+
};
|
|
2530
|
+
}
|
|
2531
|
+
},
|
|
2532
|
+
{
|
|
2533
|
+
id: "exfil.reverse_shell",
|
|
2534
|
+
category: "exfiltration",
|
|
2535
|
+
framework_ref: "MITRE ATLAS AML.T0086",
|
|
2536
|
+
title: "Netcat / reverse-shell style connection",
|
|
2537
|
+
tools: ["Bash"],
|
|
2538
|
+
baseSeverity: "critical",
|
|
2539
|
+
test(ctx) {
|
|
2540
|
+
const c = ctx.command;
|
|
2541
|
+
if (!c)
|
|
2542
|
+
return null;
|
|
2543
|
+
if (!/\b(nc|ncat|netcat)\b[^|;&\n]*(-e\b|-c\b|\d{2,5})|\/dev\/tcp\/|bash\s+-i\b/i.test(c))
|
|
2544
|
+
return null;
|
|
2545
|
+
return { evidence: evidence(c) };
|
|
2546
|
+
}
|
|
2547
|
+
},
|
|
2548
|
+
// --- Privilege escalation / guardrail bypass (OWASP LLM06 Excessive Agency)
|
|
2549
|
+
{
|
|
2550
|
+
id: "privilege.skip_permissions",
|
|
2551
|
+
category: "privilege-bypass",
|
|
2552
|
+
framework_ref: "OWASP LLM06",
|
|
2553
|
+
title: "Bypasses the permission guardrail",
|
|
2554
|
+
tools: "*",
|
|
2555
|
+
baseSeverity: "high",
|
|
2556
|
+
test(ctx) {
|
|
2557
|
+
const c = ctx.command;
|
|
2558
|
+
if (!c || !/--dangerously-skip-permissions\b|--yolo\b|--dangerously-skip\b/i.test(c))
|
|
2559
|
+
return null;
|
|
2560
|
+
return { evidence: evidence(c) };
|
|
2561
|
+
}
|
|
2562
|
+
},
|
|
2563
|
+
{
|
|
2564
|
+
id: "privilege.curl_pipe_shell",
|
|
2565
|
+
category: "privilege-bypass",
|
|
2566
|
+
framework_ref: "OWASP LLM06",
|
|
2567
|
+
title: "Executes a remote script (curl | sh)",
|
|
2568
|
+
tools: ["Bash"],
|
|
2569
|
+
baseSeverity: "high",
|
|
2570
|
+
test(ctx) {
|
|
2571
|
+
const c = ctx.command;
|
|
2572
|
+
if (!c || !/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python\d?|node)\b/i.test(c))
|
|
2573
|
+
return null;
|
|
2574
|
+
return { evidence: evidence(c) };
|
|
2575
|
+
}
|
|
2576
|
+
},
|
|
2577
|
+
{
|
|
2578
|
+
id: "privilege.sudo",
|
|
2579
|
+
category: "privilege-bypass",
|
|
2580
|
+
framework_ref: "OWASP LLM06",
|
|
2581
|
+
title: "Runs a command as root (sudo)",
|
|
2582
|
+
tools: ["Bash"],
|
|
2583
|
+
baseSeverity: "high",
|
|
2584
|
+
// root escalation removes a real safety barrier — higher than an in-project op
|
|
2585
|
+
test(ctx) {
|
|
2586
|
+
const c = ctx.command;
|
|
2587
|
+
if (!c || !/(^|\s|\|)sudo\s+/i.test(c))
|
|
2588
|
+
return null;
|
|
2589
|
+
return { evidence: evidence(c) };
|
|
2590
|
+
}
|
|
2591
|
+
},
|
|
2592
|
+
{
|
|
2593
|
+
id: "privilege.chmod_777",
|
|
2594
|
+
category: "privilege-bypass",
|
|
2595
|
+
framework_ref: "OWASP LLM06",
|
|
2596
|
+
title: "Overly permissive permissions (chmod 777)",
|
|
2597
|
+
tools: ["Bash"],
|
|
2598
|
+
baseSeverity: "medium",
|
|
2599
|
+
test(ctx) {
|
|
2600
|
+
const c = ctx.command;
|
|
2601
|
+
if (!c || !/\bchmod\s+(-[a-zA-Z]+\s+)*0?777\b/i.test(c))
|
|
2602
|
+
return null;
|
|
2603
|
+
return { evidence: evidence(c) };
|
|
2604
|
+
}
|
|
2605
|
+
},
|
|
2606
|
+
{
|
|
2607
|
+
id: "privilege.persistence",
|
|
2608
|
+
category: "privilege-bypass",
|
|
2609
|
+
framework_ref: "OWASP LLM06",
|
|
2610
|
+
title: "Installs a persistence mechanism",
|
|
2611
|
+
tools: ["Bash"],
|
|
2612
|
+
baseSeverity: "high",
|
|
2613
|
+
test(ctx) {
|
|
2614
|
+
const c = ctx.command;
|
|
2615
|
+
if (!c)
|
|
2616
|
+
return null;
|
|
2617
|
+
if (!/\bcrontab\s+-|\bsystemctl\s+enable\b|\blaunchctl\s+load\b|>>\s*~\/\.(bashrc|zshrc|profile|bash_profile)\b|\/etc\/(cron|systemd|rc\.local)/i.test(c))
|
|
2618
|
+
return null;
|
|
2619
|
+
return { evidence: evidence(c) };
|
|
2620
|
+
}
|
|
2621
|
+
},
|
|
2622
|
+
{
|
|
2623
|
+
id: "privilege.write_outside_project",
|
|
2624
|
+
category: "privilege-bypass",
|
|
2625
|
+
framework_ref: "OWASP LLM06",
|
|
2626
|
+
title: "Writes a file outside the working project",
|
|
2627
|
+
tools: ["Write", "Edit", "MultiEdit"],
|
|
2628
|
+
baseSeverity: "medium",
|
|
2629
|
+
test(ctx) {
|
|
2630
|
+
const f = ctx.filePath;
|
|
2631
|
+
if (!outsideProject(f, ctx.projectPath))
|
|
2632
|
+
return null;
|
|
2633
|
+
const system = !!f && SYSTEM_PATH.test(f);
|
|
2634
|
+
return { evidence: evidence(f), severity: system ? "high" : "medium", mods: { project: ctx.projectPath, system_path: system } };
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
];
|
|
2638
|
+
function appliesTo(rule, toolName) {
|
|
2639
|
+
return rule.tools === "*" || rule.tools.includes(toolName);
|
|
2640
|
+
}
|
|
2641
|
+
function buildContext(row, projectPath) {
|
|
2642
|
+
let input = null;
|
|
2643
|
+
try {
|
|
2644
|
+
input = row.input_json ? JSON.parse(row.input_json) : null;
|
|
2645
|
+
} catch {
|
|
2646
|
+
input = null;
|
|
2647
|
+
}
|
|
2648
|
+
const command = typeof input?.command === "string" ? input.command : null;
|
|
2649
|
+
const filePath = typeof input?.file_path === "string" ? input.file_path : null;
|
|
2650
|
+
return { toolName: row.tool_name, command, filePath, input, resultSummary: row.result_summary, status: row.status, projectPath };
|
|
2651
|
+
}
|
|
2652
|
+
function detect(db, dirty) {
|
|
2653
|
+
const incremental = dirty != null;
|
|
2654
|
+
if (incremental) {
|
|
2655
|
+
db.exec("DROP TABLE IF EXISTS _dirty_sec");
|
|
2656
|
+
db.exec("CREATE TEMP TABLE _dirty_sec (id TEXT PRIMARY KEY)");
|
|
2657
|
+
const ins = db.prepare("INSERT OR IGNORE INTO _dirty_sec (id) VALUES (?)");
|
|
2658
|
+
db.transaction((ids) => {
|
|
2659
|
+
for (const id of ids)
|
|
2660
|
+
ins.run(id);
|
|
2661
|
+
})(dirty);
|
|
2662
|
+
}
|
|
2663
|
+
const scope = incremental ? " WHERE tc.session_id IN (SELECT id FROM _dirty_sec)" : "";
|
|
2664
|
+
const projPath = /* @__PURE__ */ new Map();
|
|
2665
|
+
for (const r of db.prepare(`SELECT s.id id, p.path path FROM sessions s JOIN projects p ON p.id = s.project_id${incremental ? " WHERE s.id IN (SELECT id FROM _dirty_sec)" : ""}`).all()) {
|
|
2666
|
+
projPath.set(r.id, r.path);
|
|
2667
|
+
}
|
|
2668
|
+
const rows = db.prepare(`SELECT tc.id, tc.session_id, tc.event_uuid, tc.turn_id, tc.tool_name, tc.input_json, tc.result_summary, tc.status
|
|
2669
|
+
FROM tool_calls tc${scope}`).all();
|
|
2670
|
+
const insert = db.prepare(`INSERT INTO findings (id, session_id, tool_call_id, event_uuid, turn_id, rule_id, category, framework_ref, severity, title, evidence, signals_json, detector_version)
|
|
2671
|
+
VALUES (@id, @session_id, @tool_call_id, @event_uuid, @turn_id, @rule_id, @category, @framework_ref, @severity, @title, @evidence, @signals_json, @detector_version)
|
|
2672
|
+
ON CONFLICT(id) DO NOTHING`);
|
|
2673
|
+
const delScope = incremental ? "DELETE FROM findings WHERE session_id IN (SELECT id FROM _dirty_sec)" : "DELETE FROM findings";
|
|
2674
|
+
const tx = db.transaction(() => {
|
|
2675
|
+
db.exec(delScope);
|
|
2676
|
+
for (const row of rows) {
|
|
2677
|
+
const ctx = buildContext(row, projPath.get(row.session_id) ?? null);
|
|
2678
|
+
for (const rule of RULES) {
|
|
2679
|
+
if (!appliesTo(rule, ctx.toolName))
|
|
2680
|
+
continue;
|
|
2681
|
+
const m = rule.test(ctx);
|
|
2682
|
+
if (!m)
|
|
2683
|
+
continue;
|
|
2684
|
+
let severity = m.severity ?? rule.baseSeverity;
|
|
2685
|
+
const mods = { ...m.mods ?? {} };
|
|
2686
|
+
if (row.status === "error") {
|
|
2687
|
+
severity = bumpSeverity(severity, -1);
|
|
2688
|
+
mods.attempted = true;
|
|
2689
|
+
}
|
|
2690
|
+
const signals = {
|
|
2691
|
+
rule: rule.id,
|
|
2692
|
+
category: rule.category,
|
|
2693
|
+
framework_ref: rule.framework_ref,
|
|
2694
|
+
tool_name: ctx.toolName,
|
|
2695
|
+
base_severity: rule.baseSeverity,
|
|
2696
|
+
severity,
|
|
2697
|
+
status: row.status,
|
|
2698
|
+
modifiers: mods,
|
|
2699
|
+
detector_version: DETECTOR_VERSION
|
|
2700
|
+
};
|
|
2701
|
+
insert.run({
|
|
2702
|
+
id: findingId(row.id, rule.id),
|
|
2703
|
+
session_id: row.session_id,
|
|
2704
|
+
tool_call_id: row.id,
|
|
2705
|
+
event_uuid: row.event_uuid,
|
|
2706
|
+
turn_id: row.turn_id,
|
|
2707
|
+
rule_id: rule.id,
|
|
2708
|
+
category: rule.category,
|
|
2709
|
+
framework_ref: rule.framework_ref,
|
|
2710
|
+
severity,
|
|
2711
|
+
title: rule.title,
|
|
2712
|
+
evidence: m.evidence,
|
|
2713
|
+
signals_json: JSON.stringify(signals),
|
|
2714
|
+
detector_version: DETECTOR_VERSION
|
|
2715
|
+
});
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
});
|
|
2719
|
+
tx();
|
|
2720
|
+
if (incremental)
|
|
2721
|
+
db.exec("DROP TABLE IF EXISTS _dirty_sec");
|
|
2722
|
+
const count = db.prepare("SELECT COUNT(*) n FROM findings").get().n;
|
|
2723
|
+
return { count, version: DETECTOR_VERSION };
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
// ../ingest/dist/pipeline.js
|
|
2727
|
+
import { createHash as createHash2 } from "crypto";
|
|
2244
2728
|
function newStats() {
|
|
2245
2729
|
return { files: 0, skipped: 0, malformed: 0, newEvents: 0 };
|
|
2246
2730
|
}
|
|
@@ -2278,7 +2762,7 @@ function skillSummary(body) {
|
|
|
2278
2762
|
return null;
|
|
2279
2763
|
}
|
|
2280
2764
|
function skillVersionId(name, body) {
|
|
2281
|
-
return
|
|
2765
|
+
return createHash2("sha1").update(name).update("\0").update(body).digest("hex").slice(0, 16);
|
|
2282
2766
|
}
|
|
2283
2767
|
function pruneExcluded(db, excludedPaths) {
|
|
2284
2768
|
if (!excludedPaths.length)
|
|
@@ -2312,6 +2796,7 @@ function pruneExcluded(db, excludedPaths) {
|
|
|
2312
2796
|
const files = db.prepare("SELECT DISTINCT source_file f FROM events WHERE session_id IN (SELECT id FROM _prune) AND source_file IS NOT NULL").all().map((r) => r.f);
|
|
2313
2797
|
db.pragma("foreign_keys = OFF");
|
|
2314
2798
|
db.transaction(() => {
|
|
2799
|
+
db.exec("DELETE FROM findings WHERE session_id IN (SELECT id FROM _prune)");
|
|
2315
2800
|
for (const t of ["token_usage", "tool_calls", "turns", "events"])
|
|
2316
2801
|
db.exec(`DELETE FROM ${t} WHERE session_id IN (SELECT id FROM _prune)`);
|
|
2317
2802
|
db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id IN (SELECT id FROM _prune)");
|
|
@@ -2544,6 +3029,7 @@ function rebuildDerived(db, dirty) {
|
|
|
2544
3029
|
`);
|
|
2545
3030
|
db.exec(`DELETE FROM sessions WHERE event_count = 0${andId}`);
|
|
2546
3031
|
db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id NOT IN (SELECT id FROM sessions)");
|
|
3032
|
+
db.exec("DELETE FROM findings WHERE session_id NOT IN (SELECT id FROM sessions)");
|
|
2547
3033
|
if (!incremental) {
|
|
2548
3034
|
db.exec("DELETE FROM skills WHERE id NOT IN (SELECT skill_id FROM tool_calls WHERE skill_id IS NOT NULL)");
|
|
2549
3035
|
}
|
|
@@ -2609,7 +3095,7 @@ function prepareStatements(db) {
|
|
|
2609
3095
|
workflow_run_id = COALESCE(excluded.workflow_run_id, workflow_run_id)`)
|
|
2610
3096
|
};
|
|
2611
3097
|
}
|
|
2612
|
-
function ingestFile(db, stmts, adapter, file, lines, meta,
|
|
3098
|
+
function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
|
|
2613
3099
|
const sessionMeta = {};
|
|
2614
3100
|
let eventsInFile = 0;
|
|
2615
3101
|
const tx = db.transaction(() => {
|
|
@@ -2658,13 +3144,13 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
|
|
|
2658
3144
|
}
|
|
2659
3145
|
let projectId = null;
|
|
2660
3146
|
if (sessionMeta.cwd) {
|
|
2661
|
-
projectId =
|
|
3147
|
+
projectId = createHash2("sha1").update(`${adapter.agentId}\0${sessionMeta.cwd}`).digest("hex").slice(0, 16);
|
|
2662
3148
|
stmts.upsertProject.run({
|
|
2663
3149
|
id: projectId,
|
|
2664
3150
|
agent_id: adapter.agentId,
|
|
2665
3151
|
path: sessionMeta.cwd,
|
|
2666
3152
|
encoded_dir: file.encodedDir || null,
|
|
2667
|
-
now
|
|
3153
|
+
now: now2
|
|
2668
3154
|
});
|
|
2669
3155
|
}
|
|
2670
3156
|
stmts.upsertSession.run({
|
|
@@ -2686,7 +3172,7 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
|
|
|
2686
3172
|
mtime_ms: Math.trunc(meta.mtimeMs),
|
|
2687
3173
|
sha256: meta.hash,
|
|
2688
3174
|
events_ingested: eventsInFile,
|
|
2689
|
-
ingested_at:
|
|
3175
|
+
ingested_at: now2
|
|
2690
3176
|
});
|
|
2691
3177
|
});
|
|
2692
3178
|
tx();
|
|
@@ -2697,7 +3183,7 @@ import { readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as
|
|
|
2697
3183
|
import { basename as basename3, join as join5 } from "path";
|
|
2698
3184
|
|
|
2699
3185
|
// ../ingest/dist/redact.js
|
|
2700
|
-
import { createHash as
|
|
3186
|
+
import { createHash as createHash3, randomBytes } from "crypto";
|
|
2701
3187
|
function parseExcludes(csv) {
|
|
2702
3188
|
return (csv ?? "").split(",").map((s) => s.trim()).filter(Boolean).map(encodeProjectPath);
|
|
2703
3189
|
}
|
|
@@ -2706,17 +3192,17 @@ function isExcludedArchivePath(filePath, excluded) {
|
|
|
2706
3192
|
}
|
|
2707
3193
|
|
|
2708
3194
|
// ../ingest/dist/fileread.js
|
|
2709
|
-
import { createHash as
|
|
3195
|
+
import { createHash as createHash4 } from "crypto";
|
|
2710
3196
|
import { openSync as openSync2, readSync as readSync2, closeSync as closeSync2 } from "fs";
|
|
2711
3197
|
import { StringDecoder } from "string_decoder";
|
|
2712
3198
|
var STREAM_THRESHOLD = 8 * 1024 * 1024;
|
|
2713
3199
|
var CHUNK2 = 64 * 1024;
|
|
2714
3200
|
function sha256(buf) {
|
|
2715
|
-
return
|
|
3201
|
+
return createHash4("sha256").update(buf).digest("hex");
|
|
2716
3202
|
}
|
|
2717
3203
|
function sha256File(path) {
|
|
2718
3204
|
const fd = openSync2(path, "r");
|
|
2719
|
-
const h =
|
|
3205
|
+
const h = createHash4("sha256");
|
|
2720
3206
|
const buf = Buffer.allocUnsafe(CHUNK2);
|
|
2721
3207
|
try {
|
|
2722
3208
|
let n;
|
|
@@ -2803,7 +3289,7 @@ function cleanStr(v) {
|
|
|
2803
3289
|
return null;
|
|
2804
3290
|
return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\[[0-9;]+m\]?/g, "").trim() || null;
|
|
2805
3291
|
}
|
|
2806
|
-
function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs,
|
|
3292
|
+
function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now2, stats, full) {
|
|
2807
3293
|
let paths = discoverSidecars(sourceArchiveDir);
|
|
2808
3294
|
if (excludedDirs.length)
|
|
2809
3295
|
paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
|
|
@@ -2846,7 +3332,7 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
|
|
|
2846
3332
|
}
|
|
2847
3333
|
const hash = sha256(buf);
|
|
2848
3334
|
if (prev && prev.sha256 === hash) {
|
|
2849
|
-
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at:
|
|
3335
|
+
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
|
|
2850
3336
|
stats.skipped++;
|
|
2851
3337
|
continue;
|
|
2852
3338
|
}
|
|
@@ -2884,9 +3370,9 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
|
|
|
2884
3370
|
duration_ms: durationMs2,
|
|
2885
3371
|
started_at: startedAt,
|
|
2886
3372
|
ended_at: endedAt,
|
|
2887
|
-
ingested_at:
|
|
3373
|
+
ingested_at: now2
|
|
2888
3374
|
});
|
|
2889
|
-
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at:
|
|
3375
|
+
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
|
|
2890
3376
|
stats.upserted++;
|
|
2891
3377
|
}
|
|
2892
3378
|
}
|
|
@@ -2925,7 +3411,7 @@ function discoverMeta(sourceArchiveDir) {
|
|
|
2925
3411
|
}
|
|
2926
3412
|
var intOrNull2 = (v) => typeof v === "number" && Number.isFinite(v) ? Math.trunc(v) : null;
|
|
2927
3413
|
var strOrNull2 = (v) => typeof v === "string" && v ? v : null;
|
|
2928
|
-
function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs,
|
|
3414
|
+
function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2, stats, full) {
|
|
2929
3415
|
let paths = discoverMeta(sourceArchiveDir);
|
|
2930
3416
|
if (excludedDirs.length)
|
|
2931
3417
|
paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
|
|
@@ -2960,7 +3446,7 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, s
|
|
|
2960
3446
|
}
|
|
2961
3447
|
const hash = sha256(buf);
|
|
2962
3448
|
if (prev && prev.sha256 === hash) {
|
|
2963
|
-
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at:
|
|
3449
|
+
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
|
|
2964
3450
|
stats.skipped++;
|
|
2965
3451
|
continue;
|
|
2966
3452
|
}
|
|
@@ -2983,9 +3469,9 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, s
|
|
|
2983
3469
|
agent_description: strOrNull2(d.description),
|
|
2984
3470
|
spawn_depth: intOrNull2(d.spawnDepth),
|
|
2985
3471
|
tool_use_id: strOrNull2(d.toolUseId),
|
|
2986
|
-
ingested_at:
|
|
3472
|
+
ingested_at: now2
|
|
2987
3473
|
});
|
|
2988
|
-
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at:
|
|
3474
|
+
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
|
|
2989
3475
|
stats.upserted++;
|
|
2990
3476
|
}
|
|
2991
3477
|
}
|
|
@@ -3027,7 +3513,7 @@ function sessionFromPath2(path) {
|
|
|
3027
3513
|
const i = parts.indexOf("tool-results");
|
|
3028
3514
|
return i > 0 ? parts[i - 1] : null;
|
|
3029
3515
|
}
|
|
3030
|
-
function ingestToolResults(db, sourceArchiveDir, excludedDirs,
|
|
3516
|
+
function ingestToolResults(db, sourceArchiveDir, excludedDirs, now2, stats, full) {
|
|
3031
3517
|
let paths = discoverResults(sourceArchiveDir);
|
|
3032
3518
|
if (excludedDirs.length)
|
|
3033
3519
|
paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
|
|
@@ -3061,7 +3547,7 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full)
|
|
|
3061
3547
|
}
|
|
3062
3548
|
const hash = sha256(buf);
|
|
3063
3549
|
if (prev && prev.sha256 === hash) {
|
|
3064
|
-
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at:
|
|
3550
|
+
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
|
|
3065
3551
|
stats.skipped++;
|
|
3066
3552
|
continue;
|
|
3067
3553
|
}
|
|
@@ -3071,9 +3557,9 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full)
|
|
|
3071
3557
|
path,
|
|
3072
3558
|
bytes: st.size,
|
|
3073
3559
|
text: buf.toString("utf8"),
|
|
3074
|
-
ingested_at:
|
|
3560
|
+
ingested_at: now2
|
|
3075
3561
|
});
|
|
3076
|
-
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at:
|
|
3562
|
+
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
|
|
3077
3563
|
stats.upserted++;
|
|
3078
3564
|
}
|
|
3079
3565
|
}
|
|
@@ -3115,7 +3601,7 @@ function runIngest(argv = process.argv.slice(2)) {
|
|
|
3115
3601
|
const adapterList = [new ClaudeCodeAdapter()];
|
|
3116
3602
|
const adapterById = new Map(adapterList.map((a) => [a.agentId, a]));
|
|
3117
3603
|
const sources = loadSources();
|
|
3118
|
-
const
|
|
3604
|
+
const now2 = (/* @__PURE__ */ new Date()).toISOString();
|
|
3119
3605
|
const excludePaths = loadExcludes();
|
|
3120
3606
|
const excludedDirs = parseExcludes(excludePaths.join(","));
|
|
3121
3607
|
const pruned = pruneExcluded(db, excludePaths);
|
|
@@ -3156,21 +3642,22 @@ function runIngest(argv = process.argv.slice(2)) {
|
|
|
3156
3642
|
mtime_ms: mtimeMs,
|
|
3157
3643
|
sha256: hash,
|
|
3158
3644
|
events_ingested: prev.events_ingested,
|
|
3159
|
-
ingested_at:
|
|
3645
|
+
ingested_at: now2
|
|
3160
3646
|
});
|
|
3161
3647
|
stats.skipped++;
|
|
3162
3648
|
continue;
|
|
3163
3649
|
}
|
|
3164
3650
|
const lines = small ? buf.toString("utf8").split("\n") : streamLines(file.path);
|
|
3165
|
-
ingestFile(db, stmts, adapter, file, lines, { size: st.size, mtimeMs, hash },
|
|
3651
|
+
ingestFile(db, stmts, adapter, file, lines, { size: st.size, mtimeMs, hash }, now2, stats);
|
|
3166
3652
|
dirty.add(file.sessionId);
|
|
3167
3653
|
}
|
|
3168
|
-
ingestWorkflowResults(db, join8(archiveRoot, source.label), source.label, excludedDirs,
|
|
3169
|
-
ingestSubagentMeta(db, join8(archiveRoot, source.label), source.label, excludedDirs,
|
|
3170
|
-
ingestToolResults(db, join8(archiveRoot, source.label), excludedDirs,
|
|
3654
|
+
ingestWorkflowResults(db, join8(archiveRoot, source.label), source.label, excludedDirs, now2, wfStats, args.full);
|
|
3655
|
+
ingestSubagentMeta(db, join8(archiveRoot, source.label), source.label, excludedDirs, now2, metaStats, args.full);
|
|
3656
|
+
ingestToolResults(db, join8(archiveRoot, source.label), excludedDirs, now2, trStats, args.full);
|
|
3171
3657
|
}
|
|
3172
3658
|
const expanded = rebuildDerived(db, args.full ? null : dirty);
|
|
3173
3659
|
const classified = classify(db, args.full ? null : expanded);
|
|
3660
|
+
const detected = detect(db, args.full ? null : expanded);
|
|
3174
3661
|
const count = (sql) => db.prepare(sql).get().n;
|
|
3175
3662
|
const sessions = count("SELECT COUNT(*) n FROM sessions");
|
|
3176
3663
|
const turns = count("SELECT COUNT(*) n FROM turns");
|
|
@@ -3186,12 +3673,16 @@ function runIngest(argv = process.argv.slice(2)) {
|
|
|
3186
3673
|
const wfRuns = count("SELECT COUNT(*) n FROM workflow_results");
|
|
3187
3674
|
const metaRows = count("SELECT COUNT(*) n FROM session_meta");
|
|
3188
3675
|
const trRows = count("SELECT COUNT(*) n FROM tool_results");
|
|
3676
|
+
const sevCounts = db.prepare("SELECT severity, COUNT(*) n FROM findings GROUP BY severity").all();
|
|
3677
|
+
const sevMap = Object.fromEntries(sevCounts.map((r) => [r.severity, r.n]));
|
|
3678
|
+
const sevLine = ["critical", "high", "medium", "low", "info"].map((s) => `${s}=${sevMap[s] ?? 0}`).join(" ");
|
|
3189
3679
|
db.close();
|
|
3190
3680
|
console.log(`agent-lens-ingest: files=${stats.files} skipped=${stats.skipped} new_events=${stats.newEvents} malformed=${stats.malformed}${pruned ? ` excluded_pruned=${pruned}` : ""}
|
|
3191
3681
|
sessions=${sessions} turns=${turns} events=${events} tool_calls=${tools} classified=${classified.count}
|
|
3192
3682
|
workflow_results=${wfRuns} (sidecar upserted=${wfStats.upserted} skipped=${wfStats.skipped} malformed=${wfStats.malformed})
|
|
3193
3683
|
session_meta=${metaRows} (upserted=${metaStats.upserted} skipped=${metaStats.skipped} malformed=${metaStats.malformed})
|
|
3194
3684
|
tool_results=${trRows} (upserted=${trStats.upserted} skipped=${trStats.skipped} malformed=${trStats.malformed})
|
|
3685
|
+
findings=${detected.count} (${sevLine})
|
|
3195
3686
|
tokens=${totalTokens.toLocaleString()} est_cost=$${cost.toFixed(2)} db=${dbPath}`);
|
|
3196
3687
|
}
|
|
3197
3688
|
function runMetrics(argv = process.argv.slice(2)) {
|
|
@@ -3207,8 +3698,9 @@ function runMetrics(argv = process.argv.slice(2)) {
|
|
|
3207
3698
|
}
|
|
3208
3699
|
const db = openDb(dbPath);
|
|
3209
3700
|
const r = classify(db);
|
|
3701
|
+
const d = detect(db);
|
|
3210
3702
|
db.close();
|
|
3211
|
-
console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} db=${dbPath}`);
|
|
3703
|
+
console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} findings=${d.count} detector_version=${DETECTOR_VERSION} db=${dbPath}`);
|
|
3212
3704
|
}
|
|
3213
3705
|
|
|
3214
3706
|
// ../server/dist/run.js
|
|
@@ -3281,9 +3773,9 @@ function listSessions(db, f) {
|
|
|
3281
3773
|
if (f.project)
|
|
3282
3774
|
where.push("s.project_id = ?"), params.push(f.project);
|
|
3283
3775
|
if (f.from)
|
|
3284
|
-
where.push("s.started_at >= ?"), params.push(f.from);
|
|
3776
|
+
where.push("date(s.started_at) >= date(?)"), params.push(f.from);
|
|
3285
3777
|
if (f.to)
|
|
3286
|
-
where.push("s.started_at <= ?"), params.push(f.to);
|
|
3778
|
+
where.push("date(s.started_at) <= date(?)"), params.push(f.to);
|
|
3287
3779
|
if (f.kind === "main")
|
|
3288
3780
|
where.push("s.is_sidechain = 0");
|
|
3289
3781
|
if (f.kind === "subagent")
|
|
@@ -3390,6 +3882,33 @@ function getSession(db, id) {
|
|
|
3390
3882
|
t.full_result = { text: full.text, bytes: full.bytes };
|
|
3391
3883
|
}
|
|
3392
3884
|
}
|
|
3885
|
+
const sessionFindings = [];
|
|
3886
|
+
if (tableExists(db, "findings")) {
|
|
3887
|
+
const findingRows = db.prepare(`SELECT id, tool_call_id, event_uuid, turn_id, rule_id, category, framework_ref, severity, title, evidence, signals_json
|
|
3888
|
+
FROM findings WHERE session_id = ?`).all(id);
|
|
3889
|
+
const byToolCall = /* @__PURE__ */ new Map();
|
|
3890
|
+
for (const fr of findingRows) {
|
|
3891
|
+
const f = {
|
|
3892
|
+
id: fr.id,
|
|
3893
|
+
tool_call_id: fr.tool_call_id,
|
|
3894
|
+
event_uuid: fr.event_uuid,
|
|
3895
|
+
turn_id: fr.turn_id,
|
|
3896
|
+
rule_id: fr.rule_id,
|
|
3897
|
+
category: fr.category,
|
|
3898
|
+
framework_ref: fr.framework_ref,
|
|
3899
|
+
severity: fr.severity,
|
|
3900
|
+
title: fr.title,
|
|
3901
|
+
evidence: fr.evidence,
|
|
3902
|
+
signals: safeJson(fr.signals_json)
|
|
3903
|
+
};
|
|
3904
|
+
sessionFindings.push(f);
|
|
3905
|
+
if (fr.tool_call_id)
|
|
3906
|
+
(byToolCall.get(fr.tool_call_id) ?? byToolCall.set(fr.tool_call_id, []).get(fr.tool_call_id)).push(f);
|
|
3907
|
+
}
|
|
3908
|
+
for (const t of toolRows)
|
|
3909
|
+
t.findings = byToolCall.get(t.id) ?? [];
|
|
3910
|
+
sessionFindings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity));
|
|
3911
|
+
}
|
|
3393
3912
|
const toolsByEvent = /* @__PURE__ */ new Map();
|
|
3394
3913
|
for (const t of toolRows) {
|
|
3395
3914
|
if (!t.event_uuid)
|
|
@@ -3478,7 +3997,7 @@ function getSession(db, id) {
|
|
|
3478
3997
|
FROM tool_calls tc LEFT JOIN turns t ON t.id = tc.turn_id
|
|
3479
3998
|
WHERE tc.session_id = ? AND tc.workflow_run_id IS NOT NULL
|
|
3480
3999
|
ORDER BY t.seq`).all(id);
|
|
3481
|
-
return { session, turns, events, classification, parent, children, workflow_runs };
|
|
4000
|
+
return { session, turns, events, classification, parent, children, workflow_runs, findings: sessionFindings };
|
|
3482
4001
|
}
|
|
3483
4002
|
function xmlTag(text, tag) {
|
|
3484
4003
|
return text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim() ?? null;
|
|
@@ -3634,6 +4153,101 @@ function getSkill(db, name) {
|
|
|
3634
4153
|
const call_count = sessions.reduce((a, s) => a + s.fire_count, 0);
|
|
3635
4154
|
return { name, versions, sessions, call_count };
|
|
3636
4155
|
}
|
|
4156
|
+
var SEVERITY_RANK_SQL = "CASE f.severity WHEN 'critical' THEN 5 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'low' THEN 2 WHEN 'info' THEN 1 ELSE 0 END";
|
|
4157
|
+
var MUTED_SQL = `EXISTS (SELECT 1 FROM triage.muted_rules m WHERE m.rule_id = f.rule_id
|
|
4158
|
+
AND (m.scope='global' OR (m.scope='project' AND m.scope_id = s.project_id) OR (m.scope='source' AND m.scope_id = s.source_id)))`;
|
|
4159
|
+
function triageAttached(db) {
|
|
4160
|
+
return db.prepare("PRAGMA database_list").all().some((r) => r.name === "triage");
|
|
4161
|
+
}
|
|
4162
|
+
function findingWhere(f) {
|
|
4163
|
+
const sql = [];
|
|
4164
|
+
const params = [];
|
|
4165
|
+
if (f.severity)
|
|
4166
|
+
sql.push("f.severity = ?"), params.push(f.severity);
|
|
4167
|
+
if (f.category)
|
|
4168
|
+
sql.push("f.category = ?"), params.push(f.category);
|
|
4169
|
+
if (f.rule)
|
|
4170
|
+
sql.push("f.rule_id = ?"), params.push(f.rule);
|
|
4171
|
+
if (f.session)
|
|
4172
|
+
sql.push("f.session_id = ?"), params.push(f.session);
|
|
4173
|
+
if (f.source)
|
|
4174
|
+
sql.push("s.source_id = ?"), params.push(f.source);
|
|
4175
|
+
if (f.project)
|
|
4176
|
+
sql.push("s.project_id = ?"), params.push(f.project);
|
|
4177
|
+
if (f.from)
|
|
4178
|
+
sql.push("date(s.started_at) >= date(?)"), params.push(f.from);
|
|
4179
|
+
if (f.to)
|
|
4180
|
+
sql.push("date(s.started_at) <= date(?)"), params.push(f.to);
|
|
4181
|
+
return { sql, params };
|
|
4182
|
+
}
|
|
4183
|
+
function statusWhere(status, attached) {
|
|
4184
|
+
if (!attached)
|
|
4185
|
+
return status === "dismissed" || status === "muted" ? "1 = 0" : "";
|
|
4186
|
+
switch (status) {
|
|
4187
|
+
case "dismissed":
|
|
4188
|
+
return "d.finding_id IS NOT NULL";
|
|
4189
|
+
case "muted":
|
|
4190
|
+
return `d.finding_id IS NULL AND ${MUTED_SQL}`;
|
|
4191
|
+
case "all":
|
|
4192
|
+
return "";
|
|
4193
|
+
default:
|
|
4194
|
+
return `d.finding_id IS NULL AND NOT ${MUTED_SQL}`;
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
function listFindings(db, f) {
|
|
4198
|
+
if (!tableExists(db, "findings"))
|
|
4199
|
+
return { total: 0, findings: [] };
|
|
4200
|
+
const attached = triageAttached(db);
|
|
4201
|
+
const w = findingWhere(f);
|
|
4202
|
+
const st = statusWhere(f.status ?? "open", attached);
|
|
4203
|
+
const where = [...w.sql, ...st ? [st] : []];
|
|
4204
|
+
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
4205
|
+
const dismissJoin = attached ? "LEFT JOIN triage.dismissed_findings d ON d.finding_id = f.id" : "";
|
|
4206
|
+
const triageCols = attached ? `, (d.finding_id IS NOT NULL) AS dismissed, d.note AS dismiss_note, d.dismissed_at, ${MUTED_SQL} AS muted` : `, 0 AS dismissed, NULL AS dismiss_note, NULL AS dismissed_at, 0 AS muted`;
|
|
4207
|
+
const total = db.prepare(`SELECT COUNT(*) n FROM findings f JOIN sessions s ON s.id = f.session_id ${dismissJoin} ${whereSql}`).get(...w.params).n;
|
|
4208
|
+
const ORDER = {
|
|
4209
|
+
severity: SEVERITY_RANK_SQL,
|
|
4210
|
+
session: "f.session_id",
|
|
4211
|
+
rule: "f.rule_id",
|
|
4212
|
+
category: "f.category",
|
|
4213
|
+
time: "s.started_at"
|
|
4214
|
+
};
|
|
4215
|
+
const orderCol = ORDER[f.sort ?? "severity"] ?? SEVERITY_RANK_SQL;
|
|
4216
|
+
const dir = f.dir === "asc" ? "ASC" : "DESC";
|
|
4217
|
+
const findings = db.prepare(`SELECT f.id, f.session_id, f.tool_call_id, f.event_uuid, f.turn_id, f.rule_id, f.category,
|
|
4218
|
+
f.framework_ref, f.severity, f.title, f.evidence, tc.tool_name,
|
|
4219
|
+
COALESCE(s.ai_title, s.slug) AS session_title, s.source_id, s.is_sidechain,
|
|
4220
|
+
s.started_at, p.path AS project_path, s.project_id ${triageCols}
|
|
4221
|
+
FROM findings f
|
|
4222
|
+
JOIN sessions s ON s.id = f.session_id
|
|
4223
|
+
LEFT JOIN projects p ON p.id = s.project_id
|
|
4224
|
+
LEFT JOIN tool_calls tc ON tc.id = f.tool_call_id
|
|
4225
|
+
${dismissJoin}
|
|
4226
|
+
${whereSql}
|
|
4227
|
+
ORDER BY ${orderCol} ${dir}, ${SEVERITY_RANK_SQL} DESC, f.id ASC
|
|
4228
|
+
LIMIT ? OFFSET ?`).all(...w.params, f.limit, f.offset);
|
|
4229
|
+
return { total, findings };
|
|
4230
|
+
}
|
|
4231
|
+
function securitySummary(db) {
|
|
4232
|
+
const categories = SECURITY_CATEGORIES;
|
|
4233
|
+
if (!tableExists(db, "findings")) {
|
|
4234
|
+
return { total: 0, sessions_flagged: 0, dismissed: 0, muted: 0, by_severity: [], by_category: [], by_rule: [], categories };
|
|
4235
|
+
}
|
|
4236
|
+
const attached = triageAttached(db);
|
|
4237
|
+
const dismissJoin = attached ? "LEFT JOIN triage.dismissed_findings d ON d.finding_id = f.id" : "";
|
|
4238
|
+
const base = `FROM findings f JOIN sessions s ON s.id = f.session_id ${dismissJoin}`;
|
|
4239
|
+
const openWhere = attached ? `WHERE d.finding_id IS NULL AND NOT ${MUTED_SQL}` : "";
|
|
4240
|
+
const scalar = (sql) => db.prepare(sql).get().n;
|
|
4241
|
+
const total = scalar(`SELECT COUNT(*) n ${base} ${openWhere}`);
|
|
4242
|
+
const sessions_flagged = scalar(`SELECT COUNT(DISTINCT f.session_id) n ${base} ${openWhere}`);
|
|
4243
|
+
const dismissed = attached ? scalar(`SELECT COUNT(*) n FROM findings f LEFT JOIN triage.dismissed_findings d ON d.finding_id = f.id WHERE d.finding_id IS NOT NULL`) : 0;
|
|
4244
|
+
const muted = attached ? scalar(`SELECT COUNT(*) n ${base} WHERE d.finding_id IS NULL AND ${MUTED_SQL}`) : 0;
|
|
4245
|
+
const by_severity = db.prepare(`SELECT f.severity AS severity, COUNT(*) n ${base} ${openWhere} GROUP BY f.severity ORDER BY ${SEVERITY_RANK_SQL} DESC`).all();
|
|
4246
|
+
const by_category = db.prepare(`SELECT f.category AS category, COUNT(*) n ${base} ${openWhere} GROUP BY f.category ORDER BY n DESC`).all();
|
|
4247
|
+
const by_rule = db.prepare(`SELECT f.rule_id AS rule_id, f.category AS category, f.title AS title, COUNT(*) n, MAX(${SEVERITY_RANK_SQL}) AS rank
|
|
4248
|
+
${base} ${openWhere} GROUP BY f.rule_id ORDER BY rank DESC, n DESC`).all();
|
|
4249
|
+
return { total, sessions_flagged, dismissed, muted, by_severity, by_category, by_rule, categories };
|
|
4250
|
+
}
|
|
3637
4251
|
|
|
3638
4252
|
// ../server/dist/app.js
|
|
3639
4253
|
import { existsSync as existsSync6 } from "fs";
|
|
@@ -3647,9 +4261,9 @@ function sessionWhere(f) {
|
|
|
3647
4261
|
if (f.source)
|
|
3648
4262
|
where.push("s.source_id = ?"), params.push(f.source);
|
|
3649
4263
|
if (f.from)
|
|
3650
|
-
where.push("s.started_at >= ?"), params.push(f.from);
|
|
4264
|
+
where.push("date(s.started_at) >= date(?)"), params.push(f.from);
|
|
3651
4265
|
if (f.to)
|
|
3652
|
-
where.push("s.started_at <= ?"), params.push(f.to);
|
|
4266
|
+
where.push("date(s.started_at) <= date(?)"), params.push(f.to);
|
|
3653
4267
|
return { sql: where.length ? `WHERE ${where.join(" AND ")}` : "", params };
|
|
3654
4268
|
}
|
|
3655
4269
|
var zeroSplit = () => ({ input: 0, output: 0, cache_creation: 0, cache_read: 0 });
|
|
@@ -3839,9 +4453,93 @@ function runRefresh() {
|
|
|
3839
4453
|
}
|
|
3840
4454
|
}
|
|
3841
4455
|
|
|
4456
|
+
// ../server/dist/triage.js
|
|
4457
|
+
import Database3 from "better-sqlite3";
|
|
4458
|
+
var TRIAGE_SCHEMA_SQL = (
|
|
4459
|
+
/* sql */
|
|
4460
|
+
`
|
|
4461
|
+
PRAGMA journal_mode = WAL;
|
|
4462
|
+
|
|
4463
|
+
-- Findings the user reviewed and marked safe. Keyed by the stable finding id so it survives re-ingest.
|
|
4464
|
+
CREATE TABLE IF NOT EXISTS dismissed_findings (
|
|
4465
|
+
finding_id TEXT PRIMARY KEY, -- findings.id = sha1(tool_call_id, rule_id)
|
|
4466
|
+
tool_call_id TEXT, -- reference only (may be null)
|
|
4467
|
+
rule_id TEXT, -- reference only (may be null)
|
|
4468
|
+
note TEXT,
|
|
4469
|
+
dismissed_at TEXT NOT NULL
|
|
4470
|
+
);
|
|
4471
|
+
|
|
4472
|
+
-- Rules the user muted (suppressed as noise), optionally scoped to a project or source. A muted rule
|
|
4473
|
+
-- hides its current AND future findings from the open view \u2014 the scalable lever for a noisy rule.
|
|
4474
|
+
CREATE TABLE IF NOT EXISTS muted_rules (
|
|
4475
|
+
rule_id TEXT NOT NULL,
|
|
4476
|
+
scope TEXT NOT NULL DEFAULT 'global', -- 'global' | 'project' | 'source'
|
|
4477
|
+
scope_id TEXT NOT NULL DEFAULT '', -- project_id / source_id; '' for global (NOT NULL keeps it in the PK)
|
|
4478
|
+
note TEXT,
|
|
4479
|
+
muted_at TEXT NOT NULL,
|
|
4480
|
+
PRIMARY KEY (rule_id, scope, scope_id)
|
|
4481
|
+
);
|
|
4482
|
+
`
|
|
4483
|
+
);
|
|
4484
|
+
function openTriage(file) {
|
|
4485
|
+
const db = new Database3(file);
|
|
4486
|
+
db.pragma("journal_mode = WAL");
|
|
4487
|
+
db.exec(TRIAGE_SCHEMA_SQL);
|
|
4488
|
+
return db;
|
|
4489
|
+
}
|
|
4490
|
+
var now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
4491
|
+
function dismiss(db, ids, note) {
|
|
4492
|
+
const stmt = db.prepare(`INSERT INTO dismissed_findings (finding_id, note, dismissed_at) VALUES (?, ?, ?)
|
|
4493
|
+
ON CONFLICT(finding_id) DO UPDATE SET note = excluded.note, dismissed_at = excluded.dismissed_at`);
|
|
4494
|
+
const ts = now();
|
|
4495
|
+
const tx = db.transaction((list) => {
|
|
4496
|
+
for (const id of list)
|
|
4497
|
+
stmt.run(id, note ?? null, ts);
|
|
4498
|
+
});
|
|
4499
|
+
tx(ids);
|
|
4500
|
+
return ids.length;
|
|
4501
|
+
}
|
|
4502
|
+
function reopen(db, ids) {
|
|
4503
|
+
const stmt = db.prepare("DELETE FROM dismissed_findings WHERE finding_id = ?");
|
|
4504
|
+
let n = 0;
|
|
4505
|
+
const tx = db.transaction((list) => {
|
|
4506
|
+
for (const id of list)
|
|
4507
|
+
n += stmt.run(id).changes;
|
|
4508
|
+
});
|
|
4509
|
+
tx(ids);
|
|
4510
|
+
return n;
|
|
4511
|
+
}
|
|
4512
|
+
function muteRule(db, ruleId, scope = "global", scopeId = "", note) {
|
|
4513
|
+
db.prepare(`INSERT INTO muted_rules (rule_id, scope, scope_id, note, muted_at) VALUES (?, ?, ?, ?, ?)
|
|
4514
|
+
ON CONFLICT(rule_id, scope, scope_id) DO UPDATE SET note = excluded.note, muted_at = excluded.muted_at`).run(ruleId, scope, scope === "global" ? "" : scopeId, note ?? null, now());
|
|
4515
|
+
}
|
|
4516
|
+
function unmute(db, ruleId, scope = "global", scopeId = "") {
|
|
4517
|
+
return db.prepare("DELETE FROM muted_rules WHERE rule_id = ? AND scope = ? AND scope_id = ?").run(ruleId, scope, scope === "global" ? "" : scopeId).changes;
|
|
4518
|
+
}
|
|
4519
|
+
function listMutes(db) {
|
|
4520
|
+
return db.prepare("SELECT rule_id, scope, scope_id, note, muted_at FROM muted_rules ORDER BY muted_at DESC").all();
|
|
4521
|
+
}
|
|
4522
|
+
|
|
3842
4523
|
// ../server/dist/app.js
|
|
3843
4524
|
async function createApp(db, opts = {}) {
|
|
3844
4525
|
const app = Fastify({ logger: false });
|
|
4526
|
+
let triageDb = null;
|
|
4527
|
+
if (opts.triageDbPath) {
|
|
4528
|
+
triageDb = openTriage(opts.triageDbPath);
|
|
4529
|
+
db.exec(`ATTACH DATABASE '${opts.triageDbPath.replace(/'/g, "''")}' AS triage`);
|
|
4530
|
+
app.addHook("onClose", async () => triageDb?.close());
|
|
4531
|
+
}
|
|
4532
|
+
const guardWrite = (req, reply) => {
|
|
4533
|
+
if (!originAllowed(req.headers.origin)) {
|
|
4534
|
+
reply.code(403).send({ error: { code: "FORBIDDEN_ORIGIN", message: "cross-origin write blocked" } });
|
|
4535
|
+
return false;
|
|
4536
|
+
}
|
|
4537
|
+
if (!triageDb) {
|
|
4538
|
+
reply.code(503).send({ error: { code: "TRIAGE_UNAVAILABLE", message: "triage store not configured" } });
|
|
4539
|
+
return false;
|
|
4540
|
+
}
|
|
4541
|
+
return true;
|
|
4542
|
+
};
|
|
3845
4543
|
app.get("/api/health", async () => {
|
|
3846
4544
|
const s = schemaStatus(db);
|
|
3847
4545
|
return { ok: true, last_ingested: lastIngested(db), schema_version: s.db_version, schema_stale: s.stale };
|
|
@@ -3904,6 +4602,72 @@ async function createApp(db, opts = {}) {
|
|
|
3904
4602
|
return reply.code(404).send({ error: "not found" });
|
|
3905
4603
|
return result;
|
|
3906
4604
|
});
|
|
4605
|
+
app.get("/api/security/summary", async () => securitySummary(db));
|
|
4606
|
+
const findingFilters = (q) => ({
|
|
4607
|
+
severity: q.severity,
|
|
4608
|
+
category: q.category,
|
|
4609
|
+
rule: q.rule,
|
|
4610
|
+
session: q.session,
|
|
4611
|
+
source: q.source,
|
|
4612
|
+
project: q.project,
|
|
4613
|
+
from: q.from,
|
|
4614
|
+
to: q.to,
|
|
4615
|
+
status: ["open", "dismissed", "muted", "all"].find((s) => s === q.status)
|
|
4616
|
+
});
|
|
4617
|
+
app.get("/api/security/findings", async (req) => {
|
|
4618
|
+
const q = req.query;
|
|
4619
|
+
return listFindings(db, {
|
|
4620
|
+
...findingFilters(q),
|
|
4621
|
+
sort: ["severity", "session", "rule", "category", "time"].find((s) => s === q.sort),
|
|
4622
|
+
dir: q.dir === "asc" ? "asc" : q.dir === "desc" ? "desc" : void 0,
|
|
4623
|
+
limit: Math.min(Number(q.limit) || 50, 5e3),
|
|
4624
|
+
offset: Number(q.offset) || 0
|
|
4625
|
+
});
|
|
4626
|
+
});
|
|
4627
|
+
app.get("/api/security/mutes", async () => triageDb ? listMutes(triageDb) : []);
|
|
4628
|
+
app.post("/api/security/dismiss", async (req, reply) => {
|
|
4629
|
+
if (!guardWrite(req, reply))
|
|
4630
|
+
return reply;
|
|
4631
|
+
const b = req.body ?? {};
|
|
4632
|
+
const n = dismiss(triageDb, Array.isArray(b.ids) ? b.ids : [], b.note ?? null);
|
|
4633
|
+
return { ok: true, dismissed: n };
|
|
4634
|
+
});
|
|
4635
|
+
app.post("/api/security/reopen", async (req, reply) => {
|
|
4636
|
+
if (!guardWrite(req, reply))
|
|
4637
|
+
return reply;
|
|
4638
|
+
const b = req.body ?? {};
|
|
4639
|
+
const n = reopen(triageDb, Array.isArray(b.ids) ? b.ids : []);
|
|
4640
|
+
return { ok: true, reopened: n };
|
|
4641
|
+
});
|
|
4642
|
+
app.post("/api/security/dismiss-matching", async (req, reply) => {
|
|
4643
|
+
if (!guardWrite(req, reply))
|
|
4644
|
+
return reply;
|
|
4645
|
+
const b = req.body ?? {};
|
|
4646
|
+
const page = listFindings(db, { ...findingFilters(b.filter ?? {}), status: "open", limit: 1e5, offset: 0 });
|
|
4647
|
+
const ids = page.findings.map((r) => r.id);
|
|
4648
|
+
const n = dismiss(triageDb, ids, b.note ?? null);
|
|
4649
|
+
return { ok: true, dismissed: n };
|
|
4650
|
+
});
|
|
4651
|
+
app.post("/api/security/mute", async (req, reply) => {
|
|
4652
|
+
if (!guardWrite(req, reply))
|
|
4653
|
+
return reply;
|
|
4654
|
+
const b = req.body ?? {};
|
|
4655
|
+
if (!b.rule_id)
|
|
4656
|
+
return reply.code(400).send({ error: { code: "BAD_REQUEST", message: "rule_id required" } });
|
|
4657
|
+
const scope = ["global", "project", "source"].find((s) => s === b.scope) ?? "global";
|
|
4658
|
+
muteRule(triageDb, b.rule_id, scope, b.scope_id ?? "", b.note ?? null);
|
|
4659
|
+
return { ok: true };
|
|
4660
|
+
});
|
|
4661
|
+
app.post("/api/security/unmute", async (req, reply) => {
|
|
4662
|
+
if (!guardWrite(req, reply))
|
|
4663
|
+
return reply;
|
|
4664
|
+
const b = req.body ?? {};
|
|
4665
|
+
if (!b.rule_id)
|
|
4666
|
+
return reply.code(400).send({ error: { code: "BAD_REQUEST", message: "rule_id required" } });
|
|
4667
|
+
const scope = ["global", "project", "source"].find((s) => s === b.scope) ?? "global";
|
|
4668
|
+
const n = unmute(triageDb, b.rule_id, scope, b.scope_id ?? "");
|
|
4669
|
+
return { ok: true, unmuted: n };
|
|
4670
|
+
});
|
|
3907
4671
|
app.get("/api/skills", async (req) => {
|
|
3908
4672
|
const q = req.query;
|
|
3909
4673
|
return listSkills(db, { q: q.q, source: q.source, project: q.project });
|
|
@@ -3973,8 +4737,9 @@ async function startServer() {
|
|
|
3973
4737
|
console.error(`agent-lens-server: db not found: ${dbPath} (run ingest first)`);
|
|
3974
4738
|
process.exit(1);
|
|
3975
4739
|
}
|
|
4740
|
+
const triageDbPath = process.env.AGENT_LENS_TRIAGE_DB || join10(dirname5(dbPath), "triage.db");
|
|
3976
4741
|
const db = openReadonly(dbPath);
|
|
3977
|
-
const app = await createApp(db, { webDist });
|
|
4742
|
+
const app = await createApp(db, { webDist, triageDbPath });
|
|
3978
4743
|
try {
|
|
3979
4744
|
await app.listen({ host, port });
|
|
3980
4745
|
console.log(`agent-lens-server: http://${host}:${port} (db: ${dbPath})`);
|