@clear-capabilities/agentic-security-scanner 0.174.0 → 0.176.0
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 +23 -0
- package/bin/agentic-security.js +199 -28
- package/dist/2066.index.js +850 -0
- package/dist/2199.index.js +1 -1
- package/dist/2584.index.js +3332 -0
- package/dist/2923.index.js +13 -0
- package/dist/2946.index.js +81 -0
- package/dist/3196.index.js +27 -0
- package/dist/3727.index.js +292 -0
- package/dist/4168.index.js +509 -0
- package/dist/4281.index.js +993 -0
- package/dist/4978bec8fdf5c733dc85.json +283 -0
- package/dist/5099.index.js +96 -0
- package/dist/5616.index.js +1471 -0
- package/dist/5620.index.js +2370 -0
- package/dist/5817.index.js +714 -0
- package/dist/6333.index.js +98 -0
- package/dist/6655.index.js +3221 -0
- package/dist/7838.index.js +446 -974
- package/dist/818.index.js +4 -3
- package/dist/83.index.js +159 -0
- package/dist/8551.index.js +82 -0
- package/dist/8633.index.js +93 -0
- package/dist/8714.index.js +76 -76
- package/dist/8825.index.js +84 -0
- package/dist/932.index.js +186 -0
- package/dist/9560.index.js +358 -19
- package/dist/9800.index.js +6 -1
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/df1e8683e7eda7b2b79c.js +29 -0
- package/package.json +7 -6
- package/src/artifact/CLAUDE.md +4 -0
- package/src/artifact/artifact-evidence.js +63 -0
- package/src/artifact/cosign.js +61 -0
- package/src/artifact/engine-source.js +72 -0
- package/src/artifact/oci-layout.js +47 -0
- package/src/artifact/registry.js +213 -0
- package/src/capabilities/tool-registry.js +2 -0
- package/src/language/haskell-security-rules.js +1 -1
- package/src/language/nixos-hardening.js +37 -0
- package/src/lineage/CLAUDE.md +4 -0
- package/src/lineage/identity-token.js +11 -0
- package/src/lineage/observation-adapters.js +13 -0
- package/src/lineage/runtime-edge-states.js +139 -0
- package/src/lineage/runtime-explorer-view.js +82 -0
- package/src/lineage/runtime-observation.js +11 -0
- package/src/mcp/CLAUDE.md +3 -1
- package/src/mcp/proof-tools.js +132 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +3 -1
- package/src/posture/CLAUDE.md +23 -0
- package/src/posture/artifact-registry.js +4 -0
- package/src/posture/dnr/attack-facts.js +31 -0
- package/src/posture/dnr/cli.js +138 -0
- package/src/posture/dnr/detection-ir.js +123 -0
- package/src/posture/dnr/exposure-window.js +134 -0
- package/src/posture/dnr/hunt.js +84 -0
- package/src/posture/dnr/log-reader.js +189 -0
- package/src/posture/dnr/matchers.js +90 -0
- package/src/posture/dnr/outputs.js +262 -0
- package/src/posture/dnr/payload-classes.js +120 -0
- package/src/posture/dnr/redact.js +25 -0
- package/src/posture/dnr/store.js +57 -0
- package/src/posture/dnr/validate.js +64 -0
- package/src/posture/dnr/view.js +51 -0
- package/src/posture/evaluation/lab/arm-ollama.js +88 -0
- package/src/posture/evaluation/lab/arms.js +142 -0
- package/src/posture/evaluation/lab/cli.js +135 -0
- package/src/posture/evaluation/lab/dataset.js +139 -0
- package/src/posture/evaluation/lab/materialize.js +44 -0
- package/src/posture/evaluation/lab/neutralize.js +144 -0
- package/src/posture/evaluation/lab/report.js +176 -0
- package/src/posture/evaluation/lab/run.js +225 -0
- package/src/posture/evaluation/lab/score.js +135 -0
- package/src/posture/evaluation/lab/scoreboard.js +48 -0
- package/src/posture/evaluation/lab/stats.js +41 -0
- package/src/posture/evaluation/lab/store.js +53 -0
- package/src/posture/fleet/cli.js +248 -63
- package/src/posture/fleet/discover-edges.js +135 -0
- package/src/posture/fleet/enterprise-graph.js +148 -0
- package/src/posture/fleet/fleet-graph.js +9 -4
- package/src/posture/fleet/fleet-manifest.js +50 -5
- package/src/posture/fleet/job-runner.js +153 -0
- package/src/posture/fleet/job-store.js +136 -0
- package/src/posture/fleet/org-health.js +56 -0
- package/src/posture/fleet/scan-cache.js +112 -0
- package/src/posture/fleet/scan-child.js +29 -0
- package/src/posture/proof-fabric/cli.js +34 -6
- package/src/posture/proof-fabric/container.js +57 -0
- package/src/posture/proof-fabric/fix-proof.js +2 -1
- package/src/posture/proof-fabric/generate-python.js +129 -0
- package/src/posture/proof-fabric/generate.js +213 -0
- package/src/posture/proof-fabric/reproducer.js +3 -0
- package/src/posture/proof-fabric/runner.js +20 -11
- package/src/posture/proof-fabric/siblings.js +77 -0
- package/src/posture/routing/policy-router.js +103 -0
- package/src/posture/rule-compiler/cli.js +64 -6
- package/src/posture/rule-compiler/corpus.js +10 -2
- package/src/posture/rule-compiler/mutation.js +72 -1
- package/src/posture/rule-compiler/promotion.js +21 -5
- package/src/posture/rule-compiler/proposal.js +132 -0
- package/src/posture/rule-compiler/store.js +16 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// A minimal OCI Distribution (v2) client for ONE purpose: fetching a single image the operator named explicitly
|
|
2
|
+
// (`image scan --registry <ref>`), so it can be scanned like a local archive (PRD 5, requirement 1: "explicit remote OCI reference").
|
|
3
|
+
//
|
|
4
|
+
// It is deliberately small and deliberately strict. It is hostile-input handling first:
|
|
5
|
+
// * a reference is parsed, not interpolated: no scheme, no userinfo, no whitespace, no path tricks;
|
|
6
|
+
// * transport is HTTPS. Plain HTTP is accepted only for a loopback registry (a local test or a local mirror); anything else is refused;
|
|
7
|
+
// * every redirect is followed by hand, at most 5 hops, to an HTTPS (or loopback) target, and the credential is NOT sent to a host
|
|
8
|
+
// other than the registry's own, which is what keeps a blob redirected to a CDN from receiving it;
|
|
9
|
+
// * every body is digest-verified against what the manifest said, and size-capped while it streams, so a registry that lies about
|
|
10
|
+
// a size or sends endless bytes is cut off, not buffered;
|
|
11
|
+
// * the manifest a registry returns for a tag is hashed and checked against `Docker-Content-Digest` when one is sent, and for a
|
|
12
|
+
// digest reference against the reference itself;
|
|
13
|
+
// * credentials come from the environment or the Docker config for that registry only, are never logged, never put in an error,
|
|
14
|
+
// and live in an object that refuses to serialize.
|
|
15
|
+
// Nothing is executed and nothing is extracted to disk; the result is an OCI-layout archive in memory for `image-archive.js`.
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { sha256Hex, buildOciArchive } from './oci-layout.js';
|
|
20
|
+
|
|
21
|
+
export class RegistryError extends Error {
|
|
22
|
+
constructor(message, code) { super(message); this.name = 'RegistryError'; this.code = code; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const REGISTRY_LIMITS = Object.freeze({ maxManifestBytes: 1024 * 1024, maxConfigBytes: 4 * 1024 * 1024, maxLayerBytes: 512 * 1024 * 1024, maxTotalBytes: 1024 * 1024 * 1024, maxLayers: 128, maxRedirects: 5, requestTimeoutMs: 60_000, totalTimeoutMs: 15 * 60_000 });
|
|
26
|
+
|
|
27
|
+
const MANIFEST_ACCEPT = [
|
|
28
|
+
'application/vnd.oci.image.index.v1+json', 'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
29
|
+
'application/vnd.oci.image.manifest.v1+json', 'application/vnd.docker.distribution.manifest.v2+json',
|
|
30
|
+
].join(', ');
|
|
31
|
+
const INDEX_TYPES = new Set(['application/vnd.oci.image.index.v1+json', 'application/vnd.docker.distribution.manifest.list.v2+json']);
|
|
32
|
+
const MANIFEST_TYPES = new Set(['application/vnd.oci.image.manifest.v1+json', 'application/vnd.docker.distribution.manifest.v2+json']);
|
|
33
|
+
const LOOPBACK_HOST = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])$/i;
|
|
34
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
35
|
+
|
|
36
|
+
/** Credentials that cannot be printed: `JSON.stringify`, `String()` and `util.inspect` all give a placeholder. */
|
|
37
|
+
class Credentials {
|
|
38
|
+
#basic = null; #bearer = null;
|
|
39
|
+
constructor({ user, password, token }) { if (token) this.#bearer = token; else if (user && password) this.#basic = Buffer.from(`${user}:${password}`).toString('base64'); }
|
|
40
|
+
get present() { return !!(this.#basic || this.#bearer); }
|
|
41
|
+
basicHeader() { return this.#basic ? `Basic ${this.#basic}` : null; }
|
|
42
|
+
bearerHeader() { return this.#bearer ? `Bearer ${this.#bearer}` : null; }
|
|
43
|
+
toJSON() { return '[credentials]'; }
|
|
44
|
+
toString() { return '[credentials]'; }
|
|
45
|
+
[Symbol.for('nodejs.util.inspect.custom')]() { return '[credentials]'; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The registry's own credentials: environment first, then the Docker config entry for exactly this registry. Never another host's. */
|
|
49
|
+
export function resolveCredentials(registry, { env = process.env, dockerConfig = path.join(os.homedir(), '.docker', 'config.json') } = {}) {
|
|
50
|
+
if (env.AGENTIC_SECURITY_REGISTRY_TOKEN) return new Credentials({ token: env.AGENTIC_SECURITY_REGISTRY_TOKEN });
|
|
51
|
+
if (env.AGENTIC_SECURITY_REGISTRY_USER && env.AGENTIC_SECURITY_REGISTRY_PASSWORD) return new Credentials({ user: env.AGENTIC_SECURITY_REGISTRY_USER, password: env.AGENTIC_SECURITY_REGISTRY_PASSWORD });
|
|
52
|
+
try {
|
|
53
|
+
const cfg = JSON.parse(fs.readFileSync(dockerConfig, 'utf8'));
|
|
54
|
+
const keys = [registry, `https://${registry}`, ...(registry === 'registry-1.docker.io' ? ['https://index.docker.io/v1/', 'docker.io'] : [])];
|
|
55
|
+
for (const k of keys) {
|
|
56
|
+
const auth = cfg.auths?.[k]?.auth;
|
|
57
|
+
if (typeof auth === 'string') { const [user, ...rest] = Buffer.from(auth, 'base64').toString('utf8').split(':'); if (user && rest.length) return new Credentials({ user, password: rest.join(':') }); }
|
|
58
|
+
}
|
|
59
|
+
} catch { /* no usable Docker config */ }
|
|
60
|
+
return new Credentials({});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @returns {{registry: string, repository: string, reference: string, isDigest: boolean, loopback: boolean}} */
|
|
64
|
+
export function parseReference(input) {
|
|
65
|
+
const raw = String(input ?? '');
|
|
66
|
+
if (!raw || raw.length > 300 || /[\s\0\\]/.test(raw) || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.includes('@') && !/@sha256:[0-9a-f]{64}$/.test(raw) || raw.includes('..') || raw.startsWith('-')) throw new RegistryError('not a valid image reference', 'bad_reference');
|
|
67
|
+
let rest = raw; let reference = 'latest'; let isDigest = false;
|
|
68
|
+
const at = rest.indexOf('@');
|
|
69
|
+
if (at !== -1) { reference = rest.slice(at + 1); rest = rest.slice(0, at); isDigest = true; }
|
|
70
|
+
const parts = rest.split('/');
|
|
71
|
+
let registry = 'registry-1.docker.io';
|
|
72
|
+
if (parts.length > 1 && (parts[0].includes('.') || parts[0].includes(':') || parts[0] === 'localhost')) registry = parts.shift();
|
|
73
|
+
let repo = parts.join('/');
|
|
74
|
+
if (!isDigest) { const colon = repo.lastIndexOf(':'); if (colon !== -1) { reference = repo.slice(colon + 1); repo = repo.slice(0, colon); } }
|
|
75
|
+
if (registry === 'registry-1.docker.io' && !repo.includes('/')) repo = `library/${repo}`;
|
|
76
|
+
if (registry === 'docker.io' || registry === 'index.docker.io') registry = 'registry-1.docker.io';
|
|
77
|
+
if (!/^[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*$/.test(repo)) throw new RegistryError('not a valid repository name', 'bad_reference');
|
|
78
|
+
if (!isDigest && !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(reference)) throw new RegistryError('not a valid tag', 'bad_reference');
|
|
79
|
+
if (isDigest && !DIGEST_RE.test(reference)) throw new RegistryError('only sha256 digests are supported', 'bad_reference');
|
|
80
|
+
if (!/^(?:\[::1\]|[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?)(?::\d{1,5})?$/.test(registry)) throw new RegistryError('not a valid registry host', 'bad_reference');
|
|
81
|
+
const host = registry.replace(/:\d+$/, '');
|
|
82
|
+
return { registry, repository: repo, reference, isDigest, loopback: LOOPBACK_HOST.test(host) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const scrubUrl = (u) => { try { const x = new URL(u); return `${x.protocol}//${x.host}${x.pathname}`; } catch { return '[url]'; } };
|
|
86
|
+
|
|
87
|
+
export async function fetchImage(input, { platform = 'linux/amd64', credentials = null, fetchImpl = globalThis.fetch, limits = REGISTRY_LIMITS, signal = null, env = process.env, allowLoopbackHttp = false } = {}) {
|
|
88
|
+
const ref = parseReference(input);
|
|
89
|
+
const L = { ...REGISTRY_LIMITS, ...limits };
|
|
90
|
+
const creds = credentials ?? resolveCredentials(ref.registry, { env });
|
|
91
|
+
const scheme = ref.loopback && allowLoopbackHttp ? 'http' : 'https';
|
|
92
|
+
const origin = `${scheme}://${ref.registry}`;
|
|
93
|
+
const deadline = Date.now() + L.totalTimeoutMs;
|
|
94
|
+
let authHeader = null; let total = 0;
|
|
95
|
+
const allowedUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' || (x.protocol === 'http:' && LOOPBACK_HOST.test(x.hostname)); } catch { return false; } };
|
|
96
|
+
|
|
97
|
+
async function request(url, { accept, auth = true, maxBytes, expectDigest = null } = {}) {
|
|
98
|
+
let current = url; let sendAuth = auth;
|
|
99
|
+
for (let hop = 0; hop <= L.maxRedirects; hop++) {
|
|
100
|
+
if (!allowedUrl(current)) throw new RegistryError(`refusing a non-HTTPS target: ${scrubUrl(current)}`, 'insecure_transport');
|
|
101
|
+
if (Date.now() > deadline) throw new RegistryError('the total time budget was exhausted', 'timeout');
|
|
102
|
+
if (signal?.aborted) throw new RegistryError('the request was cancelled', 'network');
|
|
103
|
+
const sameOrigin = new URL(current).origin === new URL(origin).origin;
|
|
104
|
+
const headers = { 'user-agent': 'agentic-security-image-scan', ...(accept ? { accept } : {}) };
|
|
105
|
+
if (sendAuth && sameOrigin) { const h = authHeader ?? creds.bearerHeader(); if (h) headers.authorization = h; }
|
|
106
|
+
const ctl = new AbortController();
|
|
107
|
+
const t = setTimeout(() => ctl.abort(), L.requestTimeoutMs);
|
|
108
|
+
const onAbort = () => ctl.abort(); signal?.addEventListener('abort', onAbort, { once: true });
|
|
109
|
+
let res;
|
|
110
|
+
try { res = await fetchImpl(current, { headers, redirect: 'manual', signal: ctl.signal }); }
|
|
111
|
+
catch (e) { throw new RegistryError(ctl.signal.aborted ? 'the request timed out or was cancelled' : `request failed: ${String(e.cause?.code ?? e.code ?? e.message).slice(0, 80)}`, 'network'); }
|
|
112
|
+
finally { clearTimeout(t); signal?.removeEventListener('abort', onAbort); }
|
|
113
|
+
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
|
|
114
|
+
const next = new URL(res.headers.get('location'), current).toString();
|
|
115
|
+
if (new URL(next).origin !== new URL(origin).origin) sendAuth = false; // never carry the registry credential to another host
|
|
116
|
+
current = next; continue;
|
|
117
|
+
}
|
|
118
|
+
if (res.status === 401 && sameOrigin && !authHeader) return { status: 401, challenge: res.headers.get('www-authenticate') ?? '' };
|
|
119
|
+
if (res.status === 404) throw new RegistryError('not found in the registry', 'not_found');
|
|
120
|
+
if (res.status === 401 || res.status === 403) throw new RegistryError('the registry refused access (authentication required or not permitted)', 'unauthorized');
|
|
121
|
+
if (!res.ok) throw new RegistryError(`the registry answered ${res.status}`, 'registry_error');
|
|
122
|
+
const declared = Number(res.headers.get('content-length'));
|
|
123
|
+
if (Number.isFinite(declared) && declared > maxBytes) throw new RegistryError(`a response larger than the ${maxBytes}-byte limit`, 'too_large');
|
|
124
|
+
const chunks = []; let got = 0;
|
|
125
|
+
for await (const c of res.body) {
|
|
126
|
+
got += c.length; total += c.length;
|
|
127
|
+
if (got > maxBytes || total > L.maxTotalBytes) { try { await res.body.cancel(); } catch { /* closing */ } throw new RegistryError('the response exceeded its size limit', 'too_large'); }
|
|
128
|
+
chunks.push(c);
|
|
129
|
+
}
|
|
130
|
+
const body = Buffer.concat(chunks);
|
|
131
|
+
if (expectDigest && `sha256:${sha256Hex(body)}` !== expectDigest) throw new RegistryError('a downloaded blob does not match its digest', 'digest_mismatch');
|
|
132
|
+
return { status: res.status, body, headers: res.headers };
|
|
133
|
+
}
|
|
134
|
+
throw new RegistryError('too many redirects', 'redirect_loop');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function authed(url, opts) {
|
|
138
|
+
let r = await request(url, opts);
|
|
139
|
+
if (r.status !== 401) return r;
|
|
140
|
+
const basic = creds.basicHeader();
|
|
141
|
+
if (/^Basic\b/i.test(r.challenge)) {
|
|
142
|
+
if (!basic) throw new RegistryError('the registry requires authentication and none is configured', 'unauthorized');
|
|
143
|
+
authHeader = basic;
|
|
144
|
+
r = await request(url, opts);
|
|
145
|
+
if (r.status === 401) throw new RegistryError('the registry rejected the credentials', 'unauthorized');
|
|
146
|
+
return r;
|
|
147
|
+
}
|
|
148
|
+
const m = /^Bearer\s+(.*)$/i.exec(r.challenge);
|
|
149
|
+
if (!m) throw new RegistryError(basic ? 'the registry asked for a scheme this client does not support' : 'the registry requires authentication and none is configured', 'unauthorized');
|
|
150
|
+
const params = Object.fromEntries([...m[1].matchAll(/(\w+)="([^"]*)"/g)].map((x) => [x[1], x[2]]));
|
|
151
|
+
if (!params.realm) throw new RegistryError('the authentication challenge named no token endpoint', 'unauthorized');
|
|
152
|
+
const tokenUrl = new URL(params.realm);
|
|
153
|
+
if (params.service) tokenUrl.searchParams.set('service', params.service);
|
|
154
|
+
tokenUrl.searchParams.set('scope', params.scope || `repository:${ref.repository}:pull`);
|
|
155
|
+
if (!allowedUrl(tokenUrl.toString())) throw new RegistryError('refusing a non-HTTPS token endpoint', 'insecure_transport');
|
|
156
|
+
// the token endpoint may be a different host (Docker Hub's is); the registry credential goes only to the host the challenge named, over HTTPS
|
|
157
|
+
const headers = { 'user-agent': 'agentic-security-image-scan', ...(basic ? { authorization: basic } : {}) };
|
|
158
|
+
let tr;
|
|
159
|
+
try { tr = await fetchImpl(tokenUrl.toString(), { headers, redirect: 'error' }); } catch { throw new RegistryError('the token request failed', 'network'); }
|
|
160
|
+
if (!tr.ok) throw new RegistryError(`the token endpoint answered ${tr.status}`, 'unauthorized');
|
|
161
|
+
let tj; try { tj = JSON.parse((await tr.text()).slice(0, 65536)); } catch { throw new RegistryError('the token endpoint returned something that is not JSON', 'unauthorized'); }
|
|
162
|
+
const token = tj.token ?? tj.access_token;
|
|
163
|
+
if (typeof token !== 'string' || !token) throw new RegistryError('the token endpoint returned no token', 'unauthorized');
|
|
164
|
+
authHeader = `Bearer ${token}`;
|
|
165
|
+
r = await request(url, opts);
|
|
166
|
+
if (r.status === 401) throw new RegistryError('the registry rejected the token', 'unauthorized');
|
|
167
|
+
return r;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const base = `${origin}/v2/${ref.repository}`;
|
|
171
|
+
let m = await authed(`${base}/manifests/${ref.reference}`, { accept: MANIFEST_ACCEPT, maxBytes: L.maxManifestBytes, ...(ref.isDigest ? { expectDigest: ref.reference } : {}) });
|
|
172
|
+
let manifestBytes = m.body;
|
|
173
|
+
const topDigest = `sha256:${sha256Hex(m.body)}`;
|
|
174
|
+
const headerDigest = m.headers.get('docker-content-digest');
|
|
175
|
+
if (headerDigest && headerDigest !== `sha256:${sha256Hex(manifestBytes)}`) throw new RegistryError('the manifest does not match the digest the registry reported for it', 'digest_mismatch');
|
|
176
|
+
let doc = parseJson(manifestBytes, 'manifest');
|
|
177
|
+
let mediaType = doc.mediaType ?? m.headers.get('content-type')?.split(';')[0];
|
|
178
|
+
let selected = null;
|
|
179
|
+
if (INDEX_TYPES.has(mediaType) || Array.isArray(doc.manifests)) {
|
|
180
|
+
const [os_, arch] = platform.split('/');
|
|
181
|
+
const entries = (doc.manifests ?? []).filter((e) => e?.platform && e.platform.os === os_ && e.platform.architecture === arch && DIGEST_RE.test(String(e.digest)));
|
|
182
|
+
if (!entries.length) throw new RegistryError(`the image has no ${platform} variant (available: ${(doc.manifests ?? []).map((e) => `${e?.platform?.os}/${e?.platform?.architecture}`).filter((x) => !x.includes('undefined')).slice(0, 12).join(', ') || 'none'})`, 'platform_not_found');
|
|
183
|
+
selected = entries[0];
|
|
184
|
+
m = await authed(`${base}/manifests/${selected.digest}`, { accept: MANIFEST_ACCEPT, maxBytes: L.maxManifestBytes, expectDigest: selected.digest });
|
|
185
|
+
manifestBytes = m.body; doc = parseJson(manifestBytes, 'platform manifest'); mediaType = doc.mediaType ?? selected.mediaType;
|
|
186
|
+
}
|
|
187
|
+
if (!MANIFEST_TYPES.has(mediaType) && !(Array.isArray(doc.layers) && doc.config)) throw new RegistryError('the registry returned a manifest of a kind this client does not read', 'unsupported_manifest');
|
|
188
|
+
if (!doc.config || !DIGEST_RE.test(String(doc.config.digest)) || !Array.isArray(doc.layers)) throw new RegistryError('the manifest is malformed', 'malformed_manifest');
|
|
189
|
+
if (doc.layers.length > L.maxLayers) throw new RegistryError(`the image has more than ${L.maxLayers} layers`, 'too_large');
|
|
190
|
+
for (const l of doc.layers) {
|
|
191
|
+
if (!DIGEST_RE.test(String(l.digest))) throw new RegistryError('a layer has no sha256 digest', 'malformed_manifest');
|
|
192
|
+
if (/foreign|nondistributable/i.test(String(l.mediaType)) || Array.isArray(l.urls)) throw new RegistryError('the image has a foreign (externally hosted) layer, which is not fetched', 'unsupported_manifest');
|
|
193
|
+
if (!Number.isInteger(l.size) || l.size < 0 || l.size > L.maxLayerBytes) throw new RegistryError(`a layer is larger than the ${L.maxLayerBytes}-byte limit`, 'too_large');
|
|
194
|
+
}
|
|
195
|
+
const blob = async (desc, cap) => {
|
|
196
|
+
if (Number.isInteger(desc.size) && desc.size > cap) throw new RegistryError('a blob is larger than its limit', 'too_large');
|
|
197
|
+
const r = await authed(`${base}/blobs/${desc.digest}`, { maxBytes: cap, expectDigest: desc.digest });
|
|
198
|
+
if (Number.isInteger(desc.size) && r.body.length !== desc.size) throw new RegistryError('a blob is not the size its manifest declared', 'digest_mismatch');
|
|
199
|
+
return r.body;
|
|
200
|
+
};
|
|
201
|
+
const config = await blob(doc.config, L.maxConfigBytes);
|
|
202
|
+
const layers = [];
|
|
203
|
+
for (const l of doc.layers) layers.push(await blob(l, L.maxLayerBytes));
|
|
204
|
+
const built = buildOciArchive({ manifest: manifestBytes, config, layers, mediaType: MANIFEST_TYPES.has(mediaType) ? mediaType : 'application/vnd.oci.image.manifest.v1+json' });
|
|
205
|
+
return {
|
|
206
|
+
tar: built.tar, manifestDigest: built.manifestDigest,
|
|
207
|
+
source: { kind: 'registry', registry: ref.registry, repository: ref.repository, reference: ref.reference, platform: selected ? `${selected.platform.os}/${selected.platform.architecture}` : null, indexDigest: selected ? topDigest : null, bytes: total, authenticated: creds.present },
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseJson(buf, what) {
|
|
212
|
+
try { const j = JSON.parse(buf.toString('utf8')); if (!j || typeof j !== 'object' || Array.isArray(j)) throw new Error('not an object'); return j; } catch { throw new RegistryError(`the ${what} is not valid JSON`, 'malformed_manifest'); }
|
|
213
|
+
}
|
|
@@ -43,6 +43,8 @@ export const TOOL_CAPABILITIES = Object.freeze({
|
|
|
43
43
|
portfolio_progress: read('builds a read-only progress view from a portfolio store, ledger and optional inputs, confined to the session root'),
|
|
44
44
|
apply_fix: mutating('writes verified patches into the project'),
|
|
45
45
|
verify_fix: mutating('runs the project linter and tests and appends fix metrics'),
|
|
46
|
+
prove_finding: mutating('runs a reproducer in the confined proof environment and writes a proof record'),
|
|
47
|
+
prove_fix: mutating('replays the original exploit against a copy of the patched sources and runs the project tests; writes nothing to the project'),
|
|
46
48
|
append_scratchpad: mutating('writes under the agent scratchpad'),
|
|
47
49
|
append_agents_memory: mutating('appends to the continual-learning file'),
|
|
48
50
|
synthesize_sca_upgrade: external('runs a package manager dry-run, which may reach a registry'),
|
|
@@ -46,7 +46,7 @@ export const BOUNDARIES = Object.freeze({
|
|
|
46
46
|
unsafeCoerce: 'Reinterprets a value as another type without a check. Modelled as a boundary only.',
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
const WEAK_HASH_CALLS = new Set(['Crypto.Hash.MD5.hash', 'Crypto.Hash.MD5.hashlazy', 'Crypto.Hash.SHA1.hash', 'Crypto.Hash.SHA1.hashlazy', 'Data.Digest.Pure.MD5.md5', 'Data.Digest.Pure.SHA.sha1']);
|
|
49
|
+
const WEAK_HASH_CALLS = new Set(['Crypto.Hash.MD5.hash', 'Crypto.Hash.MD5.hashlazy', 'Crypto.Hash.SHA1.hash', 'Crypto.Hash.SHA1.hashlazy', 'Data.Digest.Pure.MD5.md5', 'Data.Digest.Pure.MD5.md5DigestBytes', 'Data.Digest.Pure.SHA.sha1', 'Data.Digest.Pure.SHA.sha1Incremental']);
|
|
50
50
|
const WEAK_ALGO_CONS = new Set(['MD5', 'MD4', 'MD2', 'SHA1']);
|
|
51
51
|
const ANY_HASH_CALLS = new Set([...WEAK_HASH_CALLS, 'Crypto.Hash.hash', 'Crypto.Hash.hashlazy', 'Crypto.Hash.hashWith', 'Crypto.Hash.SHA256.hash', 'Crypto.Hash.SHA256.hashlazy', 'Crypto.Hash.SHA512.hash', 'Crypto.Hash.SHA512.hashlazy', 'Data.Digest.Pure.SHA.sha256', 'Data.Digest.Pure.SHA.sha512']);
|
|
52
52
|
const WEAK_CIPHER_MODULES = new Set(['Crypto.Cipher.DES', 'Crypto.Cipher.RC4', 'Crypto.Cipher.RC2', 'Crypto.Cipher.Blowfish']);
|
|
@@ -59,6 +59,12 @@ export const HARDENING_RULES = Object.freeze({
|
|
|
59
59
|
'getty-autologin': { family: 'login-access', cwe: 'CWE-306', severity: 'high', ruleVersion: 1, vuln: 'The console logs a user in without a password', why: 'services.getty.autologinUser starts a session on every virtual console with no authentication. For root that is a root shell for anyone with the keyboard, a serial line or a hypervisor console.', fix: 'Remove services.getty.autologinUser. Keep autologin to throwaway virtual machines and tests.' },
|
|
60
60
|
'display-autologin': { family: 'login-access', cwe: 'CWE-306', severity: 'low', ruleVersion: 1, vuln: 'The display manager logs a user in without a password', why: 'Automatic login skips authentication at the graphical login, so anyone who can power the machine on gets the session. Disk encryption is the only remaining barrier.', fix: 'Disable autoLogin, or keep it only where physical access is controlled and the disk is encrypted.' },
|
|
61
61
|
'pam-null-password': { family: 'login-access', cwe: 'CWE-258', severity: 'high', ruleVersion: 1, vuln: 'A PAM service accepts accounts with an empty password', why: 'allowNullPassword lets an account with no password log in through this service with an empty answer, which turns every passwordless account into an open login.', fix: 'Remove allowNullPassword (it defaults to false) and give every account a password hash or a key.' },
|
|
62
|
+
'nix-sandbox-off': { family: 'nix-daemon-trust', cwe: 'CWE-693', severity: 'medium', ruleVersion: 1, vuln: 'The Nix build sandbox is disabled', why: 'With the sandbox off, a build can read the host filesystem and the network, so a malicious or impure derivation can steal files and secrets or change its output depending on the host.', fix: 'Remove nix.settings.sandbox = false (the default is true). Fix the one derivation that needs impurity instead of weakening every build.' },
|
|
63
|
+
'nix-signatures-off': { family: 'nix-daemon-trust', cwe: 'CWE-347', severity: 'high', ruleVersion: 1, vuln: 'Nix accepts unsigned store paths', why: 'require-sigs = false lets the daemon import any store path from a client or substituter without a trusted signature, so whoever can push a path can run code as every user of that path.', fix: 'Remove nix.settings.require-sigs = false and sign the paths you copy with a key listed in trusted-public-keys.' },
|
|
64
|
+
'nix-trusted-users-wildcard': { family: 'nix-daemon-trust', cwe: 'CWE-269', severity: 'high', ruleVersion: 1, vuln: 'Every user is a Nix trusted user', why: 'A trusted user can import unsigned paths and set daemon options, which is root on the machine. Listing "*" or a broad group extends that to everyone who can reach the daemon.', fix: 'Keep nix.settings.trusted-users to root (and named administrators). Give other users nix.settings.allowed-users instead.' },
|
|
65
|
+
'kernel-sysctl-weakened': { family: 'kernel-hardening', cwe: 'CWE-693', severity: 'medium', ruleVersion: 1, vuln: 'A kernel hardening sysctl is set to a weaker value', why: 'Turning off address space randomisation, kernel pointer hiding or the unprivileged BPF restriction removes a mitigation that makes local exploits harder.', fix: 'Remove the boot.kernel.sysctl entry or restore the hardened value (randomize_va_space = 2, kptr_restrict = 1 or 2, unprivileged_bpf_disabled = 1).' },
|
|
66
|
+
'nfs-no-root-squash': { family: 'network-filesystem', cwe: 'CWE-269', severity: 'high', ruleVersion: 1, vuln: 'NFS export keeps remote root as root', why: 'no_root_squash maps a client\'s root user to root on the exported files, so anyone with root on a client can plant setuid binaries on the server.', fix: 'Drop no_root_squash (root_squash is the default) and restrict the client list to specific hosts.' },
|
|
67
|
+
'docker-tcp-no-tls': { family: 'container-declaration', cwe: 'CWE-306', severity: 'high', ruleVersion: 1, vuln: 'The Docker daemon listens on an unauthenticated TCP socket', why: 'A tcp:// host without TLS client verification gives anyone who can reach the port root on the machine, since the Docker API starts privileged containers.', fix: 'Remove the tcp:// host, or enable TLS with --tlsverify and a CA, and bind only to a private address.' },
|
|
62
68
|
});
|
|
63
69
|
|
|
64
70
|
/** Which rules explain each privileged entry-point kind (reconciliation join). */
|
|
@@ -408,6 +414,37 @@ export function analyzeNixosHardening(opts, extra = {}) {
|
|
|
408
414
|
if (j === 'yes' || j === 'conditional') addFinding('pam-null-password', { anchor: anchorOf(o), scope: o.path.replace(/\.allowNullPassword$/, ''), state: j === 'conditional' ? 'conditional' : 'active', uncertain: !view(o).definite, evidence: [evidenceOf(o)] });
|
|
409
415
|
}
|
|
410
416
|
|
|
417
|
+
// ── nix daemon trust, kernel sysctls, network filesystems, docker socket ──
|
|
418
|
+
const sandboxRes = note(nixos('nix.settings.sandbox'), 'nix');
|
|
419
|
+
{ const j = judge(sandboxRes, (x) => x === false); if (j === 'yes' || j === 'conditional') addFinding('nix-sandbox-off', { anchor: anchorOf(sandboxRes), scope: 'nix.settings.sandbox', state: j === 'conditional' ? 'conditional' : 'active', uncertain: !view(sandboxRes).definite, evidence: [evidenceOf(sandboxRes)] }); }
|
|
420
|
+
const sigsRes = note(nixos('nix.settings.require-sigs'), 'nix');
|
|
421
|
+
{ const j = judge(sigsRes, (x) => x === false); if (j === 'yes' || j === 'conditional') addFinding('nix-signatures-off', { anchor: anchorOf(sigsRes), scope: 'nix.settings.require-sigs', state: j === 'conditional' ? 'conditional' : 'active', uncertain: !view(sigsRes).definite, evidence: [evidenceOf(sigsRes)] }); }
|
|
422
|
+
const trustedRes = note(nixos('nix.settings.trusted-users'), 'nix');
|
|
423
|
+
{ const v = view(trustedRes);
|
|
424
|
+
const items = v.state === 'known' ? asList(v.value) : v.state === 'conditional' ? asList(v.definiteItems) : [];
|
|
425
|
+
if (items.some((u) => u === '*' || u === '@users' || u === '@everyone')) addFinding('nix-trusted-users-wildcard', { anchor: anchorOf(trustedRes), scope: 'nix.settings.trusted-users', state: v.state === 'conditional' ? 'conditional' : 'active', uncertain: !v.definite, evidence: [evidenceOf(trustedRes)] }); }
|
|
426
|
+
const SYSCTL_WEAK = new Map([['kernel.randomize_va_space', (x) => String(x) === '0'], ['kernel.kptr_restrict', (x) => String(x) === '0'], ['kernel.unprivileged_bpf_disabled', (x) => String(x) === '0'], ['kernel.dmesg_restrict', (x) => String(x) === '0'], ['kernel.yama.ptrace_scope', (x) => String(x) === '0'], ['fs.protected_symlinks', (x) => String(x) === '0'], ['fs.protected_hardlinks', (x) => String(x) === '0']]);
|
|
427
|
+
for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && /^boot\.kernel\.sysctl\./.test(x.path))) {
|
|
428
|
+
const key = o.path.slice('boot.kernel.sysctl.'.length).replace(/^"|"$/g, '');
|
|
429
|
+
const weak = SYSCTL_WEAK.get(key);
|
|
430
|
+
if (!weak) continue;
|
|
431
|
+
const j = judge(note(o, 'kernel'), weak);
|
|
432
|
+
if (j === 'yes' || j === 'conditional') addFinding('kernel-sysctl-weakened', { anchor: anchorOf(o), scope: `boot.kernel.sysctl.${key}`, state: j === 'conditional' ? 'conditional' : 'active', uncertain: !view(o).definite, evidence: [evidenceOf(o)], extra: { sysctl: key } });
|
|
433
|
+
}
|
|
434
|
+
const nfsRes = note(nixos('services.nfs.server.exports'), 'nfs');
|
|
435
|
+
{ const j = judge(nfsRes, (x) => typeof x === 'string' && /(^|[\s,(])no_root_squash(?=[\s,)]|$)/.test(x.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n')));
|
|
436
|
+
if (j === 'yes' || j === 'conditional') addFinding('nfs-no-root-squash', { anchor: anchorOf(nfsRes), scope: 'services.nfs.server.exports', state: j === 'conditional' ? 'conditional' : 'active', uncertain: !view(nfsRes).definite, evidence: [evidenceOf(nfsRes)] }); }
|
|
437
|
+
{ const dockerOn = judge(nixos('virtualisation.docker.enable'), (x) => x === true);
|
|
438
|
+
if (dockerOn === 'yes' || dockerOn === 'conditional') {
|
|
439
|
+
const optRes = nixos('virtualisation.docker.extraOptions');
|
|
440
|
+
const hostsRes = nixos('virtualisation.docker.daemon.settings.hosts');
|
|
441
|
+
const tlsOn = ['tls', 'tlsverify'].some((k) => view(nixos(`virtualisation.docker.daemon.settings.${k}`)).value === true);
|
|
442
|
+
const j1 = judge(optRes, (x) => typeof x === 'string' && /(?:-H|--host)[ =]+tcp:\/\//.test(x) && !/--tlsverify|--tls\b/.test(x));
|
|
443
|
+
const j2 = tlsOn ? 'no' : judge(hostsRes, (x) => Array.isArray(x) && x.some((h) => typeof h === 'string' && /^tcp:\/\//.test(h)));
|
|
444
|
+
const j = j1 === 'yes' ? 'yes' : j2 === 'yes' ? 'yes' : j1 === 'conditional' || j2 === 'conditional' ? 'conditional' : 'no';
|
|
445
|
+
if (j === 'yes' || j === 'conditional') { const res = j1 !== 'no' ? optRes : hostsRes; addFinding('docker-tcp-no-tls', { anchor: anchorOf(res), scope: 'virtualisation.docker', state: j === 'conditional' || dockerOn === 'conditional' ? 'conditional' : 'active', uncertain: false, evidence: [evidenceOf(res)] }); }
|
|
446
|
+
} }
|
|
447
|
+
|
|
411
448
|
// ── databases / listeners ──
|
|
412
449
|
if (hasDefs('services.postgresql.')) {
|
|
413
450
|
const enableRes = nixos('services.postgresql.enable');
|
package/src/lineage/CLAUDE.md
CHANGED
|
@@ -1259,3 +1259,7 @@ synthetic root `$hs`, seeds record-parameter sources, and treats `length`/`null`
|
|
|
1259
1259
|
field-to-store flows from `language/nix-privacy.js` (a personal-data `config.<module>.<field>` interpolated into another option;
|
|
1260
1260
|
hashed, measured or compared references are protected and make no flow). Coverage reports `unknown` for both languages: no
|
|
1261
1261
|
taint-recall tier is borrowed. See `scanner/src/language/CLAUDE.md`.
|
|
1262
|
+
|
|
1263
|
+
## Runtime twin additions (PRD 2 gap closure)
|
|
1264
|
+
|
|
1265
|
+
`runtime-edge-states.js` derives the six edge states and the identity / absent-from-static queries from the existing correlation, as a pure read: nothing is written into the static graph. `RUNTIME_EXPLOIT_CORRELATED` is never inferred from observation; it needs validated operator evidence. `identity-token.js` supplies a keyed-hash tokenizer injected into the adapters through `context.tokenizeIdentity`; the adapters stay zero-import and drop identity when no tokenizer is given. The attribute allowlist test pins `identity.token`; widen it deliberately, never by loosening the grammar.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// A keyed-hash token for an authenticated identity (PRD 2 section 1, "authenticated identity"). The observation contract is closed-world
|
|
2
|
+
// and refuses raw identities, so what is stored is `idt_` + 16 hex of an HMAC under the per-install key:
|
|
3
|
+
// * the same identity always yields the same token on this install, so "the same identity used these paths" can be answered;
|
|
4
|
+
// * without the install key the token cannot be reversed or confirmed by guessing usernames (an unkeyed hash could);
|
|
5
|
+
// * tokens from two installs do not match, which is deliberate: they are not a portable identifier.
|
|
6
|
+
// It is a pseudonym, not anonymity: someone who holds the install key and a candidate username can confirm a match.
|
|
7
|
+
import { signLastScan } from '../posture/integrity.js';
|
|
8
|
+
|
|
9
|
+
export function makeIdentityTokenizer() {
|
|
10
|
+
return (raw) => (typeof raw === 'string' && raw.length > 0 ? `idt_${signLastScan(`identity-token:v1:${raw}`).slice(0, 16)}` : null);
|
|
11
|
+
}
|
|
@@ -305,6 +305,16 @@ function _splitHostPort(v) {
|
|
|
305
305
|
return { host: _safeIdentifierOrNull(host), port };
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
// Identity is recorded ONLY as a keyed-hash token, and only when the importer supplied the tokenizer (`context.tokenizeIdentity`).
|
|
309
|
+
// This module stays zero-import and holds no key: with no tokenizer, identity is dropped, never stored raw and never hashed without a
|
|
310
|
+
// key (an unkeyed hash of a username is reversible by guessing). A raw value that is not a plausible identity string is dropped too.
|
|
311
|
+
function _identityToken(context, raw) {
|
|
312
|
+
if (typeof context?.tokenizeIdentity !== 'function') return null;
|
|
313
|
+
if (typeof raw !== 'string' || raw.length === 0 || raw.length > 256 || /[\u0000-\u001f]/.test(raw)) return null;
|
|
314
|
+
const t = context.tokenizeIdentity(raw);
|
|
315
|
+
return typeof t === 'string' && /^idt_[0-9a-f]{16}$/.test(t) ? t : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
308
318
|
function _draftBase(context, environmentOverride) {
|
|
309
319
|
const { version, adapter, source, environment, windowStart, windowEnd, importedAt, retention } = context;
|
|
310
320
|
return {
|
|
@@ -398,6 +408,7 @@ export function parseOtelJsonObservations(text, context) {
|
|
|
398
408
|
if (storeService) attributes['destination.service'] = storeService;
|
|
399
409
|
|
|
400
410
|
if (Object.keys(attributes).length === 0) continue; // nothing safe to report for this span
|
|
411
|
+
{ const idt = _identityToken(context, a['enduser.id']); if (idt) attributes['identity.token'] = idt; }
|
|
401
412
|
|
|
402
413
|
const at = _isoFromUnixNano(span.startTimeUnixNano) ?? context.windowStart;
|
|
403
414
|
drafts.push({
|
|
@@ -547,6 +558,7 @@ export function parseK8sAuditLogObservations(text, context) {
|
|
|
547
558
|
}
|
|
548
559
|
|
|
549
560
|
if (Object.keys(attributes).length === 0) continue;
|
|
561
|
+
{ const idt = _identityToken(context, _isPlainObject(record.user) ? record.user.username : undefined); if (idt) attributes['identity.token'] = idt; }
|
|
550
562
|
|
|
551
563
|
const observedAt = typeof record.requestReceivedTimestamp === 'string' && Number.isFinite(Date.parse(record.requestReceivedTimestamp))
|
|
552
564
|
? record.requestReceivedTimestamp
|
|
@@ -610,6 +622,7 @@ export function parseHttpAccessLogObservations(text, context) {
|
|
|
610
622
|
if (scheme) attributes['destination.scheme'] = scheme;
|
|
611
623
|
|
|
612
624
|
if (Object.keys(attributes).length === 0) continue;
|
|
625
|
+
{ const idt = _identityToken(context, record.user ?? record.remote_user ?? record.authenticated_user); if (idt) attributes['identity.token'] = idt; }
|
|
613
626
|
|
|
614
627
|
const ts = record.timestamp ?? record.time;
|
|
615
628
|
const observedAt = typeof ts === 'string' && Number.isFinite(Date.parse(ts)) ? ts : context.windowStart;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Edge states and the identity-aware / absent-from-static queries of the Runtime-Corroborated Security Twin (PRD 2: "Edge States",
|
|
2
|
+
// section 6). Pure: no fs, no I/O, never throws, never mutates the graph. It READS what `observation-correlation.js` and the
|
|
3
|
+
// observation store already hold; it adds no second matching engine and writes nothing into the static graph (the overlay stays an
|
|
4
|
+
// overlay).
|
|
5
|
+
//
|
|
6
|
+
// The six states, and when each is ALLOWED to appear:
|
|
7
|
+
// STATIC_ONLY no telemetry was consulted at all (not "nothing seen": nothing was looked at)
|
|
8
|
+
// RUNTIME_NOT_OBSERVED telemetry was consulted for this environment/window and no trusted observation maps to the edge.
|
|
9
|
+
// Never "unreachable": the window may be short, the exporter partial, the mapping weak.
|
|
10
|
+
// RUNTIME_OBSERVED at least one high- or medium-confidence observation maps to the edge
|
|
11
|
+
// RUNTIME_BLOCKED observations map to the edge and every decision recorded for them was `denied` (the path was
|
|
12
|
+
// attempted and refused); an edge with both allowed and denied observations carries OBSERVED and BLOCKED
|
|
13
|
+
// RUNTIME_POLICY_VIOLATION the edge was observed (not only denied) AND a flow through it is `prohibited` by the privacy policy
|
|
14
|
+
// RUNTIME_EXPLOIT_CORRELATED the edge was observed AND the operator supplied explicit evidence naming this edge/flow/node as the
|
|
15
|
+
// target of a proven exploit. Never inferred from observation alone: seeing traffic is not seeing an attack.
|
|
16
|
+
// Low-confidence and ambiguous observations (PRD: "low-confidence observations remain unmatched") never create a state; they are
|
|
17
|
+
// counted in `uncertainObservations` so the weakness is visible instead of silently dropped.
|
|
18
|
+
export const EDGE_STATES = Object.freeze(['STATIC_ONLY', 'RUNTIME_NOT_OBSERVED', 'RUNTIME_OBSERVED', 'RUNTIME_BLOCKED', 'RUNTIME_POLICY_VIOLATION', 'RUNTIME_EXPLOIT_CORRELATED']);
|
|
19
|
+
const SEVERITY_ORDER = ['RUNTIME_EXPLOIT_CORRELATED', 'RUNTIME_POLICY_VIOLATION', 'RUNTIME_BLOCKED', 'RUNTIME_OBSERVED', 'RUNTIME_NOT_OBSERVED', 'STATIC_ONLY'];
|
|
20
|
+
const TRUSTED = new Set(['high', 'medium']);
|
|
21
|
+
|
|
22
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
23
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
24
|
+
|
|
25
|
+
/** Operator-supplied exploit evidence: [{ kind, id, edgeId?|flowId?|nodeId? }]. Anything malformed is reported, not trusted. */
|
|
26
|
+
export function validateExploitEvidence(list) {
|
|
27
|
+
const errors = []; const ok = [];
|
|
28
|
+
if (list === null || list === undefined) return { evidence: [], errors };
|
|
29
|
+
if (!Array.isArray(list)) return { evidence: [], errors: ['exploit evidence must be an array'] };
|
|
30
|
+
list.forEach((e, i) => {
|
|
31
|
+
if (!isObj(e) || typeof e.id !== 'string' || !e.id || !['proof', 'dnr-hunt', 'incident'].includes(e.kind)) { errors.push(`entry ${i}: needs kind (proof|dnr-hunt|incident) and id`); return; }
|
|
32
|
+
const targets = ['edgeId', 'flowId', 'nodeId'].filter((k) => typeof e[k] === 'string' && e[k]);
|
|
33
|
+
if (targets.length !== 1) { errors.push(`entry ${i}: name exactly one of edgeId, flowId, nodeId`); return; }
|
|
34
|
+
ok.push({ kind: e.kind, id: e.id.slice(0, 120), [targets[0]]: e[targets[0]] });
|
|
35
|
+
});
|
|
36
|
+
return { evidence: ok, errors };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @returns {{ evaluated: boolean, byNode: Record<string, {states: string[], observations: number, allowed: number, denied: number}>, byEdge: Record<string, {states: string[], primary: string, observations: number, allowed: number, denied: number, uncertainObservations: number, identityTokens: string[], exploitEvidence: object[], note: string}>, counts: Record<string, number>, limitations: string[] }}
|
|
41
|
+
*/
|
|
42
|
+
export function deriveEdgeStates(graph, correlation, observations, { exploitEvidence = [] } = {}) {
|
|
43
|
+
const edges = arr(graph?.edges).filter((e) => isObj(e) && typeof e.id === 'string');
|
|
44
|
+
const flows = arr(graph?.flows).filter((f) => isObj(f) && typeof f.id === 'string');
|
|
45
|
+
const evaluated = !!correlation?.evaluated;
|
|
46
|
+
const considered = new Set(arr(correlation?.consideredObservationIds));
|
|
47
|
+
const byEdge = {}; const counts = Object.fromEntries(EDGE_STATES.map((s) => [s, 0]));
|
|
48
|
+
const limitations = [
|
|
49
|
+
'RUNTIME_NOT_OBSERVED means "no trusted observation in this window", never "unreachable" or "safe".',
|
|
50
|
+
'Only high- and medium-confidence mappings count; low-confidence and ambiguous observations stay unmatched and are counted as uncertain.',
|
|
51
|
+
'RUNTIME_EXPLOIT_CORRELATED requires explicit operator-supplied evidence naming the edge, flow or node; observation alone never sets it.',
|
|
52
|
+
];
|
|
53
|
+
const flowsByEdge = new Map();
|
|
54
|
+
for (const f of flows) for (const eid of arr(f.edgeIds)) { const a = flowsByEdge.get(eid) ?? []; a.push(f); flowsByEdge.set(eid, a); }
|
|
55
|
+
const obsByEdge = new Map();
|
|
56
|
+
for (const o of arr(observations)) {
|
|
57
|
+
if (!isObj(o) || (evaluated && considered.size && !considered.has(o.id))) continue;
|
|
58
|
+
for (const eid of arr(o.matchedEdgeIds)) { const a = obsByEdge.get(eid) ?? []; a.push(o); obsByEdge.set(eid, a); }
|
|
59
|
+
}
|
|
60
|
+
for (const e of edges) {
|
|
61
|
+
if (!evaluated) { byEdge[e.id] = entry(['STATIC_ONLY'], { note: 'no telemetry was consulted' }); counts.STATIC_ONLY++; continue; }
|
|
62
|
+
const all = obsByEdge.get(e.id) ?? [];
|
|
63
|
+
const trusted = all.filter((o) => TRUSTED.has(o.matchConfidence));
|
|
64
|
+
const uncertain = all.length - trusted.length;
|
|
65
|
+
if (!trusted.length) { byEdge[e.id] = entry(['RUNTIME_NOT_OBSERVED'], { uncertain, note: uncertain ? `${uncertain} low-confidence observation(s) not counted` : 'no observation in this window' }); counts.RUNTIME_NOT_OBSERVED++; continue; }
|
|
66
|
+
const dec = (o) => o.attributes?.['network.securityDecision'];
|
|
67
|
+
const allowed = trusted.filter((o) => dec(o) === 'allowed').length; const denied = trusted.filter((o) => dec(o) === 'denied').length;
|
|
68
|
+
const states = [];
|
|
69
|
+
const onlyDenied = denied > 0 && allowed === 0 && trusted.every((o) => dec(o) === 'denied');
|
|
70
|
+
if (!onlyDenied) states.push('RUNTIME_OBSERVED');
|
|
71
|
+
if (denied > 0) states.push('RUNTIME_BLOCKED');
|
|
72
|
+
const prohibited = flowsByEdge.get(e.id)?.some((f) => f.policyVerdict === 'prohibited');
|
|
73
|
+
if (!onlyDenied && prohibited) states.push('RUNTIME_POLICY_VIOLATION');
|
|
74
|
+
const flowIds = new Set((flowsByEdge.get(e.id) ?? []).map((f) => f.id));
|
|
75
|
+
const nodeIds = new Set([e.source, e.target, e.from, e.to].filter(Boolean));
|
|
76
|
+
const ev = arr(exploitEvidence).filter((x) => x.edgeId === e.id || (x.flowId && flowIds.has(x.flowId)) || (x.nodeId && nodeIds.has(x.nodeId)));
|
|
77
|
+
if (!onlyDenied && ev.length) states.push('RUNTIME_EXPLOIT_CORRELATED');
|
|
78
|
+
const tokens = [...new Set(trusted.map((o) => o.attributes?.['identity.token']).filter(Boolean))].sort();
|
|
79
|
+
byEdge[e.id] = entry(states, { observations: trusted.length, allowed, denied, uncertain, tokens, ev, note: '' });
|
|
80
|
+
for (const s of states) counts[s]++;
|
|
81
|
+
}
|
|
82
|
+
// Observations the matcher placed on a NODE but on no edge or flow (a queue publish, a store write with no modelled path into it).
|
|
83
|
+
// They still carry the allow/deny fact, so the refused queue publish is visible instead of vanishing between edge and node.
|
|
84
|
+
const byNode = {};
|
|
85
|
+
if (evaluated) {
|
|
86
|
+
const perNode = new Map();
|
|
87
|
+
for (const o of arr(observations)) {
|
|
88
|
+
if (!isObj(o) || (considered.size && !considered.has(o.id)) || !TRUSTED.has(o.matchConfidence) || arr(o.matchedEdgeIds).length) continue;
|
|
89
|
+
for (const nid of arr(o.matchedNodeIds)) { const a = perNode.get(nid) ?? []; a.push(o); perNode.set(nid, a); }
|
|
90
|
+
}
|
|
91
|
+
for (const [nid, list] of perNode) {
|
|
92
|
+
const dec = (o) => o.attributes?.['network.securityDecision'];
|
|
93
|
+
const denied = list.filter((o) => dec(o) === 'denied').length; const allowed = list.filter((o) => dec(o) === 'allowed').length;
|
|
94
|
+
const onlyDenied = denied > 0 && denied === list.length;
|
|
95
|
+
byNode[nid] = { states: [...(onlyDenied ? [] : ['RUNTIME_OBSERVED']), ...(denied ? ['RUNTIME_BLOCKED'] : [])], observations: list.length, allowed, denied };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { evaluated, byEdge, byNode, counts, limitations };
|
|
99
|
+
|
|
100
|
+
function entry(states, { observations = 0, allowed = 0, denied = 0, uncertain = 0, tokens = [], ev = [], note = '' } = {}) {
|
|
101
|
+
const primary = SEVERITY_ORDER.find((s) => states.includes(s));
|
|
102
|
+
return { states, primary, observations, allowed, denied, uncertainObservations: uncertain, identityTokens: tokens, exploitEvidence: ev, note };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* "Show runtime flows absent from the static graph": the observations the matcher could NOT place on any static node, reduced to the
|
|
108
|
+
* destination identifiers the closed-world schema already allows. These are CANDIDATES. The matcher failing to place an observation
|
|
109
|
+
* can mean a path the static analysis missed, or a mapping the matcher is too weak to make; this lists them, it does not decide.
|
|
110
|
+
*/
|
|
111
|
+
export function runtimeFlowsAbsentFromStatic(observations, correlation) {
|
|
112
|
+
const unmatched = new Set(arr(correlation?.unmatchedObservationIds));
|
|
113
|
+
const rows = [];
|
|
114
|
+
for (const o of arr(observations)) {
|
|
115
|
+
if (!isObj(o) || !unmatched.has(o.id)) continue;
|
|
116
|
+
// an observation the matcher placed on a static NODE is not absent from the static graph; it only lacks a modelled flow
|
|
117
|
+
if (arr(o.matchedNodeIds).length || arr(o.matchedEdgeIds).length) continue;
|
|
118
|
+
const a = isObj(o.attributes) ? o.attributes : {};
|
|
119
|
+
const dest = ['destination.host', 'destination.service', 'destination.port', 'destination.scheme'].filter((k) => a[k] !== undefined).map((k) => `${k.split('.')[1]}=${a[k]}`).join(' ');
|
|
120
|
+
if (!dest) continue;
|
|
121
|
+
rows.push({ observationId: o.id, destination: dest, service: a['service.name'] ?? null, environment: o.environment ?? null, firstObservedAt: o.firstObservedAt ?? null, lastObservedAt: o.lastObservedAt ?? null, eventCountBand: o.eventCountBand ?? null });
|
|
122
|
+
}
|
|
123
|
+
rows.sort((x, y) => (x.destination < y.destination ? -1 : x.destination > y.destination ? 1 : 0));
|
|
124
|
+
return { candidates: rows, note: 'Unmatched observations are candidates, not proof that static analysis missed a path: the matcher may be too weak to place them.' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** "Show identities using risky paths": identity TOKENS (never identities) seen on edges in the risky states, with a band, never an exact count. */
|
|
128
|
+
export function identitiesUsingRiskyPaths(edgeStates) {
|
|
129
|
+
const risky = new Set(['RUNTIME_POLICY_VIOLATION', 'RUNTIME_EXPLOIT_CORRELATED']);
|
|
130
|
+
const byToken = new Map();
|
|
131
|
+
for (const [edgeId, e] of Object.entries(edgeStates?.byEdge ?? {})) {
|
|
132
|
+
if (!e.states.some((s) => risky.has(s))) continue;
|
|
133
|
+
for (const t of e.identityTokens) { const x = byToken.get(t) ?? { token: t, edges: [], states: new Set() }; x.edges.push(edgeId); e.states.filter((s) => risky.has(s)).forEach((s) => x.states.add(s)); byToken.set(t, x); }
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
identities: [...byToken.values()].map((x) => ({ token: x.token, edgeIds: x.edges.sort(), states: [...x.states].sort() })).sort((a, b) => (a.token < b.token ? -1 : 1)),
|
|
137
|
+
note: 'Tokens are keyed hashes under this install\'s key; they identify "the same identity" but never reveal who. Edges with no identity telemetry contribute nothing, which is not evidence that no identity used them.',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The runtime half of the Data Flow Explorer as ONE static, script-free HTML page (PRD 2 section 5). Pure: it renders what
|
|
2
|
+
// `runtime-edge-states.js` and the correlation already computed, writes nothing, and runs nothing in the browser. It exists as a page
|
|
3
|
+
// rather than a route on the local explore server because that server's contract (`server/CLAUDE.md`) deliberately serves only the
|
|
4
|
+
// signed static graph; widening it is a security decision of its own. A page needs no server, no token and no script.
|
|
5
|
+
//
|
|
6
|
+
// Five views, in the order the PRD names them: Static, Runtime, Static vs Runtime, Observed High-Risk Paths, Unexpected Runtime Paths.
|
|
7
|
+
// Clicking (expanding) an edge shows BOTH its static evidence and its runtime evidence. Every flow is also shown as a path
|
|
8
|
+
// ("source -> ... -> sink") with the state of each hop, which is the PRD's "credit-card field -> API -> payments service -> DB" view.
|
|
9
|
+
//
|
|
10
|
+
// Everything interpolated into the page is escaped. Attribute values (hosts, services) came from telemetry the operator supplied and
|
|
11
|
+
// are treated as hostile text. The page states, at the top, what absence of observation does and does not mean.
|
|
12
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
13
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
14
|
+
const SENSITIVE = new Set(['PII', 'PHI', 'PCI', 'FIN', 'CREDENTIALS']);
|
|
15
|
+
const CSS = 'body{font:14px/1.45 system-ui,sans-serif;margin:24px auto;max-width:1100px;padding:0 16px;color:#1b1f24;background:#fff}'
|
|
16
|
+
+ '@media(prefers-color-scheme:dark){body{background:#14171a;color:#e6e8ea}}'
|
|
17
|
+
+ 'table{border-collapse:collapse;width:100%;margin:8px 0}td,th{border:1px solid #8886;padding:4px 8px;text-align:left;vertical-align:top}'
|
|
18
|
+
+ 'summary{cursor:pointer}.s{display:inline-block;padding:0 6px;border-radius:4px;border:1px solid #8888;font-size:12px;margin-right:4px}'
|
|
19
|
+
+ '.RUNTIME_BLOCKED,.RUNTIME_POLICY_VIOLATION,.RUNTIME_EXPLOIT_CORRELATED{border-color:#c0392b;color:#c0392b}.RUNTIME_OBSERVED{border-color:#1e8449;color:#1e8449}'
|
|
20
|
+
+ '.note{border-left:4px solid #8888;padding:4px 12px;margin:12px 0}nav a{margin-right:12px}code{font-size:12px}';
|
|
21
|
+
|
|
22
|
+
export function renderRuntimeExplorer({ graph, edgeStates, correlation, observations = [], absent = { candidates: [] }, title = 'Runtime twin' }) {
|
|
23
|
+
const nodes = new Map(arr(graph?.nodes).map((n) => [n.id, n]));
|
|
24
|
+
const edges = arr(graph?.edges); const flows = arr(graph?.flows);
|
|
25
|
+
const dataEls = new Map(arr(graph?.dataElements).map((d) => [d.id, d]));
|
|
26
|
+
const label = (id) => esc(nodes.get(id)?.label ?? id);
|
|
27
|
+
const stateOf = (eid) => edgeStates?.byEdge?.[eid];
|
|
28
|
+
const obsByEdge = new Map();
|
|
29
|
+
for (const o of arr(observations)) for (const eid of arr(o.matchedEdgeIds)) { const a = obsByEdge.get(eid) ?? []; a.push(o); obsByEdge.set(eid, a); }
|
|
30
|
+
const chips = (states) => arr(states).map((s) => `<span class="s ${esc(s)}">${esc(s)}</span>`).join('');
|
|
31
|
+
const flowClasses = (f) => arr(f.dataElementIds).flatMap((id) => arr(dataEls.get(id)?.dataClasses));
|
|
32
|
+
const observedEdge = (eid) => stateOf(eid)?.states?.includes('RUNTIME_OBSERVED');
|
|
33
|
+
|
|
34
|
+
const staticRows = edges.map((e) => `<tr><td>${label(e.from)} → ${label(e.to)}</td><td><code>${esc(e.id)}</code></td><td>${esc(e.protocol?.name ?? e.protocol?.kind ?? '')}</td><td>${esc(arr(e.boundaryCrossings).join(', '))}</td></tr>`).join('');
|
|
35
|
+
const runtimeRows = edges.map((e) => { const s = stateOf(e.id); return `<tr><td>${label(e.from)} → ${label(e.to)}</td><td>${chips(s?.states)}</td><td>${s?.observations ?? 0}</td><td>${esc(s?.identityTokens?.length ?? 0)}</td></tr>`; }).join('');
|
|
36
|
+
const bothRows = edges.map((e) => { const s = stateOf(e.id); return `<tr><td>${label(e.from)} → ${label(e.to)}</td><td>${esc(arr(e.boundaryCrossings).length ? 'crosses a boundary' : 'internal')}, ${esc(e.provenance ?? '')}</td><td>${chips(s?.states)}${s?.uncertainObservations ? ` <small>(${s.uncertainObservations} uncertain, not counted)</small>` : ''}</td></tr>`; }).join('');
|
|
37
|
+
|
|
38
|
+
const detail = (e) => {
|
|
39
|
+
const s = stateOf(e.id); const obs = obsByEdge.get(e.id) ?? [];
|
|
40
|
+
const o = obs.map((x) => `<li>${esc(x.adapter)} / ${esc(x.environment ?? 'no environment')}: ${esc(x.firstObservedAt)} to ${esc(x.lastObservedAt)}, count band ${esc(x.eventCountBand)}, match ${esc(x.matchMethod)} (${esc(x.matchConfidence)})${x.attributes?.['network.securityDecision'] ? `, decision ${esc(x.attributes['network.securityDecision'])}` : ''}${x.attributes?.['identity.token'] ? `, identity <code>${esc(x.attributes['identity.token'])}</code>` : ''}</li>`).join('');
|
|
41
|
+
return `<details><summary>${label(e.from)} → ${label(e.to)} ${chips(s?.states)}</summary>`
|
|
42
|
+
+ `<h4>Static evidence</h4><p><code>${esc(e.id)}</code>, provenance ${esc(e.provenance)}, coverage ${esc(e.coverageStatus)}; boundaries: ${esc(arr(e.boundaryCrossings).join(', ') || 'none')}; evidence: ${esc(arr(e.evidenceRefs).join(', ') || 'none')}</p>`
|
|
43
|
+
+ `<h4>Runtime evidence</h4>${obs.length ? `<ul>${o}</ul>` : `<p>${esc(s?.note || 'none')}</p>`}</details>`;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const pathRow = (f) => {
|
|
47
|
+
const hops = arr(f.edgeIds).map((eid) => { const e = edges.find((x) => x.id === eid); return e ? `${label(e.from)} → ${label(e.to)} ${chips(stateOf(eid)?.states)}` : esc(eid); });
|
|
48
|
+
return `<tr><td>${esc(arr(f.dataElementIds).map((id) => dataEls.get(id)?.label ?? id).join(', '))}<br><small>${esc(flowClasses(f).join(', '))}</small></td><td>${hops.join('<br>') || '(no modelled hops)'}</td><td>${esc(f.policyVerdict)}</td></tr>`;
|
|
49
|
+
};
|
|
50
|
+
const highRisk = flows.filter((f) => arr(f.edgeIds).some(observedEdge) && (flowClasses(f).some((c) => SENSITIVE.has(c)) || f.policyVerdict === 'prohibited' || arr(f.edgeIds).some((eid) => arr(edges.find((e) => e.id === eid)?.boundaryCrossings).length)));
|
|
51
|
+
const blockedNodes = Object.entries(edgeStates?.byNode ?? {});
|
|
52
|
+
const unexpected = [
|
|
53
|
+
...arr(absent.candidates).map((c) => `<tr><td>${esc(c.destination)}</td><td>observed, not placed on any static node</td><td>${esc(c.firstObservedAt)} to ${esc(c.lastObservedAt)}</td></tr>`),
|
|
54
|
+
...blockedNodes.filter(([, n]) => n.states.includes('RUNTIME_BLOCKED')).map(([id, n]) => `<tr><td>${label(id)}</td><td>attempted and refused (${n.denied} denied)</td><td></td></tr>`),
|
|
55
|
+
];
|
|
56
|
+
const section = (id, h, body) => `<section id="${id}"><h2>${h}</h2>${body}</section>`;
|
|
57
|
+
const table = (head, rows, empty) => (rows.length ? `<table><tr>${head.map((h) => `<th>${h}</th>`).join('')}</tr>${rows.join('')}</table>` : `<p>${esc(empty)}</p>`);
|
|
58
|
+
const body = [
|
|
59
|
+
`<h1>${esc(title)}</h1>`,
|
|
60
|
+
`<div class="note">${edgeStates?.evaluated ? 'Telemetry was consulted.' : '<strong>No telemetry was consulted:</strong> every edge is STATIC_ONLY. That is not evidence that nothing happens at runtime.'} <strong>Not observed does not mean unreachable or safe.</strong> Only high- and medium-confidence mappings are counted. Telemetry values shown are metadata only; identities appear only as keyed-hash tokens.</div>`,
|
|
61
|
+
'<nav><a href="#static">Static</a><a href="#runtime">Runtime</a><a href="#both">Static vs Runtime</a><a href="#risk">Observed high-risk paths</a><a href="#unexpected">Unexpected runtime paths</a><a href="#paths">Flow paths</a><a href="#edges">Edge detail</a></nav>',
|
|
62
|
+
section('static', 'Static', table(['Edge', 'Id', 'Protocol', 'Boundaries'], staticRows ? [staticRows] : [], 'No static edges.')),
|
|
63
|
+
section('runtime', 'Runtime', table(['Edge', 'State', 'Trusted observations', 'Identity tokens'], runtimeRows ? [runtimeRows] : [], 'No edges.')),
|
|
64
|
+
section('both', 'Static vs Runtime', table(['Edge', 'Static', 'Runtime'], bothRows ? [bothRows] : [], 'No edges.')),
|
|
65
|
+
section('risk', 'Observed high-risk paths', table(['Data', 'Path and state per hop', 'Policy'], highRisk.map(pathRow), 'No observed flow is sensitive, policy-prohibited or boundary-crossing. This is a statement about the telemetry window, not about the system.')),
|
|
66
|
+
section('unexpected', 'Unexpected runtime paths', table(['Destination or node', 'What was seen', 'When'], unexpected, 'Nothing unplaced or refused. Unplaced observations are candidates, not proof static analysis missed a path.')),
|
|
67
|
+
section('paths', 'Flow paths', table(['Data', 'Path and state per hop', 'Policy'], flows.map(pathRow), 'No flows.')),
|
|
68
|
+
section('edges', 'Edge detail (expand an edge for its static and runtime evidence)', edges.map(detail).join('') || '<p>No edges.</p>'),
|
|
69
|
+
].join('\n');
|
|
70
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>${esc(title)}</title><style>${CSS}</style></head><body>${body}</body></html>\n`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** "Show vulnerable functions actually exercised in production": scan findings whose own path appears in the runtime trace. Expects findings already
|
|
74
|
+
* annotated by `posture/runtime-correlation.js`; it adds no matching of its own. */
|
|
75
|
+
export function vulnerableFunctionsInProduction(findings) {
|
|
76
|
+
const list = arr(findings).filter((f) => f && typeof f === 'object');
|
|
77
|
+
const withEvidence = list.filter((f) => f.runtimeEvidence);
|
|
78
|
+
if (!withEvidence.some((f) => f.runtimeEvidence.state !== 'unknown')) return { evaluated: false, reason: 'no runtime trace could be evaluated (none present, or it says nothing about any finding): put one at .agentic-security/runtime-trace.jsonl (or set AGENTIC_SECURITY_RUNTIME_TRACE_PATH). This is not "no vulnerable function ran".' };
|
|
79
|
+
const observed = withEvidence.filter((f) => f.runtimeEvidence.state === 'observed').map((f) => ({ id: f.id, severity: f.severity, vuln: f.vuln, file: f.file, line: f.line, cwe: f.cwe, basis: f.runtimeEvidence.basis, sampling: f.runtimeEvidence.sampling?.state ?? null }));
|
|
80
|
+
const counts = { observed: observed.length, notObservedInWindow: withEvidence.filter((f) => f.runtimeEvidence.state === 'not_observed_in_window').length, unknown: withEvidence.filter((f) => f.runtimeEvidence.state === 'unknown').length };
|
|
81
|
+
return { evaluated: true, findings: observed, counts, note: 'A finding listed here has a function, line or route on its path in the trace. A finding not listed may still run: counts show how many were not observed or unknown. Absence of observation is a statement about the window and the instrumentation, not about reachability.' };
|
|
82
|
+
}
|