Skip to content

Commit 7c6499b

Browse files
committed
Add Adversarial Execution Evidence predicate
Adds the Adversarial Execution Evidence predicate specification and its protobuf definition, and registers both in the spec and protos indexes. The predicate records signed, recomputable evidence from executing an untrusted artifact against an adversarial corpus inside a containment substrate. A verifier re-derives the reported outcome from the attestation rather than trusting the party that produced it. Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
1 parent 2dcd055 commit 7c6499b

4 files changed

Lines changed: 2725 additions & 0 deletions

File tree

protos/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ predicates have protobuf definitions:
2424
chain attributes.
2525
- [Test Result]: Expresses the result of a test run in the software supply
2626
chain.
27+
- [Adversarial Execution Evidence]: Records recomputable evidence from a
28+
bounded adversarial-stimulus run against a system under test.
2729

2830
## Supported language bindings
2931

@@ -55,6 +57,7 @@ testing the supported language bindings.
5557
[VULNS]: in_toto_attestation/predicates/vulns/
5658
[in-toto Link]: in_toto_attestation/predicates/link/
5759
[Test Result]: in_toto_attestation/predicates/test_result/
60+
[Adversarial Execution Evidence]: in_toto_attestation/predicates/adversarial_execution_evidence/
5861
[documentation]: ../docs/protos.md
5962
[go]: ../go/
6063
[python]: ../python/
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
// Proto representation of predicate type
2+
// https://in-toto.io/attestation/adversarial-execution-evidence/v0.7
3+
//
4+
// Validation of all fields is left to the users of this proto. The predicate's
5+
// soundness lives in the verifier rules (statement well-formedness, the two
6+
// coverage/tier consumption gates, the offline `result` recompute, the
7+
// digest-integrity checks, and the per-record DSSE signature), never in this
8+
// schema: a proto-valid object is not necessarily a sound attestation.
9+
//
10+
// -----------------------------------------------------------------------------
11+
// TWO PROPERTIES OF THIS PREDICATE MAKE THIS PROTO SUBTLE. READ BOTH BEFORE
12+
// EDITING OR GENERATING CODE FROM IT.
13+
// -----------------------------------------------------------------------------
14+
//
15+
// 1. STRING-TYPED VOCABULARIES, NEVER proto3 ENUMS.
16+
//
17+
// `result`, `containmentObserved`, `basis`, `method`, `attribution`,
18+
// `actualLayer`, and
19+
// `networkPosture.posture` carry closed value vocabularies, yet every one is
20+
// a `string` here, not a proto3 `enum`. This is deliberate and load-bearing:
21+
//
22+
// - The AEE vocabularies are consumer-side, FAIL-CLOSED closed vocabularies.
23+
// A value outside the carried vocabulary is STRUCTURALLY VALID and
24+
// fail-closes at the verifier's recompute gate (a fail-closed row forces
25+
// `result` to `fail`), exactly as an absent `basis`/`method` does. A
26+
// proto3 enum cannot express this: an unknown wire value would deserialize
27+
// to the zero member (`UNKNOWN = 0`) and be silently swallowed, erasing
28+
// the very fail-closed signal the recompute depends on. String preserves
29+
// the unknown value verbatim for the verifier to reject. The altitude of
30+
// that rejection differs by member and the markdown states which: an
31+
// unknown `basis`/`method`/`attribution`/`containmentObserved`
32+
// fail-closes a row, while
33+
// an unknown `actualLayer` or `networkPosture.posture` makes the whole
34+
// statement malformed. Either way the verifier must see the value it was
35+
// given, which is what an enum would take away.
36+
// - `containmentObserved`'s admissible label set is not even fixed by this
37+
// predicate: it is producer vocabulary carried on-wire in
38+
// `observationEnvironment.observationVocabulary.{labels,caught}` and read
39+
// by the verifier, so no enum could enumerate it.
40+
// - It matches the in-toto house convention: there are ZERO proto3 `enum`
41+
// declarations across any merged in-toto predicate `.proto`
42+
// (vsa, scai, test_result, provenance, release, vulns, svr, ...); every
43+
// closed vocabulary in those predicates travels as a `string`.
44+
//
45+
// 2. THIS PROTO IS FOR TRANSPORT/CODEGEN ONLY. ITS JSON OUTPUT MUST NEVER BE
46+
// RE-CANONICALIZED FOR SIGNING OR RE-HASHED.
47+
//
48+
// The CANONICAL BYTES of an AEE attestation are the on-wire JSON emitted by
49+
// the producer. The DSSE signature is computed over
50+
// `PAE(payloadType, SERIALIZED_BODY)` -- the Pre-Authentication Encoding over
51+
// the body BYTES VERBATIM -- and every downstream digest (corpus manifest
52+
// digest, observation-vocabulary digest, the network-posture object digest
53+
// the run binding folds in, `batchRoot` leaves, and the run binding itself)
54+
// is taken over RFC 8785 (JCS) canonical bytes. proto3 canonical ProtoJSON is
55+
// NOT RFC 8785 / JCS and cannot be made so; at least two divergences are
56+
// unfixable:
57+
//
58+
// - ProtoJSON emits 64-bit integers (`int64`/`uint64`) as QUOTED strings,
59+
// a JSON token-type change JCS never makes (JCS keeps them as numbers).
60+
// - ProtoJSON preserves proto field declaration order; JCS lexicographically
61+
// sorts object member names by UTF-16 code unit. The two orderings differ.
62+
// (ProtoJSON/Struct also re-formats timestamps, re-pads base64, and may
63+
// drop or reformat members, each a further divergence.)
64+
//
65+
// Therefore a consumer MUST NOT parse an attestation into this proto, re-emit
66+
// its JSON, and re-hash or re-verify against that output: it would compute a
67+
// digest over bytes the producer never signed and reject a genuine bundle
68+
// (or, worse, mask a tampered one). Verify DSSE over the producer's exact
69+
// on-wire bytes; use this proto only for language bindings and transport.
70+
// (This mirrors sigstore's decision to DELETE its proto-derived JSON-Schema
71+
// rather than maintain a co-equal signed schema.)
72+
//
73+
// Consequences of (2) visible below: `manifest.classes`,
74+
// `manifest.expectedPayloads` and `_ext` use google.protobuf.Struct (proto3
75+
// maps cannot carry repeated/array values, so a message-wrapped modeling
76+
// would ALTER the JSON shape); `payload` is the
77+
// opaque base64 string carried verbatim (not proto `bytes`, whose base64
78+
// decode/re-encode round-trip could alter padding); and the reserved
79+
// `aee*` payload members (aeeKind, aeeMethod, aeeRunBinding, armedAt,
80+
// aeePostureDigest, aeeStillArmed, aeeDropCount, aeeChainScope, aeeRunSeq,
81+
// aeePrevRunBinding, aeePayloadCommitment, aeeAssessedAttacks,
82+
// aeeObservedSet, aeeObservedAttacks, aeeVersion, ...) are NOT modeled as
83+
// proto fields: they
84+
// live INSIDE the base64 `payload` bytes and are producer-signed and
85+
// opaque-until-verified (verify-then-read), never sibling wire fields.
86+
87+
syntax = "proto3";
88+
89+
package in_toto_attestation.predicates.adversarial_execution_evidence.v07;
90+
91+
import "in_toto_attestation/v1/resource_descriptor.proto";
92+
import "google/protobuf/struct.proto";
93+
import "google/protobuf/timestamp.proto";
94+
95+
option go_package = "github.com/in-toto/attestation/go/predicates/adversarial_execution_evidence/v07";
96+
option java_package = "io.github.intoto.attestation.predicates.adversarial_execution_evidence.v07";
97+
98+
// The single signed artifact one bounded adversarial-stimulus run emits per
99+
// system under test.
100+
message AdversarialExecutionEvidence {
101+
// The fused fail|degraded|pass_indirect|pass reduction, recomputed offline
102+
// from the rest of the predicate as the MINIMUM of three independent
103+
// conditions under that order. `pass_indirect` is the value a
104+
// coverage-complete statement takes when some clean row declares a `basis`
105+
// other than `substrate` or a `method` other than `intercepted`; it says
106+
// nothing about signature verification, which is the evidence tier's
107+
// question and is not recomputable. STRING, not enum (see header rule 1):
108+
// the recompute reads this value, and a value the recompute does not
109+
// reproduce is invalid.
110+
string result = 1;
111+
112+
ObservationEnvironment observation_environment = 2 [json_name = "observationEnvironment"];
113+
114+
Coverage coverage = 3;
115+
116+
repeated AttackResult attack_results = 4 [json_name = "attackResults"];
117+
118+
// Optional. Absent on an artifact-only statement. When present and non-empty,
119+
// `batch_root` is required (a rule the verifier enforces, not this proto).
120+
repeated ObservationRecord observation_records = 5 [json_name = "observationRecords"];
121+
122+
// RFC 6962 Merkle root over `observation_records` (SHA-256, domain-separated,
123+
// recursive split). Required iff `observation_records` is non-empty, omitted
124+
// otherwise. lowercase 64-hex.
125+
string batch_root = 6 [json_name = "batchRoot"];
126+
127+
// Optional, advisory. Explicit negative-scope statements; a verifier MUST NOT
128+
// require it and nothing in it weakens a required check.
129+
repeated string does_not_assert = 7 [json_name = "doesNotAssert"];
130+
131+
// When the producer signed the evidence bundle (RFC 3339). Modeled as a
132+
// Timestamp to match the in-toto house style (cf. vsa `timeVerified`); on the
133+
// wire this is the producer's exact RFC 3339 string, which is the canonical
134+
// byte -- see header rule 2 on why ProtoJSON's reformatted output must not be
135+
// re-hashed.
136+
//
137+
// The markdown pins the profile this type leaves open: the date-time
138+
// separator and the zone designator are uppercase, and the zone designator is
139+
// `Z`, `+00:00` or `-00:00`. ProtoJSON parsers accept spellings outside that
140+
// profile, so parsing a statement into this message is not a conformance
141+
// check on the field; the verifier checks the carried string.
142+
google.protobuf.Timestamp issued_at = 8 [json_name = "issuedAt"];
143+
144+
// Optional producer extension.
145+
ExecutionEnvelope execution_envelope = 9 [json_name = "executionEnvelope"];
146+
147+
// Optional producer extension bag (`_ext` on the wire). Free-form JSON object.
148+
google.protobuf.Struct ext = 10 [json_name = "_ext"];
149+
}
150+
151+
// The digest-pinned context the evidence was earned under. Two of its six
152+
// members are ResourceDescriptors and the other four are locally typed; the
153+
// markdown states the rule that decides which, and its reason.
154+
message ObservationEnvironment {
155+
// The subject reference of the substrate's own attestation. A
156+
// ResourceDescriptor: the member carries a name and a digest and nothing
157+
// else, which is what the descriptor holds, and the verifier's requirement
158+
// of a `sha256` digest is one the descriptor specification permits a context
159+
// using the type to impose. The JSON member names are identical either way,
160+
// so this typing is not a wire change.
161+
in_toto_attestation.v1.ResourceDescriptor substrate = 1;
162+
163+
// The corpus thrown at the subject.
164+
message Corpus {
165+
string name = 1;
166+
// RECOMMENDED as a purl, e.g. pkg:<producer>/<corpus>@<version>.
167+
string uri = 2;
168+
// JCS digest of the embedded `manifest` (algorithm -> lowercase-hex).
169+
map<string, string> digest = 3;
170+
171+
// A map from assessment class code to the complete array of attack
172+
// identifiers it defines, e.g. {"CO":["CO-EXFIL-1"]}. Modeled as a Struct,
173+
// NOT map<string, repeated string>: proto3 map values cannot be `repeated`,
174+
// so any message-wrapped modeling would inject an extra JSON nesting level
175+
// and change the on-wire shape. A Struct's ProtoJSON reproduces the
176+
// map-of-arrays exactly. (Verifier re-derives `corpus.digest` from these
177+
// bytes; see header rule 2.)
178+
message Manifest {
179+
google.protobuf.Struct classes = 1;
180+
181+
// Optional. attackId -> the array of lowercase 64-hex commitment values
182+
// a substrate is expected to carry when it observes that attack. A
183+
// Struct for the same reason `classes` is one: the values are arrays,
184+
// which a proto3 map cannot hold without injecting a nesting level the
185+
// wire shape does not have. It sits inside the pre-image
186+
// `corpus.digest` is taken over, so adding, editing or removing an
187+
// entry after the arming record is signed derives a run binding the
188+
// statement's own records do not carry.
189+
google.protobuf.Struct expected_payloads = 2 [json_name = "expectedPayloads"];
190+
}
191+
Manifest manifest = 4;
192+
}
193+
Corpus corpus = 2;
194+
195+
// JCS digest of the parsed catch-policy document. A ResourceDescriptor for
196+
// the same reason `substrate` is one: a digest and nothing beside it.
197+
in_toto_attestation.v1.ResourceDescriptor catch_policy = 3 [json_name = "catchPolicy"];
198+
199+
// The substrate-authoritative egress posture and its configuration digest.
200+
//
201+
// The run binding takes the RFC 8785 canonical digest of THIS WHOLE MESSAGE
202+
// as carried on the wire, not the value of its `digest` member, so every
203+
// member here is inside the substrate's signature and a member added or
204+
// removed after the arming record is signed invalidates the statement. That
205+
// is a reason not to extend this message casually: unlike the descriptors
206+
// above, a producer-specific member here changes a signed digest.
207+
message NetworkPosture {
208+
// STRING, not enum (see header rule 1): the registered posture vocabulary
209+
// is closed and fail-closed at exactly four values -- no_network,
210+
// allowlist, sinkhole, unsafe_bypass_egress -- and an unregistered value
211+
// makes the statement malformed. A proto3 enum would deserialize the
212+
// unregistered value to the zero member and erase the rejection, which is
213+
// the same reason every other closed vocabulary here is a string.
214+
string posture = 1;
215+
map<string, string> digest = 2;
216+
}
217+
NetworkPosture network_posture = 4 [json_name = "networkPosture"];
218+
219+
// The producer's on-wire, versioned observation label set. The recompute and
220+
// the coverage-validity gate read THIS carried set (not the producer's docs),
221+
// so archived attestations verify without external documentation.
222+
message ObservationVocabulary {
223+
// JCS digest of {"caught":[...],"labels":[...]} (algorithm -> lowercase-hex).
224+
// This value is also a run-binding input, so narrowing `caught` after the
225+
// run changes every record's required aeeRunBinding rather than merely
226+
// re-deriving this digest against the arrays beside it.
227+
map<string, string> digest = 1;
228+
// Every `containmentObserved` value the producer can emit; sorted ascending
229+
// by UTF-16 code unit, no duplicates, BMP-only (verifier soundness rules).
230+
repeated string labels = 2;
231+
// The subset of `labels` whose observation is a caught containment event.
232+
repeated string caught = 3;
233+
}
234+
ObservationVocabulary observation_vocabulary = 5 [json_name = "observationVocabulary"];
235+
236+
// The substrate-emitted run-start value folded into the run binding. OPTIONAL
237+
// here; the verifier requires it exactly when any `attack_results` row is
238+
// basis:substrate.
239+
message RunEntropy {
240+
map<string, string> digest = 1;
241+
}
242+
RunEntropy run_entropy = 6 [json_name = "runEntropy"];
243+
}
244+
245+
// The coverage bound: the disjoint partition of the manifest's classes into
246+
// assessed / out-of-scope / routed-elsewhere.
247+
message Coverage {
248+
// Class codes actually assessed.
249+
repeated string assessed_classes = 1 [json_name = "assessedClasses"];
250+
// Class code -> reason string; empty when complete. A non-empty map forces
251+
// `result` to degraded.
252+
map<string, string> out_of_scope = 2 [json_name = "outOfScope"];
253+
// Class code -> reason string; empty when complete. A non-empty map forces
254+
// `result` to degraded.
255+
map<string, string> routed_elsewhere = 3 [json_name = "routedElsewhere"];
256+
}
257+
258+
// One row per executed attack.
259+
message AttackResult {
260+
// Must appear in the manifest; unique across rows (one row per executed attack).
261+
string attack_id = 1 [json_name = "attackId"];
262+
263+
// A label from the carried `observationVocabulary.labels`. STRING, not enum
264+
// (see header rule 1): the admissible set travels on-wire in
265+
// `observationVocabulary`; a label outside the carried set is valid here and
266+
// fail-closes at the recompute gate.
267+
string containment_observed = 2 [json_name = "containmentObserved"];
268+
269+
// The observation's vantage (producer MUST emit `substrate` or `artifact`).
270+
// STRING, not enum: an absent or out-of-vocabulary value is valid-but-
271+
// fail-closed at the recompute gate, never proto/schema-rejected. NOT a
272+
// proto-required field for the same reason.
273+
string basis = 3;
274+
275+
// The observation's directness (producer MUST emit `intercepted` or
276+
// `reconstructed`). STRING, not enum; same fail-closed rule as `basis`.
277+
string method = 4;
278+
279+
// Which enforcement layer acted, drawn from the registered layer vocabulary,
280+
// OR the literal string "none" (the clean-row sentinel). STRING, not enum: a
281+
// missing value is a MALFORMED statement (a different altitude than the
282+
// fail-closed row members); the closed layer set is producer identity the
283+
// verifier checks.
284+
string actual_layer = 5 [json_name = "actualLayer"];
285+
286+
// Indexes into `observation_records` binding this row to its covering records.
287+
// uint32 (not int64) so ProtoJSON emits bare JSON numbers, matching the
288+
// on-wire `[0]` shape (see header rule 2 on int64 -> quoted-string divergence).
289+
// An out-of-range index makes the statement malformed (verifier rule).
290+
repeated uint32 observation_refs = 6 [json_name = "observationRefs"];
291+
292+
// Optional producer-defined tokens, positionally parallel to
293+
// `observation_refs`, naming the sub-observation within a referenced record's
294+
// committed payload. Producer vocabulary; nothing normative reads it.
295+
repeated string observation_selectors = 7 [json_name = "observationSelectors"];
296+
297+
// How firmly this row is bound to the records that cover it (producer MUST
298+
// emit `pinned` or `paired`). STRING, not enum; same fail-closed rule as
299+
// `basis`. A `pinned` row is checked against the corpus manifest's
300+
// `expectedPayloads` and the covering records' `aeePayloadCommitment`; a
301+
// `paired` row rests on a producer-asserted correspondence. The value
302+
// constrains the binding and never the result, the method or the evidence
303+
// tier. It is declared last because its field number is new, not because it
304+
// is optional: the verifier requires it on every row.
305+
string attribution = 8;
306+
}
307+
308+
// One DSSE envelope per observation. The producer signs at observation time,
309+
// before attribution; a consumer verifies the signature (DSSE PAE over
310+
// `payload_type` and the base64-decoded `payload`) BEFORE reading any field
311+
// inside the payload (verify-then-read).
312+
//
313+
// NOTE (header rule 2): the reserved `aee*` members the verifier reads
314+
// post-verify (aeeRunBinding, aeeKind, aeeMethod, and per-kind members
315+
// armedAt / aeePostureDigest / aeeStillArmed / aeeDropCount / aeeDropBound /
316+
// aeeRunSeq / aeePrevRunBinding / aeeChainScope / aeeVersion, ...) live INSIDE
317+
// the base64 `payload` bytes as top-level members of the signed +json object.
318+
// They are producer-signed and opaque-until-verified and are deliberately NOT
319+
// modeled as proto fields of this record.
320+
message ObservationRecord {
321+
// base64(exact canonical +json bytes the substrate signed). Carried as the
322+
// opaque base64 STRING verbatim (not proto `bytes`): the DSSE PAE is over the
323+
// producer's exact base64-decoded bytes, and proto `bytes` would round-trip
324+
// through its own base64 decode/re-encode, which may alter padding.
325+
string payload = 1;
326+
327+
// A producer-defined media type ending in `+json`. The exact bytes are the
328+
// first PAE input, so a differing value changes the signed bytes.
329+
string payload_type = 2 [json_name = "payloadType"];
330+
331+
// DSSE signature. `keyid` is an unauthenticated lookup hint and OPTIONAL
332+
// (never the check itself); only `sig` carries the signature.
333+
message Signature {
334+
string keyid = 1;
335+
string sig = 2;
336+
}
337+
repeated Signature signatures = 3;
338+
339+
// Optional predicate-layer emission sequence (1-based) of this record within
340+
// the run's signed stream: the stable join key a vmi.attribution record's
341+
// catch_seq resolves against, robust to a later filter or reorder of
342+
// `observation_records`. The signed payload carries no seq. uint32 so
343+
// ProtoJSON emits a bare number (see header rule 2). Absent only when the
344+
// producer emitted an un-sequenced stream.
345+
uint32 seq = 4;
346+
}
347+
348+
// Optional producer extension: linkage to a distributed-trace context.
349+
message ExecutionEnvelope {
350+
string otel_trace_id = 1 [json_name = "otelTraceId"];
351+
}

0 commit comments

Comments
 (0)