obaaf 0.1.0__py3-none-any.whl
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.
- obaaf/__init__.py +6 -0
- obaaf/__main__.py +3 -0
- obaaf/assess.py +344 -0
- obaaf/attest/__init__.py +6 -0
- obaaf/attest/agentrecord.py +67 -0
- obaaf/attest/common.py +111 -0
- obaaf/attest/manifest.py +143 -0
- obaaf/attest/proposal.py +516 -0
- obaaf/attest/records.py +283 -0
- obaaf/attest/snapshot.py +226 -0
- obaaf/catalog.py +235 -0
- obaaf/checker/__init__.py +150 -0
- obaaf/checker/repo.py +193 -0
- obaaf/checker/report.py +125 -0
- obaaf/checker/rules.py +546 -0
- obaaf/checker/workflow.py +587 -0
- obaaf/cli.py +659 -0
- obaaf/data/__init__.py +0 -0
- obaaf/data/agent_controls.yaml +276 -0
- obaaf/data/assessment.yaml +373 -0
- obaaf/data/compromises.yaml +220 -0
- obaaf/data/detection.yaml +151 -0
- obaaf/data/frameworks.yaml +48 -0
- obaaf/data/profile.yaml +619 -0
- obaaf/data/rules.yaml +371 -0
- obaaf/data/schemas/agent-approval.schema.json +39 -0
- obaaf/data/schemas/agent-run.schema.json +361 -0
- obaaf/data/schemas/build-record.schema.json +82 -0
- obaaf/form_template.html +347 -0
- obaaf/predicates.py +21 -0
- obaaf/render.py +339 -0
- obaaf/verify/__init__.py +14 -0
- obaaf/verify/dsse.py +135 -0
- obaaf/verify/policy.py +104 -0
- obaaf/verify/release.py +671 -0
- obaaf/yamlutil.py +96 -0
- obaaf-0.1.0.dist-info/METADATA +123 -0
- obaaf-0.1.0.dist-info/RECORD +43 -0
- obaaf-0.1.0.dist-info/WHEEL +5 -0
- obaaf-0.1.0.dist-info/entry_points.txt +2 -0
- obaaf-0.1.0.dist-info/licenses/LICENSE +202 -0
- obaaf-0.1.0.dist-info/licenses/LICENSE-docs +317 -0
- obaaf-0.1.0.dist-info/top_level.txt +1 -0
obaaf/__init__.py
ADDED
obaaf/__main__.py
ADDED
obaaf/assess.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Score an OBAAF self-assessment and rank the fixes (FR2).
|
|
2
|
+
|
|
3
|
+
Scoring follows Paper 2, design requirement DR6: a studio reaches a tier when
|
|
4
|
+
every item at that tier and below is answered yes. An item may be marked not
|
|
5
|
+
applicable only when its condition is absent, and then counts as satisfied.
|
|
6
|
+
|
|
7
|
+
Fixes are ranked lowest tier first, then lowest effort, then by the number of
|
|
8
|
+
documented compromises for which the item is a deciding control (Paper 2,
|
|
9
|
+
Table 7). See DECISIONS.md, D-006.
|
|
10
|
+
|
|
11
|
+
Everything here runs locally. Nothing is sent over the network (NFR5).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import date
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from obaaf import __version__, yamlutil
|
|
24
|
+
from obaaf.catalog import Catalog, load
|
|
25
|
+
|
|
26
|
+
ANSWER_FORMAT = "obaaf-assessment/v0.1"
|
|
27
|
+
VALID = {"yes", "no", "na", "unanswered"}
|
|
28
|
+
ALIASES = {
|
|
29
|
+
"y": "yes", "true": "yes", "n": "no", "false": "no", "n/a": "na", "not applicable": "na",
|
|
30
|
+
"": "unanswered", "?": "unanswered", "unknown": "unanswered", "unsure": "unanswered",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AnswerError(ValueError):
|
|
35
|
+
"""The answer file cannot be scored as written."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Answer:
|
|
40
|
+
value: str
|
|
41
|
+
evidence: str = ""
|
|
42
|
+
note: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class AnswerSet:
|
|
47
|
+
answers: dict[str, Answer]
|
|
48
|
+
studio: str = ""
|
|
49
|
+
respondent: str = ""
|
|
50
|
+
date: str = ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Fix:
|
|
55
|
+
rank: int
|
|
56
|
+
item: dict
|
|
57
|
+
answer: str
|
|
58
|
+
prevents: list[dict]
|
|
59
|
+
limits: list[dict]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Result:
|
|
64
|
+
tier: int
|
|
65
|
+
tier_name: str
|
|
66
|
+
per_tier: dict[int, dict[str, int]]
|
|
67
|
+
fixes: list[Fix]
|
|
68
|
+
answers: AnswerSet
|
|
69
|
+
warnings: list[str] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def next_tier(self) -> int | None:
|
|
73
|
+
return self.tier + 1 if self.tier < 4 else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normalize(raw: Any, iid: str) -> Answer:
|
|
77
|
+
evidence = note = ""
|
|
78
|
+
if isinstance(raw, dict):
|
|
79
|
+
evidence = str(raw.get("evidence") or "")
|
|
80
|
+
note = str(raw.get("note") or "")
|
|
81
|
+
raw = raw.get("answer")
|
|
82
|
+
if raw is True:
|
|
83
|
+
raw = "yes"
|
|
84
|
+
elif raw is False:
|
|
85
|
+
raw = "no"
|
|
86
|
+
elif raw is None:
|
|
87
|
+
raw = "unanswered"
|
|
88
|
+
value = str(raw).strip().lower()
|
|
89
|
+
value = ALIASES.get(value, value)
|
|
90
|
+
if value not in VALID:
|
|
91
|
+
raise AnswerError(f"item {iid}: answer {raw!r} is not one of yes, no, na or unanswered")
|
|
92
|
+
return Answer(value=value, evidence=evidence, note=note)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_answers(data: Any, catalog: Catalog | None = None) -> AnswerSet:
|
|
96
|
+
catalog = catalog or load()
|
|
97
|
+
if not isinstance(data, dict) or "answers" not in data:
|
|
98
|
+
raise AnswerError("the file must be a mapping with an 'answers' key (run 'obaaf assess init' for a template)")
|
|
99
|
+
fmt = data.get("format", ANSWER_FORMAT)
|
|
100
|
+
if fmt != ANSWER_FORMAT:
|
|
101
|
+
raise AnswerError(f"unsupported answer format {fmt!r}; expected {ANSWER_FORMAT}")
|
|
102
|
+
raw_answers = data["answers"]
|
|
103
|
+
if isinstance(raw_answers, list):
|
|
104
|
+
pairs = []
|
|
105
|
+
for entry in raw_answers:
|
|
106
|
+
if not isinstance(entry, dict) or "item" not in entry:
|
|
107
|
+
raise AnswerError("each entry in a list of answers needs an 'item' key")
|
|
108
|
+
pairs.append((entry["item"], entry))
|
|
109
|
+
elif isinstance(raw_answers, dict):
|
|
110
|
+
pairs = list(raw_answers.items())
|
|
111
|
+
else:
|
|
112
|
+
raise AnswerError("'answers' must be a mapping or a list")
|
|
113
|
+
|
|
114
|
+
answers: dict[str, Answer] = {}
|
|
115
|
+
for key, raw in pairs:
|
|
116
|
+
if not isinstance(key, str):
|
|
117
|
+
raise AnswerError(
|
|
118
|
+
f"item ID {key!r} must be quoted (write \"2.10\", not 2.10, or YAML reads it as the number 2.1)"
|
|
119
|
+
)
|
|
120
|
+
iid = key.strip()
|
|
121
|
+
if iid not in catalog.items:
|
|
122
|
+
raise AnswerError(f"unknown item {iid!r}")
|
|
123
|
+
if iid in answers:
|
|
124
|
+
raise AnswerError(f"item {iid} is answered twice")
|
|
125
|
+
answer = _normalize(raw, iid)
|
|
126
|
+
if answer.value == "na" and not catalog.items[iid].get("na_when"):
|
|
127
|
+
raise AnswerError(
|
|
128
|
+
f"item {iid} cannot be marked not applicable; only items whose condition can be absent can "
|
|
129
|
+
"(Paper 2, DR6). Answer yes or no."
|
|
130
|
+
)
|
|
131
|
+
answers[iid] = answer
|
|
132
|
+
for iid in catalog.items:
|
|
133
|
+
answers.setdefault(iid, Answer(value="unanswered"))
|
|
134
|
+
return AnswerSet(
|
|
135
|
+
answers=answers,
|
|
136
|
+
studio=str(data.get("studio") or ""),
|
|
137
|
+
respondent=str(data.get("respondent") or ""),
|
|
138
|
+
date=str(data.get("date") or ""),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_answers(path: str | Path, catalog: Catalog | None = None) -> AnswerSet:
|
|
143
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
144
|
+
if str(path).endswith(".json"):
|
|
145
|
+
data = json.loads(text)
|
|
146
|
+
else:
|
|
147
|
+
try:
|
|
148
|
+
data = yamlutil.load(text)
|
|
149
|
+
except yamlutil.DuplicateKeyError as exc:
|
|
150
|
+
raise AnswerError(str(exc)) from exc
|
|
151
|
+
return parse_answers(data, catalog)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _item_order(iid: str) -> tuple[int, int]:
|
|
155
|
+
tier, num = iid.split(".")
|
|
156
|
+
return int(tier), int(num)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def score(answers: AnswerSet, catalog: Catalog | None = None) -> Result:
|
|
160
|
+
catalog = catalog or load()
|
|
161
|
+
per_tier: dict[int, dict[str, int]] = {}
|
|
162
|
+
for tier in sorted(catalog.tiers):
|
|
163
|
+
counts = {"items": 0, "yes": 0, "na": 0, "no": 0, "unanswered": 0}
|
|
164
|
+
for iid, item in catalog.items.items():
|
|
165
|
+
if item["tier"] == tier:
|
|
166
|
+
counts["items"] += 1
|
|
167
|
+
counts[answers.answers[iid].value] += 1
|
|
168
|
+
per_tier[tier] = counts
|
|
169
|
+
|
|
170
|
+
reached = 0
|
|
171
|
+
for tier in sorted(per_tier):
|
|
172
|
+
counts = per_tier[tier]
|
|
173
|
+
if counts["yes"] + counts["na"] == counts["items"]:
|
|
174
|
+
reached = tier
|
|
175
|
+
else:
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
effort_rank = {k: v["rank"] for k, v in catalog.assessment["effort"].items()}
|
|
179
|
+
open_items = [
|
|
180
|
+
(iid, item) for iid, item in catalog.items.items()
|
|
181
|
+
if answers.answers[iid].value in ("no", "unanswered")
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
def key(entry: tuple[str, dict]) -> tuple:
|
|
185
|
+
iid, item = entry
|
|
186
|
+
cases = catalog.item_cases(iid)
|
|
187
|
+
return (item["tier"], effort_rank[item["effort"]], -len(cases["prevent"]), -len(cases["limit"]), _item_order(iid))
|
|
188
|
+
|
|
189
|
+
fixes = []
|
|
190
|
+
for rank, (iid, item) in enumerate(sorted(open_items, key=key), start=1):
|
|
191
|
+
cases = catalog.item_cases(iid)
|
|
192
|
+
fixes.append(Fix(rank=rank, item=item, answer=answers.answers[iid].value,
|
|
193
|
+
prevents=cases["prevent"], limits=cases["limit"]))
|
|
194
|
+
|
|
195
|
+
warnings = []
|
|
196
|
+
unanswered = [iid for iid, a in answers.answers.items() if a.value == "unanswered"]
|
|
197
|
+
if unanswered:
|
|
198
|
+
warnings.append(
|
|
199
|
+
f"{len(unanswered)} item(s) unanswered; they count as 'no' until answered: "
|
|
200
|
+
+ ", ".join(sorted(unanswered, key=_item_order))
|
|
201
|
+
)
|
|
202
|
+
tier_name = catalog.tiers[reached]["name"] if reached else "Not yet Accounted"
|
|
203
|
+
return Result(tier=reached, tier_name=tier_name, per_tier=per_tier, fixes=fixes, answers=answers, warnings=warnings)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------- output
|
|
207
|
+
def template(catalog: Catalog | None = None) -> str:
|
|
208
|
+
"""An answer file with every question, ready to fill in."""
|
|
209
|
+
catalog = catalog or load()
|
|
210
|
+
lines = [
|
|
211
|
+
"# OBAAF self-assessment answers. Answer each item yes, no, or na where allowed.",
|
|
212
|
+
"# Keep this file inside your studio: it is never sent anywhere.",
|
|
213
|
+
"# Score it with: obaaf assess score this-file.yaml",
|
|
214
|
+
f"format: {ANSWER_FORMAT}",
|
|
215
|
+
'studio: ""',
|
|
216
|
+
'respondent: ""',
|
|
217
|
+
f'date: "{date.today().isoformat()}"',
|
|
218
|
+
"answers:",
|
|
219
|
+
]
|
|
220
|
+
for tier in sorted(catalog.tiers):
|
|
221
|
+
t = catalog.tiers[tier]
|
|
222
|
+
lines.append(f" # Tier {tier}, {t['name']}: {t['summary']}")
|
|
223
|
+
for iid, item in sorted(catalog.items.items(), key=lambda kv: _item_order(kv[0])):
|
|
224
|
+
if item["tier"] != tier:
|
|
225
|
+
continue
|
|
226
|
+
lines.append(f" # {iid} {item['question']}")
|
|
227
|
+
lines.append(f" # Evidence: {item['evidence']}.")
|
|
228
|
+
if item.get("na_when"):
|
|
229
|
+
lines.append(f" # Answer na only if: {item['na_when']}")
|
|
230
|
+
lines.append(f' "{iid}": {{answer: unanswered, evidence: ""}}')
|
|
231
|
+
return "\n".join(lines) + "\n"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _case_names(cases: list[dict]) -> str:
|
|
235
|
+
return ", ".join(f"{c['name']} ({c['reported']})" for c in cases)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def to_text(result: Result, catalog: Catalog | None = None, limit: int = 10) -> str:
|
|
239
|
+
catalog = catalog or load()
|
|
240
|
+
out = []
|
|
241
|
+
who = f" for {result.answers.studio}" if result.answers.studio else ""
|
|
242
|
+
out.append(f"OBAAF self-assessment{who}")
|
|
243
|
+
out.append(f"Tier reached: {result.tier} ({result.tier_name})")
|
|
244
|
+
for tier, counts in result.per_tier.items():
|
|
245
|
+
satisfied = counts["yes"] + counts["na"]
|
|
246
|
+
out.append(f" Tier {tier} {catalog.tiers[tier]['name']:<11} {satisfied:>2}/{counts['items']} satisfied")
|
|
247
|
+
for warning in result.warnings:
|
|
248
|
+
out.append(f"Note: {warning}")
|
|
249
|
+
if result.fixes:
|
|
250
|
+
shown = result.fixes[:limit] if limit else result.fixes
|
|
251
|
+
out.append("")
|
|
252
|
+
out.append(f"Fix next ({len(shown)} of {len(result.fixes)}, lowest tier and effort first):")
|
|
253
|
+
effort = catalog.assessment["effort"]
|
|
254
|
+
for fix in shown:
|
|
255
|
+
item = fix.item
|
|
256
|
+
label = effort[item["effort"]]["label"].lower()
|
|
257
|
+
out.append(f"{fix.rank:>3}. {item['id']} {item['short']} [tier {item['tier']}, {label}]")
|
|
258
|
+
if fix.prevents:
|
|
259
|
+
out.append(f" Would have prevented: {_case_names(fix.prevents)}")
|
|
260
|
+
if fix.limits:
|
|
261
|
+
out.append(f" Detects or limits: {_case_names(fix.limits)}")
|
|
262
|
+
out.append(f" How: {item['fix'][0]}")
|
|
263
|
+
else:
|
|
264
|
+
out.append("All items satisfied.")
|
|
265
|
+
return "\n".join(out) + "\n"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def to_markdown(result: Result, catalog: Catalog | None = None, link_base: str = "") -> str:
|
|
269
|
+
catalog = catalog or load()
|
|
270
|
+
who = f" - {result.answers.studio}" if result.answers.studio else ""
|
|
271
|
+
out = [f"# OBAAF self-assessment{who}", ""]
|
|
272
|
+
meta = []
|
|
273
|
+
if result.answers.date:
|
|
274
|
+
meta.append(f"Date: {result.answers.date}")
|
|
275
|
+
if result.answers.respondent:
|
|
276
|
+
meta.append(f"Respondent: {result.answers.respondent}")
|
|
277
|
+
meta.append(f"Scored by obaaf {__version__}")
|
|
278
|
+
out += [" · ".join(meta), ""]
|
|
279
|
+
out.append(f"**Tier reached: {result.tier} ({result.tier_name}).** "
|
|
280
|
+
"A tier describes controls in place, not a guarantee of security.")
|
|
281
|
+
out.append("")
|
|
282
|
+
out.append("| Tier | Satisfied | No | Unanswered |")
|
|
283
|
+
out.append("| --- | --- | --- | --- |")
|
|
284
|
+
for tier, counts in result.per_tier.items():
|
|
285
|
+
out.append(f"| {tier} {catalog.tiers[tier]['name']} | {counts['yes'] + counts['na']} of {counts['items']} "
|
|
286
|
+
f"| {counts['no']} | {counts['unanswered']} |")
|
|
287
|
+
out.append("")
|
|
288
|
+
if result.fixes:
|
|
289
|
+
out.append("## What to fix next")
|
|
290
|
+
out.append("")
|
|
291
|
+
out.append("Ranked lowest tier first, then lowest effort, then by the documented compromises each item "
|
|
292
|
+
"addresses.")
|
|
293
|
+
out.append("")
|
|
294
|
+
out.append("| # | Item | Tier | Effort | Documented cases it addresses | How to fix |")
|
|
295
|
+
out.append("| --- | --- | --- | --- | --- | --- |")
|
|
296
|
+
effort = catalog.assessment["effort"]
|
|
297
|
+
for fix in result.fixes:
|
|
298
|
+
item = fix.item
|
|
299
|
+
cases = _case_names(fix.prevents + fix.limits) or "-"
|
|
300
|
+
links = ", ".join(f"[{_label(link)}]({link_base}{link})" for link in item["fix"])
|
|
301
|
+
out.append(f"| {fix.rank} | {item['id']} {item['short']} | {item['tier']} | "
|
|
302
|
+
f"{effort[item['effort']]['label']} | {cases} | {links} |")
|
|
303
|
+
out.append("")
|
|
304
|
+
if result.warnings:
|
|
305
|
+
out.append("## Notes")
|
|
306
|
+
out.append("")
|
|
307
|
+
out.extend(f"- {w}" for w in result.warnings)
|
|
308
|
+
out.append("")
|
|
309
|
+
return "\n".join(out)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _label(link: str) -> str:
|
|
313
|
+
from obaaf.render import _label as render_label
|
|
314
|
+
|
|
315
|
+
return render_label(link)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def to_json(result: Result) -> str:
|
|
319
|
+
payload = {
|
|
320
|
+
"format": "obaaf-assessment-result/v0.1",
|
|
321
|
+
"scoredBy": f"obaaf {__version__}",
|
|
322
|
+
"studio": result.answers.studio,
|
|
323
|
+
"date": result.answers.date,
|
|
324
|
+
"tier": result.tier,
|
|
325
|
+
"tierName": result.tier_name,
|
|
326
|
+
"perTier": {str(k): v for k, v in result.per_tier.items()},
|
|
327
|
+
"fixes": [
|
|
328
|
+
{
|
|
329
|
+
"rank": f.rank,
|
|
330
|
+
"item": f.item["id"],
|
|
331
|
+
"short": f.item["short"],
|
|
332
|
+
"tier": f.item["tier"],
|
|
333
|
+
"effort": f.item["effort"],
|
|
334
|
+
"answer": f.answer,
|
|
335
|
+
"profile": f.item["profile"],
|
|
336
|
+
"prevents": [c["id"] for c in f.prevents],
|
|
337
|
+
"limits": [c["id"] for c in f.limits],
|
|
338
|
+
"fix": f.item["fix"],
|
|
339
|
+
}
|
|
340
|
+
for f in result.fixes
|
|
341
|
+
],
|
|
342
|
+
"warnings": result.warnings,
|
|
343
|
+
}
|
|
344
|
+
return json.dumps(payload, indent=2) + "\n"
|
obaaf/attest/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Generators for OBAAF's records: snapshot, agent run, approval, build record, manifest.
|
|
2
|
+
|
|
3
|
+
The generators write in-toto predicates (and optionally whole statements). In
|
|
4
|
+
the GitHub Actions templates the predicates are signed by actions/attest in a
|
|
5
|
+
job that holds the OIDC permission, so no long-lived key exists (FR11).
|
|
6
|
+
"""
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The agent record that travels with an agent-authored change (R8, G8.3).
|
|
2
|
+
|
|
3
|
+
When the effect lane applies a proposal, it commits a record file next to the
|
|
4
|
+
change, at `.obaaf/records/<bundle digest>.json`, and names it in a commit
|
|
5
|
+
trailer (`OBAAF-Agent-Record: <path>`). The record holds the proposal bundle and
|
|
6
|
+
the two signed attestations: the agent-run record from the agent lane and the
|
|
7
|
+
approval record from the effect lane. The release verifier reads it from the
|
|
8
|
+
commit, so the evidence is archived with the source and needs no network
|
|
9
|
+
lookups beyond signature verification.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from obaaf.attest.common import RecordError, canonical_json, files_under, sha256_bytes, write_json
|
|
19
|
+
from obaaf.attest.proposal import ALLOWED_FILES, bundle_digest
|
|
20
|
+
|
|
21
|
+
FORMAT = "obaaf-agent-record/v0.1"
|
|
22
|
+
TRAILER = "OBAAF-Agent-Record"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = ["FORMAT", "TRAILER", "bundle_digest", "digest_of_listing", "build", "write", "parse"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def digest_of_listing(files: dict[str, bytes]) -> str:
|
|
29
|
+
listing = sorted([name, sha256_bytes(data)] for name, data in files.items())
|
|
30
|
+
return sha256_bytes(canonical_json(listing))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build(proposal_dir: str | Path, agent_run: dict, approval: dict, agent_run_url: str = "") -> dict:
|
|
34
|
+
root = Path(proposal_dir)
|
|
35
|
+
files = {p.relative_to(root).as_posix(): base64.b64encode(p.read_bytes()).decode("ascii") for p in files_under(root)}
|
|
36
|
+
return {
|
|
37
|
+
"format": FORMAT,
|
|
38
|
+
"bundleDigest": {"sha256": bundle_digest(root)},
|
|
39
|
+
"proposal": {"files": files},
|
|
40
|
+
"attestations": {"agentRun": agent_run, "approval": approval},
|
|
41
|
+
"agentRunUrl": agent_run_url,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write(record: dict, records_dir: str | Path) -> Path:
|
|
46
|
+
path = Path(records_dir) / f"{record['bundleDigest']['sha256']}.json"
|
|
47
|
+
write_json(path, record)
|
|
48
|
+
return path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse(text: str) -> tuple[dict, dict[str, bytes]]:
|
|
52
|
+
"""Return (record, proposal files) after checking the record's own digest."""
|
|
53
|
+
try:
|
|
54
|
+
record = json.loads(text)
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
raise RecordError(f"agent record is not JSON: {exc}") from exc
|
|
57
|
+
if record.get("format") != FORMAT:
|
|
58
|
+
raise RecordError(f"unexpected agent record format {record.get('format')!r}")
|
|
59
|
+
files = {}
|
|
60
|
+
for name, encoded in record["proposal"]["files"].items():
|
|
61
|
+
if name not in ALLOWED_FILES:
|
|
62
|
+
raise RecordError(f"unexpected file in agent record: {name!r}")
|
|
63
|
+
files[name] = base64.b64decode(encoded)
|
|
64
|
+
expected = record.get("bundleDigest", {}).get("sha256")
|
|
65
|
+
if digest_of_listing(files) != expected:
|
|
66
|
+
raise RecordError("the proposal files in the record do not match its bundle digest")
|
|
67
|
+
return record, files
|
obaaf/attest/common.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Shared helpers: digests, in-toto statements, schema validation, the CI environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Iterable
|
|
11
|
+
|
|
12
|
+
from obaaf import __version__
|
|
13
|
+
from obaaf.catalog import data_text
|
|
14
|
+
from obaaf.predicates import SCHEMA_FOR_TYPE, STATEMENT_TYPE
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RecordError(ValueError):
|
|
18
|
+
"""A record cannot be produced or does not validate."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sha256_bytes(data: bytes) -> str:
|
|
22
|
+
return hashlib.sha256(data).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sha256_file(path: str | Path) -> str:
|
|
26
|
+
digest = hashlib.sha256()
|
|
27
|
+
with open(path, "rb") as handle:
|
|
28
|
+
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
|
29
|
+
digest.update(chunk)
|
|
30
|
+
return digest.hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def canonical_json(obj: Any) -> bytes:
|
|
34
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def now() -> str:
|
|
38
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def files_under(path: str | Path) -> list[Path]:
|
|
42
|
+
"""Files under a directory (sorted, relative order stable), or the file itself."""
|
|
43
|
+
path = Path(path)
|
|
44
|
+
if path.is_file():
|
|
45
|
+
return [path]
|
|
46
|
+
if not path.is_dir():
|
|
47
|
+
raise RecordError(f"not found: {path}")
|
|
48
|
+
return sorted(p for p in path.rglob("*") if p.is_file() and not p.is_symlink())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def subjects_for(paths: Iterable[str | Path], base: str | Path | None = None) -> list[dict]:
|
|
52
|
+
"""in-toto ResourceDescriptors (name + sha256) for files; names are relative to `base`."""
|
|
53
|
+
out = []
|
|
54
|
+
for path in paths:
|
|
55
|
+
path = Path(path)
|
|
56
|
+
name = path.relative_to(base).as_posix() if base else path.name
|
|
57
|
+
out.append({"name": name, "digest": {"sha256": sha256_file(path)}})
|
|
58
|
+
if not out:
|
|
59
|
+
raise RecordError("no subjects: the record must cover at least one file")
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def statement(subjects: list[dict], predicate_type: str, predicate: dict) -> dict:
|
|
64
|
+
return {"_type": STATEMENT_TYPE, "subject": subjects, "predicateType": predicate_type, "predicate": predicate}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_predicate(predicate_type: str, predicate: dict) -> list[str]:
|
|
68
|
+
"""Errors from validating a predicate against its JSON Schema (empty when valid)."""
|
|
69
|
+
schema_path = SCHEMA_FOR_TYPE.get(predicate_type)
|
|
70
|
+
if not schema_path:
|
|
71
|
+
return []
|
|
72
|
+
import jsonschema # imported lazily: only the record commands need it
|
|
73
|
+
|
|
74
|
+
schema = json.loads(data_text(schema_path))
|
|
75
|
+
validator = jsonschema.Draft202012Validator(schema, format_checker=jsonschema.FormatChecker())
|
|
76
|
+
return [
|
|
77
|
+
f"{'/'.join(str(p) for p in err.absolute_path) or '(root)'}: {err.message}"
|
|
78
|
+
for err in sorted(validator.iter_errors(predicate), key=lambda e: list(e.absolute_path))
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ci_run() -> dict:
|
|
83
|
+
"""Facts about the current GitHub Actions run, from the runner's environment."""
|
|
84
|
+
env = os.environ
|
|
85
|
+
server = env.get("GITHUB_SERVER_URL", "https://github.com")
|
|
86
|
+
repo = env.get("GITHUB_REPOSITORY", "")
|
|
87
|
+
run_id = env.get("GITHUB_RUN_ID", "")
|
|
88
|
+
out = {
|
|
89
|
+
"platform": "github-actions" if env.get("GITHUB_ACTIONS") == "true" else "local",
|
|
90
|
+
"repository": repo,
|
|
91
|
+
"runId": run_id,
|
|
92
|
+
"runAttempt": env.get("GITHUB_RUN_ATTEMPT", ""),
|
|
93
|
+
"url": f"{server}/{repo}/actions/runs/{run_id}" if repo and run_id else "",
|
|
94
|
+
"workflowRef": env.get("GITHUB_WORKFLOW_REF", ""),
|
|
95
|
+
"workflowSha": env.get("GITHUB_WORKFLOW_SHA", ""),
|
|
96
|
+
"eventName": env.get("GITHUB_EVENT_NAME", ""),
|
|
97
|
+
"actor": env.get("GITHUB_ACTOR", ""),
|
|
98
|
+
"triggeringActor": env.get("GITHUB_TRIGGERING_ACTOR", ""),
|
|
99
|
+
"runnerEnvironment": env.get("RUNNER_ENVIRONMENT", ""),
|
|
100
|
+
}
|
|
101
|
+
return {k: v for k, v in out.items() if v}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def write_json(path: str | Path, obj: Any) -> None:
|
|
105
|
+
path = Path(path)
|
|
106
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
path.write_text(json.dumps(obj, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def obaaf_version() -> str:
|
|
111
|
+
return __version__
|
obaaf/attest/manifest.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""The binary manifest (G2.1, item 2.6).
|
|
2
|
+
|
|
3
|
+
A manifest records every vendored binary, SDK, engine build and plugin with its
|
|
4
|
+
source, version and digest. `check` fails on any unrecorded change: a new file,
|
|
5
|
+
a changed file or a missing file under a tracked path. Symbolic links are
|
|
6
|
+
recorded with their targets, so replacing a file with a link, or retargeting a
|
|
7
|
+
link, is a change too.
|
|
8
|
+
|
|
9
|
+
Components are declared in a small YAML file, for example:
|
|
10
|
+
|
|
11
|
+
components:
|
|
12
|
+
- id: steamworks-sdk
|
|
13
|
+
name: Steamworks SDK
|
|
14
|
+
version: "1.62"
|
|
15
|
+
source: Steamworks partner site
|
|
16
|
+
kind: sdk
|
|
17
|
+
paths: ["ThirdParty/Steamworks/**"]
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import fnmatch
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from obaaf import yamlutil
|
|
28
|
+
from obaaf.attest.common import RecordError, now, sha256_bytes, sha256_file, write_json
|
|
29
|
+
|
|
30
|
+
FORMAT = "obaaf-manifest/v0.1"
|
|
31
|
+
KINDS = {"sdk", "engine", "plugin", "middleware", "binary", "asset-pack", "tool"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_components(path: str | Path) -> list[dict]:
|
|
35
|
+
data = yamlutil.load_file(path) or {}
|
|
36
|
+
components = data.get("components") if isinstance(data, dict) else None
|
|
37
|
+
if not isinstance(components, list) or not components:
|
|
38
|
+
raise RecordError(f"{path}: expected a 'components' list")
|
|
39
|
+
seen = set()
|
|
40
|
+
for comp in components:
|
|
41
|
+
for key in ("id", "name", "version", "source", "kind", "paths"):
|
|
42
|
+
if not comp.get(key):
|
|
43
|
+
raise RecordError(f"{path}: component {comp.get('id', '?')} is missing {key!r}")
|
|
44
|
+
if comp["kind"] not in KINDS:
|
|
45
|
+
raise RecordError(f"{path}: component {comp['id']} has unknown kind {comp['kind']!r}")
|
|
46
|
+
if comp["id"] in seen:
|
|
47
|
+
raise RecordError(f"{path}: duplicate component id {comp['id']}")
|
|
48
|
+
seen.add(comp["id"])
|
|
49
|
+
comp["version"] = str(comp["version"])
|
|
50
|
+
return components
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _matches(rel: str, patterns: list[str]) -> bool:
|
|
54
|
+
for pattern in patterns:
|
|
55
|
+
if fnmatch.fnmatchcase(rel, pattern):
|
|
56
|
+
return True
|
|
57
|
+
if pattern.endswith("/**") and (rel == pattern[:-3] or rel.startswith(pattern[:-2])):
|
|
58
|
+
return True
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _tracked_files(root: Path, components: list[dict]) -> dict[str, str]:
|
|
63
|
+
"""Map each tracked file (relative path) to its component id. First matching component wins."""
|
|
64
|
+
owned: dict[str, str] = {}
|
|
65
|
+
for path in sorted(root.rglob("*")):
|
|
66
|
+
if not (path.is_symlink() or path.is_file()) or ".git" in path.relative_to(root).parts:
|
|
67
|
+
continue
|
|
68
|
+
rel = path.relative_to(root).as_posix()
|
|
69
|
+
for comp in components:
|
|
70
|
+
if _matches(rel, comp["paths"]):
|
|
71
|
+
owned[rel] = comp["id"]
|
|
72
|
+
break
|
|
73
|
+
return owned
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def create(root: str | Path, components_file: str | Path) -> dict:
|
|
77
|
+
root = Path(root)
|
|
78
|
+
components = load_components(components_file)
|
|
79
|
+
owned = _tracked_files(root, components)
|
|
80
|
+
empty = [c["id"] for c in components if c["id"] not in owned.values()]
|
|
81
|
+
if empty:
|
|
82
|
+
raise RecordError(f"no files match the paths of component(s): {', '.join(empty)}")
|
|
83
|
+
entries = [dict(_describe(root / rel), path=rel, component=comp) for rel, comp in sorted(owned.items())]
|
|
84
|
+
return {
|
|
85
|
+
"format": FORMAT,
|
|
86
|
+
"createdAt": now(),
|
|
87
|
+
"components": [
|
|
88
|
+
{k: c[k] for k in ("id", "name", "version", "source", "kind", "paths")} for c in components
|
|
89
|
+
],
|
|
90
|
+
"entries": entries,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _describe(path: Path) -> dict:
|
|
95
|
+
"""The digest of a file, or of a symbolic link's target text (the link is never followed)."""
|
|
96
|
+
if path.is_symlink():
|
|
97
|
+
target = os.readlink(path)
|
|
98
|
+
return {"sha256": sha256_bytes(target.encode("utf-8", "surrogateescape")), "size": 0, "symlink": target}
|
|
99
|
+
return {"sha256": sha256_file(path), "size": path.stat().st_size}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load(path: str | Path) -> dict:
|
|
103
|
+
manifest = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
104
|
+
if manifest.get("format") != FORMAT:
|
|
105
|
+
raise RecordError(f"{path}: not an OBAAF manifest ({manifest.get('format')!r})")
|
|
106
|
+
return manifest
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check(root: str | Path, manifest: dict) -> list[str]:
|
|
110
|
+
"""Differences between the files on disk and the manifest (empty when they match)."""
|
|
111
|
+
root = Path(root)
|
|
112
|
+
recorded = {e["path"]: e for e in manifest["entries"]}
|
|
113
|
+
current = _tracked_files(root, manifest["components"])
|
|
114
|
+
problems = []
|
|
115
|
+
for rel in sorted(set(recorded) - set(current)):
|
|
116
|
+
problems.append(f"missing: {rel} is in the manifest but not on disk")
|
|
117
|
+
for rel in sorted(set(current) - set(recorded)):
|
|
118
|
+
problems.append(f"unrecorded: {rel} ({current[rel]}) is not in the manifest")
|
|
119
|
+
for rel in sorted(set(current) & set(recorded)):
|
|
120
|
+
actual = _describe(root / rel)
|
|
121
|
+
expected = recorded[rel]
|
|
122
|
+
if ("symlink" in actual) != ("symlink" in expected):
|
|
123
|
+
what = "a symbolic link" if "symlink" in actual else "a regular file"
|
|
124
|
+
problems.append(f"changed: {rel} is now {what}")
|
|
125
|
+
elif "symlink" in actual and actual["symlink"] != expected["symlink"]:
|
|
126
|
+
problems.append(f"changed: {rel} now links to {actual['symlink']!r}, the manifest records "
|
|
127
|
+
f"{expected['symlink']!r}")
|
|
128
|
+
elif actual["sha256"] != expected["sha256"]:
|
|
129
|
+
problems.append(f"changed: {rel} has sha256 {actual['sha256']}, the manifest records {expected['sha256']}")
|
|
130
|
+
return problems
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def sdk_versions(manifest: dict) -> list[dict]:
|
|
134
|
+
"""Components that ship to players or build the game, for the build record."""
|
|
135
|
+
return [
|
|
136
|
+
{"name": c["name"], "id": c["id"], "version": c["version"], "kind": c["kind"], "reportedBy": "manifest"}
|
|
137
|
+
for c in manifest["components"]
|
|
138
|
+
if c["kind"] in ("sdk", "engine", "plugin", "middleware")
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def write(manifest: dict, out: str | Path) -> None:
|
|
143
|
+
write_json(out, manifest)
|