@clear-capabilities/agentic-security-scanner 0.175.0 → 0.176.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/agentic-security.js +199 -28
  3. package/dist/2066.index.js +850 -0
  4. package/dist/2199.index.js +1 -1
  5. package/dist/2584.index.js +3332 -0
  6. package/dist/2923.index.js +13 -0
  7. package/dist/2946.index.js +81 -0
  8. package/dist/3196.index.js +27 -0
  9. package/dist/3727.index.js +292 -0
  10. package/dist/4168.index.js +509 -0
  11. package/dist/4281.index.js +993 -0
  12. package/dist/4978bec8fdf5c733dc85.json +283 -0
  13. package/dist/5099.index.js +96 -0
  14. package/dist/5616.index.js +1471 -0
  15. package/dist/5620.index.js +2370 -0
  16. package/dist/5817.index.js +714 -0
  17. package/dist/6333.index.js +98 -0
  18. package/dist/6655.index.js +3221 -0
  19. package/dist/7838.index.js +446 -974
  20. package/dist/818.index.js +4 -3
  21. package/dist/83.index.js +159 -0
  22. package/dist/8551.index.js +82 -0
  23. package/dist/8633.index.js +93 -0
  24. package/dist/8714.index.js +76 -76
  25. package/dist/8825.index.js +84 -0
  26. package/dist/932.index.js +186 -0
  27. package/dist/9560.index.js +358 -19
  28. package/dist/9800.index.js +6 -1
  29. package/dist/agentic-security.mjs +10 -10
  30. package/dist/agentic-security.mjs.sha256 +1 -1
  31. package/dist/df1e8683e7eda7b2b79c.js +29 -0
  32. package/package.json +5 -4
  33. package/src/artifact/CLAUDE.md +4 -0
  34. package/src/artifact/artifact-evidence.js +63 -0
  35. package/src/artifact/cosign.js +61 -0
  36. package/src/artifact/engine-source.js +72 -0
  37. package/src/artifact/oci-layout.js +47 -0
  38. package/src/artifact/registry.js +213 -0
  39. package/src/capabilities/tool-registry.js +2 -0
  40. package/src/lineage/CLAUDE.md +4 -0
  41. package/src/lineage/identity-token.js +11 -0
  42. package/src/lineage/observation-adapters.js +13 -0
  43. package/src/lineage/runtime-edge-states.js +139 -0
  44. package/src/lineage/runtime-explorer-view.js +82 -0
  45. package/src/lineage/runtime-observation.js +11 -0
  46. package/src/mcp/CLAUDE.md +3 -1
  47. package/src/mcp/proof-tools.js +132 -0
  48. package/src/mcp/server.js +1 -1
  49. package/src/mcp/tools.js +3 -1
  50. package/src/posture/CLAUDE.md +23 -0
  51. package/src/posture/artifact-registry.js +4 -0
  52. package/src/posture/dnr/attack-facts.js +31 -0
  53. package/src/posture/dnr/cli.js +138 -0
  54. package/src/posture/dnr/detection-ir.js +123 -0
  55. package/src/posture/dnr/exposure-window.js +134 -0
  56. package/src/posture/dnr/hunt.js +84 -0
  57. package/src/posture/dnr/log-reader.js +189 -0
  58. package/src/posture/dnr/matchers.js +90 -0
  59. package/src/posture/dnr/outputs.js +262 -0
  60. package/src/posture/dnr/payload-classes.js +120 -0
  61. package/src/posture/dnr/redact.js +25 -0
  62. package/src/posture/dnr/store.js +57 -0
  63. package/src/posture/dnr/validate.js +64 -0
  64. package/src/posture/dnr/view.js +51 -0
  65. package/src/posture/evaluation/lab/arm-ollama.js +88 -0
  66. package/src/posture/evaluation/lab/arms.js +142 -0
  67. package/src/posture/evaluation/lab/cli.js +135 -0
  68. package/src/posture/evaluation/lab/dataset.js +139 -0
  69. package/src/posture/evaluation/lab/materialize.js +44 -0
  70. package/src/posture/evaluation/lab/neutralize.js +144 -0
  71. package/src/posture/evaluation/lab/report.js +176 -0
  72. package/src/posture/evaluation/lab/run.js +225 -0
  73. package/src/posture/evaluation/lab/score.js +135 -0
  74. package/src/posture/evaluation/lab/scoreboard.js +48 -0
  75. package/src/posture/evaluation/lab/stats.js +41 -0
  76. package/src/posture/evaluation/lab/store.js +53 -0
  77. package/src/posture/fleet/cli.js +248 -63
  78. package/src/posture/fleet/discover-edges.js +135 -0
  79. package/src/posture/fleet/enterprise-graph.js +148 -0
  80. package/src/posture/fleet/fleet-graph.js +9 -4
  81. package/src/posture/fleet/fleet-manifest.js +50 -5
  82. package/src/posture/fleet/job-runner.js +153 -0
  83. package/src/posture/fleet/job-store.js +136 -0
  84. package/src/posture/fleet/org-health.js +56 -0
  85. package/src/posture/fleet/scan-cache.js +112 -0
  86. package/src/posture/fleet/scan-child.js +29 -0
  87. package/src/posture/proof-fabric/cli.js +34 -6
  88. package/src/posture/proof-fabric/container.js +57 -0
  89. package/src/posture/proof-fabric/fix-proof.js +2 -1
  90. package/src/posture/proof-fabric/generate-python.js +129 -0
  91. package/src/posture/proof-fabric/generate.js +213 -0
  92. package/src/posture/proof-fabric/reproducer.js +3 -0
  93. package/src/posture/proof-fabric/runner.js +20 -11
  94. package/src/posture/proof-fabric/siblings.js +77 -0
  95. package/src/posture/routing/policy-router.js +103 -0
  96. package/src/posture/rule-compiler/cli.js +64 -6
  97. package/src/posture/rule-compiler/corpus.js +10 -2
  98. package/src/posture/rule-compiler/mutation.js +72 -1
  99. package/src/posture/rule-compiler/promotion.js +21 -5
  100. package/src/posture/rule-compiler/proposal.js +132 -0
  101. package/src/posture/rule-compiler/store.js +16 -0
@@ -0,0 +1,262 @@
1
+ // Renderers for a detection IR (PRD 7, requirement 2): Sigma-compatible logic, a generic HTTP detection, generic WAF
2
+ // suggestions, a structured log query and YARA where it is appropriate. The P1 renderers (Splunk SPL, Microsoft Sentinel KQL,
3
+ // CloudWatch Logs Insights) are text only; nothing here connects to a SIEM.
4
+ //
5
+ // Every value interpolated into a rule comes from the IR, and the IR is closed-world (detection-ir.js), so a rule can only
6
+ // contain what a proof established. Each renderer escapes for ITS target's quoting rules, because an endpoint path or a
7
+ // parameter name is hostile input (it came from a reproducer a model may have written).
8
+ import { PAYLOAD_CLASSES } from './payload-classes.js';
9
+
10
+ export const FORMATS = Object.freeze(['sigma', 'http', 'waf', 'log-query', 'yara', 'spl', 'kql', 'cloudwatch', 'elastic', 'yaral', 'datadog']);
11
+
12
+ /** One regular expression (source text, case-insensitive) covering a class's indicators. */
13
+ export function classRegex(className) {
14
+ const def = PAYLOAD_CLASSES[className];
15
+ if (!def) return null;
16
+ if (def.matcher === 'length') return `.{${def.minLength},}`;
17
+ if (def.matcher === 'behavior') return null;
18
+ return def.indicators.map((re) => `(?:${re.source})`).join('|');
19
+ }
20
+
21
+ const hasTemplate = (p) => /\/(?::[A-Za-z_]\w*|\{[A-Za-z_]\w*\})(?=\/|$)/.test(p);
22
+ const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
23
+ /** A path template as an anchored regular expression. */
24
+ function pathRegex(path) {
25
+ return `^${path.split('/').map((seg) => (/^(?::[A-Za-z_]\w*|\{[A-Za-z_]\w*\})$/.test(seg) ? '[^/]+' : escRe(seg))).join('/')}$`;
26
+ }
27
+
28
+ const levelFor = (ir) => (ir.confidence >= 0.8 ? 'high' : ir.confidence >= 0.5 ? 'medium' : 'low');
29
+ const title = (ir) => `Exploitation attempt: ${ir.payloadClass} against ${ir.endpoint ? `${ir.endpoint.method} ${ir.endpoint.path}` : 'an application endpoint'}`;
30
+ const numericRuleId = (ir) => 9000000 + (parseInt(ir.digest.slice(0, 5), 16) % 999999);
31
+ const unavailable = (reason) => ({ applicable: false, reason });
32
+ const fpNotes = (ir) => [
33
+ 'A legitimate client that sends this payload shape to the same endpoint (a security scanner, a penetration test).',
34
+ ...(ir.payloadClass === 'auth-bypass' ? ['Public endpoints that are intentionally reachable without a credential.'] : []),
35
+ ];
36
+
37
+ /** Sigma rule as YAML text. JSON string syntax is used for every scalar; it is valid YAML and cannot be broken out of. */
38
+ function sigma(ir) {
39
+ if (!ir.endpoint) return unavailable('no endpoint is known for this finding, so there is no request to describe');
40
+ const q = JSON.stringify;
41
+ const lines = [
42
+ `title: ${q(title(ir))}`,
43
+ `id: ${q(`${ir.digest.slice(0, 8)}-${ir.digest.slice(8, 12)}-${ir.digest.slice(12, 16)}-${ir.digest.slice(16, 20)}-${ir.digest.slice(20, 32)}`)}`,
44
+ 'status: experimental',
45
+ `description: ${q(`${ir.attackBehavior}. Generated from finding ${ir.findingId} (${ir.grounding}).`)}`,
46
+ 'references:', ` - ${q(`finding:${ir.findingId}`)}`,
47
+ 'author: agentic-security',
48
+ 'logsource:', ' category: webserver',
49
+ 'detection:', ' selection_request:',
50
+ ` cs-method: ${q(ir.endpoint.method)}`,
51
+ ];
52
+ if (hasTemplate(ir.endpoint.path)) lines.push(` cs-uri-stem|re: ${q(pathRegex(ir.endpoint.path))}`);
53
+ else lines.push(` cs-uri-stem: ${q(ir.endpoint.path)}`);
54
+ let condition = 'selection_request';
55
+ if (ir.payloadClass === 'auth-bypass') {
56
+ lines.push(' selection_outcome:', ' sc-status|startswith: "2"', ' filter_credential:', ' cs-authorization|exists: true');
57
+ condition = 'selection_request and selection_outcome and not filter_credential';
58
+ } else {
59
+ const field = ir.parameter?.location === 'body' ? 'request-body' : ir.parameter?.location === 'header' ? `cs(${ir.parameter.name})` : ir.parameter?.location === 'cookie' ? 'cs-cookie' : 'cs-uri-query';
60
+ lines.push(' selection_payload:', ` ${field}|re|i: ${q(classRegex(ir.payloadClass))}`);
61
+ condition = 'selection_request and selection_payload';
62
+ }
63
+ lines.push(` condition: ${condition}`, 'falsepositives:', ...fpNotes(ir).map((n) => ` - ${q(n)}`), `level: ${levelFor(ir)}`,
64
+ 'tags:', ' - attack.initial_access', ' - attack.t1190', ...(ir.cwe ? [` - ${q(String(ir.cwe).toLowerCase().replace('cwe-', 'cwe.'))}`] : []));
65
+ return { applicable: true, format: 'sigma', content: `${lines.join('\n')}\n` };
66
+ }
67
+
68
+ function http(ir) {
69
+ if (!ir.endpoint) return unavailable('no endpoint is known for this finding');
70
+ const def = PAYLOAD_CLASSES[ir.payloadClass];
71
+ const detection = {
72
+ schema: 'agentic-security/http-detection', version: 1, id: ir.id, findingId: ir.findingId, level: levelFor(ir), mode: 'detect',
73
+ request: { method: ir.endpoint.method, pathTemplate: ir.endpoint.path },
74
+ parameter: ir.parameter,
75
+ payload: ir.payloadClass === 'auth-bypass' ? { class: 'auth-bypass', behavior: 'status 2xx with no credential' }
76
+ : { class: ir.payloadClass, matcher: def.matcher, minLength: def.minLength ?? null, regex: classRegex(ir.payloadClass), flags: 'i' },
77
+ limitations: ir.limitations,
78
+ };
79
+ return { applicable: true, format: 'http', content: `${JSON.stringify(detection, null, 2)}\n` };
80
+ }
81
+
82
+ /** ModSecurity-style suggestion. Always detect-only (`pass`): a generated rule must never block traffic on its own. */
83
+ function waf(ir) {
84
+ if (!ir.endpoint) return unavailable('no endpoint is known for this finding');
85
+ const id = numericRuleId(ir);
86
+ const msg = `Agentic Security ${ir.id}: ${ir.payloadClass} on ${ir.endpoint.method} ${ir.endpoint.path}`.replace(/['"\\]/g, '');
87
+ const rx = (s) => s.replace(/"/g, '\\x22');
88
+ const pathOp = hasTemplate(ir.endpoint.path) ? `"@rx ${rx(pathRegex(ir.endpoint.path))}"` : `"@streq ${rx(ir.endpoint.path)}"`;
89
+ const out = [
90
+ '# SUGGESTION ONLY. Detect-only (pass). Review, test against your own traffic, then decide whether to enforce.',
91
+ `SecRule REQUEST_METHOD "@streq ${ir.endpoint.method}" "id:${id},phase:2,pass,nolog,chain"`,
92
+ ` SecRule REQUEST_FILENAME ${pathOp} "chain"`,
93
+ ];
94
+ if (ir.payloadClass === 'auth-bypass') {
95
+ out.push(` SecRule &REQUEST_HEADERS:Authorization "@eq 0" "log,auditlog,msg:'${msg}',severity:'WARNING',tag:'cwe-${(ir.cwe || '').replace(/\D/g, '')}'"`);
96
+ } else {
97
+ const target = ir.parameter?.location === 'body' ? `ARGS_POST${ir.parameter ? `:${ir.parameter.name}` : ''}` : ir.parameter?.location === 'header' ? `REQUEST_HEADERS:${ir.parameter.name}` : ir.parameter?.location === 'cookie' ? `REQUEST_COOKIES:${ir.parameter.name}` : ir.parameter ? `ARGS_GET:${ir.parameter.name}` : 'ARGS';
98
+ out.push(` SecRule ${target} "@rx ${rx(classRegex(ir.payloadClass))}" "t:urlDecodeUni,log,auditlog,msg:'${msg}',severity:'CRITICAL',tag:'cwe-${(ir.cwe || '').replace(/\D/g, '')}'"`);
99
+ }
100
+ return { applicable: true, format: 'waf', content: `${out.join('\n')}\n`, enforcement: 'detect-only' };
101
+ }
102
+
103
+ function logQuery(ir) {
104
+ if (!ir.endpoint) return unavailable('no endpoint is known for this finding');
105
+ const where = [{ field: 'http.request.method', op: 'eq', value: ir.endpoint.method }, hasTemplate(ir.endpoint.path) ? { field: 'url.path', op: 'regex', value: pathRegex(ir.endpoint.path) } : { field: 'url.path', op: 'eq', value: ir.endpoint.path }];
106
+ if (ir.payloadClass === 'auth-bypass') where.push({ field: 'http.response.status_code', op: 'range', value: [200, 299] }, { field: 'http.request.headers.authorization', op: 'absent' });
107
+ else where.push({ field: ir.parameter?.location === 'body' ? 'http.request.body.content' : ir.parameter?.location === 'query' ? `url.query.${ir.parameter.name}` : 'url.query', op: 'regex', value: classRegex(ir.payloadClass), flags: 'i' });
108
+ return { applicable: true, format: 'log-query', content: `${JSON.stringify({ schema: 'agentic-security/structured-log-query', version: 1, id: ir.id, findingId: ir.findingId, where }, null, 2)}\n` };
109
+ }
110
+
111
+ function yara(ir) {
112
+ if (ir.payloadClass !== 'deserialization') return unavailable(`YARA matches file or memory content; the ${ir.payloadClass} class is request-shaped, so a YARA rule would be misleading`);
113
+ const ident = `as_${ir.digest.slice(0, 12)}`;
114
+ const content = [
115
+ `rule ${ident} {`, ' meta:', ` description = ${JSON.stringify(`serialized object stream (${ir.findingId})`)}`, ` finding = ${JSON.stringify(ir.findingId)}`, ' generated_by = "agentic-security"',
116
+ ' strings:', ' $java_raw = { AC ED 00 05 }', ' $java_b64 = "rO0AB"', ' $pickle_b64 = "gASV"', ' $php = /O:[0-9]{1,4}:"[A-Za-z_\\\\]/',
117
+ ' condition:', ' any of them', '}', '',
118
+ ].join('\n');
119
+ return { applicable: true, format: 'yara', content };
120
+ }
121
+
122
+ const splQ = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
123
+ function spl(ir) {
124
+ if (!ir.endpoint) return unavailable('no endpoint is known for this finding');
125
+ const pathPart = hasTemplate(ir.endpoint.path) ? `| regex uri_path=${splQ(pathRegex(ir.endpoint.path))}` : `uri_path=${splQ(ir.endpoint.path)}`;
126
+ const head = `index=* method=${ir.endpoint.method} ${hasTemplate(ir.endpoint.path) ? '' : pathPart}`.trim();
127
+ const rest = [hasTemplate(ir.endpoint.path) ? pathPart : null,
128
+ ir.payloadClass === 'auth-bypass' ? '| where status>=200 AND status<300 AND isnull(http_authorization)' : `| regex ${ir.parameter?.location === 'body' ? 'request_body' : 'uri_query'}=${splQ(`(?i)${classRegex(ir.payloadClass)}`)}`].filter(Boolean);
129
+ return { applicable: true, format: 'spl', content: `${[head, ...rest].join('\n')}\n` };
130
+ }
131
+ const kqlQ = (s) => `@"${s.replace(/"/g, '""')}"`;
132
+ function kql(ir) {
133
+ if (!ir.endpoint) return unavailable('no endpoint is known for this finding');
134
+ if (['body', 'header', 'cookie'].includes(ir.parameter?.location)) return unavailable(`the W3CIISLog table does not record request ${ir.parameter.location}s, so this rule would never see the parameter`);
135
+ const lines = ['W3CIISLog', `| where csMethod == ${JSON.stringify(ir.endpoint.method)}`,
136
+ hasTemplate(ir.endpoint.path) ? `| where csUriStem matches regex ${kqlQ(pathRegex(ir.endpoint.path))}` : `| where csUriStem == ${JSON.stringify(ir.endpoint.path)}`,
137
+ ir.payloadClass === 'auth-bypass' ? '| where scStatus between (200 .. 299) and isempty(csAuthorization)' : `| where csUriQuery matches regex ${kqlQ(`(?i)${classRegex(ir.payloadClass)}`)}`];
138
+ return { applicable: true, format: 'kql', content: `${lines.join('\n')}\n` };
139
+ }
140
+ function cloudwatch(ir) {
141
+ if (!ir.endpoint) return unavailable('no endpoint is known for this finding');
142
+ if (['body', 'header', 'cookie'].includes(ir.parameter?.location)) return unavailable(`access logs do not record request ${ir.parameter.location}s, so this query would never see the parameter`);
143
+ const re = (s) => `/${s.replace(/\//g, '\\/')}/`;
144
+ const lines = ['fields @timestamp, @message', `| filter @message like ${re(`${ir.endpoint.method} ${hasTemplate(ir.endpoint.path) ? pathRegex(ir.endpoint.path).replace(/^\^|\$$/g, '') : escRe(ir.endpoint.path)}`)}`,
145
+ ir.payloadClass === 'auth-bypass' ? '| filter @message like /" 2[0-9][0-9] /' : `| filter @message like ${re(`(?i)${classRegex(ir.payloadClass)}`)}`, '| sort @timestamp desc'];
146
+ return { applicable: true, format: 'cloudwatch', content: `${lines.join('\n')}\n` };
147
+ }
148
+
149
+ const RENDERERS = { sigma, http, waf, 'log-query': logQuery, yara, spl, kql, cloudwatch };
150
+
151
+ /** Render one format. An inapplicable format is reported as such with its reason, never as an empty rule. */
152
+ export function renderDetection(ir, format) {
153
+ const fn = RENDERERS[format];
154
+ if (!fn) return { applicable: false, reason: `unknown format: ${format}` };
155
+ return { format, ...fn(ir) };
156
+ }
157
+
158
+ export function renderAll(ir) {
159
+ return Object.fromEntries(FORMATS.map((f) => [f, renderDetection(ir, f)]));
160
+ }
161
+
162
+ // ── Elasticsearch / Elastic Security, Google Chronicle (YARA-L) and Datadog Cloud SIEM (P1 adapters) ──────────────────────────────────
163
+ // Text only, like every other renderer here: nothing connects to a SIEM. Each target has a different regular-expression dialect or none,
164
+ // and a rule that silently means something else is worse than no rule, so a renderer either translates exactly or reports why it cannot.
165
+
166
+ /**
167
+ * A JavaScript regular-expression source as an Elasticsearch/Lucene `regexp` pattern, or null when some construct has no Lucene
168
+ * equivalent (anchors, word boundaries, look-around, back-references). A lazy quantifier becomes greedy, which is exact for a yes/no match. Lucene matches the WHOLE term, so the caller
169
+ * wraps unanchored patterns in `.*(...).*`; case-insensitivity is the query's `case_insensitive` flag, not an inline flag.
170
+ */
171
+ export function luceneRegex(src) {
172
+ const cls = { d: '0-9', w: 'a-zA-Z0-9_', s: ' \\t\\n\\r' };
173
+ const bare = { d: '[0-9]', D: '[^0-9]', w: '[a-zA-Z0-9_]', s: '[ \\t\\n\\r]', S: '[^ \\t\\n\\r]' };
174
+ const RESERVED = new Set(['&', '~', '<', '>', '@', '"', '#']);
175
+ let out = ''; let inClass = false; let prevQuant = false;
176
+ for (let i = 0; i < src.length; i++) {
177
+ const c = src[i];
178
+ if (c === '\\') {
179
+ const n = src[++i];
180
+ if (n === undefined) return null;
181
+ if (inClass) {
182
+ if (cls[n]) out += cls[n]; else if (/[dswDSWbBnrt0-9A-Za-z]/.test(n)) { if (n === 'n') out += '\\n'; else if (n === 'r') out += '\\r'; else if (n === 't') out += '\\t'; else return null; } else out += `\\${n}`;
183
+ } else if (bare[n]) out += bare[n];
184
+ else if (n === 'n') out += '\\n'; else if (n === 'r') out += '\\r'; else if (n === 't') out += '\\t';
185
+ else if (/[A-Za-z0-9]/.test(n)) return null;
186
+ else out += `\\${n}`;
187
+ prevQuant = false; continue;
188
+ }
189
+ if (inClass) { if (c === ']') inClass = false; out += RESERVED.has(c) ? `\\${c}` : c; continue; }
190
+ if (c === '[') { inClass = true; out += c; prevQuant = false; continue; }
191
+ if (c === '(') {
192
+ if (src[i + 1] === '?') { if (src[i + 2] === ':') { out += '('; i += 2; prevQuant = false; continue; } return null; }
193
+ out += '('; prevQuant = false; continue;
194
+ }
195
+ if (c === '^' || c === '$') return null;
196
+ if (c === '?' && prevQuant) { prevQuant = false; continue; } // a lazy quantifier: for a yes/no match the greedy form is equivalent, and Lucene has no lazy form
197
+ if (c === '*' || c === '+' || c === '?') { out += c; prevQuant = true; continue; }
198
+ if (c === '{') { const m = /^\{\d+(?:,\d*)?\}/.exec(src.slice(i)); if (!m) { out += '\\{'; prevQuant = false; continue; } out += m[0]; i += m[0].length - 1; prevQuant = true; continue; }
199
+ if (c === '}') { out += '\\}'; prevQuant = false; continue; }
200
+ out += RESERVED.has(c) ? `\\${c}` : c; prevQuant = false;
201
+ }
202
+ return inClass ? null : out;
203
+ }
204
+
205
+ function elastic(ir) {
206
+ if (!ir.endpoint) return unavailable('no endpoint is known for this finding');
207
+ const loc = ir.parameter?.location;
208
+ const filter = [{ term: { 'http.request.method': ir.endpoint.method } }];
209
+ if (hasTemplate(ir.endpoint.path)) {
210
+ const p = luceneRegex(pathRegex(ir.endpoint.path).replace(/^\^|\$$/g, ''));
211
+ if (p === null) return unavailable('the endpoint path template has no Lucene regular-expression form');
212
+ filter.push({ regexp: { 'url.path': { value: p } } });
213
+ } else filter.push({ term: { 'url.path': ir.endpoint.path } });
214
+ const mustNot = [];
215
+ if (ir.payloadClass === 'auth-bypass') {
216
+ filter.push({ range: { 'http.response.status_code': { gte: 200, lte: 299 } } });
217
+ mustNot.push({ exists: { field: 'http.request.headers.authorization' } });
218
+ } else {
219
+ const re = luceneRegex(classRegex(ir.payloadClass) ?? '');
220
+ if (re === null || !classRegex(ir.payloadClass)) return unavailable(`the ${ir.payloadClass} indicators use regular-expression constructs Lucene cannot express exactly (anchors, word boundaries or look-around), so a rule would not mean what the proof established`);
221
+ const field = loc === 'body' ? 'http.request.body.content' : loc === 'query' ? 'url.query' : loc === 'header' || loc === 'cookie' ? null : 'url.query';
222
+ if (!field) return unavailable(`ECS web logs do not record request ${loc}s by default, so this rule would never see the parameter`);
223
+ filter.push({ regexp: { [field]: { value: `.*(${re}).*`, case_insensitive: true } } });
224
+ }
225
+ const body = { query: { bool: { filter, ...(mustNot.length ? { must_not: mustNot } : {}) } }, _meta: { id: ir.id, findingId: ir.findingId, generatedBy: 'agentic-security', note: 'Lucene regexp matches the whole field value; the pattern is wrapped accordingly.' } };
226
+ return { applicable: true, format: 'elastic', content: `${JSON.stringify(body, null, 2)}\n` };
227
+ }
228
+
229
+ /** True when a regex source uses `^`, `$` or a word boundary outside a character class: its meaning depends on WHERE the value starts and ends. */
230
+ const edgeSensitive = (src) => /[\^$]/.test(src.replace(/\\./g, 'x').replace(/\[[^\]]*\]/g, 'x')) || /\\[bB]/.test(src);
231
+
232
+ function yaral(ir) {
233
+ if (!ir.endpoint) return unavailable('no endpoint is known for this finding');
234
+ if (['body', 'header', 'cookie'].includes(ir.parameter?.location)) return unavailable(`UDM HTTP events carry the URL, not request ${ir.parameter.location}s, so this rule would never see the parameter`);
235
+ if (ir.payloadClass === 'auth-bypass') return unavailable('UDM HTTP events do not carry request headers, so "no credential was sent" cannot be expressed');
236
+ const cr = classRegex(ir.payloadClass);
237
+ if (!cr) return unavailable(`the ${ir.payloadClass} class has no regular-expression indicators`);
238
+ // the indicators are written against a query string or a body; YARA-L sees the whole URL, so a pattern anchored to "the start/end of the value" would mean something else
239
+ if (edgeSensitive(cr)) return unavailable(`the ${ir.payloadClass} indicators are anchored to the start or end of the parameter value, and UDM only exposes the whole URL, so a rule would not mean what the proof established`);
240
+ const pathRe = hasTemplate(ir.endpoint.path) ? pathRegex(ir.endpoint.path).replace(/^\^|\$$/g, '') : escRe(ir.endpoint.path);
241
+ const urlRe = `(?i)^[^?]*${pathRe}(?:\\?.*)?$`;
242
+ const payloadRe = `(?i)${cr}`;
243
+ if (urlRe.includes('`') || payloadRe.includes('`')) return unavailable('the pattern contains a backtick, which cannot appear in a YARA-L raw regular expression');
244
+ const ident = `as_${ir.digest.slice(0, 12)}`;
245
+ const content = [
246
+ `rule ${ident} {`, ' meta:', ` description = ${JSON.stringify(title(ir))}`, ` finding = ${JSON.stringify(ir.findingId)}`, ' author = "agentic-security"', ` severity = ${JSON.stringify(levelFor(ir).toUpperCase())}`,
247
+ ' events:', ' $e.metadata.event_type = "NETWORK_HTTP"', ` $e.network.http.method = ${JSON.stringify(ir.endpoint.method)}`,
248
+ ` re.regex($e.target.url, \`${urlRe}\`)`, ` re.regex($e.target.url, \`${payloadRe}\`)`, ' condition:', ' $e', '}', '',
249
+ ].join('\n');
250
+ return { applicable: true, format: 'yaral', content };
251
+ }
252
+
253
+ function datadog(ir) {
254
+ if (!ir.endpoint) return unavailable('no endpoint is known for this finding');
255
+ if (ir.payloadClass !== 'auth-bypass') return unavailable(`Datadog log search has wildcards but no regular expressions, so the ${ir.payloadClass} indicators cannot be expressed exactly; use the log-query or sigma output`);
256
+ const wild = ir.endpoint.path.replace(/\/(?::[A-Za-z_]\w*|\{[A-Za-z_]\w*\})(?=\/|$)/g, '/*');
257
+ if (/[\s"\\]/.test(wild)) return unavailable('the endpoint path contains characters that cannot be quoted safely in a Datadog query');
258
+ const query = `@http.method:${ir.endpoint.method} @http.url_details.path:${wild} @http.status_code:[200 TO 299] -@http.request.headers.authorization:*`;
259
+ const rule = { name: title(ir), type: 'log_detection', isEnabled: false, message: `${title(ir)}. Finding ${ir.findingId}. Generated by agentic-security; review before enabling.`, tags: ['source:agentic-security'], queries: [{ query, aggregation: 'count', groupByFields: [], distinctFields: [], name: 'a' }], cases: [{ name: 'proven pattern seen', status: levelFor(ir) === 'high' ? 'high' : 'medium', condition: 'a > 0', notifications: [] }], options: { evaluationWindow: 900, keepAlive: 3600, maxSignalDuration: 86400 } };
260
+ return { applicable: true, format: 'datadog', content: `${JSON.stringify(rule, null, 2)}\n` };
261
+ }
262
+ RENDERERS.elastic = elastic; RENDERERS.yaral = yaral; RENDERERS.datadog = datadog;
@@ -0,0 +1,120 @@
1
+ // Payload classes for the Detection-and-Response loop (PRD 7). A class is a GENERIC, structural description of what an
2
+ // exploit of a vulnerability family looks like on the wire (a shell metacharacter followed by a command word, a `..`
3
+ // path segment, a request to a loopback address). It is deliberately not an indicator of compromise: nothing here names
4
+ // an attacker address, domain, malware hash or user agent, because no proof ever observed one. A detection built from a
5
+ // class can say "this request has the shape of a command injection against /api/export"; it cannot say who sent it.
6
+ //
7
+ // The same matcher (`matchesClass`) is used when a detection is validated, when historical logs are hunted, and when
8
+ // the rendered rules are described, so the rule that is tested is the rule that ships.
9
+
10
+ export const PAYLOAD_CLASSES = Object.freeze({
11
+ 'sql-injection': {
12
+ cwe: ['CWE-89', 'CWE-564'],
13
+ behavior: 'SQL metacharacters change the structure of a database query built from request input',
14
+ matcher: 'regex',
15
+ indicators: [
16
+ /(?:'|%27)\s*(?:or|and)\s+(?:'|%27|"|%22|\d)/i,
17
+ /union(?:\s|\+|%20|\/\*.*?\*\/)+(?:all(?:\s|\+|%20)+)?select/i,
18
+ /(?:;|%3b)\s*(?:drop|insert|update|delete|alter|truncate)(?:\s|\+|%20)/i,
19
+ /(?:--|#|%23)(?:\s|\+|%20|$)/,
20
+ /\b(?:sleep|benchmark|pg_sleep|waitfor\s+delay)\s*\(?\s*['"]?\d/i,
21
+ ],
22
+ exemplars: { positive: ["' OR '1'='1", '1 UNION SELECT NULL,NULL--', "x'; DROP TABLE users--"], benign: ['alice', '42', "o'brien", 'blue-widget', 'sleeping bag'] },
23
+ effects: { process: 'none', network: 'none', db: 'query-structure-altered' },
24
+ },
25
+ 'command-injection': {
26
+ cwe: ['CWE-78', 'CWE-77', 'CWE-88'],
27
+ behavior: 'shell metacharacters followed by a command cause the server to run an attacker-chosen command',
28
+ matcher: 'regex',
29
+ indicators: [
30
+ /(?:;|\||&&|\|\||`|\$\(|%3b|%7c|%26%26|%60|%24%28|%0a|\r?\n)(?:[ \t+]|%20){0,16}(?:cat|ls|id|whoami|uname|curl|wget|nc|ncat|bash|sh|zsh|python\d?|perl|ruby|php|node|touch|rm|chmod|ping|nslookup|dig|env|printenv)(?:\b|\s|%20|\+)/i,
31
+ /\$\{IFS\}|%24%7bIFS%7d/i,
32
+ ],
33
+ exemplars: { positive: ['report.pdf; cat /etc/passwd', 'a | whoami', '$(id)', 'x`uname -a`'], benign: ['report.pdf', 'quarterly summary', 'a-b_c.txt', 'cat photos', 'id'] },
34
+ effects: { process: 'child-process-spawned', network: 'none', db: 'none' },
35
+ },
36
+ 'path-traversal': {
37
+ cwe: ['CWE-22', 'CWE-23', 'CWE-36', 'CWE-73'],
38
+ behavior: 'parent-directory segments in a path parameter reach files outside the intended directory',
39
+ matcher: 'regex',
40
+ indicators: [/(?:^|[\\/]|%2f|%5c)\.\.(?:[\\/]|%2f|%5c|$)/i, /%2e%2e(?:[\\/]|%2f|%5c)/i, /\.\.;\//, /%252e%252e/i, /\.{3,16}[\\/]/],
41
+ exemplars: { positive: ['../../etc/passwd', '..%2f..%2fetc%2fpasswd', '....//....//etc/passwd'], benign: ['report.pdf', 'docs/readme.txt', 'a..b.txt', 'v1.2..3'] },
42
+ effects: { process: 'none', network: 'none', db: 'none', file: 'file-outside-root-accessed' },
43
+ },
44
+ ssrf: {
45
+ cwe: ['CWE-918'],
46
+ behavior: 'a server-side request is directed at an internal, loopback or metadata address chosen by the caller',
47
+ matcher: 'regex',
48
+ indicators: [
49
+ /(?:^|[/@=:])(?:127(?:\.\d{1,3}){3}|localhost|0\.0\.0\.0|\[?::1\]?|0x7f[0-9a-f]{6}|2130706433)(?:[:/?#]|$)/i,
50
+ /169\.254\.\d{1,3}\.\d{1,3}/,
51
+ /(?:^|[/@=])(?:10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})(?:[:/?#]|$)/,
52
+ /metadata\.google\.internal|metadata\.azure\.com/i,
53
+ ],
54
+ exemplars: { positive: ['http://169.254.169.254/latest/meta-data/', 'http://127.0.0.1:8080/admin', 'http://localhost/'], benign: ['https://example.com/image.png', 'https://cdn.example.org/a.jpg', 'http://127.example.com.invalid/ok'] },
55
+ effects: { process: 'none', network: 'outbound-to-internal-address', db: 'none' },
56
+ },
57
+ deserialization: {
58
+ cwe: ['CWE-502'],
59
+ behavior: 'a serialized object stream is supplied where the application deserializes request data',
60
+ matcher: 'regex',
61
+ indicators: [/rO0AB/, /(?:%ac|\\xac)(?:%ed|\\xed)(?:%00|\\x00)(?:%05|\\x05)/i, /gASV|gAJ[A-Za-z]/, /(?:^|[^A-Za-z0-9])O:\d{1,4}:"[A-Za-z_\\]/, /!!python\/object|!ruby\/object/i, /__reduce__|cos\\nsystem|c__builtin__/],
62
+ exemplars: { positive: ['rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcA', 'gASVKAAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjAJpZJSFlFKULg==', 'O:8:"stdClass":0:{}'], benign: ['hello world', 'Order 12345', 'rolling stones'] },
63
+ effects: { process: 'object-graph-instantiated', network: 'none', db: 'none' },
64
+ },
65
+ 'file-write': {
66
+ cwe: ['CWE-434', 'CWE-646'],
67
+ behavior: 'a file name with an executable extension or a traversal segment is accepted by a file-writing operation',
68
+ matcher: 'regex',
69
+ indicators: [/\.(?:php\d?|phtml|phar|jsp|jspx|aspx?|cgi|sh|exe|dll)(?:[.\s%;]|$)/i, /(?:^|[\\/]|%2f|%5c)\.\.(?:[\\/]|%2f|%5c)/i, /%00/],
70
+ exemplars: { positive: ['shell.php', 'avatar.png.php', '../../var/www/html/x.jsp'], benign: ['photo.png', 'invoice-2024.pdf', 'notes.txt'] },
71
+ effects: { process: 'none', network: 'none', db: 'none', file: 'file-written-by-request' },
72
+ },
73
+ 'buffer-overflow': {
74
+ cwe: ['CWE-120', 'CWE-121', 'CWE-122', 'CWE-787', 'CWE-125', 'CWE-119'],
75
+ behavior: 'an input far longer than any legitimate value is supplied to a fixed-size buffer',
76
+ matcher: 'length',
77
+ minLength: 512,
78
+ indicators: [],
79
+ exemplars: { positive: ['A'.repeat(600), 'B'.repeat(4096)], benign: ['alice', 'a normal sentence of moderate length', 'x'.repeat(64)] },
80
+ effects: { process: 'memory-corruption-possible', network: 'none', db: 'none' },
81
+ },
82
+ 'auth-bypass': {
83
+ cwe: ['CWE-287', 'CWE-306', 'CWE-285', 'CWE-862', 'CWE-863', 'CWE-639', 'CWE-284'],
84
+ behavior: 'a protected operation succeeds for a request that carries no credential',
85
+ matcher: 'behavior',
86
+ indicators: [],
87
+ exemplars: { positive: [''], benign: [''] },
88
+ effects: { process: 'none', network: 'none', db: 'none', access: 'protected-operation-without-credential' },
89
+ },
90
+ });
91
+
92
+ export const PAYLOAD_CLASS_NAMES = Object.freeze(Object.keys(PAYLOAD_CLASSES));
93
+
94
+ /** Map a CWE id (`CWE-89`, `89`, `cwe-89`) to a payload class name, or null when no class describes it. */
95
+ export function classForCwe(cwe) {
96
+ const m = /(\d{1,5})/.exec(String(cwe ?? ''));
97
+ if (!m) return null;
98
+ const want = `CWE-${Number(m[1])}`;
99
+ for (const [name, def] of Object.entries(PAYLOAD_CLASSES)) if (def.cwe.includes(want)) return name;
100
+ return null;
101
+ }
102
+
103
+ /** Percent-decode once, tolerating malformed escapes (a hostile value must never throw here). */
104
+ function decodeOnce(value) {
105
+ const s = String(value ?? '');
106
+ try { return decodeURIComponent(s.replace(/\+/g, ' ')); } catch { return s; }
107
+ }
108
+
109
+ /**
110
+ * Does `value` look like an exploit of `className`? Checks the raw and the once-decoded form, so an encoded payload and a
111
+ * plain one are treated alike. `behavior` classes have no value test: they match on request behavior, not content.
112
+ */
113
+ export function matchesClass(className, value) {
114
+ const def = PAYLOAD_CLASSES[className];
115
+ if (!def || def.matcher === 'behavior') return false;
116
+ const raw = String(value ?? '').slice(0, 65536);
117
+ const forms = raw === decodeOnce(raw) ? [raw] : [raw, decodeOnce(raw)];
118
+ if (def.matcher === 'length') return forms.some((f) => f.length >= def.minLength);
119
+ return forms.some((f) => def.indicators.some((re) => re.test(f)));
120
+ }
@@ -0,0 +1,25 @@
1
+ // Redaction for anything the loop persists or prints from production logs (PRD 7, privacy requirements). A matched log value
2
+ // is evidence that an exploit-shaped request existed; it must never also be a copy of a password, a token or regulated data.
3
+ // Layered on the proof fabric's secret scrubber (credentials), with the data classes a request log is likely to carry.
4
+ import { createHash } from 'node:crypto';
5
+ import { scrubDeep } from '../proof-fabric/store.js';
6
+
7
+ const PATTERNS = [
8
+ [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '[REDACTED-EMAIL]'],
9
+ [/\b(?:\d[ -]?){13,19}\b/g, '[REDACTED-NUMBER]'],
10
+ [/\b\d{3}-\d{2}-\d{4}\b/g, '[REDACTED-ID]'],
11
+ [/\b(?:bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, '[REDACTED-CREDENTIAL]'],
12
+ [/\b(?:password|passwd|pwd|secret|token|api[_-]?key|authorization|session|sid)=[^&\s"']+/gi, (m) => `${m.split('=')[0]}=[REDACTED]`],
13
+ [/\b[A-Za-z0-9+/_-]{40,}={0,2}/g, '[REDACTED-TOKEN]'],
14
+ ];
15
+
16
+ /** Redact a string for storage or display, then bound its length. */
17
+ export function redactValue(value, max = 120) {
18
+ let t = String(value ?? '');
19
+ for (const [re, rep] of PATTERNS) t = t.replace(re, rep);
20
+ t = scrubDeep(t);
21
+ return t.length > max ? `${t.slice(0, max)}…` : t;
22
+ }
23
+
24
+ /** Stable short fingerprint of the ORIGINAL value, so two sightings of the same payload can be recognised without storing it. */
25
+ export const fingerprint = (value) => createHash('sha256').update(String(value ?? '')).digest('hex').slice(0, 16);
@@ -0,0 +1,57 @@
1
+ // Persistence for the detection-and-response loop: `.agentic-security/dnr/<findingKey>/<kind>-<digest>.json`.
2
+ // Same machinery and the same guarantees as the proof store, reused rather than copied where it is exported:
3
+ // safe state-directory writes (no symlinks, no unsafe dirs), the encryption policy, retention as evidence, legal holds, and
4
+ // a recursive secret scrub before anything touches disk. A record that no longer matches its own digest is returned FLAGGED,
5
+ // never trusted and never silently dropped.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
9
+ import { maybeEncryptForWrite, maybeDecryptForRead } from '../encryption-provider.js';
10
+ import { canonicalize, sha256 } from '../proof-fabric/reproducer.js';
11
+ import { scrubDeep, findingKey } from '../proof-fabric/store.js';
12
+
13
+ const DIR = 'dnr';
14
+ const MAX_RECORD_BYTES = 1024 * 1024;
15
+ const RECORD_KINDS = Object.freeze(['detection', 'hunt', 'window', 'fix']);
16
+
17
+ const digestOf = (record) => { const { recordDigest: _omit, ...rest } = record; return sha256(canonicalize(rest)); };
18
+
19
+ export function saveRecord(root, findingId, kind, record) {
20
+ if (!RECORD_KINDS.includes(kind)) return { ok: false, reason: `unknown record kind: ${kind}` };
21
+ const safe = scrubDeep(record);
22
+ safe.schema = `agentic-security/dnr-${kind}-record`;
23
+ safe.kind = kind;
24
+ safe.findingId = String(findingId);
25
+ safe.savedAt = record.savedAt ?? new Date().toISOString();
26
+ safe.recordDigest = digestOf(safe);
27
+ const text = JSON.stringify(safe, null, 2);
28
+ if (Buffer.byteLength(text) > MAX_RECORD_BYTES) return { ok: false, reason: 'record exceeds the size limit' };
29
+ if (!ensureStateDir(root)) return { ok: false, reason: 'no safe state directory for this project' };
30
+ const enc = maybeEncryptForWrite(root, DIR, text);
31
+ if (!enc.ok) return { ok: false, reason: enc.reason };
32
+ const file = statePath(root, DIR, findingKey(findingId), `${kind}-${safe.recordDigest.slice(0, 16)}.json`);
33
+ if (!safeWriteState(file, enc.content)) return { ok: false, reason: 'state write refused' };
34
+ return { ok: true, file, digest: safe.recordDigest, encrypted: enc.encrypted };
35
+ }
36
+
37
+ /** Newest-first records of one kind for a finding. Tampered records are flagged. */
38
+ export function listRecords(root, findingId, kind) {
39
+ const dir = statePath(root, DIR, findingKey(findingId));
40
+ let names = [];
41
+ try { names = fs.readdirSync(dir).filter((n) => n.startsWith(`${kind}-`) && n.endsWith('.json')); } catch { return []; }
42
+ const out = [];
43
+ for (const n of names) {
44
+ const file = path.join(dir, n);
45
+ try {
46
+ const rec = JSON.parse(maybeDecryptForRead(fs.readFileSync(file, 'utf8')));
47
+ out.push({ file, record: rec, tampered: rec.recordDigest !== digestOf(rec), mtime: fs.statSync(file).mtimeMs });
48
+ } catch { out.push({ file, record: null, tampered: true, mtime: 0 }); }
49
+ }
50
+ return out.sort((a, b) => b.mtime - a.mtime);
51
+ }
52
+
53
+ /** Newest intact record of a kind, or null. A tampered newest record does NOT fall through silently: the caller can see it via listRecords. */
54
+ export function latestRecord(root, findingId, kind) {
55
+ return listRecords(root, findingId, kind).find((r) => r.record && !r.tampered) ?? null;
56
+ }
57
+ export const hasTamperedRecords = (root, findingId, kind) => listRecords(root, findingId, kind).some((r) => r.tampered);
@@ -0,0 +1,64 @@
1
+ // Detection validation (PRD 7, requirement 6): before a detection is allowed to be "enabled", prove it behaves.
2
+ // positive exploit event -> the detection fires
3
+ // negative lookalike -> the detection does not fire
4
+ // Events are synthesized from the IR's own proven facts (endpoint, parameter, class) and the class's exemplar payloads, then
5
+ // pushed through the SAME evaluator the hunt uses, so a rule that validates is the rule that searches the logs. The negatives
6
+ // are the point: a rule that fires on everything passes every positive test.
7
+ import { PAYLOAD_CLASSES } from './payload-classes.js';
8
+ import { evaluateEvent } from './matchers.js';
9
+ import { classRegex } from './outputs.js';
10
+
11
+ const PROBE = 'probe';
12
+
13
+ function eventFor(ir, { method, path, values, extraParam = null, status = 200, authPresent = null }) {
14
+ const p = ir.parameter;
15
+ const bag = {};
16
+ const ev = { time: '2024-01-01T00:00:00.000Z', method, path, query: {}, bodyParams: {}, headers: {}, status, authPresent };
17
+ const put = (loc, name, vals) => {
18
+ if (loc === 'query') ev.query[name] = vals;
19
+ else if (loc === 'body') ev.bodyParams[name] = vals;
20
+ else if (loc === 'header' || loc === 'cookie') ev.headers[name.toLowerCase()] = vals[0];
21
+ };
22
+ void bag;
23
+ if (values) {
24
+ if (p?.location === 'path') {
25
+ const t = ir.endpoint.path.split('/').map((seg) => (seg === `:${p.name}` || seg === `{${p.name}}` ? encodeURIComponent(values[0]) : (/^(?::\w+|\{\w+\})$/.test(seg) ? 'x' : seg)));
26
+ if (path === ir.endpoint.path.replace(/(:[A-Za-z_]\w*|\{[A-Za-z_]\w*\})/g, '42')) ev.path = t.join('/');
27
+ } else if (p) put(p.location, extraParam ?? p.name, values);
28
+ else put('query', extraParam ?? PROBE, values);
29
+ }
30
+ // a channel the IR does not use is "present and empty", not "not logged": the event models a log that records everything
31
+ return ev;
32
+ }
33
+
34
+ export function validateDetection(ir) {
35
+ const def = PAYLOAD_CLASSES[ir.payloadClass];
36
+ const cases = [];
37
+ const render = { regexCompiles: true };
38
+ try { const rx = classRegex(ir.payloadClass); if (rx) new RegExp(rx, 'i'); } catch { render.regexCompiles = false; }
39
+ if (!ir.endpoint) return { schema: 'agentic-security/detection-validation', version: 1, detectionId: ir.id, status: 'NOT_VALIDATED', reason: 'no endpoint is known, so there is no request to synthesize', cases, render, counts: { positives: 0, negatives: 0, passed: 0, failed: 0 } };
40
+ const concretePath = ir.endpoint.path.replace(/(:[A-Za-z_]\w*|\{[A-Za-z_]\w*\})/g, '42');
41
+ const { method } = ir.endpoint;
42
+ const add = (kind, name, ev) => { const r = evaluateEvent(ir, ev); cases.push({ kind, name, expected: kind === 'positive', fired: r.match, undecidable: r.undecidable, ok: kind === 'positive' ? r.match : !r.match }); };
43
+
44
+ if (ir.payloadClass === 'auth-bypass') {
45
+ add('positive', 'success with no credential', eventFor(ir, { method, path: concretePath, status: 200, authPresent: false }));
46
+ add('negative', 'success with a credential', eventFor(ir, { method, path: concretePath, status: 200, authPresent: true }));
47
+ add('negative', 'refusal without a credential (401)', eventFor(ir, { method, path: concretePath, status: 401, authPresent: false }));
48
+ add('negative', 'refusal without a credential (403)', eventFor(ir, { method, path: concretePath, status: 403, authPresent: false }));
49
+ add('negative', 'same request on another path', eventFor(ir, { method, path: '/other/unrelated', status: 200, authPresent: false }));
50
+ } else {
51
+ for (const [i, v] of def.exemplars.positive.entries()) add('positive', `exploit payload #${i + 1}`, eventFor(ir, { method, path: concretePath, values: [v] }));
52
+ for (const [i, v] of def.exemplars.benign.entries()) add('negative', `benign value #${i + 1} on the same endpoint`, eventFor(ir, { method, path: concretePath, values: [v] }));
53
+ const exploit = [def.exemplars.positive[0]];
54
+ const otherMethod = method === 'GET' ? 'POST' : 'GET';
55
+ add('negative', `exploit payload with another method (${otherMethod})`, eventFor(ir, { method: otherMethod, path: concretePath, values: exploit }));
56
+ add('negative', 'exploit payload on another path', eventFor(ir, { method, path: '/other/unrelated', values: exploit }));
57
+ add('negative', 'same endpoint, no parameter at all', eventFor(ir, { method, path: concretePath }));
58
+ if (ir.parameter && ir.parameter.location !== 'path') add('negative', 'exploit payload in an unrelated parameter', eventFor(ir, { method, path: concretePath, values: exploit, extraParam: 'unrelated_field' }));
59
+ }
60
+ const positives = cases.filter((c) => c.kind === 'positive'); const negatives = cases.filter((c) => c.kind === 'negative');
61
+ const failed = cases.filter((c) => !c.ok);
62
+ const status = failed.length === 0 && positives.length && negatives.length && render.regexCompiles ? 'VALIDATED' : 'FAILED';
63
+ return { schema: 'agentic-security/detection-validation', version: 1, detectionId: ir.id, status, cases, render, counts: { positives: positives.length, negatives: negatives.length, passed: cases.length - failed.length, failed: failed.length }, failures: failed.map((c) => c.name) };
64
+ }
@@ -0,0 +1,51 @@
1
+ // The unified finding view (PRD 7, requirement 8): FIND, PROVE, HUNT, FIX, VERIFY, DETECT in one place, assembled only from
2
+ // records that exist. A step with no record is shown as missing, not inferred from its neighbours; a tampered record is
3
+ // shown as such and not used.
4
+ import fs from 'node:fs';
5
+ import { statePath } from '../state-dir.js';
6
+ import { listProofs } from '../proof-fabric/store.js';
7
+ import { stateRank } from '../proof-fabric/states.js';
8
+ import { latestRecord, listRecords } from './store.js';
9
+
10
+ export function loadFindingFromLastScan(root, id) {
11
+ try {
12
+ const last = JSON.parse(fs.readFileSync(statePath(root, 'last-scan.json'), 'utf8'));
13
+ return (last.findings || []).find((f) => f.id === id || f.stableId === id) ?? null;
14
+ } catch { return null; }
15
+ }
16
+
17
+ const days = (w) => (w?.start && w?.end ? Math.max(1, Math.round((Date.parse(w.end) - Date.parse(w.start)) / 86400000)) : null);
18
+
19
+ export function buildLifecycleView(root, findingId) {
20
+ const finding = loadFindingFromLastScan(root, findingId);
21
+ const proofs = listProofs(root, findingId);
22
+ const proofTampered = proofs.some((p) => p.tampered);
23
+ const proof = proofs.find((p) => p.record && !p.tampered)?.record ?? null;
24
+ // An earlier hunt that OBSERVED matches must not be hidden by a later hunt over different logs that did not.
25
+ const hunts = listRecords(root, findingId, 'hunt').filter((r) => r.record && !r.tampered).map((r) => r.record);
26
+ const hunt = hunts.find((h) => h.verdict === 'OBSERVED') ?? hunts[0] ?? null;
27
+ const otherVerdicts = hunt ? [...new Set(hunts.filter((h) => h !== hunt).map((h) => h.verdict))] : [];
28
+ const fix = latestRecord(root, findingId, 'fix')?.record ?? null;
29
+ const det = latestRecord(root, findingId, 'detection')?.record ?? null;
30
+ const tampered = [proofTampered ? 'proof' : null, ...['hunt', 'fix', 'detection'].map((k) => (listRecords(root, findingId, k).some((r) => r.tampered) ? k : null))].filter(Boolean);
31
+
32
+ const confirmed = proof && stateRank(proof.state) >= stateRank('EXPLOIT_CONFIRMED');
33
+ const steps = {
34
+ FIND: finding ? { status: 'done', text: `${confirmed ? 'Confirmed vulnerability' : 'Candidate finding'}: ${finding.vuln ?? finding.id}${finding.file ? ` (${finding.file}${finding.line ? `:${finding.line}` : ''})` : ''}` } : { status: 'missing', text: 'Finding not found in the last scan' },
35
+ PROVE: proof ? { status: confirmed ? 'done' : 'partial', text: confirmed ? `Exploit reproduced ${proof.attempts?.successes ?? 0}/${proof.attempts?.run ?? 0}, independent replay ${proof.independentReplay?.status ?? 'not run'}` : `Not confirmed (${proof.state})` } : { status: 'missing', text: 'No exploit proof' },
36
+ HUNT: hunt ? { status: hunt.verdict === 'OBSERVED' || hunt.verdict === 'NOT_OBSERVED' ? 'done' : 'partial', text: `${days(hunt.window) ?? 'unknown'} day(s) of telemetry searched, ${hunt.verdict === 'OBSERVED' ? `${hunt.matches?.count ?? 0} matching event(s) observed` : hunt.verdict === 'NOT_OBSERVED' ? 'no matching events (not the same as not exploited)' : `${hunt.verdict.toLowerCase().replace('_', ' ')}`}${otherVerdicts.length ? `; other hunt(s): ${otherVerdicts.join(', ')}` : ''}` } : { status: 'missing', text: 'No historical hunt run' },
37
+ FIX: fix ? { status: fix.verified ? 'done' : 'partial', text: fix.verified ? `${fix.completeness} verified remediation` : `Fix not verified: ${fix.reason ?? fix.incompleteStep ?? 'incomplete'}` } : { status: 'missing', text: 'No verified remediation recorded' },
38
+ VERIFY: fix ? (fix.legs?.postPatch && fix.legs.postPatch.exploitSucceeded === 0 && fix.legs.postPatch.completed === fix.legs.postPatch.attempts ? { status: 'done', text: 'Original exploit blocked' } : { status: 'partial', text: 'Original exploit not shown to be blocked' }) : { status: 'missing', text: 'No post-fix replay recorded' },
39
+ DETECT: det ? (det.validation?.status === 'VALIDATED' ? { status: 'done', text: `Recurrence rule enabled (${det.ir?.id})` } : { status: 'partial', text: `Detection ${det.ir?.id} generated but not validated (${det.validation?.status ?? 'no validation'}); not enabled` }) : { status: 'missing', text: 'No detection generated' },
40
+ };
41
+ return { findingId, steps, tampered, links: { detection: det ? { id: det.ir?.id, recordDigest: det.recordDigest, proofRecordDigest: det.proof?.recordDigest ?? null } : null, proofRecordDigest: proof?.recordDigest ?? null } };
42
+ }
43
+
44
+ export function formatLifecycleView(v) {
45
+ const mark = { done: 'x', partial: '~', missing: ' ' };
46
+ const lines = [`Finding ${v.findingId}`, ''];
47
+ for (const [name, s] of Object.entries(v.steps)) lines.push(`[${mark[s.status]}] ${name.padEnd(7)} ${s.text}`);
48
+ if (v.tampered.length) lines.push('', `WARNING: ${v.tampered.join(', ')} record(s) failed their integrity check and were not used.`);
49
+ lines.push('', '[x] done [~] partial or unconfirmed [ ] missing');
50
+ return lines.join('\n');
51
+ }