@clear-capabilities/agentic-security-scanner 0.195.26 → 0.195.28
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 +39 -7
- package/dist/396.index.js +936 -0
- package/dist/{60f58f89664e53fc2f96.json → 3eabd90d6d9c8c8dbbd9.json} +4 -3
- package/dist/5856.index.js +634 -0
- package/dist/7033.index.js +74 -14
- package/dist/7838.index.js +22 -0
- package/dist/9220.index.js +24 -1
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -3
- package/src/dataflow/catalog.js +42 -1
- package/src/engine.js +90 -10
- package/src/language/CLAUDE.md +6 -0
- package/src/language/assurance.js +53 -2
- package/src/language/haskell-budget-state.js +85 -0
- package/src/language/haskell-coverage.js +230 -0
- package/src/language/haskell-project-models.js +107 -0
- package/src/language/nix-eval-isolation.js +74 -14
- package/src/llm-validator/bounded-context.js +87 -0
- package/src/llm-validator/claim-groups.js +105 -0
- package/src/llm-validator/disposition.js +264 -0
- package/src/llm-validator/index.js +52 -26
- package/src/pipeline/evidence-record.js +112 -0
- package/src/pipeline/scan-health.js +12 -1
- package/src/posture/CLAUDE.md +10 -2
- package/src/posture/artifact-registry.js +2 -0
- package/src/posture/custom-rules.js +7 -2
- package/src/posture/drift.js +19 -1
- 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/reachability-filter.js +3 -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/posture/suppressions.js +21 -0
- package/src/posture/watch-mode.js +22 -1
- package/src/report/disposition-view.js +90 -0
- package/src/report/index.js +47 -0
- package/src/runScan.js +60 -6
- package/src/sast/oidc-trust.js +74 -0
- package/src/sast/pipeline.js +14 -1
- package/src/sast/workflow-flow.js +272 -0
- package/src/sca/basis.js +88 -0
- package/src/scope/flags.js +55 -0
- package/src/scope/inventory.js +241 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Evidence contract (PRD FR 10): additive scan-record and finding-record fields. Nothing here replaces an existing
|
|
2
|
+
// field; every field is derived from data the scan already holds or from read-only git queries on the scan root.
|
|
3
|
+
|
|
4
|
+
import * as crypto from 'node:crypto';
|
|
5
|
+
import * as cp from 'node:child_process';
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
9
|
+
|
|
10
|
+
export const SCAN_RECORD_SCHEMA = 'scan-record/1';
|
|
11
|
+
export const FINDING_RECORD_SCHEMA = 'finding-record/1';
|
|
12
|
+
const DIRTY_CAP = 200;
|
|
13
|
+
|
|
14
|
+
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
15
|
+
|
|
16
|
+
function git(root, args) {
|
|
17
|
+
const r = cp.spawnSync('git', hardenGitArgs(args), { cwd: root, env: hardenGitEnv({}), encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 });
|
|
18
|
+
return r.status === 0 ? r.stdout.toString('utf8') : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Source commit and digests of the files that differ from it, so a result is tied to the exact code scanned. */
|
|
22
|
+
export function sourceState(root) {
|
|
23
|
+
const inside = git(root, ['rev-parse', '--is-inside-work-tree']);
|
|
24
|
+
if (!inside || inside.trim() !== 'true') return { vcs: 'none', commit: null, dirty: null, dirtyFiles: [], dirtyFilesTruncated: false };
|
|
25
|
+
const commit = (git(root, ['rev-parse', 'HEAD']) || '').trim() || null;
|
|
26
|
+
const status = git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']);
|
|
27
|
+
const entries = (status || '').split('\0').filter(Boolean).map((e) => ({ code: e.slice(0, 2), file: e.slice(3) }));
|
|
28
|
+
const dirtyFiles = entries.slice(0, DIRTY_CAP).map((e) => {
|
|
29
|
+
let digest = null;
|
|
30
|
+
try { const abs = path.join(root, e.file); if (fs.lstatSync(abs).isFile()) digest = sha(fs.readFileSync(abs)); } catch { /* deleted */ }
|
|
31
|
+
return { file: e.file, status: e.code.trim() || '?', sha256: digest };
|
|
32
|
+
});
|
|
33
|
+
return { vcs: 'git', commit, dirty: entries.length > 0, dirtyFiles, dirtyFilesTruncated: entries.length > DIRTY_CAP, dirtyCount: entries.length };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Flags that shaped the run, with values that could carry secrets or local paths reduced to their names. */
|
|
37
|
+
export function invocationSummary(argv) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
if (!a.startsWith('--')) continue;
|
|
42
|
+
let [k, v] = a.split(/=(.*)/s, 2);
|
|
43
|
+
// `--flag value`: keep a short non-path value so the record shows the format and mode that were chosen.
|
|
44
|
+
if (v === undefined && argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && /^[A-Za-z0-9_.,:-]{1,40}$/.test(argv[i + 1]) && !argv[i + 1].includes('/')) v = argv[i + 1];
|
|
45
|
+
out.push(/key|token|secret|password/i.test(k) ? `${k}=<redacted>` : (v !== undefined ? `${k}=${v}` : k));
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildScanRecord({ root, scan, meta, argv = [], engineVersion, mode }) {
|
|
51
|
+
const h = scan.scanHealth || {};
|
|
52
|
+
const model = scan.aiAssistance || null;
|
|
53
|
+
return {
|
|
54
|
+
schema: SCAN_RECORD_SCHEMA,
|
|
55
|
+
tool: { name: 'agentic-security', version: engineVersion || null },
|
|
56
|
+
source: sourceState(root),
|
|
57
|
+
invocation: { flags: invocationSummary(argv), mode: mode || (meta && meta.mode) || 'full' },
|
|
58
|
+
ignorePolicy: scan.scope ? { mode: scan.scope.policy.mode, ruleDigest: scan.scope.policy.ruleDigest, trackedFileSemantics: scan.scope.policy.trackedFileSemantics } : { mode: 'legacy-walk', ruleDigest: null, trackedFileSemantics: false },
|
|
59
|
+
rules: { rulesetVersion: scan._rulesetVersion || null, rulesetSource: scan._rulesetVersionSource || null },
|
|
60
|
+
model: model ? { provider: model.provider, model: model.model, egress: model.egress, stages: model.stages } : { configured: false },
|
|
61
|
+
capability: { status: h.status || null, conditions: Array.isArray(h.conditions) ? h.conditions.length : 0 },
|
|
62
|
+
fileCounts: { scanned: scan.filesScanned ?? null, skipped: scan.scope ? scan.scope.skippedCount : null, included: scan.scope ? scan.scope.includedCount : null },
|
|
63
|
+
// Wall-clock time differs on every run, so deterministic mode leaves it out and the record stays byte-identical.
|
|
64
|
+
elapsedMs: process.env.AGENTIC_SECURITY_DETERMINISTIC === '1' ? null : (meta && meta.durationMs != null ? meta.durationMs : null),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The span a finding occupies: source line through sink line when both are known, else its own line. */
|
|
69
|
+
function spanOf(f) {
|
|
70
|
+
const lines = [f.line, f.source && f.source.line, f.sink && f.sink.line].filter((n) => Number.isInteger(n));
|
|
71
|
+
if (!lines.length) return null;
|
|
72
|
+
return { startLine: Math.min(...lines), endLine: Math.max(...lines) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Finding record. `fileContents` maps scan-relative path to text. Existing fields are untouched. */
|
|
76
|
+
export function annotateFindingRecords(findings, fileContents, { engineVersion = null } = {}) {
|
|
77
|
+
for (const f of findings || []) {
|
|
78
|
+
if (!f || f.record) continue;
|
|
79
|
+
const text = fileContents && (fileContents instanceof Map ? fileContents.get(f.file) : fileContents[f.file]);
|
|
80
|
+
f.record = {
|
|
81
|
+
schema: FINDING_RECORD_SCHEMA,
|
|
82
|
+
stableId: f.stableId || null,
|
|
83
|
+
path: f.file || null,
|
|
84
|
+
sourceHash: typeof text === 'string' ? sha(text) : null,
|
|
85
|
+
span: spanOf(f),
|
|
86
|
+
ruleFamily: f.family || null,
|
|
87
|
+
ruleVersion: engineVersion,
|
|
88
|
+
originalSeverity: f.originalSeverity || f.severity || null,
|
|
89
|
+
confidence: typeof f.confidence === 'number' ? f.confidence : null,
|
|
90
|
+
disposition: f.disposition || null,
|
|
91
|
+
evidence: {
|
|
92
|
+
sourceToSink: f.source || f.sink ? { source: f.source || null, sink: f.sink || null } : null,
|
|
93
|
+
guards: f.falsification ? [f.falsification] : [],
|
|
94
|
+
missingEvidence: (f.dispositionDetail && f.dispositionDetail.missingEvidence) || [],
|
|
95
|
+
},
|
|
96
|
+
reviewerVerdicts: Array.isArray(f.reviewerVerdicts) ? f.reviewerVerdicts : [],
|
|
97
|
+
modelOpinion: f.validator_verdict ? { verdict: f.validator_verdict, confidence: f.llm_confidence ?? null } : null,
|
|
98
|
+
remediation: f.fix || f.remediation || null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return findings;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Raw and displayed findings must reconcile: every raw item is either displayed or accounted for by a named reason.
|
|
106
|
+
* Returns { ok, raw, displayed, accounted, gap }.
|
|
107
|
+
*/
|
|
108
|
+
export function reconcileFindings({ raw, displayed, hidden = {} }) {
|
|
109
|
+
const accounted = Object.values(hidden).reduce((a, b) => a + b, 0);
|
|
110
|
+
const gap = raw - displayed - accounted;
|
|
111
|
+
return { ok: gap === 0, raw, displayed, accounted, gap };
|
|
112
|
+
}
|
|
@@ -42,6 +42,17 @@ export const SCAN_HEALTH_SCHEMA_VERSION = 1;
|
|
|
42
42
|
* never a fabricated all-zero summary.
|
|
43
43
|
* @returns {object} scanHealth per PRD §10.3, additive fields only.
|
|
44
44
|
*/
|
|
45
|
+
// The per-file table travels with the record up to this many files; beyond it only the count is kept
|
|
46
|
+
// (the aggregate stages, rates and incomplete lists above are always present).
|
|
47
|
+
const HASKELL_BYFILE_INLINE_MAX = 2000;
|
|
48
|
+
function summarizeHaskellCoverage(h) {
|
|
49
|
+
if (!h) return null;
|
|
50
|
+
const n = h.byFile ? Object.keys(h.byFile).length : 0;
|
|
51
|
+
if (n <= HASKELL_BYFILE_INLINE_MAX) return { ...h, byFileCount: n };
|
|
52
|
+
const { byFile, ...rest } = h;
|
|
53
|
+
return { ...rest, byFileCount: n, byFileOmitted: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engineErrors = null, deepStatus = null, analyzerCoverage = null, lineageStatus = null, languageCoverage = null, supplyChain = null } = {}) {
|
|
46
57
|
const conditions = [];
|
|
47
58
|
const safeAnnotatorErrors = Array.isArray(annotatorErrors) ? annotatorErrors : [];
|
|
@@ -130,7 +141,7 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
|
|
|
130
141
|
schemaVersion: SCAN_HEALTH_SCHEMA_VERSION,
|
|
131
142
|
status,
|
|
132
143
|
...(trustFindings.length ? { buildTrust: { findings: trustFindings.length, uncertain: trustUncertain } } : {}),
|
|
133
|
-
...(languageCoverage ? { languageCoverage: { totals: languageCoverage.totals ?? null, byKind: languageCoverage.byKind ?? null, ...(languageCoverage.limitations ? { limitations: languageCoverage.limitations } : {}), ...(languageCoverage.capabilities ? { capabilities: languageCoverage.capabilities } : {}), ...(languageCoverage.optionalModes ? { optionalModes: languageCoverage.optionalModes } : {}) } } : {}),
|
|
144
|
+
...(languageCoverage ? { languageCoverage: { totals: languageCoverage.totals ?? null, byKind: languageCoverage.byKind ?? null, ...(languageCoverage.limitations ? { limitations: languageCoverage.limitations } : {}), ...(languageCoverage.capabilities ? { capabilities: languageCoverage.capabilities } : {}), ...(languageCoverage.optionalModes ? { optionalModes: languageCoverage.optionalModes } : {}), ...(languageCoverage.haskell ? { haskell: summarizeHaskellCoverage(languageCoverage.haskell) } : {}) } } : {}),
|
|
134
145
|
files: {
|
|
135
146
|
expected: scanMeta?.checkpoint?.total ?? null,
|
|
136
147
|
scanned: scanMeta?.filesScanned ?? null,
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -906,7 +906,7 @@ are both pre-existing unrelated fields.
|
|
|
906
906
|
| `coordinator.js` | the integration point — budget, cache, per-finding dispatch, the terminal-status guarantee |
|
|
907
907
|
| `git-evidence.js` | the only Git wrapper (`getRepoState`, `blameLine`, `candidateCommitsForLine`, `getBlobAtCommit`, `commitMeta`) |
|
|
908
908
|
| `origin-resolver.js` | which commit introduced a SAST finding |
|
|
909
|
-
| `dag-walk.js` | (M3 §3.1) non-first-parent DAG walk + revert/cherry-pick detection for `--provenance deep` |
|
|
909
|
+
| `dag-walk.js` | (M3 §3.1) non-first-parent DAG walk + revert/cherry-pick detection for `--provenance deep`. Since 0.195.27 (GA-05) `origin-resolver.js` applies the revert and cherry-pick annotation to EVERY origin deep mode resolves, not only the ones the retry loop found, and a `Reapply "..."` subject (git's name for a revert of a revert since 2.43) counts as a revert; the diff inversion still decides. The corpus `bench/provenance-accuracy` holds ten adverse histories (revert, cherry-pick, squash, amend, `git mv`, dirty file, first-parent merge, conflict resolution, transitive lockfile, unreachable provider) and the contract is `docs/reference/finding-provenance-contract.md` |
|
|
910
910
|
| `predicate-replay.js` | was this finding's condition true at commit X (calls `runFullScan` on that commit's blobs) |
|
|
911
911
|
| `sca-origin.js` | which commit moved a directly-declared dependency version into an advisory's vulnerable range |
|
|
912
912
|
| `transitive-sca.js` | (M3 §3.2) the same question for a TRANSITIVE dependency, re-deriving lockfile ancestry per historical commit |
|
|
@@ -918,7 +918,7 @@ are both pre-existing unrelated fields.
|
|
|
918
918
|
| `schema.js` | the status/method/role/confidence enums, `emptyProvenance`, `redactFindingProvenance`, `isProvenanceHealthy` |
|
|
919
919
|
| `validate.js` | shape assertion for tests |
|
|
920
920
|
| `missing-control-resolver.js` | (M3 §3.3, FR-PROV-017) when a previously-observed safeguard disappeared — **wired into `coordinator.js`**: `resolveMissingControlOrigin` calls `resolveMissingControl` for any finding with `missingControlCandidate:true` (today, `sast/rate-limit.js`'s findings) |
|
|
921
|
-
| `providers/config.js`, `providers/github.js`, `providers/gitlab.js` | (M3 §3.4, FR-PROV-022) GitHub/GitLab PR-metadata + CODEOWNERS fetch, config resolved from `.agentic-security/provenance-providers.yml` / token env vars — **wired into `coordinator.js`**: `resolveProviderConfig` is resolved once per scan in `annotateGitProvenance`, and `fetchPRMetadata`/`fetchCodeowners` are called per `complete`-status finding (capped, see `MAX_PROVIDER_ENRICHMENTS_PER_SCAN`), landing on `findingProvenance.providerEnrichment` |
|
|
921
|
+
| `providers/config.js`, `providers/github.js`, `providers/gitlab.js` | (M3 §3.4, FR-PROV-022) GitHub/GitLab PR-metadata + CODEOWNERS fetch, config resolved from `.agentic-security/provenance-providers.yml` / token env vars — **wired into `coordinator.js`**: `resolveProviderConfig` is resolved once per scan in `annotateGitProvenance`, and `fetchPRMetadata`/`fetchCodeowners` are called per `complete`-status finding (capped, see `MAX_PROVIDER_ENRICHMENTS_PER_SCAN`), landing on `findingProvenance.providerEnrichment`. Since 0.195.27 a provider that errs or does not answer returns `{unavailable, reason}` instead of `null`, and the coordinator records a limitation naming the provider and the reason; `null` means "no pull request names this commit" and nothing else |
|
|
922
922
|
| `repo-lineage.js` | (M4 §4.2) loads + fully verifies an operator-declared `.agentic-security/repo-lineage.json` cross-repo link (local clones only, no remote fetch) — used by `origin-resolver.js`'s root-commit case, not a standalone-unwired module |
|
|
923
923
|
| `ai-authorship.js` | (M4 §4.3) extensible AI-authorship verifier registry (`registerAIAuthorshipVerifier`/`resolveAIAuthorship`), defaults to `{status:'unknown', verifier:null}` with nothing registered (today's real state) — wired into `origin-resolver.js`'s `originFrom`, so every SAST `findingOrigin` carries `aiAuthorship`; scoped to SAST only, not direct/transitive SCA origins |
|
|
924
924
|
|
|
@@ -1014,6 +1014,14 @@ Turns a PROVEN finding into a validated detection, hunts local historical logs f
|
|
|
1014
1014
|
* **Redaction before persistence or display** (`redact.js`), on top of the proof store's secret scrubber. Records live under `.agentic-security/dnr/` (registered in `artifact-registry.js`, evidence class, encrypted by policy, tamper-evident).
|
|
1015
1015
|
* **Names:** a detection is not a finding rule. `rule-compiler/` promotes DETECTION rules for the scanner; `dnr/` produces RESPONSE rules for a SIEM, a WAF or a log search. Do not merge them.
|
|
1016
1016
|
|
|
1017
|
+
## Finding-to-rule compiler: `rule-compiler/` (PRD 3, GA-07)
|
|
1018
|
+
|
|
1019
|
+
`cli.js` (`rules synthesize|evaluate|promote|status|rollback|deprecate|cost|regress`), `ir.js`, `corpus.js`, `mutation.js`, `promotion.js`, `proposal.js`, `lifecycle.js`, `store.js`, `regression.js` and `eligibility.js`; contract `docs/reference/rule-compiler-contract.md`, guide `docs/guides/rule-compiler.md`, tests `test/rule-compiler/` and `test/cli/rule-compiler-cli.test.js` (`test:posture`). Since 0.195.27 (GA-07 §2 and §3):
|
|
1020
|
+
|
|
1021
|
+
* **`negative-corpus.js`** is the shared negative corpus shipped with the scanner: safe snippets as strings in a module (no build step, identical everywhere) that `regression.js` puts in front of the shared false-positive corpus, so every evaluation, promotion and regression is held to it. Keep every entry free of the unsafe form; a vulnerable snippet here fails a correct rule. Directories named in `AGENTIC_SECURITY_RULES_FP_CORPUS` are held to as well.
|
|
1022
|
+
* **The ledger is portable.** `store.js` `saveLedger(root, ledger, { authorKey })` writes `ledger.json.author.sig` (Ed25519, the key `promote --sign-key` takes) beside the per-install HMAC signature, and `loadLedger` accepts a ledger whose chain verifies and whose author signature verifies against `AGENTIC_SECURITY_RULES_PUBLIC_KEY` or the rule-pack trust store when the HMAC does not (another install, a CI runner). A project-local key file alone is not trust. The ledger object carries `verifiedBy` (`install-key`, `author-key`, `unsigned-accepted`), stripped before it is written.
|
|
1023
|
+
* **Promotion records what was reviewed.** `--scope` is required; the ledger entry and `promotionEvent.evidence` carry digests of the evaluation, the provenance and both corpus halves (`regression.js` `corpusHash`), and `regress` reports a rule whose corpus changed after the review. `promotion.js` `dependentExamples` refuses an independent example that equals the original, its patch or a generated mutation after whitespace is collapsed.
|
|
1024
|
+
|
|
1017
1025
|
## Eval Lab and the policy router: `evaluation/lab/`, `routing/policy-router.js` (PRD 6)
|
|
1018
1026
|
|
|
1019
1027
|
Measures a deterministic baseline and model arms, and routes tasks from the measurements. Entry points `evaluation/lab/cli.js` (`cmdEval`, `cmdModelsLab`) and `routing/policy-router.js`; guide `docs/guides/eval-lab.md`; tests `test/eval-lab/`, `test/cli/eval-lab-cli.test.js`.
|
|
@@ -253,6 +253,8 @@ export const ARTIFACT_REGISTRY = [
|
|
|
253
253
|
{ name: 'integrations.yml', kind: 'file', classification: 'operator-config' },
|
|
254
254
|
{ name: 'profile.yml', kind: 'file', classification: 'operator-config' },
|
|
255
255
|
{ name: 'sca-policy.yml', kind: 'file', classification: 'operator-config' },
|
|
256
|
+
{ name: 'haskell-budget.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/language/haskell-budget-state.js (saveBudgetState)', note: 'Haskell parse budgets in effect and every file left unanalysed when one was hit, with a content digest, so the next scan can say what finished since. Rewritten by every scan that has Haskell work left.' },
|
|
257
|
+
{ name: 'haskell-models.json', kind: 'file', classification: 'operator-config', note: 'reviewed project models for Haskell wrappers; only ever loaded by language/haskell-project-models.js' },
|
|
256
258
|
{ name: 'suppressions.yml', kind: 'file', classification: 'operator-config', note: 'audit-tier suppression config; only ever loaded, never saved, by posture/suppressions.js' },
|
|
257
259
|
{ name: 'network-policy.json', kind: 'file', classification: 'operator-config', note: 'documented as an Inputs-section artifact in posture/network-policy-import.js, not a scanner-written digest' },
|
|
258
260
|
{ name: 'privacy-taxonomy.json', kind: 'file', classification: 'operator-config', note: 'FR-402 privacy data-classification taxonomy overrides/additions — read by dataflow/privacy-taxonomy.js, never written by the scanner' },
|
|
@@ -436,6 +436,7 @@ export async function runRuleTests(scanRoot, fixtureGlob) {
|
|
|
436
436
|
const files = await globFiles(fixtureGlob);
|
|
437
437
|
console.log(`Loaded ${rules.length} rule(s); testing against ${files.length} file(s).\n`);
|
|
438
438
|
let fired = 0;
|
|
439
|
+
let failures = 0;
|
|
439
440
|
for (const fp of files) {
|
|
440
441
|
let content = '';
|
|
441
442
|
try { content = fs.readFileSync(fp, 'utf8'); } catch { continue; }
|
|
@@ -446,12 +447,16 @@ export async function runRuleTests(scanRoot, fixtureGlob) {
|
|
|
446
447
|
if (findings.length) {
|
|
447
448
|
fired++;
|
|
448
449
|
const verdict = expectClean ? 'FAIL (false positive)' : 'PASS';
|
|
450
|
+
if (expectClean) failures++;
|
|
449
451
|
console.log(` [${verdict}] ${r.id} → ${fp}:${findings[0].line}`);
|
|
450
452
|
} else if (expectFire) {
|
|
453
|
+
failures++;
|
|
451
454
|
console.log(` [FAIL (missed)] ${r.id} → ${fp}`);
|
|
452
455
|
}
|
|
453
456
|
}
|
|
454
457
|
}
|
|
455
|
-
|
|
456
|
-
|
|
458
|
+
// GA-07 §3 (0.195.27): `ok` reports the verdicts, not merely that the run finished. It used to be `true` whatever the fixtures
|
|
459
|
+
// said, so `rule test` could never fail a build; a missed vulnerable fixture or a hit on a clean one is now a failure.
|
|
460
|
+
console.log(`\n${fired} match(es) across ${files.length} file(s)${failures ? `, ${failures} FAILED` : ''}.`);
|
|
461
|
+
return { ok: failures === 0, rules: rules.length, fired, failures };
|
|
457
462
|
}
|
package/src/posture/drift.js
CHANGED
|
@@ -78,7 +78,21 @@ export function driftBetween(scanA, scanB) {
|
|
|
78
78
|
// Findings — use kind+file+line+vuln as the stable key.
|
|
79
79
|
const allFindingsA = [...(scanA.findings || []), ...(scanA.logicVulns || [])];
|
|
80
80
|
const allFindingsB = [...(scanB.findings || []), ...(scanB.logicVulns || [])];
|
|
81
|
-
|
|
81
|
+
// A claim grouped under an equivalent primary (llm-validator/claim-groups.js) is still present: it keeps its identity for drift,
|
|
82
|
+
// so grouping never shows a finding as fixed, and ungrouping never shows one as new.
|
|
83
|
+
const findings = _diffSets([...allFindingsA, ...(scanA.groupedClaims || [])], [...allFindingsB, ...(scanB.groupedClaims || [])], _findingKey);
|
|
84
|
+
// PRD FR 01: a finding that disappeared because the newer scan did not cover its file is out of scope, never fixed.
|
|
85
|
+
const outOfScope = [];
|
|
86
|
+
if (scanB.scope && Array.isArray(scanB.scope.included)) {
|
|
87
|
+
const inB = new Set(scanB.scope.included);
|
|
88
|
+
const keep = [];
|
|
89
|
+
for (const f of findings.removed) (f && f.file && !inB.has(f.file) ? outOfScope : keep).push(f);
|
|
90
|
+
findings.removed = keep;
|
|
91
|
+
findings.outOfScope = outOfScope;
|
|
92
|
+
} else {
|
|
93
|
+
findings.outOfScope = [];
|
|
94
|
+
findings.scopeUnknown = !!(scanB.scope && scanB.scope.includedOmitted);
|
|
95
|
+
}
|
|
82
96
|
const severity = _severityDelta(allFindingsA, allFindingsB);
|
|
83
97
|
const authBoundaries = _authBoundaryDelta(scanA.routes || [], scanB.routes || []);
|
|
84
98
|
const dataClasses = _dataClassDelta(scanA.routes || [], scanB.routes || []);
|
|
@@ -163,6 +177,10 @@ export function driftToMarkdown(drift) {
|
|
|
163
177
|
lines.push(`**Findings fixed: ${drift.findings.removed.length}**`);
|
|
164
178
|
lines.push('');
|
|
165
179
|
}
|
|
180
|
+
if (drift.findings.outOfScope && drift.findings.outOfScope.length) {
|
|
181
|
+
lines.push(`**Findings not covered by this scan (out of scope, not fixed): ${drift.findings.outOfScope.length}**`);
|
|
182
|
+
lines.push('');
|
|
183
|
+
}
|
|
166
184
|
if (drift.dataClasses.newlyExposed.length) {
|
|
167
185
|
lines.push(`**Newly exposed data classes: ${drift.dataClasses.newlyExposed.join(', ')}**`);
|
|
168
186
|
lines.push('');
|
|
@@ -101,7 +101,7 @@ const MAX_CONCURRENCY = 4;
|
|
|
101
101
|
// quotient: a 200-finding scan under a 60s global budget divides to 300ms,
|
|
102
102
|
// which is less than a single `git blame`'s own 2s timeout and would starve
|
|
103
103
|
// every finding equally instead of a few. 2s is one blame's worth of work.
|
|
104
|
-
const MIN_PER_FINDING_BUDGET_MS = 2000;
|
|
104
|
+
export const MIN_PER_FINDING_BUDGET_MS = 2000;
|
|
105
105
|
|
|
106
106
|
// FR-PROV-022: an 8s-timeout-capped network call per finding is not
|
|
107
107
|
// automatically bounded by the scan's own deadlineAt -- N findings x up to
|
|
@@ -563,7 +563,11 @@ async function resolveAndCache(finding, ctx, cacheKey, isSca, isTransitiveSca, i
|
|
|
563
563
|
const fetchPRFn = ctx.providerName === 'github' ? fetchPRMetadataGithub : fetchPRMetadataGitlab;
|
|
564
564
|
const fetchCodeownersFn = ctx.providerName === 'github' ? fetchCodeownersGithub : fetchCodeownersGitlab;
|
|
565
565
|
const pr = await fetchPRFn(scanRoot, originResult.findingOrigin.commit, ctx.remoteUrl, ctx.providerConfig);
|
|
566
|
-
if (pr) {
|
|
566
|
+
if (pr && pr.unavailable) {
|
|
567
|
+
// GA-05 (0.195.27): forge metadata that could not be fetched is recorded, never silently absent. The origin,
|
|
568
|
+
// status and confidence above are unaffected: they come from local history, which the provider cannot change.
|
|
569
|
+
provenance.limitations.push(`provider metadata unavailable (${ctx.providerName}: ${pr.reason}); the origin commit is unaffected, only the pull request and code owner enrichment is missing`);
|
|
570
|
+
} else if (pr) {
|
|
567
571
|
const codeowners = await fetchCodeownersFn(scanRoot, ctx.remoteUrl, ctx.providerConfig);
|
|
568
572
|
provenance.providerEnrichment = {
|
|
569
573
|
provider: ctx.providerName,
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -25,12 +25,12 @@ const SEVERITY_DEMOTE = {
|
|
|
25
25
|
// keeps full severity. route-reachable-via-function and function-reachable
|
|
26
26
|
// both keep full severity because the vulnerable function is provably
|
|
27
27
|
// called; import-reachable also keeps full severity (imported = uncertain
|
|
28
|
-
// but plausible).
|
|
28
|
+
// but plausible). manifest-only and transitive-only record only that no use was
|
|
29
|
+
// observed, which is unknown reachability, and unknown reachability cannot lower
|
|
30
|
+
// severity (PRD FR 08). Only positive evidence (unreachable, build-only) demotes.
|
|
29
31
|
const DEMOTE_SCA_TIERS = new Set([
|
|
30
32
|
'unreachable', // function never called from project
|
|
31
33
|
'build-only', // dev/build-time dependency only
|
|
32
|
-
'manifest-only', // declared, but no use observed
|
|
33
|
-
'transitive-only', // transitive dep, scope unclear
|
|
34
34
|
]);
|
|
35
35
|
|
|
36
36
|
export function demoteUnreachable(findings, opts = {}) {
|
|
@@ -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];
|