@clear-capabilities/agentic-security-scanner 0.195.22 → 0.195.24

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/3596.index.js +1 -1
  3. package/dist/550.index.js +9 -8
  4. package/dist/5620.index.js +59 -10
  5. package/dist/5937.index.js +88 -16
  6. package/dist/7838.index.js +32 -5
  7. package/dist/9441.index.js +50 -7
  8. package/dist/9824.index.js +19 -13
  9. package/dist/agentic-security.mjs +9 -9
  10. package/dist/agentic-security.mjs.sha256 +1 -1
  11. package/dist/{c1e282247b06d51c8ffb.json → daefda0819bb18309b75.json} +3 -3
  12. package/package.json +3 -3
  13. package/src/discovery/detector-spec-synthesis.js +3 -3
  14. package/src/discovery/llm-invoke.js +3 -3
  15. package/src/egress/transport.js +148 -0
  16. package/src/lineage/CLAUDE.md +12 -0
  17. package/src/lineage/DESIGN_GRAPH_BUILDER.md +23 -0
  18. package/src/lineage/driver.js +19 -2
  19. package/src/lineage/engine.js +24 -1
  20. package/src/lineage/graph-builder.js +3 -1
  21. package/src/lineage/lineage-source-catalog.js +143 -0
  22. package/src/lineage/source-seeding.js +142 -16
  23. package/src/lineage/summaries.js +21 -5
  24. package/src/llm-validator/index.js +4 -2
  25. package/src/llm-validator/local-endpoint.js +4 -1
  26. package/src/llm-validator/ollama-provider.js +8 -6
  27. package/src/posture/adversary-agent.js +27 -10
  28. package/src/posture/custom-rules.js +13 -1
  29. package/src/posture/fleet/cli.js +2 -4
  30. package/src/posture/fleet/fleet-manifest.js +4 -1
  31. package/src/posture/fleet/job-runner.js +28 -4
  32. package/src/posture/fleet/org-health.js +23 -0
  33. package/src/posture/fleet/scan-cache.js +1 -1
  34. package/src/posture/flow-narration.js +10 -8
  35. package/src/posture/llm-redteam.js +3 -1
  36. package/src/posture/provenance/coordinator.js +8 -0
  37. package/src/posture/provenance/origin-resolver.js +17 -4
  38. package/src/posture/rule-compiler/cli.js +56 -8
  39. package/src/posture/rule-compiler/corpus.js +10 -2
  40. package/src/posture/rule-compiler/ir.js +10 -1
  41. package/src/posture/rule-compiler/promotion.js +12 -2
  42. package/src/posture/rule-compiler/regression.js +1 -1
  43. package/src/posture/rule-compiler/store.js +27 -7
  44. package/src/posture/strategic-metrics.js +50 -7
  45. package/src/sca/llm-function-extract.js +17 -13
@@ -63,7 +63,7 @@ export function evidenceConversion(findings) {
63
63
  const byState = Object.fromEntries(EVIDENCE_STATES.map((s) => [s, 0]));
64
64
  for (const f of list) byState[evidenceStateOf(f)]++;
65
65
  const converted = list.length - byState['single-signal'];
66
- return { ...rate(converted, list.length), byState, basis: 'findings that reached corroborated or stronger, over all findings' };
66
+ return { status: list.length ? 'measured' : 'unmeasured', ...rate(converted, list.length), byState, basis: 'findings that reached corroborated or stronger, over all findings of the last scan' };
67
67
  }
68
68
 
69
69
  const strongest = (a) => {
@@ -72,21 +72,52 @@ const strongest = (a) => {
72
72
  return disappeared && a.testsRan === true && a.testsOk !== false && a.pocOk === true;
73
73
  };
74
74
 
75
+ const isoOrNull = (v) => (typeof v === 'string' && !Number.isNaN(Date.parse(v)) ? v : null);
76
+
77
+ /**
78
+ * One attempt per finding (GA-11): a fix tried five times is one remediation, not five, so the LATEST recorded attempt for each
79
+ * finding id is the one counted and the earlier ones are reported as `repeated`. An attempt with no finding id cannot be matched to
80
+ * another and counts on its own. Order is by `at` where every record has one, else by position in the log.
81
+ */
82
+ function dedupeAttempts(attempts) {
83
+ const list = Array.isArray(attempts) ? attempts.filter((a) => a && typeof a === 'object') : [];
84
+ const latest = new Map();
85
+ const loose = [];
86
+ list.forEach((a, i) => {
87
+ const key = typeof a.stableId === 'string' && a.stableId ? a.stableId : (typeof a.findingId === 'string' && a.findingId ? a.findingId : null);
88
+ if (!key) { loose.push(a); return; }
89
+ const prev = latest.get(key);
90
+ const newer = !prev || (isoOrNull(a.at) && isoOrNull(prev.at) ? Date.parse(a.at) >= Date.parse(prev.at) : i > prev._i);
91
+ if (newer) latest.set(key, Object.assign(Object.create(null), a, { _i: i }));
92
+ });
93
+ const kept = [...latest.values()].map((a) => { const { _i, ...rest } = a; return rest; }).concat(loose);
94
+ return { attempts: kept, recorded: list.length, repeated: list.length - kept.length };
95
+ }
96
+
97
+ /** The time span the recorded attempts cover: `from` and `to` are the earliest and latest `at`, or `null` when none carries one. */
98
+ function attemptsWindow(attempts) {
99
+ const times = (Array.isArray(attempts) ? attempts : []).map((a) => a && isoOrNull(a.at)).filter(Boolean).sort();
100
+ return { from: times[0] ?? null, to: times[times.length - 1] ?? null };
101
+ }
102
+
75
103
  /** Of the fix attempts, how many reached the strongest verification tier. A lower bound: some classes have no independent proof to run. */
76
104
  export function verifiedRemediationRate(attempts) {
77
- const list = Array.isArray(attempts) ? attempts.filter((a) => a && typeof a === 'object') : [];
105
+ const { attempts: list, recorded, repeated } = dedupeAttempts(attempts);
78
106
  const full = list.filter(strongest).length;
79
107
  const disappearedOnly = list.filter((a) => typeof a.verifiedFull !== 'boolean' && (a.rescanOk === true || (a.ok === true && a.rescanOk !== false)) && !(a.testsRan === true && a.testsOk !== false) && a.pocOk !== true).length;
108
+ const failed = list.filter((a) => a.ok === false || a.verifiedFull === false).length;
80
109
  return {
110
+ status: list.length ? 'measured' : 'unmeasured',
81
111
  ...rate(full, list.length),
82
- disappearedOnly,
83
- basis: 'attempts that passed the re-scan, the project tests and the independent attack replay, over all attempts. A lower bound where a class has no replay',
112
+ recorded, repeated, failed, disappearedOnly,
113
+ window: attemptsWindow(attempts),
114
+ basis: 'the latest attempt per finding that passed the re-scan, the project tests and the independent attack replay, over the latest attempt per finding. A lower bound where a class has no replay',
84
115
  };
85
116
  }
86
117
 
87
- /** Nothing measures it independently of the rule compiler's own corpus, so it is reported as not measured. */
118
+ /** Nothing measures it independently of the rule compiler's own corpus, so it is reported as unmeasured, with the reason. */
88
119
  export function securityLearningRate() {
89
- return { status: 'not-measured', reason: 'the rule compiler grades its rules against a corpus built from the same finding; no independent measurement of novel weaknesses converted into reusable knowledge exists' };
120
+ return { status: 'unmeasured', reason: 'the rule compiler grades its rules against a corpus built from the same finding and the reviewer-supplied independent examples; no independent longitudinal measurement of novel weaknesses converted into reusable knowledge exists' };
90
121
  }
91
122
 
92
123
  /**
@@ -95,11 +126,21 @@ export function securityLearningRate() {
95
126
  * @param {object[]} [o.fixAttempts] fix verification attempts
96
127
  */
97
128
  export function computeStrategicMetrics({ scan = null, fixAttempts = [] } = {}) {
129
+ const attempts = Array.isArray(fixAttempts) ? fixAttempts : [];
98
130
  return {
99
131
  schema: STRATEGIC_METRICS_SCHEMA, version: STRATEGIC_METRICS_VERSION,
132
+ // The scope and time basis (GA-11): these are per-scan figures for ONE scan plus the attempts recorded on this machine, never an
133
+ // organisation trend. The scan is named by its id and start time; the attempts by the span their timestamps cover.
134
+ scope: {
135
+ kind: 'per-scan',
136
+ scanId: scan && typeof scan.scanId === 'string' ? scan.scanId : null,
137
+ scanStartedAt: scan ? isoOrNull(scan.startedAt) : null,
138
+ attempts: { ...attemptsWindow(attempts), recorded: attempts.filter((a) => a && typeof a === 'object').length },
139
+ trend: 'not a trend: one scan and this machine\'s recorded attempts; compare scans by scanId to see movement',
140
+ },
100
141
  assuranceCoverage: assuranceCoverage(scan && scan.scanHealth),
101
142
  evidenceConversion: evidenceConversion(scan && scan.findings),
102
- verifiedRemediationRate: verifiedRemediationRate(fixAttempts),
143
+ verifiedRemediationRate: verifiedRemediationRate(attempts),
103
144
  securityLearningRate: securityLearningRate(),
104
145
  };
105
146
  }
@@ -109,6 +150,7 @@ const pc = (r) => (r && r.percent !== null && r.percent !== undefined ? `${r.per
109
150
  export function renderStrategicMetrics(m) {
110
151
  const ac = m.assuranceCoverage; const ec = m.evidenceConversion; const vr = m.verifiedRemediationRate;
111
152
  const L = [];
153
+ if (m.scope) L.push(`Scope: one scan${m.scope.scanId ? ` (${m.scope.scanId}${m.scope.scanStartedAt ? `, started ${m.scope.scanStartedAt}` : ''})` : ''} and ${m.scope.attempts.recorded} fix attempt(s) recorded on this machine${m.scope.attempts.from ? ` between ${m.scope.attempts.from} and ${m.scope.attempts.to}` : ''}; not a trend`);
112
154
  L.push(`Assurance Coverage: ${ac.percent === null ? 'not measurable' : `${ac.percent}% (the lower of the two components)`}${ac.scanStatus ? `; scan ${ac.scanStatus}` : ''}`);
113
155
  if (ac.files) L.push(` files: ${ac.files.numerator} of ${ac.files.denominator} completed (${pc(ac.files)}); ${ac.files.skipped} skipped, ${ac.files.unanalysed} not analysed, ${ac.files.timedOut} timed out`);
114
156
  if (ac.analyzers) L.push(` analyzers: ${ac.analyzers.numerator} of ${ac.analyzers.denominator} completed (${pc(ac.analyzers)}); ${ac.analyzers.failed} failed, ${ac.analyzers.timedOut} timed out`);
@@ -117,6 +159,7 @@ export function renderStrategicMetrics(m) {
117
159
  L.push(` ${EVIDENCE_STATES.map((s) => `${s} ${ec.byState[s]}`).join(', ')}`);
118
160
  L.push(`Verified Remediation Rate: ${vr.numerator} of ${vr.denominator} fix attempts reached the strongest verification tier (${pc(vr)})${vr.denominator ? '; a lower bound where a class has no attack replay' : ''}`);
119
161
  if (vr.disappearedOnly) L.push(` ${vr.disappearedOnly} satisfied only the re-scan, which is also what deleting the code produces`);
162
+ if (vr.repeated) L.push(` ${vr.recorded} attempt(s) recorded; ${vr.repeated} earlier attempt(s) on the same finding counted once, by the latest`);
120
163
  L.push(`Security Learning Rate: not measured. ${m.securityLearningRate.reason}`);
121
164
  return L.join('\n');
122
165
  }
@@ -10,6 +10,8 @@ import * as crypto from 'node:crypto';
10
10
  import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
12
  import { evaluateEgress } from '../egress/policy.js';
13
+ import { modelFetch } from '../egress/transport.js';
14
+ import { resolveProvider, buildProviderRequest } from '../llm-validator/providers.js';
13
15
 
14
16
  const CACHE_DIR = process.env.XDG_CONFIG_HOME
15
17
  ? path.join(process.env.XDG_CONFIG_HOME, 'agentic-security', 'llm-sca-cache')
@@ -37,24 +39,26 @@ export function isLlmScaEnabled() {
37
39
  return process.env.AGENTIC_SECURITY_LLM_SCA === '1';
38
40
  }
39
41
 
40
- function _endpointConfig() {
41
- const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
42
- const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY;
43
- const model = process.env.AGENTIC_SECURITY_LLM_MODEL || 'unknown';
44
- return endpoint ? { endpoint, apiKey, model } : null;
42
+ // The provider is resolved the same way the validator resolves it (GA-06.3): the `local` preset refuses a non-loopback endpoint here
43
+ // too, instead of this module reading the raw endpoint variable and calling it regardless. The Ollama provider speaks a different
44
+ // protocol and this extractor does not implement it, so it is treated as not configured.
45
+ function _endpointConfig(env = process.env) {
46
+ const r = resolveProvider({ role: 'validate', env });
47
+ if (!r.ok || r.config.provider === 'ollama') return null;
48
+ return { endpoint: r.config.endpoint, apiKey: r.config.apiKey, model: r.config.model || 'unknown', shape: r.config.shape };
45
49
  }
46
50
 
47
51
  async function _askLlm(prompt, config) {
48
- const headers = { 'Content-Type': 'application/json' };
49
- if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
50
- const resp = await fetch(config.endpoint, {
52
+ const req = buildProviderRequest(config, prompt, 256);
53
+ const resp = await modelFetch(config.endpoint, {
51
54
  method: 'POST',
52
- headers,
53
- body: JSON.stringify({ prompt, model: config.model }),
54
- signal: AbortSignal.timeout(15000),
55
- });
55
+ headers: req.headers,
56
+ body: JSON.stringify(req.body),
57
+ }, { timeoutMs: 15000 });
56
58
  if (!resp.ok) return null;
57
- const text = await resp.text();
59
+ let text = await resp.text();
60
+ // a vendor shape wraps the model's text in its own envelope; the generic shape's reply is searched as it came
61
+ try { const t = req.extractText(JSON.parse(text)); if (typeof t === 'string' && t) text = t; } catch { /* not an envelope */ }
58
62
  const jsonMatch = text.match(/\{[^{}]*"functions"\s*:\s*\[[^\]]*\][^{}]*\}/);
59
63
  if (!jsonMatch) return null;
60
64
  try { return JSON.parse(jsonMatch[0]); } catch { return null; }