@clear-capabilities/agentic-security-scanner 0.195.21 → 0.195.23
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 +26 -0
- package/bin/agentic-security.js +5 -2
- package/dist/{7feb08356355c30181a1.json → 01087ded263953062654.json} +3 -3
- package/dist/550.index.js +9 -8
- package/dist/5620.index.js +59 -10
- package/dist/5937.index.js +88 -16
- package/dist/7838.index.js +32 -5
- package/dist/9441.index.js +50 -7
- package/dist/9824.index.js +19 -13
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/discovery/CLAUDE.md +4 -0
- package/src/discovery/detector-spec-synthesis.js +203 -0
- package/src/discovery/llm-invoke.js +3 -3
- package/src/egress/transport.js +148 -0
- package/src/engine.js +9 -0
- package/src/integrations/index.js +18 -9
- package/src/llm-validator/index.js +4 -2
- package/src/llm-validator/local-endpoint.js +4 -1
- package/src/llm-validator/ollama-provider.js +8 -6
- package/src/pipeline/scan-health.js +15 -2
- package/src/posture/CLAUDE.md +1 -0
- package/src/posture/adversary-agent.js +27 -10
- package/src/posture/assurance/config.js +2 -0
- package/src/posture/assurance/decision.js +45 -0
- package/src/posture/assurance/rollout.js +3 -0
- package/src/posture/custom-rules.js +13 -1
- package/src/posture/fleet/cli.js +2 -4
- package/src/posture/fleet/fleet-manifest.js +4 -1
- package/src/posture/fleet/job-runner.js +28 -4
- package/src/posture/fleet/org-health.js +23 -0
- package/src/posture/fleet/scan-cache.js +1 -1
- package/src/posture/flow-narration.js +10 -8
- package/src/posture/llm-redteam.js +3 -1
- package/src/posture/provenance/coordinator.js +8 -0
- package/src/posture/provenance/origin-resolver.js +17 -4
- package/src/posture/rule-compiler/cli.js +56 -8
- package/src/posture/rule-compiler/corpus.js +10 -2
- package/src/posture/rule-compiler/ir.js +10 -1
- package/src/posture/rule-compiler/promotion.js +12 -2
- package/src/posture/rule-compiler/regression.js +1 -1
- package/src/posture/rule-compiler/store.js +27 -7
- package/src/posture/strategic-metrics.js +50 -7
- package/src/report/index.js +7 -6
- package/src/sca/llm-function-extract.js +17 -13
|
@@ -15,6 +15,8 @@ import * as path from 'node:path';
|
|
|
15
15
|
import * as yaml from '../util/yaml.js';
|
|
16
16
|
import { statePath } from '../posture/state-dir.js';
|
|
17
17
|
import { portfolioAssuranceEnabled, HEADLINE, BLOCKED_HEADLINE } from '../posture/portfolio/wording.js';
|
|
18
|
+
// One decision for every surface (0.195.22): a digest built from a findings file with no scan health says it cannot confirm, never "safe".
|
|
19
|
+
import { assuranceDecision } from '../posture/assurance/decision.js';
|
|
18
20
|
|
|
19
21
|
function _configPath(scanRoot) {
|
|
20
22
|
return statePath(scanRoot, 'integrations.yml');
|
|
@@ -35,10 +37,10 @@ export function configHas(scanRoot, key) {
|
|
|
35
37
|
// ─── Slack webhook (vibecoder) ───────────────────────────────────────────────
|
|
36
38
|
export function buildSlackDigest(findings, summary, options = {}) {
|
|
37
39
|
const project = options.project || 'project';
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
+
const d = assuranceDecision({ actionable: (summary.critical || 0) + (summary.high || 0), scanHealth: options.scanHealth || null });
|
|
41
|
+
const status = `${d.icon} ${d.headline.toLowerCase()}`;
|
|
40
42
|
const statusLine = portfolioAssuranceEnabled({ option: options.portfolioAssurance })
|
|
41
|
-
? (
|
|
43
|
+
? (d.decision === 'deployable' ? `✅ ${HEADLINE} (completed supported checks only)` : d.decision === 'not_deployable' ? `❌ ${BLOCKED_HEADLINE}` : status) : status;
|
|
42
44
|
const top = (findings || []).slice(0, 3);
|
|
43
45
|
|
|
44
46
|
const lines = [
|
|
@@ -63,13 +65,15 @@ export function buildSlackDigest(findings, summary, options = {}) {
|
|
|
63
65
|
// ─── Discord webhook (vibecoder) ─────────────────────────────────────────────
|
|
64
66
|
export function buildDiscordDigest(findings, summary, options = {}) {
|
|
65
67
|
const project = options.project || 'project';
|
|
66
|
-
const
|
|
68
|
+
const d = assuranceDecision({ actionable: (summary.critical || 0) + (summary.high || 0), scanHealth: options.scanHealth || null });
|
|
69
|
+
const safe = d.decision === 'deployable';
|
|
67
70
|
const top = (findings || []).slice(0, 3);
|
|
71
|
+
const plain = `${d.icon} ${d.headline.toLowerCase()}`;
|
|
68
72
|
return {
|
|
69
73
|
embeds: [{
|
|
70
74
|
title: `🛡 agentic-security — ${project}`,
|
|
71
|
-
color: safe ? 0x2ecc71 : 0xe74c3c,
|
|
72
|
-
description: portfolioAssuranceEnabled({ option: options.portfolioAssurance }) ? (safe ? `✅ ${HEADLINE} (completed supported checks only)` : `❌ ${BLOCKED_HEADLINE}`
|
|
75
|
+
color: safe ? 0x2ecc71 : d.decision === 'not_deployable' ? 0xe74c3c : 0xf1c40f,
|
|
76
|
+
description: portfolioAssuranceEnabled({ option: options.portfolioAssurance }) ? (safe ? `✅ ${HEADLINE} (completed supported checks only)` : d.decision === 'not_deployable' ? `❌ ${BLOCKED_HEADLINE}` : plain) : plain,
|
|
73
77
|
fields: [
|
|
74
78
|
{ name: 'Critical', value: String(summary.critical || 0), inline: true },
|
|
75
79
|
{ name: 'High', value: String(summary.high || 0), inline: true },
|
|
@@ -252,10 +256,11 @@ export async function postPagerDutyEvent(finding) {
|
|
|
252
256
|
// ─── Microsoft Teams (Adaptive Card via Incoming Webhook) ───────────────────
|
|
253
257
|
// Activated by AGENTIC_SECURITY_TEAMS_WEBHOOK (a webhook URL from a Teams
|
|
254
258
|
// channel's "Incoming Webhook" connector).
|
|
255
|
-
export function buildTeamsCard(findings, summary) {
|
|
259
|
+
export function buildTeamsCard(findings, summary, options = {}) {
|
|
256
260
|
const sev = summary || {};
|
|
257
261
|
const total = (sev.critical || 0) + (sev.high || 0) + (sev.medium || 0) + (sev.low || 0);
|
|
258
262
|
const top = (findings || []).filter(f => /critical|high/.test(f.severity || '')).slice(0, 5);
|
|
263
|
+
const d = assuranceDecision({ actionable: (sev.critical || 0) + (sev.high || 0), scanHealth: options.scanHealth || null });
|
|
259
264
|
const card = {
|
|
260
265
|
type: 'message',
|
|
261
266
|
attachments: [{
|
|
@@ -266,6 +271,7 @@ export function buildTeamsCard(findings, summary) {
|
|
|
266
271
|
version: '1.4',
|
|
267
272
|
body: [
|
|
268
273
|
{ type: 'TextBlock', size: 'Medium', weight: 'Bolder', text: '🛡 agentic-security scan' },
|
|
274
|
+
{ type: 'TextBlock', wrap: true, text: `${d.icon} ${d.headline}` },
|
|
269
275
|
{ type: 'FactSet', facts: [
|
|
270
276
|
{ title: 'Critical', value: String(sev.critical || 0) },
|
|
271
277
|
{ title: 'High', value: String(sev.high || 0) },
|
|
@@ -287,10 +293,10 @@ export function buildTeamsCard(findings, summary) {
|
|
|
287
293
|
return card;
|
|
288
294
|
}
|
|
289
295
|
|
|
290
|
-
export async function postTeamsCard(findings, summary) {
|
|
296
|
+
export async function postTeamsCard(findings, summary, options = {}) {
|
|
291
297
|
const url = process.env.AGENTIC_SECURITY_TEAMS_WEBHOOK;
|
|
292
298
|
if (!url) return { ok: false, reason: 'teams-not-configured' };
|
|
293
|
-
const card = buildTeamsCard(findings, summary);
|
|
299
|
+
const card = buildTeamsCard(findings, summary, options);
|
|
294
300
|
try {
|
|
295
301
|
const r = await fetch(url, {
|
|
296
302
|
method: 'POST',
|
|
@@ -307,9 +313,12 @@ export async function postTeamsCard(findings, summary) {
|
|
|
307
313
|
export function buildPrComment(findings, summary, options = {}) {
|
|
308
314
|
const sev = summary;
|
|
309
315
|
const top = (findings || []).filter(f => /critical|high/.test(f.severity)).slice(0, 10);
|
|
316
|
+
const d = assuranceDecision({ actionable: (sev.critical || 0) + (sev.high || 0), scanHealth: options.scanHealth || null });
|
|
310
317
|
const body = [
|
|
311
318
|
'## 🛡 agentic-security',
|
|
312
319
|
'',
|
|
320
|
+
`**${d.icon} ${d.headline}**`,
|
|
321
|
+
'',
|
|
313
322
|
'| Critical | High | Medium | Low | Info |',
|
|
314
323
|
'|---:|---:|---:|---:|---:|',
|
|
315
324
|
`| ${sev.critical||0} | ${sev.high||0} | ${sev.medium||0} | ${sev.low||0} | ${sev.info||0} |`,
|
|
@@ -77,6 +77,7 @@ import { localEndpointConfig } from './local-endpoint.js';
|
|
|
77
77
|
import { resolveProvider, buildProviderRequest, providerMatrix } from './providers.js';
|
|
78
78
|
import { evaluateEgress } from '../egress/policy.js';
|
|
79
79
|
import { recordEgressCall, payloadMetrics } from '../egress/audit.js';
|
|
80
|
+
import { modelFetch } from '../egress/transport.js';
|
|
80
81
|
import { MODEL_STATUS, summarizeModelStatus } from './model-status.js';
|
|
81
82
|
import { callOllamaChat } from './ollama-provider.js';
|
|
82
83
|
|
|
@@ -408,12 +409,13 @@ async function callEndpoint(endpoint, apiKey, model, prompt, preset = null, shap
|
|
|
408
409
|
// A model that accepts the connection and never answers must not hold a scan open forever: every call is bounded.
|
|
409
410
|
// The result is an UNAVAILABLE finding (kept, unvalidated), never a silent pass.
|
|
410
411
|
const ms = Number(process.env.AGENTIC_SECURITY_LLM_TIMEOUT_MS) > 0 ? Number(process.env.AGENTIC_SECURITY_LLM_TIMEOUT_MS) : 60_000;
|
|
411
|
-
const r = await
|
|
412
|
+
const r = await modelFetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) }, { timeoutMs: ms });
|
|
412
413
|
if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
|
|
413
414
|
const j = await r.json().catch(() => null);
|
|
414
415
|
return { ok: true, text: String(extractText(j) || ''), usage: extractUsage ? extractUsage(j) : null };
|
|
415
416
|
} catch (e) {
|
|
416
|
-
|
|
417
|
+
// a redirect, an oversized body or a spent budget is an unavailable validator, named by its transport code, never a pass
|
|
418
|
+
return { ok: false, error: e && e.code === 'timeout' ? 'timeout' : (e && e.code ? `${e.code}: ${e.message}` : e.message) };
|
|
417
419
|
}
|
|
418
420
|
}
|
|
419
421
|
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
// The connect timeout is short for the same reason — a dead port must not hang
|
|
23
23
|
// a scan.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// `localhost` is the one name accepted; `[::1]` is how the URL parser spells the IPv6 loopback (a bare `::1` never reaches this set).
|
|
26
|
+
// `0.0.0.0` is NOT here (GA-06.3): it is the unspecified address, and a connect to it reaches a local interface only by an operating
|
|
27
|
+
// system convention, not by the address's meaning. The promise is "literal loopback", so it is refused like `[::]` and `http://0/`.
|
|
28
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
26
29
|
|
|
27
30
|
export const DEFAULT_LOCAL_ENDPOINT = 'http://127.0.0.1:11434/v1/chat/completions';
|
|
28
31
|
export const DEFAULT_LOCAL_TIMEOUT_MS = 20000;
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
// never to this module's error path. See ollama-offline-egress.test.js.
|
|
33
33
|
|
|
34
34
|
import { isLoopbackUrl } from './local-endpoint.js';
|
|
35
|
+
import { modelFetch } from '../egress/transport.js';
|
|
35
36
|
import { recordOOMEvent } from './oom-feedback.js';
|
|
36
37
|
|
|
37
38
|
export const DEFAULT_OLLAMA_HOST = 'http://127.0.0.1:11434';
|
|
@@ -199,16 +200,17 @@ export function parseOllamaChatResponse(json, model) {
|
|
|
199
200
|
* "slow to generate" without a lower-level HTTP client.
|
|
200
201
|
*/
|
|
201
202
|
async function _fetchOllama(url, init, { connectTimeoutMs, requestTimeoutMs }) {
|
|
202
|
-
|
|
203
|
-
const totalTimer = setTimeout(() => controller.abort('total-timeout'), requestTimeoutMs);
|
|
203
|
+
void connectTimeoutMs; // see the note above: the client exposes no connect-only phase to bound separately
|
|
204
204
|
try {
|
|
205
|
-
|
|
205
|
+
// the shared transport: no redirect is followed, the reply is capped while it is read, the deadline is the total timeout
|
|
206
|
+
const res = await modelFetch(url, init, { timeoutMs: requestTimeoutMs });
|
|
206
207
|
return { ok: true, res };
|
|
207
208
|
} catch (e) {
|
|
208
|
-
if (
|
|
209
|
+
if (e?.code === 'timeout') return _err('ollama-timeout', `Ollama request timed out after ${requestTimeoutMs}ms.`);
|
|
210
|
+
if (e?.code === 'redirect') return _err('ollama-unreachable', `Ollama answered with a redirect, which is never followed: ${e.message}`);
|
|
211
|
+
if (e?.code === 'response-too-large') return _err('ollama-malformed-response', e.message);
|
|
212
|
+
if (e?.code === 'budget-exhausted') return _err('ollama-timeout', e.message);
|
|
209
213
|
return _err('ollama-unreachable', e?.message || String(e));
|
|
210
|
-
} finally {
|
|
211
|
-
clearTimeout(totalTimer);
|
|
212
214
|
}
|
|
213
215
|
}
|
|
214
216
|
|
|
@@ -185,9 +185,22 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
|
|
|
185
185
|
// twice. Each leg is additive: a leg not present in `freshnessPartial`
|
|
186
186
|
// leaves scanHealth's existing `freshness` object and conditions
|
|
187
187
|
// untouched, exactly like every other optional input this module accepts.
|
|
188
|
+
// A feed whose freshness is UNKNOWN (`stale: null` because the feed was unavailable, never loaded, or cached with no timestamp) AND
|
|
189
|
+
// that the scan consulted for at least one item (`consulted` for the KEV catalog, `cvesChecked` for EPSS) is a condition exactly like
|
|
190
|
+
// a stale one (0.195.22, the GA plan's "missing scan health must remain unknown"): the result reasoned without data it needed, so it
|
|
191
|
+
// cannot be called complete, and strict mode fails closed on it through the status. Two things are deliberately NOT conditions: a feed
|
|
192
|
+
// the scan had nothing to look up in (it changed no result), and `offline-skipped`, which is the operator's own declared configuration
|
|
193
|
+
// (AGENTIC_SECURITY_OFFLINE=1), recorded as `stale: null` with its source and left to the scan-health contract's existing offline rule.
|
|
194
|
+
// A loaded catalog always carries a boolean, so this never fires on one.
|
|
195
|
+
const unknownFeed = (f, what) => {
|
|
196
|
+
if (!f || f.stale !== null || !f.source || ['cache-fresh', 'live', 'cache/live', 'network', 'offline-skipped'].includes(f.source)) return null;
|
|
197
|
+
const consulted = Number(f.consulted ?? f.cvesChecked ?? 0);
|
|
198
|
+
if (!(consulted > 0)) return null;
|
|
199
|
+
return `${what} freshness is unknown (${f.source}): ${consulted} item(s) were checked without it, and the absence of a finding that needs it is not evidence`;
|
|
200
|
+
};
|
|
188
201
|
const FRESHNESS_CONDITION_BUILDERS = {
|
|
189
|
-
kev: (f) => (f?.stale === true) ? `KEV catalog is stale (${f.ageDays} day(s) old) — recently-added CVEs may not be reflected` :
|
|
190
|
-
epss: (f) => (f?.stale === true) ? `EPSS exploit-probability data is stale (${f.ageDays} day(s) old)` :
|
|
202
|
+
kev: (f) => (f?.stale === true) ? `KEV catalog is stale (${f.ageDays} day(s) old) — recently-added CVEs may not be reflected` : unknownFeed(f, 'KEV catalog'),
|
|
203
|
+
epss: (f) => (f?.stale === true) ? `EPSS exploit-probability data is stale (${f.ageDays} day(s) old)` : unknownFeed(f, 'EPSS exploit-probability data'),
|
|
191
204
|
calibration: (f) => (f?.stale === true) ? `calibration data is stale (${f.ageDays} day(s) old${f.generatedAt ? `, last generated ${f.generatedAt}` : ''})` : null,
|
|
192
205
|
customRules: (f) => (f?.stale === true) ? `${f.staleFiles.length} custom rule file(s) exceed their configured review interval` : null,
|
|
193
206
|
advisories: (f) => (Number(f?.unanswered) > 0) ? `${f.unanswered} of ${f.componentsChecked} dependencies were not checked against advisory data (${f.source}) — the absence of a vulnerable dependency finding is not evidence that the dependency is safe` : null,
|
package/src/posture/CLAUDE.md
CHANGED
|
@@ -156,6 +156,7 @@ Foundation for the differentiation work (CORE-001, CORE-002, CORE-004). All of i
|
|
|
156
156
|
- **`contracts.js`**: observation binding (references a lineage observation by id only; absence in a sampled observation cannot establish `blocked`), capability decision (only runner/proxy mediation with a backend and an active-probe digest may claim `enforced`; a hook or in-process check never can), routing label (a decided label must name an adjudication or trusted-execution source), release evidence (`complete` is derived, never trusted), and `validateRecordSet` for duplicate ids, dangling references and conflicting claims.
|
|
157
157
|
- **`migrations.js`**: explicit v1 adapters from legacy shapes (proof tier, boolean verified, egress decision, attestation, lineage observation) and a legacy view back out. Evidence can be lost, never invented: a legacy `true` is `inconclusive`, `proof-failed` is `inconclusive` not `refuted`, and the only route to `confirmed` is an explicit `trustedRunner` assertion bound to a commit. The legacy view's `verified` is `true` only for confirmed and `false` only for refuted, otherwise `null`.
|
|
158
158
|
- **`config.js`**: the unified feature configuration. Reuses the env/`.agentic-security/*.yml` mechanism. Precedence: kill switch (`AGENTIC_SECURITY_NO_ASSURANCE` or `AGENTIC_SECURITY_NO_<FEATURE>`) > explicit override > `AGENTIC_SECURITY_ASSURANCE_<FEATURE>` > `.agentic-security/assurance.yml` > default (everything off). **A project file can never enable a `high-risk-execution` feature** (the file lives in the scanned repo); invalid configuration fails closed; unsupported platforms return a typed `unsupported` with the disclosure. `runFeature` returns typed `blocked`/`unsupported`/`degraded`/`disabled` results and never prompts.
|
|
159
|
+
- **`decision.js`** (0.195.22, the GA plan GA-11.7): `assuranceDecision({ actionable, scanHealth })`, the ONE decision every user-facing surface states (`deployable`, `needs_review`, `not_deployable`, `unknown` when no scan health was recorded). `report/index.js` `toShipVerdict`, the Slack, Discord and Teams digests and the pull-request comment in `integrations/index.js` derive their wording from it; actionable findings outrank a missing health record, and zero findings with no health record are `unknown`, never clean. The default wording of the three older states is the one the docs and brand assets carry; the bounded wording stays the `portfolio-assurance` opt-in.
|
|
159
160
|
- **`bounded-io.js`**: `guardedModelCall` is the only route for new model/network calls. It owns no HTTP client (the transport is injected): feature gate, then `evaluateEgress` BEFORE the payload exists, then `redactPayload`, then the request-size limit, then deadline plus bounded retries, then output cap, with every decision appended to the existing egress audit chain. `readFileBounded`, `capOutput`, `withDeadline`, `retryBounded` enforce the limits `config.js` claims; `maxMemoryMiB` is carried for a runner and disclosed as not enforced here.
|
|
160
161
|
- **`baseline.js`, `baseline-inventory.js`**: baseline capture (`scripts/baseline-capture.mjs`, `npm run baseline:capture`). Read-only; records HEAD, dirty paths, exact source and bundle digests, tool versions, entry points, the seven mappings and an 18-entry capability inventory (implemented / partial / unsupported / unmeasured, every path and exported symbol verified at capture time, a missing one is a reported problem). Unknown facts are `{status:'unknown', reason}` with no value. `evaluateBaseline` invalidates exactly the capabilities whose cited files changed and lists unrelated user changes as retained/introduced. The manifest is machine-specific, so it is generated on demand and not committed. When you change a capability's source or tests, update its inventory entry in the same change.
|
|
161
162
|
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
|
|
27
27
|
import * as crypto from 'node:crypto';
|
|
28
28
|
import { evaluateEgress } from '../egress/policy.js';
|
|
29
|
+
import { modelFetch } from '../egress/transport.js';
|
|
30
|
+
import { resolveProvider } from '../llm-validator/providers.js';
|
|
29
31
|
|
|
30
32
|
const MAX_CALLS_DEFAULT = 50;
|
|
31
33
|
const MAX_WALL_MS_DEFAULT = 15 * 60 * 1000;
|
|
@@ -96,11 +98,21 @@ export function isExceeded(transcript, budget) {
|
|
|
96
98
|
// and parses the response as a single tool-call request. The endpoint is
|
|
97
99
|
// expected to follow an OpenAI-style chat-completions shape; we extract a
|
|
98
100
|
// JSON object of `{ tool, args }` from the model's reply.
|
|
101
|
+
// GA-06.3: the provider is resolved like the validator's, so the `local` preset's loopback refusal applies here, and a vendor key is
|
|
102
|
+
// sent only with that vendor's own preset (a bare OPENAI_API_KEY is never attached to an arbitrary endpoint). Ollama's protocol is not
|
|
103
|
+
// implemented here, so that preset counts as not configured for this agent.
|
|
104
|
+
export function _agentProvider(env = process.env) {
|
|
105
|
+
const r = resolveProvider({ role: 'verify', env });
|
|
106
|
+
if (!r.ok || r.config.provider === 'ollama') return null;
|
|
107
|
+
return r.config;
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
export async function defaultLlmInvoke(transcript) {
|
|
100
|
-
const
|
|
101
|
-
if (!
|
|
102
|
-
const
|
|
103
|
-
const
|
|
111
|
+
const provider = _agentProvider();
|
|
112
|
+
if (!provider) return null;
|
|
113
|
+
const { endpoint } = provider;
|
|
114
|
+
const model = provider.model || 'gpt-4o-mini';
|
|
115
|
+
const apiKey = provider.apiKey || '';
|
|
104
116
|
const headers = { 'Content-Type': 'application/json' };
|
|
105
117
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
|
106
118
|
const tools = [...TOOL_ACL].map(t => ({ tool: t, description: TOOL_HELP[t] || '' }));
|
|
@@ -115,7 +127,7 @@ export async function defaultLlmInvoke(transcript) {
|
|
|
115
127
|
};
|
|
116
128
|
let res;
|
|
117
129
|
try {
|
|
118
|
-
res = await
|
|
130
|
+
res = await modelFetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
119
131
|
} catch (e) {
|
|
120
132
|
return null;
|
|
121
133
|
}
|
|
@@ -136,6 +148,9 @@ const TOOL_HELP = {
|
|
|
136
148
|
'record_outcome': 'Terminate the run with a verdict (outcome: one of OUTCOMES)',
|
|
137
149
|
};
|
|
138
150
|
|
|
151
|
+
/** Bounds for a tool probe against the target: a reply is kept to 4000 characters anyway, so 64 KiB and 30 s are generous. */
|
|
152
|
+
const PROBE_BOUNDS = Object.freeze({ timeoutMs: 30_000, maxResponseBytes: 64 * 1024 });
|
|
153
|
+
|
|
139
154
|
// Default executeTool — wraps http.get / http.post against `transcript.target`.
|
|
140
155
|
// Falls back to refusal when the tool is db.read_sandbox_copy (we do not ship
|
|
141
156
|
// a sandboxed DB by default; the caller must supply one).
|
|
@@ -144,7 +159,8 @@ export async function defaultExecuteTool(call, transcript) {
|
|
|
144
159
|
if (call.tool === 'http.get') {
|
|
145
160
|
const url = (transcript?.target || '').replace(/\/$/, '') + (call.args?.path || '/');
|
|
146
161
|
try {
|
|
147
|
-
|
|
162
|
+
// the probe goes through the shared transport too: no redirect off the target, a capped reply, a deadline
|
|
163
|
+
const r = await modelFetch(url, { method: 'GET', headers: call.args?.headers || {} }, PROBE_BOUNDS);
|
|
148
164
|
const body = await r.text();
|
|
149
165
|
return { status: r.status, headers: Object.fromEntries(r.headers), body: body.slice(0, 4000) };
|
|
150
166
|
} catch (e) { return { error: String(e?.message || e) }; }
|
|
@@ -152,11 +168,11 @@ export async function defaultExecuteTool(call, transcript) {
|
|
|
152
168
|
if (call.tool === 'http.post') {
|
|
153
169
|
const url = (transcript?.target || '').replace(/\/$/, '') + (call.args?.path || '/');
|
|
154
170
|
try {
|
|
155
|
-
const r = await
|
|
171
|
+
const r = await modelFetch(url, {
|
|
156
172
|
method: 'POST',
|
|
157
173
|
headers: { 'Content-Type': 'application/json', ...(call.args?.headers || {}) },
|
|
158
174
|
body: typeof call.args?.body === 'string' ? call.args.body : JSON.stringify(call.args?.body || {}),
|
|
159
|
-
});
|
|
175
|
+
}, PROBE_BOUNDS);
|
|
160
176
|
const body = await r.text();
|
|
161
177
|
return { status: r.status, headers: Object.fromEntries(r.headers), body: body.slice(0, 4000) };
|
|
162
178
|
} catch (e) { return { error: String(e?.message || e) }; }
|
|
@@ -176,8 +192,9 @@ export async function runAgent(finding, opts = {}) {
|
|
|
176
192
|
// called later in the loop below) never runs at all.
|
|
177
193
|
let egressDecision = null;
|
|
178
194
|
let llmInvoke = opts.llmInvoke || null;
|
|
179
|
-
|
|
180
|
-
|
|
195
|
+
const provider = llmInvoke ? null : _agentProvider();
|
|
196
|
+
if (!llmInvoke && provider) {
|
|
197
|
+
egressDecision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: 'adversary-agent', endpoint: provider.endpoint });
|
|
181
198
|
if (egressDecision.allowed) llmInvoke = defaultLlmInvoke;
|
|
182
199
|
}
|
|
183
200
|
const executeTool = opts.executeTool || (transcript.target ? (call) => defaultExecuteTool(call, transcript) : null);
|
|
@@ -58,6 +58,8 @@ export const FEATURES = Object.freeze({
|
|
|
58
58
|
platformNote: 'runner-level filesystem, network and process enforcement is only advertised on Linux; macOS can supervise but not enforce',
|
|
59
59
|
},
|
|
60
60
|
'model-routing': { risk: 'model-network', platforms: ['linux', 'darwin', 'win32'], platformNote: null },
|
|
61
|
+
// a detector-spec proposal drafted by a model for human review (Labs); the only route is guardedModelCall, with the egress policy evaluated before any sample file is read
|
|
62
|
+
'detector-spec-synthesis': { risk: 'model-network', platforms: ['linux', 'darwin', 'win32'], platformNote: null },
|
|
61
63
|
'portfolio-assurance': { risk: 'passive', platforms: ['linux', 'darwin', 'win32'], platformNote: null },
|
|
62
64
|
});
|
|
63
65
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The one assurance decision every user-facing surface states (the GA plan, GA-11.7): the CLI ship verdict, the Slack, Discord and
|
|
2
|
+
// Teams digests and the pull-request comment all call `assuranceDecision` and print its wording, so a surface can no longer decide
|
|
3
|
+
// from severity counts alone. The decision vocabulary is the release verdict's (`release-verdict.js`): `deployable`, `needs_review`,
|
|
4
|
+
// `not_deployable`, plus `unknown` when no scan health was recorded, because a result that cannot say whether the scan completed
|
|
5
|
+
// cannot say anything about deployability.
|
|
6
|
+
//
|
|
7
|
+
// Rules, in order:
|
|
8
|
+
// - actionable findings (critical or high) -> not_deployable (an incomplete or missing scan health is noted)
|
|
9
|
+
// - no scan health at all (a findings file with no health record) -> unknown: zero findings with no record of completion prove nothing
|
|
10
|
+
// - scan health not `complete`, no actionable findings -> needs_review (the first condition is the reason)
|
|
11
|
+
// - complete, no actionable findings -> deployable
|
|
12
|
+
//
|
|
13
|
+
// Wording: the default strings are the ones the CLI, the docs and the brand assets have carried since the ship verdict existed, kept
|
|
14
|
+
// so that existing output stays recognisable; the bounded wording of the `portfolio-assurance` feature (`portfolio/wording.js`) is the
|
|
15
|
+
// opt-in alternative and the surfaces still choose it the way they did. What is new is the `unknown` state and that every surface
|
|
16
|
+
// derives its wording from the same decision.
|
|
17
|
+
// the four decisions: deployable, needs_review, not_deployable, unknown
|
|
18
|
+
|
|
19
|
+
const WORDING = Object.freeze({
|
|
20
|
+
deployable: { icon: '✅', headline: 'Safe to deploy' },
|
|
21
|
+
not_deployable: { icon: '❌', headline: 'Not safe to deploy' },
|
|
22
|
+
needs_review: { icon: '⚠️', headline: 'Scan incomplete — cannot confirm safe to deploy' },
|
|
23
|
+
unknown: { icon: '⚠️', headline: 'Cannot confirm safe to deploy — no scan health recorded' },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} p
|
|
28
|
+
* @param {number|Array} p.actionable the critical and high findings (a count or the array)
|
|
29
|
+
* @param {object|null} p.scanHealth scan.scanHealth, or null when none was recorded
|
|
30
|
+
* @returns {{decision:string, icon:string, headline:string, reason:string, actionable:number, scanStatus:string|null, condition:string|null}}
|
|
31
|
+
*/
|
|
32
|
+
export function assuranceDecision({ actionable = 0, scanHealth = null } = {}) {
|
|
33
|
+
const n = Array.isArray(actionable) ? actionable.length : Number(actionable) || 0;
|
|
34
|
+
const health = scanHealth && typeof scanHealth === 'object' && typeof scanHealth.status === 'string' ? scanHealth : null;
|
|
35
|
+
const conditions = health && Array.isArray(health.conditions) ? health.conditions : [];
|
|
36
|
+
const condition = conditions[0] || (health && health.status !== 'complete' ? 'analysis did not complete cleanly' : null);
|
|
37
|
+
let decision;
|
|
38
|
+
let reason;
|
|
39
|
+
if (n > 0) { decision = 'not_deployable'; reason = `${n} actionable finding(s)${!health ? ' (no scan health recorded)' : health.status !== 'complete' ? `, and the scan is ${health.status}: ${condition}` : ''}`; }
|
|
40
|
+
else if (!health) { decision = 'unknown'; reason = 'no scan health was recorded with this result, so whether the scan completed is unknown'; }
|
|
41
|
+
else if (health.status !== 'complete') { decision = 'needs_review'; reason = `no actionable finding, but the scan is ${health.status}: ${condition}${conditions.length > 1 ? ` (+${conditions.length - 1} more)` : ''}`; }
|
|
42
|
+
else { decision = 'deployable'; reason = 'no critical or high finding and every analyzer finished'; }
|
|
43
|
+
return { decision, ...WORDING[decision], reason, actionable: n, scanStatus: health ? health.status : null, condition };
|
|
44
|
+
}
|
|
45
|
+
|
|
@@ -47,6 +47,8 @@ const FEATURE_SUITE = Object.freeze({
|
|
|
47
47
|
'capability-enforcement': 'capabilities',
|
|
48
48
|
'model-routing': 'routing',
|
|
49
49
|
'portfolio-assurance': 'portfolio',
|
|
50
|
+
// the synthesis module is a CORE-004 consumer (the shared bounded call); its pins run in the foundation step
|
|
51
|
+
'detector-spec-synthesis': 'foundation',
|
|
50
52
|
});
|
|
51
53
|
|
|
52
54
|
// What each feature must show before opt-in. Feature specific, and quoted from the PRD's own targets
|
|
@@ -58,6 +60,7 @@ const OPT_IN_SPECIFIC = Object.freeze({
|
|
|
58
60
|
'invariant-scenarios': [review('contract-approval-path', 'Invariant contracts are approved by an authorized reviewer, never by the generator that proposed them')],
|
|
59
61
|
'capability-enforcement': [measure('backend-probes', 'Every mandatory canary-secret and escape fixture is blocked on the advertised backend with zero canary leakage; macOS stays supervise-only')],
|
|
60
62
|
'model-routing': [measure('promotion-thresholds', 'At least 200 paired adjudicated tasks overall and 30 per promoted stratum, lower 95% quality bound above -0.02, median cost 20% lower or quality 0.05 higher at no higher median cost, p95 latency within 1.2x')],
|
|
63
|
+
'detector-spec-synthesis': [measure('proposal-validity', 'At least 95% schema-valid proposal completion on the frozen supported samples, safe refusal of malformed and adversarial replies, zero automatic installation, zero requests under deny and zero remote requests under local-only (the GA plan, GA-07)')],
|
|
61
64
|
'portfolio-assurance': [measure('interrupted-run-convergence', 'Interrupted, duplicate-delivery and incremental runs converge to a fresh scoped result with no double-counted progress')],
|
|
62
65
|
});
|
|
63
66
|
|
|
@@ -39,6 +39,7 @@ import * as path from 'node:path';
|
|
|
39
39
|
import * as yaml from '../util/yaml.js';
|
|
40
40
|
import { globFiles } from '../util/glob.js';
|
|
41
41
|
import { loadTrustedKeys, verifyRulePack } from './rule-pack-signing.js';
|
|
42
|
+
import { isUnsafeRegex } from '../sast/redos-nfa.js';
|
|
42
43
|
|
|
43
44
|
import { statePath, safeWriteState } from './state-dir.js';
|
|
44
45
|
const LANG_EXTS = {
|
|
@@ -156,8 +157,19 @@ export function loadCustomRules(scanRoot) {
|
|
|
156
157
|
// Catches (a+)+, (a*)*, (.+)+, (\w+)+, (a|b)+, (a+|b)* etc.
|
|
157
158
|
// Not exhaustive but eliminates the vast majority of catastrophic patterns.
|
|
158
159
|
const _REDOS_NESTED_RE = /\([^)]*[+*][^)]*\)[+*?{]/;
|
|
160
|
+
// The textual heuristic above misses an ambiguous alternation under a quantifier (`(a|a)*b`) and the polynomial shapes; since
|
|
161
|
+
// 0.195.23 (GA-07) the NFA-based detector the SAST layer already ships (`sast/redos-nfa.js`) is consulted as well, so a custom or
|
|
162
|
+
// compiled rule is held to the same bar as the code the scanner flags for CWE-1333.
|
|
159
163
|
function _isSafePattern(p) {
|
|
160
|
-
|
|
164
|
+
if (_REDOS_NESTED_RE.test(p)) return false;
|
|
165
|
+
return !isUnsafeRegex(p).unsafe;
|
|
166
|
+
}
|
|
167
|
+
/** Why a pattern is refused, for the loader's message and the rule compiler's validation. Null when it is safe. */
|
|
168
|
+
export function unsafePatternReason(p) {
|
|
169
|
+
if (typeof p !== 'string') return 'not a string';
|
|
170
|
+
if (_REDOS_NESTED_RE.test(p)) return 'nested quantifiers';
|
|
171
|
+
const r = isUnsafeRegex(p);
|
|
172
|
+
return r.unsafe ? (r.reason || 'superlinear matching') : null;
|
|
161
173
|
}
|
|
162
174
|
|
|
163
175
|
/** The loader's own rule normalizer, exported so anything that scores or validates a rule uses the SAME one the scanner loads
|
package/src/posture/fleet/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { rollupFleet, renderFleetSummary } from '../fleet.js';
|
|
|
15
15
|
import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
|
|
16
16
|
import { createSqliteJobStore } from './job-store.js';
|
|
17
17
|
import { planRun, runJobs, loadArtifacts, reclaimDeadWorkers, childScan } from './job-runner.js';
|
|
18
|
-
import { computeOrgHealth, renderOrgHealth } from './org-health.js';
|
|
18
|
+
import { computeOrgHealth, renderOrgHealth, applyArtifactProblems } from './org-health.js';
|
|
19
19
|
import { discoverEdges, splitDeclared } from './discover-edges.js';
|
|
20
20
|
import { buildEnterpriseGraph, finalizeGraph, validateEnterpriseGraph, attackPaths, vulnerableSet, componentBlastRadius, renderBlastRadius, INTERNET } from './enterprise-graph.js';
|
|
21
21
|
|
|
@@ -90,9 +90,7 @@ function runOfJob(store, id) {
|
|
|
90
90
|
async function finishRun({ args, root, store, runId, manifest, manifestFile, interrupted }) {
|
|
91
91
|
const { artifacts, problems } = loadArtifacts({ store, runId, root });
|
|
92
92
|
const jobs = store.listJobs(runId);
|
|
93
|
-
const health = computeOrgHealth({ manifest, jobs });
|
|
94
|
-
for (const p of problems) { const r = health.repositories.find((x) => x.id === p.repoId); if (r) { r.state = 'failed'; r.error = p.problem; } }
|
|
95
|
-
if (problems.length) { health.counts.complete -= problems.filter((p) => health.repositories.find((x) => x.id === p.repoId)).length; health.counts.failed += problems.length; if (health.assurance !== 'red' && manifest.repositories.some((r) => r.required && problems.some((p) => p.repoId === r.id))) health.assurance = 'red'; health.reasons.push(`${problems.length} result(s) could not be verified: ${problems.map((p) => `${p.repoId} (${p.problem})`).join(', ')}`); }
|
|
93
|
+
const health = applyArtifactProblems(computeOrgHealth({ manifest, jobs }), manifest, problems);
|
|
96
94
|
|
|
97
95
|
let previous = null;
|
|
98
96
|
const rollupFile = statePath(root, ROLLUP_FILE);
|
|
@@ -14,7 +14,7 @@ import fs from 'node:fs';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { load as loadYaml } from '../../util/yaml.js';
|
|
16
16
|
|
|
17
|
-
export const FLEET_MANIFEST_SCHEMA_KEYS = Object.freeze(['organization', 'repositories', 'workers', 'perRepoTimeout', 'retries', 'resources']);
|
|
17
|
+
export const FLEET_MANIFEST_SCHEMA_KEYS = Object.freeze(['organization', 'repositories', 'workers', 'perRepoTimeout', 'retries', 'resources', 'cacheMaxAge']);
|
|
18
18
|
export const REPO_KEYS = Object.freeze(['id', 'path', 'required', 'kind', 'internetFacing', 'environment', 'dataClasses', 'trustZone', 'calls', 'produces', 'consumes', 'reads', 'writes', 'providers', 'aliases']);
|
|
19
19
|
export const RESOURCE_KEYS = Object.freeze(['id', 'kind', 'dataClasses', 'trustZone', 'internetFacing', 'environment', 'aliases']);
|
|
20
20
|
export const REPO_KINDS = Object.freeze(['service', 'library', 'frontend', 'worker', 'gateway']);
|
|
@@ -93,12 +93,15 @@ export function validateFleetManifest(doc, { baseDir = '.' } = {}) {
|
|
|
93
93
|
if (doc.workers != null && !(Number.isInteger(doc.workers) && doc.workers >= 1 && doc.workers <= 32)) errors.push('workers must be an integer in 1..32');
|
|
94
94
|
if (doc.retries != null && !(Number.isInteger(doc.retries) && doc.retries >= 0 && doc.retries <= 5)) errors.push('retries must be an integer in 0..5');
|
|
95
95
|
if (doc.perRepoTimeout != null && parseDuration(doc.perRepoTimeout, null) == null) errors.push(`perRepoTimeout is not a valid duration: ${doc.perRepoTimeout}`);
|
|
96
|
+
if (doc.cacheMaxAge != null && parseDuration(doc.cacheMaxAge, null) == null) errors.push(`cacheMaxAge is not a valid duration: ${doc.cacheMaxAge}`);
|
|
96
97
|
if (errors.length) return { ok: false, errors };
|
|
97
98
|
return {
|
|
98
99
|
ok: true, errors: [],
|
|
99
100
|
manifest: {
|
|
100
101
|
organization: doc.organization, repositories: repos, resources,
|
|
101
102
|
workers: doc.workers ?? 4, retries: doc.retries ?? 1, perRepoTimeoutMs: parseDuration(doc.perRepoTimeout, 20 * 60000),
|
|
103
|
+
// GA-09: a cached result older than this is stale and is not reused (the feeds it was enriched with have moved on); 7 days by default
|
|
104
|
+
cacheMaxAgeMs: parseDuration(doc.cacheMaxAge, 7 * 24 * 3600000),
|
|
102
105
|
},
|
|
103
106
|
};
|
|
104
107
|
}
|
|
@@ -69,6 +69,21 @@ export function planRun({ manifest, manifestHash, store, runId = null, config =
|
|
|
69
69
|
* Run (or continue) a planned run until no job is queued or leased by a live worker. Returns when the work is done, the signal
|
|
70
70
|
* aborts, or `maxWaitMs` passes with only other workers' leases outstanding.
|
|
71
71
|
*/
|
|
72
|
+
export const DEFAULT_CACHE_MAX_AGE_MS = 7 * 24 * 3600000;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Is a cache hit fresh enough to reuse (GA-09)? A record with no readable `cachedAt` (written before 0.195.23) is never fresh, and a
|
|
76
|
+
* record older than `maxAgeMs` (the manifest's `cacheMaxAge`, 7 days by default) is stale. Returns `{ fresh, cacheAgeMs }`.
|
|
77
|
+
*/
|
|
78
|
+
export function cacheFreshness(hit, maxAgeMs, nowMs) {
|
|
79
|
+
if (!hit) return { fresh: false, cacheAgeMs: null };
|
|
80
|
+
const t = typeof hit.cachedAt === 'string' ? Date.parse(hit.cachedAt) : NaN;
|
|
81
|
+
if (Number.isNaN(t)) return { fresh: false, cacheAgeMs: null };
|
|
82
|
+
const cacheAgeMs = Math.max(0, nowMs - t);
|
|
83
|
+
const max = Number.isFinite(maxAgeMs) && maxAgeMs > 0 ? maxAgeMs : DEFAULT_CACHE_MAX_AGE_MS;
|
|
84
|
+
return { fresh: cacheAgeMs <= max, cacheAgeMs };
|
|
85
|
+
}
|
|
86
|
+
|
|
72
87
|
export async function runJobs({ manifest, store, runId, root, scan = childScan, scanOptions = {}, workers = manifest.workers, leaseMs = DEFAULTS.leaseMs, heartbeatMs = DEFAULTS.heartbeatMs, retryDelayMs = DEFAULTS.retryDelayMs, pollMs = DEFAULTS.pollMs, maxWaitMs = null, signal, now = () => Date.now(), onEvent = () => {}, cacheDir = null, artifactDir = null, useCache = true }) {
|
|
73
88
|
const repoById = new Map(manifest.repositories.map((r) => [r.id, r]));
|
|
74
89
|
const cacheD = cacheDir ?? statePath(root, 'fleet', 'cache');
|
|
@@ -78,7 +93,11 @@ export async function runJobs({ manifest, store, runId, root, scan = childScan,
|
|
|
78
93
|
const writeArtifact = (job, payload) => {
|
|
79
94
|
const text = JSON.stringify({ schema: 'agentic-security/fleet-scan-artifact', version: 1, runId, repoId: job.repoId, epoch: job.epoch, commit: job.commit, ...payload });
|
|
80
95
|
const file = path.join(artD, `${job.repoId}.${job.epoch}.json`);
|
|
81
|
-
|
|
96
|
+
// written whole or not at all (GA-09): a worker killed mid-write leaves no half artifact for the hash check to find, only a
|
|
97
|
+
// stray temp file, and the job stays leased until the lease expires and another worker redoes it
|
|
98
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
99
|
+
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
|
100
|
+
fs.renameSync(tmp, file);
|
|
82
101
|
return { file, sha256: sha(text) };
|
|
83
102
|
};
|
|
84
103
|
|
|
@@ -100,11 +119,16 @@ export async function runJobs({ manifest, store, runId, root, scan = childScan,
|
|
|
100
119
|
idleSince = null;
|
|
101
120
|
const repo = repoById.get(job.repoId);
|
|
102
121
|
onEvent({ type: 'start', repo: job.repoId, attempt: job.attempts });
|
|
103
|
-
const
|
|
122
|
+
const hit = useCache ? readCache(cacheD, job.cacheKey) : null;
|
|
123
|
+
// GA-09: a hit is reused only while it is fresh. A result with no recorded time (written before 0.195.23) or older than the
|
|
124
|
+
// manifest's `cacheMaxAge` is stale: it is scanned again, never folded into a green organization as if it were current.
|
|
125
|
+
const { fresh, cacheAgeMs } = cacheFreshness(hit, manifest.cacheMaxAgeMs, now());
|
|
126
|
+
const cached = fresh ? hit : null;
|
|
127
|
+
if (hit && !cached) onEvent({ type: 'cache-stale', repo: job.repoId, cacheAgeMs });
|
|
104
128
|
if (cached) {
|
|
105
|
-
const a = writeArtifact(job, { entry: cached.entry, findings: cached.findings, findingsTruncated: !!cached.findingsTruncated, lineage: cached.lineage ?? null, cached: true });
|
|
129
|
+
const a = writeArtifact(job, { entry: cached.entry, findings: cached.findings, findingsTruncated: !!cached.findingsTruncated, lineage: cached.lineage ?? null, cached: true, cachedAt: cached.cachedAt, cacheAgeMs });
|
|
106
130
|
const status = cached.entry?.scanHealth && cached.entry.scanHealth.status !== 'complete' ? 'partial' : 'succeeded';
|
|
107
|
-
if (store.complete(job.jobId, job.epoch, { status, artifactSha256: a.sha256, summary: { total: cached.entry?.total ?? null, cached: true, artifact: path.basename(a.file) }, now: now() })) onEvent({ type: 'done', repo: job.repoId, cached: true, status });
|
|
131
|
+
if (store.complete(job.jobId, job.epoch, { status, artifactSha256: a.sha256, summary: { total: cached.entry?.total ?? null, cached: true, cacheAgeMs, artifact: path.basename(a.file) }, now: now() })) onEvent({ type: 'done', repo: job.repoId, cached: true, status });
|
|
108
132
|
continue;
|
|
109
133
|
}
|
|
110
134
|
const ac = new AbortController();
|
|
@@ -45,6 +45,29 @@ export function computeOrgHealth({ manifest, jobs }) {
|
|
|
45
45
|
return { counts, assurance, reasons, repositories: repos };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Fold artifact problems (a result whose file is missing or does not match its recorded hash) into the health (GA-09). The repository
|
|
50
|
+
* becomes `failed`; a required one turns the organization red, an optional one makes it degraded (it used to stay green); the counts
|
|
51
|
+
* move from whatever state the repository had, not always from `complete`. Returns the same object, changed in place.
|
|
52
|
+
*/
|
|
53
|
+
export function applyArtifactProblems(health, manifest, problems) {
|
|
54
|
+
if (!Array.isArray(problems) || !problems.length) return health;
|
|
55
|
+
const stateKey = { complete: 'complete', partial: 'partial', failed: 'failed', 'timed-out': 'timedOut', cancelled: 'cancelled', 'not-run': 'notRun' };
|
|
56
|
+
let requiredHit = false; let optionalHit = false;
|
|
57
|
+
for (const p of problems) {
|
|
58
|
+
const r = health.repositories.find((x) => x.id === p.repoId);
|
|
59
|
+
if (!r) continue;
|
|
60
|
+
if (r.state !== 'failed') { health.counts[stateKey[r.state]]--; health.counts.failed++; }
|
|
61
|
+
r.state = 'failed'; r.error = p.problem;
|
|
62
|
+
const required = manifest.repositories.some((m) => m.id === p.repoId && m.required);
|
|
63
|
+
if (required) requiredHit = true; else optionalHit = true;
|
|
64
|
+
}
|
|
65
|
+
if (requiredHit) health.assurance = 'red';
|
|
66
|
+
else if (optionalHit && health.assurance === 'green') health.assurance = 'degraded';
|
|
67
|
+
health.reasons.push(`${problems.length} result(s) could not be verified: ${problems.map((p) => `${p.repoId} (${p.problem})`).join(', ')}`);
|
|
68
|
+
return health;
|
|
69
|
+
}
|
|
70
|
+
|
|
48
71
|
const n = (x) => x.toLocaleString('en-US');
|
|
49
72
|
export function renderOrgHealth(h) {
|
|
50
73
|
const c = h.counts;
|
|
@@ -102,7 +102,7 @@ export function writeCache(dir, key, payload) {
|
|
|
102
102
|
if (!key || !/^[0-9a-f]{64}$/.test(key)) return false;
|
|
103
103
|
try {
|
|
104
104
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
105
|
-
const body = { schema: 'agentic-security/fleet-scan-cache', version: 1, key, ...payload };
|
|
105
|
+
const body = { schema: 'agentic-security/fleet-scan-cache', version: 1, key, cachedAt: new Date().toISOString(), ...payload };
|
|
106
106
|
const doc = { ...body, digest: sha(JSON.stringify(body)) };
|
|
107
107
|
const tmp = path.join(dir, `.${key}.${process.pid}.tmp`);
|
|
108
108
|
fs.writeFileSync(tmp, JSON.stringify(doc), { mode: 0o600 });
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
// Fail-closed: any LLM error → template fallback, never a missing field.
|
|
15
15
|
|
|
16
16
|
import { evaluateEgress } from '../egress/policy.js';
|
|
17
|
+
import { modelFetch } from '../egress/transport.js';
|
|
18
|
+
import { resolveProvider, buildProviderRequest } from '../llm-validator/providers.js';
|
|
17
19
|
|
|
18
20
|
const TEMPLATES = {
|
|
19
21
|
'sql-injection': (f) =>
|
|
@@ -63,14 +65,14 @@ function _renderTemplate(f) {
|
|
|
63
65
|
// Optional LLM call. Disabled by default; opt-in via env. Falls back to the
|
|
64
66
|
// template on any error.
|
|
65
67
|
async function _renderLlm(f, scanRoot) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
// GA-06.3: the provider is resolved like the validator's, so the `local` preset's loopback refusal applies here too, instead of the
|
|
69
|
+
// raw endpoint variable being called regardless of the preset. The Ollama protocol is not implemented here: treated as not configured.
|
|
70
|
+
const provider = resolveProvider({ role: 'explain' });
|
|
71
|
+
if (!provider.ok || provider.config.provider === 'ollama') return null;
|
|
72
|
+
const { endpoint } = provider.config;
|
|
68
73
|
// FR-601: evaluated before the prompt below is built.
|
|
69
74
|
const egressDecision = evaluateEgress({ scanRoot, purpose: 'flow-narration', endpoint });
|
|
70
75
|
if (!egressDecision.allowed) return null;
|
|
71
|
-
const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY;
|
|
72
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
73
|
-
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
|
74
76
|
const prompt = `You are explaining a security finding to a developer in one paragraph.
|
|
75
77
|
Vuln: ${f.vuln}
|
|
76
78
|
CWE: ${f.cwe}
|
|
@@ -80,11 +82,11 @@ Snippet: ${(f.snippet || '').slice(0, 200)}
|
|
|
80
82
|
|
|
81
83
|
Write ONE paragraph (5-7 sentences) covering: (1) how an attacker reaches this code, (2) what they get if exploited, (3) typical recovery cost. Plain English, no marketing language, no emoji.`;
|
|
82
84
|
try {
|
|
83
|
-
const
|
|
85
|
+
const req = buildProviderRequest(provider.config, prompt, 512);
|
|
86
|
+
const r = await modelFetch(endpoint, { method: 'POST', headers: req.headers, body: JSON.stringify(req.body) });
|
|
84
87
|
if (!r.ok) return null;
|
|
85
88
|
const j = await r.json().catch(() => null);
|
|
86
|
-
const text =
|
|
87
|
-
j.choices?.[0]?.message?.content || j.message?.content);
|
|
89
|
+
const text = req.extractText(j);
|
|
88
90
|
if (typeof text !== 'string' || text.length < 30) return null;
|
|
89
91
|
// Sanitize: strip control chars, markdown fences, HTML metachars.
|
|
90
92
|
return text.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/[<>&]/g, ' ')
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { RED_TEAM_PROMPTS, ATTACK_STRATEGIES, PLUGIN_SEVERITY, categorizePrompts, pluginCoverage } from './llm-redteam-prompts.js';
|
|
16
16
|
import { evaluateEgress } from '../egress/policy.js';
|
|
17
|
+
import { modelFetch } from '../egress/transport.js';
|
|
17
18
|
|
|
18
19
|
// ─── STATIC mode ─────────────────────────────────────────────────────────
|
|
19
20
|
// Scan the repo for prompt files / system-prompt strings and check whether
|
|
@@ -143,7 +144,8 @@ async function sendPrompt(endpoint, apiKey, prompt) {
|
|
|
143
144
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
|
144
145
|
let response;
|
|
145
146
|
try {
|
|
146
|
-
|
|
147
|
+
// the target under test is the operator's named endpoint; the shared transport still refuses a redirect off it and caps the reply
|
|
148
|
+
const r = await modelFetch(endpoint, {
|
|
147
149
|
method: 'POST',
|
|
148
150
|
headers,
|
|
149
151
|
body: JSON.stringify({ prompt }),
|