1use camino::Utf8PathBuf;
2use chrono::{DateTime, Utc};
3use clap::ValueEnum;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7pub type SchemaVersion = u32;
8pub const SCHEMA_VERSION: SchemaVersion = 1;
9pub const GH_LOG_SCHEMA_VERSION: SchemaVersion = 1;
10
11#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "lowercase")]
13pub enum OutputFormat {
14 Text,
15 Json,
16 Markdown,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ToolInfo {
21 pub name: String,
22 pub version: String,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum CheckStatus {
28 Pass,
29 Warn,
30 Fail,
31 Skip,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Check {
36 pub id: String,
37 pub status: CheckStatus,
38 pub message: String,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub location: Option<String>,
41}
42
43impl Check {
44 pub fn pass(id: impl Into<String>, message: impl Into<String>) -> Self {
45 Self {
46 id: id.into(),
47 status: CheckStatus::Pass,
48 message: message.into(),
49 location: None,
50 }
51 }
52
53 pub fn warn(id: impl Into<String>, message: impl Into<String>) -> Self {
54 Self {
55 id: id.into(),
56 status: CheckStatus::Warn,
57 message: message.into(),
58 location: None,
59 }
60 }
61
62 pub fn fail(id: impl Into<String>, message: impl Into<String>) -> Self {
63 Self {
64 id: id.into(),
65 status: CheckStatus::Fail,
66 message: message.into(),
67 location: None,
68 }
69 }
70
71 pub fn skip(id: impl Into<String>, message: impl Into<String>) -> Self {
72 Self {
73 id: id.into(),
74 status: CheckStatus::Skip,
75 message: message.into(),
76 location: None,
77 }
78 }
79
80 pub fn at(mut self, location: impl Into<String>) -> Self {
81 self.location = Some(location.into());
82 self
83 }
84}
85
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
87pub struct ReceiptSummary {
88 pub passed: usize,
89 pub warnings: usize,
90 pub failed: usize,
91 pub skipped: usize,
92}
93
94impl ReceiptSummary {
95 pub fn from_checks(checks: &[Check]) -> Self {
96 let mut summary = Self::default();
97 for check in checks {
98 match check.status {
99 CheckStatus::Pass => summary.passed += 1,
100 CheckStatus::Warn => summary.warnings += 1,
101 CheckStatus::Fail => summary.failed += 1,
102 CheckStatus::Skip => summary.skipped += 1,
103 }
104 }
105 summary
106 }
107
108 pub fn add(&mut self, other: &Self) {
109 self.passed += other.passed;
110 self.warnings += other.warnings;
111 self.failed += other.failed;
112 self.skipped += other.skipped;
113 }
114
115 pub fn merge_checks(&mut self, checks: &[Check]) {
116 let other = Self::from_checks(checks);
117 self.add(&other);
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)]
122#[serde(rename_all = "kebab-case")]
123pub enum PermissionKey {
124 Actions,
125 Attestations,
126 Checks,
127 Contents,
128 Deployments,
129 Discussions,
130 IdToken,
131 Issues,
132 Models,
133 Packages,
134 Pages,
135 PullRequests,
136 RepositoryProjects,
137 SecurityEvents,
138 Statuses,
139}
140
141impl PermissionKey {
142 pub fn as_str(&self) -> &'static str {
143 match self {
144 Self::Actions => "actions",
145 Self::Attestations => "attestations",
146 Self::Checks => "checks",
147 Self::Contents => "contents",
148 Self::Deployments => "deployments",
149 Self::Discussions => "discussions",
150 Self::IdToken => "id-token",
151 Self::Issues => "issues",
152 Self::Models => "models",
153 Self::Packages => "packages",
154 Self::Pages => "pages",
155 Self::PullRequests => "pull-requests",
156 Self::RepositoryProjects => "repository-projects",
157 Self::SecurityEvents => "security-events",
158 Self::Statuses => "statuses",
159 }
160 }
161
162 pub fn parse(value: &str) -> Option<Self> {
163 match value.trim() {
164 "actions" => Some(Self::Actions),
165 "attestations" => Some(Self::Attestations),
166 "checks" => Some(Self::Checks),
167 "contents" => Some(Self::Contents),
168 "deployments" => Some(Self::Deployments),
169 "discussions" => Some(Self::Discussions),
170 "id-token" => Some(Self::IdToken),
171 "issues" => Some(Self::Issues),
172 "models" => Some(Self::Models),
173 "packages" => Some(Self::Packages),
174 "pages" => Some(Self::Pages),
175 "pull-requests" => Some(Self::PullRequests),
176 "repository-projects" => Some(Self::RepositoryProjects),
177 "security-events" => Some(Self::SecurityEvents),
178 "statuses" => Some(Self::Statuses),
179 _ => None,
180 }
181 }
182
183 pub fn all() -> &'static [PermissionKey] {
184 &[
185 Self::Actions,
186 Self::Attestations,
187 Self::Checks,
188 Self::Contents,
189 Self::Deployments,
190 Self::Discussions,
191 Self::IdToken,
192 Self::Issues,
193 Self::Models,
194 Self::Packages,
195 Self::Pages,
196 Self::PullRequests,
197 Self::RepositoryProjects,
198 Self::SecurityEvents,
199 Self::Statuses,
200 ]
201 }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
205#[serde(rename_all = "kebab-case")]
206pub enum PermissionLevel {
207 None,
208 Read,
209 Write,
210}
211
212impl PermissionLevel {
213 pub fn as_str(&self) -> &'static str {
214 match self {
215 Self::None => "none",
216 Self::Read => "read",
217 Self::Write => "write",
218 }
219 }
220
221 pub fn parse(value: &str) -> Option<Self> {
222 match value.trim() {
223 "none" => Some(Self::None),
224 "read" => Some(Self::Read),
225 "write" => Some(Self::Write),
226 _ => None,
227 }
228 }
229
230 pub fn satisfies(&self, required: PermissionLevel) -> bool {
231 *self >= required
232 }
233}
234
235#[derive(Debug, Clone, Default, Serialize, Deserialize)]
236pub struct PermissionSet {
237 pub entries: BTreeMap<String, PermissionLevel>,
238 #[serde(default, skip_serializing_if = "Vec::is_empty")]
239 pub unknown_keys: Vec<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub shorthand: Option<String>,
242}
243
244impl PermissionSet {
245 pub fn level(&self, key: PermissionKey) -> PermissionLevel {
246 self.entries
247 .get(key.as_str())
248 .copied()
249 .unwrap_or(PermissionLevel::None)
250 }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct PermissionResolution {
255 pub scope: PermissionScope,
256 pub workflow_permissions: Option<PermissionSet>,
257 pub job_permissions: Option<PermissionSet>,
258 pub effective: PermissionSet,
259 pub source: PermissionSource,
260 pub checks: Vec<Check>,
261}
262
263#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
264#[serde(rename_all = "kebab-case")]
265pub enum PermissionScope {
266 Workflow,
267 Job,
268}
269
270#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
271#[serde(rename_all = "kebab-case")]
272pub enum PermissionSource {
273 JobBlock,
274 WorkflowBlock,
275 DefaultRestricted,
276}
277
278#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
279#[serde(rename_all = "kebab-case")]
280pub enum Compatibility {
281 Exact,
282 Simulated,
283 Unsupported,
284}
285
286impl Compatibility {
287 pub fn as_str(&self) -> &'static str {
288 match self {
289 Self::Exact => "exact",
290 Self::Simulated => "simulated",
291 Self::Unsupported => "unsupported",
292 }
293 }
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct CatalogMatch {
298 pub endpoint_id: String,
299 pub method: String,
300 pub path_template: String,
301 pub category: String,
302 pub required_permissions: Vec<RequiredPermission>,
303 pub classification: Compatibility,
304 pub side_effect: String,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct RequiredPermission {
309 pub key: PermissionKey,
310 pub level: PermissionLevel,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct CallReport {
315 pub method: String,
316 pub path: String,
317 #[serde(skip_serializing_if = "Option::is_none")]
318 pub url: Option<String>,
319 pub classification: Compatibility,
320 #[serde(skip_serializing_if = "Option::is_none")]
321 pub catalog_match: Option<CatalogMatch>,
322 pub unsupported_reason: Option<String>,
323 pub permissions: Option<PermissionSet>,
324 pub satisfied: bool,
325 pub missing_permissions: Vec<RequiredPermission>,
326 pub checks: Vec<Check>,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 pub origin: Option<String>,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct WorkflowReport {
333 pub workflow: Utf8PathBuf,
334 pub workflow_permissions: Option<PermissionSet>,
335 pub jobs: Vec<JobApiReport>,
336 pub summary: ReceiptSummary,
337 pub checks: Vec<Check>,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct JobApiReport {
342 pub job_id: String,
343 pub permissions: PermissionResolution,
344 pub steps: Vec<StepApiReport>,
345 pub summary: ReceiptSummary,
346 pub checks: Vec<Check>,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct StepApiReport {
351 pub step_index: usize,
352 pub step_name: Option<String>,
353 pub uses: Option<String>,
354 pub detections: Vec<ApiDetection>,
355 pub checks: Vec<Check>,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct ApiDetection {
360 pub origin: ApiDetectionOrigin,
361 pub label: String,
362 pub method: String,
363 pub path: String,
364 pub classification: Compatibility,
365 #[serde(skip_serializing_if = "Option::is_none")]
366 pub catalog_match: Option<CatalogMatch>,
367 pub satisfied: bool,
368 pub missing_permissions: Vec<RequiredPermission>,
369 pub unsupported_reason: Option<String>,
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "kebab-case")]
374pub enum ApiDetectionOrigin {
375 GhCli,
376 Curl,
377 GithubScript,
378 ReleaseAction,
379 OidcAction,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct OidcReport {
384 pub audience: String,
385 pub repository: String,
386 pub git_ref: String,
387 pub sha: String,
388 pub workflow: String,
389 pub job: String,
390 pub job_workflow_ref: String,
391 pub run_id: String,
392 pub iat: i64,
393 pub exp: i64,
394 pub claims: BTreeMap<String, serde_json::Value>,
395 pub token: String,
396 pub signing_mode: String,
397 pub compatibility: Compatibility,
398 pub permissions: Option<PermissionSet>,
399 pub checks: Vec<Check>,
400 pub warning: String,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct GhLogReport {
405 pub schema_version: SchemaVersion,
406 pub tool: ToolInfo,
407 pub captured_at: Option<DateTime<Utc>>,
408 pub call_count: usize,
409 pub redaction_enforced: bool,
410 pub calls: Vec<CallReport>,
411 pub summary: ReceiptSummary,
412 pub checks: Vec<Check>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct GithubServiceReceipt {
417 pub schema_version: SchemaVersion,
418 pub tool: ToolInfo,
419 pub checked_at: DateTime<Utc>,
420 pub mode: String,
421 pub summary: ReceiptSummary,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub permissions: Option<PermissionResolution>,
424 #[serde(default, skip_serializing_if = "Vec::is_empty")]
425 pub workflows: Vec<WorkflowReport>,
426 #[serde(default, skip_serializing_if = "Vec::is_empty")]
427 pub calls: Vec<CallReport>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub oidc: Option<OidcReport>,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub gh_log: Option<GhLogReport>,
432 pub checks: Vec<Check>,
433}