@clear-capabilities/agentic-security-scanner 0.168.0 → 0.170.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 +74 -0
- package/bin/agentic-security.js +567 -0
- package/dist/2199.index.js +1 -1
- package/dist/2675.index.js +174 -0
- package/dist/2923.index.js +369 -1
- package/dist/4142.index.js +6 -6
- package/dist/4700.index.js +151 -0
- package/dist/5014.index.js +2 -2
- package/dist/5086.index.js +645 -0
- package/dist/5144.index.js +2 -2
- package/dist/5206.index.js +32 -0
- package/dist/642.index.js +197 -0
- package/dist/6447.index.js +131 -0
- package/dist/6645.index.js +87 -0
- package/dist/6730.index.js +2 -2
- package/dist/6932.index.js +2 -2
- package/dist/7055.index.js +369 -0
- package/dist/7232.index.js +24 -0
- package/dist/7552.index.js +2 -2
- package/dist/7952.index.js +301 -0
- package/dist/8714.index.js +2 -2
- package/dist/9102.index.js +110 -0
- package/dist/9800.index.js +1 -1
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/artifact/CLAUDE.md +100 -0
- package/src/artifact/filesystem.js +100 -0
- package/src/artifact/image-archive.js +159 -0
- package/src/artifact/inventory.js +69 -0
- package/src/artifact/layer-secrets.js +92 -0
- package/src/artifact/mismatch.js +72 -0
- package/src/artifact/provenance-graph.js +115 -0
- package/src/artifact/safe-tar.js +175 -0
- package/src/artifact/signature.js +156 -0
- package/src/artifact/vex.js +94 -0
- package/src/artifact/vuln-correlate.js +17 -0
- package/src/lineage/CLAUDE.md +19 -0
- package/src/lineage/observation-adapters.js +368 -0
- package/src/lineage/runtime-assurance.js +135 -0
- package/src/lineage/runtime-observation.js +37 -4
- package/src/posture/artifact-registry.js +8 -0
package/dist/2199.index.js
CHANGED
|
@@ -313,7 +313,7 @@ function saveProof(scanRoot, record) {
|
|
|
313
313
|
const enc = (0,_encryption_provider_js__WEBPACK_IMPORTED_MODULE_3__/* .maybeEncryptForWrite */ .v2)(scanRoot, PROOFS_DIR, text);
|
|
314
314
|
if (!enc.ok) return { ok: false, reason: enc.reason };
|
|
315
315
|
const file = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.statePath)(scanRoot, PROOFS_DIR, findingKey(record.findingId), `${safe.recordDigest}.json`);
|
|
316
|
-
if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__
|
|
316
|
+
if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.safeWriteState)(file, enc.content)) return { ok: false, reason: 'state write refused' };
|
|
317
317
|
return { ok: true, file, digest: safe.recordDigest, encrypted: enc.encrypted };
|
|
318
318
|
}
|
|
319
319
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
export const id = 2675;
|
|
2
|
+
export const ids = [2675];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 82675:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
|
+
/* harmony export */ verifyImageSignature: () => (/* binding */ verifyImageSignature),
|
|
10
|
+
/* harmony export */ verifyProvenanceAttestation: () => (/* binding */ verifyProvenanceAttestation)
|
|
11
|
+
/* harmony export */ });
|
|
12
|
+
/* unused harmony export SIGNATURE_STATES */
|
|
13
|
+
/* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(77598);
|
|
14
|
+
// PRD 5 P0 §5 — Signature Verification. States are exactly the PRD's own
|
|
15
|
+
// four, deliberately different from `sca/sigstore-verify.js`'s own vocabulary
|
|
16
|
+
// (`verified/unverified/tampered/unknown`) so the two are never confused by
|
|
17
|
+
// shared words — see docs/implementation-status/source-to-artifact-provenance.md's
|
|
18
|
+
// naming-discipline section:
|
|
19
|
+
//
|
|
20
|
+
// verified — a real cryptographic check ran and the signature matches.
|
|
21
|
+
// invalid — a real cryptographic check ran and the signature does NOT match.
|
|
22
|
+
// missing — no signature/attestation material was supplied at all.
|
|
23
|
+
// unavailable — material was supplied but could not be cryptographically
|
|
24
|
+
// checked (no public key given, or the check itself could
|
|
25
|
+
// not run) — the PRD's "never treat missing tooling as
|
|
26
|
+
// verified" clause means this state, not `verified`, is what
|
|
27
|
+
// an incomplete check must report.
|
|
28
|
+
//
|
|
29
|
+
// Two real, independent mechanisms are implemented:
|
|
30
|
+
// 1. A plain detached signature over the image digest string, verified
|
|
31
|
+
// with an operator-supplied public key. This is this tool's OWN local
|
|
32
|
+
// convention, not cosign's "simple signing" JSON format — disclosed in
|
|
33
|
+
// the implementation-status doc, not silently passed off as cosign-
|
|
34
|
+
// compatible.
|
|
35
|
+
// 2. Real DSSE (Dead Simple Signing Envelope) verification for in-toto/SLSA
|
|
36
|
+
// provenance attestations — the actual encoding cosign/Sigstore use for
|
|
37
|
+
// attestations, so a real cosign-keyed attestation bundle DOES verify
|
|
38
|
+
// here given the matching public key. `dssePae` is the exact PAE
|
|
39
|
+
// (Pre-Authentication Encoding) from the DSSE spec.
|
|
40
|
+
//
|
|
41
|
+
// Keyless/Rekor-transparency-log verification is NOT implemented — disclosed
|
|
42
|
+
// as a gap, never faked. `sca/sigstore-verify.js`'s Rekor-query pattern is
|
|
43
|
+
// the template a future increment could extend this module with.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
const SIGNATURE_STATES = Object.freeze(['verified', 'invalid', 'missing', 'unavailable']);
|
|
47
|
+
|
|
48
|
+
function verifyRaw(payload, signature, publicKeyPem) {
|
|
49
|
+
let publicKey;
|
|
50
|
+
try {
|
|
51
|
+
publicKey = node_crypto__WEBPACK_IMPORTED_MODULE_0__.createPublicKey(publicKeyPem);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return { ok: false, error: `public key is not parseable: ${err.message}` };
|
|
54
|
+
}
|
|
55
|
+
const keyType = publicKey.asymmetricKeyType;
|
|
56
|
+
const algorithm = (keyType === 'ed25519' || keyType === 'ed448') ? null : 'sha256';
|
|
57
|
+
try {
|
|
58
|
+
const ok = node_crypto__WEBPACK_IMPORTED_MODULE_0__.verify(algorithm, payload, publicKey, signature);
|
|
59
|
+
return { ok, keyType };
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return { ok: false, error: `signature verification failed to run: ${err.message}` };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Verifies a detached signature over an image's digest string.
|
|
67
|
+
* `signature`/`publicKeyPem` are Buffer/PEM-string; either missing means
|
|
68
|
+
* `missing` (nothing was supplied to check).
|
|
69
|
+
*/
|
|
70
|
+
function verifyImageSignature({ digest, signature, publicKeyPem }) {
|
|
71
|
+
if (typeof digest !== 'string' || !digest.startsWith('sha256:')) {
|
|
72
|
+
return { state: 'unavailable', reason: 'no image digest available to verify a signature against' };
|
|
73
|
+
}
|
|
74
|
+
if (!signature || !publicKeyPem) {
|
|
75
|
+
return { state: 'missing', reason: 'no detached signature and public key were supplied for this image' };
|
|
76
|
+
}
|
|
77
|
+
const { ok, error, keyType } = verifyRaw(Buffer.from(digest, 'utf8'), signature, publicKeyPem);
|
|
78
|
+
if (error) return { state: 'invalid', reason: error };
|
|
79
|
+
return ok
|
|
80
|
+
? { state: 'verified', keyType }
|
|
81
|
+
: { state: 'invalid', reason: 'signature does not match the image digest under the supplied public key' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// DSSE Pre-Authentication Encoding: "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body.
|
|
85
|
+
function dssePae(payloadType, payloadBytes) {
|
|
86
|
+
const typeBytes = Buffer.from(payloadType, 'utf8');
|
|
87
|
+
return Buffer.concat([
|
|
88
|
+
Buffer.from('DSSEv1 ', 'ascii'),
|
|
89
|
+
Buffer.from(String(typeBytes.length), 'ascii'),
|
|
90
|
+
Buffer.from(' ', 'ascii'),
|
|
91
|
+
typeBytes,
|
|
92
|
+
Buffer.from(' ', 'ascii'),
|
|
93
|
+
Buffer.from(String(payloadBytes.length), 'ascii'),
|
|
94
|
+
Buffer.from(' ', 'ascii'),
|
|
95
|
+
payloadBytes,
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const SLSA_PREDICATE_PREFIX = 'https://slsa.dev/provenance/';
|
|
100
|
+
const INTOTO_STATEMENT_TYPES = new Set(['https://in-toto.io/Statement/v0.1', 'https://in-toto.io/Statement/v1']);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Verifies an in-toto/SLSA provenance attestation for an image.
|
|
104
|
+
* `envelope` is a DSSE envelope: `{ payloadType, payload (base64 in-toto
|
|
105
|
+
* Statement JSON), signatures: [{ sig (base64) }] }` — the real shape
|
|
106
|
+
* cosign/Sigstore attestations use. `publicKeyPem` verifies one of
|
|
107
|
+
* `signatures` against `dssePae(payloadType, payload)`.
|
|
108
|
+
*
|
|
109
|
+
* The subject-digest match is checked FIRST and independently of signature
|
|
110
|
+
* verification, so a mismatched subject is always `invalid` (never
|
|
111
|
+
* "unavailable", which would understate a real, checkable problem).
|
|
112
|
+
*/
|
|
113
|
+
function verifyProvenanceAttestation({ digest, envelope, publicKeyPem }) {
|
|
114
|
+
if (!envelope) return { state: 'missing', reason: 'no in-toto/SLSA provenance attestation was supplied for this image' };
|
|
115
|
+
|
|
116
|
+
let env;
|
|
117
|
+
try {
|
|
118
|
+
env = typeof envelope === 'string' ? JSON.parse(envelope) : envelope;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return { state: 'invalid', reason: `attestation envelope is not valid JSON: ${err.message}` };
|
|
121
|
+
}
|
|
122
|
+
if (typeof env.payload !== 'string' || typeof env.payloadType !== 'string' || !Array.isArray(env.signatures)) {
|
|
123
|
+
return { state: 'invalid', reason: 'not a recognized DSSE envelope (missing payload, payloadType, or signatures)' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let payloadBytes;
|
|
127
|
+
let stmt;
|
|
128
|
+
try {
|
|
129
|
+
payloadBytes = Buffer.from(env.payload, 'base64');
|
|
130
|
+
stmt = JSON.parse(payloadBytes.toString('utf8'));
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return { state: 'invalid', reason: `attestation payload is not a valid base64-encoded JSON statement: ${err.message}` };
|
|
133
|
+
}
|
|
134
|
+
if (!INTOTO_STATEMENT_TYPES.has(stmt._type)) {
|
|
135
|
+
return { state: 'invalid', reason: 'statement is missing a recognized in-toto "_type"' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const digestHex = typeof digest === 'string' && digest.startsWith('sha256:') ? digest.slice('sha256:'.length) : digest;
|
|
139
|
+
const subjects = Array.isArray(stmt.subject) ? stmt.subject : [];
|
|
140
|
+
const subjectMatches = subjects.some((s) => s && s.digest && s.digest.sha256 === digestHex);
|
|
141
|
+
if (!subjectMatches) {
|
|
142
|
+
return { state: 'invalid', reason: 'attestation subject digest does not match this image', predicateType: stmt.predicateType || null };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const predicateType = stmt.predicateType || null;
|
|
146
|
+
const isSlsa = typeof predicateType === 'string' && predicateType.startsWith(SLSA_PREDICATE_PREFIX);
|
|
147
|
+
|
|
148
|
+
if (!publicKeyPem || env.signatures.length === 0) {
|
|
149
|
+
return {
|
|
150
|
+
state: 'unavailable',
|
|
151
|
+
reason: 'attestation subject matches this image, but no public key was supplied to verify its signature',
|
|
152
|
+
predicateType, isSlsa,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const pae = dssePae(env.payloadType, payloadBytes);
|
|
157
|
+
for (const sigEntry of env.signatures) {
|
|
158
|
+
if (!sigEntry || typeof sigEntry.sig !== 'string') continue;
|
|
159
|
+
let sigBytes;
|
|
160
|
+
try {
|
|
161
|
+
sigBytes = Buffer.from(sigEntry.sig, 'base64');
|
|
162
|
+
} catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const { ok } = verifyRaw(pae, sigBytes, publicKeyPem);
|
|
166
|
+
if (ok) return { state: 'verified', predicateType, isSlsa };
|
|
167
|
+
}
|
|
168
|
+
return { state: 'invalid', reason: 'no signature in the attestation envelope verifies under the supplied public key', predicateType, isSlsa };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
/***/ })
|
|
173
|
+
|
|
174
|
+
};
|
package/dist/2923.index.js
CHANGED
|
@@ -8,7 +8,7 @@ export const modules = {
|
|
|
8
8
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
9
|
/* harmony export */ adapterFor: () => (/* binding */ adapterFor)
|
|
10
10
|
/* harmony export */ });
|
|
11
|
-
/* unused harmony exports NATIVE_JSONL_RECORD_KEYS, parseNativeJsonlObservations */
|
|
11
|
+
/* unused harmony exports NATIVE_JSONL_RECORD_KEYS, parseNativeJsonlObservations, parseOtelJsonObservations, parseEnvoyAccessLogObservations, parseK8sAuditLogObservations, parseHttpAccessLogObservations */
|
|
12
12
|
// observation-adapters.js — the RuntimeObservation ADAPTER interface, plus
|
|
13
13
|
// the one shipped implementation (`native-jsonl`) (M5 deliverable #7,
|
|
14
14
|
// "Runtime-Corroborated Digital Twin" — the runtime-observed half only,
|
|
@@ -273,6 +273,370 @@ function parseNativeJsonlObservations(text, context) {
|
|
|
273
273
|
return { drafts, errors };
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
// ── The four PRD "Runtime-Corroborated Security Twin" adapters ─────────
|
|
277
|
+
//
|
|
278
|
+
// Each of the four parsers below follows the identical contract the
|
|
279
|
+
// native-jsonl adapter above already established: pure (zero imports, no
|
|
280
|
+
// fs, never throws), `(text, context) -> {drafts, errors}`, one
|
|
281
|
+
// `{line, message}` error per malformed line (never a value echo), every
|
|
282
|
+
// OTHER line still parses. Each produces drafts carrying ONLY attribute
|
|
283
|
+
// keys already on `RUNTIME_ATTRIBUTE_KEYS` (`runtime-observation.js`) —
|
|
284
|
+
// none of these parsers imports that file (this module stays zero-import
|
|
285
|
+
// by contract), so an attribute key typo here is caught one layer up, at
|
|
286
|
+
// import time, exactly like the native-jsonl adapter's own documented
|
|
287
|
+
// two-layer split. Each adapter deliberately DROPS every field of its
|
|
288
|
+
// source format with no safe, approved destination — a raw URL path/query
|
|
289
|
+
// string, a user agent, a client IP, a trace/span id, a username/identity
|
|
290
|
+
// — rather than inventing a new attribute key to carry it (PRD "Privacy
|
|
291
|
+
// Protection" P0-7: schema/field classification only, never a captured
|
|
292
|
+
// value). What each adapter keeps and drops is documented at its own
|
|
293
|
+
// definition.
|
|
294
|
+
|
|
295
|
+
const _IDENTIFIER_ATTR_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
296
|
+
|
|
297
|
+
// Only ever WRITES a value that already looks like an approved identifier
|
|
298
|
+
// — never force-sanitizes an unsafe value into a misleadingly different
|
|
299
|
+
// one. Returns `null` (meaning "drop this attribute") for anything that
|
|
300
|
+
// doesn't already look safe, rather than guessing.
|
|
301
|
+
function _safeIdentifierOrNull(v) {
|
|
302
|
+
if (typeof v !== 'string' || v.length === 0) return null;
|
|
303
|
+
return _IDENTIFIER_ATTR_RE.test(v) ? v : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// `"host:port"` -> `{host, port}` (port `null` when absent/unparseable).
|
|
307
|
+
// Never a full URL parse — this shape (Envoy's `upstream_host`, a bare
|
|
308
|
+
// `host:port` pair) has no scheme/path to confuse it with.
|
|
309
|
+
function _splitHostPort(v) {
|
|
310
|
+
if (typeof v !== 'string' || v.length === 0) return { host: null, port: null };
|
|
311
|
+
const i = v.lastIndexOf(':');
|
|
312
|
+
if (i === -1) return { host: _safeIdentifierOrNull(v), port: null };
|
|
313
|
+
const host = v.slice(0, i);
|
|
314
|
+
const portStr = v.slice(i + 1);
|
|
315
|
+
const port = /^[0-9]{1,5}$/.test(portStr) ? Number(portStr) : null;
|
|
316
|
+
return { host: _safeIdentifierOrNull(host), port };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function _draftBase(context, environmentOverride) {
|
|
320
|
+
const { version, adapter, source, environment, windowStart, windowEnd, importedAt, retention } = context;
|
|
321
|
+
return {
|
|
322
|
+
version, adapter, source,
|
|
323
|
+
environment: environmentOverride !== undefined ? environmentOverride : environment,
|
|
324
|
+
windowStart, windowEnd, retention, importedAt,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* OpenTelemetry JSON trace export (the standard OTLP/JSON `resourceSpans`
|
|
330
|
+
* document — a SINGLE JSON object, never JSONL; this is the format
|
|
331
|
+
* `otlptracehttp` with `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/json`
|
|
332
|
+
* writes). One draft per span. Reads only well-known OpenTelemetry
|
|
333
|
+
* semantic-convention attribute keys — `server.address`/`net.peer.name`,
|
|
334
|
+
* `server.port`/`net.peer.port`, `url.scheme`/`http.scheme`,
|
|
335
|
+
* `network.protocol.name`, `db.system`/`db.name`,
|
|
336
|
+
* `messaging.destination.name` — mapped onto the approved
|
|
337
|
+
* `destination.*`/`network.protocol`/`schema.name` keys; `resource`-level
|
|
338
|
+
* `service.name` is carried as `service.name`. Every other span
|
|
339
|
+
* attribute (`http.url`'s full query string, `http.request.header.*`,
|
|
340
|
+
* `gen_ai.*` prompt/completion content, trace/span ids themselves) is
|
|
341
|
+
* dropped — none has a safe, approved destination. `eventCountBand` is
|
|
342
|
+
* always `'1'`: a single exported span is a single observed event, never
|
|
343
|
+
* an aggregate count OTel itself does not report.
|
|
344
|
+
*/
|
|
345
|
+
function parseOtelJsonObservations(text, context) {
|
|
346
|
+
if (typeof text !== 'string' || !_isPlainObject(context)) {
|
|
347
|
+
return { drafts: [], errors: [{ line: 0, message: 'parseOtelJsonObservations requires a string text and a plain-object context' }] };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let doc;
|
|
351
|
+
try {
|
|
352
|
+
doc = JSON.parse(text);
|
|
353
|
+
} catch {
|
|
354
|
+
return { drafts: [], errors: [{ line: 0, message: 'invalid JSON: expected one OTLP/JSON resourceSpans document' }] };
|
|
355
|
+
}
|
|
356
|
+
if (!_isPlainObject(doc) || !Array.isArray(doc.resourceSpans)) {
|
|
357
|
+
return { drafts: [], errors: [{ line: 0, message: 'expected a JSON object with a top-level "resourceSpans" array (the OTLP/JSON export shape)' }] };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const _attrMap = (attrs) => {
|
|
361
|
+
const m = {};
|
|
362
|
+
if (!Array.isArray(attrs)) return m;
|
|
363
|
+
for (const a of attrs) {
|
|
364
|
+
if (!_isPlainObject(a) || typeof a.key !== 'string' || !_isPlainObject(a.value)) continue;
|
|
365
|
+
const v = a.value;
|
|
366
|
+
if (typeof v.stringValue === 'string') m[a.key] = v.stringValue;
|
|
367
|
+
else if (typeof v.intValue === 'number' || typeof v.intValue === 'string') m[a.key] = String(v.intValue);
|
|
368
|
+
else if (typeof v.boolValue === 'boolean') m[a.key] = v.boolValue;
|
|
369
|
+
}
|
|
370
|
+
return m;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// Nanoseconds-since-epoch (OTLP's own wire unit) as a string or number
|
|
374
|
+
// -> ISO-8601. Returns null (never a fabricated "now") when absent or
|
|
375
|
+
// unparseable, letting the caller fall back to the import window.
|
|
376
|
+
const _isoFromUnixNano = (v) => {
|
|
377
|
+
if (v === undefined || v === null) return null;
|
|
378
|
+
const n = typeof v === 'string' ? Number(v) : v;
|
|
379
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
380
|
+
const ms = Math.round(n / 1e6);
|
|
381
|
+
const d = new Date(ms);
|
|
382
|
+
return Number.isFinite(d.getTime()) ? d.toISOString() : null;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const drafts = [];
|
|
386
|
+
for (const rs of doc.resourceSpans) {
|
|
387
|
+
if (!_isPlainObject(rs)) continue;
|
|
388
|
+
const resourceAttrs = _attrMap(rs.resource?.attributes);
|
|
389
|
+
const serviceName = _safeIdentifierOrNull(resourceAttrs['service.name']);
|
|
390
|
+
|
|
391
|
+
for (const ss of Array.isArray(rs.scopeSpans) ? rs.scopeSpans : []) {
|
|
392
|
+
if (!_isPlainObject(ss)) continue;
|
|
393
|
+
for (const span of Array.isArray(ss.spans) ? ss.spans : []) {
|
|
394
|
+
if (!_isPlainObject(span)) continue;
|
|
395
|
+
const a = _attrMap(span.attributes);
|
|
396
|
+
|
|
397
|
+
const attributes = {};
|
|
398
|
+
if (serviceName) attributes['service.name'] = serviceName;
|
|
399
|
+
const host = _safeIdentifierOrNull(a['server.address'] ?? a['net.peer.name']);
|
|
400
|
+
if (host) attributes['destination.host'] = host;
|
|
401
|
+
const portRaw = a['server.port'] ?? a['net.peer.port'];
|
|
402
|
+
const port = /^[0-9]{1,5}$/.test(String(portRaw ?? '')) ? Number(portRaw) : null;
|
|
403
|
+
if (port !== null) attributes['destination.port'] = port;
|
|
404
|
+
const scheme = _safeIdentifierOrNull(a['url.scheme'] ?? a['http.scheme']);
|
|
405
|
+
if (scheme) attributes['destination.scheme'] = scheme;
|
|
406
|
+
const protocol = _safeIdentifierOrNull(a['network.protocol.name']);
|
|
407
|
+
if (protocol) attributes['network.protocol'] = protocol;
|
|
408
|
+
const storeService = _safeIdentifierOrNull(a['db.system'] ?? a['db.name'] ?? a['messaging.destination.name']);
|
|
409
|
+
if (storeService) attributes['destination.service'] = storeService;
|
|
410
|
+
|
|
411
|
+
if (Object.keys(attributes).length === 0) continue; // nothing safe to report for this span
|
|
412
|
+
|
|
413
|
+
const at = _isoFromUnixNano(span.startTimeUnixNano) ?? context.windowStart;
|
|
414
|
+
drafts.push({
|
|
415
|
+
..._draftBase(context),
|
|
416
|
+
attributes,
|
|
417
|
+
eventCountBand: '1',
|
|
418
|
+
firstObservedAt: at,
|
|
419
|
+
lastObservedAt: _isoFromUnixNano(span.endTimeUnixNano) ?? at,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return { drafts, errors: [] };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Envoy's default JSON access log (`json_format` logging, one JSON
|
|
430
|
+
* object per line). Reads `upstream_host` (split into
|
|
431
|
+
* `destination.host`/`destination.port`), `upstream_cluster` (as
|
|
432
|
+
* `destination.service`), `protocol` (normalized — Envoy writes
|
|
433
|
+
* `"HTTP/1.1"`, which contains `/` and is not an approved identifier
|
|
434
|
+
* shape; lowercased and `/` stripped, e.g. `"http1.1"`), and
|
|
435
|
+
* `response_flags` (`'-'`/empty means no flag, i.e. allowed; a non-empty
|
|
436
|
+
* flags string, e.g. `"UAEX"` for unauthorized, is treated as `'denied'`
|
|
437
|
+
* via `network.securityDecision`). `start_time`/`end_time` (or
|
|
438
|
+
* `start_time` alone, with `duration` in milliseconds added) feed
|
|
439
|
+
* `firstObservedAt`/`lastObservedAt`. Everything else — client address,
|
|
440
|
+
* request path/query, user agent, request id, response code's own exact
|
|
441
|
+
* value — is dropped.
|
|
442
|
+
*/
|
|
443
|
+
function parseEnvoyAccessLogObservations(text, context) {
|
|
444
|
+
if (typeof text !== 'string' || !_isPlainObject(context)) {
|
|
445
|
+
return { drafts: [], errors: [{ line: 0, message: 'parseEnvoyAccessLogObservations requires a string text and a plain-object context' }] };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const drafts = [];
|
|
449
|
+
const errors = [];
|
|
450
|
+
const lines = text.split(/\r?\n/);
|
|
451
|
+
for (let i = 0; i < lines.length; i++) {
|
|
452
|
+
const rawLine = lines[i];
|
|
453
|
+
if (rawLine.trim().length === 0) continue;
|
|
454
|
+
const line = i + 1;
|
|
455
|
+
|
|
456
|
+
let record;
|
|
457
|
+
try {
|
|
458
|
+
record = JSON.parse(rawLine);
|
|
459
|
+
} catch {
|
|
460
|
+
errors.push({ line, message: `invalid JSON on line ${line}` });
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (!_isPlainObject(record)) {
|
|
464
|
+
errors.push({ line, message: `record on line ${line} must be a JSON object` });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const { host, port } = _splitHostPort(record.upstream_host);
|
|
469
|
+
const cluster = _safeIdentifierOrNull(record.upstream_cluster);
|
|
470
|
+
const protocol = typeof record.protocol === 'string'
|
|
471
|
+
? _safeIdentifierOrNull(record.protocol.toLowerCase().replace(/\//g, ''))
|
|
472
|
+
: null;
|
|
473
|
+
|
|
474
|
+
const attributes = {};
|
|
475
|
+
if (host) attributes['destination.host'] = host;
|
|
476
|
+
if (port !== null) attributes['destination.port'] = port;
|
|
477
|
+
if (cluster) attributes['destination.service'] = cluster;
|
|
478
|
+
if (protocol) attributes['network.protocol'] = protocol;
|
|
479
|
+
if (typeof record.response_flags === 'string') {
|
|
480
|
+
attributes['network.securityDecision'] = record.response_flags.trim().length === 0 || record.response_flags.trim() === '-' ? 'allowed' : 'denied';
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (Object.keys(attributes).length === 0) continue; // nothing safe/present to report for this line
|
|
484
|
+
|
|
485
|
+
const startIso = typeof record.start_time === 'string' && Number.isFinite(Date.parse(record.start_time))
|
|
486
|
+
? record.start_time : context.windowStart;
|
|
487
|
+
let endIso = typeof record.end_time === 'string' && Number.isFinite(Date.parse(record.end_time))
|
|
488
|
+
? record.end_time : null;
|
|
489
|
+
if (endIso === null && typeof record.duration === 'number' && Number.isFinite(record.duration)) {
|
|
490
|
+
endIso = new Date(Date.parse(startIso) + record.duration).toISOString();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
drafts.push({
|
|
494
|
+
..._draftBase(context),
|
|
495
|
+
attributes,
|
|
496
|
+
eventCountBand: '1',
|
|
497
|
+
firstObservedAt: startIso,
|
|
498
|
+
lastObservedAt: endIso ?? startIso,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return { drafts, errors };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Kubernetes `audit.k8s.io` audit-event JSONL (one event object per
|
|
507
|
+
* line — the default `webhook`/file backend format). Reads `objectRef`
|
|
508
|
+
* (`namespace` + `resource`, joined as `"<namespace>:<resource>"` into
|
|
509
|
+
* `destination.service` — never `objectRef.name`, which is frequently a
|
|
510
|
+
* user-chosen, potentially sensitive resource name) and
|
|
511
|
+
* `responseStatus.code` (mapped to `network.securityDecision`: `< 400`
|
|
512
|
+
* is `'allowed'`, `>= 400` is `'denied'` — a client error is treated as a
|
|
513
|
+
* decision here because the audit log's own admission/authorization
|
|
514
|
+
* failures surface as 401/403/409, which is exactly the "security
|
|
515
|
+
* decision" fact this adapter exists to carry). `requestReceivedTimestamp`/
|
|
516
|
+
* `stageTimestamp` feed the observed-at window. **`user.username` and
|
|
517
|
+
* every request/response body field are deliberately never read** — a
|
|
518
|
+
* K8s audit event's `user` block is exactly the "authenticated identity"
|
|
519
|
+
* PRD 2 names, and `runtime-observation.js`'s own contract has no
|
|
520
|
+
* approved field for it (a deliberate, disclosed scope boundary — see
|
|
521
|
+
* this PRD's implementation-status doc).
|
|
522
|
+
*/
|
|
523
|
+
function parseK8sAuditLogObservations(text, context) {
|
|
524
|
+
if (typeof text !== 'string' || !_isPlainObject(context)) {
|
|
525
|
+
return { drafts: [], errors: [{ line: 0, message: 'parseK8sAuditLogObservations requires a string text and a plain-object context' }] };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const drafts = [];
|
|
529
|
+
const errors = [];
|
|
530
|
+
const lines = text.split(/\r?\n/);
|
|
531
|
+
for (let i = 0; i < lines.length; i++) {
|
|
532
|
+
const rawLine = lines[i];
|
|
533
|
+
if (rawLine.trim().length === 0) continue;
|
|
534
|
+
const line = i + 1;
|
|
535
|
+
|
|
536
|
+
let record;
|
|
537
|
+
try {
|
|
538
|
+
record = JSON.parse(rawLine);
|
|
539
|
+
} catch {
|
|
540
|
+
errors.push({ line, message: `invalid JSON on line ${line}` });
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (!_isPlainObject(record)) {
|
|
544
|
+
errors.push({ line, message: `record on line ${line} must be a JSON object` });
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const objectRef = _isPlainObject(record.objectRef) ? record.objectRef : {};
|
|
549
|
+
const namespace = _safeIdentifierOrNull(objectRef.namespace);
|
|
550
|
+
const resource = _safeIdentifierOrNull(objectRef.resource);
|
|
551
|
+
const code = record.responseStatus && typeof record.responseStatus === 'object' ? record.responseStatus.code : undefined;
|
|
552
|
+
|
|
553
|
+
const attributes = {};
|
|
554
|
+
if (namespace && resource) attributes['destination.service'] = `${namespace}:${resource}`;
|
|
555
|
+
else if (resource) attributes['destination.service'] = resource;
|
|
556
|
+
if (typeof code === 'number' && Number.isFinite(code)) {
|
|
557
|
+
attributes['network.securityDecision'] = code < 400 ? 'allowed' : 'denied';
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (Object.keys(attributes).length === 0) continue;
|
|
561
|
+
|
|
562
|
+
const observedAt = typeof record.requestReceivedTimestamp === 'string' && Number.isFinite(Date.parse(record.requestReceivedTimestamp))
|
|
563
|
+
? record.requestReceivedTimestamp
|
|
564
|
+
: (typeof record.stageTimestamp === 'string' && Number.isFinite(Date.parse(record.stageTimestamp)) ? record.stageTimestamp : context.windowStart);
|
|
565
|
+
|
|
566
|
+
drafts.push({
|
|
567
|
+
..._draftBase(context),
|
|
568
|
+
attributes,
|
|
569
|
+
eventCountBand: '1',
|
|
570
|
+
firstObservedAt: observedAt,
|
|
571
|
+
lastObservedAt: observedAt,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return { drafts, errors };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* A generic JSON-lines HTTP access log (one JSON object per line — the
|
|
580
|
+
* shape a structured reverse proxy or load-balancer access log normalizes
|
|
581
|
+
* to; deliberately not an Apache/Nginx TEXT combined-log parser, which
|
|
582
|
+
* would need its own separate, much larger format-string handling).
|
|
583
|
+
* Reads `host`/`destination_host` -> `destination.host`,
|
|
584
|
+
* `upstream`/`upstream_service` -> `destination.service`, `scheme` ->
|
|
585
|
+
* `destination.scheme`. `timestamp` (or `time`) feeds the observed-at
|
|
586
|
+
* window. Request path/query, client IP, user agent, status code's own
|
|
587
|
+
* value, and every header are dropped.
|
|
588
|
+
*/
|
|
589
|
+
function parseHttpAccessLogObservations(text, context) {
|
|
590
|
+
if (typeof text !== 'string' || !_isPlainObject(context)) {
|
|
591
|
+
return { drafts: [], errors: [{ line: 0, message: 'parseHttpAccessLogObservations requires a string text and a plain-object context' }] };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const drafts = [];
|
|
595
|
+
const errors = [];
|
|
596
|
+
const lines = text.split(/\r?\n/);
|
|
597
|
+
for (let i = 0; i < lines.length; i++) {
|
|
598
|
+
const rawLine = lines[i];
|
|
599
|
+
if (rawLine.trim().length === 0) continue;
|
|
600
|
+
const line = i + 1;
|
|
601
|
+
|
|
602
|
+
let record;
|
|
603
|
+
try {
|
|
604
|
+
record = JSON.parse(rawLine);
|
|
605
|
+
} catch {
|
|
606
|
+
errors.push({ line, message: `invalid JSON on line ${line}` });
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (!_isPlainObject(record)) {
|
|
610
|
+
errors.push({ line, message: `record on line ${line} must be a JSON object` });
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const host = _safeIdentifierOrNull(record.host ?? record.destination_host);
|
|
615
|
+
const upstream = _safeIdentifierOrNull(record.upstream ?? record.upstream_service);
|
|
616
|
+
const scheme = _safeIdentifierOrNull(record.scheme);
|
|
617
|
+
|
|
618
|
+
const attributes = {};
|
|
619
|
+
if (host) attributes['destination.host'] = host;
|
|
620
|
+
if (upstream) attributes['destination.service'] = upstream;
|
|
621
|
+
if (scheme) attributes['destination.scheme'] = scheme;
|
|
622
|
+
|
|
623
|
+
if (Object.keys(attributes).length === 0) continue;
|
|
624
|
+
|
|
625
|
+
const ts = record.timestamp ?? record.time;
|
|
626
|
+
const observedAt = typeof ts === 'string' && Number.isFinite(Date.parse(ts)) ? ts : context.windowStart;
|
|
627
|
+
|
|
628
|
+
drafts.push({
|
|
629
|
+
..._draftBase(context),
|
|
630
|
+
attributes,
|
|
631
|
+
eventCountBand: '1',
|
|
632
|
+
firstObservedAt: observedAt,
|
|
633
|
+
lastObservedAt: observedAt,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return { drafts, errors };
|
|
638
|
+
}
|
|
639
|
+
|
|
276
640
|
// The adapter registry — a frozen, module-level lookup. `adapterFor`
|
|
277
641
|
// returns `null` for anything unknown, NEVER a default: an unimplemented
|
|
278
642
|
// adapter name (e.g. `'otlp'`, deferred per the scoping doc's §4.3 ruling)
|
|
@@ -280,6 +644,10 @@ function parseNativeJsonlObservations(text, context) {
|
|
|
280
644
|
// someday exist.
|
|
281
645
|
const _ADAPTERS = Object.freeze({
|
|
282
646
|
'native-jsonl': Object.freeze({ name: 'native-jsonl', parse: parseNativeJsonlObservations }),
|
|
647
|
+
'otel-json': Object.freeze({ name: 'otel-json', parse: parseOtelJsonObservations }),
|
|
648
|
+
'envoy-access-log': Object.freeze({ name: 'envoy-access-log', parse: parseEnvoyAccessLogObservations }),
|
|
649
|
+
'k8s-audit-log': Object.freeze({ name: 'k8s-audit-log', parse: parseK8sAuditLogObservations }),
|
|
650
|
+
'http-access-log': Object.freeze({ name: 'http-access-log', parse: parseHttpAccessLogObservations }),
|
|
283
651
|
});
|
|
284
652
|
|
|
285
653
|
/**
|
package/dist/4142.index.js
CHANGED
|
@@ -712,13 +712,13 @@ function loadLedger(scanRoot) {
|
|
|
712
712
|
function saveLedger(scanRoot, ledger) {
|
|
713
713
|
if (!(0,state_dir/* ensureStateDir */.xH)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
714
714
|
const f = (0,state_dir.statePath)(scanRoot, DIR, 'ledger.json');
|
|
715
|
-
return { ok: (0,state_dir
|
|
715
|
+
return { ok: (0,state_dir.safeWriteState)(f, JSON.stringify(ledger, null, 2)) };
|
|
716
716
|
}
|
|
717
717
|
|
|
718
718
|
function saveIR(scanRoot, ir) {
|
|
719
719
|
if (!(0,state_dir/* ensureStateDir */.xH)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
720
720
|
const f = external_node_path_.join(ruleDir(scanRoot, ir.id), 'ir.json');
|
|
721
|
-
return { ok: (0,state_dir
|
|
721
|
+
return { ok: (0,state_dir.safeWriteState)(f, JSON.stringify(ir, null, 2)), file: f };
|
|
722
722
|
}
|
|
723
723
|
|
|
724
724
|
function loadIR(scanRoot, id) {
|
|
@@ -729,7 +729,7 @@ function loadIR(scanRoot, id) {
|
|
|
729
729
|
function saveEvaluation(scanRoot, id, evaluation) {
|
|
730
730
|
if (!(0,state_dir/* ensureStateDir */.xH)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
|
|
731
731
|
const f = external_node_path_.join(ruleDir(scanRoot, id), 'evaluation.json');
|
|
732
|
-
return { ok: (0,state_dir
|
|
732
|
+
return { ok: (0,state_dir.safeWriteState)(f, JSON.stringify(evaluation, null, 2)) };
|
|
733
733
|
}
|
|
734
734
|
|
|
735
735
|
/** Write the regression fixtures a promoted rule needs: the SAME vulnerable/clean convention `runRuleTests`
|
|
@@ -741,11 +741,11 @@ function writeFixtures(scanRoot, ir, corpus) {
|
|
|
741
741
|
const written = [];
|
|
742
742
|
for (const p of corpus.positives) {
|
|
743
743
|
const f = external_node_path_.join(base, 'vulnerable', `${p.label.replace(/[^a-z0-9_-]/gi, '_')}.${ext}`);
|
|
744
|
-
if ((0,state_dir
|
|
744
|
+
if ((0,state_dir.safeWriteState)(f, p.code)) written.push(f);
|
|
745
745
|
}
|
|
746
746
|
for (const n of corpus.negatives) {
|
|
747
747
|
const f = external_node_path_.join(base, 'clean', `${n.label.replace(/[^a-z0-9_-]/gi, '_')}.${ext}`);
|
|
748
|
-
if ((0,state_dir
|
|
748
|
+
if ((0,state_dir.safeWriteState)(f, n.code)) written.push(f);
|
|
749
749
|
}
|
|
750
750
|
return { ok: true, written, glob: external_node_path_.join(base, '**', '*') };
|
|
751
751
|
}
|
|
@@ -772,7 +772,7 @@ function publishCustomRule(scanRoot, ir) {
|
|
|
772
772
|
` message: ${JSON.stringify(doc.message)}`,
|
|
773
773
|
'',
|
|
774
774
|
].join('\n');
|
|
775
|
-
return { ok: (0,state_dir
|
|
775
|
+
return { ok: (0,state_dir.safeWriteState)(f, yamlText), file: f };
|
|
776
776
|
}
|
|
777
777
|
|
|
778
778
|
function unpublishCustomRule(scanRoot, id) {
|