@clear-capabilities/agentic-security-scanner 0.195.26 → 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 +13 -0
- package/bin/agentic-security.js +4 -3
- package/dist/396.index.js +936 -0
- package/dist/7838.index.js +22 -0
- package/dist/{60f58f89664e53fc2f96.json → 90ebba38d8860ec77376.json} +2 -2
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +2 -2
- package/src/posture/CLAUDE.md +10 -2
- package/src/posture/custom-rules.js +7 -2
- package/src/posture/provenance/coordinator.js +6 -2
- package/src/posture/provenance/dag-walk.js +7 -3
- 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
|
@@ -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) {
|