@clear-capabilities/agentic-security-scanner 0.132.0 → 0.134.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/CHANGELOG.md +228 -0
- package/bin/agentic-security.js +103 -1
- package/dist/113.index.js +3 -3
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/499.index.js +86 -0
- package/dist/526.index.js +3 -3
- package/dist/609.index.js +741 -0
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +56 -56
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +9 -4
- package/src/discovery/CLAUDE.md +38 -0
- package/src/discovery/confirm.js +47 -0
- package/src/discovery/disprove.js +79 -0
- package/src/discovery/hunter.js +116 -0
- package/src/discovery/index.js +159 -0
- package/src/discovery/judge.js +97 -0
- package/src/discovery/lenses.js +69 -0
- package/src/discovery/llm-invoke.js +31 -0
- package/src/discovery/partition.js +92 -0
- package/src/engine.js +151 -1
- package/src/llm-validator/cost-ceiling.js +199 -0
- package/src/llm-validator/index.js +254 -35
- package/src/llm-validator/local-endpoint.js +90 -0
- package/src/llm-validator/providers.js +227 -0
- package/src/posture/CLAUDE.md +76 -0
- package/src/posture/accuracy-scorecard.js +37 -6
- package/src/posture/autopilot.js +225 -0
- package/src/posture/comparison.js +181 -0
- package/src/posture/corpus-match.js +29 -14
- package/src/posture/execution-proof.js +25 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/integrity.js +42 -9
- package/src/posture/learning.js +8 -1
- package/src/posture/logic-claims.js +266 -0
- package/src/posture/model-routing.js +26 -0
- package/src/posture/model-trust.js +174 -0
- package/src/posture/poc-inprocess.js +567 -0
- package/src/posture/proof-artifact.js +101 -0
- package/src/posture/prove-findings.js +172 -0
- package/src/posture/rule-overrides.js +64 -3
- package/src/posture/state-dir.js +25 -0
- package/src/posture/vuln-archaeology.js +231 -0
- package/src/report/index.js +16 -0
- package/src/sandbox/CLAUDE.md +27 -5
- package/src/sandbox/backend-namespace.js +39 -11
- package/src/sandbox/backend-userspace.js +4 -0
- package/src/sast/CLAUDE.md +4 -0
- package/src/sast/crypto-specialist.js +247 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// PRD Epic 3 — the model-neutral seam.
|
|
2
|
+
//
|
|
3
|
+
// The deterministic engine has never depended on a model. The only coupling was
|
|
4
|
+
// here, in how an AI call is shaped: one hardcoded pair of request builders and
|
|
5
|
+
// one `endpointConfig` that knew about exactly two presets. This module makes
|
|
6
|
+
// the provider a parameter instead, so an OpenAI or Gemini shop — or a
|
|
7
|
+
// regulated org that cannot send code to any vendor — is a configuration
|
|
8
|
+
// change rather than a fork.
|
|
9
|
+
//
|
|
10
|
+
// WHAT A PROVIDER IS. Endpoint, auth header shape, request body, and how to
|
|
11
|
+
// read text and token usage back out. Nothing else. Prompt construction,
|
|
12
|
+
// redaction, response validation, the challenge/nonce cross-check and the cost
|
|
13
|
+
// ceiling all stay upstream and apply identically to every provider — those are
|
|
14
|
+
// the security properties, and they must not become per-vendor.
|
|
15
|
+
//
|
|
16
|
+
// PER-ROLE PINNING. The strong pipelines route cheap models at triage and
|
|
17
|
+
// frontier models at synthesis. `resolveProvider({ role })` reads a per-role
|
|
18
|
+
// override before the global one, so `AGENTIC_SECURITY_LLM_MODEL_VERIFY` can
|
|
19
|
+
// point at something cheap while `..._FIX` points at something capable. Roles
|
|
20
|
+
// are a closed set: an unknown role would silently fall back to the global
|
|
21
|
+
// model, which is how a "cheap verify" quietly becomes an expensive one.
|
|
22
|
+
//
|
|
23
|
+
// GRACEFUL DEGRADATION IS THE DEFAULT. No provider configured means no AI
|
|
24
|
+
// stages — the engine still returns its deterministic findings. That is the
|
|
25
|
+
// existing behaviour and it is load-bearing: adding providers must not make the
|
|
26
|
+
// scanner require one.
|
|
27
|
+
//
|
|
28
|
+
// THE LOCAL PATH KEEPS ITS GUARANTEE. `local` still routes through
|
|
29
|
+
// `local-endpoint.js`, which enforces loopback on the host literal. A provider
|
|
30
|
+
// abstraction must not become a way to smuggle a remote endpoint into the mode
|
|
31
|
+
// whose entire promise is that nothing leaves the machine.
|
|
32
|
+
|
|
33
|
+
import { localEndpointConfig } from './local-endpoint.js';
|
|
34
|
+
|
|
35
|
+
// Roles the pipeline dispatches under. Closed set on purpose — see above.
|
|
36
|
+
export const ROLES = Object.freeze([
|
|
37
|
+
'validate', // the FP-suppression validator (the original caller)
|
|
38
|
+
'verify', // adversarial verification (Epic 2) — cheap tier by default
|
|
39
|
+
'explain', // human-facing explanation
|
|
40
|
+
'fix', // patch synthesis
|
|
41
|
+
'poc', // proof-of-concept synthesis (Epic 1)
|
|
42
|
+
'logic', // cross-file business-logic reasoning (Epic 6)
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const ANTHROPIC_VERSION = '2023-06-01';
|
|
46
|
+
|
|
47
|
+
// ── Request/response shapes, one per wire protocol ──────────────────────────
|
|
48
|
+
//
|
|
49
|
+
// Kept as data rather than subclasses: each is four small functions, and a flat
|
|
50
|
+
// table makes it obvious what a new provider must supply.
|
|
51
|
+
const SHAPES = {
|
|
52
|
+
anthropic: {
|
|
53
|
+
headers: () => ({ 'Content-Type': 'application/json', 'anthropic-version': ANTHROPIC_VERSION }),
|
|
54
|
+
auth: (h, key) => { if (key) h['x-api-key'] = key; },
|
|
55
|
+
body: (model, prompt, maxTokens) => ({ model, max_tokens: maxTokens, messages: [{ role: 'user', content: prompt }] }),
|
|
56
|
+
text: (j) => (Array.isArray(j?.content) ? j.content.filter(b => b?.type === 'text').map(b => b.text || '').join('') : ''),
|
|
57
|
+
usage: (j) => (j?.usage && Number.isFinite(j.usage.input_tokens)
|
|
58
|
+
? { inputTokens: j.usage.input_tokens, outputTokens: j.usage.output_tokens || 0 } : null),
|
|
59
|
+
},
|
|
60
|
+
// OpenAI-compatible: also what most local servers speak, which is why the
|
|
61
|
+
// `local` provider reuses it rather than inventing a third shape.
|
|
62
|
+
openai: {
|
|
63
|
+
headers: () => ({ 'Content-Type': 'application/json' }),
|
|
64
|
+
auth: (h, key) => { if (key) h.Authorization = `Bearer ${key}`; },
|
|
65
|
+
body: (model, prompt, maxTokens) => ({ model, max_tokens: maxTokens, messages: [{ role: 'user', content: prompt }] }),
|
|
66
|
+
text: (j) => j?.choices?.[0]?.message?.content || j?.choices?.[0]?.text || '',
|
|
67
|
+
usage: (j) => {
|
|
68
|
+
const u = j?.usage; if (!u) return null;
|
|
69
|
+
const inputTokens = u.prompt_tokens ?? u.input_tokens;
|
|
70
|
+
const outputTokens = u.completion_tokens ?? u.output_tokens ?? 0;
|
|
71
|
+
return Number.isFinite(inputTokens) ? { inputTokens, outputTokens } : null;
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
// The LEGACY generic shape: `{prompt, model}` with a permissive extractor.
|
|
75
|
+
// This is what BYO endpoints and local servers have always been sent, and
|
|
76
|
+
// existing deployments speak it. Assuming OpenAI-compatibility here would
|
|
77
|
+
// silently break every one of them, so the OpenAI shape is used ONLY when the
|
|
78
|
+
// operator explicitly asks for the openai preset.
|
|
79
|
+
generic: {
|
|
80
|
+
headers: () => ({ 'Content-Type': 'application/json' }),
|
|
81
|
+
auth: (h, key) => { if (key) h.Authorization = `Bearer ${key}`; },
|
|
82
|
+
body: (model, prompt) => ({ prompt, model }),
|
|
83
|
+
text: (j) => (j && (j.response || j.text || j.content || j.output
|
|
84
|
+
|| j.choices?.[0]?.message?.content || j.message?.content)) || '',
|
|
85
|
+
usage: (j) => {
|
|
86
|
+
const u = j?.usage; if (!u) return null;
|
|
87
|
+
const inputTokens = u.prompt_tokens ?? u.input_tokens;
|
|
88
|
+
const outputTokens = u.completion_tokens ?? u.output_tokens ?? 0;
|
|
89
|
+
return Number.isFinite(inputTokens) ? { inputTokens, outputTokens } : null;
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
gemini: {
|
|
93
|
+
headers: () => ({ 'Content-Type': 'application/json' }),
|
|
94
|
+
// Gemini takes the key on the query string; the caller appends it, so the
|
|
95
|
+
// header set stays empty rather than carrying a bearer token that the API
|
|
96
|
+
// would ignore.
|
|
97
|
+
auth: () => {},
|
|
98
|
+
body: (model, prompt, maxTokens) => ({
|
|
99
|
+
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
100
|
+
generationConfig: { maxOutputTokens: maxTokens },
|
|
101
|
+
}),
|
|
102
|
+
text: (j) => (j?.candidates?.[0]?.content?.parts || []).map(p => p?.text || '').join(''),
|
|
103
|
+
usage: (j) => {
|
|
104
|
+
const u = j?.usageMetadata; if (!u) return null;
|
|
105
|
+
return Number.isFinite(u.promptTokenCount)
|
|
106
|
+
? { inputTokens: u.promptTokenCount, outputTokens: u.candidatesTokenCount || 0 } : null;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const DEFAULT_MODEL = {
|
|
112
|
+
anthropic: 'claude-haiku-4-5',
|
|
113
|
+
openai: 'gpt-4o-mini',
|
|
114
|
+
gemini: 'gemini-2.0-flash',
|
|
115
|
+
local: 'local-model',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
function _envKey(role, suffix) {
|
|
119
|
+
return `AGENTIC_SECURITY_LLM_${suffix}_${String(role).toUpperCase()}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Per-role override, falling back to the global setting. */
|
|
123
|
+
function _forRole(env, role, suffix) {
|
|
124
|
+
if (role && ROLES.includes(role)) {
|
|
125
|
+
const v = env[_envKey(role, suffix)];
|
|
126
|
+
if (v) return v;
|
|
127
|
+
}
|
|
128
|
+
return env[`AGENTIC_SECURITY_LLM_${suffix}`];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the provider for a role.
|
|
133
|
+
*
|
|
134
|
+
* @returns {{ok:true, config:object} | {ok:false, reason:string|null}}
|
|
135
|
+
* `ok:false` with `reason:null` means "no AI configured", which is the normal
|
|
136
|
+
* default and NOT an error. A non-null reason means a configuration was
|
|
137
|
+
* supplied and refused — those must be surfaced, not silently treated the
|
|
138
|
+
* same as "off".
|
|
139
|
+
*/
|
|
140
|
+
export function resolveProvider({ role = 'validate', env = process.env } = {}) {
|
|
141
|
+
const explicit = (_forRole(env, role, 'PRESET') || '').toLowerCase();
|
|
142
|
+
const model = _forRole(env, role, 'MODEL');
|
|
143
|
+
|
|
144
|
+
// 1. Local — checked first because it is the only mode that makes a promise
|
|
145
|
+
// about where data goes, and a stray endpoint must not override it.
|
|
146
|
+
if (explicit === 'local') {
|
|
147
|
+
const r = localEndpointConfig(env);
|
|
148
|
+
if (!r.ok) return { ok: false, reason: r.reason };
|
|
149
|
+
return {
|
|
150
|
+
ok: true,
|
|
151
|
+
config: {
|
|
152
|
+
provider: 'local', shape: SHAPES.generic, endpoint: r.config.endpoint,
|
|
153
|
+
apiKey: r.config.apiKey, model: model || r.config.model,
|
|
154
|
+
egress: 'loopback-only', role,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2. Explicit BYO endpoint — checked BEFORE the vendor presets because that
|
|
160
|
+
// is the documented precedence: an operator who names an endpoint means
|
|
161
|
+
// that endpoint, even with a preset also set. Reversing it would silently
|
|
162
|
+
// redirect traffic to a vendor.
|
|
163
|
+
const byoEndpoint = _forRole(env, role, 'ENDPOINT');
|
|
164
|
+
if (byoEndpoint) {
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
config: {
|
|
168
|
+
provider: 'byo', shape: SHAPES.generic, endpoint: byoEndpoint,
|
|
169
|
+
apiKey: _forRole(env, role, 'API_KEY') || null,
|
|
170
|
+
model: model || 'unknown', egress: 'remote', role,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 3. Explicit vendor presets.
|
|
176
|
+
if (explicit === 'anthropic' || explicit === 'openai' || explicit === 'gemini') {
|
|
177
|
+
const apiKey = _forRole(env, role, 'API_KEY')
|
|
178
|
+
|| (explicit === 'anthropic' ? env.ANTHROPIC_API_KEY
|
|
179
|
+
: explicit === 'openai' ? env.OPENAI_API_KEY
|
|
180
|
+
: env.GEMINI_API_KEY || env.GOOGLE_API_KEY);
|
|
181
|
+
// No key -> the tier is off, not broken. Same offline-degrading rule the
|
|
182
|
+
// anthropic preset has always had.
|
|
183
|
+
if (!apiKey) return { ok: false, reason: null };
|
|
184
|
+
const m = model || DEFAULT_MODEL[explicit];
|
|
185
|
+
const endpoint = (
|
|
186
|
+
explicit === 'anthropic' ? 'https://api.anthropic.com/v1/messages'
|
|
187
|
+
: explicit === 'openai' ? 'https://api.openai.com/v1/chat/completions'
|
|
188
|
+
: `https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent?key=${encodeURIComponent(apiKey)}`
|
|
189
|
+
);
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
config: {
|
|
193
|
+
provider: explicit, shape: SHAPES[explicit], endpoint,
|
|
194
|
+
apiKey: explicit === 'gemini' ? null : apiKey, model: m, egress: 'remote', role,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 4. Nothing configured — deterministic engine only.
|
|
200
|
+
return { ok: false, reason: null };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Build the HTTP call for a resolved provider. Pure. */
|
|
204
|
+
export function buildProviderRequest(config, prompt, maxTokens) {
|
|
205
|
+
const headers = config.shape.headers();
|
|
206
|
+
config.shape.auth(headers, config.apiKey);
|
|
207
|
+
return {
|
|
208
|
+
headers,
|
|
209
|
+
body: config.shape.body(config.model, prompt, maxTokens),
|
|
210
|
+
extractText: config.shape.text,
|
|
211
|
+
extractUsage: config.shape.usage,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Which provider each role would use, for reporting. Never includes keys. */
|
|
216
|
+
export function providerMatrix(env = process.env) {
|
|
217
|
+
const out = {};
|
|
218
|
+
for (const role of ROLES) {
|
|
219
|
+
const r = resolveProvider({ role, env });
|
|
220
|
+
out[role] = r.ok
|
|
221
|
+
? { provider: r.config.provider, model: r.config.model, egress: r.config.egress }
|
|
222
|
+
: { provider: null, reason: r.reason || 'not configured' };
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export const _internals = { SHAPES, DEFAULT_MODEL, _forRole };
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -98,6 +98,31 @@ Wired in `bin/agentic-security.js` after every filter and after `makeDeterminist
|
|
|
98
98
|
credibility, orthogonal to `confidence`/`exploitability`: whether the bug was
|
|
99
99
|
*run*, not just reasoned about.
|
|
100
100
|
|
|
101
|
+
**The five proof classes** (`poc-inprocess.js`), and what each observes:
|
|
102
|
+
|
|
103
|
+
| Family | Evidence |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `command-injection`, `code-injection` | the injected payload itself writes the marker |
|
|
106
|
+
| `webhook-missing-signature-verification` | the handler is observed *accepting* an unsigned request |
|
|
107
|
+
| `sql-injection` | the payload reaches a stubbed driver inside the **SQL text** rather than as a bound parameter |
|
|
108
|
+
| `path-traversal` | a sentinel planted outside the served directory comes back out of the handler |
|
|
109
|
+
|
|
110
|
+
The last two need no running application, which is the point: the SQL question
|
|
111
|
+
("text or bound parameter?") is settled where the query crosses into the driver,
|
|
112
|
+
and the traversal question is settled by what the handler hands back. A
|
|
113
|
+
parameterised query and a `basename`-guarded read both reach `proof-failed` by
|
|
114
|
+
**execution**, not by a source pattern.
|
|
115
|
+
|
|
116
|
+
Classes deliberately absent, with reasons, are listed in the module header —
|
|
117
|
+
IDOR (needs two identities and a populated store; a PoC built on invented state
|
|
118
|
+
proves something about the invention), SSRF (the sandbox denies egress, so a
|
|
119
|
+
failed fetch is confinement talking), XSS (a marker file cannot observe a DOM).
|
|
120
|
+
|
|
121
|
+
`extraFiles` on a PoC carries support files that are **not** the vulnerable
|
|
122
|
+
source (the SQL driver stub). `mergePocFiles` merges it under `requires`, which
|
|
123
|
+
always wins — otherwise a template could replace the code it is supposed to
|
|
124
|
+
exploit and prove a fact about itself.
|
|
125
|
+
|
|
101
126
|
**The four tiers** (`PROOF_TIERS`, most-proven first):
|
|
102
127
|
|
|
103
128
|
- `execution-proven` — a generated PoC ran inside the sandbox and the sandbox
|
|
@@ -236,6 +261,57 @@ State lives at `<scanRoot>/.agentic-security/scan-checkpoint.jsonl`; like every
|
|
|
236
261
|
other module here, nothing throws — a failure to open, read or append degrades to
|
|
237
262
|
"no checkpoint", i.e. a normal full scan.
|
|
238
263
|
|
|
264
|
+
## The autonomous loop, the fleet, and the two things that judge them
|
|
265
|
+
|
|
266
|
+
**`autopilot.js`** is the chain — scan → prove → validate → fix → **re-verify** —
|
|
267
|
+
and nothing else. Every stage is injected, so the orchestration is testable
|
|
268
|
+
without an engine or a model; `scripts/autopilot.mjs` is where the real stages
|
|
269
|
+
get wired (a real scan, a real sandboxed exploit, a deterministic-then-model fix,
|
|
270
|
+
and the real gate).
|
|
271
|
+
|
|
272
|
+
The rule that makes it safe to automate: a fix is applied **only** if the PoC
|
|
273
|
+
that proved the bug no longer fires **and** the test suite still passes. Anything
|
|
274
|
+
else is `NEEDS_REVIEW` and is not written. A re-scan proves the *detector* went
|
|
275
|
+
quiet; only re-running the exploit proves the hole is shut. Gates are ON by
|
|
276
|
+
default — `apply` is an explicit opt-in — and the outcome set (`OUTCOMES`) is
|
|
277
|
+
closed, because the report groups on it and an undeclared value would vanish
|
|
278
|
+
from every count. `maxFindings` is reported as `capped`, never applied silently.
|
|
279
|
+
|
|
280
|
+
The CLI refuses to start with no confinement backend (the gate's verdict
|
|
281
|
+
requires executing something) and refuses a dirty git tree by default (the test
|
|
282
|
+
leg writes the candidate patch to disk and restores it in a `finally`, so a
|
|
283
|
+
clean tree is what makes a crash recoverable). A VERIFIED_FIXED reached with no
|
|
284
|
+
test runner detected is counted and reported separately — the exploit stopped
|
|
285
|
+
firing, but nothing checked the application still works.
|
|
286
|
+
|
|
287
|
+
**`fleet.js`** rolls many repositories into one offline page. `renderFleetHtml`
|
|
288
|
+
emits no scripts and no external references, and a repo that FAILED to scan
|
|
289
|
+
always forces a non-zero exit: an unscanned repo is unknown, not clean.
|
|
290
|
+
|
|
291
|
+
**`logic-claims.js` (PRD Epic 6)** is the business-logic tier's other half. The
|
|
292
|
+
deterministic side already existed (`sast/logic.js`, `posture/business-logic.js`);
|
|
293
|
+
what did not was any way to be *wrong* about a claim from the reviewing agent,
|
|
294
|
+
which is prose and was the only tier nothing could disagree with. Three offline
|
|
295
|
+
lenses can refute one: `citation` (the file exists and the line is inside it),
|
|
296
|
+
`quotation` (the quoted snippet is at the cited line ±3), and `corroboration`
|
|
297
|
+
(for kinds that assert something checkable — "this route has no authentication"
|
|
298
|
+
against a handler that plainly authenticates). Verdicts go through
|
|
299
|
+
`verification-separation.js`, so a lens can never vote on a claim it produced.
|
|
300
|
+
Recall-preserving: a refuted claim is `quarantined`, never deleted, never
|
|
301
|
+
severity-touched. Wired in `engine.js`, which reads
|
|
302
|
+
`.agentic-security/logic-claims.json` from the scan root and lands the results
|
|
303
|
+
on `scan.logicVulns` with a summary at `scan.logicClaims`.
|
|
304
|
+
|
|
305
|
+
**`comparison.js` (PRD Epic 7.2)** scores this engine head-to-head against
|
|
306
|
+
participants **the operator supplies** — the repository ships the harness and the
|
|
307
|
+
answer key, never a participant, and a test asserts no tool name appears in
|
|
308
|
+
either file. Two properties are the whole module: every rate is computed over the
|
|
309
|
+
**intersection** of entries *all* participants completed (two tools scored over
|
|
310
|
+
different subsets are not comparable, and the difference is invisible in the
|
|
311
|
+
output), and an entry a participant could not run is **unscored**, never counted
|
|
312
|
+
as a miss. Matching is CWE-only so nobody is scored on this engine's vocabulary.
|
|
313
|
+
Driver: `scripts/comparison.mjs`, over the CVE-replay corpus.
|
|
314
|
+
|
|
239
315
|
## Gotchas
|
|
240
316
|
|
|
241
317
|
- The seed `calibration-seed.json` is small (n < 30 for several families). Don't treat it as a held-out set — that's `holdout-eval.js`'s job, against an externally-supplied JSONL.
|
|
@@ -263,17 +263,48 @@ export function renderScorecardMarkdown(m) {
|
|
|
263
263
|
L.push('');
|
|
264
264
|
L.push('## Precision-side signal: self-scan (measured this run)');
|
|
265
265
|
L.push('');
|
|
266
|
-
L.push('The engine scanned
|
|
267
|
-
L.push('
|
|
268
|
-
L.push('
|
|
269
|
-
L.push('
|
|
270
|
-
L.push('
|
|
266
|
+
L.push('The engine scanned its own repository. These are absolute finding');
|
|
267
|
+
L.push('counts, not a rate — there is no labelled ground truth over this code,');
|
|
268
|
+
L.push('so no precision figure is derived from it. What it supports is a');
|
|
269
|
+
L.push('movement claim: any change in these counts between releases is a real');
|
|
270
|
+
L.push('change in what the engine reports on unchanged code.');
|
|
271
|
+
L.push('');
|
|
272
|
+
L.push('**The two halves are not the same kind of evidence, so they are not');
|
|
273
|
+
L.push('reported together.** `hooks/` and `scripts/` were reviewed by hand,');
|
|
274
|
+
L.push('finding by finding. `scanner/src` was not: it is the engine itself, at a');
|
|
275
|
+
L.push('scale no one has read line by line, and a scanner\'s own source contains');
|
|
276
|
+
L.push('sink patterns as DATA — rule tables, catalogs, remediation strings — so a');
|
|
277
|
+
L.push('large share of its findings are self-referential rather than defects.');
|
|
278
|
+
L.push('Treat it as a tripwire, never as a quality figure.');
|
|
279
|
+
L.push('');
|
|
280
|
+
// Split deliberately. Both halves used to sit under a single "hand-reviewed"
|
|
281
|
+
// sentence; when `scanner/src` was added to the gate its 594 findings
|
|
282
|
+
// inherited a review claim that was true of 48 findings and false of these.
|
|
283
|
+
// Widening a gate must not silently upgrade what its numbers assert.
|
|
284
|
+
const REVIEWED = new Set(['hooks', 'scripts']);
|
|
285
|
+
const entries = Object.entries(m.selfScan.targets);
|
|
286
|
+
const reviewed = entries.filter(([k]) => REVIEWED.has(k));
|
|
287
|
+
const unreviewed = entries.filter(([k]) => !REVIEWED.has(k));
|
|
288
|
+
|
|
289
|
+
L.push('### Hand-reviewed targets');
|
|
271
290
|
L.push('');
|
|
272
291
|
L.push('| Target | Findings |');
|
|
273
292
|
L.push('| --- | --- |');
|
|
274
|
-
for (const [k, v] of
|
|
293
|
+
for (const [k, v] of reviewed) L.push(`| \`${k}\` | ${v.total} |`);
|
|
275
294
|
L.push(`| \`polyglot\` fixture (expected 0) | ${m.selfScan.polyglot.total} |`);
|
|
276
295
|
L.push('');
|
|
296
|
+
if (unreviewed.length) {
|
|
297
|
+
L.push('### Drift tripwire — NOT hand-reviewed, NOT a precision signal');
|
|
298
|
+
L.push('');
|
|
299
|
+
L.push('| Target | Findings |');
|
|
300
|
+
L.push('| --- | --- |');
|
|
301
|
+
for (const [k, v] of unreviewed) L.push(`| \`${k}\` | ${v.total} |`);
|
|
302
|
+
L.push('');
|
|
303
|
+
L.push('These counts exist so that a rule which starts firing somewhere new is');
|
|
304
|
+
L.push('visible per file. Nobody has adjudicated them, and quoting the total as');
|
|
305
|
+
L.push('a false-positive count would be wrong in both directions.');
|
|
306
|
+
L.push('');
|
|
307
|
+
}
|
|
277
308
|
L.push('Per-file counts are in `docs/scorecard.json`.');
|
|
278
309
|
L.push('');
|
|
279
310
|
L.push('## Committed artifacts referenced (not re-run by this command)');
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// PRD Epic 4 — the autonomous loop.
|
|
2
|
+
//
|
|
3
|
+
// Every stage already existed as a separate command. What did not exist was the
|
|
4
|
+
// chain: scan → prove → validate → fix → RE-VERIFY, with state between stages
|
|
5
|
+
// and a gate before anything is written. This module is that chain and nothing
|
|
6
|
+
// else — it owns no analysis, and each stage is injected, so the orchestration
|
|
7
|
+
// can be tested without running an engine or calling a model.
|
|
8
|
+
//
|
|
9
|
+
// THE RULE THAT MAKES IT SAFE TO AUTOMATE: a fix is applied only if the PoC
|
|
10
|
+
// that proved the bug no longer fires AND the test suite still passes. That is
|
|
11
|
+
// `VERIFIED_FIXED`. Anything else is `NEEDS_REVIEW` and is NOT written. A loop
|
|
12
|
+
// that applies patches on the strength of "the scanner stopped complaining"
|
|
13
|
+
// automates the failure mode where a cosmetic edit silences a detector while
|
|
14
|
+
// the vulnerability remains — the re-scan proves the DETECTOR went quiet, and
|
|
15
|
+
// only re-running the exploit proves the hole is shut.
|
|
16
|
+
//
|
|
17
|
+
// GATES ARE ON BY DEFAULT. `apply` requires an explicit opt-in. Autonomy plus
|
|
18
|
+
// write access is the combination that turns a bad patch into a bad commit, and
|
|
19
|
+
// the default must be the one that cannot.
|
|
20
|
+
//
|
|
21
|
+
// RESUMABLE, AND HONEST ABOUT WHAT IT SKIPPED. Each stage records its outcome
|
|
22
|
+
// per finding; a resumed run replays completed stages from state rather than
|
|
23
|
+
// re-running them. A stage that was skipped is reported as skipped — never
|
|
24
|
+
// folded into "nothing to do", which is how an interrupted run reads as a clean
|
|
25
|
+
// one.
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
const SCHEMA = 'agentic-security/autopilot@1';
|
|
31
|
+
|
|
32
|
+
export const STAGES = Object.freeze(['scan', 'prove', 'validate', 'fix', 'reverify']);
|
|
33
|
+
|
|
34
|
+
// Outcomes a finding can end a run in. Closed set: a new outcome must be
|
|
35
|
+
// declared here, because the report groups on it and an unknown value would
|
|
36
|
+
// silently vanish from every count.
|
|
37
|
+
export const OUTCOMES = Object.freeze([
|
|
38
|
+
'VERIFIED_FIXED', // patch applied (or ready): PoC no longer fires, tests pass
|
|
39
|
+
'NEEDS_REVIEW', // a fix exists but did not re-verify — NOT applied
|
|
40
|
+
'NO_FIX', // nothing was synthesised
|
|
41
|
+
'UNPROVEN', // no PoC fired, so no fix was attempted at this tier
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export function loadAutopilotState(stateFile) {
|
|
45
|
+
try {
|
|
46
|
+
const j = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
47
|
+
if (j && j.schema === SCHEMA) return j;
|
|
48
|
+
} catch { /* absent -> clean */ }
|
|
49
|
+
return { schema: SCHEMA, stages: {}, findings: {} };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _save(stateFile, state) {
|
|
53
|
+
if (!stateFile) return;
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
56
|
+
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
57
|
+
} catch { /* state is an optimisation; losing it must not fail a run */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run the loop.
|
|
62
|
+
*
|
|
63
|
+
* Every stage is injected. `apply` defaults to false: the loop produces
|
|
64
|
+
* VERIFIED_FIXED patches and does not write them unless told to.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} stages
|
|
67
|
+
* scan() -> { findings: [] }
|
|
68
|
+
* prove(finding) -> { proofTier, proofEvidence, poc }
|
|
69
|
+
* validate(finding) -> { verdict: 'upheld'|'refuted'|'undecided' }
|
|
70
|
+
* synthesizeFix(finding) -> { patch: {file: content} } | null
|
|
71
|
+
* verifyFix(finding, patch) -> { ok, pocStillFires, testsPass, reason }
|
|
72
|
+
* applyFix(finding, patch) -> void (only called when apply === true)
|
|
73
|
+
*/
|
|
74
|
+
export async function runAutopilot({
|
|
75
|
+
stages = {}, stateFile = null, resume = true, apply = false,
|
|
76
|
+
onStage = () => {}, severities = ['critical', 'high'], maxFindings = Infinity,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const required = ['scan'];
|
|
79
|
+
for (const r of required) {
|
|
80
|
+
if (typeof stages[r] !== 'function') return { ok: false, reason: `no ${r} stage supplied` };
|
|
81
|
+
}
|
|
82
|
+
const state = resume && stateFile ? loadAutopilotState(stateFile) : { schema: SCHEMA, stages: {}, findings: {} };
|
|
83
|
+
const skipped = [];
|
|
84
|
+
|
|
85
|
+
// ── scan ────────────────────────────────────────────────────────────────
|
|
86
|
+
let findings;
|
|
87
|
+
if (resume && state.stages.scan) {
|
|
88
|
+
findings = state.stages.scan.findings || [];
|
|
89
|
+
skipped.push('scan');
|
|
90
|
+
} else {
|
|
91
|
+
const r = await stages.scan();
|
|
92
|
+
findings = r?.findings || [];
|
|
93
|
+
state.stages.scan = { findings };
|
|
94
|
+
_save(stateFile, state);
|
|
95
|
+
}
|
|
96
|
+
onStage({ stage: 'scan', count: findings.length });
|
|
97
|
+
|
|
98
|
+
// Only the severities asked for reach the expensive stages. Reported, so a
|
|
99
|
+
// reader can see what was in scope rather than assuming everything was.
|
|
100
|
+
const all = findings.filter(f => severities.includes(String(f.severity || '').toLowerCase()));
|
|
101
|
+
const outOfScope = findings.length - all.length;
|
|
102
|
+
// Every stage costs a sandboxed process and possibly a test-suite run, so the
|
|
103
|
+
// count is bounded. Reported, never silent: findings past the cap were NOT
|
|
104
|
+
// examined, and a run that quietly stopped at N would read as a run that
|
|
105
|
+
// found only N.
|
|
106
|
+
const inScope = all.slice(0, Number.isFinite(maxFindings) ? Math.max(0, maxFindings) : all.length);
|
|
107
|
+
const capped = all.length - inScope.length;
|
|
108
|
+
|
|
109
|
+
const results = [];
|
|
110
|
+
for (const f of inScope) {
|
|
111
|
+
const key = f.stableId || `${f.file}:${f.line}:${f.vuln}`;
|
|
112
|
+
const prior = resume ? state.findings[key] : null;
|
|
113
|
+
if (prior?.outcome) { results.push(prior); continue; }
|
|
114
|
+
|
|
115
|
+
const rec = { key, file: f.file, line: f.line, vuln: f.vuln, severity: f.severity };
|
|
116
|
+
|
|
117
|
+
// ── prove ─────────────────────────────────────────────────────────────
|
|
118
|
+
let proved = null;
|
|
119
|
+
if (typeof stages.prove === 'function') {
|
|
120
|
+
proved = await stages.prove(f).catch(e => ({ error: String(e?.message || e) }));
|
|
121
|
+
rec.proofTier = proved?.proofTier || null;
|
|
122
|
+
}
|
|
123
|
+
const isProven = rec.proofTier === 'execution-proven';
|
|
124
|
+
|
|
125
|
+
// ── validate ──────────────────────────────────────────────────────────
|
|
126
|
+
if (typeof stages.validate === 'function') {
|
|
127
|
+
const v = await stages.validate(f).catch(() => null);
|
|
128
|
+
rec.validation = v?.verdict || null;
|
|
129
|
+
// A refuted finding is NOT dropped — same recall-preserving rule the rest
|
|
130
|
+
// of the engine follows. It is recorded and skipped for fixing.
|
|
131
|
+
if (rec.validation === 'refuted') {
|
|
132
|
+
rec.outcome = 'NEEDS_REVIEW';
|
|
133
|
+
rec.reason = 'an independent verifier refuted this finding; not fixed automatically';
|
|
134
|
+
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
135
|
+
onStage({ stage: 'validate', key, verdict: 'refuted' });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Fixing is gated on proof at this tier. An unproven finding may still be
|
|
141
|
+
// real — it is reported UNPROVEN, not dismissed.
|
|
142
|
+
if (!isProven) {
|
|
143
|
+
rec.outcome = 'UNPROVEN';
|
|
144
|
+
rec.reason = proved?.proofEvidence?.reason || 'no proof-of-concept demonstrated this finding';
|
|
145
|
+
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── fix ───────────────────────────────────────────────────────────────
|
|
150
|
+
let patch = null;
|
|
151
|
+
if (typeof stages.synthesizeFix === 'function') {
|
|
152
|
+
patch = await stages.synthesizeFix(f).catch(() => null);
|
|
153
|
+
}
|
|
154
|
+
if (!patch || !patch.patch) {
|
|
155
|
+
rec.outcome = 'NO_FIX';
|
|
156
|
+
rec.reason = 'no patch was synthesised';
|
|
157
|
+
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── re-verify ─────────────────────────────────────────────────────────
|
|
162
|
+
// The gate. Without a verifier we cannot claim VERIFIED_FIXED, so we do
|
|
163
|
+
// not — an unverifiable patch is NEEDS_REVIEW even if it looks right.
|
|
164
|
+
if (typeof stages.verifyFix !== 'function') {
|
|
165
|
+
rec.outcome = 'NEEDS_REVIEW';
|
|
166
|
+
rec.reason = 'no verifyFix stage supplied — a patch that cannot be re-verified is never applied';
|
|
167
|
+
results.push(rec); state.findings[key] = rec; _save(stateFile, state);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const v = await stages.verifyFix(f, patch).catch(e => ({ ok: false, reason: String(e?.message || e) }));
|
|
171
|
+
rec.pocStillFires = v?.pocStillFires === true;
|
|
172
|
+
rec.testsPass = v?.testsPass !== false;
|
|
173
|
+
|
|
174
|
+
if (v?.ok && !rec.pocStillFires && rec.testsPass) {
|
|
175
|
+
rec.outcome = 'VERIFIED_FIXED';
|
|
176
|
+
if (apply && typeof stages.applyFix === 'function') {
|
|
177
|
+
try { await stages.applyFix(f, patch); rec.applied = true; }
|
|
178
|
+
catch (e) { rec.applied = false; rec.outcome = 'NEEDS_REVIEW'; rec.reason = `apply failed: ${e.message}`; }
|
|
179
|
+
} else {
|
|
180
|
+
rec.applied = false;
|
|
181
|
+
rec.reason = apply ? 'no applyFix stage supplied' : 'gates on: patch is ready but was not written';
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
rec.outcome = 'NEEDS_REVIEW';
|
|
185
|
+
rec.reason = rec.pocStillFires
|
|
186
|
+
? 'the proof-of-concept still fires against the patch — the vulnerability is not fixed'
|
|
187
|
+
: (!rec.testsPass ? 'the project test suite fails with this patch'
|
|
188
|
+
: (v?.reason || 'the patch did not re-verify'));
|
|
189
|
+
}
|
|
190
|
+
results.push(rec);
|
|
191
|
+
state.findings[key] = rec;
|
|
192
|
+
_save(stateFile, state);
|
|
193
|
+
onStage({ stage: 'reverify', key, outcome: rec.outcome });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { ok: true, results, skipped, outOfScope, capped, summary: summarizeAutopilot(results, outOfScope, capped) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function summarizeAutopilot(results, outOfScope = 0, capped = 0) {
|
|
200
|
+
const byOutcome = Object.fromEntries(OUTCOMES.map(o => [o, 0]));
|
|
201
|
+
for (const r of results) if (r.outcome in byOutcome) byOutcome[r.outcome]++;
|
|
202
|
+
return {
|
|
203
|
+
considered: results.length,
|
|
204
|
+
outOfScope,
|
|
205
|
+
capped,
|
|
206
|
+
byOutcome,
|
|
207
|
+
applied: results.filter(r => r.applied).length,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** One line. Leads with what was NOT fixed, because that is the actionable part. */
|
|
212
|
+
export function renderAutopilotSummary(s) {
|
|
213
|
+
if (!s) return null;
|
|
214
|
+
const b = s.byOutcome;
|
|
215
|
+
const bits = [`${s.considered} finding(s) in scope`];
|
|
216
|
+
if (b.VERIFIED_FIXED) bits.push(`${b.VERIFIED_FIXED} VERIFIED_FIXED (${s.applied} applied)`);
|
|
217
|
+
if (b.NEEDS_REVIEW) bits.push(`${b.NEEDS_REVIEW} NEEDS_REVIEW — not applied`);
|
|
218
|
+
if (b.NO_FIX) bits.push(`${b.NO_FIX} with no patch`);
|
|
219
|
+
if (b.UNPROVEN) bits.push(`${b.UNPROVEN} unproven (not dismissed — no PoC fired)`);
|
|
220
|
+
if (s.outOfScope) bits.push(`${s.outOfScope} below the severity floor and not considered`);
|
|
221
|
+
if (s.capped) bits.push(`${s.capped} in scope but NOT examined (per-run cap) — unexamined, not clean`);
|
|
222
|
+
return bits.join('; ') + '.';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const _internals = { SCHEMA };
|