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";
43
44pub fn is_sha_pinned(ref_str: &str) -> bool {
50 ref_str.contains('@')
51 && ref_str
52 .split('@')
53 .next_back()
54 .map(|s| s.len() >= 40 && s.chars().all(|c| c.is_ascii_hexdigit()))
55 .unwrap_or(false)
56}
57
58pub fn is_docker_digest_pinned(image: &str) -> bool {
61 image.contains("@sha256:")
62 && image
63 .split("@sha256:")
64 .nth(1)
65 .map(|h| h.len() == 64 && h.chars().all(|c| c.is_ascii_hexdigit()))
66 .unwrap_or(false)
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum AuthorityCompleteness {
79 Complete,
81 Partial,
85 Unknown,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum IdentityScope {
94 Broad,
96 Constrained,
98 Unknown,
100}
101
102impl IdentityScope {
103 pub fn from_permissions(perms: &str) -> Self {
105 let p = perms.to_lowercase();
106 if p.contains("write-all") || p.contains("admin") || p == "{}" || p.is_empty() {
107 IdentityScope::Broad
108 } else if p.contains("write") {
109 IdentityScope::Broad
111 } else if p.contains("read") {
112 IdentityScope::Constrained
113 } else {
114 IdentityScope::Unknown
115 }
116 }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum NodeKind {
125 Step,
126 Secret,
127 Artifact,
128 Identity,
129 Image,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub enum TrustZone {
136 FirstParty,
138 ThirdParty,
140 Untrusted,
142}
143
144impl TrustZone {
145 pub fn is_lower_than(&self, other: &TrustZone) -> bool {
147 self.rank() < other.rank()
148 }
149
150 fn rank(&self) -> u8 {
151 match self {
152 TrustZone::FirstParty => 2,
153 TrustZone::ThirdParty => 1,
154 TrustZone::Untrusted => 0,
155 }
156 }
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct Node {
162 pub id: NodeId,
163 pub kind: NodeKind,
164 pub name: String,
165 pub trust_zone: TrustZone,
166 pub metadata: HashMap<String, String>,
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum EdgeKind {
177 HasAccessTo,
179 Produces,
181 Consumes,
183 UsesImage,
185 DelegatesTo,
187 PersistsTo,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct Edge {
196 pub id: EdgeId,
197 pub from: NodeId,
198 pub to: NodeId,
199 pub kind: EdgeKind,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct PipelineSource {
207 pub file: String,
208 #[serde(skip_serializing_if = "Option::is_none")]
209 pub repo: Option<String>,
210 #[serde(skip_serializing_if = "Option::is_none")]
211 pub git_ref: Option<String>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct AuthorityGraph {
220 pub source: PipelineSource,
221 pub nodes: Vec<Node>,
222 pub edges: Vec<Edge>,
223 pub completeness: AuthorityCompleteness,
225 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub completeness_gaps: Vec<String>,
228 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
230 pub metadata: HashMap<String, String>,
231}
232
233impl AuthorityGraph {
234 pub fn new(source: PipelineSource) -> Self {
235 Self {
236 source,
237 nodes: Vec::new(),
238 edges: Vec::new(),
239 completeness: AuthorityCompleteness::Complete,
240 completeness_gaps: Vec::new(),
241 metadata: HashMap::new(),
242 }
243 }
244
245 pub fn mark_partial(&mut self, reason: impl Into<String>) {
247 self.completeness = AuthorityCompleteness::Partial;
248 self.completeness_gaps.push(reason.into());
249 }
250
251 pub fn add_node(
253 &mut self,
254 kind: NodeKind,
255 name: impl Into<String>,
256 trust_zone: TrustZone,
257 ) -> NodeId {
258 let id = self.nodes.len();
259 self.nodes.push(Node {
260 id,
261 kind,
262 name: name.into(),
263 trust_zone,
264 metadata: HashMap::new(),
265 });
266 id
267 }
268
269 pub fn add_node_with_metadata(
271 &mut self,
272 kind: NodeKind,
273 name: impl Into<String>,
274 trust_zone: TrustZone,
275 metadata: HashMap<String, String>,
276 ) -> NodeId {
277 let id = self.nodes.len();
278 self.nodes.push(Node {
279 id,
280 kind,
281 name: name.into(),
282 trust_zone,
283 metadata,
284 });
285 id
286 }
287
288 pub fn add_edge(&mut self, from: NodeId, to: NodeId, kind: EdgeKind) -> EdgeId {
290 let id = self.edges.len();
291 self.edges.push(Edge { id, from, to, kind });
292 id
293 }
294
295 pub fn edges_from(&self, id: NodeId) -> impl Iterator<Item = &Edge> {
297 self.edges.iter().filter(move |e| e.from == id)
298 }
299
300 pub fn edges_to(&self, id: NodeId) -> impl Iterator<Item = &Edge> {
302 self.edges.iter().filter(move |e| e.to == id)
303 }
304
305 pub fn authority_sources(&self) -> impl Iterator<Item = &Node> {
308 self.nodes
309 .iter()
310 .filter(|n| matches!(n.kind, NodeKind::Secret | NodeKind::Identity))
311 }
312
313 pub fn nodes_of_kind(&self, kind: NodeKind) -> impl Iterator<Item = &Node> {
315 self.nodes.iter().filter(move |n| n.kind == kind)
316 }
317
318 pub fn nodes_in_zone(&self, zone: TrustZone) -> impl Iterator<Item = &Node> {
320 self.nodes.iter().filter(move |n| n.trust_zone == zone)
321 }
322
323 pub fn node(&self, id: NodeId) -> Option<&Node> {
325 self.nodes.get(id)
326 }
327
328 pub fn edge(&self, id: EdgeId) -> Option<&Edge> {
330 self.edges.get(id)
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn build_simple_graph() {
340 let mut g = AuthorityGraph::new(PipelineSource {
341 file: "deploy.yml".into(),
342 repo: None,
343 git_ref: None,
344 });
345
346 let secret = g.add_node(NodeKind::Secret, "AWS_KEY", TrustZone::FirstParty);
347 let step_build = g.add_node(NodeKind::Step, "build", TrustZone::FirstParty);
348 let artifact = g.add_node(NodeKind::Artifact, "dist.tar.gz", TrustZone::FirstParty);
349 let step_deploy = g.add_node(NodeKind::Step, "deploy", TrustZone::ThirdParty);
350
351 g.add_edge(step_build, secret, EdgeKind::HasAccessTo);
352 g.add_edge(step_build, artifact, EdgeKind::Produces);
353 g.add_edge(artifact, step_deploy, EdgeKind::Consumes);
354
355 assert_eq!(g.nodes.len(), 4);
356 assert_eq!(g.edges.len(), 3);
357 assert_eq!(g.authority_sources().count(), 1);
358 assert_eq!(g.edges_from(step_build).count(), 2);
359 assert_eq!(g.edges_from(artifact).count(), 1); }
361
362 #[test]
363 fn completeness_default_is_complete() {
364 let g = AuthorityGraph::new(PipelineSource {
365 file: "test.yml".into(),
366 repo: None,
367 git_ref: None,
368 });
369 assert_eq!(g.completeness, AuthorityCompleteness::Complete);
370 assert!(g.completeness_gaps.is_empty());
371 }
372
373 #[test]
374 fn mark_partial_records_reason() {
375 let mut g = AuthorityGraph::new(PipelineSource {
376 file: "test.yml".into(),
377 repo: None,
378 git_ref: None,
379 });
380 g.mark_partial("secrets in run: block inferred, not precisely mapped");
381 assert_eq!(g.completeness, AuthorityCompleteness::Partial);
382 assert_eq!(g.completeness_gaps.len(), 1);
383 }
384
385 #[test]
386 fn identity_scope_from_permissions() {
387 assert_eq!(
388 IdentityScope::from_permissions("write-all"),
389 IdentityScope::Broad
390 );
391 assert_eq!(
392 IdentityScope::from_permissions("{ contents: write }"),
393 IdentityScope::Broad
394 );
395 assert_eq!(
396 IdentityScope::from_permissions("{ contents: read }"),
397 IdentityScope::Constrained
398 );
399 assert_eq!(
400 IdentityScope::from_permissions("{ id-token: write }"),
401 IdentityScope::Broad
402 );
403 assert_eq!(IdentityScope::from_permissions(""), IdentityScope::Broad);
404 assert_eq!(
405 IdentityScope::from_permissions("custom-scope"),
406 IdentityScope::Unknown
407 );
408 }
409
410 #[test]
411 fn trust_zone_ordering() {
412 assert!(TrustZone::Untrusted.is_lower_than(&TrustZone::FirstParty));
413 assert!(TrustZone::ThirdParty.is_lower_than(&TrustZone::FirstParty));
414 assert!(TrustZone::Untrusted.is_lower_than(&TrustZone::ThirdParty));
415 assert!(!TrustZone::FirstParty.is_lower_than(&TrustZone::FirstParty));
416 }
417}