1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4pub type NodeId = usize;
6
7pub type EdgeId = usize;
9
10pub const META_DIGEST: &str = "digest";
14pub const META_PERMISSIONS: &str = "permissions";
15pub const META_IDENTITY_SCOPE: &str = "identity_scope";
16pub const META_INFERRED: &str = "inferred";
17pub const META_CONTAINER: &str = "container";
19pub const META_OIDC: &str = "oidc";
21pub const META_CLI_FLAG_EXPOSED: &str = "cli_flag_exposed";
25pub const META_TRIGGER: &str = "trigger";
27pub const META_WRITES_ENV_GATE: &str = "writes_env_gate";
29pub const META_ATTESTS: &str = "attests";
31pub const META_VARIABLE_GROUP: &str = "variable_group";
33pub const META_SELF_HOSTED: &str = "self_hosted";
35pub const META_CHECKOUT_SELF: &str = "checkout_self";
37pub const META_SERVICE_CONNECTION: &str = "service_connection";
39pub const META_IMPLICIT: &str = "implicit";
43pub const META_ENV_APPROVAL: &str = "env_approval";
48pub const META_JOB_NAME: &str = "job_name";
53
54pub fn is_sha_pinned(ref_str: &str) -> bool {
60 ref_str.contains('@')
61 && ref_str
62 .split('@')
63 .next_back()
64 .map(|s| s.len() >= 40 && s.chars().all(|c| c.is_ascii_hexdigit()))
65 .unwrap_or(false)
66}
67
68pub fn is_docker_digest_pinned(image: &str) -> bool {
71 image.contains("@sha256:")
72 && image
73 .split("@sha256:")
74 .nth(1)
75 .map(|h| h.len() == 64 && h.chars().all(|c| c.is_ascii_hexdigit()))
76 .unwrap_or(false)
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum AuthorityCompleteness {
89 Complete,
91 Partial,
95 Unknown,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum IdentityScope {
104 Broad,
106 Constrained,
108 Unknown,
110}
111
112impl IdentityScope {
113 pub fn from_permissions(perms: &str) -> Self {
115 let p = perms.to_lowercase();
116 if p.contains("write-all") || p.contains("admin") || p == "{}" || p.is_empty() {
117 IdentityScope::Broad
118 } else if p.contains("write") {
119 IdentityScope::Broad
121 } else if p.contains("read") {
122 IdentityScope::Constrained
123 } else {
124 IdentityScope::Unknown
125 }
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum NodeKind {
135 Step,
136 Secret,
137 Artifact,
138 Identity,
139 Image,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum TrustZone {
146 FirstParty,
148 ThirdParty,
150 Untrusted,
152}
153
154impl TrustZone {
155 pub fn is_lower_than(&self, other: &TrustZone) -> bool {
157 self.rank() < other.rank()
158 }
159
160 fn rank(&self) -> u8 {
161 match self {
162 TrustZone::FirstParty => 2,
163 TrustZone::ThirdParty => 1,
164 TrustZone::Untrusted => 0,
165 }
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct Node {
172 pub id: NodeId,
173 pub kind: NodeKind,
174 pub name: String,
175 pub trust_zone: TrustZone,
176 pub metadata: HashMap<String, String>,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum EdgeKind {
187 HasAccessTo,
189 Produces,
191 Consumes,
193 UsesImage,
195 DelegatesTo,
197 PersistsTo,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct Edge {
206 pub id: EdgeId,
207 pub from: NodeId,
208 pub to: NodeId,
209 pub kind: EdgeKind,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct PipelineSource {
217 pub file: String,
218 #[serde(skip_serializing_if = "Option::is_none")]
219 pub repo: Option<String>,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub git_ref: Option<String>,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct AuthorityGraph {
230 pub source: PipelineSource,
231 pub nodes: Vec<Node>,
232 pub edges: Vec<Edge>,
233 pub completeness: AuthorityCompleteness,
235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
237 pub completeness_gaps: Vec<String>,
238 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
240 pub metadata: HashMap<String, String>,
241}
242
243impl AuthorityGraph {
244 pub fn new(source: PipelineSource) -> Self {
245 Self {
246 source,
247 nodes: Vec::new(),
248 edges: Vec::new(),
249 completeness: AuthorityCompleteness::Complete,
250 completeness_gaps: Vec::new(),
251 metadata: HashMap::new(),
252 }
253 }
254
255 pub fn mark_partial(&mut self, reason: impl Into<String>) {
257 self.completeness = AuthorityCompleteness::Partial;
258 self.completeness_gaps.push(reason.into());
259 }
260
261 pub fn add_node(
263 &mut self,
264 kind: NodeKind,
265 name: impl Into<String>,
266 trust_zone: TrustZone,
267 ) -> NodeId {
268 let id = self.nodes.len();
269 self.nodes.push(Node {
270 id,
271 kind,
272 name: name.into(),
273 trust_zone,
274 metadata: HashMap::new(),
275 });
276 id
277 }
278
279 pub fn add_node_with_metadata(
281 &mut self,
282 kind: NodeKind,
283 name: impl Into<String>,
284 trust_zone: TrustZone,
285 metadata: HashMap<String, String>,
286 ) -> NodeId {
287 let id = self.nodes.len();
288 self.nodes.push(Node {
289 id,
290 kind,
291 name: name.into(),
292 trust_zone,
293 metadata,
294 });
295 id
296 }
297
298 pub fn add_edge(&mut self, from: NodeId, to: NodeId, kind: EdgeKind) -> EdgeId {
300 let id = self.edges.len();
301 self.edges.push(Edge { id, from, to, kind });
302 id
303 }
304
305 pub fn edges_from(&self, id: NodeId) -> impl Iterator<Item = &Edge> {
307 self.edges.iter().filter(move |e| e.from == id)
308 }
309
310 pub fn edges_to(&self, id: NodeId) -> impl Iterator<Item = &Edge> {
312 self.edges.iter().filter(move |e| e.to == id)
313 }
314
315 pub fn authority_sources(&self) -> impl Iterator<Item = &Node> {
318 self.nodes
319 .iter()
320 .filter(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
321 }
322
323 pub fn nodes_of_kind(&self, kind: NodeKind) -> impl Iterator<Item = &Node> {
325 self.nodes.iter().filter(move |n| n.kind == kind)
326 }
327
328 pub fn nodes_in_zone(&self, zone: TrustZone) -> impl Iterator<Item = &Node> {
330 self.nodes.iter().filter(move |n| n.trust_zone == zone)
331 }
332
333 pub fn node(&self, id: NodeId) -> Option<&Node> {
335 self.nodes.get(id)
336 }
337
338 pub fn edge(&self, id: EdgeId) -> Option<&Edge> {
340 self.edges.get(id)
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn build_simple_graph() {
350 let mut g = AuthorityGraph::new(PipelineSource {
351 file: "deploy.yml".into(),
352 repo: None,
353 git_ref: None,
354 });
355
356 let secret = g.add_node(NodeKind::Secret, "AWS_KEY", TrustZone::FirstParty);
357 let step_build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
358 let artifact = g.add_node(NodeKind::Artifact, "dist.tar.gz", TrustZone::FirstParty);
359 let step_deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
360
361 g.add_edge(step_build, secret, EdgeKind::HasAccessTo);
362 g.add_edge(step_build, artifact, EdgeKind::Produces);
363 g.add_edge(artifact, step_deploy, EdgeKind::Consumes);
364
365 assert_eq!(g.nodes.len(), 4);
366 assert_eq!(g.edges.len(), 3);
367 assert_eq!(g.authority_sources().count(), 1);
368 assert_eq!(g.edges_from(step_build).count(), 2);
369 assert_eq!(g.edges_from(artifact).count(), 1); }
371
372 #[test]
373 fn completeness_default_is_complete() {
374 let g = AuthorityGraph::new(PipelineSource {
375 file: "test.yml".into(),
376 repo: None,
377 git_ref: None,
378 });
379 assert_eq!(g.completeness, AuthorityCompleteness::Complete);
380 assert!(g.completeness_gaps.is_empty());
381 }
382
383 #[test]
384 fn mark_partial_records_reason() {
385 let mut g = AuthorityGraph::new(PipelineSource {
386 file: "test.yml".into(),
387 repo: None,
388 git_ref: None,
389 });
390 g.mark_partial("secrets in run: block inferred, not precisely mapped");
391 assert_eq!(g.completeness, AuthorityCompleteness::Partial);
392 assert_eq!(g.completeness_gaps.len(), 1);
393 }
394
395 #[test]
396 fn identity_scope_from_permissions() {
397 assert_eq!(
398 IdentityScope::from_permissions("write-all"),
399 IdentityScope::Broad
400 );
401 assert_eq!(
402 IdentityScope::from_permissions("{ contents: write }"),
403 IdentityScope::Broad
404 );
405 assert_eq!(
406 IdentityScope::from_permissions("{ contents: read }"),
407 IdentityScope::Constrained
408 );
409 assert_eq!(
410 IdentityScope::from_permissions("{ id-token: write }"),
411 IdentityScope::Broad
412 );
413 assert_eq!(IdentityScope::from_permissions(""), IdentityScope::Broad);
414 assert_eq!(
415 IdentityScope::from_permissions("custom-scope"),
416 IdentityScope::Unknown
417 );
418 }
419
420 #[test]
421 fn trust_zone_ordering() {
422 assert!(TrustZone::Untrusted.is_lower_than(&TrustZone::FirstParty));
423 assert!(TrustZone::ThirdParty.is_lower_than(&TrustZone::FirstParty));
424 assert!(TrustZone::Untrusted.is_lower_than(&TrustZone::ThirdParty));
425 assert!(!TrustZone::FirstParty.is_lower_than(&TrustZone::FirstParty));
426 }
427}