@zanii/blackbox 0.2.0 → 0.3.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/dist/a2a/index.d.ts +50 -0
- package/dist/a2a/index.js +202 -0
- package/dist/analysis/taxonomy.d.ts +18 -0
- package/dist/analysis/taxonomy.js +65 -0
- package/dist/archive/index.d.ts +39 -0
- package/dist/archive/index.js +96 -0
- package/dist/bom/index.d.ts +14 -0
- package/dist/bom/index.js +132 -0
- package/dist/compliance/art12.d.ts +35 -0
- package/dist/compliance/art12.js +163 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.js +8 -1
- package/dist/ocsf/index.d.ts +1 -1
- package/dist/ocsf/index.js +36 -3
- package/dist/otlp/index.d.ts +8 -1
- package/dist/otlp/index.js +231 -1
- package/dist/policy/index.d.ts +20 -3
- package/dist/policy/index.js +39 -4
- package/dist/session/index.d.ts +18 -0
- package/dist/session/index.js +46 -0
- package/dist/timestamp/index.d.ts +24 -0
- package/dist/timestamp/index.js +274 -0
- package/dist/transparency/index.d.ts +188 -0
- package/dist/transparency/index.js +712 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type Json } from "../verify/envelope.ts";
|
|
2
|
+
type Meta = {
|
|
3
|
+
[key: string]: Json;
|
|
4
|
+
};
|
|
5
|
+
/** The methods whose answer is an SSE stream. */
|
|
6
|
+
export declare const A2A_STREAMING: Set<string>;
|
|
7
|
+
/** The v1.0 name of an A2A method, either name set, or undefined for one A2A doesn't define. */
|
|
8
|
+
export declare function a2aMethod(method: string): string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* §2: a request's meta. `version` and `extensions` are the A2A-Version and A2A-Extensions headers
|
|
11
|
+
* (or the A2A-Version query parameter); an empty version means 0.3.
|
|
12
|
+
*/
|
|
13
|
+
export declare function a2aRequestMeta(body: Uint8Array, headers?: {
|
|
14
|
+
version?: string | null;
|
|
15
|
+
extensions?: string | null;
|
|
16
|
+
}): Meta;
|
|
17
|
+
/** §2: an answer's meta: a JSON body, or (for a stream) one SSE event's `data:` lines. */
|
|
18
|
+
export declare function a2aResponseMeta(bytes: Uint8Array, contentType: string | undefined): Meta;
|
|
19
|
+
/** `sha256:<hex>` of the card's JCS form without `signatures`: what `agent.card_digest` names. */
|
|
20
|
+
export declare function agentCardDigest(card: Record<string, unknown>): string;
|
|
21
|
+
/** What an Agent Card's JWS signs: BASE64URL(JCS(card without signatures)). */
|
|
22
|
+
export declare function agentCardPayload(card: Record<string, unknown>): string;
|
|
23
|
+
export interface AgentCardReport {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
digest: string | null;
|
|
26
|
+
/** Each signature's kid and alg, and whether it verifies under the keys given (null: no such key). */
|
|
27
|
+
signatures: Array<{
|
|
28
|
+
kid: string | null;
|
|
29
|
+
alg: string | null;
|
|
30
|
+
ok: boolean | null;
|
|
31
|
+
}>;
|
|
32
|
+
problems: string[];
|
|
33
|
+
}
|
|
34
|
+
type Jwk = {
|
|
35
|
+
kty?: string;
|
|
36
|
+
crv?: string;
|
|
37
|
+
kid?: string;
|
|
38
|
+
x?: string;
|
|
39
|
+
y?: string;
|
|
40
|
+
n?: string;
|
|
41
|
+
e?: string;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* §3: checks an Agent Card's signatures against trusted JWKs (by kid): each is a JWS over
|
|
45
|
+
* BASE64URL(protected) "." BASE64URL(JCS(card without signatures)), ES256, RS256 or EdDSA. The card
|
|
46
|
+
* is canonicalised as received (A2A's proto default-dropping can't be known from JSON). `ok` when at
|
|
47
|
+
* least one signature verifies.
|
|
48
|
+
*/
|
|
49
|
+
export declare function verifyAgentCard(card: unknown, keys: readonly Jwk[]): AgentCardReport;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// A2A v1.0 capture (spec/a2a.md): what the gateway records of an agent-to-agent JSON-RPC call (both
|
|
2
|
+
// the v1.0 PascalCase methods and the v0.3 names), of each answer or stream event, and the check of
|
|
3
|
+
// an Agent Card's JWS signatures (A2A §8.4: detached, over the JCS card without `signatures`).
|
|
4
|
+
// Mirrors sdks/python/src/zanii_blackbox/a2a.py.
|
|
5
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
6
|
+
import { canonicalBytes } from "@zanii/core";
|
|
7
|
+
import { assertSafeIntegers } from "../verify/envelope.js";
|
|
8
|
+
/** v0.3 method names → v1.0 (A2A §9.4). */
|
|
9
|
+
const LEGACY = {
|
|
10
|
+
"message/send": "SendMessage",
|
|
11
|
+
"message/stream": "SendStreamingMessage",
|
|
12
|
+
"tasks/get": "GetTask",
|
|
13
|
+
"tasks/list": "ListTasks",
|
|
14
|
+
"tasks/cancel": "CancelTask",
|
|
15
|
+
"tasks/resubscribe": "SubscribeToTask",
|
|
16
|
+
"tasks/pushNotificationConfig/set": "CreateTaskPushNotificationConfig",
|
|
17
|
+
"tasks/pushNotificationConfig/get": "GetTaskPushNotificationConfig",
|
|
18
|
+
"tasks/pushNotificationConfig/list": "ListTaskPushNotificationConfigs",
|
|
19
|
+
"tasks/pushNotificationConfig/delete": "DeleteTaskPushNotificationConfig",
|
|
20
|
+
"agent/getAuthenticatedExtendedCard": "GetExtendedAgentCard",
|
|
21
|
+
};
|
|
22
|
+
const V1 = new Set(Object.values(LEGACY));
|
|
23
|
+
/** The methods whose answer is an SSE stream. */
|
|
24
|
+
export const A2A_STREAMING = new Set(["SendStreamingMessage", "SubscribeToTask"]);
|
|
25
|
+
/** The v1.0 name of an A2A method, either name set, or undefined for one A2A doesn't define. */
|
|
26
|
+
export function a2aMethod(method) {
|
|
27
|
+
return V1.has(method) ? method : LEGACY[method];
|
|
28
|
+
}
|
|
29
|
+
const str = (v, max) => (typeof v === "string" && v.length <= max ? v : undefined);
|
|
30
|
+
const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
|
|
31
|
+
const rpcId = (id) => typeof id === "string" && id.length <= 256
|
|
32
|
+
? id
|
|
33
|
+
: typeof id === "number" && Number.isSafeInteger(id)
|
|
34
|
+
? id
|
|
35
|
+
: undefined;
|
|
36
|
+
const put = (meta, key, value) => {
|
|
37
|
+
if (value !== undefined)
|
|
38
|
+
meta[key] = value;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* §2: a request's meta. `version` and `extensions` are the A2A-Version and A2A-Extensions headers
|
|
42
|
+
* (or the A2A-Version query parameter); an empty version means 0.3.
|
|
43
|
+
*/
|
|
44
|
+
export function a2aRequestMeta(body, headers = {}) {
|
|
45
|
+
let rpc = {};
|
|
46
|
+
try {
|
|
47
|
+
rpc = obj(JSON.parse(Buffer.from(body).toString("utf8")));
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
const meta = { a2a_version: str(headers.version, 16) || "0.3" };
|
|
51
|
+
const ext = (headers.extensions ?? "")
|
|
52
|
+
.split(",")
|
|
53
|
+
.map((e) => e.trim())
|
|
54
|
+
.filter((e) => e !== "" && e.length <= 256)
|
|
55
|
+
.slice(0, 10);
|
|
56
|
+
if (ext.length > 0)
|
|
57
|
+
meta.extensions = ext;
|
|
58
|
+
const method = str(rpc.method, 128);
|
|
59
|
+
put(meta, "method", method);
|
|
60
|
+
const canonical = method === undefined ? undefined : a2aMethod(method);
|
|
61
|
+
put(meta, "a2a_method", canonical);
|
|
62
|
+
if (method !== undefined && canonical !== undefined && canonical !== method)
|
|
63
|
+
meta.legacy = true;
|
|
64
|
+
put(meta, "rpc_id", rpcId(rpc.id));
|
|
65
|
+
const p = obj(rpc.params);
|
|
66
|
+
put(meta, "tenant", str(p.tenant, 128));
|
|
67
|
+
if (canonical === "SendMessage" || canonical === "SendStreamingMessage") {
|
|
68
|
+
const m = obj(p.message);
|
|
69
|
+
put(meta, "message_id", str(m.messageId, 128));
|
|
70
|
+
put(meta, "context_id", str(m.contextId, 128));
|
|
71
|
+
put(meta, "task_id", str(m.taskId, 128));
|
|
72
|
+
put(meta, "role", str(m.role, 32));
|
|
73
|
+
}
|
|
74
|
+
else if (canonical !== undefined && /Task$/.test(canonical))
|
|
75
|
+
put(meta, "task_id", str(p.id, 128));
|
|
76
|
+
return meta;
|
|
77
|
+
}
|
|
78
|
+
const LEGACY_KINDS = {
|
|
79
|
+
task: "task",
|
|
80
|
+
message: "message",
|
|
81
|
+
"status-update": "statusUpdate",
|
|
82
|
+
"artifact-update": "artifactUpdate",
|
|
83
|
+
};
|
|
84
|
+
const STATE = /^TASK_STATE_[A-Z_]{1,32}$/;
|
|
85
|
+
const legacyState = (s) => typeof s === "string" && /^[a-z-]{1,32}$/.test(s)
|
|
86
|
+
? `TASK_STATE_${s.replaceAll("-", "_").toUpperCase()}`
|
|
87
|
+
: undefined;
|
|
88
|
+
/** One JSON-RPC response (or SSE event's data), as meta. */
|
|
89
|
+
function responseOf(rpc) {
|
|
90
|
+
const meta = {};
|
|
91
|
+
put(meta, "rpc_id", rpcId(rpc.id));
|
|
92
|
+
const code = obj(rpc.error).code;
|
|
93
|
+
if (typeof code === "number" && Number.isSafeInteger(code))
|
|
94
|
+
meta.rpc_error = code;
|
|
95
|
+
const result = obj(rpc.result);
|
|
96
|
+
// v1.0: a oneof by key; v0.3: `kind`
|
|
97
|
+
let type = ["task", "message", "statusUpdate", "artifactUpdate"].find((k) => k in result);
|
|
98
|
+
let inner = type ? obj(result[type]) : {};
|
|
99
|
+
if (!type && typeof result.kind === "string" && result.kind in LEGACY_KINDS) {
|
|
100
|
+
type = LEGACY_KINDS[result.kind];
|
|
101
|
+
inner = result;
|
|
102
|
+
}
|
|
103
|
+
if (!type)
|
|
104
|
+
return meta;
|
|
105
|
+
meta.result_type = type;
|
|
106
|
+
put(meta, "task_id", str(type === "task" ? inner.id : inner.taskId, 128));
|
|
107
|
+
put(meta, "context_id", str(inner.contextId, 128));
|
|
108
|
+
const state = obj(inner.status).state;
|
|
109
|
+
put(meta, "task_state", typeof state === "string" && STATE.test(state) ? state : legacyState(state));
|
|
110
|
+
if (type === "artifactUpdate")
|
|
111
|
+
put(meta, "artifact_id", str(obj(inner.artifact).artifactId, 128));
|
|
112
|
+
return meta;
|
|
113
|
+
}
|
|
114
|
+
/** §2: an answer's meta: a JSON body, or (for a stream) one SSE event's `data:` lines. */
|
|
115
|
+
export function a2aResponseMeta(bytes, contentType) {
|
|
116
|
+
let text = Buffer.from(bytes).toString("utf8");
|
|
117
|
+
if (contentType?.includes("text/event-stream"))
|
|
118
|
+
text = text
|
|
119
|
+
.split(/\r?\n/)
|
|
120
|
+
.filter((l) => l.startsWith("data:"))
|
|
121
|
+
.map((l) => l.slice(5).trim())
|
|
122
|
+
.join("\n");
|
|
123
|
+
try {
|
|
124
|
+
return responseOf(obj(JSON.parse(text)));
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// ---------------------------------------------------------------- Agent Cards (A2A §8.4)
|
|
131
|
+
const b64u = (b) => Buffer.from(b).toString("base64url");
|
|
132
|
+
/** `sha256:<hex>` of the card's JCS form without `signatures`: what `agent.card_digest` names. */
|
|
133
|
+
export function agentCardDigest(card) {
|
|
134
|
+
const { signatures: _, ...rest } = card;
|
|
135
|
+
assertSafeIntegers(rest); // as Python's canonical JSON does
|
|
136
|
+
return `sha256:${createHash("sha256")
|
|
137
|
+
.update(canonicalBytes(rest))
|
|
138
|
+
.digest("hex")}`;
|
|
139
|
+
}
|
|
140
|
+
/** What an Agent Card's JWS signs: BASE64URL(JCS(card without signatures)). */
|
|
141
|
+
export function agentCardPayload(card) {
|
|
142
|
+
const { signatures: _, ...rest } = card;
|
|
143
|
+
assertSafeIntegers(rest);
|
|
144
|
+
return b64u(canonicalBytes(rest));
|
|
145
|
+
}
|
|
146
|
+
function verifyJws(alg, jwk, input, sig) {
|
|
147
|
+
try {
|
|
148
|
+
const key = createPublicKey({ key: jwk, format: "jwk" });
|
|
149
|
+
if (alg === "ES256" && jwk.kty === "EC" && jwk.crv === "P-256")
|
|
150
|
+
return verify("sha256", input, { key, dsaEncoding: "ieee-p1363" }, sig);
|
|
151
|
+
if (alg === "RS256" && jwk.kty === "RSA")
|
|
152
|
+
return verify("sha256", input, key, sig);
|
|
153
|
+
if (alg === "EdDSA" && jwk.kty === "OKP" && jwk.crv === "Ed25519")
|
|
154
|
+
return verify(null, input, key, sig);
|
|
155
|
+
}
|
|
156
|
+
catch { }
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* §3: checks an Agent Card's signatures against trusted JWKs (by kid): each is a JWS over
|
|
161
|
+
* BASE64URL(protected) "." BASE64URL(JCS(card without signatures)), ES256, RS256 or EdDSA. The card
|
|
162
|
+
* is canonicalised as received (A2A's proto default-dropping can't be known from JSON). `ok` when at
|
|
163
|
+
* least one signature verifies.
|
|
164
|
+
*/
|
|
165
|
+
export function verifyAgentCard(card, keys) {
|
|
166
|
+
const report = { ok: false, digest: null, signatures: [], problems: [] };
|
|
167
|
+
const c = obj(card);
|
|
168
|
+
if (Object.keys(c).length === 0)
|
|
169
|
+
return { ...report, problems: ["not an Agent Card"] };
|
|
170
|
+
let payload;
|
|
171
|
+
try {
|
|
172
|
+
report.digest = agentCardDigest(c);
|
|
173
|
+
payload = agentCardPayload(c);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return { ...report, problems: ["the card can't be canonicalised (non-integer numbers?)"] };
|
|
177
|
+
}
|
|
178
|
+
const sigs = Array.isArray(c.signatures) ? c.signatures.slice(0, 10) : [];
|
|
179
|
+
if (sigs.length === 0)
|
|
180
|
+
report.problems.push("the card isn't signed");
|
|
181
|
+
for (const s of sigs) {
|
|
182
|
+
const sig = obj(s);
|
|
183
|
+
let header = {};
|
|
184
|
+
try {
|
|
185
|
+
header = obj(JSON.parse(Buffer.from(String(sig.protected), "base64url").toString("utf8")));
|
|
186
|
+
}
|
|
187
|
+
catch { }
|
|
188
|
+
const kid = str(header.kid, 256) ?? null;
|
|
189
|
+
const alg = str(header.alg, 16) ?? null;
|
|
190
|
+
const jwk = keys.find((k) => k.kid !== undefined && k.kid === kid);
|
|
191
|
+
const ok = jwk && alg && typeof sig.protected === "string" && typeof sig.signature === "string"
|
|
192
|
+
? verifyJws(alg, jwk, Buffer.from(`${sig.protected}.${payload}`), Buffer.from(sig.signature, "base64url"))
|
|
193
|
+
: null;
|
|
194
|
+
report.signatures.push({ kid, alg, ok });
|
|
195
|
+
}
|
|
196
|
+
report.ok = report.signatures.some((s) => s.ok === true);
|
|
197
|
+
if (sigs.length > 0 && !report.ok)
|
|
198
|
+
report.problems.push(report.signatures.some((s) => s.ok === false)
|
|
199
|
+
? "a signature doesn't verify"
|
|
200
|
+
: "no signature is by a trusted key");
|
|
201
|
+
return report;
|
|
202
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const OWASP_ASI: Readonly<Record<string, string>>;
|
|
2
|
+
export declare const MITRE_ATLAS: Readonly<Record<string, string>>;
|
|
3
|
+
export declare const TAXONOMY: Readonly<Record<string, {
|
|
4
|
+
owasp?: readonly string[];
|
|
5
|
+
atlas?: readonly string[];
|
|
6
|
+
}>>;
|
|
7
|
+
export interface Taxonomy {
|
|
8
|
+
owasp: Array<{
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
}>;
|
|
12
|
+
atlas: Array<{
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
/** The OWASP ASI and MITRE ATLAS entries a finding code maps to; empty lists when none fits. */
|
|
18
|
+
export declare function taxonomyOf(code: string): Taxonomy;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Threat taxonomy per finding code (spec/findings.md §7, spec/faults/taxonomy-v1.json): OWASP Top 10
|
|
2
|
+
// for Agentic Applications 2026 and MITRE ATLAS technique ids. A test checks this table matches the
|
|
3
|
+
// file exactly. Mirrors analysis/taxonomy.py.
|
|
4
|
+
export const OWASP_ASI = {
|
|
5
|
+
ASI01: "Agent Goal Hijack",
|
|
6
|
+
ASI02: "Tool Misuse & Exploitation",
|
|
7
|
+
ASI03: "Identity & Privilege Abuse",
|
|
8
|
+
ASI04: "Agentic Supply Chain Vulnerabilities",
|
|
9
|
+
ASI05: "Unexpected Code Execution (RCE)",
|
|
10
|
+
ASI06: "Memory & Context Poisoning",
|
|
11
|
+
ASI07: "Insecure Inter-Agent Communication",
|
|
12
|
+
ASI08: "Cascading Failures",
|
|
13
|
+
ASI09: "Human-Agent Trust Exploitation",
|
|
14
|
+
ASI10: "Rogue Agents",
|
|
15
|
+
};
|
|
16
|
+
export const MITRE_ATLAS = {
|
|
17
|
+
"AML.T0034": "Cost Harvesting",
|
|
18
|
+
"AML.T0034.000": "Cost Harvesting: Excessive Queries",
|
|
19
|
+
"AML.T0034.001": "Cost Harvesting: Resource-Intensive Queries",
|
|
20
|
+
"AML.T0034.002": "Cost Harvesting: Agentic Resource Consumption",
|
|
21
|
+
"AML.T0053": "AI Agent Tool Invocation",
|
|
22
|
+
"AML.T0057": "LLM Data Leakage",
|
|
23
|
+
"AML.T0080.000": "AI Agent Context Poisoning: Memory",
|
|
24
|
+
"AML.T0086": "Exfiltration via AI Agent Tool Invocation",
|
|
25
|
+
"AML.T0091.000": "Application Access Token",
|
|
26
|
+
"AML.T0099": "AI Agent Tool Data Poisoning",
|
|
27
|
+
"AML.T0110.002": "AI Agent Tool Poisoning: Runtime Response",
|
|
28
|
+
};
|
|
29
|
+
export const TAXONOMY = {
|
|
30
|
+
ABNORMAL_RUN: { owasp: ["ASI10"] },
|
|
31
|
+
ALTERED: { owasp: ["ASI10"] },
|
|
32
|
+
AUTOMATION_SURPRISE: { owasp: ["ASI10"] },
|
|
33
|
+
BYPASS: { owasp: ["ASI10"] },
|
|
34
|
+
CALL_LIMIT: { atlas: ["AML.T0034", "AML.T0034.000"] },
|
|
35
|
+
CLAIM_UNVERIFIED: { owasp: ["ASI09"] },
|
|
36
|
+
D1_REPEAT_CALL: { atlas: ["AML.T0034.002"] },
|
|
37
|
+
D2_ACTION_LOOP: { atlas: ["AML.T0034.002"] },
|
|
38
|
+
D4_CONTEXT_BLOAT: { atlas: ["AML.T0034.001"] },
|
|
39
|
+
D6_SPEND: { atlas: ["AML.T0034", "AML.T0034.002"] },
|
|
40
|
+
DATA_CANARY_TRIPPED: { owasp: ["ASI02"], atlas: ["AML.T0086", "AML.T0057"] },
|
|
41
|
+
DATA_TO_UNAPPROVED_DESTINATION: { owasp: ["ASI02"], atlas: ["AML.T0086"] },
|
|
42
|
+
EXTRA_LOCAL: { owasp: ["ASI10"] },
|
|
43
|
+
FALSE_CLAIM: { owasp: ["ASI09", "ASI10"] },
|
|
44
|
+
FALSE_SUCCESS: { owasp: ["ASI09", "ASI10"] },
|
|
45
|
+
MISSING_LOCAL: { owasp: ["ASI10"] },
|
|
46
|
+
PLAN_DEVIATION: { owasp: ["ASI01", "ASI10"] },
|
|
47
|
+
PLAN_VIOLATION: { owasp: ["ASI02", "ASI10"], atlas: ["AML.T0053"] },
|
|
48
|
+
POLICY_AUDIT: { owasp: ["ASI02"], atlas: ["AML.T0053"] },
|
|
49
|
+
POLICY_DENIED: { owasp: ["ASI02"], atlas: ["AML.T0053"] },
|
|
50
|
+
POLICY_VIOLATION: { owasp: ["ASI02"], atlas: ["AML.T0053"] },
|
|
51
|
+
RETRY_STORM: { owasp: ["ASI08"] },
|
|
52
|
+
REVOKED_MEMORY_READ: { owasp: ["ASI06"], atlas: ["AML.T0080.000"] },
|
|
53
|
+
STERILE_VIOLATION: { owasp: ["ASI02", "ASI10"], atlas: ["AML.T0053"] },
|
|
54
|
+
TOKEN_MISUSE: { owasp: ["ASI03"], atlas: ["AML.T0091.000", "AML.T0086"] },
|
|
55
|
+
TOOL_OUTPUT_MISMATCH: { owasp: ["ASI04", "ASI06"], atlas: ["AML.T0110.002", "AML.T0099"] },
|
|
56
|
+
TOOL_WITNESS_INVALID: { owasp: ["ASI04"], atlas: ["AML.T0110.002"] },
|
|
57
|
+
};
|
|
58
|
+
/** The OWASP ASI and MITRE ATLAS entries a finding code maps to; empty lists when none fits. */
|
|
59
|
+
export function taxonomyOf(code) {
|
|
60
|
+
const t = TAXONOMY[code];
|
|
61
|
+
return {
|
|
62
|
+
owasp: (t?.owasp ?? []).map((id) => ({ id, title: OWASP_ASI[id] ?? id })),
|
|
63
|
+
atlas: (t?.atlas ?? []).map((id) => ({ id, name: MITRE_ATLAS[id] ?? id })),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface ArchiveFile {
|
|
2
|
+
name: string;
|
|
3
|
+
sha256: string;
|
|
4
|
+
bytes: number;
|
|
5
|
+
}
|
|
6
|
+
export interface ArchiveManifest {
|
|
7
|
+
v: 1;
|
|
8
|
+
id: string;
|
|
9
|
+
created_at: string;
|
|
10
|
+
files: ArchiveFile[];
|
|
11
|
+
/** RFC 9162 root over leafHash(`<name> <sha256>`), in name order. */
|
|
12
|
+
root: string;
|
|
13
|
+
/** The log checkpoint (a signed note) when it was made, or null. */
|
|
14
|
+
checkpoint: string | null;
|
|
15
|
+
/** RFC 3161 TimeStampResps, base64, oldest first. */
|
|
16
|
+
timestamps: string[];
|
|
17
|
+
}
|
|
18
|
+
/** §2: one file's entry. Names are plain: 1-128 of [A-Za-z0-9._-]. */
|
|
19
|
+
export declare function archiveEntry(name: string, data: Uint8Array): ArchiveFile;
|
|
20
|
+
/** §2: the manifest, without timestamps, for these entries (unique names). */
|
|
21
|
+
export declare function archiveManifest(id: string, createdAt: string, entries: readonly ArchiveFile[], checkpoint: string | null): ArchiveManifest;
|
|
22
|
+
/** §3: what the `n`th timestamp stamps: SHA-256 of the manifest's core, then of that and the previous token. */
|
|
23
|
+
export declare function archiveDigest(manifest: ArchiveManifest, n: number): Uint8Array;
|
|
24
|
+
export interface ArchiveReport {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
/** Each file's hash and size match; null when no files were given. */
|
|
27
|
+
files: boolean | null;
|
|
28
|
+
root: boolean;
|
|
29
|
+
/** Each timestamp's time, oldest first, while the chain holds. */
|
|
30
|
+
timestamps: string[];
|
|
31
|
+
problems: string[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* §4: checks a manifest offline: its root, each file given (by name), and the timestamp chain,
|
|
35
|
+
* each token against the digest it must stamp and `tsaRoots`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function verifyArchive(manifest: ArchiveManifest, files: ReadonlyMap<string, Uint8Array> | null, options?: {
|
|
38
|
+
tsaRoots?: readonly string[];
|
|
39
|
+
}): ArchiveReport;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Cold archives (spec/archive.md): a set of closed sessions' records, kept for years as plain JSONL
|
|
2
|
+
// files with a manifest: each file's SHA-256, an RFC 9162 Merkle root over them, the log checkpoint
|
|
3
|
+
// they sit under, and a chain of RFC 3161 timestamps. Each renewal stamps the manifest and the
|
|
4
|
+
// previous token, so the evidence outlives the TSA's certificate and its algorithms (the RFC 4998
|
|
5
|
+
// idea). Mirrors sdks/python/src/zanii_blackbox/archive.py.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { canonicalBytes } from "@zanii/core";
|
|
8
|
+
import { verifyTimestamp } from "../timestamp/index.js";
|
|
9
|
+
import { leafHash, merkleRoot } from "../transparency/index.js";
|
|
10
|
+
const sha256 = (...parts) => {
|
|
11
|
+
const h = createHash("sha256");
|
|
12
|
+
for (const p of parts)
|
|
13
|
+
h.update(p);
|
|
14
|
+
return new Uint8Array(h.digest());
|
|
15
|
+
};
|
|
16
|
+
const hex = (b) => Buffer.from(b).toString("hex");
|
|
17
|
+
const NAME = /^[A-Za-z0-9._-]{1,128}$/;
|
|
18
|
+
/** §2: one file's entry. Names are plain: 1-128 of [A-Za-z0-9._-]. */
|
|
19
|
+
export function archiveEntry(name, data) {
|
|
20
|
+
if (!NAME.test(name))
|
|
21
|
+
throw new Error(`an archive file name must be 1-128 of [A-Za-z0-9._-]: ${name}`);
|
|
22
|
+
return { name, sha256: hex(sha256(data)), bytes: data.length };
|
|
23
|
+
}
|
|
24
|
+
/** §2: the manifest, without timestamps, for these entries (unique names). */
|
|
25
|
+
export function archiveManifest(id, createdAt, entries, checkpoint) {
|
|
26
|
+
const names = new Set(entries.map((f) => f.name));
|
|
27
|
+
if (names.size !== entries.length)
|
|
28
|
+
throw new Error("archive file names must be unique");
|
|
29
|
+
const listed = [...entries]
|
|
30
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
31
|
+
.map((f) => ({ name: f.name, sha256: f.sha256, bytes: f.bytes }));
|
|
32
|
+
return {
|
|
33
|
+
v: 1,
|
|
34
|
+
id,
|
|
35
|
+
created_at: createdAt,
|
|
36
|
+
files: listed,
|
|
37
|
+
root: hex(merkleRoot(listed.map((f) => leafHash(Buffer.from(`${f.name} ${f.sha256}`, "utf8"))))),
|
|
38
|
+
checkpoint,
|
|
39
|
+
timestamps: [],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** §3: what the `n`th timestamp stamps: SHA-256 of the manifest's core, then of that and the previous token. */
|
|
43
|
+
export function archiveDigest(manifest, n) {
|
|
44
|
+
const { timestamps, ...core } = manifest;
|
|
45
|
+
const first = sha256(canonicalBytes(core));
|
|
46
|
+
if (n === 0)
|
|
47
|
+
return first;
|
|
48
|
+
const previous = timestamps[n - 1];
|
|
49
|
+
if (previous === undefined)
|
|
50
|
+
throw new Error(`the archive has no timestamp ${n - 1}`);
|
|
51
|
+
return sha256(first, Buffer.from(previous, "base64"));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* §4: checks a manifest offline: its root, each file given (by name), and the timestamp chain,
|
|
55
|
+
* each token against the digest it must stamp and `tsaRoots`.
|
|
56
|
+
*/
|
|
57
|
+
export function verifyArchive(manifest, files, options = {}) {
|
|
58
|
+
const report = {
|
|
59
|
+
ok: false,
|
|
60
|
+
files: null,
|
|
61
|
+
root: false,
|
|
62
|
+
timestamps: [],
|
|
63
|
+
problems: [],
|
|
64
|
+
};
|
|
65
|
+
const leaves = manifest.files.map((f) => leafHash(Buffer.from(`${f.name} ${f.sha256}`, "utf8")));
|
|
66
|
+
report.root = leaves.length > 0 && hex(merkleRoot(leaves)) === manifest.root;
|
|
67
|
+
if (!report.root)
|
|
68
|
+
report.problems.push("the root isn't the files'");
|
|
69
|
+
if (files) {
|
|
70
|
+
const bad = manifest.files.filter((f) => {
|
|
71
|
+
const data = files.get(f.name);
|
|
72
|
+
return !data || data.length !== f.bytes || hex(sha256(data)) !== f.sha256;
|
|
73
|
+
});
|
|
74
|
+
report.files = bad.length === 0;
|
|
75
|
+
for (const f of bad)
|
|
76
|
+
report.problems.push(`${f.name} is missing or changed`);
|
|
77
|
+
}
|
|
78
|
+
let last = Number.NEGATIVE_INFINITY;
|
|
79
|
+
for (let i = 0; i < manifest.timestamps.length; i++) {
|
|
80
|
+
const t = verifyTimestamp(new Uint8Array(Buffer.from(manifest.timestamps[i], "base64")), {
|
|
81
|
+
digest: archiveDigest(manifest, i),
|
|
82
|
+
...(options.tsaRoots ? { roots: options.tsaRoots } : {}),
|
|
83
|
+
});
|
|
84
|
+
const at = t.gen_time ? Date.parse(t.gen_time) : Number.NaN;
|
|
85
|
+
if (!t.ok || !t.gen_time || !(at >= last)) {
|
|
86
|
+
report.problems.push(`timestamp ${i} doesn't hold: ${t.problems.join("; ") || "out of order"}`);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
last = at;
|
|
90
|
+
report.timestamps.push(t.gen_time);
|
|
91
|
+
}
|
|
92
|
+
if (manifest.timestamps.length === 0)
|
|
93
|
+
report.problems.push("the archive has no timestamp");
|
|
94
|
+
report.ok = report.problems.length === 0;
|
|
95
|
+
return report;
|
|
96
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface AgentIdentity {
|
|
2
|
+
id: string;
|
|
3
|
+
version?: string;
|
|
4
|
+
/** A SPIFFE ID for the workload the agent runs as. */
|
|
5
|
+
workload?: string;
|
|
6
|
+
/** The user or agent it acts for. */
|
|
7
|
+
on_behalf_of?: string;
|
|
8
|
+
/** The SHA-256 of its A2A Agent Card, `sha256:<hex>`. */
|
|
9
|
+
card_digest?: string;
|
|
10
|
+
}
|
|
11
|
+
/** spec/bom.md §1: an error message, or undefined for a valid `agent`. */
|
|
12
|
+
export declare function checkAgent(value: unknown): string | undefined;
|
|
13
|
+
/** spec/bom.md §2: a CycloneDX 1.6 JSON ML-BOM of one session's record. */
|
|
14
|
+
export declare function sessionBom(lines: readonly string[]): Record<string, unknown>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Agent identity and the agent's bill of materials (spec/bom.md): the `agent` a session is opened
|
|
2
|
+
// with, and a CycloneDX 1.6 ML-BOM of what one session actually used (models, providers, tool
|
|
3
|
+
// servers), made from its record. Deterministic: the same record gives the same bytes.
|
|
4
|
+
// Mirrors sdks/python/src/zanii_blackbox/bom.py.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { hashLine } from "../verify/envelope.js";
|
|
7
|
+
const AGENT_KEYS = new Set(["id", "version", "workload", "on_behalf_of", "card_digest"]);
|
|
8
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
9
|
+
/** spec/bom.md §1: an error message, or undefined for a valid `agent`. */
|
|
10
|
+
export function checkAgent(value) {
|
|
11
|
+
if (!isObject(value))
|
|
12
|
+
return "agent must be an object";
|
|
13
|
+
const extra = Object.keys(value).find((k) => !AGENT_KEYS.has(k));
|
|
14
|
+
if (extra !== undefined)
|
|
15
|
+
return `agent.${extra} isn't a field`;
|
|
16
|
+
const { id, version, workload, on_behalf_of: onBehalfOf, card_digest: card } = value;
|
|
17
|
+
if (typeof id !== "string" || !/^[A-Za-z0-9._:@/-]{1,128}$/.test(id))
|
|
18
|
+
return "agent.id must be 1-128 of [A-Za-z0-9._:@/-]";
|
|
19
|
+
if (version !== undefined &&
|
|
20
|
+
(typeof version !== "string" || !/^[A-Za-z0-9._+-]{1,64}$/.test(version)))
|
|
21
|
+
return "agent.version must be 1-64 of [A-Za-z0-9._+-]";
|
|
22
|
+
if (workload !== undefined &&
|
|
23
|
+
(typeof workload !== "string" ||
|
|
24
|
+
!/^spiffe:\/\/[^\s/]+(\/\S*)?$/.test(workload) ||
|
|
25
|
+
workload.length > 2048))
|
|
26
|
+
return "agent.workload must be a SPIFFE ID (spiffe://trust-domain/path)";
|
|
27
|
+
if (onBehalfOf !== undefined &&
|
|
28
|
+
(typeof onBehalfOf !== "string" ||
|
|
29
|
+
onBehalfOf.length < 1 ||
|
|
30
|
+
onBehalfOf.length > 256 ||
|
|
31
|
+
[...onBehalfOf].some((c) => c < " " || c === "\u007f")))
|
|
32
|
+
return "agent.on_behalf_of must be 1-256 characters, no control characters";
|
|
33
|
+
if (card !== undefined && (typeof card !== "string" || !/^sha256:[0-9a-f]{64}$/.test(card)))
|
|
34
|
+
return "agent.card_digest must be sha256:<64 hex>";
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const props = (pairs) => pairs
|
|
38
|
+
.filter((p) => typeof p[1] === "string" || typeof p[1] === "number")
|
|
39
|
+
.map(([name, value]) => ({ name, value: String(value) }));
|
|
40
|
+
/** A UUID (version 8, RFC 9562) from the record's identity: the same record, the same serial. */
|
|
41
|
+
function serialOf(sessionId, count, head) {
|
|
42
|
+
const h = createHash("sha256").update(`${sessionId}\n${count}\n${head}`).digest();
|
|
43
|
+
h[6] = (h[6] & 0x0f) | 0x80;
|
|
44
|
+
h[8] = (h[8] & 0x3f) | 0x80;
|
|
45
|
+
const x = h.subarray(0, 16).toString("hex");
|
|
46
|
+
return `urn:uuid:${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20)}`;
|
|
47
|
+
}
|
|
48
|
+
/** spec/bom.md §2: a CycloneDX 1.6 JSON ML-BOM of one session's record. */
|
|
49
|
+
export function sessionBom(lines) {
|
|
50
|
+
const events = lines.map((l) => JSON.parse(l));
|
|
51
|
+
const first = events[0];
|
|
52
|
+
const last = lines.at(-1);
|
|
53
|
+
if (!first || last === undefined)
|
|
54
|
+
throw new Error("a BOM needs a record");
|
|
55
|
+
const sid = first.session_id;
|
|
56
|
+
const open = events.find((e) => e.kind === "session.open");
|
|
57
|
+
const agent = (isObject(open?.meta.agent) ? open.meta.agent : {});
|
|
58
|
+
const label = typeof open?.meta.label === "string" ? open.meta.label : undefined;
|
|
59
|
+
const providerOf = new Map();
|
|
60
|
+
const providers = new Map();
|
|
61
|
+
const models = new Map();
|
|
62
|
+
const servers = new Map();
|
|
63
|
+
for (const e of events) {
|
|
64
|
+
const m = e.meta;
|
|
65
|
+
if (e.kind === "llm.request" && typeof m.provider === "string") {
|
|
66
|
+
providerOf.set(e.seq, m.provider);
|
|
67
|
+
providers.set(m.provider, (providers.get(m.provider) ?? 0) + 1);
|
|
68
|
+
}
|
|
69
|
+
else if (e.kind === "llm.response" && typeof m.model === "string") {
|
|
70
|
+
const provider = providerOf.get(m.request_seq) ?? "unknown";
|
|
71
|
+
const key = `${provider}/${m.model}`;
|
|
72
|
+
const had = models.get(key) ?? { provider, model: m.model, calls: 0 };
|
|
73
|
+
had.calls++;
|
|
74
|
+
models.set(key, had);
|
|
75
|
+
}
|
|
76
|
+
else if (e.kind === "tool.call" && typeof m.server === "string") {
|
|
77
|
+
const tools = servers.get(m.server) ?? new Set();
|
|
78
|
+
if (typeof m.tool === "string")
|
|
79
|
+
tools.add(m.tool);
|
|
80
|
+
servers.set(m.server, tools);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const byKey = (m) => [...m.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
84
|
+
const components = byKey(models).map(([key, v]) => ({
|
|
85
|
+
type: "machine-learning-model",
|
|
86
|
+
"bom-ref": `model:${key}`,
|
|
87
|
+
name: v.model,
|
|
88
|
+
publisher: v.provider,
|
|
89
|
+
properties: props([["zanii:calls", v.calls]]),
|
|
90
|
+
}));
|
|
91
|
+
const services = [
|
|
92
|
+
...byKey(providers).map(([id, calls]) => ({
|
|
93
|
+
"bom-ref": `provider:${id}`,
|
|
94
|
+
name: id,
|
|
95
|
+
group: "llm-provider",
|
|
96
|
+
properties: props([["zanii:calls", calls]]),
|
|
97
|
+
})),
|
|
98
|
+
...byKey(servers).map(([id, tools]) => ({
|
|
99
|
+
"bom-ref": `tools:${id}`,
|
|
100
|
+
name: id,
|
|
101
|
+
group: "tool-server",
|
|
102
|
+
properties: [...tools].sort().map((t) => ({ name: "zanii:tool", value: t })),
|
|
103
|
+
})),
|
|
104
|
+
];
|
|
105
|
+
const refs = [...components.map((c) => c["bom-ref"]), ...services.map((s) => s["bom-ref"])];
|
|
106
|
+
return {
|
|
107
|
+
bomFormat: "CycloneDX",
|
|
108
|
+
specVersion: "1.6",
|
|
109
|
+
serialNumber: serialOf(sid, lines.length, hashLine(last)),
|
|
110
|
+
version: 1,
|
|
111
|
+
metadata: {
|
|
112
|
+
timestamp: events.at(-1).ts,
|
|
113
|
+
tools: { components: [{ type: "application", name: "zanii-blackbox" }] },
|
|
114
|
+
component: {
|
|
115
|
+
type: "application",
|
|
116
|
+
"bom-ref": "agent",
|
|
117
|
+
name: agent.id ?? label ?? sid,
|
|
118
|
+
...(agent.version ? { version: agent.version } : {}),
|
|
119
|
+
properties: props([
|
|
120
|
+
["zanii:session_id", sid],
|
|
121
|
+
["zanii:workload", agent.workload],
|
|
122
|
+
["zanii:on_behalf_of", agent.on_behalf_of],
|
|
123
|
+
["zanii:card_digest", agent.card_digest],
|
|
124
|
+
["zanii:record_head", hashLine(last)],
|
|
125
|
+
]),
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
components,
|
|
129
|
+
services,
|
|
130
|
+
dependencies: [{ ref: "agent", dependsOn: refs }],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
type Evidence = Record<string, number | string | boolean | null | string[] | Record<string, number>>;
|
|
2
|
+
export interface Art12Check {
|
|
3
|
+
id: string;
|
|
4
|
+
article: string;
|
|
5
|
+
requirement: string;
|
|
6
|
+
/** False: the paragraph covers only Annex III point 1(a) systems (remote biometric identification). */
|
|
7
|
+
applies: boolean;
|
|
8
|
+
/** True or false where the record can show it; null where a person must judge the evidence. */
|
|
9
|
+
ok: boolean | null;
|
|
10
|
+
evidence: Evidence;
|
|
11
|
+
}
|
|
12
|
+
export interface Art12Report {
|
|
13
|
+
session_id: string;
|
|
14
|
+
regulation: "Regulation (EU) 2024/1689";
|
|
15
|
+
annex_iii_1a: boolean;
|
|
16
|
+
/** Every applicable check that can pass, passes. */
|
|
17
|
+
ok: boolean;
|
|
18
|
+
checks: Art12Check[];
|
|
19
|
+
notes: string;
|
|
20
|
+
}
|
|
21
|
+
/** Art. 19(1) and 26(6): "at least six months". Counted as 183 days, the longest six months. */
|
|
22
|
+
export declare const ART12_MIN_RETENTION_DAYS = 183;
|
|
23
|
+
/**
|
|
24
|
+
* Checks one session's record against Article 12. `verified`: whether its chain verifies (the caller
|
|
25
|
+
* runs the check). `retentionDays`: the deployment's retention, null when records are kept forever.
|
|
26
|
+
* `annexIII1a`: the system is a remote biometric identification system, so 12(3) applies.
|
|
27
|
+
*/
|
|
28
|
+
export declare function art12Check(record: {
|
|
29
|
+
lines: readonly string[];
|
|
30
|
+
verified: boolean;
|
|
31
|
+
}, options: {
|
|
32
|
+
retentionDays: number | null;
|
|
33
|
+
annexIII1a?: boolean;
|
|
34
|
+
}): Art12Report;
|
|
35
|
+
export {};
|