@clear-capabilities/agentic-security-scanner 0.172.0 → 0.172.1
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 +14 -0
- package/dist/1336.index.js +101 -101
- package/dist/7405.index.js +23 -6
- package/dist/9382.index.js +205 -39
- package/dist/agentic-security.mjs +3 -3
- 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/adapters/haskell-hie.js +28 -1
- package/src/context-intelligence/canonical.js +25 -0
- package/src/context-intelligence/cli.js +14 -10
- package/src/context-intelligence/config.js +2 -1
- package/src/context-intelligence/controls.js +59 -9
- package/src/context-intelligence/entrypoints.js +2 -2
- package/src/context-intelligence/evaluation/index.js +11 -4
- package/src/context-intelligence/evaluation/scripted-reader.js +12 -2
- package/src/context-intelligence/reachability.js +8 -2
- package/src/context-intelligence/review.js +61 -12
|
@@ -20,14 +20,14 @@ import { redactPayload } from '../egress/redact.js';
|
|
|
20
20
|
import { evaluateEgress } from '../egress/policy.js';
|
|
21
21
|
import { resolveProvider } from '../llm-validator/providers.js';
|
|
22
22
|
import { resolveLlmInvokeWithDecision } from '../discovery/llm-invoke.js';
|
|
23
|
-
import { hashOf, safeRelPath, sha256 } from './canonical.js';
|
|
23
|
+
import { hashOf, safeLabel, safeRelPath, sha256 } from './canonical.js';
|
|
24
24
|
import { estimateTokens } from './packer.js';
|
|
25
25
|
import { REVIEW_SCHEMA_VERSION, packRef } from './prompts/packs.js';
|
|
26
26
|
|
|
27
27
|
export const CONCLUSIONS = Object.freeze(['potential_issue', 'no_issue_observed', 'insufficient_evidence']);
|
|
28
28
|
export const LIKELIHOODS = Object.freeze(['low', 'medium', 'high']);
|
|
29
29
|
const FORBIDDEN_MODEL_FIELDS = Object.freeze(['severity', 'proofTier', 'proof_tier', 'confirmation', 'confirmed', 'status', 'verdict', 'shipVerdict', 'ship_verdict', 'exploitability', 'cvss', 'exploit_confirmed', 'EXPLOIT_CONFIRMED']);
|
|
30
|
-
const MAX_CITATION_SPAN =
|
|
30
|
+
const MAX_CITATION_SPAN = 60;
|
|
31
31
|
const MAX_STR = 1200;
|
|
32
32
|
const MAX_ITEMS = 24;
|
|
33
33
|
|
|
@@ -65,8 +65,10 @@ export function buildReviewPrompt({ pack, entrypoints, packageTexts, contextHash
|
|
|
65
65
|
for (const g of pack.guidance) lines.push(`- ${g}`);
|
|
66
66
|
lines.push('', 'OUTPUT (JSON, evidence listed first):', JSON.stringify(OUTPUT_SHAPE, null, 2), '');
|
|
67
67
|
entrypoints.forEach((ep, i) => {
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
// The label comes from a route string in the repository, so it is DATA: it goes inside the fence, sanitised, and never
|
|
69
|
+
// into the instruction text around it.
|
|
70
|
+
const fenced = fenceUntrusted(`ENTRY POINT LABEL (data): ${safeLabel(ep.label, 120)}\n${packageTexts[i]}`, `context-${i + 1}`);
|
|
71
|
+
lines.push(`ENTRY POINT ${i + 1} (id ${ep.id})`);
|
|
70
72
|
lines.push(fenced.text, '');
|
|
71
73
|
});
|
|
72
74
|
const prompt = lines.join('\n');
|
|
@@ -102,18 +104,39 @@ export function redactPackageText(text, { scanRoot }) {
|
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
/** Provider/model identity used in the job key, from the same resolver the rest of the discovery layer uses. */
|
|
105
|
-
export function
|
|
107
|
+
export function isLoopbackEndpoint(endpoint) {
|
|
108
|
+
if (!endpoint) return false;
|
|
109
|
+
const parts = String(endpoint).split(',').map(x => x.trim()).filter(Boolean);
|
|
110
|
+
if (!parts.length) return false;
|
|
111
|
+
return parts.every(u => {
|
|
112
|
+
try {
|
|
113
|
+
const h = new URL(u).hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
114
|
+
return h === 'localhost' || h === '::1' || /^127\.\d+\.\d+\.\d+$/.test(h);
|
|
115
|
+
} catch { return false; }
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function describeModel({ allowRemote = false } = {}) {
|
|
120
|
+
let m;
|
|
106
121
|
try {
|
|
107
122
|
const r = resolveProvider({ role: 'hunt' });
|
|
108
|
-
if (r.ok)
|
|
123
|
+
if (r.ok) m = { provider: r.config.provider, model: r.config.model || process.env.AGENTIC_SECURITY_LLM_MODEL || 'endpoint-default', endpoint: r.config.endpoint || null };
|
|
109
124
|
} catch { /* fall through */ }
|
|
110
|
-
|
|
111
|
-
|
|
125
|
+
if (!m) {
|
|
126
|
+
const ep = process.env.AGENTIC_SECURITY_LLM_ENDPOINT || null;
|
|
127
|
+
m = { provider: ep ? 'byo' : null, model: process.env.AGENTIC_SECURITY_LLM_MODEL || (ep ? 'endpoint-default' : null), endpoint: ep };
|
|
128
|
+
}
|
|
129
|
+
// `remote` is true for any endpoint that is not loopback; an endpoint-less provider is judged remote too, since its host is not visible here.
|
|
130
|
+
const remote = m.provider ? !(m.provider === 'local' || isLoopbackEndpoint(m.endpoint)) : false;
|
|
131
|
+
return { ...m, remote, remoteApproved: allowRemote === true };
|
|
112
132
|
}
|
|
113
133
|
|
|
114
134
|
/** Evaluate the egress policy for every file that would appear in the prompt. Returns { allowed, reason }. */
|
|
115
135
|
export function checkEgressForFiles(files, { scanRoot, model }) {
|
|
116
136
|
if (!model.endpoint) return { allowed: true, evaluated: false };
|
|
137
|
+
// Source bundles leave the machine only when the operator has said so. A loopback endpoint needs no approval; any
|
|
138
|
+
// other endpoint needs --allow-remote (or review.allowRemote), on top of the egress policy below, never instead of it.
|
|
139
|
+
if (model.remote && !model.remoteApproved) return { allowed: false, reason: 'remote-endpoint-not-approved (pass --allow-remote or set review.allowRemote after reviewing the egress policy)', file: files[0] || null, evaluated: false };
|
|
117
140
|
for (const file of files) {
|
|
118
141
|
const d = evaluateEgress({ scanRoot, purpose: 'context-review', endpoint: model.endpoint, provider: model.provider, model: model.model, path: file });
|
|
119
142
|
if (!d.allowed) return { allowed: false, reason: d.reason, file, evaluated: true };
|
|
@@ -149,14 +172,29 @@ function normalizeCitations(list) {
|
|
|
149
172
|
return { items, malformed };
|
|
150
173
|
}
|
|
151
174
|
|
|
152
|
-
|
|
175
|
+
const CLAIM_STOPWORDS = new Set(['this', 'that', 'with', 'from', 'which', 'where', 'when', 'does', 'have', 'been', 'would', 'could', 'should', 'into', 'over', 'only', 'also', 'shows', 'show', 'line', 'lines', 'code', 'file', 'function']);
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A claim is grounded when at least one meaningful word in it (or the first five letters of a longer one, so "ownership"
|
|
179
|
+
* meets `ownerId`) appears in the lines it cites. This does not prove the claim is true; it stops a citation that points at
|
|
180
|
+
* real lines while describing something else, and an empty claim, from being accepted as evidence.
|
|
181
|
+
*/
|
|
182
|
+
export function claimGrounded(claim, citedText) {
|
|
183
|
+
const hay = String(citedText).toLowerCase();
|
|
184
|
+
const words = (String(claim).toLowerCase().match(/[a-z_][a-z0-9_]{3,}/g) || []).filter(w => !CLAIM_STOPWORDS.has(w));
|
|
185
|
+
return words.some(w => hay.includes(w.length > 5 ? w.slice(0, 5) : w));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A citation resolves when it is a real, bounded range inside a segment the reviewed package contained, and its claim is about those lines. */
|
|
153
189
|
export function resolveCitation(c, segments, fileContents) {
|
|
154
190
|
if (c.startLine < 1 || c.endLine < c.startLine || c.endLine - c.startLine > MAX_CITATION_SPAN) return 'range-invalid';
|
|
155
191
|
const text = fileContents[c.file];
|
|
156
192
|
if (typeof text !== 'string') return 'file-not-in-snapshot';
|
|
157
|
-
|
|
193
|
+
const fileLines = text.split('\n');
|
|
194
|
+
if (c.endLine > fileLines.length) return 'beyond-end-of-file';
|
|
158
195
|
const inside = segments.some(s => s.file === c.file && c.startLine >= s.startLine && c.endLine <= s.endLine);
|
|
159
|
-
|
|
196
|
+
if (!inside) return 'outside-provided-context';
|
|
197
|
+
return claimGrounded(c.claim, fileLines.slice(c.startLine - 1, c.endLine).join('\n')) ? null : 'claim-not-grounded-in-cited-lines';
|
|
160
198
|
}
|
|
161
199
|
|
|
162
200
|
function decline(base, reasons, extra = {}) {
|
|
@@ -245,13 +283,24 @@ export function verifyReview({ raw, pack, entrypoints, manifests, packageIndexes
|
|
|
245
283
|
out.downgradedFrom = 'no_issue_observed';
|
|
246
284
|
out.conclusion = 'insufficient_evidence';
|
|
247
285
|
out.downgradeReason = 'no-mitigation-cited';
|
|
286
|
+
} else if (dim) {
|
|
287
|
+
// The cited mitigation must be a control the deterministic pass also recognised, and one the text actually shows:
|
|
288
|
+
// a model cannot clear a route by pointing at an arbitrary line or at a control known only from its name.
|
|
289
|
+
const recognised = mit.items.some(c => manifests.some(m => ((m.protection[dim] || {}).evidence || []).some(e => e.file === c.file && e.line >= c.startLine && e.line <= c.endLine)));
|
|
290
|
+
const onlyInferred = manifests.every(m => (m.protection[dim] || {}).state === 'present' && (m.protection[dim] || {}).strength === 'inferred');
|
|
291
|
+
if (!recognised || onlyInferred) {
|
|
292
|
+
out.downgradedFrom = 'no_issue_observed';
|
|
293
|
+
out.conclusion = 'insufficient_evidence';
|
|
294
|
+
out.downgradeReason = !recognised ? 'mitigation-not-a-recognised-control' : 'only-inferred-controls';
|
|
295
|
+
}
|
|
248
296
|
}
|
|
249
297
|
}
|
|
250
298
|
|
|
251
299
|
// how the model's reading compares with the deterministic reading (metadata only; neither overrides the other)
|
|
252
300
|
if (dim) {
|
|
253
301
|
const states = manifests.map(m => m.protection[dim]?.state);
|
|
254
|
-
|
|
302
|
+
// `present` counts as agreement only when the control is shown by the text; a control known only from its name is neutral.
|
|
303
|
+
const present = manifests.every(m => m.protection[dim]?.state === 'present' && m.protection[dim]?.strength !== 'inferred');
|
|
255
304
|
const absent = states.some(s => s === 'not_present' || s === 'evaluated_and_ineffective');
|
|
256
305
|
out.deterministicProtection = { dimension: dim, states };
|
|
257
306
|
out.deterministicAgreement = out.conclusion === 'potential_issue' ? (absent ? 'agrees' : present ? 'conflicts' : 'neutral') : (out.conclusion === 'no_issue_observed' ? (present ? 'agrees' : absent ? 'conflicts' : 'neutral') : 'neutral');
|