@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
package/dist/2923.index.js
CHANGED
|
@@ -316,6 +316,16 @@ function _splitHostPort(v) {
|
|
|
316
316
|
return { host: _safeIdentifierOrNull(host), port };
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
// Identity is recorded ONLY as a keyed-hash token, and only when the importer supplied the tokenizer (`context.tokenizeIdentity`).
|
|
320
|
+
// This module stays zero-import and holds no key: with no tokenizer, identity is dropped, never stored raw and never hashed without a
|
|
321
|
+
// key (an unkeyed hash of a username is reversible by guessing). A raw value that is not a plausible identity string is dropped too.
|
|
322
|
+
function _identityToken(context, raw) {
|
|
323
|
+
if (typeof context?.tokenizeIdentity !== 'function') return null;
|
|
324
|
+
if (typeof raw !== 'string' || raw.length === 0 || raw.length > 256 || /[\u0000-\u001f]/.test(raw)) return null;
|
|
325
|
+
const t = context.tokenizeIdentity(raw);
|
|
326
|
+
return typeof t === 'string' && /^idt_[0-9a-f]{16}$/.test(t) ? t : null;
|
|
327
|
+
}
|
|
328
|
+
|
|
319
329
|
function _draftBase(context, environmentOverride) {
|
|
320
330
|
const { version, adapter, source, environment, windowStart, windowEnd, importedAt, retention } = context;
|
|
321
331
|
return {
|
|
@@ -409,6 +419,7 @@ function parseOtelJsonObservations(text, context) {
|
|
|
409
419
|
if (storeService) attributes['destination.service'] = storeService;
|
|
410
420
|
|
|
411
421
|
if (Object.keys(attributes).length === 0) continue; // nothing safe to report for this span
|
|
422
|
+
{ const idt = _identityToken(context, a['enduser.id']); if (idt) attributes['identity.token'] = idt; }
|
|
412
423
|
|
|
413
424
|
const at = _isoFromUnixNano(span.startTimeUnixNano) ?? context.windowStart;
|
|
414
425
|
drafts.push({
|
|
@@ -558,6 +569,7 @@ function parseK8sAuditLogObservations(text, context) {
|
|
|
558
569
|
}
|
|
559
570
|
|
|
560
571
|
if (Object.keys(attributes).length === 0) continue;
|
|
572
|
+
{ const idt = _identityToken(context, _isPlainObject(record.user) ? record.user.username : undefined); if (idt) attributes['identity.token'] = idt; }
|
|
561
573
|
|
|
562
574
|
const observedAt = typeof record.requestReceivedTimestamp === 'string' && Number.isFinite(Date.parse(record.requestReceivedTimestamp))
|
|
563
575
|
? record.requestReceivedTimestamp
|
|
@@ -621,6 +633,7 @@ function parseHttpAccessLogObservations(text, context) {
|
|
|
621
633
|
if (scheme) attributes['destination.scheme'] = scheme;
|
|
622
634
|
|
|
623
635
|
if (Object.keys(attributes).length === 0) continue;
|
|
636
|
+
{ const idt = _identityToken(context, record.user ?? record.remote_user ?? record.authenticated_user); if (idt) attributes['identity.token'] = idt; }
|
|
624
637
|
|
|
625
638
|
const ts = record.timestamp ?? record.time;
|
|
626
639
|
const observedAt = typeof ts === 'string' && Number.isFinite(Date.parse(ts)) ? ts : context.windowStart;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export const id = 2946;
|
|
2
|
+
export const ids = [2946];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 52946:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
|
+
/* harmony export */ verifyWithCosign: () => (/* binding */ verifyWithCosign)
|
|
10
|
+
/* harmony export */ });
|
|
11
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(73024);
|
|
12
|
+
/* harmony import */ var node_os__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48161);
|
|
13
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(76760);
|
|
14
|
+
/* harmony import */ var _sandbox_supervise_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(74676);
|
|
15
|
+
/* harmony import */ var _sandbox_trust_domains_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(38832);
|
|
16
|
+
// Signature verification by DELEGATION to cosign (PRD 5, requirement 5: "Sigstore, cosign"). This module does not implement keyless
|
|
17
|
+
// verification (certificate chains, transparency-log proofs, identity matching); cosign does, and this runs it and reads its answer.
|
|
18
|
+
//
|
|
19
|
+
// The state is one of verified | invalid | missing | unavailable, and the rules are conservative:
|
|
20
|
+
// * the check is bound to the DIGEST we scanned (`<repo>@sha256:...`), never to a tag, so a signature for some other image cannot
|
|
21
|
+
// be mistaken for this one;
|
|
22
|
+
// * `verified` needs cosign to exit 0 AND its output to name that exact digest in a signature payload. Exit 0 with a different (or no)
|
|
23
|
+
// digest is `invalid`;
|
|
24
|
+
// * a cosign that is not installed, that timed out, or that could not reach what it needed is `unavailable` and says so. It is never
|
|
25
|
+
// read as verified, and never as invalid either: a check that did not run is not a failed check;
|
|
26
|
+
// * the key is passed as a temporary file (mode 0600, removed afterwards) and the child gets a scrubbed environment.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
34
|
+
const REPO_RE = /^[A-Za-z0-9][A-Za-z0-9.\-_/:]{0,300}$/;
|
|
35
|
+
|
|
36
|
+
function findTool(name, pathEnv = process.env.PATH || '') {
|
|
37
|
+
for (const dir of pathEnv.split(node_path__WEBPACK_IMPORTED_MODULE_2__.delimiter)) {
|
|
38
|
+
if (!dir) continue;
|
|
39
|
+
const p = node_path__WEBPACK_IMPORTED_MODULE_2__.join(dir, name);
|
|
40
|
+
try { node_fs__WEBPACK_IMPORTED_MODULE_0__.accessSync(p, node_fs__WEBPACK_IMPORTED_MODULE_0__.constants.X_OK); if (node_fs__WEBPACK_IMPORTED_MODULE_0__.statSync(p).isFile()) return node_fs__WEBPACK_IMPORTED_MODULE_0__.realpathSync(p); } catch { /* next */ }
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {{repository: string, digest: string, publicKeyPem?: string, certificateIdentity?: string, certificateOidcIssuer?: string}} o */
|
|
46
|
+
async function verifyWithCosign({ repository, digest, publicKeyPem = null, certificateIdentity = null, certificateOidcIssuer = null, timeoutMs = 60_000, signal, pathEnv = process.env.PATH } = {}) {
|
|
47
|
+
const na = (reason) => ({ state: 'unavailable', via: 'cosign', reason });
|
|
48
|
+
if (!DIGEST_RE.test(String(digest))) return na('there is no sha256 image digest to verify a signature against');
|
|
49
|
+
if (!REPO_RE.test(String(repository ?? '')) || String(repository).includes('..') || String(repository).startsWith('-')) return na('the repository name is not valid');
|
|
50
|
+
const keyless = !!(certificateIdentity && certificateOidcIssuer);
|
|
51
|
+
if (!publicKeyPem && !keyless) return na('no verification material: give a public key, or a certificate identity and OIDC issuer for keyless verification');
|
|
52
|
+
if (publicKeyPem && keyless) return na('give either a public key or a keyless identity, not both');
|
|
53
|
+
const bin = findTool('cosign', pathEnv);
|
|
54
|
+
if (!bin) return na('cosign is not installed; nothing was verified');
|
|
55
|
+
const dir = node_fs__WEBPACK_IMPORTED_MODULE_0__.mkdtempSync(node_path__WEBPACK_IMPORTED_MODULE_2__.join(node_os__WEBPACK_IMPORTED_MODULE_1__.tmpdir(), 'cosign-'));
|
|
56
|
+
try {
|
|
57
|
+
const args = ['verify'];
|
|
58
|
+
if (publicKeyPem) { const kf = node_path__WEBPACK_IMPORTED_MODULE_2__.join(dir, 'key.pub'); node_fs__WEBPACK_IMPORTED_MODULE_0__.writeFileSync(kf, publicKeyPem, { mode: 0o600 }); args.push('--key', kf); } else args.push('--certificate-identity', certificateIdentity, '--certificate-oidc-issuer', certificateOidcIssuer);
|
|
59
|
+
args.push(`${repository}@${digest}`);
|
|
60
|
+
const r = await (0,_sandbox_supervise_js__WEBPACK_IMPORTED_MODULE_3__/* .superviseSpawn */ .u)(bin, args, { cwd: dir, env: (0,_sandbox_trust_domains_js__WEBPACK_IMPORTED_MODULE_4__/* .scrubEnv */ .LK)(process.env), timeoutMs, signal, maxOutputBytes: 2 * 1024 * 1024 });
|
|
61
|
+
if (r.spawnError) return na(`could not start cosign: ${r.spawnError}`);
|
|
62
|
+
if (r.timedOut) return na('cosign did not answer in time');
|
|
63
|
+
if (r.cancelled) return na('cancelled');
|
|
64
|
+
const err = String(r.stderr).toLowerCase();
|
|
65
|
+
if (r.exitCode === 0) {
|
|
66
|
+
let payloads = [];
|
|
67
|
+
try { const j = JSON.parse(r.stdout); payloads = Array.isArray(j) ? j : [j]; } catch { /* handled below */ }
|
|
68
|
+
const matching = payloads.filter((p) => p?.critical?.image?.['docker-manifest-digest'] === digest);
|
|
69
|
+
if (matching.length) return { state: 'verified', via: 'cosign', mode: keyless ? 'keyless' : 'key', signatures: matching.length, identity: keyless ? certificateIdentity : null, reason: null };
|
|
70
|
+
return { state: 'invalid', via: 'cosign', reason: 'cosign reported success but none of the verified signatures names this image digest' };
|
|
71
|
+
}
|
|
72
|
+
if (/no signatures found|no matching signatures|no signatures associated|manifest unknown/.test(err)) return { state: 'missing', via: 'cosign', reason: 'no signature exists for this digest' };
|
|
73
|
+
if (/invalid signature|signature verification failed|crypto\/ecdsa|verification failed|certificate identity|none of the expected identities/.test(err)) return { state: 'invalid', via: 'cosign', reason: 'cosign rejected the signature or the identity' };
|
|
74
|
+
return na(`cosign could not complete the check (exit ${r.exitCode}): ${String(r.stderr).trim().split('\n').pop()?.slice(0, 160) ?? ''}`);
|
|
75
|
+
} finally { try { node_fs__WEBPACK_IMPORTED_MODULE_0__.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
/***/ })
|
|
80
|
+
|
|
81
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const id = 3196;
|
|
2
|
+
export const ids = [3196];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 33196:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
|
+
/* harmony export */ makeIdentityTokenizer: () => (/* binding */ makeIdentityTokenizer)
|
|
10
|
+
/* harmony export */ });
|
|
11
|
+
/* harmony import */ var _posture_integrity_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(71130);
|
|
12
|
+
// A keyed-hash token for an authenticated identity (PRD 2 section 1, "authenticated identity"). The observation contract is closed-world
|
|
13
|
+
// and refuses raw identities, so what is stored is `idt_` + 16 hex of an HMAC under the per-install key:
|
|
14
|
+
// * the same identity always yields the same token on this install, so "the same identity used these paths" can be answered;
|
|
15
|
+
// * without the install key the token cannot be reversed or confirmed by guessing usernames (an unkeyed hash could);
|
|
16
|
+
// * tokens from two installs do not match, which is deliberate: they are not a portable identifier.
|
|
17
|
+
// It is a pseudonym, not anonymity: someone who holds the install key and a candidate username can confirm a match.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
function makeIdentityTokenizer() {
|
|
21
|
+
return (raw) => (typeof raw === 'string' && raw.length > 0 ? `idt_${(0,_posture_integrity_js__WEBPACK_IMPORTED_MODULE_0__/* .signLastScan */ .lU)(`identity-token:v1:${raw}`).slice(0, 16)}` : null);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
/***/ })
|
|
26
|
+
|
|
27
|
+
};
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
export const id = 3727;
|
|
2
|
+
export const ids = [3727];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 73727:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// EXPORTS
|
|
10
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
11
|
+
RegistryError: () => (/* binding */ RegistryError),
|
|
12
|
+
fetchImage: () => (/* binding */ fetchImage)
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// UNUSED EXPORTS: REGISTRY_LIMITS, parseReference, resolveCredentials
|
|
16
|
+
|
|
17
|
+
// EXTERNAL MODULE: external "node:fs"
|
|
18
|
+
var external_node_fs_ = __webpack_require__(73024);
|
|
19
|
+
// EXTERNAL MODULE: external "node:os"
|
|
20
|
+
var external_node_os_ = __webpack_require__(48161);
|
|
21
|
+
// EXTERNAL MODULE: external "node:path"
|
|
22
|
+
var external_node_path_ = __webpack_require__(76760);
|
|
23
|
+
// EXTERNAL MODULE: external "node:crypto"
|
|
24
|
+
var external_node_crypto_ = __webpack_require__(77598);
|
|
25
|
+
;// CONCATENATED MODULE: ./src/artifact/oci-layout.js
|
|
26
|
+
// Assemble an OCI image-layout archive (a plain tar) from a manifest, a config and layer blobs that were already fetched and
|
|
27
|
+
// digest-verified. The result is exactly the shape `image-archive.js` reads from a local `oci-archive`, so a remote image and a local
|
|
28
|
+
// one go through ONE parser and the same size and path guards. Nothing is extracted to disk.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
const BLOCK = 512;
|
|
32
|
+
|
|
33
|
+
function octal(n, len) { return n.toString(8).padStart(len - 1, '0') + '\0'; }
|
|
34
|
+
|
|
35
|
+
/** A single ustar entry. Names here are generated by this module (`blobs/sha256/<hex>`), never taken from the network. */
|
|
36
|
+
function tarEntry(name, data) {
|
|
37
|
+
if (Buffer.byteLength(name) > 100) throw new Error('tar entry name too long');
|
|
38
|
+
const header = Buffer.alloc(BLOCK, 0);
|
|
39
|
+
header.write(name, 0, 'utf8');
|
|
40
|
+
header.write(octal(0o644, 8), 100);
|
|
41
|
+
header.write(octal(0, 8), 108); header.write(octal(0, 8), 116);
|
|
42
|
+
header.write(octal(data.length, 12), 124);
|
|
43
|
+
header.write(octal(0, 12), 136);
|
|
44
|
+
header.fill(0x20, 148, 156);
|
|
45
|
+
header.write('0', 156);
|
|
46
|
+
header.write('ustar\0', 257); header.write('00', 263);
|
|
47
|
+
let sum = 0; for (const b of header) sum += b;
|
|
48
|
+
header.write(octal(sum, 7), 148); header[155] = 0x20;
|
|
49
|
+
const pad = (BLOCK - (data.length % BLOCK)) % BLOCK;
|
|
50
|
+
return Buffer.concat([header, data, Buffer.alloc(pad, 0)]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sha256Hex = (buf) => external_node_crypto_.createHash('sha256').update(buf).digest('hex');
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {{manifest: Buffer, config: Buffer, layers: Buffer[]}} img bytes exactly as the registry served them
|
|
57
|
+
* @returns {{tar: Buffer, manifestDigest: string}}
|
|
58
|
+
*/
|
|
59
|
+
function buildOciArchive({ manifest, config, layers, mediaType = 'application/vnd.oci.image.manifest.v1+json' }) {
|
|
60
|
+
const manifestHex = sha256Hex(manifest);
|
|
61
|
+
const index = Buffer.from(JSON.stringify({ schemaVersion: 2, manifests: [{ mediaType, digest: `sha256:${manifestHex}`, size: manifest.length }] }));
|
|
62
|
+
const parts = [
|
|
63
|
+
tarEntry('oci-layout', Buffer.from(JSON.stringify({ imageLayoutVersion: '1.0.0' }))),
|
|
64
|
+
tarEntry('index.json', index),
|
|
65
|
+
tarEntry(`blobs/sha256/${manifestHex}`, manifest),
|
|
66
|
+
tarEntry(`blobs/sha256/${sha256Hex(config)}`, config),
|
|
67
|
+
];
|
|
68
|
+
const seen = new Set([manifestHex, sha256Hex(config)]);
|
|
69
|
+
for (const l of layers) { const h = sha256Hex(l); if (seen.has(h)) continue; seen.add(h); parts.push(tarEntry(`blobs/sha256/${h}`, l)); }
|
|
70
|
+
parts.push(Buffer.alloc(BLOCK * 2, 0));
|
|
71
|
+
return { tar: Buffer.concat(parts), manifestDigest: `sha256:${manifestHex}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
;// CONCATENATED MODULE: ./src/artifact/registry.js
|
|
75
|
+
// A minimal OCI Distribution (v2) client for ONE purpose: fetching a single image the operator named explicitly
|
|
76
|
+
// (`image scan --registry <ref>`), so it can be scanned like a local archive (PRD 5, requirement 1: "explicit remote OCI reference").
|
|
77
|
+
//
|
|
78
|
+
// It is deliberately small and deliberately strict. It is hostile-input handling first:
|
|
79
|
+
// * a reference is parsed, not interpolated: no scheme, no userinfo, no whitespace, no path tricks;
|
|
80
|
+
// * transport is HTTPS. Plain HTTP is accepted only for a loopback registry (a local test or a local mirror); anything else is refused;
|
|
81
|
+
// * 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
|
|
82
|
+
// other than the registry's own, which is what keeps a blob redirected to a CDN from receiving it;
|
|
83
|
+
// * every body is digest-verified against what the manifest said, and size-capped while it streams, so a registry that lies about
|
|
84
|
+
// a size or sends endless bytes is cut off, not buffered;
|
|
85
|
+
// * the manifest a registry returns for a tag is hashed and checked against `Docker-Content-Digest` when one is sent, and for a
|
|
86
|
+
// digest reference against the reference itself;
|
|
87
|
+
// * credentials come from the environment or the Docker config for that registry only, are never logged, never put in an error,
|
|
88
|
+
// and live in an object that refuses to serialize.
|
|
89
|
+
// Nothing is executed and nothing is extracted to disk; the result is an OCI-layout archive in memory for `image-archive.js`.
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RegistryError extends Error {
|
|
96
|
+
constructor(message, code) { super(message); this.name = 'RegistryError'; this.code = code; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
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 });
|
|
100
|
+
|
|
101
|
+
const MANIFEST_ACCEPT = [
|
|
102
|
+
'application/vnd.oci.image.index.v1+json', 'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
103
|
+
'application/vnd.oci.image.manifest.v1+json', 'application/vnd.docker.distribution.manifest.v2+json',
|
|
104
|
+
].join(', ');
|
|
105
|
+
const INDEX_TYPES = new Set(['application/vnd.oci.image.index.v1+json', 'application/vnd.docker.distribution.manifest.list.v2+json']);
|
|
106
|
+
const MANIFEST_TYPES = new Set(['application/vnd.oci.image.manifest.v1+json', 'application/vnd.docker.distribution.manifest.v2+json']);
|
|
107
|
+
const LOOPBACK_HOST = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])$/i;
|
|
108
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
109
|
+
|
|
110
|
+
/** Credentials that cannot be printed: `JSON.stringify`, `String()` and `util.inspect` all give a placeholder. */
|
|
111
|
+
class Credentials {
|
|
112
|
+
#basic = null; #bearer = null;
|
|
113
|
+
constructor({ user, password, token }) { if (token) this.#bearer = token; else if (user && password) this.#basic = Buffer.from(`${user}:${password}`).toString('base64'); }
|
|
114
|
+
get present() { return !!(this.#basic || this.#bearer); }
|
|
115
|
+
basicHeader() { return this.#basic ? `Basic ${this.#basic}` : null; }
|
|
116
|
+
bearerHeader() { return this.#bearer ? `Bearer ${this.#bearer}` : null; }
|
|
117
|
+
toJSON() { return '[credentials]'; }
|
|
118
|
+
toString() { return '[credentials]'; }
|
|
119
|
+
[Symbol.for('nodejs.util.inspect.custom')]() { return '[credentials]'; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The registry's own credentials: environment first, then the Docker config entry for exactly this registry. Never another host's. */
|
|
123
|
+
function resolveCredentials(registry, { env = process.env, dockerConfig = external_node_path_.join(external_node_os_.homedir(), '.docker', 'config.json') } = {}) {
|
|
124
|
+
if (env.AGENTIC_SECURITY_REGISTRY_TOKEN) return new Credentials({ token: env.AGENTIC_SECURITY_REGISTRY_TOKEN });
|
|
125
|
+
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 });
|
|
126
|
+
try {
|
|
127
|
+
const cfg = JSON.parse(external_node_fs_.readFileSync(dockerConfig, 'utf8'));
|
|
128
|
+
const keys = [registry, `https://${registry}`, ...(registry === 'registry-1.docker.io' ? ['https://index.docker.io/v1/', 'docker.io'] : [])];
|
|
129
|
+
for (const k of keys) {
|
|
130
|
+
const auth = cfg.auths?.[k]?.auth;
|
|
131
|
+
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(':') }); }
|
|
132
|
+
}
|
|
133
|
+
} catch { /* no usable Docker config */ }
|
|
134
|
+
return new Credentials({});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @returns {{registry: string, repository: string, reference: string, isDigest: boolean, loopback: boolean}} */
|
|
138
|
+
function parseReference(input) {
|
|
139
|
+
const raw = String(input ?? '');
|
|
140
|
+
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');
|
|
141
|
+
let rest = raw; let reference = 'latest'; let isDigest = false;
|
|
142
|
+
const at = rest.indexOf('@');
|
|
143
|
+
if (at !== -1) { reference = rest.slice(at + 1); rest = rest.slice(0, at); isDigest = true; }
|
|
144
|
+
const parts = rest.split('/');
|
|
145
|
+
let registry = 'registry-1.docker.io';
|
|
146
|
+
if (parts.length > 1 && (parts[0].includes('.') || parts[0].includes(':') || parts[0] === 'localhost')) registry = parts.shift();
|
|
147
|
+
let repo = parts.join('/');
|
|
148
|
+
if (!isDigest) { const colon = repo.lastIndexOf(':'); if (colon !== -1) { reference = repo.slice(colon + 1); repo = repo.slice(0, colon); } }
|
|
149
|
+
if (registry === 'registry-1.docker.io' && !repo.includes('/')) repo = `library/${repo}`;
|
|
150
|
+
if (registry === 'docker.io' || registry === 'index.docker.io') registry = 'registry-1.docker.io';
|
|
151
|
+
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');
|
|
152
|
+
if (!isDigest && !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(reference)) throw new RegistryError('not a valid tag', 'bad_reference');
|
|
153
|
+
if (isDigest && !DIGEST_RE.test(reference)) throw new RegistryError('only sha256 digests are supported', 'bad_reference');
|
|
154
|
+
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');
|
|
155
|
+
const host = registry.replace(/:\d+$/, '');
|
|
156
|
+
return { registry, repository: repo, reference, isDigest, loopback: LOOPBACK_HOST.test(host) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const scrubUrl = (u) => { try { const x = new URL(u); return `${x.protocol}//${x.host}${x.pathname}`; } catch { return '[url]'; } };
|
|
160
|
+
|
|
161
|
+
async function fetchImage(input, { platform = 'linux/amd64', credentials = null, fetchImpl = globalThis.fetch, limits = REGISTRY_LIMITS, signal = null, env = process.env, allowLoopbackHttp = false } = {}) {
|
|
162
|
+
const ref = parseReference(input);
|
|
163
|
+
const L = { ...REGISTRY_LIMITS, ...limits };
|
|
164
|
+
const creds = credentials ?? resolveCredentials(ref.registry, { env });
|
|
165
|
+
const scheme = ref.loopback && allowLoopbackHttp ? 'http' : 'https';
|
|
166
|
+
const origin = `${scheme}://${ref.registry}`;
|
|
167
|
+
const deadline = Date.now() + L.totalTimeoutMs;
|
|
168
|
+
let authHeader = null; let total = 0;
|
|
169
|
+
const allowedUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' || (x.protocol === 'http:' && LOOPBACK_HOST.test(x.hostname)); } catch { return false; } };
|
|
170
|
+
|
|
171
|
+
async function request(url, { accept, auth = true, maxBytes, expectDigest = null } = {}) {
|
|
172
|
+
let current = url; let sendAuth = auth;
|
|
173
|
+
for (let hop = 0; hop <= L.maxRedirects; hop++) {
|
|
174
|
+
if (!allowedUrl(current)) throw new RegistryError(`refusing a non-HTTPS target: ${scrubUrl(current)}`, 'insecure_transport');
|
|
175
|
+
if (Date.now() > deadline) throw new RegistryError('the total time budget was exhausted', 'timeout');
|
|
176
|
+
if (signal?.aborted) throw new RegistryError('the request was cancelled', 'network');
|
|
177
|
+
const sameOrigin = new URL(current).origin === new URL(origin).origin;
|
|
178
|
+
const headers = { 'user-agent': 'agentic-security-image-scan', ...(accept ? { accept } : {}) };
|
|
179
|
+
if (sendAuth && sameOrigin) { const h = authHeader ?? creds.bearerHeader(); if (h) headers.authorization = h; }
|
|
180
|
+
const ctl = new AbortController();
|
|
181
|
+
const t = setTimeout(() => ctl.abort(), L.requestTimeoutMs);
|
|
182
|
+
const onAbort = () => ctl.abort(); signal?.addEventListener('abort', onAbort, { once: true });
|
|
183
|
+
let res;
|
|
184
|
+
try { res = await fetchImpl(current, { headers, redirect: 'manual', signal: ctl.signal }); }
|
|
185
|
+
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'); }
|
|
186
|
+
finally { clearTimeout(t); signal?.removeEventListener('abort', onAbort); }
|
|
187
|
+
if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
|
|
188
|
+
const next = new URL(res.headers.get('location'), current).toString();
|
|
189
|
+
if (new URL(next).origin !== new URL(origin).origin) sendAuth = false; // never carry the registry credential to another host
|
|
190
|
+
current = next; continue;
|
|
191
|
+
}
|
|
192
|
+
if (res.status === 401 && sameOrigin && !authHeader) return { status: 401, challenge: res.headers.get('www-authenticate') ?? '' };
|
|
193
|
+
if (res.status === 404) throw new RegistryError('not found in the registry', 'not_found');
|
|
194
|
+
if (res.status === 401 || res.status === 403) throw new RegistryError('the registry refused access (authentication required or not permitted)', 'unauthorized');
|
|
195
|
+
if (!res.ok) throw new RegistryError(`the registry answered ${res.status}`, 'registry_error');
|
|
196
|
+
const declared = Number(res.headers.get('content-length'));
|
|
197
|
+
if (Number.isFinite(declared) && declared > maxBytes) throw new RegistryError(`a response larger than the ${maxBytes}-byte limit`, 'too_large');
|
|
198
|
+
const chunks = []; let got = 0;
|
|
199
|
+
for await (const c of res.body) {
|
|
200
|
+
got += c.length; total += c.length;
|
|
201
|
+
if (got > maxBytes || total > L.maxTotalBytes) { try { await res.body.cancel(); } catch { /* closing */ } throw new RegistryError('the response exceeded its size limit', 'too_large'); }
|
|
202
|
+
chunks.push(c);
|
|
203
|
+
}
|
|
204
|
+
const body = Buffer.concat(chunks);
|
|
205
|
+
if (expectDigest && `sha256:${sha256Hex(body)}` !== expectDigest) throw new RegistryError('a downloaded blob does not match its digest', 'digest_mismatch');
|
|
206
|
+
return { status: res.status, body, headers: res.headers };
|
|
207
|
+
}
|
|
208
|
+
throw new RegistryError('too many redirects', 'redirect_loop');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function authed(url, opts) {
|
|
212
|
+
let r = await request(url, opts);
|
|
213
|
+
if (r.status !== 401) return r;
|
|
214
|
+
const basic = creds.basicHeader();
|
|
215
|
+
if (/^Basic\b/i.test(r.challenge)) {
|
|
216
|
+
if (!basic) throw new RegistryError('the registry requires authentication and none is configured', 'unauthorized');
|
|
217
|
+
authHeader = basic;
|
|
218
|
+
r = await request(url, opts);
|
|
219
|
+
if (r.status === 401) throw new RegistryError('the registry rejected the credentials', 'unauthorized');
|
|
220
|
+
return r;
|
|
221
|
+
}
|
|
222
|
+
const m = /^Bearer\s+(.*)$/i.exec(r.challenge);
|
|
223
|
+
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');
|
|
224
|
+
const params = Object.fromEntries([...m[1].matchAll(/(\w+)="([^"]*)"/g)].map((x) => [x[1], x[2]]));
|
|
225
|
+
if (!params.realm) throw new RegistryError('the authentication challenge named no token endpoint', 'unauthorized');
|
|
226
|
+
const tokenUrl = new URL(params.realm);
|
|
227
|
+
if (params.service) tokenUrl.searchParams.set('service', params.service);
|
|
228
|
+
tokenUrl.searchParams.set('scope', params.scope || `repository:${ref.repository}:pull`);
|
|
229
|
+
if (!allowedUrl(tokenUrl.toString())) throw new RegistryError('refusing a non-HTTPS token endpoint', 'insecure_transport');
|
|
230
|
+
// 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
|
|
231
|
+
const headers = { 'user-agent': 'agentic-security-image-scan', ...(basic ? { authorization: basic } : {}) };
|
|
232
|
+
let tr;
|
|
233
|
+
try { tr = await fetchImpl(tokenUrl.toString(), { headers, redirect: 'error' }); } catch { throw new RegistryError('the token request failed', 'network'); }
|
|
234
|
+
if (!tr.ok) throw new RegistryError(`the token endpoint answered ${tr.status}`, 'unauthorized');
|
|
235
|
+
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'); }
|
|
236
|
+
const token = tj.token ?? tj.access_token;
|
|
237
|
+
if (typeof token !== 'string' || !token) throw new RegistryError('the token endpoint returned no token', 'unauthorized');
|
|
238
|
+
authHeader = `Bearer ${token}`;
|
|
239
|
+
r = await request(url, opts);
|
|
240
|
+
if (r.status === 401) throw new RegistryError('the registry rejected the token', 'unauthorized');
|
|
241
|
+
return r;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const base = `${origin}/v2/${ref.repository}`;
|
|
245
|
+
let m = await authed(`${base}/manifests/${ref.reference}`, { accept: MANIFEST_ACCEPT, maxBytes: L.maxManifestBytes, ...(ref.isDigest ? { expectDigest: ref.reference } : {}) });
|
|
246
|
+
let manifestBytes = m.body;
|
|
247
|
+
const topDigest = `sha256:${sha256Hex(m.body)}`;
|
|
248
|
+
const headerDigest = m.headers.get('docker-content-digest');
|
|
249
|
+
if (headerDigest && headerDigest !== `sha256:${sha256Hex(manifestBytes)}`) throw new RegistryError('the manifest does not match the digest the registry reported for it', 'digest_mismatch');
|
|
250
|
+
let doc = parseJson(manifestBytes, 'manifest');
|
|
251
|
+
let mediaType = doc.mediaType ?? m.headers.get('content-type')?.split(';')[0];
|
|
252
|
+
let selected = null;
|
|
253
|
+
if (INDEX_TYPES.has(mediaType) || Array.isArray(doc.manifests)) {
|
|
254
|
+
const [os_, arch] = platform.split('/');
|
|
255
|
+
const entries = (doc.manifests ?? []).filter((e) => e?.platform && e.platform.os === os_ && e.platform.architecture === arch && DIGEST_RE.test(String(e.digest)));
|
|
256
|
+
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');
|
|
257
|
+
selected = entries[0];
|
|
258
|
+
m = await authed(`${base}/manifests/${selected.digest}`, { accept: MANIFEST_ACCEPT, maxBytes: L.maxManifestBytes, expectDigest: selected.digest });
|
|
259
|
+
manifestBytes = m.body; doc = parseJson(manifestBytes, 'platform manifest'); mediaType = doc.mediaType ?? selected.mediaType;
|
|
260
|
+
}
|
|
261
|
+
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');
|
|
262
|
+
if (!doc.config || !DIGEST_RE.test(String(doc.config.digest)) || !Array.isArray(doc.layers)) throw new RegistryError('the manifest is malformed', 'malformed_manifest');
|
|
263
|
+
if (doc.layers.length > L.maxLayers) throw new RegistryError(`the image has more than ${L.maxLayers} layers`, 'too_large');
|
|
264
|
+
for (const l of doc.layers) {
|
|
265
|
+
if (!DIGEST_RE.test(String(l.digest))) throw new RegistryError('a layer has no sha256 digest', 'malformed_manifest');
|
|
266
|
+
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');
|
|
267
|
+
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');
|
|
268
|
+
}
|
|
269
|
+
const blob = async (desc, cap) => {
|
|
270
|
+
if (Number.isInteger(desc.size) && desc.size > cap) throw new RegistryError('a blob is larger than its limit', 'too_large');
|
|
271
|
+
const r = await authed(`${base}/blobs/${desc.digest}`, { maxBytes: cap, expectDigest: desc.digest });
|
|
272
|
+
if (Number.isInteger(desc.size) && r.body.length !== desc.size) throw new RegistryError('a blob is not the size its manifest declared', 'digest_mismatch');
|
|
273
|
+
return r.body;
|
|
274
|
+
};
|
|
275
|
+
const config = await blob(doc.config, L.maxConfigBytes);
|
|
276
|
+
const layers = [];
|
|
277
|
+
for (const l of doc.layers) layers.push(await blob(l, L.maxLayerBytes));
|
|
278
|
+
const built = buildOciArchive({ manifest: manifestBytes, config, layers, mediaType: MANIFEST_TYPES.has(mediaType) ? mediaType : 'application/vnd.oci.image.manifest.v1+json' });
|
|
279
|
+
return {
|
|
280
|
+
tar: built.tar, manifestDigest: built.manifestDigest,
|
|
281
|
+
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 },
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parseJson(buf, what) {
|
|
286
|
+
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'); }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
/***/ })
|
|
291
|
+
|
|
292
|
+
};
|