@clear-capabilities/agentic-security-scanner 0.195.25 → 0.195.27
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 +27 -0
- package/bin/agentic-security.js +13 -3
- package/dist/396.index.js +936 -0
- package/dist/5752.index.js +15 -2
- package/dist/7838.index.js +22 -0
- package/dist/8218.index.js +19 -2
- package/dist/{809c900a90284017aaff.json → 90ebba38d8860ec77376.json} +4 -4
- package/dist/9382.index.js +315 -174
- package/dist/9560.index.js +19 -2
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- package/src/context-intelligence/CLAUDE.md +4 -0
- package/src/context-intelligence/entrypoints.js +141 -9
- package/src/context-intelligence/imports.js +1 -1
- package/src/context-intelligence/review.js +8 -3
- package/src/engine.js +10 -0
- package/src/lineage/CLAUDE.md +4 -0
- package/src/lineage/graph-builder.js +8 -0
- package/src/lineage/index.js +2 -0
- package/src/posture/CLAUDE.md +10 -2
- package/src/posture/custom-rules.js +7 -2
- package/src/posture/dag-commands.js +15 -2
- package/src/posture/provenance/coordinator.js +6 -2
- package/src/posture/provenance/dag-walk.js +7 -3
- package/src/posture/provenance/git-evidence.js +21 -0
- package/src/posture/provenance/origin-resolver.js +16 -2
- package/src/posture/provenance/providers/github.js +6 -3
- package/src/posture/provenance/providers/gitlab.js +5 -3
- package/src/posture/rule-compiler/cli.js +76 -21
- package/src/posture/rule-compiler/negative-corpus.js +39 -0
- package/src/posture/rule-compiler/promotion.js +21 -0
- package/src/posture/rule-compiler/regression.js +39 -9
- package/src/posture/rule-compiler/store.js +72 -6
- package/src/server/graph-loader.js +19 -2
|
@@ -81,9 +81,13 @@ export async function checkAbsentInSomeParent(scanRoot, sha, replay) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
// git's own convention when using `git revert` (no -n/--no-edit override):
|
|
84
|
-
// "Revert \"<original subject>\"".
|
|
85
|
-
//
|
|
86
|
-
|
|
84
|
+
// "Revert \"<original subject>\"". Since git 2.43 a revert of a revert is
|
|
85
|
+
// titled "Reapply \"<original subject>\"" instead (GA-05, 0.195.27: found by
|
|
86
|
+
// the corpus case `revert-reintroduces`, whose origin commit is exactly that
|
|
87
|
+
// shape); it is still a revert, and the diff inversion below still decides.
|
|
88
|
+
// Message alone is spoofable, so this is only the FIRST half of detection —
|
|
89
|
+
// see detectRevert.
|
|
90
|
+
const REVERT_MESSAGE_RE = /^(?:Revert|Reapply) "/;
|
|
87
91
|
|
|
88
92
|
/**
|
|
89
93
|
* A commit is a real revert only when its message matches git's own Revert
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as cp from 'node:child_process';
|
|
2
|
+
import { STATE_DIR_NAME } from '../state-dir.js';
|
|
2
3
|
import * as fs from 'node:fs';
|
|
3
4
|
import * as path from 'node:path';
|
|
4
5
|
import { hardenGitArgs, hardenGitEnv } from '../../util/git-hardening.js';
|
|
@@ -95,6 +96,26 @@ export function getRemoteUrl(scanRoot) {
|
|
|
95
96
|
return r.ok ? r.stdout.trim() : null;
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Whether the working tree has changes OUTSIDE the given path prefixes (default: the scanner's own state
|
|
101
|
+
* directory). `getRepoState().dirty` counts the scanner's own untracked state directory as dirty, which would make the
|
|
102
|
+
* lineage graph's recorded revision flip between two scans of an unchanged tree merely because the first
|
|
103
|
+
* scan created that directory. Null when not a git repository or when git fails.
|
|
104
|
+
*/
|
|
105
|
+
export function worktreeDirtyExcluding(scanRoot, ignorePrefixes = [`${STATE_DIR_NAME}/`]) {
|
|
106
|
+
if (!isGitRepo(scanRoot)) return null;
|
|
107
|
+
const r = _run(scanRoot, ['status', '--porcelain', '--untracked-files=all']);
|
|
108
|
+
if (!r.ok) return null;
|
|
109
|
+
for (const line of r.stdout.split('\n')) {
|
|
110
|
+
if (!line.trim()) continue;
|
|
111
|
+
const p = line.slice(3).replace(/^"|"$/g, '');
|
|
112
|
+
const renamedTo = p.includes(' -> ') ? p.split(' -> ')[1] : p;
|
|
113
|
+
if (ignorePrefixes.some((pre) => p.startsWith(pre) || renamedTo.startsWith(pre))) continue;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
98
119
|
export function getRepoState(scanRoot) {
|
|
99
120
|
if (!isGitRepo(scanRoot)) return null;
|
|
100
121
|
const head = _run(scanRoot, ['rev-parse', 'HEAD']);
|
|
@@ -55,6 +55,20 @@ function relevantFiles(finding) {
|
|
|
55
55
|
return [...files];
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// GA-05 (0.195.27): revert and cherry-pick detection used to run only in deep mode's retry loop, which the standard walk reaches
|
|
59
|
+
// only after it has MISSED. A standard-walk hit (the common case: the origin commit's first parent lacks the finding) therefore
|
|
60
|
+
// never carried `revertOf` or `cherryPickOf`, even in deep mode. Whether the origin commit is a revert or a cherry-pick is a
|
|
61
|
+
// property of that commit, not of which walk found it, so a deep-mode result is annotated the same way wherever it was resolved.
|
|
62
|
+
// Standard mode is unchanged (the detection costs git processes, which is what the mode exists to make optional).
|
|
63
|
+
function annotateNonLinear(scanRoot, origin, sha, candidates, mode) {
|
|
64
|
+
if (mode !== 'deep') return origin;
|
|
65
|
+
const { isRevert, revertsCommit } = detectRevert(scanRoot, sha, candidates);
|
|
66
|
+
const { isCherryPick, originalCommit } = detectCherryPick(scanRoot, sha);
|
|
67
|
+
origin.revertOf = isRevert ? revertsCommit : null;
|
|
68
|
+
origin.cherryPickOf = isCherryPick ? originalCommit : null;
|
|
69
|
+
return origin;
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
function originFrom(meta, { absentInParents }) {
|
|
59
73
|
return {
|
|
60
74
|
commit: meta.commit, authorName: meta.authorName, authorEmail: meta.authorEmail,
|
|
@@ -283,7 +297,7 @@ export async function resolveOrigin(scanRoot, finding, { since, deadlineAt, repo
|
|
|
283
297
|
// and confidence.js will cap this at MEDIUM.
|
|
284
298
|
return {
|
|
285
299
|
status: 'complete', method: PROVENANCE_METHOD.SEMANTIC_REPLAY, commitsConsidered,
|
|
286
|
-
findingOrigin: originFrom(meta, { absentInParents: [] }),
|
|
300
|
+
findingOrigin: annotateNonLinear(scanRoot, originFrom(meta, { absentInParents: [] }), sha, candidates, mode),
|
|
287
301
|
parentBoundaryVerified: false,
|
|
288
302
|
};
|
|
289
303
|
}
|
|
@@ -304,7 +318,7 @@ export async function resolveOrigin(scanRoot, finding, { since, deadlineAt, repo
|
|
|
304
318
|
|
|
305
319
|
return {
|
|
306
320
|
status: 'complete', method: PROVENANCE_METHOD.SEMANTIC_REPLAY, commitsConsidered,
|
|
307
|
-
findingOrigin: originFrom(meta, { absentInParents: [parent] }),
|
|
321
|
+
findingOrigin: annotateNonLinear(scanRoot, originFrom(meta, { absentInParents: [parent] }), sha, candidates, mode),
|
|
308
322
|
parentBoundaryVerified: true, absenceEvidence: presentInParent.reason,
|
|
309
323
|
};
|
|
310
324
|
}
|
|
@@ -23,7 +23,10 @@ export async function fetchPRMetadata(scanRoot, commitSha, remoteUrl, config) {
|
|
|
23
23
|
headers: { Authorization: `Bearer ${config.token}`, Accept: 'application/vnd.github+json' },
|
|
24
24
|
signal: AbortSignal.timeout(8000),
|
|
25
25
|
});
|
|
26
|
-
|
|
26
|
+
// GA-05 (0.195.27): a provider that answered with an error, or did not answer, is reported as UNAVAILABLE, which the
|
|
27
|
+
// coordinator records as a limitation on the finding. It is a different fact from "no pull request names this commit"
|
|
28
|
+
// (null), and folding the two together hid every outage, bad token and wrong base URL behind an empty enrichment.
|
|
29
|
+
if (!r.ok) return { unavailable: true, reason: `http-${r.status}` };
|
|
27
30
|
const prs = await r.json();
|
|
28
31
|
if (!Array.isArray(prs) || prs.length === 0) return null;
|
|
29
32
|
const pr = prs[0];
|
|
@@ -33,8 +36,8 @@ export async function fetchPRMetadata(scanRoot, commitSha, remoteUrl, config) {
|
|
|
33
36
|
approvals: null, // GitHub's PR-list-by-commit endpoint doesn't include review state; a real approvals count needs a second call, deliberately not made here to keep this a single-request enrichment.
|
|
34
37
|
mergedAt: pr.merged_at || null,
|
|
35
38
|
};
|
|
36
|
-
} catch {
|
|
37
|
-
return
|
|
39
|
+
} catch (e) {
|
|
40
|
+
return { unavailable: true, reason: e && e.name === 'TimeoutError' ? 'timeout' : 'network' };
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -21,7 +21,9 @@ export async function fetchPRMetadata(scanRoot, commitSha, remoteUrl, config) {
|
|
|
21
21
|
headers: { 'PRIVATE-TOKEN': config.token },
|
|
22
22
|
signal: AbortSignal.timeout(8000),
|
|
23
23
|
});
|
|
24
|
-
|
|
24
|
+
// GA-05 (0.195.27): an error reply or no reply is UNAVAILABLE (recorded as a limitation by the coordinator), which is a
|
|
25
|
+
// different fact from "no merge request names this commit" (null). See providers/github.js for the reasoning.
|
|
26
|
+
if (!r.ok) return { unavailable: true, reason: `http-${r.status}` };
|
|
25
27
|
const mrs = await r.json();
|
|
26
28
|
if (!Array.isArray(mrs) || mrs.length === 0) return null;
|
|
27
29
|
const mr = mrs[0];
|
|
@@ -31,8 +33,8 @@ export async function fetchPRMetadata(scanRoot, commitSha, remoteUrl, config) {
|
|
|
31
33
|
approvals: typeof mr.upvotes === 'number' ? mr.upvotes : null,
|
|
32
34
|
mergedAt: mr.merged_at || null,
|
|
33
35
|
};
|
|
34
|
-
} catch {
|
|
35
|
-
return
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return { unavailable: true, reason: e && e.name === 'TimeoutError' ? 'timeout' : 'network' };
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -11,8 +11,8 @@ import fs from 'node:fs';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { execFileSync } from 'node:child_process';
|
|
13
13
|
import { statePath } from '../state-dir.js';
|
|
14
|
-
import { packTrust, signPublishedRule } from './store.js';
|
|
15
|
-
import { sharedFalsePositiveCorpus, falsePositiveHits, regressRules, promotedRuleIds } from './regression.js';
|
|
14
|
+
import { packTrust, signPublishedRule, publicKeyOf } from './store.js';
|
|
15
|
+
import { sharedFalsePositiveCorpus, falsePositiveHits, regressRules, promotedRuleIds, corpusHash } from './regression.js';
|
|
16
16
|
import { draftRuleIR, validateRuleIR, ruleIRDigest } from './ir.js';
|
|
17
17
|
import { checkEligibility } from './eligibility.js';
|
|
18
18
|
import { buildCorpus } from './corpus.js';
|
|
@@ -39,7 +39,7 @@ export async function cmdRules(args, root) {
|
|
|
39
39
|
const sub = args._[1];
|
|
40
40
|
// GA-07: a ledger that failed its integrity check (broken chain, bad or missing signature) refuses EVERY command, not only the
|
|
41
41
|
// ones that change state; nothing below can read a tampered history as fact or write over it.
|
|
42
|
-
if (['synthesize', 'evaluate', 'promote', 'status', 'rollback', 'cost', 'regress'].includes(sub)) {
|
|
42
|
+
if (['synthesize', 'evaluate', 'promote', 'status', 'rollback', 'deprecate', 'cost', 'regress'].includes(sub)) {
|
|
43
43
|
const guard = loadLedger(root);
|
|
44
44
|
if (guard.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${guard.tamperReason}).`); return 4; }
|
|
45
45
|
}
|
|
@@ -48,12 +48,25 @@ export async function cmdRules(args, root) {
|
|
|
48
48
|
if (sub === 'promote') return cmdPromote(args, root);
|
|
49
49
|
if (sub === 'status') return cmdStatus(args, root);
|
|
50
50
|
if (sub === 'rollback') return cmdRollback(args, root);
|
|
51
|
+
if (sub === 'deprecate') return cmdDeprecate(args, root);
|
|
51
52
|
if (sub === 'cost') return cmdCost(args, root);
|
|
52
53
|
if (sub === 'regress') return cmdRegress(args, root);
|
|
53
|
-
console.error('usage: rules synthesize <finding-id> --spec <file.json> [--auto] [--max-attempts N] | evaluate <rule-id> | promote <rule-id> --reviewed-by <person>
|
|
54
|
+
console.error('usage: rules synthesize <finding-id> --spec <file.json> [--auto] [--max-attempts N] | evaluate <rule-id> | promote <rule-id> --reviewed-by <person> --scope <text> [--sign-key <base64>] | status [rule-id] | rollback <rule-id> | deprecate <rule-id> | cost <rule-id> | regress [rule-id]');
|
|
54
55
|
return 4;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/** The operator's Ed25519 key, when given: it signs the published rule AND the ledger, so a clone without this install's HMAC key can
|
|
59
|
+
* still verify the lifecycle history (store.js loadLedger). Never stored. */
|
|
60
|
+
function authorKeyFrom(args) { return args.flags['sign-key'] ?? process.env.AGENTIC_SECURITY_PRIVATE_KEY ?? null; }
|
|
61
|
+
|
|
62
|
+
/** Every command that changes the ledger saves it through here, so an author signature made at promotion is renewed by a later
|
|
63
|
+
* rollback or evaluation run with the same key instead of going stale (a stale one would read as tampering on the next clone). */
|
|
64
|
+
function persistLedger(root, ledger, args) {
|
|
65
|
+
const r = saveLedger(root, ledger, { authorKey: authorKeyFrom(args) });
|
|
66
|
+
if (!r.ok && r.reason) console.error(r.reason);
|
|
67
|
+
return r;
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
// `--spec` carries exactly what a model or a human proposed: id/title/severity/cwe/family/languages/match/
|
|
58
71
|
// remediation plus the corpus inputs (originalCode/patchedCode/nearbyCleanCode/protectedVariantsCode). This
|
|
59
72
|
// command never invents a pattern; it only drafts the IR, checks eligibility and records DRAFT->CANDIDATE.
|
|
@@ -86,10 +99,16 @@ async function cmdSynthesize(args, root) {
|
|
|
86
99
|
if (errs.length) { console.error(`Invalid rule: ${errs.join('; ')}`); return 4; }
|
|
87
100
|
|
|
88
101
|
const ledger = loadLedger(root);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
102
|
+
// GA-07 (0.195.27): a rule that was rolled back re-enters as a CANDIDATE (the ledger's ROLLED_BACK -> CANDIDATE edge), keeping its
|
|
103
|
+
// history; any other existing state refuses, so a promoted or validated rule cannot be silently re-drafted over.
|
|
104
|
+
const current = stateOf(ledger, ir.id);
|
|
105
|
+
if (current !== null && current !== 'ROLLED_BACK') { console.error(`Rule ${ir.id} already exists in state ${current}; roll it back first, or choose another id.`); return 4; }
|
|
106
|
+
if (current === null) {
|
|
107
|
+
const draft = recordTransition(ledger, { ruleId: ir.id, from: null, to: 'DRAFT', reason: `drafted from ${findingId}`, actor: args.flags.actor || 'agent' });
|
|
108
|
+
if (!draft.ok) { console.error(draft.reason); return 4; }
|
|
109
|
+
}
|
|
110
|
+
const cand = recordTransition(ledger, { ruleId: ir.id, from: current === null ? 'DRAFT' : 'ROLLED_BACK', to: 'CANDIDATE', reason: current === null ? 'eligibility passed' : `re-entered after rollback; eligibility passed (drafted again from ${findingId})`, evidence: { eligibility: elig }, actor: args.flags.actor || 'agent' });
|
|
111
|
+
persistLedger(root, ledger, args);
|
|
93
112
|
saveIR(root, { ...ir, state: 'CANDIDATE' });
|
|
94
113
|
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) });
|
|
95
114
|
if (synthesis) saveSynthesis(root, ir.id, { attempts: synthesis.attempts, best: synthesis.best });
|
|
@@ -119,9 +138,8 @@ async function cmdEvaluate(args, root) {
|
|
|
119
138
|
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 ? ', ...' : ''}`); }
|
|
120
139
|
const cur = stateOf(ledger, id);
|
|
121
140
|
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' });
|
|
122
|
-
|
|
141
|
+
persistLedger(root, ledger, args);
|
|
123
142
|
saveEvaluation(root, id, gate);
|
|
124
|
-
const corpusHash = (list) => sha256(JSON.stringify(list.map((x) => [x.label, x.code]).sort()));
|
|
125
143
|
const prev = loadProvenance(root, id);
|
|
126
144
|
saveProvenance(root, id, {
|
|
127
145
|
schema: 'agentic-security/rule-provenance', version: 1, ruleId: id, ruleVersion: 1, ruleHash: ruleIRDigest(ir),
|
|
@@ -159,7 +177,13 @@ async function cmdPromote(args, root) {
|
|
|
159
177
|
if (!id) { console.error('usage: rules promote <rule-id> --reviewed-by <person> [--scope <text>] [--reason <text>]'); return 4; }
|
|
160
178
|
const reviewer = args.flags['reviewed-by'];
|
|
161
179
|
if (!isStr(reviewer) || NOT_A_REVIEWER.test(reviewer.trim())) {
|
|
162
|
-
console.error('usage: rules promote <rule-id> --reviewed-by <person>. A promotion records the person who reviewed the rule and its scope; a model or agent recommendation is not a review, so nothing is promoted without a named reviewer.');
|
|
180
|
+
console.error('usage: rules promote <rule-id> --reviewed-by <person> --scope <text>. A promotion records the person who reviewed the rule and its scope; a model or agent recommendation is not a review, so nothing is promoted without a named reviewer.');
|
|
181
|
+
return 4;
|
|
182
|
+
}
|
|
183
|
+
// GA-07 §3 (0.195.27): the reviewed SCOPE (what the rule is for, where it applies, what it must not flag) is part of the review
|
|
184
|
+
// record, not an optional note; a promotion without one records nothing a later reader could hold the rule to.
|
|
185
|
+
if (!isStr(args.flags.scope)) {
|
|
186
|
+
console.error('usage: rules promote <rule-id> --reviewed-by <person> --scope <text>. The reviewed scope is recorded with the promotion; state what the rule covers and where it applies.');
|
|
163
187
|
return 4;
|
|
164
188
|
}
|
|
165
189
|
const ledger = loadLedger(root);
|
|
@@ -196,16 +220,32 @@ async function cmdPromote(args, root) {
|
|
|
196
220
|
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; }
|
|
197
221
|
const pub = publishCustomRule(root, ir);
|
|
198
222
|
if (!pub.ok) { console.error(`Promotion refused: ${pub.reason}`); return 4; }
|
|
199
|
-
const scope =
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
223
|
+
const scope = args.flags.scope.trim();
|
|
224
|
+
// GA-07 §3 (0.195.27): the evidence the reviewer looked at, digested into the promotion: the evaluation and provenance records
|
|
225
|
+
// as they stood, and the two corpus halves. `rules regress` re-derives the corpus digests and reports a rule whose corpus moved
|
|
226
|
+
// after the review as no longer the reviewed one.
|
|
227
|
+
const corpusNow = buildCorpus(ir, loadCorpus(root, id) ?? {});
|
|
228
|
+
const evidence = {
|
|
229
|
+
reviewer: reviewer.trim(), scope, independent: prov.benchmarkResults?.independent ?? null, ruleHash: ruleIRDigest(ir),
|
|
230
|
+
evaluationDigest: sha256(JSON.stringify(readJsonArg(path.join(statePath(root, 'rule-compiler', id), 'evaluation.json')))),
|
|
231
|
+
provenanceDigest: sha256(JSON.stringify({ ...prov, promotionEvent: undefined })),
|
|
232
|
+
positiveCorpusHash: corpusHash(corpusNow.positives), negativeCorpusHash: corpusHash(corpusNow.negatives),
|
|
233
|
+
sharedFalsePositiveItems: sharedFalsePositiveCorpus(root, ledger, id).length,
|
|
234
|
+
};
|
|
235
|
+
const t = recordTransition(ledger, { ruleId: id, from: 'VALIDATED', to: 'PROMOTED', reason: args.flags.reason || 'promoted', evidence, actor: args.flags.actor || reviewer.trim() });
|
|
236
|
+
const saved = persistLedger(root, ledger, args);
|
|
237
|
+
saveProvenance(root, id, { ...prov, promotionEvent: { at: new Date().toISOString(), actor: args.flags.actor || reviewer.trim(), reviewer: reviewer.trim(), scope, reason: args.flags.reason || 'promoted', ruleHash: ruleIRDigest(ir), evidence } });
|
|
204
238
|
console.log(`Rule ${id}: VALIDATED -> PROMOTED. Published to ${pub.file}.`);
|
|
205
239
|
// Publishing is not trusting. Rule packs are signature-checked so a rule dropped into a repository cannot run in someone's CI; sign this
|
|
206
|
-
// one with the operator's key when there is one, and say plainly whether the scanner will load it.
|
|
207
|
-
|
|
208
|
-
|
|
240
|
+
// one with the operator's key when there is one, and say plainly whether the scanner will load it. The same key author-signs the
|
|
241
|
+
// ledger, which is what lets `rules regress` on another machine (CI) verify the history without this install's HMAC key.
|
|
242
|
+
const key = authorKeyFrom(args);
|
|
243
|
+
if (key) {
|
|
244
|
+
const sg = signPublishedRule(pub.file, key); console.log(sg.ok ? ` signed: ${pub.file}.sig` : ` could not sign (${sg.reason}); the rule is published unsigned`);
|
|
245
|
+
console.log(saved.authorSigned ? ` ledger author-signed; another machine verifies it with AGENTIC_SECURITY_RULES_PUBLIC_KEY=${publicKeyOf(key)}` : ' ledger NOT author-signed; rules regress on another machine will refuse it');
|
|
246
|
+
} else {
|
|
247
|
+
console.log(' no --sign-key: the ledger carries only this install\'s signature, so rules regress on another machine (CI) will refuse it; promote with --sign-key <base64> to make it portable');
|
|
248
|
+
}
|
|
209
249
|
const trust = packTrust(root, id);
|
|
210
250
|
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}.`);
|
|
211
251
|
if (!t.ok) console.error(t.reason);
|
|
@@ -218,9 +258,10 @@ async function cmdRegress(args, root) {
|
|
|
218
258
|
if (ledger.tampered) { console.error(`Refusing: rule lifecycle ledger failed its integrity check (${ledger.tamperReason}).`); return 4; }
|
|
219
259
|
const only = args._[2] ?? null;
|
|
220
260
|
const results = regressRules(root, ledger, { only, thresholds: args.flags.thresholds ? readJsonArg(args.flags.thresholds) : undefined });
|
|
221
|
-
if (args.flags.json) console.log(JSON.stringify({ results }, null, 2));
|
|
261
|
+
if (args.flags.json) console.log(JSON.stringify({ ledgerVerifiedBy: ledger.verifiedBy ?? null, results }, null, 2));
|
|
222
262
|
else if (!results.length) console.log(only ? `No promoted rule ${only}.` : 'No rule is PROMOTED; nothing to regress.');
|
|
223
263
|
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}`); }
|
|
264
|
+
if (!args.flags.json && results.length) console.log(`ledger verified by ${ledger.verifiedBy === 'author-key' ? `the author key (${ledger.authorKeyId})` : ledger.verifiedBy === 'install-key' ? 'this install\'s key' : 'nothing (unsigned, accepted by AGENTIC_SECURITY_RULES_UNSIGNED=1)'}`);
|
|
224
265
|
if (only && !results.length) return 4;
|
|
225
266
|
return results.every((r) => r.status === 'ok') ? 0 : 1;
|
|
226
267
|
}
|
|
@@ -233,11 +274,25 @@ async function cmdRollback(args, root) {
|
|
|
233
274
|
if (cur !== 'PROMOTED') { console.error(`Rule ${id} is not PROMOTED (current: ${cur ?? 'unknown'}); nothing to roll back.`); return 4; }
|
|
234
275
|
const un = unpublishCustomRule(root, id);
|
|
235
276
|
const t = recordTransition(ledger, { ruleId: id, from: 'PROMOTED', to: 'ROLLED_BACK', reason: args.flags.reason || 'operator rollback', actor: args.flags.actor || 'operator' });
|
|
236
|
-
|
|
277
|
+
persistLedger(root, ledger, args);
|
|
237
278
|
console.log(`Rule ${id}: PROMOTED -> ROLLED_BACK.${un.ok ? '' : ` (warning: could not remove published rule file: ${un.reason})`}`);
|
|
238
279
|
return t.ok ? 0 : 1;
|
|
239
280
|
}
|
|
240
281
|
|
|
282
|
+
/** `rules deprecate <rule-id>`: retire a promoted rule for good (PROMOTED -> DEPRECATED, a terminal state); the published file is removed. */
|
|
283
|
+
async function cmdDeprecate(args, root) {
|
|
284
|
+
const id = args._[2];
|
|
285
|
+
if (!id) { console.error('usage: rules deprecate <rule-id> [--reason text]'); return 4; }
|
|
286
|
+
const ledger = loadLedger(root);
|
|
287
|
+
const cur = stateOf(ledger, id);
|
|
288
|
+
if (cur !== 'PROMOTED') { console.error(`Rule ${id} is not PROMOTED (current: ${cur ?? 'unknown'}); only a promoted rule can be deprecated (roll back a candidate instead).`); return 4; }
|
|
289
|
+
const un = unpublishCustomRule(root, id);
|
|
290
|
+
const t = recordTransition(ledger, { ruleId: id, from: 'PROMOTED', to: 'DEPRECATED', reason: args.flags.reason || 'operator deprecation', actor: args.flags.actor || 'operator' });
|
|
291
|
+
persistLedger(root, ledger, args);
|
|
292
|
+
console.log(`Rule ${id}: PROMOTED -> DEPRECATED (terminal).${un.ok ? '' : ` (warning: could not remove published rule file: ${un.reason})`}`);
|
|
293
|
+
return t.ok ? 0 : 1;
|
|
294
|
+
}
|
|
295
|
+
|
|
241
296
|
async function cmdStatus(args, root) {
|
|
242
297
|
const ledger = loadLedger(root);
|
|
243
298
|
const id = args._[2];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The shared negative corpus shipped with the scanner (GA-07 §3, 0.195.27): safe, idiomatic code that no promoted rule may fire
|
|
2
|
+
// on, the same on every machine and in every repository. `regression.js` puts it in front of the shared false-positive corpus, so
|
|
3
|
+
// `rules evaluate`, `rules promote` and `rules regress` all hold a rule to it; a rule that fires on any entry is refused at
|
|
4
|
+
// evaluation and reported as regressed later, wherever it runs.
|
|
5
|
+
//
|
|
6
|
+
// Each entry is the SAFE side of a pattern the compiler is likely to be asked to detect: a parameterised query beside the
|
|
7
|
+
// concatenated one, an argument-vector process spawn beside the shell string, a constant-time comparison beside the plain one.
|
|
8
|
+
// They are plain strings in a module (not files on disk) so the bundle carries them without a build step and the corpus is
|
|
9
|
+
// identical wherever the scanner runs. Keep every entry free of the unsafe form: a vulnerable snippet here would make a correct
|
|
10
|
+
// rule fail its gate.
|
|
11
|
+
export const SHIPPED_NEGATIVE_CORPUS = Object.freeze([
|
|
12
|
+
{ name: 'js-parameterised-query', language: 'javascript', code: 'const rows = await db.query("SELECT * FROM orders WHERE owner = $1", [ownerId]);\n' },
|
|
13
|
+
{ name: 'js-placeholder-query', language: 'javascript', code: 'const [row] = await conn.execute("SELECT id FROM users WHERE email = ?", [email]);\n' },
|
|
14
|
+
{ name: 'js-orm-where-object', language: 'javascript', code: 'const user = await User.findOne({ where: { id: req.params.id, tenantId: req.user.tenantId } });\n' },
|
|
15
|
+
{ name: 'js-execfile-argv', language: 'javascript', code: 'const out = execFileSync("git", ["log", "--format=%H", "-1", ref], { encoding: "utf8" });\n' },
|
|
16
|
+
{ name: 'js-spawn-argv', language: 'javascript', code: 'const child = spawn("convert", [inputPath, "-resize", "200x200", outputPath], { stdio: "ignore" });\n' },
|
|
17
|
+
{ name: 'js-path-confined', language: 'javascript', code: 'const target = path.resolve(root, name);\nif (!target.startsWith(root + path.sep)) throw new Error("outside root");\nconst text = fs.readFileSync(target, "utf8");\n' },
|
|
18
|
+
{ name: 'js-constant-time-compare', language: 'javascript', code: 'const ok = a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));\n' },
|
|
19
|
+
{ name: 'js-password-hash', language: 'javascript', code: 'const hash = await bcrypt.hash(password, 12);\nawait store.save({ email, hash });\n' },
|
|
20
|
+
{ name: 'js-random-token', language: 'javascript', code: 'const token = crypto.randomBytes(32).toString("hex");\n' },
|
|
21
|
+
{ name: 'js-json-parse-validated', language: 'javascript', code: 'const body = schema.parse(JSON.parse(raw));\nres.json({ id: body.id });\n' },
|
|
22
|
+
{ name: 'js-template-escaped', language: 'javascript', code: 'res.send(template.render({ name: escapeHtml(req.query.name) }));\n' },
|
|
23
|
+
{ name: 'js-fetch-allowlist', language: 'javascript', code: 'if (!ALLOWED_HOSTS.has(new URL(target).host)) return res.status(400).end();\nconst r = await fetch(target, { signal: AbortSignal.timeout(5000) });\n' },
|
|
24
|
+
{ name: 'ts-parameterised-query', language: 'typescript', code: 'const rows: Row[] = await db.query("SELECT * FROM t WHERE id = $1", [id]);\n' },
|
|
25
|
+
{ name: 'py-parameterised-query', language: 'python', code: 'cur.execute("SELECT * FROM users WHERE email = %s", (email,))\n' },
|
|
26
|
+
{ name: 'py-subprocess-argv', language: 'python', code: 'subprocess.run(["ls", "-l", directory], check=True, capture_output=True)\n' },
|
|
27
|
+
{ name: 'py-safe-yaml', language: 'python', code: 'config = yaml.safe_load(text)\n' },
|
|
28
|
+
{ name: 'py-path-confined', language: 'python', code: 'target = os.path.realpath(os.path.join(root, name))\nif not target.startswith(root + os.sep):\n raise ValueError("outside root")\n' },
|
|
29
|
+
{ name: 'py-password-hash', language: 'python', code: 'digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 200_000)\n' },
|
|
30
|
+
{ name: 'go-parameterised-query', language: 'go', code: 'row := db.QueryRow("SELECT name FROM users WHERE id = $1", id)\n' },
|
|
31
|
+
{ name: 'go-exec-argv', language: 'go', code: 'out, err := exec.Command("git", "rev-parse", ref).Output()\n' },
|
|
32
|
+
{ name: 'rb-parameterised-query', language: 'ruby', code: 'User.where("email = ?", params[:email]).first\n' },
|
|
33
|
+
{ name: 'rb-system-argv', language: 'ruby', code: 'system("convert", input_path, "-resize", "200x200", output_path)\n' },
|
|
34
|
+
{ name: 'java-prepared-statement', language: 'java', code: 'PreparedStatement ps = conn.prepareStatement("SELECT * FROM t WHERE id = ?");\nps.setInt(1, id);\nResultSet rs = ps.executeQuery();\n' },
|
|
35
|
+
{ name: 'java-processbuilder-argv', language: 'java', code: 'Process p = new ProcessBuilder("git", "log", "-1", ref).start();\n' },
|
|
36
|
+
{ name: 'php-prepared-statement', language: 'php', code: '$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");\n$stmt->execute([":id" => $id]);\n' },
|
|
37
|
+
{ name: 'cs-parameterised-query', language: 'csharp', code: 'var cmd = new SqlCommand("SELECT * FROM t WHERE id = @id", conn);\ncmd.Parameters.AddWithValue("@id", id);\n' },
|
|
38
|
+
{ name: 'rs-parameterised-query', language: 'rust', code: 'let row = client.query_one("SELECT name FROM users WHERE id = $1", &[&id]).await?;\n' },
|
|
39
|
+
]);
|
|
@@ -69,5 +69,26 @@ export function gatePromotion({ ir, corpus, leakageCheck, thresholds } = {}) {
|
|
|
69
69
|
else if (ind.detected < ind.positives) reasons.push(`misses ${ind.positives - ind.detected} of ${ind.positives} independent vulnerable example(s)`);
|
|
70
70
|
if (ind.negatives < t.minIndependentClean) reasons.push(`${ind.negatives} independent safe example(s) (min ${t.minIndependentClean}): supply independentCleanCode`);
|
|
71
71
|
else if (ind.falseAlarms > 0) reasons.push(`fires on ${ind.falseAlarms} of ${ind.negatives} independent safe example(s)`);
|
|
72
|
+
for (const dup of dependentExamples(corpus)) reasons.push(`${dup.label} is not independent: it is the same code as ${dup.sameAs} (the source finding, its patch or a generated mutation), so it grades nothing the source did not`);
|
|
72
73
|
return { pass: reasons.length === 0, reasons, evaluation: evalResult, independent: ind };
|
|
73
74
|
}
|
|
75
|
+
|
|
76
|
+
// GA-07 §2 (0.195.27): an "independent" example that is the original snippet, its patch, or one of the compiler's own mutations
|
|
77
|
+
// (whitespace aside) is circular grading, which the independent-example rule exists to prevent. Compared after collapsing
|
|
78
|
+
// whitespace so a re-indented copy is caught; a genuinely different example with the same shape is not (that is what a rule is for).
|
|
79
|
+
const squash = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
80
|
+
export function dependentExamples(corpus) {
|
|
81
|
+
const known = new Map();
|
|
82
|
+
for (const item of [...(corpus?.positives ?? []), ...(corpus?.negatives ?? [])]) {
|
|
83
|
+
if (item.label.startsWith('independent-')) continue;
|
|
84
|
+
const k = squash(item.code);
|
|
85
|
+
if (k && !known.has(k)) known.set(k, item.label);
|
|
86
|
+
}
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const item of [...(corpus?.positives ?? []), ...(corpus?.negatives ?? [])]) {
|
|
89
|
+
if (!item.label.startsWith('independent-')) continue;
|
|
90
|
+
const sameAs = known.get(squash(item.code));
|
|
91
|
+
if (sameAs) out.push({ label: item.label, sameAs });
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
@@ -15,18 +15,22 @@ import { statePath } from '../state-dir.js';
|
|
|
15
15
|
import { buildCorpus, scoreRule } from './corpus.js';
|
|
16
16
|
import { evaluateRule } from './promotion.js';
|
|
17
17
|
import { runRule, normalizeCustomRule } from '../custom-rules.js';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
18
19
|
import { compileToCustomRule } from './ir.js';
|
|
19
20
|
import { loadIR, loadCorpus, loadProvenance } from './store.js';
|
|
20
21
|
import { stateOf } from './lifecycle.js';
|
|
22
|
+
import { SHIPPED_NEGATIVE_CORPUS } from './negative-corpus.js';
|
|
21
23
|
|
|
22
24
|
const FP_DIR = 'rule-compiler/fp-corpus';
|
|
23
25
|
const MAX_FP_FILE = 256 * 1024;
|
|
24
26
|
const MAX_FP_FILES = 500;
|
|
25
27
|
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
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
// GA-07 §3 (0.195.27): one or more directories of safe code shared ACROSS repositories (a fleet's common false-positive corpus),
|
|
30
|
+
// named by path-separator-joined entries in this variable. Same rules as the project directory: plain files, size and count caps.
|
|
31
|
+
const SHARED_FP_ENV = 'AGENTIC_SECURITY_RULES_FP_CORPUS';
|
|
32
|
+
|
|
33
|
+
function falsePositiveFiles(dir, prefix) {
|
|
30
34
|
const out = [];
|
|
31
35
|
let names = [];
|
|
32
36
|
try { names = fs.readdirSync(dir).sort(); } catch { return out; }
|
|
@@ -35,19 +39,32 @@ function operatorFalsePositives(root) {
|
|
|
35
39
|
try {
|
|
36
40
|
const st = fs.lstatSync(f);
|
|
37
41
|
if (!st.isFile() || st.isSymbolicLink() || st.size > MAX_FP_FILE) continue;
|
|
38
|
-
out.push({ label:
|
|
42
|
+
out.push({ label: `${prefix}:${n}`, file: n, language: EXT_OF[path.extname(n).toLowerCase()] ?? 'any', code: fs.readFileSync(f, 'utf8') });
|
|
39
43
|
} catch { /* unreadable: skipped */ }
|
|
40
44
|
}
|
|
41
45
|
return out;
|
|
42
46
|
}
|
|
43
47
|
|
|
48
|
+
/** Operator-supplied false positives (real code a rule should never flag): the project's own directory, then every shared directory. */
|
|
49
|
+
function operatorFalsePositives(root, env = process.env) {
|
|
50
|
+
const out = falsePositiveFiles(statePath(root, ...FP_DIR.split('/')), 'operator-fp');
|
|
51
|
+
for (const dir of String(env[SHARED_FP_ENV] || '').split(path.delimiter).map((s) => s.trim()).filter(Boolean)) {
|
|
52
|
+
out.push(...falsePositiveFiles(path.resolve(dir), `shared-fp:${path.basename(dir)}`));
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
export function promotedRuleIds(ledger) {
|
|
45
58
|
return [...new Set(ledger.entries.map((e) => e.ruleId))].filter((id) => stateOf(ledger, id) === 'PROMOTED');
|
|
46
59
|
}
|
|
47
60
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
/**
|
|
62
|
+
* The shared false-positive corpus as seen by `exceptId`: the safe code shipped with the scanner (`negative-corpus.js`, the same on
|
|
63
|
+
* every machine and in every repository), the safe code of every OTHER promoted rule, the operator's project files, and any
|
|
64
|
+
* directories shared across repositories through AGENTIC_SECURITY_RULES_FP_CORPUS.
|
|
65
|
+
*/
|
|
66
|
+
export function sharedFalsePositiveCorpus(root, ledger, exceptId = null, env = process.env) {
|
|
67
|
+
const items = SHIPPED_NEGATIVE_CORPUS.map((s) => ({ label: `shipped-fp:${s.name}`, language: s.language, code: s.code }));
|
|
51
68
|
for (const id of promotedRuleIds(ledger)) {
|
|
52
69
|
if (id === exceptId) continue;
|
|
53
70
|
const ir = loadIR(root, id); const spec = loadCorpus(root, id);
|
|
@@ -55,7 +72,12 @@ export function sharedFalsePositiveCorpus(root, ledger, exceptId = null) {
|
|
|
55
72
|
let corpus; try { corpus = buildCorpus(ir, spec); } catch { continue; }
|
|
56
73
|
for (const n of corpus.negatives) items.push({ label: `rule:${id}:${n.label}`, language: ir.languages[0] ?? 'any', code: n.code });
|
|
57
74
|
}
|
|
58
|
-
return [...items, ...operatorFalsePositives(root)];
|
|
75
|
+
return [...items, ...operatorFalsePositives(root, env)];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The digest of a corpus half, as `rules evaluate` records it in the provenance record and `rules regress` re-derives it. */
|
|
79
|
+
export function corpusHash(list) {
|
|
80
|
+
return createHash('sha256').update(JSON.stringify(list.map((x) => [x.label, x.code]).sort())).digest('hex');
|
|
59
81
|
}
|
|
60
82
|
|
|
61
83
|
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' };
|
|
@@ -85,8 +107,16 @@ export function regressRules(root, ledger, { only = null, thresholds } = {}) {
|
|
|
85
107
|
let ev; let corpus;
|
|
86
108
|
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
109
|
const fp = falsePositiveHits(ir, sharedFalsePositiveCorpus(root, ledger, id));
|
|
88
|
-
const
|
|
110
|
+
const prov = loadProvenance(root, id);
|
|
111
|
+
const baseline = prov?.benchmarkResults ?? null;
|
|
89
112
|
const reasons = [...ev.reasons];
|
|
113
|
+
// GA-07 §3 (0.195.27): the promotion was a review of ONE rule against ONE corpus, both digested into the promotion event. A
|
|
114
|
+
// corpus edited after the review (an independent example dropped, a protected variant softened) can still clear the gate, so
|
|
115
|
+
// the gate alone would not notice; the digests do. Such a rule is `review-stale`: not regressed, but no longer the reviewed one.
|
|
116
|
+
const reviewed = prov?.promotionEvent?.evidence ?? null;
|
|
117
|
+
if (reviewed) {
|
|
118
|
+
if (reviewed.positiveCorpusHash !== corpusHash(corpus.positives) || reviewed.negativeCorpusHash !== corpusHash(corpus.negatives)) reasons.push('the corpus changed since the reviewed promotion (its digests no longer match the promotion event); re-evaluate and promote again');
|
|
119
|
+
}
|
|
90
120
|
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
121
|
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
122
|
results.push({ id, status: reasons.length ? 'regressed' : 'ok', reasons, precision: ev.score.precision, recall: ev.score.recall, protectedFailures: ev.score.protectedFailures, fpCorpusHits: fp.length });
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// using the SAME filename convention `runRuleTests` already reads (`vulnerable`/`clean` in the path).
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
8
9
|
import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
|
|
9
10
|
import { newLedger, verifyLedger } from './lifecycle.js';
|
|
10
11
|
import { signLastScan, verifyLastScan } from '../integrity.js';
|
|
@@ -35,20 +36,85 @@ export function loadLedger(scanRoot, env = process.env) {
|
|
|
35
36
|
const v = verifyLedger(ledger);
|
|
36
37
|
if (!v.ok) return { ...ledger, tampered: true, tamperReason: v.reason };
|
|
37
38
|
const sig = verifyLastScan(text, `${f}.sig`);
|
|
38
|
-
if (sig ===
|
|
39
|
+
if (sig === true) return { ...ledger, verifiedBy: 'install-key' };
|
|
40
|
+
// GA-07 §3 (0.195.27): the install key never leaves the machine that promoted the rule, so on a CI runner (or any other
|
|
41
|
+
// clone) the HMAC cannot verify. A ledger that `promote --sign-key` also signed with the operator's Ed25519 key is accepted
|
|
42
|
+
// when that signature verifies against a key the environment trusts: AGENTIC_SECURITY_RULES_PUBLIC_KEY (set outside the
|
|
43
|
+
// repository, a CI variable) or the trusted-keys store the rule packs already use. The chain check above still applies, and
|
|
44
|
+
// the signature covers the whole file, so a dropped last entry fails it like any other edit. A project-local key file alone
|
|
45
|
+
// is not trust (an author who can write .agentic-security/ could add one), which is why the env variable is the CI route.
|
|
46
|
+
const author = verifyAuthorSignature(text, `${f}${AUTHOR_SIG_SUFFIX}`, authorKeysFrom(scanRoot, env));
|
|
47
|
+
if (author.ok) return { ...ledger, verifiedBy: 'author-key', authorKeyId: author.keyId };
|
|
48
|
+
if (sig === false) return { ...ledger, tampered: true, tamperReason: `the ledger signature does not verify under this install key${author.present ? ` and its author signature does not verify against any trusted key (${author.reason})` : ' and it carries no author signature (promote with --sign-key to make it portable)'}` };
|
|
39
49
|
if (sig === null && ledger.entries.length && env.AGENTIC_SECURITY_RULES_UNSIGNED !== '1') {
|
|
40
50
|
return { ...ledger, tampered: true, tamperReason: 'the ledger is unsigned (written before 0.195.23 or by another tool); set AGENTIC_SECURITY_RULES_UNSIGNED=1 once to accept and re-sign it' };
|
|
41
51
|
}
|
|
42
|
-
return ledger;
|
|
52
|
+
return { ...ledger, verifiedBy: sig === null ? 'unsigned-accepted' : 'install-key' };
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
|
|
55
|
+
const AUTHOR_SIG_SUFFIX = '.author.sig';
|
|
56
|
+
const PUBLIC_KEY_ENV = 'AGENTIC_SECURITY_RULES_PUBLIC_KEY';
|
|
57
|
+
|
|
58
|
+
/** The Ed25519 public keys a ledger's author signature may verify against: the environment variable (one or more base64 raw keys,
|
|
59
|
+
* comma separated) first, then the rule-pack trust store (bundled keys, and the project file only when the operator honours it). */
|
|
60
|
+
function authorKeysFrom(scanRoot, env) {
|
|
61
|
+
const keys = [];
|
|
62
|
+
for (const k of String(env[PUBLIC_KEY_ENV] || '').split(',').map((s) => s.trim()).filter(Boolean)) keys.push({ id: 'env', publicKey: k, alg: 'ed25519' });
|
|
63
|
+
const origErr = console.error; console.error = () => {};
|
|
64
|
+
try { keys.push(...loadTrustedKeys(scanRoot)); } catch { /* no trust store */ } finally { console.error = origErr; }
|
|
65
|
+
return keys;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function verifyAuthorSignature(text, sigPath, keys) {
|
|
69
|
+
let sig;
|
|
70
|
+
try { sig = fs.readFileSync(sigPath); } catch { return { ok: false, present: false, reason: 'no author signature' }; }
|
|
71
|
+
if (!keys.length) return { ok: false, present: true, reason: `no trusted key (set ${PUBLIC_KEY_ENV})` };
|
|
72
|
+
const body = Buffer.from(text, 'utf8');
|
|
73
|
+
for (const k of keys) {
|
|
74
|
+
try {
|
|
75
|
+
const keyBytes = Buffer.from(k.publicKey, 'base64');
|
|
76
|
+
if (keyBytes.length !== 32) continue;
|
|
77
|
+
const keyObj = crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: keyBytes.toString('base64url') }, format: 'jwk' });
|
|
78
|
+
if (crypto.verify(null, body, keyObj, sig)) return { ok: true, present: true, keyId: k.id || '(unnamed)' };
|
|
79
|
+
} catch { /* next key */ }
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, present: true, reason: 'bad signature' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function signWithAuthorKey(text, privateKeyB64) {
|
|
85
|
+
const privBytes = Buffer.from(privateKeyB64, 'base64');
|
|
86
|
+
if (privBytes.length !== 32) throw new Error('private key must be 32 bytes (raw ed25519)');
|
|
87
|
+
const keyObj = crypto.createPrivateKey({ key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), privBytes]), format: 'der', type: 'pkcs8' });
|
|
88
|
+
return crypto.sign(null, Buffer.from(text, 'utf8'), keyObj);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The public key (base64 raw) that pairs with a raw Ed25519 private key; printed by `promote` so the operator can set it in CI. */
|
|
92
|
+
export function publicKeyOf(privateKeyB64) {
|
|
93
|
+
const privBytes = Buffer.from(privateKeyB64, 'base64');
|
|
94
|
+
if (privBytes.length !== 32) return null;
|
|
95
|
+
const keyObj = crypto.createPrivateKey({ key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), privBytes]), format: 'der', type: 'pkcs8' });
|
|
96
|
+
return Buffer.from(crypto.createPublicKey(keyObj).export({ format: 'jwk' }).x, 'base64url').toString('base64');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Write the ledger with its install-key signature and, when the operator's Ed25519 key is given, an author signature beside it
|
|
101
|
+
* (`ledger.json.author.sig`) that another machine can verify (see loadLedger). Without a key an existing author signature is
|
|
102
|
+
* removed rather than left stale: a signature that no longer matches the file would read as tampering on the next clone.
|
|
103
|
+
*/
|
|
104
|
+
export function saveLedger(scanRoot, ledger, { authorKey = null } = {}) {
|
|
46
105
|
if (ledger?.tampered) return { ok: false, reason: `refusing to overwrite a ledger that failed its integrity check (${ledger.tamperReason})` };
|
|
47
106
|
if (!ensureStateDir(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
48
107
|
const f = statePath(scanRoot, DIR, 'ledger.json');
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
108
|
+
const { verifiedBy, authorKeyId, ...body } = ledger;
|
|
109
|
+
const text = JSON.stringify(body, null, 2);
|
|
110
|
+
let ok = safeWriteState(f, text) && safeWriteState(`${f}.sig`, signLastScan(text));
|
|
111
|
+
let authorSigned = false;
|
|
112
|
+
if (ok && authorKey) {
|
|
113
|
+
try { ok = safeWriteState(`${f}${AUTHOR_SIG_SUFFIX}`, signWithAuthorKey(text, authorKey)); authorSigned = ok; } catch (e) { return { ok: false, reason: `cannot author-sign the ledger: ${String(e.message).slice(0, 120)}` }; }
|
|
114
|
+
} else if (ok) {
|
|
115
|
+
try { fs.unlinkSync(`${f}${AUTHOR_SIG_SUFFIX}`); } catch { /* none to remove */ }
|
|
116
|
+
}
|
|
117
|
+
return { ok, authorSigned };
|
|
52
118
|
}
|
|
53
119
|
|
|
54
120
|
export function saveIR(scanRoot, ir) {
|