@swestash/agent-lens 0.4.0 → 0.5.1
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 +852 -36
- package/dist/agent-lens.js.map +1 -1
- package/package.json +1 -1
- package/web/assets/{CopyButton-CDLZMZtV.js → CopyButton-CAUFwuiX.js} +1 -1
- package/web/assets/{Dashboard-BGVInKFn.js → Dashboard-5CwsbaWY.js} +36 -36
- package/web/assets/SecurityView-DCWVNFf5.js +1 -0
- package/web/assets/SessionView-BJywkNAY.js +12 -0
- package/web/assets/{SkillView-z46KtQoA.js → SkillView-BDENXN1E.js} +1 -1
- package/web/assets/{SkillsView-BZ4hNSbk.js → SkillsView-C4SyADuV.js} +1 -1
- package/web/assets/{WorkflowView-eiRtGE5c.js → WorkflowView-BQCMn0Uf.js} +1 -1
- package/web/assets/index-3w3Rb7sW.css +1 -0
- package/web/assets/{index-5gOslh3o.js → index-BR_zJBAm.js} +1 -1
- package/web/assets/{index-Twf3zCly.js → index-dvmn2NT_.js} +12 -12
- package/web/assets/severity-4pf1WhNq.js +1 -0
- package/web/index.html +2 -2
- package/web/assets/SessionView-C9Nz-Buq.js +0 -12
- package/web/assets/index-CXU0GJds.css +0 -1
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,465 @@ 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 = 5;
|
|
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)(?![\w-])/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 stripComments = (c) => c.replace(/(^|\s)#[^\n]*/g, "$1");
|
|
2345
|
+
var neutralizeEcho = (c) => c.replace(/((?:^|[;&|\n(])\s*)(?:echo|printf)\b(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^;&|\n])*/gi, "$1echo");
|
|
2346
|
+
var blankHeredoc = (c) => c.replace(/<<-?\s*(['"]?)(\w+)\1[\s\S]*?\n\s*\2\b/g, "<<HEREDOC");
|
|
2347
|
+
var codeOf = (c) => neutralizeEcho(stripComments(blankHeredoc(c)));
|
|
2348
|
+
var blankQuoted = (c) => c.replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/'[^']*'/g, "''");
|
|
2349
|
+
var bareOf = (c) => blankQuoted(codeOf(c));
|
|
2350
|
+
function execGeneratedScript(cmd) {
|
|
2351
|
+
const targets = /* @__PURE__ */ new Set();
|
|
2352
|
+
for (const m of cmd.matchAll(/(?:>>?|\btee\b\s+(?:-a\s+)?)\s*["']?([A-Za-z0-9._/~-]+)/g)) {
|
|
2353
|
+
if (m[1] && !m[1].startsWith("/dev/"))
|
|
2354
|
+
targets.add(m[1]);
|
|
2355
|
+
}
|
|
2356
|
+
for (const t of targets) {
|
|
2357
|
+
const e = t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2358
|
+
const interp = new RegExp(`\\b(?:sh|bash|zsh|ksh|dash|source)\\s+["']?${e}\\b|(?:^|[\\n;|&(])\\s*\\.\\s+["']?${e}\\b`);
|
|
2359
|
+
const hasPath = /^(\.\/|\/|~\/)/.test(t);
|
|
2360
|
+
const path = hasPath ? new RegExp(`(?:^|[\\n;|&(])\\s*${e}(?:\\s|$|[;&|<>])`) : new RegExp(`(?:^|[\\n;|&(])\\s*\\.\\/${e}\\b`);
|
|
2361
|
+
if (interp.test(cmd) || path.test(cmd))
|
|
2362
|
+
return t;
|
|
2363
|
+
}
|
|
2364
|
+
return null;
|
|
2365
|
+
}
|
|
2366
|
+
var TEMP_PATH = /^(\/tmp\/|\/var\/folders\/|\/private\/var\/folders\/)/;
|
|
2367
|
+
function isAgentOwnedPath(p, configDirs) {
|
|
2368
|
+
if (TEMP_PATH.test(p))
|
|
2369
|
+
return true;
|
|
2370
|
+
if (configDirs.some((d) => p === d || p.startsWith(d + "/")))
|
|
2371
|
+
return true;
|
|
2372
|
+
const tmp = process.env.TMPDIR;
|
|
2373
|
+
return !!tmp && p.startsWith(tmp.replace(/\/$/, "") + "/");
|
|
2374
|
+
}
|
|
2375
|
+
function outsideProject(filePath, projectPath, configDirs) {
|
|
2376
|
+
if (!filePath || !filePath.startsWith("/"))
|
|
2377
|
+
return false;
|
|
2378
|
+
if (isAgentOwnedPath(filePath, configDirs))
|
|
2379
|
+
return false;
|
|
2380
|
+
if (!projectPath)
|
|
2381
|
+
return false;
|
|
2382
|
+
return filePath !== projectPath && !filePath.startsWith(projectPath.replace(/\/$/, "") + "/");
|
|
2383
|
+
}
|
|
2384
|
+
var RULES = [
|
|
2385
|
+
// --- Destructive / data-loss (OWASP ASI02 Tool Misuse) -------------------
|
|
2386
|
+
{
|
|
2387
|
+
id: "destructive.rm_rf",
|
|
2388
|
+
category: "destructive",
|
|
2389
|
+
framework_ref: "OWASP ASI02",
|
|
2390
|
+
title: "Recursive force delete (rm -rf)",
|
|
2391
|
+
tools: ["Bash"],
|
|
2392
|
+
baseSeverity: "high",
|
|
2393
|
+
test(ctx) {
|
|
2394
|
+
const c = ctx.commandBare;
|
|
2395
|
+
if (!c)
|
|
2396
|
+
return null;
|
|
2397
|
+
const rf = /\brm\s+[^|;&\n]*(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r|--recursive[^|;&\n]*--force|--force[^|;&\n]*--recursive)/i;
|
|
2398
|
+
if (!rf.test(c))
|
|
2399
|
+
return null;
|
|
2400
|
+
const dangerousTarget = HOME_OR_ROOT_TARGET.test(c) || /\s\/(?:\s|$)/.test(c) || /\brm\b[^|;&\n]*\*/.test(c);
|
|
2401
|
+
return { evidence: evidence(ctx.command), severity: dangerousTarget ? "critical" : "high", mods: { dangerous_target: dangerousTarget } };
|
|
2402
|
+
}
|
|
2403
|
+
},
|
|
2404
|
+
{
|
|
2405
|
+
id: "destructive.git_reset_hard",
|
|
2406
|
+
category: "destructive",
|
|
2407
|
+
framework_ref: "OWASP ASI02",
|
|
2408
|
+
title: "Discards local changes (git reset --hard)",
|
|
2409
|
+
tools: ["Bash"],
|
|
2410
|
+
baseSeverity: "medium",
|
|
2411
|
+
test(ctx) {
|
|
2412
|
+
const c = ctx.commandBare;
|
|
2413
|
+
if (!c || !/\bgit\s+reset\s+--hard\b/i.test(c))
|
|
2414
|
+
return null;
|
|
2415
|
+
return { evidence: evidence(ctx.command) };
|
|
2416
|
+
}
|
|
2417
|
+
},
|
|
2418
|
+
{
|
|
2419
|
+
id: "destructive.git_force_push",
|
|
2420
|
+
category: "destructive",
|
|
2421
|
+
framework_ref: "OWASP ASI02",
|
|
2422
|
+
title: "Force-push rewrites remote history",
|
|
2423
|
+
tools: ["Bash"],
|
|
2424
|
+
baseSeverity: "medium",
|
|
2425
|
+
test(ctx) {
|
|
2426
|
+
const c = ctx.commandBare;
|
|
2427
|
+
if (!c || !/\bgit\s+push\b[^|;&\n]*(--force\b|--force-with-lease\b|\s-f\b)/i.test(c))
|
|
2428
|
+
return null;
|
|
2429
|
+
const mainBranch = /\b(main|master|release)\b/.test(c);
|
|
2430
|
+
return { evidence: evidence(ctx.command), severity: mainBranch ? "high" : "medium", mods: { protected_branch: mainBranch } };
|
|
2431
|
+
}
|
|
2432
|
+
},
|
|
2433
|
+
{
|
|
2434
|
+
id: "destructive.sql_drop",
|
|
2435
|
+
category: "destructive",
|
|
2436
|
+
framework_ref: "OWASP ASI02",
|
|
2437
|
+
title: "Drops or truncates a database object",
|
|
2438
|
+
tools: "*",
|
|
2439
|
+
baseSeverity: "high",
|
|
2440
|
+
test(ctx) {
|
|
2441
|
+
const query = typeof ctx.input?.query === "string" ? ctx.input.query : null;
|
|
2442
|
+
const hay = ctx.commandCode ?? query;
|
|
2443
|
+
if (!hay || !/\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE(\s+TABLE)?)\b/i.test(hay))
|
|
2444
|
+
return null;
|
|
2445
|
+
return { evidence: evidence(ctx.command ?? query) };
|
|
2446
|
+
}
|
|
2447
|
+
},
|
|
2448
|
+
{
|
|
2449
|
+
id: "destructive.disk_wipe",
|
|
2450
|
+
category: "destructive",
|
|
2451
|
+
framework_ref: "OWASP ASI02",
|
|
2452
|
+
title: "Low-level disk write / wipe (dd, mkfs, shred)",
|
|
2453
|
+
tools: ["Bash"],
|
|
2454
|
+
baseSeverity: "high",
|
|
2455
|
+
test(ctx) {
|
|
2456
|
+
const c = ctx.commandBare;
|
|
2457
|
+
if (!c || !/(\bdd\s+if=|\bmkfs(\.\w+)?\b|\bshred\b)/i.test(c))
|
|
2458
|
+
return null;
|
|
2459
|
+
return { evidence: evidence(ctx.command) };
|
|
2460
|
+
}
|
|
2461
|
+
},
|
|
2462
|
+
{
|
|
2463
|
+
id: "destructive.overwrite_critical",
|
|
2464
|
+
category: "destructive",
|
|
2465
|
+
framework_ref: "OWASP ASI02",
|
|
2466
|
+
title: "Overwrites a critical project file",
|
|
2467
|
+
tools: ["Write", "Edit", "MultiEdit"],
|
|
2468
|
+
baseSeverity: "medium",
|
|
2469
|
+
test(ctx) {
|
|
2470
|
+
const f = ctx.filePath;
|
|
2471
|
+
if (!f)
|
|
2472
|
+
return null;
|
|
2473
|
+
if (!/(^|\/)(package-lock\.json|pnpm-lock\.ya?ml|yarn\.lock|Cargo\.lock|go\.sum|\.github\/workflows\/|\.gitlab-ci\.ya?ml)\b/.test(f))
|
|
2474
|
+
return null;
|
|
2475
|
+
return { evidence: evidence(f) };
|
|
2476
|
+
}
|
|
2477
|
+
},
|
|
2478
|
+
// --- Credential & secret access ------------------------------------------
|
|
2479
|
+
{
|
|
2480
|
+
id: "credential.secret_file_access",
|
|
2481
|
+
category: "credential-access",
|
|
2482
|
+
framework_ref: "MITRE ATLAS: Credential Access",
|
|
2483
|
+
title: "Reads a secret/credential file",
|
|
2484
|
+
tools: ["Read", "Bash"],
|
|
2485
|
+
baseSeverity: "high",
|
|
2486
|
+
test(ctx) {
|
|
2487
|
+
if (isBash(ctx.toolName)) {
|
|
2488
|
+
const cmd = ctx.commandBare;
|
|
2489
|
+
if (!cmd)
|
|
2490
|
+
return null;
|
|
2491
|
+
const segments = cmd.split(/\|\||&&|[|;\n]/);
|
|
2492
|
+
const reads = segments.some((seg) => SECRET_FILE.test(seg) && !EXAMPLE_FILE.test(seg) && CONTENT_READ_CMD.test(seg));
|
|
2493
|
+
return reads ? { evidence: evidence(ctx.command) } : null;
|
|
2494
|
+
}
|
|
2495
|
+
const f = ctx.filePath;
|
|
2496
|
+
if (!f || !SECRET_FILE.test(f) || EXAMPLE_FILE.test(f))
|
|
2497
|
+
return null;
|
|
2498
|
+
return { evidence: evidence(f) };
|
|
2499
|
+
}
|
|
2500
|
+
},
|
|
2501
|
+
{
|
|
2502
|
+
id: "credential.secret_in_data",
|
|
2503
|
+
category: "credential-access",
|
|
2504
|
+
framework_ref: "MITRE ATLAS: Credential Access",
|
|
2505
|
+
title: "Secret-looking value in command or output",
|
|
2506
|
+
tools: "*",
|
|
2507
|
+
baseSeverity: "high",
|
|
2508
|
+
test(ctx) {
|
|
2509
|
+
const hay = [ctx.command, ctx.resultSummary].filter(Boolean).join("\n");
|
|
2510
|
+
if (!hay)
|
|
2511
|
+
return null;
|
|
2512
|
+
for (const p of SECRET_VALUE_PATTERNS) {
|
|
2513
|
+
const m = p.re.exec(hay);
|
|
2514
|
+
if (m)
|
|
2515
|
+
return { evidence: `${p.label}: ${evidence(m[0], 40)}`, severity: p.critical ? "critical" : "high", mods: { secret_kind: p.label } };
|
|
2516
|
+
}
|
|
2517
|
+
return null;
|
|
2518
|
+
}
|
|
2519
|
+
},
|
|
2520
|
+
// --- Data exfiltration (MITRE ATLAS AML.T0086) ---------------------------
|
|
2521
|
+
{
|
|
2522
|
+
id: "exfil.pipe_to_network",
|
|
2523
|
+
category: "exfiltration",
|
|
2524
|
+
framework_ref: "MITRE ATLAS AML.T0086",
|
|
2525
|
+
title: "Pipes local data into a network command",
|
|
2526
|
+
tools: ["Bash"],
|
|
2527
|
+
baseSeverity: "critical",
|
|
2528
|
+
test(ctx) {
|
|
2529
|
+
const c = ctx.commandBare;
|
|
2530
|
+
if (!c)
|
|
2531
|
+
return null;
|
|
2532
|
+
if (!/(\||<)\s*(sudo\s+)?(curl|wget|nc|ncat|netcat|ssh|scp)\b/i.test(c))
|
|
2533
|
+
return null;
|
|
2534
|
+
if (!/\b(cat|tar|zip|gzip|base64|env|printenv|cat\s|<)\b/i.test(c))
|
|
2535
|
+
return null;
|
|
2536
|
+
return { evidence: evidence(ctx.command) };
|
|
2537
|
+
}
|
|
2538
|
+
},
|
|
2539
|
+
{
|
|
2540
|
+
id: "exfil.network_upload",
|
|
2541
|
+
category: "exfiltration",
|
|
2542
|
+
framework_ref: "MITRE ATLAS AML.T0086",
|
|
2543
|
+
title: "Uploads data to an external host",
|
|
2544
|
+
tools: ["Bash"],
|
|
2545
|
+
baseSeverity: "high",
|
|
2546
|
+
test(ctx) {
|
|
2547
|
+
const c = ctx.commandBare;
|
|
2548
|
+
if (!c)
|
|
2549
|
+
return null;
|
|
2550
|
+
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);
|
|
2551
|
+
if (!upload)
|
|
2552
|
+
return null;
|
|
2553
|
+
const external = /https?:\/\/(?!(localhost|127\.0\.0\.1|0\.0\.0\.0))/i.test(c);
|
|
2554
|
+
const withFile = /(@[^\s'"]+|--upload-file|\s-T\b)/.test(c);
|
|
2555
|
+
return {
|
|
2556
|
+
evidence: evidence(ctx.command),
|
|
2557
|
+
severity: withFile && external ? "critical" : "high",
|
|
2558
|
+
mods: { external, with_file: withFile }
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
},
|
|
2562
|
+
{
|
|
2563
|
+
id: "exfil.reverse_shell",
|
|
2564
|
+
category: "exfiltration",
|
|
2565
|
+
framework_ref: "MITRE ATLAS AML.T0086",
|
|
2566
|
+
title: "Netcat / reverse-shell style connection",
|
|
2567
|
+
tools: ["Bash"],
|
|
2568
|
+
baseSeverity: "critical",
|
|
2569
|
+
test(ctx) {
|
|
2570
|
+
const c = ctx.commandBare;
|
|
2571
|
+
if (!c)
|
|
2572
|
+
return null;
|
|
2573
|
+
if (!/\b(nc|ncat|netcat)\b[^|;&\n]*(-e\b|-c\b|\d{2,5})|\/dev\/tcp\/|bash\s+-i\b/i.test(c))
|
|
2574
|
+
return null;
|
|
2575
|
+
return { evidence: evidence(ctx.command) };
|
|
2576
|
+
}
|
|
2577
|
+
},
|
|
2578
|
+
// --- Privilege escalation / guardrail bypass (OWASP LLM06 Excessive Agency)
|
|
2579
|
+
{
|
|
2580
|
+
id: "privilege.skip_permissions",
|
|
2581
|
+
category: "privilege-bypass",
|
|
2582
|
+
framework_ref: "OWASP LLM06",
|
|
2583
|
+
title: "Bypasses the permission guardrail",
|
|
2584
|
+
tools: "*",
|
|
2585
|
+
baseSeverity: "high",
|
|
2586
|
+
test(ctx) {
|
|
2587
|
+
const c = ctx.commandBare;
|
|
2588
|
+
if (!c || !/--dangerously-skip-permissions\b|--yolo\b|--dangerously-skip\b/i.test(c))
|
|
2589
|
+
return null;
|
|
2590
|
+
return { evidence: evidence(ctx.command) };
|
|
2591
|
+
}
|
|
2592
|
+
},
|
|
2593
|
+
{
|
|
2594
|
+
id: "privilege.curl_pipe_shell",
|
|
2595
|
+
category: "privilege-bypass",
|
|
2596
|
+
framework_ref: "OWASP LLM06",
|
|
2597
|
+
title: "Executes a remote script (curl | sh)",
|
|
2598
|
+
tools: ["Bash"],
|
|
2599
|
+
baseSeverity: "high",
|
|
2600
|
+
test(ctx) {
|
|
2601
|
+
const c = ctx.commandBare;
|
|
2602
|
+
if (!c || !/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python\d?|node)\b/i.test(c))
|
|
2603
|
+
return null;
|
|
2604
|
+
return { evidence: evidence(ctx.command) };
|
|
2605
|
+
}
|
|
2606
|
+
},
|
|
2607
|
+
{
|
|
2608
|
+
id: "privilege.sudo",
|
|
2609
|
+
category: "privilege-bypass",
|
|
2610
|
+
framework_ref: "OWASP LLM06",
|
|
2611
|
+
title: "Runs a command as root (sudo)",
|
|
2612
|
+
tools: ["Bash"],
|
|
2613
|
+
baseSeverity: "high",
|
|
2614
|
+
// root escalation removes a real safety barrier — higher than an in-project op
|
|
2615
|
+
test(ctx) {
|
|
2616
|
+
const c = ctx.commandBare;
|
|
2617
|
+
if (!c || !/(?:^|[\n;|&(])\s*sudo\b/i.test(c))
|
|
2618
|
+
return null;
|
|
2619
|
+
return { evidence: evidence(ctx.command) };
|
|
2620
|
+
}
|
|
2621
|
+
},
|
|
2622
|
+
{
|
|
2623
|
+
id: "privilege.exec_generated_script",
|
|
2624
|
+
category: "privilege-bypass",
|
|
2625
|
+
framework_ref: "OWASP LLM06",
|
|
2626
|
+
title: "Writes a shell script from inline output and executes it",
|
|
2627
|
+
tools: ["Bash"],
|
|
2628
|
+
baseSeverity: "high",
|
|
2629
|
+
test(ctx) {
|
|
2630
|
+
const raw = ctx.command;
|
|
2631
|
+
if (!raw)
|
|
2632
|
+
return null;
|
|
2633
|
+
const file = execGeneratedScript(stripComments(raw));
|
|
2634
|
+
if (!file)
|
|
2635
|
+
return null;
|
|
2636
|
+
const critical = /(?:^|["'\s;|&(])sudo\b|\brm\s+[^|;&\n]*-[a-z]*r[a-z]*f|\bchmod\s+0?777\b|\bmkfs|\bdd\s+if=/i.test(raw);
|
|
2637
|
+
return { evidence: evidence(raw), severity: critical ? "critical" : "high", mods: { script_file: file } };
|
|
2638
|
+
}
|
|
2639
|
+
},
|
|
2640
|
+
{
|
|
2641
|
+
id: "privilege.chmod_777",
|
|
2642
|
+
category: "privilege-bypass",
|
|
2643
|
+
framework_ref: "OWASP LLM06",
|
|
2644
|
+
title: "Overly permissive permissions (chmod 777)",
|
|
2645
|
+
tools: ["Bash"],
|
|
2646
|
+
baseSeverity: "medium",
|
|
2647
|
+
test(ctx) {
|
|
2648
|
+
const c = ctx.commandBare;
|
|
2649
|
+
if (!c || !/\bchmod\s+(-[a-zA-Z]+\s+)*0?777\b/i.test(c))
|
|
2650
|
+
return null;
|
|
2651
|
+
return { evidence: evidence(ctx.command) };
|
|
2652
|
+
}
|
|
2653
|
+
},
|
|
2654
|
+
{
|
|
2655
|
+
id: "privilege.persistence",
|
|
2656
|
+
category: "privilege-bypass",
|
|
2657
|
+
framework_ref: "OWASP LLM06",
|
|
2658
|
+
title: "Installs a persistence mechanism",
|
|
2659
|
+
tools: ["Bash"],
|
|
2660
|
+
baseSeverity: "high",
|
|
2661
|
+
test(ctx) {
|
|
2662
|
+
const c = ctx.commandBare;
|
|
2663
|
+
if (!c)
|
|
2664
|
+
return null;
|
|
2665
|
+
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))
|
|
2666
|
+
return null;
|
|
2667
|
+
return { evidence: evidence(ctx.command) };
|
|
2668
|
+
}
|
|
2669
|
+
},
|
|
2670
|
+
{
|
|
2671
|
+
id: "privilege.write_outside_project",
|
|
2672
|
+
category: "privilege-bypass",
|
|
2673
|
+
framework_ref: "OWASP LLM06",
|
|
2674
|
+
title: "Writes a file outside the working project",
|
|
2675
|
+
tools: ["Write", "Edit", "MultiEdit"],
|
|
2676
|
+
baseSeverity: "medium",
|
|
2677
|
+
test(ctx) {
|
|
2678
|
+
const f = ctx.filePath;
|
|
2679
|
+
if (!outsideProject(f, ctx.projectPath, ctx.ownedConfigDirs))
|
|
2680
|
+
return null;
|
|
2681
|
+
const system = !!f && SYSTEM_PATH.test(f);
|
|
2682
|
+
return { evidence: evidence(f), severity: system ? "high" : "medium", mods: { project: ctx.projectPath, system_path: system } };
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
];
|
|
2686
|
+
function appliesTo(rule, toolName) {
|
|
2687
|
+
return rule.tools === "*" || rule.tools.includes(toolName);
|
|
2688
|
+
}
|
|
2689
|
+
function buildContext(row, projectPath, ownedConfigDirs) {
|
|
2690
|
+
let input = null;
|
|
2691
|
+
try {
|
|
2692
|
+
input = row.input_json ? JSON.parse(row.input_json) : null;
|
|
2693
|
+
} catch {
|
|
2694
|
+
input = null;
|
|
2695
|
+
}
|
|
2696
|
+
const command = typeof input?.command === "string" ? input.command : null;
|
|
2697
|
+
const commandCode = command != null ? codeOf(command) : null;
|
|
2698
|
+
const commandBare = command != null ? bareOf(command) : null;
|
|
2699
|
+
const filePath = typeof input?.file_path === "string" ? input.file_path : null;
|
|
2700
|
+
return { toolName: row.tool_name, command, commandCode, commandBare, filePath, input, resultSummary: row.result_summary, status: row.status, projectPath, ownedConfigDirs };
|
|
2701
|
+
}
|
|
2702
|
+
function detect(db, dirty) {
|
|
2703
|
+
const incremental = dirty != null;
|
|
2704
|
+
if (incremental) {
|
|
2705
|
+
db.exec("DROP TABLE IF EXISTS _dirty_sec");
|
|
2706
|
+
db.exec("CREATE TEMP TABLE _dirty_sec (id TEXT PRIMARY KEY)");
|
|
2707
|
+
const ins = db.prepare("INSERT OR IGNORE INTO _dirty_sec (id) VALUES (?)");
|
|
2708
|
+
db.transaction((ids) => {
|
|
2709
|
+
for (const id of ids)
|
|
2710
|
+
ins.run(id);
|
|
2711
|
+
})(dirty);
|
|
2712
|
+
}
|
|
2713
|
+
const scope = incremental ? " WHERE tc.session_id IN (SELECT id FROM _dirty_sec)" : "";
|
|
2714
|
+
const ownedConfigDirs = db.prepare("SELECT config_dir FROM sources WHERE config_dir IS NOT NULL").all().map((r) => r.config_dir.replace(/\/$/, "")).filter(Boolean);
|
|
2715
|
+
const projPath = /* @__PURE__ */ new Map();
|
|
2716
|
+
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()) {
|
|
2717
|
+
projPath.set(r.id, r.path);
|
|
2718
|
+
}
|
|
2719
|
+
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
|
|
2720
|
+
FROM tool_calls tc${scope}`).all();
|
|
2721
|
+
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)
|
|
2722
|
+
VALUES (@id, @session_id, @tool_call_id, @event_uuid, @turn_id, @rule_id, @category, @framework_ref, @severity, @title, @evidence, @signals_json, @detector_version)
|
|
2723
|
+
ON CONFLICT(id) DO NOTHING`);
|
|
2724
|
+
const delScope = incremental ? "DELETE FROM findings WHERE session_id IN (SELECT id FROM _dirty_sec)" : "DELETE FROM findings";
|
|
2725
|
+
const tx = db.transaction(() => {
|
|
2726
|
+
db.exec(delScope);
|
|
2727
|
+
for (const row of rows) {
|
|
2728
|
+
const ctx = buildContext(row, projPath.get(row.session_id) ?? null, ownedConfigDirs);
|
|
2729
|
+
for (const rule of RULES) {
|
|
2730
|
+
if (!appliesTo(rule, ctx.toolName))
|
|
2731
|
+
continue;
|
|
2732
|
+
const m = rule.test(ctx);
|
|
2733
|
+
if (!m)
|
|
2734
|
+
continue;
|
|
2735
|
+
let severity = m.severity ?? rule.baseSeverity;
|
|
2736
|
+
const mods = { ...m.mods ?? {} };
|
|
2737
|
+
if (row.status === "error") {
|
|
2738
|
+
severity = bumpSeverity(severity, -1);
|
|
2739
|
+
mods.attempted = true;
|
|
2740
|
+
}
|
|
2741
|
+
const signals = {
|
|
2742
|
+
rule: rule.id,
|
|
2743
|
+
category: rule.category,
|
|
2744
|
+
framework_ref: rule.framework_ref,
|
|
2745
|
+
tool_name: ctx.toolName,
|
|
2746
|
+
base_severity: rule.baseSeverity,
|
|
2747
|
+
severity,
|
|
2748
|
+
status: row.status,
|
|
2749
|
+
modifiers: mods,
|
|
2750
|
+
detector_version: DETECTOR_VERSION
|
|
2751
|
+
};
|
|
2752
|
+
insert.run({
|
|
2753
|
+
id: findingId(row.id, rule.id),
|
|
2754
|
+
session_id: row.session_id,
|
|
2755
|
+
tool_call_id: row.id,
|
|
2756
|
+
event_uuid: row.event_uuid,
|
|
2757
|
+
turn_id: row.turn_id,
|
|
2758
|
+
rule_id: rule.id,
|
|
2759
|
+
category: rule.category,
|
|
2760
|
+
framework_ref: rule.framework_ref,
|
|
2761
|
+
severity,
|
|
2762
|
+
title: rule.title,
|
|
2763
|
+
evidence: m.evidence,
|
|
2764
|
+
signals_json: JSON.stringify(signals),
|
|
2765
|
+
detector_version: DETECTOR_VERSION
|
|
2766
|
+
});
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
});
|
|
2770
|
+
tx();
|
|
2771
|
+
if (incremental)
|
|
2772
|
+
db.exec("DROP TABLE IF EXISTS _dirty_sec");
|
|
2773
|
+
const count = db.prepare("SELECT COUNT(*) n FROM findings").get().n;
|
|
2774
|
+
return { count, version: DETECTOR_VERSION };
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
// ../ingest/dist/pipeline.js
|
|
2778
|
+
import { createHash as createHash2 } from "crypto";
|
|
2244
2779
|
function newStats() {
|
|
2245
2780
|
return { files: 0, skipped: 0, malformed: 0, newEvents: 0 };
|
|
2246
2781
|
}
|
|
@@ -2278,7 +2813,7 @@ function skillSummary(body) {
|
|
|
2278
2813
|
return null;
|
|
2279
2814
|
}
|
|
2280
2815
|
function skillVersionId(name, body) {
|
|
2281
|
-
return
|
|
2816
|
+
return createHash2("sha1").update(name).update("\0").update(body).digest("hex").slice(0, 16);
|
|
2282
2817
|
}
|
|
2283
2818
|
function pruneExcluded(db, excludedPaths) {
|
|
2284
2819
|
if (!excludedPaths.length)
|
|
@@ -2312,6 +2847,7 @@ function pruneExcluded(db, excludedPaths) {
|
|
|
2312
2847
|
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
2848
|
db.pragma("foreign_keys = OFF");
|
|
2314
2849
|
db.transaction(() => {
|
|
2850
|
+
db.exec("DELETE FROM findings WHERE session_id IN (SELECT id FROM _prune)");
|
|
2315
2851
|
for (const t of ["token_usage", "tool_calls", "turns", "events"])
|
|
2316
2852
|
db.exec(`DELETE FROM ${t} WHERE session_id IN (SELECT id FROM _prune)`);
|
|
2317
2853
|
db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id IN (SELECT id FROM _prune)");
|
|
@@ -2544,6 +3080,7 @@ function rebuildDerived(db, dirty) {
|
|
|
2544
3080
|
`);
|
|
2545
3081
|
db.exec(`DELETE FROM sessions WHERE event_count = 0${andId}`);
|
|
2546
3082
|
db.exec("DELETE FROM classifications WHERE scope = 'session' AND target_id NOT IN (SELECT id FROM sessions)");
|
|
3083
|
+
db.exec("DELETE FROM findings WHERE session_id NOT IN (SELECT id FROM sessions)");
|
|
2547
3084
|
if (!incremental) {
|
|
2548
3085
|
db.exec("DELETE FROM skills WHERE id NOT IN (SELECT skill_id FROM tool_calls WHERE skill_id IS NOT NULL)");
|
|
2549
3086
|
}
|
|
@@ -2609,7 +3146,7 @@ function prepareStatements(db) {
|
|
|
2609
3146
|
workflow_run_id = COALESCE(excluded.workflow_run_id, workflow_run_id)`)
|
|
2610
3147
|
};
|
|
2611
3148
|
}
|
|
2612
|
-
function ingestFile(db, stmts, adapter, file, lines, meta,
|
|
3149
|
+
function ingestFile(db, stmts, adapter, file, lines, meta, now2, stats) {
|
|
2613
3150
|
const sessionMeta = {};
|
|
2614
3151
|
let eventsInFile = 0;
|
|
2615
3152
|
const tx = db.transaction(() => {
|
|
@@ -2658,13 +3195,13 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
|
|
|
2658
3195
|
}
|
|
2659
3196
|
let projectId = null;
|
|
2660
3197
|
if (sessionMeta.cwd) {
|
|
2661
|
-
projectId =
|
|
3198
|
+
projectId = createHash2("sha1").update(`${adapter.agentId}\0${sessionMeta.cwd}`).digest("hex").slice(0, 16);
|
|
2662
3199
|
stmts.upsertProject.run({
|
|
2663
3200
|
id: projectId,
|
|
2664
3201
|
agent_id: adapter.agentId,
|
|
2665
3202
|
path: sessionMeta.cwd,
|
|
2666
3203
|
encoded_dir: file.encodedDir || null,
|
|
2667
|
-
now
|
|
3204
|
+
now: now2
|
|
2668
3205
|
});
|
|
2669
3206
|
}
|
|
2670
3207
|
stmts.upsertSession.run({
|
|
@@ -2686,7 +3223,7 @@ function ingestFile(db, stmts, adapter, file, lines, meta, now, stats) {
|
|
|
2686
3223
|
mtime_ms: Math.trunc(meta.mtimeMs),
|
|
2687
3224
|
sha256: meta.hash,
|
|
2688
3225
|
events_ingested: eventsInFile,
|
|
2689
|
-
ingested_at:
|
|
3226
|
+
ingested_at: now2
|
|
2690
3227
|
});
|
|
2691
3228
|
});
|
|
2692
3229
|
tx();
|
|
@@ -2697,7 +3234,7 @@ import { readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as
|
|
|
2697
3234
|
import { basename as basename3, join as join5 } from "path";
|
|
2698
3235
|
|
|
2699
3236
|
// ../ingest/dist/redact.js
|
|
2700
|
-
import { createHash as
|
|
3237
|
+
import { createHash as createHash3, randomBytes } from "crypto";
|
|
2701
3238
|
function parseExcludes(csv) {
|
|
2702
3239
|
return (csv ?? "").split(",").map((s) => s.trim()).filter(Boolean).map(encodeProjectPath);
|
|
2703
3240
|
}
|
|
@@ -2706,17 +3243,17 @@ function isExcludedArchivePath(filePath, excluded) {
|
|
|
2706
3243
|
}
|
|
2707
3244
|
|
|
2708
3245
|
// ../ingest/dist/fileread.js
|
|
2709
|
-
import { createHash as
|
|
3246
|
+
import { createHash as createHash4 } from "crypto";
|
|
2710
3247
|
import { openSync as openSync2, readSync as readSync2, closeSync as closeSync2 } from "fs";
|
|
2711
3248
|
import { StringDecoder } from "string_decoder";
|
|
2712
3249
|
var STREAM_THRESHOLD = 8 * 1024 * 1024;
|
|
2713
3250
|
var CHUNK2 = 64 * 1024;
|
|
2714
3251
|
function sha256(buf) {
|
|
2715
|
-
return
|
|
3252
|
+
return createHash4("sha256").update(buf).digest("hex");
|
|
2716
3253
|
}
|
|
2717
3254
|
function sha256File(path) {
|
|
2718
3255
|
const fd = openSync2(path, "r");
|
|
2719
|
-
const h =
|
|
3256
|
+
const h = createHash4("sha256");
|
|
2720
3257
|
const buf = Buffer.allocUnsafe(CHUNK2);
|
|
2721
3258
|
try {
|
|
2722
3259
|
let n;
|
|
@@ -2803,7 +3340,7 @@ function cleanStr(v) {
|
|
|
2803
3340
|
return null;
|
|
2804
3341
|
return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\[[0-9;]+m\]?/g, "").trim() || null;
|
|
2805
3342
|
}
|
|
2806
|
-
function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs,
|
|
3343
|
+
function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now2, stats, full) {
|
|
2807
3344
|
let paths = discoverSidecars(sourceArchiveDir);
|
|
2808
3345
|
if (excludedDirs.length)
|
|
2809
3346
|
paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
|
|
@@ -2846,7 +3383,7 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
|
|
|
2846
3383
|
}
|
|
2847
3384
|
const hash = sha256(buf);
|
|
2848
3385
|
if (prev && prev.sha256 === hash) {
|
|
2849
|
-
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at:
|
|
3386
|
+
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
|
|
2850
3387
|
stats.skipped++;
|
|
2851
3388
|
continue;
|
|
2852
3389
|
}
|
|
@@ -2884,9 +3421,9 @@ function ingestWorkflowResults(db, sourceArchiveDir, sourceId, excludedDirs, now
|
|
|
2884
3421
|
duration_ms: durationMs2,
|
|
2885
3422
|
started_at: startedAt,
|
|
2886
3423
|
ended_at: endedAt,
|
|
2887
|
-
ingested_at:
|
|
3424
|
+
ingested_at: now2
|
|
2888
3425
|
});
|
|
2889
|
-
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at:
|
|
3426
|
+
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
|
|
2890
3427
|
stats.upserted++;
|
|
2891
3428
|
}
|
|
2892
3429
|
}
|
|
@@ -2925,7 +3462,7 @@ function discoverMeta(sourceArchiveDir) {
|
|
|
2925
3462
|
}
|
|
2926
3463
|
var intOrNull2 = (v) => typeof v === "number" && Number.isFinite(v) ? Math.trunc(v) : null;
|
|
2927
3464
|
var strOrNull2 = (v) => typeof v === "string" && v ? v : null;
|
|
2928
|
-
function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs,
|
|
3465
|
+
function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now2, stats, full) {
|
|
2929
3466
|
let paths = discoverMeta(sourceArchiveDir);
|
|
2930
3467
|
if (excludedDirs.length)
|
|
2931
3468
|
paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
|
|
@@ -2960,7 +3497,7 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, s
|
|
|
2960
3497
|
}
|
|
2961
3498
|
const hash = sha256(buf);
|
|
2962
3499
|
if (prev && prev.sha256 === hash) {
|
|
2963
|
-
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at:
|
|
3500
|
+
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
|
|
2964
3501
|
stats.skipped++;
|
|
2965
3502
|
continue;
|
|
2966
3503
|
}
|
|
@@ -2983,9 +3520,9 @@ function ingestSubagentMeta(db, sourceArchiveDir, sourceId, excludedDirs, now, s
|
|
|
2983
3520
|
agent_description: strOrNull2(d.description),
|
|
2984
3521
|
spawn_depth: intOrNull2(d.spawnDepth),
|
|
2985
3522
|
tool_use_id: strOrNull2(d.toolUseId),
|
|
2986
|
-
ingested_at:
|
|
3523
|
+
ingested_at: now2
|
|
2987
3524
|
});
|
|
2988
|
-
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at:
|
|
3525
|
+
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
|
|
2989
3526
|
stats.upserted++;
|
|
2990
3527
|
}
|
|
2991
3528
|
}
|
|
@@ -3027,7 +3564,7 @@ function sessionFromPath2(path) {
|
|
|
3027
3564
|
const i = parts.indexOf("tool-results");
|
|
3028
3565
|
return i > 0 ? parts[i - 1] : null;
|
|
3029
3566
|
}
|
|
3030
|
-
function ingestToolResults(db, sourceArchiveDir, excludedDirs,
|
|
3567
|
+
function ingestToolResults(db, sourceArchiveDir, excludedDirs, now2, stats, full) {
|
|
3031
3568
|
let paths = discoverResults(sourceArchiveDir);
|
|
3032
3569
|
if (excludedDirs.length)
|
|
3033
3570
|
paths = paths.filter((p) => !isExcludedArchivePath(p, excludedDirs));
|
|
@@ -3061,7 +3598,7 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full)
|
|
|
3061
3598
|
}
|
|
3062
3599
|
const hash = sha256(buf);
|
|
3063
3600
|
if (prev && prev.sha256 === hash) {
|
|
3064
|
-
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at:
|
|
3601
|
+
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
|
|
3065
3602
|
stats.skipped++;
|
|
3066
3603
|
continue;
|
|
3067
3604
|
}
|
|
@@ -3071,9 +3608,9 @@ function ingestToolResults(db, sourceArchiveDir, excludedDirs, now, stats, full)
|
|
|
3071
3608
|
path,
|
|
3072
3609
|
bytes: st.size,
|
|
3073
3610
|
text: buf.toString("utf8"),
|
|
3074
|
-
ingested_at:
|
|
3611
|
+
ingested_at: now2
|
|
3075
3612
|
});
|
|
3076
|
-
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at:
|
|
3613
|
+
setState.run({ file_path: path, size: st.size, mtime_ms: mtimeMs, sha256: hash, ingested_at: now2 });
|
|
3077
3614
|
stats.upserted++;
|
|
3078
3615
|
}
|
|
3079
3616
|
}
|
|
@@ -3115,7 +3652,7 @@ function runIngest(argv = process.argv.slice(2)) {
|
|
|
3115
3652
|
const adapterList = [new ClaudeCodeAdapter()];
|
|
3116
3653
|
const adapterById = new Map(adapterList.map((a) => [a.agentId, a]));
|
|
3117
3654
|
const sources = loadSources();
|
|
3118
|
-
const
|
|
3655
|
+
const now2 = (/* @__PURE__ */ new Date()).toISOString();
|
|
3119
3656
|
const excludePaths = loadExcludes();
|
|
3120
3657
|
const excludedDirs = parseExcludes(excludePaths.join(","));
|
|
3121
3658
|
const pruned = pruneExcluded(db, excludePaths);
|
|
@@ -3156,21 +3693,22 @@ function runIngest(argv = process.argv.slice(2)) {
|
|
|
3156
3693
|
mtime_ms: mtimeMs,
|
|
3157
3694
|
sha256: hash,
|
|
3158
3695
|
events_ingested: prev.events_ingested,
|
|
3159
|
-
ingested_at:
|
|
3696
|
+
ingested_at: now2
|
|
3160
3697
|
});
|
|
3161
3698
|
stats.skipped++;
|
|
3162
3699
|
continue;
|
|
3163
3700
|
}
|
|
3164
3701
|
const lines = small ? buf.toString("utf8").split("\n") : streamLines(file.path);
|
|
3165
|
-
ingestFile(db, stmts, adapter, file, lines, { size: st.size, mtimeMs, hash },
|
|
3702
|
+
ingestFile(db, stmts, adapter, file, lines, { size: st.size, mtimeMs, hash }, now2, stats);
|
|
3166
3703
|
dirty.add(file.sessionId);
|
|
3167
3704
|
}
|
|
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,
|
|
3705
|
+
ingestWorkflowResults(db, join8(archiveRoot, source.label), source.label, excludedDirs, now2, wfStats, args.full);
|
|
3706
|
+
ingestSubagentMeta(db, join8(archiveRoot, source.label), source.label, excludedDirs, now2, metaStats, args.full);
|
|
3707
|
+
ingestToolResults(db, join8(archiveRoot, source.label), excludedDirs, now2, trStats, args.full);
|
|
3171
3708
|
}
|
|
3172
3709
|
const expanded = rebuildDerived(db, args.full ? null : dirty);
|
|
3173
3710
|
const classified = classify(db, args.full ? null : expanded);
|
|
3711
|
+
const detected = detect(db, args.full ? null : expanded);
|
|
3174
3712
|
const count = (sql) => db.prepare(sql).get().n;
|
|
3175
3713
|
const sessions = count("SELECT COUNT(*) n FROM sessions");
|
|
3176
3714
|
const turns = count("SELECT COUNT(*) n FROM turns");
|
|
@@ -3186,12 +3724,16 @@ function runIngest(argv = process.argv.slice(2)) {
|
|
|
3186
3724
|
const wfRuns = count("SELECT COUNT(*) n FROM workflow_results");
|
|
3187
3725
|
const metaRows = count("SELECT COUNT(*) n FROM session_meta");
|
|
3188
3726
|
const trRows = count("SELECT COUNT(*) n FROM tool_results");
|
|
3727
|
+
const sevCounts = db.prepare("SELECT severity, COUNT(*) n FROM findings GROUP BY severity").all();
|
|
3728
|
+
const sevMap = Object.fromEntries(sevCounts.map((r) => [r.severity, r.n]));
|
|
3729
|
+
const sevLine = ["critical", "high", "medium", "low", "info"].map((s) => `${s}=${sevMap[s] ?? 0}`).join(" ");
|
|
3189
3730
|
db.close();
|
|
3190
3731
|
console.log(`agent-lens-ingest: files=${stats.files} skipped=${stats.skipped} new_events=${stats.newEvents} malformed=${stats.malformed}${pruned ? ` excluded_pruned=${pruned}` : ""}
|
|
3191
3732
|
sessions=${sessions} turns=${turns} events=${events} tool_calls=${tools} classified=${classified.count}
|
|
3192
3733
|
workflow_results=${wfRuns} (sidecar upserted=${wfStats.upserted} skipped=${wfStats.skipped} malformed=${wfStats.malformed})
|
|
3193
3734
|
session_meta=${metaRows} (upserted=${metaStats.upserted} skipped=${metaStats.skipped} malformed=${metaStats.malformed})
|
|
3194
3735
|
tool_results=${trRows} (upserted=${trStats.upserted} skipped=${trStats.skipped} malformed=${trStats.malformed})
|
|
3736
|
+
findings=${detected.count} (${sevLine})
|
|
3195
3737
|
tokens=${totalTokens.toLocaleString()} est_cost=$${cost.toFixed(2)} db=${dbPath}`);
|
|
3196
3738
|
}
|
|
3197
3739
|
function runMetrics(argv = process.argv.slice(2)) {
|
|
@@ -3207,8 +3749,9 @@ function runMetrics(argv = process.argv.slice(2)) {
|
|
|
3207
3749
|
}
|
|
3208
3750
|
const db = openDb(dbPath);
|
|
3209
3751
|
const r = classify(db);
|
|
3752
|
+
const d = detect(db);
|
|
3210
3753
|
db.close();
|
|
3211
|
-
console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} db=${dbPath}`);
|
|
3754
|
+
console.log(`agent-lens-metrics: classified=${r.count} classifier_version=${CLASSIFIER_VERSION} findings=${d.count} detector_version=${DETECTOR_VERSION} db=${dbPath}`);
|
|
3212
3755
|
}
|
|
3213
3756
|
|
|
3214
3757
|
// ../server/dist/run.js
|
|
@@ -3281,9 +3824,9 @@ function listSessions(db, f) {
|
|
|
3281
3824
|
if (f.project)
|
|
3282
3825
|
where.push("s.project_id = ?"), params.push(f.project);
|
|
3283
3826
|
if (f.from)
|
|
3284
|
-
where.push("s.started_at >= ?"), params.push(f.from);
|
|
3827
|
+
where.push("date(s.started_at) >= date(?)"), params.push(f.from);
|
|
3285
3828
|
if (f.to)
|
|
3286
|
-
where.push("s.started_at <= ?"), params.push(f.to);
|
|
3829
|
+
where.push("date(s.started_at) <= date(?)"), params.push(f.to);
|
|
3287
3830
|
if (f.kind === "main")
|
|
3288
3831
|
where.push("s.is_sidechain = 0");
|
|
3289
3832
|
if (f.kind === "subagent")
|
|
@@ -3390,6 +3933,33 @@ function getSession(db, id) {
|
|
|
3390
3933
|
t.full_result = { text: full.text, bytes: full.bytes };
|
|
3391
3934
|
}
|
|
3392
3935
|
}
|
|
3936
|
+
const sessionFindings = [];
|
|
3937
|
+
if (tableExists(db, "findings")) {
|
|
3938
|
+
const findingRows = db.prepare(`SELECT id, tool_call_id, event_uuid, turn_id, rule_id, category, framework_ref, severity, title, evidence, signals_json
|
|
3939
|
+
FROM findings WHERE session_id = ?`).all(id);
|
|
3940
|
+
const byToolCall = /* @__PURE__ */ new Map();
|
|
3941
|
+
for (const fr of findingRows) {
|
|
3942
|
+
const f = {
|
|
3943
|
+
id: fr.id,
|
|
3944
|
+
tool_call_id: fr.tool_call_id,
|
|
3945
|
+
event_uuid: fr.event_uuid,
|
|
3946
|
+
turn_id: fr.turn_id,
|
|
3947
|
+
rule_id: fr.rule_id,
|
|
3948
|
+
category: fr.category,
|
|
3949
|
+
framework_ref: fr.framework_ref,
|
|
3950
|
+
severity: fr.severity,
|
|
3951
|
+
title: fr.title,
|
|
3952
|
+
evidence: fr.evidence,
|
|
3953
|
+
signals: safeJson(fr.signals_json)
|
|
3954
|
+
};
|
|
3955
|
+
sessionFindings.push(f);
|
|
3956
|
+
if (fr.tool_call_id)
|
|
3957
|
+
(byToolCall.get(fr.tool_call_id) ?? byToolCall.set(fr.tool_call_id, []).get(fr.tool_call_id)).push(f);
|
|
3958
|
+
}
|
|
3959
|
+
for (const t of toolRows)
|
|
3960
|
+
t.findings = byToolCall.get(t.id) ?? [];
|
|
3961
|
+
sessionFindings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity));
|
|
3962
|
+
}
|
|
3393
3963
|
const toolsByEvent = /* @__PURE__ */ new Map();
|
|
3394
3964
|
for (const t of toolRows) {
|
|
3395
3965
|
if (!t.event_uuid)
|
|
@@ -3478,7 +4048,7 @@ function getSession(db, id) {
|
|
|
3478
4048
|
FROM tool_calls tc LEFT JOIN turns t ON t.id = tc.turn_id
|
|
3479
4049
|
WHERE tc.session_id = ? AND tc.workflow_run_id IS NOT NULL
|
|
3480
4050
|
ORDER BY t.seq`).all(id);
|
|
3481
|
-
return { session, turns, events, classification, parent, children, workflow_runs };
|
|
4051
|
+
return { session, turns, events, classification, parent, children, workflow_runs, findings: sessionFindings };
|
|
3482
4052
|
}
|
|
3483
4053
|
function xmlTag(text, tag) {
|
|
3484
4054
|
return text.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim() ?? null;
|
|
@@ -3634,6 +4204,101 @@ function getSkill(db, name) {
|
|
|
3634
4204
|
const call_count = sessions.reduce((a, s) => a + s.fire_count, 0);
|
|
3635
4205
|
return { name, versions, sessions, call_count };
|
|
3636
4206
|
}
|
|
4207
|
+
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";
|
|
4208
|
+
var MUTED_SQL = `EXISTS (SELECT 1 FROM triage.muted_rules m WHERE m.rule_id = f.rule_id
|
|
4209
|
+
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)))`;
|
|
4210
|
+
function triageAttached(db) {
|
|
4211
|
+
return db.prepare("PRAGMA database_list").all().some((r) => r.name === "triage");
|
|
4212
|
+
}
|
|
4213
|
+
function findingWhere(f) {
|
|
4214
|
+
const sql = [];
|
|
4215
|
+
const params = [];
|
|
4216
|
+
if (f.severity)
|
|
4217
|
+
sql.push("f.severity = ?"), params.push(f.severity);
|
|
4218
|
+
if (f.category)
|
|
4219
|
+
sql.push("f.category = ?"), params.push(f.category);
|
|
4220
|
+
if (f.rule)
|
|
4221
|
+
sql.push("f.rule_id = ?"), params.push(f.rule);
|
|
4222
|
+
if (f.session)
|
|
4223
|
+
sql.push("f.session_id = ?"), params.push(f.session);
|
|
4224
|
+
if (f.source)
|
|
4225
|
+
sql.push("s.source_id = ?"), params.push(f.source);
|
|
4226
|
+
if (f.project)
|
|
4227
|
+
sql.push("s.project_id = ?"), params.push(f.project);
|
|
4228
|
+
if (f.from)
|
|
4229
|
+
sql.push("date(s.started_at) >= date(?)"), params.push(f.from);
|
|
4230
|
+
if (f.to)
|
|
4231
|
+
sql.push("date(s.started_at) <= date(?)"), params.push(f.to);
|
|
4232
|
+
return { sql, params };
|
|
4233
|
+
}
|
|
4234
|
+
function statusWhere(status, attached) {
|
|
4235
|
+
if (!attached)
|
|
4236
|
+
return status === "dismissed" || status === "muted" ? "1 = 0" : "";
|
|
4237
|
+
switch (status) {
|
|
4238
|
+
case "dismissed":
|
|
4239
|
+
return "d.finding_id IS NOT NULL";
|
|
4240
|
+
case "muted":
|
|
4241
|
+
return `d.finding_id IS NULL AND ${MUTED_SQL}`;
|
|
4242
|
+
case "all":
|
|
4243
|
+
return "";
|
|
4244
|
+
default:
|
|
4245
|
+
return `d.finding_id IS NULL AND NOT ${MUTED_SQL}`;
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
function listFindings(db, f) {
|
|
4249
|
+
if (!tableExists(db, "findings"))
|
|
4250
|
+
return { total: 0, findings: [] };
|
|
4251
|
+
const attached = triageAttached(db);
|
|
4252
|
+
const w = findingWhere(f);
|
|
4253
|
+
const st = statusWhere(f.status ?? "open", attached);
|
|
4254
|
+
const where = [...w.sql, ...st ? [st] : []];
|
|
4255
|
+
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
4256
|
+
const dismissJoin = attached ? "LEFT JOIN triage.dismissed_findings d ON d.finding_id = f.id" : "";
|
|
4257
|
+
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`;
|
|
4258
|
+
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;
|
|
4259
|
+
const ORDER = {
|
|
4260
|
+
severity: SEVERITY_RANK_SQL,
|
|
4261
|
+
session: "f.session_id",
|
|
4262
|
+
rule: "f.rule_id",
|
|
4263
|
+
category: "f.category",
|
|
4264
|
+
time: "s.started_at"
|
|
4265
|
+
};
|
|
4266
|
+
const orderCol = ORDER[f.sort ?? "severity"] ?? SEVERITY_RANK_SQL;
|
|
4267
|
+
const dir = f.dir === "asc" ? "ASC" : "DESC";
|
|
4268
|
+
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,
|
|
4269
|
+
f.framework_ref, f.severity, f.title, f.evidence, tc.tool_name,
|
|
4270
|
+
COALESCE(s.ai_title, s.slug) AS session_title, s.source_id, s.is_sidechain,
|
|
4271
|
+
s.started_at, p.path AS project_path, s.project_id ${triageCols}
|
|
4272
|
+
FROM findings f
|
|
4273
|
+
JOIN sessions s ON s.id = f.session_id
|
|
4274
|
+
LEFT JOIN projects p ON p.id = s.project_id
|
|
4275
|
+
LEFT JOIN tool_calls tc ON tc.id = f.tool_call_id
|
|
4276
|
+
${dismissJoin}
|
|
4277
|
+
${whereSql}
|
|
4278
|
+
ORDER BY ${orderCol} ${dir}, ${SEVERITY_RANK_SQL} DESC, f.id ASC
|
|
4279
|
+
LIMIT ? OFFSET ?`).all(...w.params, f.limit, f.offset);
|
|
4280
|
+
return { total, findings };
|
|
4281
|
+
}
|
|
4282
|
+
function securitySummary(db) {
|
|
4283
|
+
const categories = SECURITY_CATEGORIES;
|
|
4284
|
+
if (!tableExists(db, "findings")) {
|
|
4285
|
+
return { total: 0, sessions_flagged: 0, dismissed: 0, muted: 0, by_severity: [], by_category: [], by_rule: [], categories };
|
|
4286
|
+
}
|
|
4287
|
+
const attached = triageAttached(db);
|
|
4288
|
+
const dismissJoin = attached ? "LEFT JOIN triage.dismissed_findings d ON d.finding_id = f.id" : "";
|
|
4289
|
+
const base = `FROM findings f JOIN sessions s ON s.id = f.session_id ${dismissJoin}`;
|
|
4290
|
+
const openWhere = attached ? `WHERE d.finding_id IS NULL AND NOT ${MUTED_SQL}` : "";
|
|
4291
|
+
const scalar = (sql) => db.prepare(sql).get().n;
|
|
4292
|
+
const total = scalar(`SELECT COUNT(*) n ${base} ${openWhere}`);
|
|
4293
|
+
const sessions_flagged = scalar(`SELECT COUNT(DISTINCT f.session_id) n ${base} ${openWhere}`);
|
|
4294
|
+
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;
|
|
4295
|
+
const muted = attached ? scalar(`SELECT COUNT(*) n ${base} WHERE d.finding_id IS NULL AND ${MUTED_SQL}`) : 0;
|
|
4296
|
+
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();
|
|
4297
|
+
const by_category = db.prepare(`SELECT f.category AS category, COUNT(*) n ${base} ${openWhere} GROUP BY f.category ORDER BY n DESC`).all();
|
|
4298
|
+
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
|
|
4299
|
+
${base} ${openWhere} GROUP BY f.rule_id ORDER BY rank DESC, n DESC`).all();
|
|
4300
|
+
return { total, sessions_flagged, dismissed, muted, by_severity, by_category, by_rule, categories };
|
|
4301
|
+
}
|
|
3637
4302
|
|
|
3638
4303
|
// ../server/dist/app.js
|
|
3639
4304
|
import { existsSync as existsSync6 } from "fs";
|
|
@@ -3647,9 +4312,9 @@ function sessionWhere(f) {
|
|
|
3647
4312
|
if (f.source)
|
|
3648
4313
|
where.push("s.source_id = ?"), params.push(f.source);
|
|
3649
4314
|
if (f.from)
|
|
3650
|
-
where.push("s.started_at >= ?"), params.push(f.from);
|
|
4315
|
+
where.push("date(s.started_at) >= date(?)"), params.push(f.from);
|
|
3651
4316
|
if (f.to)
|
|
3652
|
-
where.push("s.started_at <= ?"), params.push(f.to);
|
|
4317
|
+
where.push("date(s.started_at) <= date(?)"), params.push(f.to);
|
|
3653
4318
|
return { sql: where.length ? `WHERE ${where.join(" AND ")}` : "", params };
|
|
3654
4319
|
}
|
|
3655
4320
|
var zeroSplit = () => ({ input: 0, output: 0, cache_creation: 0, cache_read: 0 });
|
|
@@ -3839,9 +4504,93 @@ function runRefresh() {
|
|
|
3839
4504
|
}
|
|
3840
4505
|
}
|
|
3841
4506
|
|
|
4507
|
+
// ../server/dist/triage.js
|
|
4508
|
+
import Database3 from "better-sqlite3";
|
|
4509
|
+
var TRIAGE_SCHEMA_SQL = (
|
|
4510
|
+
/* sql */
|
|
4511
|
+
`
|
|
4512
|
+
PRAGMA journal_mode = WAL;
|
|
4513
|
+
|
|
4514
|
+
-- Findings the user reviewed and marked safe. Keyed by the stable finding id so it survives re-ingest.
|
|
4515
|
+
CREATE TABLE IF NOT EXISTS dismissed_findings (
|
|
4516
|
+
finding_id TEXT PRIMARY KEY, -- findings.id = sha1(tool_call_id, rule_id)
|
|
4517
|
+
tool_call_id TEXT, -- reference only (may be null)
|
|
4518
|
+
rule_id TEXT, -- reference only (may be null)
|
|
4519
|
+
note TEXT,
|
|
4520
|
+
dismissed_at TEXT NOT NULL
|
|
4521
|
+
);
|
|
4522
|
+
|
|
4523
|
+
-- Rules the user muted (suppressed as noise), optionally scoped to a project or source. A muted rule
|
|
4524
|
+
-- hides its current AND future findings from the open view \u2014 the scalable lever for a noisy rule.
|
|
4525
|
+
CREATE TABLE IF NOT EXISTS muted_rules (
|
|
4526
|
+
rule_id TEXT NOT NULL,
|
|
4527
|
+
scope TEXT NOT NULL DEFAULT 'global', -- 'global' | 'project' | 'source'
|
|
4528
|
+
scope_id TEXT NOT NULL DEFAULT '', -- project_id / source_id; '' for global (NOT NULL keeps it in the PK)
|
|
4529
|
+
note TEXT,
|
|
4530
|
+
muted_at TEXT NOT NULL,
|
|
4531
|
+
PRIMARY KEY (rule_id, scope, scope_id)
|
|
4532
|
+
);
|
|
4533
|
+
`
|
|
4534
|
+
);
|
|
4535
|
+
function openTriage(file) {
|
|
4536
|
+
const db = new Database3(file);
|
|
4537
|
+
db.pragma("journal_mode = WAL");
|
|
4538
|
+
db.exec(TRIAGE_SCHEMA_SQL);
|
|
4539
|
+
return db;
|
|
4540
|
+
}
|
|
4541
|
+
var now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
4542
|
+
function dismiss(db, ids, note) {
|
|
4543
|
+
const stmt = db.prepare(`INSERT INTO dismissed_findings (finding_id, note, dismissed_at) VALUES (?, ?, ?)
|
|
4544
|
+
ON CONFLICT(finding_id) DO UPDATE SET note = excluded.note, dismissed_at = excluded.dismissed_at`);
|
|
4545
|
+
const ts = now();
|
|
4546
|
+
const tx = db.transaction((list) => {
|
|
4547
|
+
for (const id of list)
|
|
4548
|
+
stmt.run(id, note ?? null, ts);
|
|
4549
|
+
});
|
|
4550
|
+
tx(ids);
|
|
4551
|
+
return ids.length;
|
|
4552
|
+
}
|
|
4553
|
+
function reopen(db, ids) {
|
|
4554
|
+
const stmt = db.prepare("DELETE FROM dismissed_findings WHERE finding_id = ?");
|
|
4555
|
+
let n = 0;
|
|
4556
|
+
const tx = db.transaction((list) => {
|
|
4557
|
+
for (const id of list)
|
|
4558
|
+
n += stmt.run(id).changes;
|
|
4559
|
+
});
|
|
4560
|
+
tx(ids);
|
|
4561
|
+
return n;
|
|
4562
|
+
}
|
|
4563
|
+
function muteRule(db, ruleId, scope = "global", scopeId = "", note) {
|
|
4564
|
+
db.prepare(`INSERT INTO muted_rules (rule_id, scope, scope_id, note, muted_at) VALUES (?, ?, ?, ?, ?)
|
|
4565
|
+
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());
|
|
4566
|
+
}
|
|
4567
|
+
function unmute(db, ruleId, scope = "global", scopeId = "") {
|
|
4568
|
+
return db.prepare("DELETE FROM muted_rules WHERE rule_id = ? AND scope = ? AND scope_id = ?").run(ruleId, scope, scope === "global" ? "" : scopeId).changes;
|
|
4569
|
+
}
|
|
4570
|
+
function listMutes(db) {
|
|
4571
|
+
return db.prepare("SELECT rule_id, scope, scope_id, note, muted_at FROM muted_rules ORDER BY muted_at DESC").all();
|
|
4572
|
+
}
|
|
4573
|
+
|
|
3842
4574
|
// ../server/dist/app.js
|
|
3843
4575
|
async function createApp(db, opts = {}) {
|
|
3844
4576
|
const app = Fastify({ logger: false });
|
|
4577
|
+
let triageDb = null;
|
|
4578
|
+
if (opts.triageDbPath) {
|
|
4579
|
+
triageDb = openTriage(opts.triageDbPath);
|
|
4580
|
+
db.exec(`ATTACH DATABASE '${opts.triageDbPath.replace(/'/g, "''")}' AS triage`);
|
|
4581
|
+
app.addHook("onClose", async () => triageDb?.close());
|
|
4582
|
+
}
|
|
4583
|
+
const guardWrite = (req, reply) => {
|
|
4584
|
+
if (!originAllowed(req.headers.origin)) {
|
|
4585
|
+
reply.code(403).send({ error: { code: "FORBIDDEN_ORIGIN", message: "cross-origin write blocked" } });
|
|
4586
|
+
return false;
|
|
4587
|
+
}
|
|
4588
|
+
if (!triageDb) {
|
|
4589
|
+
reply.code(503).send({ error: { code: "TRIAGE_UNAVAILABLE", message: "triage store not configured" } });
|
|
4590
|
+
return false;
|
|
4591
|
+
}
|
|
4592
|
+
return true;
|
|
4593
|
+
};
|
|
3845
4594
|
app.get("/api/health", async () => {
|
|
3846
4595
|
const s = schemaStatus(db);
|
|
3847
4596
|
return { ok: true, last_ingested: lastIngested(db), schema_version: s.db_version, schema_stale: s.stale };
|
|
@@ -3904,6 +4653,72 @@ async function createApp(db, opts = {}) {
|
|
|
3904
4653
|
return reply.code(404).send({ error: "not found" });
|
|
3905
4654
|
return result;
|
|
3906
4655
|
});
|
|
4656
|
+
app.get("/api/security/summary", async () => securitySummary(db));
|
|
4657
|
+
const findingFilters = (q) => ({
|
|
4658
|
+
severity: q.severity,
|
|
4659
|
+
category: q.category,
|
|
4660
|
+
rule: q.rule,
|
|
4661
|
+
session: q.session,
|
|
4662
|
+
source: q.source,
|
|
4663
|
+
project: q.project,
|
|
4664
|
+
from: q.from,
|
|
4665
|
+
to: q.to,
|
|
4666
|
+
status: ["open", "dismissed", "muted", "all"].find((s) => s === q.status)
|
|
4667
|
+
});
|
|
4668
|
+
app.get("/api/security/findings", async (req) => {
|
|
4669
|
+
const q = req.query;
|
|
4670
|
+
return listFindings(db, {
|
|
4671
|
+
...findingFilters(q),
|
|
4672
|
+
sort: ["severity", "session", "rule", "category", "time"].find((s) => s === q.sort),
|
|
4673
|
+
dir: q.dir === "asc" ? "asc" : q.dir === "desc" ? "desc" : void 0,
|
|
4674
|
+
limit: Math.min(Number(q.limit) || 50, 5e3),
|
|
4675
|
+
offset: Number(q.offset) || 0
|
|
4676
|
+
});
|
|
4677
|
+
});
|
|
4678
|
+
app.get("/api/security/mutes", async () => triageDb ? listMutes(triageDb) : []);
|
|
4679
|
+
app.post("/api/security/dismiss", async (req, reply) => {
|
|
4680
|
+
if (!guardWrite(req, reply))
|
|
4681
|
+
return reply;
|
|
4682
|
+
const b = req.body ?? {};
|
|
4683
|
+
const n = dismiss(triageDb, Array.isArray(b.ids) ? b.ids : [], b.note ?? null);
|
|
4684
|
+
return { ok: true, dismissed: n };
|
|
4685
|
+
});
|
|
4686
|
+
app.post("/api/security/reopen", async (req, reply) => {
|
|
4687
|
+
if (!guardWrite(req, reply))
|
|
4688
|
+
return reply;
|
|
4689
|
+
const b = req.body ?? {};
|
|
4690
|
+
const n = reopen(triageDb, Array.isArray(b.ids) ? b.ids : []);
|
|
4691
|
+
return { ok: true, reopened: n };
|
|
4692
|
+
});
|
|
4693
|
+
app.post("/api/security/dismiss-matching", async (req, reply) => {
|
|
4694
|
+
if (!guardWrite(req, reply))
|
|
4695
|
+
return reply;
|
|
4696
|
+
const b = req.body ?? {};
|
|
4697
|
+
const page = listFindings(db, { ...findingFilters(b.filter ?? {}), status: "open", limit: 1e5, offset: 0 });
|
|
4698
|
+
const ids = page.findings.map((r) => r.id);
|
|
4699
|
+
const n = dismiss(triageDb, ids, b.note ?? null);
|
|
4700
|
+
return { ok: true, dismissed: n };
|
|
4701
|
+
});
|
|
4702
|
+
app.post("/api/security/mute", async (req, reply) => {
|
|
4703
|
+
if (!guardWrite(req, reply))
|
|
4704
|
+
return reply;
|
|
4705
|
+
const b = req.body ?? {};
|
|
4706
|
+
if (!b.rule_id)
|
|
4707
|
+
return reply.code(400).send({ error: { code: "BAD_REQUEST", message: "rule_id required" } });
|
|
4708
|
+
const scope = ["global", "project", "source"].find((s) => s === b.scope) ?? "global";
|
|
4709
|
+
muteRule(triageDb, b.rule_id, scope, b.scope_id ?? "", b.note ?? null);
|
|
4710
|
+
return { ok: true };
|
|
4711
|
+
});
|
|
4712
|
+
app.post("/api/security/unmute", async (req, reply) => {
|
|
4713
|
+
if (!guardWrite(req, reply))
|
|
4714
|
+
return reply;
|
|
4715
|
+
const b = req.body ?? {};
|
|
4716
|
+
if (!b.rule_id)
|
|
4717
|
+
return reply.code(400).send({ error: { code: "BAD_REQUEST", message: "rule_id required" } });
|
|
4718
|
+
const scope = ["global", "project", "source"].find((s) => s === b.scope) ?? "global";
|
|
4719
|
+
const n = unmute(triageDb, b.rule_id, scope, b.scope_id ?? "");
|
|
4720
|
+
return { ok: true, unmuted: n };
|
|
4721
|
+
});
|
|
3907
4722
|
app.get("/api/skills", async (req) => {
|
|
3908
4723
|
const q = req.query;
|
|
3909
4724
|
return listSkills(db, { q: q.q, source: q.source, project: q.project });
|
|
@@ -3973,8 +4788,9 @@ async function startServer() {
|
|
|
3973
4788
|
console.error(`agent-lens-server: db not found: ${dbPath} (run ingest first)`);
|
|
3974
4789
|
process.exit(1);
|
|
3975
4790
|
}
|
|
4791
|
+
const triageDbPath = process.env.AGENT_LENS_TRIAGE_DB || join10(dirname5(dbPath), "triage.db");
|
|
3976
4792
|
const db = openReadonly(dbPath);
|
|
3977
|
-
const app = await createApp(db, { webDist });
|
|
4793
|
+
const app = await createApp(db, { webDist, triageDbPath });
|
|
3978
4794
|
try {
|
|
3979
4795
|
await app.listen({ host, port });
|
|
3980
4796
|
console.log(`agent-lens-server: http://${host}:${port} (db: ${dbPath})`);
|