@clear-capabilities/agentic-security-scanner 0.176.0 → 0.177.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 +9 -0
- package/bin/agentic-security.js +9 -2
- package/dist/1919.index.js +1 -1
- package/dist/2066.index.js +2 -1
- package/dist/2584.index.js +19 -1
- package/dist/{4978bec8fdf5c733dc85.json → 37d64df006576248d0d8.json} +5 -2
- package/dist/5456.index.js +291 -0
- package/dist/5620.index.js +147 -23
- package/dist/5937.index.js +672 -0
- package/dist/7838.index.js +70 -25
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{df1e8683e7eda7b2b79c.js → b4289e728659450b5e19.js} +4 -1
- package/package.json +5 -2
- package/src/posture/custom-rules.js +54 -8
- package/src/posture/dnr/exploit-correlation.js +25 -0
- package/src/posture/evaluation/lab/dataset.js +1 -1
- package/src/posture/evaluation/lab/report.js +19 -1
- package/src/posture/fleet/cli.js +22 -10
- package/src/posture/fleet/enterprise-graph.js +44 -3
- package/src/posture/fleet/job-runner.js +7 -7
- package/src/posture/fleet/job-store.js +3 -1
- package/src/posture/fleet/lineage-summary.js +55 -0
- package/src/posture/fleet/scan-child.js +4 -1
- package/src/posture/proof-fabric/generate.js +2 -1
- package/src/posture/proof-fabric/runner.js +24 -2
- package/src/posture/rule-compiler/cli.js +40 -6
- package/src/posture/rule-compiler/corpus.js +3 -7
- package/src/posture/rule-compiler/ir.js +52 -9
- package/src/posture/rule-compiler/proposal.js +14 -9
- package/src/posture/rule-compiler/regression.js +95 -0
- package/src/posture/rule-compiler/store.js +29 -1
- package/src/posture/rule-pack-signing.js +4 -2
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
export const id = 5937;
|
|
2
|
+
export const ids = [5937];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 12090:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
|
+
/* harmony export */ KF: () => (/* binding */ toProofTier),
|
|
10
|
+
/* harmony export */ g3: () => (/* binding */ stateRank),
|
|
11
|
+
/* harmony export */ jn: () => (/* binding */ fromProofTier),
|
|
12
|
+
/* harmony export */ np: () => (/* binding */ advance)
|
|
13
|
+
/* harmony export */ });
|
|
14
|
+
/* unused harmony exports EVIDENCE_STATES, COMPLETENESS */
|
|
15
|
+
// Evidence state ladder for the Universal Exploit Proof Fabric.
|
|
16
|
+
//
|
|
17
|
+
// Evidence state is SEPARATE from severity and confidence. It extends, never replaces, the vocabulary that already
|
|
18
|
+
// exists: `proof-tier.js` (execution-proven / proof-failed / taint-proven / unproven) and the fix-verify legs
|
|
19
|
+
// (`verifiedFull`, `degradedLegs`). `toProofTier` / `fromProofTier` are the explicit bridges.
|
|
20
|
+
//
|
|
21
|
+
// Transitions are guarded: a state can only be reached from the state directly before it (or by dropping back), so no
|
|
22
|
+
// caller can jump a finding to EXPLOIT_CONFIRMED without passing REPRODUCED, or to FIX_VERIFIED without
|
|
23
|
+
// FIX_REPLAY_BLOCKED. A degraded or incomplete run NEVER advances a state.
|
|
24
|
+
const EVIDENCE_STATES = Object.freeze([
|
|
25
|
+
'CANDIDATE',
|
|
26
|
+
'STATICALLY_CORROBORATED',
|
|
27
|
+
'REPRODUCER_GENERATED',
|
|
28
|
+
'REPRODUCED',
|
|
29
|
+
'EXPLOIT_CONFIRMED',
|
|
30
|
+
'FIX_REPLAY_BLOCKED',
|
|
31
|
+
'FIX_VERIFIED',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const stateRank = (s) => EVIDENCE_STATES.indexOf(s);
|
|
35
|
+
|
|
36
|
+
const REQUIRES_PREDECESSOR = Object.freeze(Object.fromEntries(EVIDENCE_STATES.map((s, i) => [s, i === 0 ? null : EVIDENCE_STATES[i - 1]])));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Advance `from` to `to`. Returns { ok, state, reason }. Moving up requires `to` to be exactly the next rung; moving
|
|
40
|
+
* down or staying is always allowed (evidence can be lost, never invented). Unknown states are rejected.
|
|
41
|
+
*/
|
|
42
|
+
function advance(from, to) {
|
|
43
|
+
if (!EVIDENCE_STATES.includes(from)) return { ok: false, state: 'CANDIDATE', reason: `unknown current state: ${String(from)}` };
|
|
44
|
+
if (!EVIDENCE_STATES.includes(to)) return { ok: false, state: from, reason: `unknown target state: ${String(to)}` };
|
|
45
|
+
if (stateRank(to) <= stateRank(from)) return { ok: true, state: to, reason: null };
|
|
46
|
+
if (REQUIRES_PREDECESSOR[to] !== from) return { ok: false, state: from, reason: `cannot reach ${to} from ${from}: ${REQUIRES_PREDECESSOR[to]} is required first` };
|
|
47
|
+
return { ok: true, state: to, reason: null };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Bridges to the older vocabulary so existing consumers keep working.
|
|
51
|
+
function toProofTier(state) {
|
|
52
|
+
const r = stateRank(state);
|
|
53
|
+
if (r >= stateRank('EXPLOIT_CONFIRMED')) return 'execution-proven';
|
|
54
|
+
if (r >= stateRank('STATICALLY_CORROBORATED')) return 'taint-proven';
|
|
55
|
+
return 'unproven';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fromProofTier(tier) {
|
|
59
|
+
if (tier === 'taint-proven') return 'STATICALLY_CORROBORATED';
|
|
60
|
+
// execution-proven under the older single-run marker path is one run, not the 3/3 + independent replay the fabric
|
|
61
|
+
// requires, so it maps to REPRODUCED and never to EXPLOIT_CONFIRMED.
|
|
62
|
+
if (tier === 'execution-proven') return 'REPRODUCED';
|
|
63
|
+
return 'CANDIDATE';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Completeness vocabulary shared with fix-verify: FULL only when nothing was skipped.
|
|
67
|
+
const COMPLETENESS = Object.freeze(['FULL', 'DEGRADED', 'INCOMPLETE']);
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
/***/ }),
|
|
71
|
+
|
|
72
|
+
/***/ 65937:
|
|
73
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
74
|
+
|
|
75
|
+
// ESM COMPAT FLAG
|
|
76
|
+
__webpack_require__.r(__webpack_exports__);
|
|
77
|
+
|
|
78
|
+
// EXPORTS
|
|
79
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
80
|
+
cmdRules: () => (/* binding */ cmdRules)
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// EXTERNAL MODULE: external "node:fs"
|
|
84
|
+
var external_node_fs_ = __webpack_require__(73024);
|
|
85
|
+
// EXTERNAL MODULE: ./src/posture/state-dir.js
|
|
86
|
+
var state_dir = __webpack_require__(31174);
|
|
87
|
+
// EXTERNAL MODULE: external "node:path"
|
|
88
|
+
var external_node_path_ = __webpack_require__(76760);
|
|
89
|
+
// EXTERNAL MODULE: external "node:crypto"
|
|
90
|
+
var external_node_crypto_ = __webpack_require__(77598);
|
|
91
|
+
// EXTERNAL MODULE: ./src/posture/rule-compiler/ir.js
|
|
92
|
+
var rule_compiler_ir = __webpack_require__(87539);
|
|
93
|
+
;// CONCATENATED MODULE: ./src/posture/rule-compiler/lifecycle.js
|
|
94
|
+
// Rule lifecycle ledger: DRAFT -> CANDIDATE -> VALIDATED -> PROMOTED -> DEPRECATED/ROLLED_BACK.
|
|
95
|
+
//
|
|
96
|
+
// Append-only, hash-chained (same idiom as remediation-ledger.js / invariants/lifecycle.js): each entry links the
|
|
97
|
+
// previous entry's hash, so a dropped or edited middle record breaks the chain and `verifyLedger` catches it. A
|
|
98
|
+
// rule cannot promote itself: `transition` only records a transition a caller already decided; `advance`-style
|
|
99
|
+
// guarding on VALID edges lives here so no caller can jump DRAFT straight to PROMOTED.
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
const LEDGER_SCHEMA = 'agentic-security/rule-lifecycle-ledger';
|
|
104
|
+
const EDGES = Object.freeze({
|
|
105
|
+
DRAFT: ['CANDIDATE'], CANDIDATE: ['VALIDATED', 'DRAFT'], VALIDATED: ['PROMOTED', 'DRAFT'],
|
|
106
|
+
PROMOTED: ['DEPRECATED', 'ROLLED_BACK'], DEPRECATED: [], ROLLED_BACK: ['CANDIDATE'],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const hashOf = (entry) => (0,external_node_crypto_.createHash)('sha256').update(JSON.stringify(entry)).digest('hex');
|
|
110
|
+
|
|
111
|
+
function newLedger() { return { schema: LEDGER_SCHEMA, entries: [] }; }
|
|
112
|
+
|
|
113
|
+
function currentState(ledger, ruleId) {
|
|
114
|
+
for (let i = ledger.entries.length - 1; i >= 0; i--) if (ledger.entries[i].ruleId === ruleId) return ledger.entries[i].to;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Record a transition. Refuses an edge not in EDGES, and refuses DRAFT->DRAFT re-entry for a rule that has never
|
|
120
|
+
* existed (the first entry for a rule id must be `from: null, to: 'DRAFT'`).
|
|
121
|
+
*/
|
|
122
|
+
function recordTransition(ledger, { ruleId, from, to, reason, evidence = null, actor }) {
|
|
123
|
+
if (!rule_compiler_ir/* RULE_STATES */.UQ.includes(to)) return { ok: false, reason: `unknown target state: ${to}` };
|
|
124
|
+
const cur = currentState(ledger, ruleId);
|
|
125
|
+
if (cur === null) {
|
|
126
|
+
if (from !== null || to !== 'DRAFT') return { ok: false, reason: 'first transition for a rule must be null -> DRAFT' };
|
|
127
|
+
} else {
|
|
128
|
+
if (from !== cur) return { ok: false, reason: `stale transition: current state is ${cur}, not ${from}` };
|
|
129
|
+
if (!EDGES[cur].includes(to)) return { ok: false, reason: `cannot go from ${cur} to ${to}` };
|
|
130
|
+
}
|
|
131
|
+
const prevHash = ledger.entries.length ? ledger.entries[ledger.entries.length - 1].entryHash : null;
|
|
132
|
+
const entry = { ruleId, from: cur, to, reason: reason ?? null, evidence, actor: actor ?? 'unknown', at: new Date().toISOString(), prevHash };
|
|
133
|
+
entry.entryHash = hashOf({ ...entry, entryHash: undefined });
|
|
134
|
+
ledger.entries.push(entry);
|
|
135
|
+
return { ok: true, entry };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function verifyLedger(ledger) {
|
|
139
|
+
let prevHash = null;
|
|
140
|
+
for (const e of ledger.entries) {
|
|
141
|
+
if (e.prevHash !== prevHash) return { ok: false, reason: 'chain broken: prevHash mismatch' };
|
|
142
|
+
const recomputed = hashOf({ ...e, entryHash: undefined });
|
|
143
|
+
if (recomputed !== e.entryHash) return { ok: false, reason: `entry hash mismatch for ${e.ruleId} at ${e.at}` };
|
|
144
|
+
prevHash = e.entryHash;
|
|
145
|
+
}
|
|
146
|
+
return { ok: true };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function historyFor(ledger, ruleId) { return ledger.entries.filter((e) => e.ruleId === ruleId); }
|
|
150
|
+
function stateOf(ledger, ruleId) { return currentState(ledger, ruleId); }
|
|
151
|
+
|
|
152
|
+
// EXTERNAL MODULE: ./src/posture/rule-pack-signing.js
|
|
153
|
+
var rule_pack_signing = __webpack_require__(99527);
|
|
154
|
+
;// CONCATENATED MODULE: ./src/posture/rule-compiler/store.js
|
|
155
|
+
// Persistence for the rule compiler: one directory per rule under `.agentic-security/rule-compiler/<id>/`
|
|
156
|
+
// (`ir.json`, `ledger.json`, `evaluation.json`), the promoted custom-rule document at
|
|
157
|
+
// `.agentic-security/rules/<id>.yml` (so the EXISTING `loadCustomRules`/`rule test` machinery picks it up with no
|
|
158
|
+
// change), and a regression fixture pair at `.agentic-security/rule-compiler/fixtures/<id>/{vulnerable,clean}/`
|
|
159
|
+
// using the SAME filename convention `runRuleTests` already reads (`vulnerable`/`clean` in the path).
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
const DIR = 'rule-compiler';
|
|
168
|
+
const EXT = { javascript: 'js', typescript: 'ts', python: 'py', go: 'go', ruby: 'rb', java: 'java', rust: 'rs', csharp: 'cs', php: 'php', any: 'txt' };
|
|
169
|
+
|
|
170
|
+
function ruleDir(scanRoot, id) { return (0,state_dir.statePath)(scanRoot, DIR, id); }
|
|
171
|
+
|
|
172
|
+
function loadLedger(scanRoot) {
|
|
173
|
+
const f = (0,state_dir.statePath)(scanRoot, DIR, 'ledger.json');
|
|
174
|
+
if (!external_node_fs_.existsSync(f)) return newLedger();
|
|
175
|
+
try {
|
|
176
|
+
const ledger = JSON.parse(external_node_fs_.readFileSync(f, 'utf8'));
|
|
177
|
+
const v = verifyLedger(ledger);
|
|
178
|
+
return v.ok ? ledger : { ...newLedger(), tampered: true, tamperReason: v.reason };
|
|
179
|
+
} catch { return newLedger(); }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function saveLedger(scanRoot, ledger) {
|
|
183
|
+
if (!(0,state_dir.ensureStateDir)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
184
|
+
const f = (0,state_dir.statePath)(scanRoot, DIR, 'ledger.json');
|
|
185
|
+
return { ok: (0,state_dir.safeWriteState)(f, JSON.stringify(ledger, null, 2)) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function saveIR(scanRoot, ir) {
|
|
189
|
+
if (!(0,state_dir.ensureStateDir)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
190
|
+
const f = external_node_path_.join(ruleDir(scanRoot, ir.id), 'ir.json');
|
|
191
|
+
return { ok: (0,state_dir.safeWriteState)(f, JSON.stringify(ir, null, 2)), file: f };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function loadIR(scanRoot, id) {
|
|
195
|
+
const f = external_node_path_.join(ruleDir(scanRoot, id), 'ir.json');
|
|
196
|
+
try { return JSON.parse(external_node_fs_.readFileSync(f, 'utf8')); } catch { return null; }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function saveJson(scanRoot, id, name, doc) {
|
|
200
|
+
if (!(0,state_dir.ensureStateDir)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
201
|
+
const f = external_node_path_.join(ruleDir(scanRoot, id), name);
|
|
202
|
+
return { ok: (0,state_dir.safeWriteState)(f, JSON.stringify(doc, null, 2)), file: f };
|
|
203
|
+
}
|
|
204
|
+
function loadJson(scanRoot, id, name) { try { return JSON.parse(external_node_fs_.readFileSync(external_node_path_.join(ruleDir(scanRoot, id), name), 'utf8')); } catch { return null; } }
|
|
205
|
+
/** The corpus a rule was synthesized on, kept so `rules evaluate <id>` needs no flag and the evidence is the same corpus. */
|
|
206
|
+
const saveCorpus = (r, id, spec) => saveJson(r, id, 'corpus.json', spec);
|
|
207
|
+
const loadCorpus = (r, id) => loadJson(r, id, 'corpus.json');
|
|
208
|
+
/** Every synthesis attempt: strategy, match, score and why it did not pass. */
|
|
209
|
+
const saveSynthesis = (r, id, syn) => saveJson(r, id, 'synthesis.json', syn);
|
|
210
|
+
const loadSynthesis = (r, id) => loadJson(r, id, 'synthesis.json');
|
|
211
|
+
/** The rule's provenance record (PRD 3 requirement 7). */
|
|
212
|
+
const saveProvenance = (r, id, doc) => saveJson(r, id, 'provenance.json', doc);
|
|
213
|
+
const loadProvenance = (r, id) => loadJson(r, id, 'provenance.json');
|
|
214
|
+
|
|
215
|
+
function saveEvaluation(scanRoot, id, evaluation) {
|
|
216
|
+
if (!(0,state_dir.ensureStateDir)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
217
|
+
const f = external_node_path_.join(ruleDir(scanRoot, id), 'evaluation.json');
|
|
218
|
+
return { ok: (0,state_dir.safeWriteState)(f, JSON.stringify(evaluation, null, 2)) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Write the regression fixtures a promoted rule needs: the SAME vulnerable/clean convention `runRuleTests`
|
|
222
|
+
* already reads, scored against the exact corpus the rule was promoted on. */
|
|
223
|
+
function writeFixtures(scanRoot, ir, corpus) {
|
|
224
|
+
const ext = EXT[ir.languages[0]] || 'txt';
|
|
225
|
+
const base = external_node_path_.join(ruleDir(scanRoot, ir.id), 'fixtures');
|
|
226
|
+
if (!(0,state_dir.ensureStateDir)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
227
|
+
const written = [];
|
|
228
|
+
for (const p of corpus.positives) {
|
|
229
|
+
const f = external_node_path_.join(base, 'vulnerable', `${p.label.replace(/[^a-z0-9_-]/gi, '_')}.${ext}`);
|
|
230
|
+
if ((0,state_dir.safeWriteState)(f, p.code)) written.push(f);
|
|
231
|
+
}
|
|
232
|
+
for (const n of corpus.negatives) {
|
|
233
|
+
const f = external_node_path_.join(base, 'clean', `${n.label.replace(/[^a-z0-9_-]/gi, '_')}.${ext}`);
|
|
234
|
+
if ((0,state_dir.safeWriteState)(f, n.code)) written.push(f);
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, written, glob: external_node_path_.join(base, '**', '*') };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Promote: write the compiled custom-rule document to the live rules directory. Does not touch the ledger —
|
|
240
|
+
* the caller records the PROMOTED transition itself, so a failed write here can never produce an inconsistent
|
|
241
|
+
* "promoted but not live" or "live but not recorded" state silently. */
|
|
242
|
+
function publishCustomRule(scanRoot, ir) {
|
|
243
|
+
const { ok, doc, errs } = (0,rule_compiler_ir/* compileToCustomRule */.Mo)(ir);
|
|
244
|
+
if (!ok) return { ok: false, reason: `invalid rule IR: ${errs.join('; ')}` };
|
|
245
|
+
if (!(0,state_dir.ensureStateDir)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
246
|
+
const f = (0,state_dir.statePath)(scanRoot, 'rules', `${ir.id}.yml`);
|
|
247
|
+
const yamlText = [
|
|
248
|
+
`# Compiled by the rule compiler from finding ${ir.provenance.originatingFindingId} (${ir.provenance.evidenceState}).`,
|
|
249
|
+
`- id: ${doc.id}`,
|
|
250
|
+
` title: ${JSON.stringify(doc.title)}`,
|
|
251
|
+
` severity: ${doc.severity}`,
|
|
252
|
+
` cwe: ${doc.cwe}`,
|
|
253
|
+
` languages: [${doc.languages.join(', ')}]`,
|
|
254
|
+
` match:`,
|
|
255
|
+
...(doc.match.pattern ? [` pattern: ${JSON.stringify(doc.match.pattern)}`] : []),
|
|
256
|
+
...(doc.match.allOf ? [` allOf:`, ...doc.match.allOf.map((p) => ` - ${JSON.stringify(p)}`), ` window: ${doc.match.window}`] : []),
|
|
257
|
+
...(doc.match.notMatch ? [` notMatch: ${JSON.stringify(doc.match.notMatch)}`] : []),
|
|
258
|
+
...(doc.match.fileRequires ? [` fileRequires:`, ...doc.match.fileRequires.map((p) => ` - ${JSON.stringify(p)}`)] : []),
|
|
259
|
+
...(doc.match.guardedBy ? [` guardedBy: ${JSON.stringify(doc.match.guardedBy)}`, ` guardWindow: ${doc.match.guardWindow}`] : []),
|
|
260
|
+
...(doc.match.excludeLine ? [` excludeLine:`, ...doc.match.excludeLine.map((p) => ` - ${JSON.stringify(p)}`)] : []),
|
|
261
|
+
` message: ${JSON.stringify(doc.message)}`,
|
|
262
|
+
'',
|
|
263
|
+
].join('\n');
|
|
264
|
+
return { ok: (0,state_dir.safeWriteState)(f, yamlText), file: f };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Sign a published rule with the operator's key (the same Ed25519 scheme `agentic-security-rule sign` uses). The key is never stored here. */
|
|
268
|
+
function signPublishedRule(file, privateKeyB64) {
|
|
269
|
+
try { (0,rule_pack_signing/* signRulePack */.pV)(file, privateKeyB64); return { ok: true }; } catch (e) { return { ok: false, reason: String(e.message).slice(0, 120) }; }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Will the scanner actually LOAD this promoted rule? Publishing a file is not the same as the scanner trusting it: rule packs are
|
|
274
|
+
* signature-checked (posture/rule-pack-signing.js) so that a rule dropped into a repository cannot run in someone's CI. A promoted rule
|
|
275
|
+
* the scanner would refuse is not a capability yet, so this is reported next to the lifecycle state instead of being assumed.
|
|
276
|
+
*/
|
|
277
|
+
function packTrust(scanRoot, id) {
|
|
278
|
+
const file = (0,state_dir.statePath)(scanRoot, 'rules', `${id}.yml`);
|
|
279
|
+
if (!external_node_fs_.existsSync(file)) return { live: false, reason: 'not published', remediation: null };
|
|
280
|
+
let r;
|
|
281
|
+
const origErr = console.error; console.error = () => {};
|
|
282
|
+
try { r = (0,rule_pack_signing/* verifyRulePack */.Ig)(file, (0,rule_pack_signing/* loadTrustedKeys */.CW)(scanRoot)); } finally { console.error = origErr; }
|
|
283
|
+
if (r.ok) return { live: true, via: r.passThrough ? 'signed, accepted in pass-through mode (no trusted keys are configured)' : `signed by ${r.keyId}`, reason: null, remediation: null };
|
|
284
|
+
if (r.reason === 'unsigned' && r.allowUnsigned) return { live: true, via: 'unsigned, accepted because AGENTIC_SECURITY_ALLOW_UNSIGNED_PACKS=1', reason: null, remediation: null };
|
|
285
|
+
const remediation = r.reason === 'unsigned'
|
|
286
|
+
? `sign it (agentic-security-rule sign ${external_node_path_.relative(process.cwd(), file) || file} with your key, or re-run promote with --sign-key <base64>), or opt in for this environment with AGENTIC_SECURITY_ALLOW_UNSIGNED_PACKS=1`
|
|
287
|
+
: (r.remediation ?? 'see the signing setup in bin/agentic-security-rule.js');
|
|
288
|
+
return { live: false, reason: r.reason, remediation };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function unpublishCustomRule(scanRoot, id) {
|
|
292
|
+
const f = (0,state_dir.statePath)(scanRoot, 'rules', `${id}.yml`);
|
|
293
|
+
try { for (const x of [f, `${f}.sig`]) if (external_node_fs_.existsSync(x)) external_node_fs_.unlinkSync(x); return { ok: true }; } catch (e) { return { ok: false, reason: e.message }; }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// EXTERNAL MODULE: ./src/posture/rule-compiler/corpus.js
|
|
297
|
+
var rule_compiler_corpus = __webpack_require__(43556);
|
|
298
|
+
// EXTERNAL MODULE: ./src/posture/rule-compiler/promotion.js
|
|
299
|
+
var promotion = __webpack_require__(57711);
|
|
300
|
+
// EXTERNAL MODULE: ./src/posture/custom-rules.js
|
|
301
|
+
var custom_rules = __webpack_require__(69400);
|
|
302
|
+
;// CONCATENATED MODULE: ./src/posture/rule-compiler/regression.js
|
|
303
|
+
// Regression integration for promoted rules (PRD 3, requirement 8): a promoted rule joins a corpus every later rule and every later
|
|
304
|
+
// run is held to, instead of being checked once at promotion and forgotten.
|
|
305
|
+
//
|
|
306
|
+
// shared false-positive corpus the SAFE code of every promoted rule (its patched, nearby-clean and protected variants) plus any file
|
|
307
|
+
// the operator drops in `.agentic-security/rule-compiler/fp-corpus/`. A new candidate must not fire
|
|
308
|
+
// on any of it, so rule N+1 cannot reintroduce the false positive rule N was built to avoid.
|
|
309
|
+
// regression run `rules regress` re-scores every PROMOTED rule against its own corpus, then against the shared false
|
|
310
|
+
// positive corpus, and reports any rule that no longer clears its gate (the matcher, the loader or
|
|
311
|
+
// another rule changed underneath it).
|
|
312
|
+
// The rule-specific unit tests are the fixtures written at evaluation (the existing `rule test` convention); this module adds the cross-rule
|
|
313
|
+
// part, which a per-rule check cannot see.
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
const FP_DIR = 'rule-compiler/fp-corpus';
|
|
325
|
+
const MAX_FP_FILE = 256 * 1024;
|
|
326
|
+
const MAX_FP_FILES = 500;
|
|
327
|
+
const EXT_OF = { '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.ts': 'typescript', '.py': 'python', '.go': 'go', '.rb': 'ruby', '.java': 'java', '.rs': 'rust', '.cs': 'csharp', '.php': 'php' };
|
|
328
|
+
|
|
329
|
+
/** Operator-supplied false positives (real code a rule should never flag). Plain files; symlinks and oversized files are ignored. */
|
|
330
|
+
function operatorFalsePositives(root) {
|
|
331
|
+
const dir = (0,state_dir.statePath)(root, ...FP_DIR.split('/'));
|
|
332
|
+
const out = [];
|
|
333
|
+
let names = [];
|
|
334
|
+
try { names = external_node_fs_.readdirSync(dir).sort(); } catch { return out; }
|
|
335
|
+
for (const n of names.slice(0, MAX_FP_FILES)) {
|
|
336
|
+
const f = external_node_path_.join(dir, n);
|
|
337
|
+
try {
|
|
338
|
+
const st = external_node_fs_.lstatSync(f);
|
|
339
|
+
if (!st.isFile() || st.isSymbolicLink() || st.size > MAX_FP_FILE) continue;
|
|
340
|
+
out.push({ label: `operator-fp:${n}`, file: n, language: EXT_OF[external_node_path_.extname(n).toLowerCase()] ?? 'any', code: external_node_fs_.readFileSync(f, 'utf8') });
|
|
341
|
+
} catch { /* unreadable: skipped */ }
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function promotedRuleIds(ledger) {
|
|
347
|
+
return [...new Set(ledger.entries.map((e) => e.ruleId))].filter((id) => stateOf(ledger, id) === 'PROMOTED');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The shared false-positive corpus as seen by `exceptId`: the safe code of every OTHER promoted rule, plus the operator's files. */
|
|
351
|
+
function sharedFalsePositiveCorpus(root, ledger, exceptId = null) {
|
|
352
|
+
const items = [];
|
|
353
|
+
for (const id of promotedRuleIds(ledger)) {
|
|
354
|
+
if (id === exceptId) continue;
|
|
355
|
+
const ir = loadIR(root, id); const spec = loadCorpus(root, id);
|
|
356
|
+
if (!ir || !spec || typeof spec.originalCode !== 'string') continue;
|
|
357
|
+
let corpus; try { corpus = (0,rule_compiler_corpus/* buildCorpus */.F)(ir, spec); } catch { continue; }
|
|
358
|
+
for (const n of corpus.negatives) items.push({ label: `rule:${id}:${n.label}`, language: ir.languages[0] ?? 'any', code: n.code });
|
|
359
|
+
}
|
|
360
|
+
return [...items, ...operatorFalsePositives(root)];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const FILE_FOR_LANG = { javascript: 'x.js', typescript: 'x.ts', python: 'x.py', go: 'x.go', ruby: 'x.rb', java: 'x.java', rust: 'x.rs', csharp: 'x.cs', php: 'x.php', any: 'x.txt' };
|
|
364
|
+
|
|
365
|
+
/** Which shared false-positive items does this rule fire on? A rule is only run against items written in a language it applies to. */
|
|
366
|
+
function falsePositiveHits(ir, items) {
|
|
367
|
+
const { ok, doc } = (0,rule_compiler_ir/* compileToCustomRule */.Mo)(ir);
|
|
368
|
+
if (!ok) return [{ label: '(rule does not compile)' }];
|
|
369
|
+
const rule = (0,custom_rules/* normalizeCustomRule */.Lj)(doc);
|
|
370
|
+
if (!rule) return [{ label: '(rule would be rejected by the loader)' }];
|
|
371
|
+
const hits = [];
|
|
372
|
+
for (const it of items) {
|
|
373
|
+
const lang = it.language && it.language !== 'any' ? it.language : (ir.languages[0] ?? 'any');
|
|
374
|
+
if (!ir.languages.includes('any') && !ir.languages.includes(lang)) continue;
|
|
375
|
+
if ((0,custom_rules/* runRule */.OA)(rule, FILE_FOR_LANG[lang] ?? 'x.txt', it.code).length > 0) hits.push({ label: it.label });
|
|
376
|
+
}
|
|
377
|
+
return hits;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Re-score every promoted rule (or one) against its own corpus and the shared false-positive corpus. */
|
|
381
|
+
function regressRules(root, ledger, { only = null, thresholds } = {}) {
|
|
382
|
+
const results = [];
|
|
383
|
+
for (const id of promotedRuleIds(ledger)) {
|
|
384
|
+
if (only && id !== only) continue;
|
|
385
|
+
const ir = loadIR(root, id); const spec = loadCorpus(root, id);
|
|
386
|
+
if (!ir || !spec || typeof spec.originalCode !== 'string') { results.push({ id, status: 'unverifiable', reasons: [!ir ? 'the rule IR is missing' : 'no evaluated corpus was kept for this rule (run rules evaluate --corpus again)'] }); continue; }
|
|
387
|
+
let ev; let corpus;
|
|
388
|
+
try { corpus = (0,rule_compiler_corpus/* buildCorpus */.F)(ir, spec); ev = (0,promotion/* evaluateRule */.kx)(ir, corpus, thresholds); } catch (e) { results.push({ id, status: 'broken', reasons: [`cannot be scored: ${String(e.message).slice(0, 120)}`] }); continue; }
|
|
389
|
+
const fp = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
|
|
390
|
+
const baseline = loadProvenance(root, id)?.benchmarkResults ?? null;
|
|
391
|
+
const reasons = [...ev.reasons];
|
|
392
|
+
if (fp.length) reasons.push(`fires on ${fp.length} item(s) of the shared false-positive corpus: ${fp.slice(0, 3).map((h) => h.label).join(', ')}${fp.length > 3 ? ', ...' : ''}`);
|
|
393
|
+
if (baseline && ev.score.recall != null && baseline.recall != null && ev.score.recall < baseline.recall) reasons.push(`recall fell from ${(baseline.recall * 100).toFixed(1)}% to ${(ev.score.recall * 100).toFixed(1)}% since promotion`);
|
|
394
|
+
results.push({ id, status: reasons.length ? 'regressed' : 'ok', reasons, precision: ev.score.precision, recall: ev.score.recall, protectedFailures: ev.score.protectedFailures, fpCorpusHits: fp.length });
|
|
395
|
+
}
|
|
396
|
+
return results;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// EXTERNAL MODULE: ./src/posture/proof-fabric/states.js
|
|
400
|
+
var states = __webpack_require__(12090);
|
|
401
|
+
;// CONCATENATED MODULE: ./src/posture/rule-compiler/eligibility.js
|
|
402
|
+
// X-eligibility (PRD 3 "Eligibility"): a finding may enter synthesis only with a stable id, a vulnerable span, a
|
|
403
|
+
// CWE/family, code-flow evidence, and deterministic corroboration OR exploit proof. Reuses the existing
|
|
404
|
+
// vocabularies rather than inventing a third: proof-fabric's evidence-state ladder when present, else the older
|
|
405
|
+
// proof-tier (`taint-proven`/`execution-proven`), else the finding's own `parser` (an IR-TAINT/MULTI-SINK parser
|
|
406
|
+
// origin is code-flow evidence even with no execution).
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
const ANALYSED_PARSERS = new Set(['IR-TAINT', 'MULTI-SINK']);
|
|
410
|
+
|
|
411
|
+
function checkEligibility(finding, { proofState = null } = {}) {
|
|
412
|
+
const reasons = [];
|
|
413
|
+
if (!finding?.stableId && !finding?.id) reasons.push('no stable id');
|
|
414
|
+
if (finding?.file == null || finding?.line == null) reasons.push('no vulnerable span (file:line)');
|
|
415
|
+
if (!finding?.cwe && !finding?.family) reasons.push('no CWE or family');
|
|
416
|
+
const hasFlowEvidence = ANALYSED_PARSERS.has(finding?.parser) || !!finding?.snippet;
|
|
417
|
+
if (!hasFlowEvidence) reasons.push('no code-flow evidence (parser not IR-TAINT/MULTI-SINK, no snippet)');
|
|
418
|
+
|
|
419
|
+
let evidenceState = 'none';
|
|
420
|
+
if (proofState && (0,states/* stateRank */.g3)(proofState) >= (0,states/* stateRank */.g3)('EXPLOIT_CONFIRMED')) evidenceState = 'execution-proven';
|
|
421
|
+
else if (finding?.proofTier === 'execution-proven') evidenceState = 'execution-proven';
|
|
422
|
+
else if (finding?.proofTier === 'taint-proven' || ANALYSED_PARSERS.has(finding?.parser)) evidenceState = 'taint-proven';
|
|
423
|
+
if (evidenceState === 'none') reasons.push('no deterministic corroboration or exploit proof');
|
|
424
|
+
|
|
425
|
+
return { eligible: reasons.length === 0, reasons, evidenceState };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// EXTERNAL MODULE: ./src/posture/rule-compiler/proposal.js
|
|
429
|
+
var proposal = __webpack_require__(47838);
|
|
430
|
+
// EXTERNAL MODULE: ./src/posture/rule-compiler/mutation.js
|
|
431
|
+
var mutation = __webpack_require__(85127);
|
|
432
|
+
// EXTERNAL MODULE: ./src/posture/proof-fabric/reproducer.js
|
|
433
|
+
var reproducer = __webpack_require__(37055);
|
|
434
|
+
;// CONCATENATED MODULE: ./src/posture/rule-compiler/cli.js
|
|
435
|
+
// CLI surface: `agentic-security rules synthesize|evaluate|promote|status|rollback|cost|regress`.
|
|
436
|
+
//
|
|
437
|
+
// Distinct from the pre-existing `labs synthesize-rule` (posture/rule-synthesis.js), which proposes SUPPRESSION
|
|
438
|
+
// rules from repeated false-positive triage verdicts — a different input, a different output and a different
|
|
439
|
+
// command group on purpose, so the two are never confused at the CLI.
|
|
440
|
+
//
|
|
441
|
+
// Non-interactive by construction: no command reads stdin, every corpus evaluation is bounded (no network, no
|
|
442
|
+
// model call — this is the deterministic half; a model may PROPOSE the candidate pattern this CLI is handed, but
|
|
443
|
+
// it never decides promotion).
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
function loadFinding(root, id) {
|
|
459
|
+
const p = (0,state_dir.statePath)(root, 'last-scan.json');
|
|
460
|
+
if (!external_node_fs_.existsSync(p)) return null;
|
|
461
|
+
const last = JSON.parse(external_node_fs_.readFileSync(p, 'utf8'));
|
|
462
|
+
return (last.findings || []).find((f) => f.id === id || f.stableId === id) ?? null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function readJsonArg(pathOrInline) {
|
|
466
|
+
if (!pathOrInline) return null;
|
|
467
|
+
try { return JSON.parse(external_node_fs_.existsSync(pathOrInline) ? external_node_fs_.readFileSync(pathOrInline, 'utf8') : pathOrInline); } catch { return null; }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function cmdRules(args, root) {
|
|
471
|
+
const sub = args._[1];
|
|
472
|
+
if (sub === 'synthesize') return cmdSynthesize(args, root);
|
|
473
|
+
if (sub === 'evaluate') return cmdEvaluate(args, root);
|
|
474
|
+
if (sub === 'promote') return cmdPromote(args, root);
|
|
475
|
+
if (sub === 'status') return cmdStatus(args, root);
|
|
476
|
+
if (sub === 'rollback') return cmdRollback(args, root);
|
|
477
|
+
if (sub === 'cost') return cmdCost(args, root);
|
|
478
|
+
if (sub === 'regress') return cmdRegress(args, root);
|
|
479
|
+
console.error('usage: rules synthesize <finding-id> --spec <file.json> [--auto] [--max-attempts N] | evaluate <rule-id> | promote <rule-id> | status [rule-id] | rollback <rule-id> | cost <rule-id>');
|
|
480
|
+
return 4;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// `--spec` carries exactly what a model or a human proposed: id/title/severity/cwe/family/languages/match/
|
|
484
|
+
// remediation plus the corpus inputs (originalCode/patchedCode/nearbyCleanCode/protectedVariantsCode). This
|
|
485
|
+
// command never invents a pattern; it only drafts the IR, checks eligibility and records DRAFT->CANDIDATE.
|
|
486
|
+
async function cmdSynthesize(args, root) {
|
|
487
|
+
const findingId = args._[2];
|
|
488
|
+
const spec = readJsonArg(args.flags.spec);
|
|
489
|
+
if (!findingId || !spec) { console.error('usage: rules synthesize <finding-id> --spec <file.json>'); return 4; }
|
|
490
|
+
const finding = loadFinding(root, findingId);
|
|
491
|
+
if (!finding) { console.error(`Finding ${findingId} not found in last scan.`); return 4; }
|
|
492
|
+
const elig = checkEligibility(finding, { proofState: args.flags['proof-state'] || null });
|
|
493
|
+
if (!elig.eligible) { console.error(`Not eligible for rule synthesis: ${elig.reasons.join('; ')}`); return 4; }
|
|
494
|
+
|
|
495
|
+
// The candidate pattern is the model's or the person's, when they gave one. Without one, or with --auto, the synthesis loop proposes
|
|
496
|
+
// from the vulnerable and patched code, scores each attempt on the real corpus and its mutations, and revises (see proposal.js).
|
|
497
|
+
let finalSpec = spec; let synthesis = null;
|
|
498
|
+
const corpusSpec = { originalCode: spec.originalCode, patchedCode: spec.patchedCode ?? null, nearbyCleanCode: spec.nearbyCleanCode ?? [], protectedVariantsCode: spec.protectedVariantsCode ?? [] };
|
|
499
|
+
const leak = (0,promotion/* checkLeakage */.r_)(JSON.stringify(corpusSpec), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: spec.fileNames ?? [] });
|
|
500
|
+
if (!leak.ok) { console.error(`Refusing: the material for this rule shows benchmark or answer-key signals (${leak.offendingTerms.join('; ')}). Nothing was written.`); return 4; }
|
|
501
|
+
if (args.flags.auto || (!spec.match && !spec.sink)) {
|
|
502
|
+
if (!isStr(spec.originalCode)) { console.error('Automatic proposal needs originalCode (the vulnerable snippet) in the spec.'); return 4; }
|
|
503
|
+
const { id, title, severity, cwe, family, languages, remediation } = spec;
|
|
504
|
+
synthesis = (0,proposal/* synthesizeCandidate */._7)({ base: { id, title, severity, cwe, family, languages, remediation, originatingFindingId: finding.stableId ?? finding.id, evidenceState: elig.evidenceState }, corpusSpec, proposed: spec.match ?? null, maxAttempts: Number(args.flags['max-attempts']) || 6, leakageOk: true });
|
|
505
|
+
for (const a of synthesis.attempts) console.log(` attempt ${a.n}: ${a.strategy.padEnd(34)} ${a.valid ? `precision ${fmtPct(a.score.precision)} recall ${fmtPct(a.score.recall)} protected-failures ${a.score.protectedFailures}${a.pass ? ' PASS' : ''}` : `invalid (${a.errors[0]})`}`);
|
|
506
|
+
if (!synthesis.best) { console.error('No valid candidate could be proposed from this material.'); return 1; }
|
|
507
|
+
finalSpec = { ...spec, ...(synthesis.best.match ? { match: synthesis.best.match } : {}), ...(synthesis.best.structure ?? {}) };
|
|
508
|
+
console.log(synthesis.best.pass ? ` candidate meets the promotion gate on this corpus (not yet recorded as VALIDATED).` : ` no attempt met the gate; the best is kept as a CANDIDATE only: ${synthesis.best.reasons.join('; ')}`);
|
|
509
|
+
}
|
|
510
|
+
const ir = (0,rule_compiler_ir/* draftRuleIR */.pc)({ ...finalSpec, originatingFindingId: finding.stableId ?? finding.id, originatingCommit: spec.originatingCommit ?? null, evidenceState: elig.evidenceState });
|
|
511
|
+
const errs = (0,rule_compiler_ir/* validateRuleIR */.g)(ir);
|
|
512
|
+
if (errs.length) { console.error(`Invalid rule: ${errs.join('; ')}`); return 4; }
|
|
513
|
+
|
|
514
|
+
const ledger = loadLedger(root);
|
|
515
|
+
const draft = recordTransition(ledger, { ruleId: ir.id, from: null, to: 'DRAFT', reason: `drafted from ${findingId}`, actor: args.flags.actor || 'agent' });
|
|
516
|
+
if (!draft.ok) { console.error(draft.reason); return 4; }
|
|
517
|
+
const cand = recordTransition(ledger, { ruleId: ir.id, from: 'DRAFT', to: 'CANDIDATE', reason: 'eligibility passed', evidence: { eligibility: elig }, actor: args.flags.actor || 'agent' });
|
|
518
|
+
saveLedger(root, ledger);
|
|
519
|
+
saveIR(root, { ...ir, state: 'CANDIDATE' });
|
|
520
|
+
saveCorpus(root, ir.id, { ...corpusSpec, fileNames: spec.fileNames ?? [], discoveryCost: spec.discoveryCost ?? (args.flags['discovery-cost-usd'] ? { usd: Number(args.flags['discovery-cost-usd']), tokens: Number(args.flags['discovery-tokens']) || null, model: spec.model ?? null } : null) });
|
|
521
|
+
if (synthesis) saveSynthesis(root, ir.id, { attempts: synthesis.attempts, best: synthesis.best });
|
|
522
|
+
console.log(`Rule ${ir.id}: DRAFT -> CANDIDATE (digest ${(0,rule_compiler_ir/* ruleIRDigest */.GE)(ir).slice(0, 12)})`);
|
|
523
|
+
if (!cand.ok) console.error(cand.reason);
|
|
524
|
+
return 0;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function cmdEvaluate(args, root) {
|
|
528
|
+
const id = args._[2];
|
|
529
|
+
if (!id) { console.error('usage: rules evaluate <rule-id> --corpus <file.json>'); return 4; }
|
|
530
|
+
const ir = loadIR(root, id);
|
|
531
|
+
if (!ir) { console.error(`No rule IR found for ${id}. Run rules synthesize first.`); return 4; }
|
|
532
|
+
const corpusSpec = readJsonArg(args.flags.corpus) ?? loadCorpus(root, id);
|
|
533
|
+
if (!corpusSpec) { console.error('No corpus supplied. Pass --corpus <file.json> with originalCode (and optionally patchedCode/nearbyCleanCode/protectedVariantsCode).'); return 4; }
|
|
534
|
+
const corpus = (0,rule_compiler_corpus/* buildCorpus */.F)(ir, corpusSpec);
|
|
535
|
+
// the corpus a rule is evaluated on is the evidence for it: keep it, so `rules regress` and the shared false-positive corpus use the SAME code
|
|
536
|
+
if (args.flags.corpus) saveCorpus(root, id, { ...(loadCorpus(root, id) ?? {}), ...corpusSpec });
|
|
537
|
+
const leakage = (0,promotion/* checkLeakage */.r_)(JSON.stringify({ ...corpusSpec, fileNames: undefined, discoveryCost: undefined }), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: corpusSpec.fileNames ?? [] });
|
|
538
|
+
const gate = (0,promotion/* gatePromotion */.z)({ ir, corpus, leakageCheck: leakage, thresholds: args.flags.thresholds ? readJsonArg(args.flags.thresholds) : promotion/* PROMOTION_DEFAULTS */.V2 });
|
|
539
|
+
|
|
540
|
+
const ledger = loadLedger(root);
|
|
541
|
+
// the shared false-positive corpus: the safe code of every other promoted rule, plus the operator's own files. Part of the gate.
|
|
542
|
+
const fpItems = sharedFalsePositiveCorpus(root, ledger, id);
|
|
543
|
+
const fpHits = falsePositiveHits(ir, fpItems);
|
|
544
|
+
gate.fpCorpus = { items: fpItems.length, hits: fpHits.map((h) => h.label) };
|
|
545
|
+
if (fpHits.length) { gate.pass = false; gate.reasons.push(`fires on ${fpHits.length} of ${fpItems.length} item(s) in the shared false-positive corpus: ${fpHits.slice(0, 3).map((h) => h.label).join(', ')}${fpHits.length > 3 ? ', ...' : ''}`); }
|
|
546
|
+
const cur = stateOf(ledger, id);
|
|
547
|
+
if (gate.pass && cur === 'CANDIDATE') recordTransition(ledger, { ruleId: id, from: 'CANDIDATE', to: 'VALIDATED', reason: 'promotion gate passed', evidence: { score: gate.evaluation.score }, actor: args.flags.actor || 'agent' });
|
|
548
|
+
saveLedger(root, ledger);
|
|
549
|
+
saveEvaluation(root, id, gate);
|
|
550
|
+
const corpusHash = (list) => (0,reproducer/* sha256 */.sc)(JSON.stringify(list.map((x) => [x.label, x.code]).sort()));
|
|
551
|
+
const prev = loadProvenance(root, id);
|
|
552
|
+
saveProvenance(root, id, {
|
|
553
|
+
schema: 'agentic-security/rule-provenance', version: 1, ruleId: id, ruleVersion: 1, ruleHash: (0,rule_compiler_ir/* ruleIRDigest */.GE)(ir),
|
|
554
|
+
originatingFinding: ir.provenance.originatingFindingId, originatingCommit: ir.provenance.originatingCommit, evidenceState: ir.provenance.evidenceState,
|
|
555
|
+
generatingModel: ir.provenance.model, promptConfigVersion: ir.provenance.promptConfigVersion,
|
|
556
|
+
positiveCorpusHash: corpusHash(corpus.positives), negativeCorpusHash: corpusHash(corpus.negatives),
|
|
557
|
+
benchmarkResults: { precision: gate.evaluation.score.precision, recall: gate.evaluation.score.recall, tp: gate.evaluation.score.tp, fp: gate.evaluation.score.fp, fn: gate.evaluation.score.fn, tn: gate.evaluation.score.tn, protectedFailures: gate.evaluation.score.protectedFailures, thresholds: gate.evaluation.thresholds, gate: gate.pass ? 'passed' : 'failed', reasons: gate.reasons },
|
|
558
|
+
leakageCheck: leakage, mutatorCoverage: (0,mutation/* mutatorCoverage */.xv)(corpusSpec.originalCode), discoveryCost: corpusSpec.discoveryCost ?? prev?.discoveryCost ?? null,
|
|
559
|
+
promotionEvent: prev?.promotionEvent ?? null, evaluatedAt: new Date().toISOString(),
|
|
560
|
+
});
|
|
561
|
+
if (args.flags.json) console.log(JSON.stringify(gate, null, 2));
|
|
562
|
+
else {
|
|
563
|
+
console.log(`Rule ${id}: ${gate.pass ? 'VALIDATED' : 'gate FAILED'}`);
|
|
564
|
+
console.log(` precision ${fmtPct(gate.evaluation.score.precision)} recall ${fmtPct(gate.evaluation.score.recall)} protected-failures ${gate.evaluation.score.protectedFailures}`);
|
|
565
|
+
for (const r of gate.reasons) console.log(` - ${r}`);
|
|
566
|
+
}
|
|
567
|
+
if (gate.pass) { const f = writeFixtures(root, ir, corpus); if (f.ok) console.log(` regression fixtures written: ${f.written.length} file(s) under .agentic-security/rule-compiler/${id}/fixtures/`); }
|
|
568
|
+
return gate.pass ? 0 : 1;
|
|
569
|
+
}
|
|
570
|
+
function isStr(v) { return typeof v === 'string' && v.length > 0; }
|
|
571
|
+
const fmtPct = (n) => (n == null ? 'unmeasured' : `${(n * 100).toFixed(1)}%`);
|
|
572
|
+
|
|
573
|
+
async function cmdPromote(args, root) {
|
|
574
|
+
const id = args._[2];
|
|
575
|
+
if (!id) { console.error('usage: rules promote <rule-id>'); return 4; }
|
|
576
|
+
const ledger = loadLedger(root);
|
|
577
|
+
if (ledger.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${ledger.tamperReason}).`); return 4; }
|
|
578
|
+
if (stateOf(ledger, id) !== 'VALIDATED') { console.error(`Rule ${id} is not VALIDATED (run rules evaluate first); refusing to promote.`); return 4; }
|
|
579
|
+
const ir = loadIR(root, id);
|
|
580
|
+
if (!ir) { console.error(`No rule IR found for ${id}.`); return 4; }
|
|
581
|
+
// other rules may have been promoted since this one was evaluated: hold it to the false-positive corpus as it is NOW
|
|
582
|
+
const fpNow = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
|
|
583
|
+
if (fpNow.length) { console.error(`Promotion refused: ${id} fires on ${fpNow.length} item(s) of the shared false-positive corpus (${fpNow.slice(0, 3).map((h) => h.label).join(', ')}). Re-evaluate or narrow the rule.`); return 1; }
|
|
584
|
+
const pub = publishCustomRule(root, ir);
|
|
585
|
+
if (!pub.ok) { console.error(`Promotion refused: ${pub.reason}`); return 4; }
|
|
586
|
+
const t = recordTransition(ledger, { ruleId: id, from: 'VALIDATED', to: 'PROMOTED', reason: args.flags.reason || 'promoted', actor: args.flags.actor || 'operator' });
|
|
587
|
+
saveLedger(root, ledger);
|
|
588
|
+
const prov = loadProvenance(root, id);
|
|
589
|
+
if (prov) saveProvenance(root, id, { ...prov, promotionEvent: { at: new Date().toISOString(), actor: args.flags.actor || 'operator', reason: args.flags.reason || 'promoted', ruleHash: (0,rule_compiler_ir/* ruleIRDigest */.GE)(ir) } });
|
|
590
|
+
console.log(`Rule ${id}: VALIDATED -> PROMOTED. Published to ${pub.file}.`);
|
|
591
|
+
// Publishing is not trusting. Rule packs are signature-checked so a rule dropped into a repository cannot run in someone's CI; sign this
|
|
592
|
+
// one with the operator's key when there is one, and say plainly whether the scanner will load it.
|
|
593
|
+
const key = args.flags['sign-key'] ?? process.env.AGENTIC_SECURITY_PRIVATE_KEY;
|
|
594
|
+
if (key) { const sg = signPublishedRule(pub.file, key); console.log(sg.ok ? ` signed: ${pub.file}.sig` : ` could not sign (${sg.reason}); the rule is published unsigned`); }
|
|
595
|
+
const trust = packTrust(root, id);
|
|
596
|
+
console.log(trust.live ? ` The scanner will load it (${trust.via}).` : ` NOT LIVE YET: the scanner will refuse this rule (${trust.reason}). To make it live: ${trust.remediation}.`);
|
|
597
|
+
if (!t.ok) console.error(t.reason);
|
|
598
|
+
return 0;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** `rules regress [rule-id]`: re-score every PROMOTED rule against its own corpus and the shared false-positive corpus. Exit 1 on any regression. */
|
|
602
|
+
async function cmdRegress(args, root) {
|
|
603
|
+
const ledger = loadLedger(root);
|
|
604
|
+
if (ledger.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${ledger.tamperReason}).`); return 4; }
|
|
605
|
+
const only = args._[2] ?? null;
|
|
606
|
+
const results = regressRules(root, ledger, { only, thresholds: args.flags.thresholds ? readJsonArg(args.flags.thresholds) : undefined });
|
|
607
|
+
if (args.flags.json) console.log(JSON.stringify({ results }, null, 2));
|
|
608
|
+
else if (!results.length) console.log(only ? `No promoted rule ${only}.` : 'No rule is PROMOTED; nothing to regress.');
|
|
609
|
+
else for (const r of results) { console.log(`${r.id}: ${r.status.toUpperCase()}${r.precision != null ? ` precision ${fmtPct(r.precision)} recall ${fmtPct(r.recall)} false-positive corpus hits ${r.fpCorpusHits}` : ''}`); for (const m of r.reasons) console.log(` - ${m}`); }
|
|
610
|
+
if (only && !results.length) return 4;
|
|
611
|
+
return results.every((r) => r.status === 'ok') ? 0 : 1;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function cmdRollback(args, root) {
|
|
615
|
+
const id = args._[2];
|
|
616
|
+
if (!id) { console.error('usage: rules rollback <rule-id> [--reason text]'); return 4; }
|
|
617
|
+
const ledger = loadLedger(root);
|
|
618
|
+
const cur = stateOf(ledger, id);
|
|
619
|
+
if (cur !== 'PROMOTED') { console.error(`Rule ${id} is not PROMOTED (current: ${cur ?? 'unknown'}); nothing to roll back.`); return 4; }
|
|
620
|
+
const un = unpublishCustomRule(root, id);
|
|
621
|
+
const t = recordTransition(ledger, { ruleId: id, from: 'PROMOTED', to: 'ROLLED_BACK', reason: args.flags.reason || 'operator rollback', actor: args.flags.actor || 'operator' });
|
|
622
|
+
saveLedger(root, ledger);
|
|
623
|
+
console.log(`Rule ${id}: PROMOTED -> ROLLED_BACK.${un.ok ? '' : ` (warning: could not remove published rule file: ${un.reason})`}`);
|
|
624
|
+
return t.ok ? 0 : 1;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async function cmdStatus(args, root) {
|
|
628
|
+
const ledger = loadLedger(root);
|
|
629
|
+
const id = args._[2];
|
|
630
|
+
if (id) {
|
|
631
|
+
const h = historyFor(ledger, id);
|
|
632
|
+
if (!h.length) { console.error(`No history for rule ${id}.`); return 4; }
|
|
633
|
+
if (args.flags.json) console.log(JSON.stringify(h, null, 2));
|
|
634
|
+
else { console.log(`Rule ${id}: ${stateOf(ledger, id)}`);
|
|
635
|
+
if (stateOf(ledger, id) === 'PROMOTED') { const tr = packTrust(root, id); console.log(tr.live ? ` live: yes (${tr.via})` : ` live: NO, the scanner refuses it (${tr.reason}). ${tr.remediation}.`); }
|
|
636
|
+
for (const e of h) console.log(` ${e.at} ${e.from ?? '(new)'} -> ${e.to} (${e.reason || ''})`);
|
|
637
|
+
const syn = loadSynthesis(root, id);
|
|
638
|
+
if (syn?.attempts?.length) { console.log(` synthesis: ${syn.attempts.length} attempt(s), best ${syn.best ? `${syn.best.strategy}${syn.best.pass ? ' (met the gate)' : ' (did not meet the gate)'}` : 'none'}`); }
|
|
639
|
+
}
|
|
640
|
+
return 0;
|
|
641
|
+
}
|
|
642
|
+
const ids = [...new Set(ledger.entries.map((e) => e.ruleId))];
|
|
643
|
+
if (args.flags.json) { console.log(JSON.stringify(ids.map((rid) => ({ id: rid, state: stateOf(ledger, rid), ...(stateOf(ledger, rid) === 'PROMOTED' ? { live: packTrust(root, rid).live } : {}) })), null, 2)); return 0; }
|
|
644
|
+
if (!ids.length) { console.log('No rules in the lifecycle ledger yet.'); return 0; }
|
|
645
|
+
for (const rid of ids) console.log(` ${rid.padEnd(40)} ${stateOf(ledger, rid)}${stateOf(ledger, rid) === 'PROMOTED' && !packTrust(root, rid).live ? ' (NOT LIVE: the scanner refuses it)' : ''}`);
|
|
646
|
+
return 0;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// PRD 3 business metric: what the first, model-assisted discovery cost, against what every later deterministic detection costs.
|
|
650
|
+
// The discovery cost is whatever the agent run reported for it (recorded at synthesis); a deterministic detection makes no model
|
|
651
|
+
// call and is $0 of model spend. The count of later detections is read from the LAST scan only, so it is a floor, not a history.
|
|
652
|
+
async function cmdCost(args, root) {
|
|
653
|
+
const id = args._[2];
|
|
654
|
+
if (!id) { console.error('usage: rules cost <rule-id>'); return 4; }
|
|
655
|
+
const prov = loadProvenance(root, id);
|
|
656
|
+
if (!prov) { console.error(`No provenance for ${id}. Run rules evaluate first.`); return 4; }
|
|
657
|
+
let detections = null;
|
|
658
|
+
try { const last = JSON.parse(external_node_fs_.readFileSync((0,state_dir.statePath)(root, 'last-scan.json'), 'utf8')); detections = (last.findings || []).filter((f) => String(f.id || '').startsWith(`custom:rule-compiler/${id}:`)).length; } catch { /* no scan */ }
|
|
659
|
+
const first = prov.discoveryCost;
|
|
660
|
+
const out = { ruleId: id, firstDiscovery: first ? { usd: first.usd ?? null, tokens: first.tokens ?? null, model: first.model ?? null } : null, laterDeterministicDetections: detections, laterDeterministicModelUsd: 0, state: prov.promotionEvent ? 'promoted' : 'not promoted' };
|
|
661
|
+
if (args.flags.json) { console.log(JSON.stringify(out, null, 2)); return 0; }
|
|
662
|
+
console.log(`Rule ${id} (${out.state})`);
|
|
663
|
+
console.log(first ? ` first discovery: ${first.usd != null ? `$${first.usd}` : 'cost not recorded'}${first.tokens ? `, ${first.tokens} tokens` : ''}${first.model ? `, ${first.model}` : ''}` : ' first discovery: cost was not recorded for this rule');
|
|
664
|
+
console.log(` later detections by this rule in the last scan: ${detections ?? 'no scan available'}, each at $0 of model spend`);
|
|
665
|
+
if (first?.usd != null && detections) console.log(` discovery cost spread over ${detections + 1} detection(s): $${(first.usd / (detections + 1)).toFixed(4)} each, falling with every further one`);
|
|
666
|
+
return 0;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
/***/ })
|
|
671
|
+
|
|
672
|
+
};
|