@clear-capabilities/agentic-security-scanner 0.77.0 → 0.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/.agentic-security/findings.json +1907 -0
- package/bin/.agentic-security/last-scan.json +1907 -0
- package/bin/.agentic-security/last-scan.json.sig +1 -0
- package/bin/.agentic-security/scan-history.json +166 -0
- package/bin/.agentic-security/streak.json +20 -0
- package/bin/agentic-security.js +55 -9
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/476.index.js +5 -5
- package/dist/637.index.js +1 -1
- package/dist/700.index.js +138 -0
- package/dist/718.index.js +159 -0
- package/dist/824.index.js +126 -0
- package/dist/838.index.js +1 -1
- package/dist/985.index.js +5 -0
- package/dist/agentic-security.mjs +32 -32
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- package/src/dataflow/async-sequencing.js +16 -7
- package/src/dataflow/builtin-summaries.js +131 -0
- package/src/dataflow/catalog.js +107 -0
- package/src/dataflow/cross-repo.js +75 -1
- package/src/dataflow/engine.js +181 -8
- package/src/dataflow/implicit-flow.js +24 -6
- package/src/dataflow/stub-aware-filter.js +69 -11
- package/src/dataflow/summaries.js +28 -3
- package/src/engine-parallel.js +70 -0
- package/src/engine.js +270 -19
- package/src/integrations/index.js +2 -1
- package/src/ir/callgraph.js +27 -7
- package/src/ir/index.js +22 -1
- package/src/ir/parser-go.js +403 -0
- package/src/ir/parser-js.js +2 -0
- package/src/ir/parser-php.js +330 -0
- package/src/ir/parser-py.helper.py +137 -11
- package/src/ir/parser-rb.js +309 -0
- package/src/llm-validator/index.js +7 -5
- package/src/mcp/audit.js +5 -0
- package/src/posture/calibration-drift.js +2 -1
- package/src/posture/calibration.js +16 -1
- package/src/posture/fix-history.js +8 -2
- package/src/posture/profile.js +4 -5
- package/src/posture/rule-overrides.js +2 -3
- package/src/posture/rule-pack-signing.js +2 -3
- package/src/posture/rule-synthesis.js +5 -6
- package/src/posture/security-trend.js +4 -7
- package/src/posture/state-dir.js +124 -0
- package/src/posture/streak.js +3 -0
- package/src/posture/suppressions.js +5 -8
- package/src/posture/triage.js +16 -5
- package/src/posture/validator-metrics.js +3 -6
- package/src/report/index.js +23 -2
- package/src/sast/cache-poisoning.js +77 -0
- package/src/sast/comparison-safety.js +73 -0
- package/src/sast/db-taint.js +78 -0
- package/src/sast/graphql.js +127 -0
- package/src/sast/llm-stored-prompt.js +57 -0
- package/src/sast/mutation-xss.js +43 -0
- package/src/sast/nosql-injection.js +5 -0
- package/src/sast/null-byte-injection.js +76 -0
- package/src/sast/redos-nfa.js +338 -0
- package/src/sast/rust.js +26 -0
- package/src/sast/sensitive-data-logging.js +73 -0
- package/src/sast/weak-password-hash.js +77 -0
- package/src/sast/weak-randomness.js +100 -0
- package/src/sca/binary-metadata.js +124 -0
- package/src/sca/llm-function-extract.js +107 -0
- package/src/sca/py-package-functions.js +118 -0
- package/src/sca/vendor-detect.js +144 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// Ruby IR frontend.
|
|
2
|
+
//
|
|
3
|
+
// Regex-based, follows the parser-cs.js / parser-go.js pattern. Focused on
|
|
4
|
+
// Rails params, ActiveRecord, Kernel methods surface area.
|
|
5
|
+
//
|
|
6
|
+
// What we model:
|
|
7
|
+
// - def / def self. method declarations
|
|
8
|
+
// - var = expr assignments
|
|
9
|
+
// - method calls: obj.method(args) and method(args)
|
|
10
|
+
// - return
|
|
11
|
+
// - each/map/select blocks as loop-header
|
|
12
|
+
//
|
|
13
|
+
// What we do NOT model:
|
|
14
|
+
// - blocks / procs / lambdas as first-class values
|
|
15
|
+
// - metaprogramming (define_method, method_missing)
|
|
16
|
+
// - module_function / protected / private method visibility scoping
|
|
17
|
+
// - control flow (if/unless/while/until/case) — body is straight-line
|
|
18
|
+
//
|
|
19
|
+
// Ruby body extraction: count def/class/module/do/if/unless/while/until/
|
|
20
|
+
// for/case/begin as openers and `end` as closers. Return null on balance
|
|
21
|
+
// failure (heredocs, multi-line strings can confuse the regex parser).
|
|
22
|
+
|
|
23
|
+
import * as crypto from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
const DEF_RE = /(?:^|\n)\s*def\s+(?:self\.)?(\w+[?!=]?)\s*(?:\(([^)]*)\))?/g;
|
|
26
|
+
|
|
27
|
+
function _extractRubyBody(src, defEnd) {
|
|
28
|
+
let depth = 1;
|
|
29
|
+
let i = defEnd;
|
|
30
|
+
let inStr = null;
|
|
31
|
+
let escape = false;
|
|
32
|
+
const openers = /\b(?:def|class|module|do|if|unless|while|until|for|case|begin)\b/;
|
|
33
|
+
while (i < src.length && depth > 0) {
|
|
34
|
+
const c = src[i];
|
|
35
|
+
if (escape) { escape = false; i++; continue; }
|
|
36
|
+
if (inStr) {
|
|
37
|
+
if (c === '\\') { escape = true; i++; continue; }
|
|
38
|
+
if (c === inStr) inStr = null;
|
|
39
|
+
i++; continue;
|
|
40
|
+
}
|
|
41
|
+
if (c === '"' || c === '\'') { inStr = c; i++; continue; }
|
|
42
|
+
if (c === '#') {
|
|
43
|
+
while (i < src.length && src[i] !== '\n') i++;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
// Check for keyword boundaries
|
|
47
|
+
if (/[a-z]/i.test(c)) {
|
|
48
|
+
let word = '';
|
|
49
|
+
const start = i;
|
|
50
|
+
while (i < src.length && /\w/.test(src[i])) { word += src[i]; i++; }
|
|
51
|
+
if (word === 'end' && (start === 0 || /[^.\w]/.test(src[start - 1] || ' '))) {
|
|
52
|
+
depth--;
|
|
53
|
+
} else if (openers.test(word) && (start === 0 || /[^.\w]/.test(src[start - 1] || ' '))) {
|
|
54
|
+
// Only count as opener if not preceded by . (e.g., x.if would be wrong but rare)
|
|
55
|
+
depth++;
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
if (depth !== 0) return null;
|
|
62
|
+
// `end` keyword ends at position `i`; body is between defEnd and the start of `end`
|
|
63
|
+
return { body: src.slice(defEnd, i - 3).trimEnd(), end: i };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const _RB_OPENERS = /^(?:if|unless|while|until|for|case|begin|do)\b/;
|
|
67
|
+
const _RB_BLOCK_KW = /\b(?:def|class|module|if|unless|while|until|for|case|begin|do)\b/;
|
|
68
|
+
|
|
69
|
+
function _splitStatements(body) {
|
|
70
|
+
const lines = body.split('\n');
|
|
71
|
+
const out = [];
|
|
72
|
+
let buf = '';
|
|
73
|
+
let depth = 0;
|
|
74
|
+
for (const rawLine of lines) {
|
|
75
|
+
const line = rawLine.trim();
|
|
76
|
+
if (!line || line.startsWith('#')) continue;
|
|
77
|
+
if (depth === 0 && _RB_OPENERS.test(line)) {
|
|
78
|
+
if (buf.trim()) out.push(buf.trim());
|
|
79
|
+
buf = line + '\n';
|
|
80
|
+
for (const m of line.matchAll(/\b(?:if|unless|while|until|for|case|begin|do|def|class|module)\b/g)) depth++;
|
|
81
|
+
if (/\bend\b/.test(line)) depth--;
|
|
82
|
+
if (depth <= 0) { depth = 0; out.push(buf.trim()); buf = ''; }
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (depth > 0) {
|
|
86
|
+
buf += line + '\n';
|
|
87
|
+
for (const m of line.matchAll(/\b(?:if|unless|while|until|for|case|begin|do|def|class|module)\b/g)) depth++;
|
|
88
|
+
const endMatches = line.match(/\bend\b/g);
|
|
89
|
+
if (endMatches) depth -= endMatches.length;
|
|
90
|
+
if (depth <= 0) { depth = 0; out.push(buf.trim()); buf = ''; }
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
out.push(line);
|
|
94
|
+
}
|
|
95
|
+
if (buf.trim()) out.push(buf.trim());
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function _lowerExpr(text) {
|
|
100
|
+
const s = String(text || '').trim();
|
|
101
|
+
if (!s) return { kind: 'unknown' };
|
|
102
|
+
// String interpolation before plain literal check
|
|
103
|
+
if (/^".*#\{/.test(s)) {
|
|
104
|
+
const parts = [];
|
|
105
|
+
for (const m of s.matchAll(/#\{([^}]+)\}/g)) parts.push(_lowerExpr(m[1]));
|
|
106
|
+
if (parts.length) return { kind: 'tpl', parts };
|
|
107
|
+
}
|
|
108
|
+
if (/^['"]/.test(s)) return { kind: 'literal', value: s };
|
|
109
|
+
if (/^\d/.test(s)) return { kind: 'literal', value: s };
|
|
110
|
+
if (/^(true|false|nil)\b/.test(s)) return { kind: 'literal', value: s };
|
|
111
|
+
// Symbol
|
|
112
|
+
if (/^:\w+/.test(s)) return { kind: 'literal', value: s };
|
|
113
|
+
// Call: obj.method(args) or method(args)
|
|
114
|
+
const callMatch = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
|
|
115
|
+
if (callMatch) {
|
|
116
|
+
return { kind: 'call', callee: callMatch[1], args: _splitTopLevelCommas(callMatch[2]).map(_lowerExpr) };
|
|
117
|
+
}
|
|
118
|
+
// Method call without parens is very common in Ruby but hard to detect
|
|
119
|
+
// reliably with regex. We handle the explicit-paren form above.
|
|
120
|
+
// Dotted member: obj.prop
|
|
121
|
+
if (/^[A-Za-z_]\w*(?:\.\w+)+$/.test(s)) {
|
|
122
|
+
const parts = s.split('.');
|
|
123
|
+
let cur = { kind: 'ident', name: parts[0] };
|
|
124
|
+
for (let i = 1; i < parts.length; i++) cur = { kind: 'member', object: cur, prop: parts[i] };
|
|
125
|
+
return cur;
|
|
126
|
+
}
|
|
127
|
+
// Hash access: params[:key]
|
|
128
|
+
if (/^[A-Za-z_]\w*\[/.test(s)) {
|
|
129
|
+
const lb = s.indexOf('[');
|
|
130
|
+
const base = s.slice(0, lb);
|
|
131
|
+
return { kind: 'member', object: { kind: 'ident', name: base }, prop: '[]' };
|
|
132
|
+
}
|
|
133
|
+
// Simple ident
|
|
134
|
+
if (/^[A-Za-z_@]\w*$/.test(s)) return { kind: 'ident', name: s };
|
|
135
|
+
// Concat with +
|
|
136
|
+
if (s.includes('+')) {
|
|
137
|
+
const parts = s.split('+').map(p => _lowerExpr(p.trim()));
|
|
138
|
+
return { kind: 'tpl', parts };
|
|
139
|
+
}
|
|
140
|
+
return { kind: 'unknown' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function _splitTopLevelCommas(s) {
|
|
144
|
+
const out = [];
|
|
145
|
+
let buf = '';
|
|
146
|
+
let depth = 0;
|
|
147
|
+
let inStr = null;
|
|
148
|
+
for (let i = 0; i < s.length; i++) {
|
|
149
|
+
const c = s[i];
|
|
150
|
+
if (inStr) {
|
|
151
|
+
buf += c;
|
|
152
|
+
if (c === '\\') { i++; buf += s[i] || ''; continue; }
|
|
153
|
+
if (c === inStr) inStr = null;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (c === '"' || c === '\'') { inStr = c; buf += c; continue; }
|
|
157
|
+
if (c === '(' || c === '{' || c === '[') depth++;
|
|
158
|
+
if (c === ')' || c === '}' || c === ']') depth--;
|
|
159
|
+
if (c === ',' && depth === 0) { out.push(buf.trim()); buf = ''; continue; }
|
|
160
|
+
buf += c;
|
|
161
|
+
}
|
|
162
|
+
if (buf.trim()) out.push(buf.trim());
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function _lowerStmt(stmt, line) {
|
|
167
|
+
const s = stmt.trim();
|
|
168
|
+
if (!s || s.startsWith('#')) return null;
|
|
169
|
+
if (/^return\b/.test(s)) {
|
|
170
|
+
const rest = s.replace(/^return\s*/, '').trim();
|
|
171
|
+
return { kind: 'return', line, value: rest ? _lowerExpr(rest) : null };
|
|
172
|
+
}
|
|
173
|
+
if (/^raise\b/.test(s)) {
|
|
174
|
+
return { kind: 'throw', line, value: _lowerExpr(s.replace(/^raise\s*/, '')) };
|
|
175
|
+
}
|
|
176
|
+
// Assignment: var = expr
|
|
177
|
+
const assign = s.match(/^(@?\w+)\s*=\s*(.+)$/s);
|
|
178
|
+
if (assign && !/^={2}/.test(assign[2])) {
|
|
179
|
+
return { kind: 'assign', line, target: assign[1], source: _lowerExpr(assign[2]) };
|
|
180
|
+
}
|
|
181
|
+
// Statement-form call with parens
|
|
182
|
+
const call = s.match(/^([\w.]+)\s*\((.*)\)\s*$/s);
|
|
183
|
+
if (call) {
|
|
184
|
+
return { kind: 'call', line, callee: call[1], args: _splitTopLevelCommas(call[2]).map(_lowerExpr) };
|
|
185
|
+
}
|
|
186
|
+
// Statement-form call without parens (common Ruby idiom): redirect_to expr
|
|
187
|
+
const bareCall = s.match(/^([a-z_]\w*)\s+(.+)$/s);
|
|
188
|
+
if (bareCall && /^[a-z_]/.test(bareCall[1]) && !/^(?:if|unless|while|until|for|case|when|elsif|else|end|return|raise|require|include|extend|attr_\w+)$/.test(bareCall[1])) {
|
|
189
|
+
return { kind: 'call', line, callee: bareCall[1], args: [_lowerExpr(bareCall[2])] };
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function _lineAt(src, idx) {
|
|
195
|
+
let line = 1;
|
|
196
|
+
for (let i = 0; i < idx && i < src.length; i++) if (src[i] === '\n') line++;
|
|
197
|
+
return line;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _qid(file, name, line, body) {
|
|
201
|
+
const sha = crypto.createHash('sha256').update(body).digest('hex').slice(0, 8);
|
|
202
|
+
return `${file}::${name}@${line}#${sha}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let _nid = 0;
|
|
206
|
+
function _nextId() { return `rn${++_nid}`; }
|
|
207
|
+
|
|
208
|
+
function _addNode(nodes, node) {
|
|
209
|
+
const id = _nextId();
|
|
210
|
+
node.succ = node.succ || [];
|
|
211
|
+
node.pred = node.pred || [];
|
|
212
|
+
nodes[id] = node;
|
|
213
|
+
return id;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function _linkNodes(nodes, src, dst) {
|
|
217
|
+
if (!nodes[src] || !nodes[dst]) return;
|
|
218
|
+
if (!nodes[src].succ.includes(dst)) nodes[src].succ.push(dst);
|
|
219
|
+
if (!nodes[dst].pred.includes(src)) nodes[dst].pred.push(src);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function _extractRubyBlockBody(compound) {
|
|
223
|
+
const lines = compound.split('\n');
|
|
224
|
+
if (lines.length < 2) return '';
|
|
225
|
+
return lines.slice(1, -1).join('\n');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function _buildCfg(bodyText, nodes, prevId, startLine) {
|
|
229
|
+
const stmts = _splitStatements(bodyText);
|
|
230
|
+
let prev = prevId;
|
|
231
|
+
let line = startLine;
|
|
232
|
+
for (const stmt of stmts) {
|
|
233
|
+
const s = stmt.trim();
|
|
234
|
+
if (!s || s.startsWith('#')) { line++; continue; }
|
|
235
|
+
|
|
236
|
+
const ifMatch = s.match(/^(if|unless)\s+(.+)$/m);
|
|
237
|
+
if (ifMatch && /\bend\b\s*$/.test(s)) {
|
|
238
|
+
const condText = ifMatch[2].trim();
|
|
239
|
+
const innerBody = _extractRubyBlockBody(s);
|
|
240
|
+
const ifNode = _addNode(nodes, { kind: 'if', cond: _lowerExpr(condText), line });
|
|
241
|
+
_linkNodes(nodes, prev, ifNode);
|
|
242
|
+
const join = _addNode(nodes, { kind: 'noop', line });
|
|
243
|
+
const thenTail = _buildCfg(innerBody, nodes, ifNode, line + 1);
|
|
244
|
+
_linkNodes(nodes, thenTail, join);
|
|
245
|
+
_linkNodes(nodes, ifNode, join);
|
|
246
|
+
prev = join;
|
|
247
|
+
line += (s.match(/\n/g) || []).length + 1;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const whileMatch = s.match(/^(while|until)\s+(.+)$/m);
|
|
252
|
+
if (whileMatch && /\bend\b\s*$/.test(s)) {
|
|
253
|
+
const innerBody = _extractRubyBlockBody(s);
|
|
254
|
+
const header = _addNode(nodes, { kind: 'loop-header', line });
|
|
255
|
+
_linkNodes(nodes, prev, header);
|
|
256
|
+
const bodyTail = _buildCfg(innerBody, nodes, header, line + 1);
|
|
257
|
+
_linkNodes(nodes, bodyTail, header);
|
|
258
|
+
const join = _addNode(nodes, { kind: 'noop', line });
|
|
259
|
+
_linkNodes(nodes, header, join);
|
|
260
|
+
prev = join;
|
|
261
|
+
line += (s.match(/\n/g) || []).length + 1;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const node = _lowerStmt(s, line);
|
|
266
|
+
if (!node) { line += (s.match(/\n/g) || []).length + 1; continue; }
|
|
267
|
+
const id = _addNode(nodes, node);
|
|
268
|
+
_linkNodes(nodes, prev, id);
|
|
269
|
+
prev = id;
|
|
270
|
+
line += (s.match(/\n/g) || []).length + 1;
|
|
271
|
+
}
|
|
272
|
+
return prev;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function parseRubyFile(file, code) {
|
|
276
|
+
if (!file || typeof code !== 'string') return null;
|
|
277
|
+
if (!/\.rb$/i.test(file)) return null;
|
|
278
|
+
if (code.length > 1_000_000) return null;
|
|
279
|
+
|
|
280
|
+
const functions = [];
|
|
281
|
+
DEF_RE.lastIndex = 0;
|
|
282
|
+
_nid = 0;
|
|
283
|
+
let m;
|
|
284
|
+
while ((m = DEF_RE.exec(code)) !== null) {
|
|
285
|
+
const name = m[1];
|
|
286
|
+
const paramsText = m[2] || '';
|
|
287
|
+
const params = paramsText.split(',').map(p => {
|
|
288
|
+
const t = p.trim().replace(/\s*=\s*.*$/, '').replace(/^[*&]+/, '');
|
|
289
|
+
return t && /^\w+$/.test(t) ? t : null;
|
|
290
|
+
}).filter(Boolean);
|
|
291
|
+
const defLineEnd = code.indexOf('\n', m.index + m[0].length);
|
|
292
|
+
if (defLineEnd < 0) continue;
|
|
293
|
+
const extracted = _extractRubyBody(code, defLineEnd + 1);
|
|
294
|
+
if (!extracted) continue;
|
|
295
|
+
const startLine = _lineAt(code, m.index);
|
|
296
|
+
const nodes = {};
|
|
297
|
+
const entry = _addNode(nodes, { kind: 'entry', line: startLine });
|
|
298
|
+
const exit = _addNode(nodes, { kind: 'exit', line: startLine });
|
|
299
|
+
const tail = _buildCfg(extracted.body, nodes, entry, startLine + 1);
|
|
300
|
+
_linkNodes(nodes, tail, exit);
|
|
301
|
+
functions.push({
|
|
302
|
+
qid: _qid(file, name, startLine, extracted.body),
|
|
303
|
+
name, line: startLine, params, file,
|
|
304
|
+
cfg: { entry, exit, nodes },
|
|
305
|
+
});
|
|
306
|
+
DEF_RE.lastIndex = extracted.end;
|
|
307
|
+
}
|
|
308
|
+
return functions.length ? { file, functions, topLevel: null } : null;
|
|
309
|
+
}
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
import * as fs from 'node:fs';
|
|
45
45
|
import * as path from 'node:path';
|
|
46
46
|
import * as crypto from 'node:crypto';
|
|
47
|
+
import { statePath, ensureStateDir, safeWriteState } from '../posture/state-dir.js';
|
|
47
48
|
|
|
48
49
|
// Bump on every prompt change so the cache invalidates. Exported as a
|
|
49
50
|
// stable public symbol (premortem 4R-15) so the validator-cache GC subcommand
|
|
@@ -98,7 +99,9 @@ function endpointConfig() {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
function ensureCacheDir(scanRoot) {
|
|
101
|
-
const
|
|
102
|
+
const base = ensureStateDir(scanRoot);
|
|
103
|
+
if (!base) return null;
|
|
104
|
+
const dir = path.join(base, 'llm-cache');
|
|
102
105
|
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
103
106
|
return dir;
|
|
104
107
|
}
|
|
@@ -112,15 +115,14 @@ function cacheKey(finding, fileHash, modelId) {
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
function readCache(scanRoot, key) {
|
|
115
|
-
const fp =
|
|
118
|
+
const fp = statePath(scanRoot, 'llm-cache', key + '.json');
|
|
116
119
|
if (!fs.existsSync(fp)) return null;
|
|
117
120
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
function writeCache(scanRoot, key, value) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
try { fs.writeFileSync(fp, JSON.stringify(value, null, 2)); } catch {}
|
|
124
|
+
const fp = statePath(scanRoot, 'llm-cache', key + '.json');
|
|
125
|
+
safeWriteState(fp, JSON.stringify(value, null, 2));
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
function fileHashOf(fileContents, file) {
|
package/src/mcp/audit.js
CHANGED
|
@@ -82,6 +82,11 @@ async function _postRemote(url, entry) {
|
|
|
82
82
|
export function auditCall({ sessionRoot, tool, args, outcome, reason }) {
|
|
83
83
|
if (!sessionRoot) return;
|
|
84
84
|
try {
|
|
85
|
+
// Safety: only write audit log if sessionRoot looks like a project root
|
|
86
|
+
const MARKERS = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml', 'composer.json', 'Gemfile'];
|
|
87
|
+
let hasMarker = false;
|
|
88
|
+
for (const m of MARKERS) { try { if (fs.existsSync(path.join(sessionRoot, m))) { hasMarker = true; break; } } catch {} }
|
|
89
|
+
if (!hasMarker) return;
|
|
85
90
|
const dir = path.join(sessionRoot, '.agentic-security');
|
|
86
91
|
fs.mkdirSync(dir, { recursive: true });
|
|
87
92
|
const logFile = path.join(dir, 'mcp-audit.log');
|
|
@@ -28,13 +28,14 @@
|
|
|
28
28
|
|
|
29
29
|
import * as fs from 'node:fs';
|
|
30
30
|
import * as path from 'node:path';
|
|
31
|
+
import { statePath } from './state-dir.js';
|
|
31
32
|
|
|
32
33
|
const DEFAULT_THRESHOLD = 0.15;
|
|
33
34
|
const MIN_SAMPLE_SIZE = 10;
|
|
34
35
|
const WINDOW_DAYS = 30;
|
|
35
36
|
|
|
36
37
|
function loadTriageFeedback(scanRoot) {
|
|
37
|
-
const fp =
|
|
38
|
+
const fp = statePath(scanRoot, 'triage-feedback.json');
|
|
38
39
|
try {
|
|
39
40
|
if (!fs.existsSync(fp)) return [];
|
|
40
41
|
const data = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import * as fs from 'node:fs';
|
|
29
29
|
import * as path from 'node:path';
|
|
30
|
+
import { statePath } from './state-dir.js';
|
|
30
31
|
|
|
31
32
|
const MIN_SAMPLES_FOR_CALIBRATION = 30;
|
|
32
33
|
|
|
@@ -102,7 +103,7 @@ function _readJsonMaybe(fp) {
|
|
|
102
103
|
// seed file. The bundled seed ships with this release; the customer file
|
|
103
104
|
// overrides per-family when N is higher there.
|
|
104
105
|
export function loadCalibrationHistory(scanRoot) {
|
|
105
|
-
const customer = _readJsonMaybe(
|
|
106
|
+
const customer = _readJsonMaybe(statePath(scanRoot, 'validator-metrics.json')) || {};
|
|
106
107
|
const seedPath = new URL('./calibration-seed.json', import.meta.url);
|
|
107
108
|
let seed = null;
|
|
108
109
|
try { seed = JSON.parse(fs.readFileSync(seedPath, 'utf8')); } catch { seed = null; }
|
|
@@ -120,6 +121,20 @@ export function loadCalibrationHistory(scanRoot) {
|
|
|
120
121
|
};
|
|
121
122
|
if (seed) merge(seed);
|
|
122
123
|
if (customer) merge(customer);
|
|
124
|
+
// Merge triage-derived TP/FP counts (auto-feedback loop)
|
|
125
|
+
try {
|
|
126
|
+
const triage = _readJsonMaybe(statePath(scanRoot, 'triage.json'));
|
|
127
|
+
if (triage && triage.findings) {
|
|
128
|
+
const triageFams = {};
|
|
129
|
+
for (const f of Object.values(triage.findings)) {
|
|
130
|
+
const fam = f.family || 'unknown';
|
|
131
|
+
if (!triageFams[fam]) triageFams[fam] = { tp: 0, fp: 0 };
|
|
132
|
+
if (f.state === 'fixed' || f.state === 'open' || f.state === 'in-progress') triageFams[fam].tp++;
|
|
133
|
+
else if (f.state === 'false-positive') triageFams[fam].fp++;
|
|
134
|
+
}
|
|
135
|
+
merge({ families: triageFams });
|
|
136
|
+
}
|
|
137
|
+
} catch { /* triage file optional */ }
|
|
123
138
|
return { families };
|
|
124
139
|
}
|
|
125
140
|
|
|
@@ -12,13 +12,19 @@ import * as fs from 'node:fs';
|
|
|
12
12
|
import * as fsp from 'node:fs/promises';
|
|
13
13
|
import * as path from 'node:path';
|
|
14
14
|
import * as crypto from 'node:crypto';
|
|
15
|
+
import { isSafeStateDir, statePath } from './state-dir.js';
|
|
15
16
|
|
|
16
17
|
function historyDir(scanRoot) {
|
|
17
|
-
return
|
|
18
|
+
return statePath(scanRoot, 'fix-history');
|
|
18
19
|
}
|
|
19
20
|
function logPath(scanRoot) { return path.join(historyDir(scanRoot), 'log.json'); }
|
|
20
21
|
|
|
21
|
-
function ensure(scanRoot) {
|
|
22
|
+
function ensure(scanRoot) {
|
|
23
|
+
const dir = historyDir(scanRoot);
|
|
24
|
+
if (!isSafeStateDir(path.dirname(dir))) return false;
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
22
28
|
|
|
23
29
|
export function readLog(scanRoot) {
|
|
24
30
|
const fp = logPath(scanRoot);
|
package/src/posture/profile.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import * as fs from 'node:fs';
|
|
7
7
|
import * as path from 'node:path';
|
|
8
8
|
import * as yaml from 'js-yaml';
|
|
9
|
+
import { statePath, safeWriteState, resolveProjectRoot } from './state-dir.js';
|
|
9
10
|
|
|
10
11
|
export const PROFILES = ['vibecoder', 'pro'];
|
|
11
12
|
|
|
@@ -35,7 +36,7 @@ export const DEFAULTS = {
|
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
function _profilePath(scanRoot) {
|
|
38
|
-
return
|
|
39
|
+
return statePath(scanRoot, 'profile.yml');
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export function loadProfile(scanRoot) {
|
|
@@ -52,10 +53,8 @@ export function loadProfile(scanRoot) {
|
|
|
52
53
|
|
|
53
54
|
export function saveProfile(scanRoot, updates) {
|
|
54
55
|
const fp = _profilePath(scanRoot);
|
|
55
|
-
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
56
56
|
const current = loadProfile(scanRoot);
|
|
57
57
|
const next = { ...current, ...updates };
|
|
58
|
-
// Strip values equal to defaults so the file stays minimal.
|
|
59
58
|
const defaults = DEFAULTS[next.profile];
|
|
60
59
|
const out = {};
|
|
61
60
|
for (const k of Object.keys(next)) {
|
|
@@ -63,7 +62,7 @@ export function saveProfile(scanRoot, updates) {
|
|
|
63
62
|
out[k] = next[k];
|
|
64
63
|
}
|
|
65
64
|
if (!('profile' in out)) out.profile = next.profile;
|
|
66
|
-
|
|
65
|
+
safeWriteState(fp, yaml.dump(out));
|
|
67
66
|
return next;
|
|
68
67
|
}
|
|
69
68
|
|
|
@@ -71,7 +70,7 @@ export function saveProfile(scanRoot, updates) {
|
|
|
71
70
|
// Returns 'pro' if the repo has signals indicating professional security work,
|
|
72
71
|
// otherwise 'vibecoder'. Run only on first scan.
|
|
73
72
|
export function detectProfile(scanRoot) {
|
|
74
|
-
const root = scanRoot
|
|
73
|
+
const root = resolveProjectRoot(scanRoot);
|
|
75
74
|
const signals = ['SECURITY.md', '.github/workflows/security.yml', '.semgrep.yml',
|
|
76
75
|
'.snyk', 'codeql-config.yml', 'compliance/', 'docs/threat-model.md'];
|
|
77
76
|
for (const s of signals) {
|
|
@@ -11,11 +11,10 @@ import * as fs from 'node:fs';
|
|
|
11
11
|
import * as path from 'node:path';
|
|
12
12
|
import * as yaml from 'js-yaml';
|
|
13
13
|
import { verifyLastScan } from './integrity.js';
|
|
14
|
-
|
|
15
|
-
const OVERRIDES_PATH = '.agentic-security/rules.yml';
|
|
14
|
+
import { statePath } from './state-dir.js';
|
|
16
15
|
|
|
17
16
|
function _path(scanRoot) {
|
|
18
|
-
return
|
|
17
|
+
return statePath(scanRoot, 'rules.yml');
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
export function loadOverrides(scanRoot) {
|
|
@@ -30,8 +30,7 @@
|
|
|
30
30
|
import * as fs from 'node:fs';
|
|
31
31
|
import * as path from 'node:path';
|
|
32
32
|
import * as crypto from 'node:crypto';
|
|
33
|
-
|
|
34
|
-
const TRUSTED_KEYS_FILE = '.agentic-security/trusted-keys.json';
|
|
33
|
+
import { statePath } from './state-dir.js';
|
|
35
34
|
|
|
36
35
|
// Built-in trust root. These are the keys the maintainers of agentic-security
|
|
37
36
|
// use to sign official rule packs. Production deployment requires the
|
|
@@ -49,7 +48,7 @@ export const BUNDLED_OFFICIAL_KEYS = [
|
|
|
49
48
|
];
|
|
50
49
|
|
|
51
50
|
function _trustedKeysPath(scanRoot) {
|
|
52
|
-
return
|
|
51
|
+
return statePath(scanRoot, 'trusted-keys.json');
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
// Load the EFFECTIVE trusted-key set. Composition:
|
|
@@ -13,14 +13,12 @@
|
|
|
13
13
|
|
|
14
14
|
import * as fs from 'node:fs';
|
|
15
15
|
import * as path from 'node:path';
|
|
16
|
-
|
|
17
|
-
const TRIAGE_PATH = path.join('.agentic-security', 'triage-feedback.json');
|
|
18
|
-
const PROPOSED_DIR = path.join('.agentic-security', 'rules-proposed');
|
|
16
|
+
import { statePath, isSafeStateDir } from './state-dir.js';
|
|
19
17
|
|
|
20
18
|
const DEFAULT_FP_THRESHOLD = 5;
|
|
21
19
|
|
|
22
20
|
function _readTriage(scanRoot) {
|
|
23
|
-
const fp =
|
|
21
|
+
const fp = statePath(scanRoot, 'triage-feedback.json');
|
|
24
22
|
if (!fs.existsSync(fp)) return null;
|
|
25
23
|
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
26
24
|
}
|
|
@@ -87,7 +85,8 @@ export function synthesizeRules(scanRoot, opts = {}) {
|
|
|
87
85
|
groups.get(k).push(e);
|
|
88
86
|
}
|
|
89
87
|
const proposals = [];
|
|
90
|
-
const dir =
|
|
88
|
+
const dir = statePath(scanRoot, 'rules-proposed');
|
|
89
|
+
if (!opts.dryRun && !isSafeStateDir(path.dirname(dir))) return [];
|
|
91
90
|
for (const [, group] of groups) {
|
|
92
91
|
if (group.length < threshold) continue;
|
|
93
92
|
const summary = _summarizeGroup(group);
|
|
@@ -105,4 +104,4 @@ export function synthesizeRules(scanRoot, opts = {}) {
|
|
|
105
104
|
return proposals;
|
|
106
105
|
}
|
|
107
106
|
|
|
108
|
-
export const _internals = { DEFAULT_FP_THRESHOLD
|
|
107
|
+
export const _internals = { DEFAULT_FP_THRESHOLD };
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
9
10
|
|
|
10
|
-
const HISTORY_FILE = '.agentic-security/scan-history.json';
|
|
11
11
|
const MAX_HISTORY = 30; // rolling window
|
|
12
12
|
|
|
13
13
|
function _readHistory(scanRoot) {
|
|
14
|
-
const histPath =
|
|
14
|
+
const histPath = statePath(scanRoot, 'scan-history.json');
|
|
15
15
|
try {
|
|
16
16
|
return JSON.parse(fs.readFileSync(histPath, 'utf8'));
|
|
17
17
|
} catch {
|
|
@@ -20,11 +20,8 @@ function _readHistory(scanRoot) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function _writeHistory(scanRoot, history) {
|
|
23
|
-
const histPath =
|
|
24
|
-
|
|
25
|
-
fs.mkdirSync(path.dirname(histPath), { recursive: true });
|
|
26
|
-
fs.writeFileSync(histPath, JSON.stringify(history.slice(-MAX_HISTORY), null, 2));
|
|
27
|
-
} catch {}
|
|
23
|
+
const histPath = statePath(scanRoot, 'scan-history.json');
|
|
24
|
+
safeWriteState(histPath, JSON.stringify(history.slice(-MAX_HISTORY), null, 2));
|
|
28
25
|
}
|
|
29
26
|
|
|
30
27
|
function _snapshotFromScan(scan, label) {
|