Skip to main content

taudit_core/
finding.rs

1use crate::graph::NodeId;
2use crate::propagation::PropagationPath;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum Severity {
8    Critical,
9    High,
10    Medium,
11    Low,
12    Info,
13}
14
15impl Severity {
16    fn rank(self) -> u8 {
17        match self {
18            Severity::Critical => 0,
19            Severity::High => 1,
20            Severity::Medium => 2,
21            Severity::Low => 3,
22            Severity::Info => 4,
23        }
24    }
25}
26
27impl Ord for Severity {
28    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
29        self.rank().cmp(&other.rank())
30    }
31}
32
33impl PartialOrd for Severity {
34    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
35        Some(self.cmp(other))
36    }
37}
38
39/// MVP categories (1-5) are derivable from pipeline YAML alone.
40/// Stretch categories (6-9) need heuristics or metadata enrichment.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum FindingCategory {
44    // MVP
45    AuthorityPropagation,
46    OverPrivilegedIdentity,
47    UnpinnedAction,
48    UntrustedWithAuthority,
49    ArtifactBoundaryCrossing,
50    // Stretch — implemented
51    FloatingImage,
52    LongLivedCredential,
53    /// Credential written to disk by a step (e.g. `persistCredentials: true` on a checkout).
54    /// Disk-persisted credentials are accessible to all subsequent steps and any process
55    /// with filesystem access, unlike runtime-only `HasAccessTo` authority.
56    PersistedCredential,
57    /// Dangerous trigger type (pull_request_target / pr) combined with secret/identity access.
58    TriggerContextMismatch,
59    /// Authority (secret/identity) flows into an opaque external workflow via DelegatesTo.
60    CrossWorkflowAuthorityChain,
61    /// Circular DelegatesTo chain — workflow calls itself transitively.
62    AuthorityCycle,
63    /// Privileged workflow (OIDC/broad identity) with no provenance attestation step.
64    UpliftWithoutAttestation,
65    /// Step writes to the environment gate ($GITHUB_ENV, pipeline variables) — authority can propagate.
66    SelfMutatingPipeline,
67    /// PR-triggered pipeline checks out the repository — attacker-controlled fork code lands on the runner.
68    CheckoutSelfPrExposure,
69    /// ADO variable group consumed by a PR-triggered job, crossing trust boundary.
70    VariableGroupInPrJob,
71    /// Self-hosted agent pool used in a PR-triggered job that also checks out the repository.
72    SelfHostedPoolPrHijack,
73    /// Broad-scope ADO service connection reachable from a PR-triggered job without OIDC.
74    ServiceConnectionScopeMismatch,
75    // Reserved — requires ADO/GH API enrichment beyond pipeline YAML
76    /// Requires runtime network telemetry or policy enrichment — not detectable from YAML alone.
77    #[doc(hidden)]
78    EgressBlindspot,
79    /// Requires external audit-sink configuration data — not detectable from YAML alone.
80    #[doc(hidden)]
81    MissingAuditTrail,
82}
83
84/// Routing: scope findings -> TsafeRemediation; isolation findings -> CellosRemediation.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(tag = "type", rename_all = "snake_case")]
87pub enum Recommendation {
88    TsafeRemediation {
89        command: String,
90        explanation: String,
91    },
92    CellosRemediation {
93        reason: String,
94        spec_hint: String,
95    },
96    PinAction {
97        current: String,
98        pinned: String,
99    },
100    ReducePermissions {
101        current: String,
102        minimum: String,
103    },
104    FederateIdentity {
105        static_secret: String,
106        oidc_provider: String,
107    },
108    Manual {
109        action: String,
110    },
111}
112
113/// A finding is a concrete, actionable authority issue.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct Finding {
116    pub severity: Severity,
117    pub category: FindingCategory,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub path: Option<PropagationPath>,
120    pub nodes_involved: Vec<NodeId>,
121    pub message: String,
122    pub recommendation: Recommendation,
123}