@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
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// controller): scanning a repository for the fleet must not change that repository.
|
|
9
9
|
import { runFleet } from '../fleet.js';
|
|
10
10
|
import { runScan } from '../../runScan.js';
|
|
11
|
+
import { summarizeLineage } from './lineage-summary.js';
|
|
11
12
|
|
|
12
13
|
export const BEGIN = '@@FLEET-SCAN-BEGIN@@';
|
|
13
14
|
export const END = '@@FLEET-SCAN-END@@';
|
|
@@ -21,7 +22,9 @@ async function main(repoPath) {
|
|
|
21
22
|
const entry = res.results?.[0];
|
|
22
23
|
if (!entry) throw new Error('the scan produced no entry');
|
|
23
24
|
const findings = (captured?.findings ?? []).slice(0, MAX_FINDINGS).map(slim);
|
|
24
|
-
|
|
25
|
+
// the Code Boundaries graph exists only when the controller asked for it (AGENTIC_SECURITY_LINEAGE_DEEP=1); it is summarized, never copied whole
|
|
26
|
+
const lineage = process.env.AGENTIC_SECURITY_LINEAGE_DEEP === '1' ? summarizeLineage(captured?.lineageGraph) : null;
|
|
27
|
+
process.stdout.write(`${BEGIN}${JSON.stringify({ entry, findings, findingsTruncated: (captured?.findings?.length ?? 0) > MAX_FINDINGS, lineage })}${END}`);
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
|
|
@@ -127,7 +127,8 @@ const P = new Proxy({}, { get: (_t, k) => (k === Symbol.toPrimitive ? () => payl
|
|
|
127
127
|
const res = new Proxy(function () {}, { get: () => res, apply: () => res });
|
|
128
128
|
const fn = ${pick};
|
|
129
129
|
if (typeof fn !== 'function') { console.error('the exported value is not a function'); process.exit(3); }
|
|
130
|
-
try { await Promise.race([Promise.resolve(fn(${args.join(', ')})), new Promise((r) => setTimeout(r, 5000))]); } catch (e) { console.error('target threw:', String(e && e.message).slice(0, 200)); }
|
|
130
|
+
let timer; try { await Promise.race([Promise.resolve(fn(${args.join(', ')})), new Promise((r) => { timer = setTimeout(r, 5000); })]); } catch (e) { console.error('target threw:', String(e && e.message).slice(0, 200)); }
|
|
131
|
+
clearTimeout(timer);
|
|
131
132
|
`;
|
|
132
133
|
}
|
|
133
134
|
|
|
@@ -29,11 +29,33 @@ const CONTAINER_IMAGES = Object.freeze({
|
|
|
29
29
|
|
|
30
30
|
const SIGNALS = { 134: 'SIGABRT', 135: 'SIGBUS', 136: 'SIGFPE', 132: 'SIGILL', 139: 'SIGSEGV' };
|
|
31
31
|
|
|
32
|
+
// only language toolchains are probed; the container engine has its own, richer health check (`container.js`)
|
|
33
|
+
const PROBED_TOOLS = new Set(['java', 'javac', 'python3', 'cc', 'gcc', 'dotnet']);
|
|
34
|
+
const probeCache = new Map();
|
|
35
|
+
/** A launcher that exists is not a toolchain that works: macOS ships /usr/bin/java and /usr/bin/python3 as stubs that only prompt for an
|
|
36
|
+
* install. A tool counts only if it answers its version flag, within a short timeout, with exit 0. */
|
|
37
|
+
function toolWorks(file, name) {
|
|
38
|
+
if (probeCache.has(file)) return probeCache.get(file);
|
|
39
|
+
const args = name === 'java' || name === 'javac' ? ['-version'] : ['--version'];
|
|
40
|
+
let ok = false;
|
|
41
|
+
try { const r = spawnSync(file, args, { stdio: 'ignore', timeout: 8000 }); ok = r.status === 0; } catch { ok = false; }
|
|
42
|
+
probeCache.set(file, ok);
|
|
43
|
+
return ok;
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
function findTool(name, pathEnv = process.env.PATH || '') {
|
|
33
|
-
|
|
47
|
+
const dirs = pathEnv.split(path.delimiter);
|
|
48
|
+
// a configured JDK wins over whatever stub sits on PATH
|
|
49
|
+
if ((name === 'java' || name === 'javac') && process.env.JAVA_HOME) dirs.unshift(path.join(process.env.JAVA_HOME, 'bin'));
|
|
50
|
+
for (const dir of dirs) {
|
|
34
51
|
if (!dir) continue;
|
|
35
52
|
const p = path.join(dir, name);
|
|
36
|
-
try {
|
|
53
|
+
try {
|
|
54
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
55
|
+
if (!fs.statSync(p).isFile()) continue;
|
|
56
|
+
const real = fs.realpathSync(p);
|
|
57
|
+
if (!PROBED_TOOLS.has(name) || toolWorks(real, name)) return real;
|
|
58
|
+
} catch { /* next */ }
|
|
37
59
|
}
|
|
38
60
|
return null;
|
|
39
61
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// CLI surface: `agentic-security rules synthesize|evaluate|promote|status|rollback`.
|
|
1
|
+
// CLI surface: `agentic-security rules synthesize|evaluate|promote|status|rollback|cost|regress`.
|
|
2
2
|
//
|
|
3
3
|
// Distinct from the pre-existing `labs synthesize-rule` (posture/rule-synthesis.js), which proposes SUPPRESSION
|
|
4
4
|
// rules from repeated false-positive triage verdicts — a different input, a different output and a different
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// it never decides promotion).
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import { statePath } from '../state-dir.js';
|
|
12
|
+
import { packTrust, signPublishedRule } from './store.js';
|
|
13
|
+
import { sharedFalsePositiveCorpus, falsePositiveHits, regressRules } from './regression.js';
|
|
12
14
|
import { draftRuleIR, validateRuleIR, ruleIRDigest } from './ir.js';
|
|
13
15
|
import { checkEligibility } from './eligibility.js';
|
|
14
16
|
import { buildCorpus } from './corpus.js';
|
|
@@ -39,6 +41,7 @@ export async function cmdRules(args, root) {
|
|
|
39
41
|
if (sub === 'status') return cmdStatus(args, root);
|
|
40
42
|
if (sub === 'rollback') return cmdRollback(args, root);
|
|
41
43
|
if (sub === 'cost') return cmdCost(args, root);
|
|
44
|
+
if (sub === 'regress') return cmdRegress(args, root);
|
|
42
45
|
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>');
|
|
43
46
|
return 4;
|
|
44
47
|
}
|
|
@@ -61,13 +64,13 @@ async function cmdSynthesize(args, root) {
|
|
|
61
64
|
const corpusSpec = { originalCode: spec.originalCode, patchedCode: spec.patchedCode ?? null, nearbyCleanCode: spec.nearbyCleanCode ?? [], protectedVariantsCode: spec.protectedVariantsCode ?? [] };
|
|
62
65
|
const leak = checkLeakage(JSON.stringify(corpusSpec), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: spec.fileNames ?? [] });
|
|
63
66
|
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; }
|
|
64
|
-
if (args.flags.auto || !spec.match) {
|
|
67
|
+
if (args.flags.auto || (!spec.match && !spec.sink)) {
|
|
65
68
|
if (!isStr(spec.originalCode)) { console.error('Automatic proposal needs originalCode (the vulnerable snippet) in the spec.'); return 4; }
|
|
66
69
|
const { id, title, severity, cwe, family, languages, remediation } = spec;
|
|
67
70
|
synthesis = synthesizeCandidate({ 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 });
|
|
68
71
|
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]})`}`);
|
|
69
72
|
if (!synthesis.best) { console.error('No valid candidate could be proposed from this material.'); return 1; }
|
|
70
|
-
finalSpec = { ...spec, match: synthesis.best.match };
|
|
73
|
+
finalSpec = { ...spec, ...(synthesis.best.match ? { match: synthesis.best.match } : {}), ...(synthesis.best.structure ?? {}) };
|
|
71
74
|
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('; ')}`);
|
|
72
75
|
}
|
|
73
76
|
const ir = draftRuleIR({ ...finalSpec, originatingFindingId: finding.stableId ?? finding.id, originatingCommit: spec.originatingCommit ?? null, evidenceState: elig.evidenceState });
|
|
@@ -95,10 +98,17 @@ async function cmdEvaluate(args, root) {
|
|
|
95
98
|
const corpusSpec = readJsonArg(args.flags.corpus) ?? loadCorpus(root, id);
|
|
96
99
|
if (!corpusSpec) { console.error('No corpus supplied. Pass --corpus <file.json> with originalCode (and optionally patchedCode/nearbyCleanCode/protectedVariantsCode).'); return 4; }
|
|
97
100
|
const corpus = buildCorpus(ir, corpusSpec);
|
|
101
|
+
// 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
|
|
102
|
+
if (args.flags.corpus) saveCorpus(root, id, { ...(loadCorpus(root, id) ?? {}), ...corpusSpec });
|
|
98
103
|
const leakage = checkLeakage(JSON.stringify({ ...corpusSpec, fileNames: undefined, discoveryCost: undefined }), { benchmarkNames: args.flags['benchmark-names'] ? String(args.flags['benchmark-names']).split(',') : [], fileNames: corpusSpec.fileNames ?? [] });
|
|
99
104
|
const gate = gatePromotion({ ir, corpus, leakageCheck: leakage, thresholds: args.flags.thresholds ? readJsonArg(args.flags.thresholds) : PROMOTION_DEFAULTS });
|
|
100
105
|
|
|
101
106
|
const ledger = loadLedger(root);
|
|
107
|
+
// the shared false-positive corpus: the safe code of every other promoted rule, plus the operator's own files. Part of the gate.
|
|
108
|
+
const fpItems = sharedFalsePositiveCorpus(root, ledger, id);
|
|
109
|
+
const fpHits = falsePositiveHits(ir, fpItems);
|
|
110
|
+
gate.fpCorpus = { items: fpItems.length, hits: fpHits.map((h) => h.label) };
|
|
111
|
+
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 ? ', ...' : ''}`); }
|
|
102
112
|
const cur = stateOf(ledger, id);
|
|
103
113
|
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' });
|
|
104
114
|
saveLedger(root, ledger);
|
|
@@ -134,6 +144,9 @@ async function cmdPromote(args, root) {
|
|
|
134
144
|
if (stateOf(ledger, id) !== 'VALIDATED') { console.error(`Rule ${id} is not VALIDATED (run rules evaluate first); refusing to promote.`); return 4; }
|
|
135
145
|
const ir = loadIR(root, id);
|
|
136
146
|
if (!ir) { console.error(`No rule IR found for ${id}.`); return 4; }
|
|
147
|
+
// other rules may have been promoted since this one was evaluated: hold it to the false-positive corpus as it is NOW
|
|
148
|
+
const fpNow = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
|
|
149
|
+
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; }
|
|
137
150
|
const pub = publishCustomRule(root, ir);
|
|
138
151
|
if (!pub.ok) { console.error(`Promotion refused: ${pub.reason}`); return 4; }
|
|
139
152
|
const t = recordTransition(ledger, { ruleId: id, from: 'VALIDATED', to: 'PROMOTED', reason: args.flags.reason || 'promoted', actor: args.flags.actor || 'operator' });
|
|
@@ -141,10 +154,29 @@ async function cmdPromote(args, root) {
|
|
|
141
154
|
const prov = loadProvenance(root, id);
|
|
142
155
|
if (prov) saveProvenance(root, id, { ...prov, promotionEvent: { at: new Date().toISOString(), actor: args.flags.actor || 'operator', reason: args.flags.reason || 'promoted', ruleHash: ruleIRDigest(ir) } });
|
|
143
156
|
console.log(`Rule ${id}: VALIDATED -> PROMOTED. Published to ${pub.file}.`);
|
|
157
|
+
// Publishing is not trusting. Rule packs are signature-checked so a rule dropped into a repository cannot run in someone's CI; sign this
|
|
158
|
+
// one with the operator's key when there is one, and say plainly whether the scanner will load it.
|
|
159
|
+
const key = args.flags['sign-key'] ?? process.env.AGENTIC_SECURITY_PRIVATE_KEY;
|
|
160
|
+
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`); }
|
|
161
|
+
const trust = packTrust(root, id);
|
|
162
|
+
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}.`);
|
|
144
163
|
if (!t.ok) console.error(t.reason);
|
|
145
164
|
return 0;
|
|
146
165
|
}
|
|
147
166
|
|
|
167
|
+
/** `rules regress [rule-id]`: re-score every PROMOTED rule against its own corpus and the shared false-positive corpus. Exit 1 on any regression. */
|
|
168
|
+
async function cmdRegress(args, root) {
|
|
169
|
+
const ledger = loadLedger(root);
|
|
170
|
+
if (ledger.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${ledger.tamperReason}).`); return 4; }
|
|
171
|
+
const only = args._[2] ?? null;
|
|
172
|
+
const results = regressRules(root, ledger, { only, thresholds: args.flags.thresholds ? readJsonArg(args.flags.thresholds) : undefined });
|
|
173
|
+
if (args.flags.json) console.log(JSON.stringify({ results }, null, 2));
|
|
174
|
+
else if (!results.length) console.log(only ? `No promoted rule ${only}.` : 'No rule is PROMOTED; nothing to regress.');
|
|
175
|
+
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}`); }
|
|
176
|
+
if (only && !results.length) return 4;
|
|
177
|
+
return results.every((r) => r.status === 'ok') ? 0 : 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
148
180
|
async function cmdRollback(args, root) {
|
|
149
181
|
const id = args._[2];
|
|
150
182
|
if (!id) { console.error('usage: rules rollback <rule-id> [--reason text]'); return 4; }
|
|
@@ -165,16 +197,18 @@ async function cmdStatus(args, root) {
|
|
|
165
197
|
const h = historyFor(ledger, id);
|
|
166
198
|
if (!h.length) { console.error(`No history for rule ${id}.`); return 4; }
|
|
167
199
|
if (args.flags.json) console.log(JSON.stringify(h, null, 2));
|
|
168
|
-
else { console.log(`Rule ${id}: ${stateOf(ledger, id)}`);
|
|
200
|
+
else { console.log(`Rule ${id}: ${stateOf(ledger, id)}`);
|
|
201
|
+
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}.`); }
|
|
202
|
+
for (const e of h) console.log(` ${e.at} ${e.from ?? '(new)'} -> ${e.to} (${e.reason || ''})`);
|
|
169
203
|
const syn = loadSynthesis(root, id);
|
|
170
204
|
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'}`); }
|
|
171
205
|
}
|
|
172
206
|
return 0;
|
|
173
207
|
}
|
|
174
208
|
const ids = [...new Set(ledger.entries.map((e) => e.ruleId))];
|
|
175
|
-
if (args.flags.json) { console.log(JSON.stringify(ids.map((rid) => ({ id: rid, state: stateOf(ledger, rid) })), null, 2)); return 0; }
|
|
209
|
+
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; }
|
|
176
210
|
if (!ids.length) { console.log('No rules in the lifecycle ledger yet.'); return 0; }
|
|
177
|
-
for (const rid of ids) console.log(` ${rid.padEnd(40)} ${stateOf(ledger, rid)}`);
|
|
211
|
+
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)' : ''}`);
|
|
178
212
|
return 0;
|
|
179
213
|
}
|
|
180
214
|
|
|
@@ -9,17 +9,13 @@
|
|
|
9
9
|
// the score a rule gets here is the score it will actually produce once promoted.
|
|
10
10
|
import { generateVariants } from './mutation.js';
|
|
11
11
|
import { compileToCustomRule } from './ir.js';
|
|
12
|
-
import { runRule } from '../custom-rules.js';
|
|
12
|
+
import { runRule, normalizeCustomRule } from '../custom-rules.js';
|
|
13
13
|
|
|
14
|
+
/** The rule exactly as the scanner would load it: through the loader's own normalizer, not a second implementation. */
|
|
14
15
|
function toRuntimeRule(ir) {
|
|
15
16
|
const { ok, doc, errs } = compileToCustomRule(ir);
|
|
16
17
|
if (!ok) throw new Error(`cannot build corpus for an invalid rule IR: ${errs.join('; ')}`);
|
|
17
|
-
|
|
18
|
-
return {
|
|
19
|
-
id: doc.id, title: doc.title, severity: doc.severity, cwe: doc.cwe, languages: doc.languages, message: doc.message,
|
|
20
|
-
regexes, requireAll: !!doc.match.allOf, windowLines: doc.match.window ?? 5,
|
|
21
|
-
notMatch: doc.match.notMatch ? new RegExp(doc.match.notMatch) : null,
|
|
22
|
-
};
|
|
18
|
+
return normalizeCustomRule(doc);
|
|
23
19
|
}
|
|
24
20
|
|
|
25
21
|
const EXT = { 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' };
|
|
@@ -4,9 +4,20 @@
|
|
|
4
4
|
// regex/allOf/notMatch/window engine; this module only adds PROVENANCE metadata around it.
|
|
5
5
|
//
|
|
6
6
|
// A rule's match semantics are deliberately the same closed vocabulary custom-rules.js
|
|
7
|
-
// already supports (pattern | allOf+window | notMatch
|
|
8
|
-
// introduced, so a promoted rule is exactly as auditable as a hand-written one.
|
|
7
|
+
// already supports (pattern | allOf+window | notMatch, plus the structural fields below): no
|
|
8
|
+
// separate matching engine is introduced, so a promoted rule is exactly as auditable as a hand-written one.
|
|
9
|
+
//
|
|
10
|
+
// The structural fields are REAL, not annotations: each is a regular expression and each compiles to a constraint the
|
|
11
|
+
// runtime matcher enforces.
|
|
12
|
+
// source, propagator, sink together they become a windowed co-occurrence (allOf) anchored on the SINK; a sink alone is the pattern
|
|
13
|
+
// sanitizer, dominatingGuard a hit is dropped when either matches within `guardWindow` lines before (or on) it
|
|
14
|
+
// frameworkConstraint a file-level precondition (the file must contain it, wherever it is)
|
|
15
|
+
// exclusions a hit is dropped when its own line matches one
|
|
16
|
+
// languages the rule only runs on files of those languages
|
|
17
|
+
// An explicit `match` always wins over a derived one. An AST pattern is NOT a field: the existing engine has no AST matcher for rules to
|
|
18
|
+
// compile to, and a field the matcher ignored would be the old mistake (an annotation that looks like a constraint).
|
|
9
19
|
import { sha256 } from '../proof-fabric/reproducer.js';
|
|
20
|
+
import { normalizeCustomRule } from '../custom-rules.js';
|
|
10
21
|
|
|
11
22
|
const RULE_IR_SCHEMA = 'agentic-security/rule-ir';
|
|
12
23
|
const RULE_IR_VERSION = 1;
|
|
@@ -28,8 +39,9 @@ export function validateRuleIR(ir) {
|
|
|
28
39
|
if (!isStr(ir.family)) errs.push('family required');
|
|
29
40
|
if (!Array.isArray(ir.languages) || !ir.languages.length || !ir.languages.every((l) => VALID_LANGS.has(l))) errs.push('languages must be a non-empty array from the supported set');
|
|
30
41
|
const m = ir.match;
|
|
31
|
-
if (
|
|
32
|
-
else
|
|
42
|
+
if (m == null && !isStr(ir.sink)) errs.push('match required (or at least a sink, from which one is derived)');
|
|
43
|
+
else if (m != null && typeof m !== 'object') errs.push('match must be an object');
|
|
44
|
+
else if (m) {
|
|
33
45
|
const hasPattern = isStr(m.pattern);
|
|
34
46
|
const hasAllOf = Array.isArray(m.allOf) && m.allOf.length > 0 && m.allOf.every(isStr);
|
|
35
47
|
if (!hasPattern && !hasAllOf) errs.push('match needs a pattern or a non-empty allOf');
|
|
@@ -39,12 +51,20 @@ export function validateRuleIR(ir) {
|
|
|
39
51
|
try { new RegExp(re); } catch { errs.push(`invalid regex: ${re}`); }
|
|
40
52
|
}
|
|
41
53
|
}
|
|
42
|
-
//
|
|
43
|
-
// never
|
|
54
|
+
// Structural fields: each is a regular expression the matcher enforces (see the header). Free text is rejected, so a field can
|
|
55
|
+
// never look like a constraint while being ignored.
|
|
44
56
|
for (const k of ['sink', 'source', 'propagator', 'sanitizer', 'dominatingGuard', 'frameworkConstraint']) {
|
|
45
|
-
if (ir[k]
|
|
57
|
+
if (ir[k] == null) continue;
|
|
58
|
+
if (!isStr(ir[k])) { errs.push(`${k} must be a regular-expression string when present`); continue; }
|
|
59
|
+
try { new RegExp(ir[k]); } catch { errs.push(`${k} is not a valid regular expression`); }
|
|
60
|
+
}
|
|
61
|
+
if ((ir.source || ir.propagator) && !ir.sink && !ir.match) errs.push('a source or propagator needs a sink');
|
|
62
|
+
if (ir.window != null && !(Number.isInteger(ir.window) && ir.window > 0 && ir.window <= 200)) errs.push('window must be an integer in 1..200');
|
|
63
|
+
if (ir.guardWindow != null && !(Number.isInteger(ir.guardWindow) && ir.guardWindow > 0 && ir.guardWindow <= 200)) errs.push('guardWindow must be an integer in 1..200');
|
|
64
|
+
if (ir.exclusions != null) {
|
|
65
|
+
if (!(Array.isArray(ir.exclusions) && ir.exclusions.every(isStr))) errs.push('exclusions must be an array of regular-expression strings');
|
|
66
|
+
else for (const re of ir.exclusions) { try { new RegExp(re); } catch { errs.push(`invalid exclusion regex: ${re}`); } }
|
|
46
67
|
}
|
|
47
|
-
if (ir.exclusions != null && !(Array.isArray(ir.exclusions) && ir.exclusions.every(isStr))) errs.push('exclusions must be an array of strings');
|
|
48
68
|
if (!ir.provenance || typeof ir.provenance !== 'object') errs.push('provenance required');
|
|
49
69
|
else {
|
|
50
70
|
if (!isStr(ir.provenance.originatingFindingId)) errs.push('provenance.originatingFindingId required');
|
|
@@ -74,14 +94,37 @@ export function draftRuleIR({ id, title, severity, cwe, family, languages, match
|
|
|
74
94
|
export function compileToCustomRule(ir) {
|
|
75
95
|
const errs = validateRuleIR(ir);
|
|
76
96
|
if (errs.length) return { ok: false, errs };
|
|
97
|
+
const alt = (...parts) => { const p = parts.filter(isStr); return p.length ? p.map((x) => `(?:${x})`).join('|') : null; };
|
|
98
|
+
// the base match: explicit wins; otherwise source -> propagator -> sink as a windowed co-occurrence, or a sink alone
|
|
99
|
+
let base = ir.match;
|
|
100
|
+
if (!base) {
|
|
101
|
+
// the SINK leads: the matcher anchors on the first pattern, and the finding, the guard window and the line exclusions all belong at
|
|
102
|
+
// the sink, where the flaw manifests. The window is symmetric, so the order of the others does not affect co-occurrence.
|
|
103
|
+
const chain = [ir.sink, ir.propagator, ir.source].filter(isStr);
|
|
104
|
+
base = chain.length > 1 ? { allOf: chain, window: ir.window ?? 8 } : { pattern: ir.sink };
|
|
105
|
+
}
|
|
106
|
+
const guard = alt(ir.sanitizer, ir.dominatingGuard);
|
|
77
107
|
const doc = {
|
|
78
108
|
id: `rule-compiler/${ir.id}`,
|
|
79
109
|
title: ir.title,
|
|
80
110
|
severity: ir.severity,
|
|
81
111
|
cwe: ir.cwe,
|
|
82
112
|
languages: ir.languages,
|
|
83
|
-
match: {
|
|
113
|
+
match: {
|
|
114
|
+
...(base.pattern ? { pattern: base.pattern } : {}),
|
|
115
|
+
...(base.allOf ? { allOf: base.allOf, window: base.window ?? 5 } : {}),
|
|
116
|
+
...(base.notMatch ? { notMatch: base.notMatch } : {}),
|
|
117
|
+
...(isStr(ir.frameworkConstraint) ? { fileRequires: [ir.frameworkConstraint] } : {}),
|
|
118
|
+
...(guard ? { guardedBy: guard, guardWindow: ir.guardWindow ?? base.window ?? 5 } : {}),
|
|
119
|
+
...(Array.isArray(ir.exclusions) && ir.exclusions.length ? { excludeLine: ir.exclusions } : {}),
|
|
120
|
+
},
|
|
84
121
|
message: ir.remediation || `${ir.title} (${ir.cwe}, compiled from ${ir.provenance.originatingFindingId})`,
|
|
85
122
|
};
|
|
123
|
+
// The loader has its own safety checks (nested quantifiers and so on). A rule it would refuse must not be reported as compiled,
|
|
124
|
+
// or it would score well here and silently never fire in a scan.
|
|
125
|
+
const origError = console.error; let loaderSaid = '';
|
|
126
|
+
console.error = (...a) => { loaderSaid = a.join(' '); };
|
|
127
|
+
let normalized; try { normalized = normalizeCustomRule(doc); } finally { console.error = origError; }
|
|
128
|
+
if (!normalized) return { ok: false, errs: [`the scanner's rule loader would reject this rule${loaderSaid ? `: ${loaderSaid.replace(/^agentic-security:\s*/, '').slice(0, 160)}` : ''}`] };
|
|
86
129
|
return { ok: true, doc };
|
|
87
130
|
}
|
|
@@ -62,7 +62,7 @@ export function proposeCandidates({ vulnerableCode, patchedCode = null }) {
|
|
|
62
62
|
const vuln = onlyIn(vulnerableCode, patchedCode);
|
|
63
63
|
const lines = vuln.length ? vuln : String(vulnerableCode).split('\n').filter(meaningful).map((l) => l.trim()).filter((l) => callNames(l).length);
|
|
64
64
|
const out = []; const seen = new Set();
|
|
65
|
-
const add = (strategy, match) => { const k = JSON.stringify(match); if (!seen.has(k)) { seen.add(k); out.push({ strategy, match }); } };
|
|
65
|
+
const add = (strategy, match, structure = null) => { const k = JSON.stringify([match, structure]); if (!seen.has(k)) { seen.add(k); out.push({ strategy, ...(match ? { match } : {}), ...(structure ? { structure } : {}) }); } };
|
|
66
66
|
for (const l of lines.slice(0, 3)) add('line-shape', { pattern: generalizeLine(l) });
|
|
67
67
|
for (const l of lines.slice(0, 3)) {
|
|
68
68
|
const sink = sinkOf(l);
|
|
@@ -71,6 +71,9 @@ export function proposeCandidates({ vulnerableCode, patchedCode = null }) {
|
|
|
71
71
|
const sink = lines.map(sinkOf).find(Boolean);
|
|
72
72
|
const src = SOURCES.find((s) => new RegExp(s).test(vulnerableCode));
|
|
73
73
|
if (sink && src) add('source-and-sink', { allOf: [`${esc(sink)}\\s*\\(`, src], window: 6 });
|
|
74
|
+
// the same evidence as an explicit structure: a source and a sink the matcher enforces, so a sanitizer found in the patched code can
|
|
75
|
+
// later be added as a LOCAL guard (dropping one hit) instead of a file-level exclusion (silencing the whole file)
|
|
76
|
+
if (sink && src) add('structural', null, { source: src, sink: `${esc(sink)}\\s*\\(`, window: 6 });
|
|
74
77
|
if (sink) add('sink-call', { pattern: `${esc(sink)}\\s*\\(` });
|
|
75
78
|
return out;
|
|
76
79
|
}
|
|
@@ -104,29 +107,31 @@ export function synthesizeCandidate({ base, corpusSpec, proposed = null, maxAtte
|
|
|
104
107
|
for (const p of proposeCandidates({ vulnerableCode: corpusSpec.originalCode, patchedCode: corpusSpec.patchedCode })) queue.push(p);
|
|
105
108
|
const names = safeNames({ vulnerableCode: corpusSpec.originalCode, patchedCode: corpusSpec.patchedCode });
|
|
106
109
|
const attempts = []; const tried = new Set();
|
|
107
|
-
const tryOne = (strategy, match) => {
|
|
108
|
-
const key = JSON.stringify(match);
|
|
110
|
+
const tryOne = (strategy, match, structure = null) => {
|
|
111
|
+
const key = JSON.stringify([match, structure]);
|
|
109
112
|
if (tried.has(key) || attempts.length >= cap) return null;
|
|
110
113
|
tried.add(key);
|
|
111
|
-
const ir = draftRuleIR({ ...base, match });
|
|
114
|
+
const ir = draftRuleIR({ ...base, ...(match ? { match } : {}), ...(structure ?? {}) });
|
|
112
115
|
const errs = validateRuleIR(ir);
|
|
113
|
-
if (errs.length) { attempts.push({ n: attempts.length + 1, strategy, match, valid: false, errors: errs, pass: false, reasons: errs }); return null; }
|
|
116
|
+
if (errs.length) { attempts.push({ n: attempts.length + 1, strategy, match: match ?? null, structure, valid: false, errors: errs, pass: false, reasons: errs }); return null; }
|
|
114
117
|
const corpus = buildCorpus(ir, corpusSpec);
|
|
115
118
|
const ev = evaluateRule(ir, corpus, thresholds);
|
|
116
|
-
const rec = { n: attempts.length + 1, strategy, match, valid: true, pass: ev.pass && leakageOk, reasons: ev.reasons, score: summary(ev.score), ir, corpusSizes: { positives: corpus.positives.length, negatives: corpus.negatives.length } };
|
|
119
|
+
const rec = { n: attempts.length + 1, strategy, match: match ?? null, structure, valid: true, pass: ev.pass && leakageOk, reasons: ev.reasons, score: summary(ev.score), ir, corpusSizes: { positives: corpus.positives.length, negatives: corpus.negatives.length } };
|
|
117
120
|
attempts.push(rec);
|
|
118
121
|
return { rec, ev };
|
|
119
122
|
};
|
|
120
123
|
for (let qi = 0; qi < queue.length && attempts.length < cap; qi++) {
|
|
121
|
-
const
|
|
124
|
+
const q = queue[qi];
|
|
125
|
+
const first = tryOne(q.strategy, q.match, q.structure);
|
|
122
126
|
if (!first) continue;
|
|
123
127
|
if (first.rec.pass) break;
|
|
124
128
|
// revise from the failure: a negative that fired means the safe version is not excluded yet
|
|
125
129
|
const fired = first.ev.score.negatives.filter((n) => n.fired);
|
|
126
130
|
const rx = safeRegex(names);
|
|
127
|
-
if (fired.length && rx && !
|
|
131
|
+
if (fired.length && rx && q.structure && !q.structure.sanitizer) { const r = tryOne(`${q.strategy}+sanitizer`, null, { ...q.structure, sanitizer: rx }); if (r?.rec.pass) break; }
|
|
132
|
+
else if (fired.length && rx && q.match && !q.match.notMatch) { const r = tryOne(`${q.strategy}+exclude-safe`, { ...q.match, notMatch: rx }); if (r?.rec.pass) break; }
|
|
128
133
|
}
|
|
129
134
|
const valid = attempts.filter((a) => a.valid);
|
|
130
135
|
const best = valid.length ? [...valid].sort((a, b) => quality({ pass: b.pass, score: b.score }) - quality({ pass: a.pass, score: a.score }))[0] : null;
|
|
131
|
-
return { attempts: attempts.map(({ ir, ...rest }) => rest), best: best ? { strategy: best.strategy, match: best.match, pass: best.pass, score: best.score, reasons: best.reasons } : null, exhausted: !attempts.some((a) => a.pass) };
|
|
136
|
+
return { attempts: attempts.map(({ ir, ...rest }) => rest), best: best ? { strategy: best.strategy, match: best.match, structure: best.structure, pass: best.pass, score: best.score, reasons: best.reasons } : null, exhausted: !attempts.some((a) => a.pass) };
|
|
132
137
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Regression integration for promoted rules (PRD 3, requirement 8): a promoted rule joins a corpus every later rule and every later
|
|
2
|
+
// run is held to, instead of being checked once at promotion and forgotten.
|
|
3
|
+
//
|
|
4
|
+
// shared false-positive corpus the SAFE code of every promoted rule (its patched, nearby-clean and protected variants) plus any file
|
|
5
|
+
// the operator drops in `.agentic-security/rule-compiler/fp-corpus/`. A new candidate must not fire
|
|
6
|
+
// on any of it, so rule N+1 cannot reintroduce the false positive rule N was built to avoid.
|
|
7
|
+
// regression run `rules regress` re-scores every PROMOTED rule against its own corpus, then against the shared false
|
|
8
|
+
// positive corpus, and reports any rule that no longer clears its gate (the matcher, the loader or
|
|
9
|
+
// another rule changed underneath it).
|
|
10
|
+
// The rule-specific unit tests are the fixtures written at evaluation (the existing `rule test` convention); this module adds the cross-rule
|
|
11
|
+
// part, which a per-rule check cannot see.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { statePath } from '../state-dir.js';
|
|
15
|
+
import { buildCorpus, scoreRule } from './corpus.js';
|
|
16
|
+
import { evaluateRule } from './promotion.js';
|
|
17
|
+
import { runRule, normalizeCustomRule } from '../custom-rules.js';
|
|
18
|
+
import { compileToCustomRule } from './ir.js';
|
|
19
|
+
import { loadIR, loadCorpus, loadProvenance } from './store.js';
|
|
20
|
+
import { stateOf } from './lifecycle.js';
|
|
21
|
+
|
|
22
|
+
const FP_DIR = 'rule-compiler/fp-corpus';
|
|
23
|
+
const MAX_FP_FILE = 256 * 1024;
|
|
24
|
+
const MAX_FP_FILES = 500;
|
|
25
|
+
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' };
|
|
26
|
+
|
|
27
|
+
/** Operator-supplied false positives (real code a rule should never flag). Plain files; symlinks and oversized files are ignored. */
|
|
28
|
+
function operatorFalsePositives(root) {
|
|
29
|
+
const dir = statePath(root, ...FP_DIR.split('/'));
|
|
30
|
+
const out = [];
|
|
31
|
+
let names = [];
|
|
32
|
+
try { names = fs.readdirSync(dir).sort(); } catch { return out; }
|
|
33
|
+
for (const n of names.slice(0, MAX_FP_FILES)) {
|
|
34
|
+
const f = path.join(dir, n);
|
|
35
|
+
try {
|
|
36
|
+
const st = fs.lstatSync(f);
|
|
37
|
+
if (!st.isFile() || st.isSymbolicLink() || st.size > MAX_FP_FILE) continue;
|
|
38
|
+
out.push({ label: `operator-fp:${n}`, file: n, language: EXT_OF[path.extname(n).toLowerCase()] ?? 'any', code: fs.readFileSync(f, 'utf8') });
|
|
39
|
+
} catch { /* unreadable: skipped */ }
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function promotedRuleIds(ledger) {
|
|
45
|
+
return [...new Set(ledger.entries.map((e) => e.ruleId))].filter((id) => stateOf(ledger, id) === 'PROMOTED');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The shared false-positive corpus as seen by `exceptId`: the safe code of every OTHER promoted rule, plus the operator's files. */
|
|
49
|
+
export function sharedFalsePositiveCorpus(root, ledger, exceptId = null) {
|
|
50
|
+
const items = [];
|
|
51
|
+
for (const id of promotedRuleIds(ledger)) {
|
|
52
|
+
if (id === exceptId) continue;
|
|
53
|
+
const ir = loadIR(root, id); const spec = loadCorpus(root, id);
|
|
54
|
+
if (!ir || !spec || typeof spec.originalCode !== 'string') continue;
|
|
55
|
+
let corpus; try { corpus = buildCorpus(ir, spec); } catch { continue; }
|
|
56
|
+
for (const n of corpus.negatives) items.push({ label: `rule:${id}:${n.label}`, language: ir.languages[0] ?? 'any', code: n.code });
|
|
57
|
+
}
|
|
58
|
+
return [...items, ...operatorFalsePositives(root)];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
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' };
|
|
62
|
+
|
|
63
|
+
/** Which shared false-positive items does this rule fire on? A rule is only run against items written in a language it applies to. */
|
|
64
|
+
export function falsePositiveHits(ir, items) {
|
|
65
|
+
const { ok, doc } = compileToCustomRule(ir);
|
|
66
|
+
if (!ok) return [{ label: '(rule does not compile)' }];
|
|
67
|
+
const rule = normalizeCustomRule(doc);
|
|
68
|
+
if (!rule) return [{ label: '(rule would be rejected by the loader)' }];
|
|
69
|
+
const hits = [];
|
|
70
|
+
for (const it of items) {
|
|
71
|
+
const lang = it.language && it.language !== 'any' ? it.language : (ir.languages[0] ?? 'any');
|
|
72
|
+
if (!ir.languages.includes('any') && !ir.languages.includes(lang)) continue;
|
|
73
|
+
if (runRule(rule, FILE_FOR_LANG[lang] ?? 'x.txt', it.code).length > 0) hits.push({ label: it.label });
|
|
74
|
+
}
|
|
75
|
+
return hits;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Re-score every promoted rule (or one) against its own corpus and the shared false-positive corpus. */
|
|
79
|
+
export function regressRules(root, ledger, { only = null, thresholds } = {}) {
|
|
80
|
+
const results = [];
|
|
81
|
+
for (const id of promotedRuleIds(ledger)) {
|
|
82
|
+
if (only && id !== only) continue;
|
|
83
|
+
const ir = loadIR(root, id); const spec = loadCorpus(root, id);
|
|
84
|
+
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; }
|
|
85
|
+
let ev; let corpus;
|
|
86
|
+
try { corpus = buildCorpus(ir, spec); ev = evaluateRule(ir, corpus, thresholds); } catch (e) { results.push({ id, status: 'broken', reasons: [`cannot be scored: ${String(e.message).slice(0, 120)}`] }); continue; }
|
|
87
|
+
const fp = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
|
|
88
|
+
const baseline = loadProvenance(root, id)?.benchmarkResults ?? null;
|
|
89
|
+
const reasons = [...ev.reasons];
|
|
90
|
+
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 ? ', ...' : ''}`);
|
|
91
|
+
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`);
|
|
92
|
+
results.push({ id, status: reasons.length ? 'regressed' : 'ok', reasons, precision: ev.score.precision, recall: ev.score.recall, protectedFailures: ev.score.protectedFailures, fpCorpusHits: fp.length });
|
|
93
|
+
}
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
@@ -8,6 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
|
|
9
9
|
import { newLedger, verifyLedger } from './lifecycle.js';
|
|
10
10
|
import { compileToCustomRule } from './ir.js';
|
|
11
|
+
import { verifyRulePack, loadTrustedKeys, signRulePack } from '../rule-pack-signing.js';
|
|
11
12
|
|
|
12
13
|
const DIR = 'rule-compiler';
|
|
13
14
|
const EXT = { javascript: 'js', typescript: 'ts', python: 'py', go: 'go', ruby: 'rb', java: 'java', rust: 'rs', csharp: 'cs', php: 'php', any: 'txt' };
|
|
@@ -100,13 +101,40 @@ export function publishCustomRule(scanRoot, ir) {
|
|
|
100
101
|
...(doc.match.pattern ? [` pattern: ${JSON.stringify(doc.match.pattern)}`] : []),
|
|
101
102
|
...(doc.match.allOf ? [` allOf:`, ...doc.match.allOf.map((p) => ` - ${JSON.stringify(p)}`), ` window: ${doc.match.window}`] : []),
|
|
102
103
|
...(doc.match.notMatch ? [` notMatch: ${JSON.stringify(doc.match.notMatch)}`] : []),
|
|
104
|
+
...(doc.match.fileRequires ? [` fileRequires:`, ...doc.match.fileRequires.map((p) => ` - ${JSON.stringify(p)}`)] : []),
|
|
105
|
+
...(doc.match.guardedBy ? [` guardedBy: ${JSON.stringify(doc.match.guardedBy)}`, ` guardWindow: ${doc.match.guardWindow}`] : []),
|
|
106
|
+
...(doc.match.excludeLine ? [` excludeLine:`, ...doc.match.excludeLine.map((p) => ` - ${JSON.stringify(p)}`)] : []),
|
|
103
107
|
` message: ${JSON.stringify(doc.message)}`,
|
|
104
108
|
'',
|
|
105
109
|
].join('\n');
|
|
106
110
|
return { ok: safeWriteState(f, yamlText), file: f };
|
|
107
111
|
}
|
|
108
112
|
|
|
113
|
+
/** Sign a published rule with the operator's key (the same Ed25519 scheme `agentic-security-rule sign` uses). The key is never stored here. */
|
|
114
|
+
export function signPublishedRule(file, privateKeyB64) {
|
|
115
|
+
try { signRulePack(file, privateKeyB64); return { ok: true }; } catch (e) { return { ok: false, reason: String(e.message).slice(0, 120) }; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Will the scanner actually LOAD this promoted rule? Publishing a file is not the same as the scanner trusting it: rule packs are
|
|
120
|
+
* signature-checked (posture/rule-pack-signing.js) so that a rule dropped into a repository cannot run in someone's CI. A promoted rule
|
|
121
|
+
* the scanner would refuse is not a capability yet, so this is reported next to the lifecycle state instead of being assumed.
|
|
122
|
+
*/
|
|
123
|
+
export function packTrust(scanRoot, id) {
|
|
124
|
+
const file = statePath(scanRoot, 'rules', `${id}.yml`);
|
|
125
|
+
if (!fs.existsSync(file)) return { live: false, reason: 'not published', remediation: null };
|
|
126
|
+
let r;
|
|
127
|
+
const origErr = console.error; console.error = () => {};
|
|
128
|
+
try { r = verifyRulePack(file, loadTrustedKeys(scanRoot)); } finally { console.error = origErr; }
|
|
129
|
+
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 };
|
|
130
|
+
if (r.reason === 'unsigned' && r.allowUnsigned) return { live: true, via: 'unsigned, accepted because AGENTIC_SECURITY_ALLOW_UNSIGNED_PACKS=1', reason: null, remediation: null };
|
|
131
|
+
const remediation = r.reason === 'unsigned'
|
|
132
|
+
? `sign it (agentic-security-rule sign ${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`
|
|
133
|
+
: (r.remediation ?? 'see the signing setup in bin/agentic-security-rule.js');
|
|
134
|
+
return { live: false, reason: r.reason, remediation };
|
|
135
|
+
}
|
|
136
|
+
|
|
109
137
|
export function unpublishCustomRule(scanRoot, id) {
|
|
110
138
|
const f = statePath(scanRoot, 'rules', `${id}.yml`);
|
|
111
|
-
try { if (fs.existsSync(
|
|
139
|
+
try { for (const x of [f, `${f}.sig`]) if (fs.existsSync(x)) fs.unlinkSync(x); return { ok: true }; } catch (e) { return { ok: false, reason: e.message }; }
|
|
112
140
|
}
|
|
@@ -198,9 +198,11 @@ export function signRulePack(rulePackPath, privateKeyB64) {
|
|
|
198
198
|
const body = fs.readFileSync(rulePackPath);
|
|
199
199
|
const privBytes = Buffer.from(privateKeyB64, 'base64');
|
|
200
200
|
if (privBytes.length !== 32) throw new Error('private key must be 32 bytes (raw ed25519)');
|
|
201
|
+
// A raw 32-byte Ed25519 seed as a PKCS#8 key. (A JWK with only `d`, which this used to build, is rejected by current Node: it also
|
|
202
|
+
// requires the public `x`, so signing failed with "Invalid JWK OKP key" and no rule pack could be signed through this workflow.)
|
|
201
203
|
const keyObj = crypto.createPrivateKey({
|
|
202
|
-
key:
|
|
203
|
-
format: '
|
|
204
|
+
key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), privBytes]),
|
|
205
|
+
format: 'der', type: 'pkcs8',
|
|
204
206
|
});
|
|
205
207
|
const sig = crypto.sign(null, body, keyObj);
|
|
206
208
|
fs.writeFileSync(rulePackPath + '.sig', sig);
|