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 /// ADO `resources.repositories[]` entry referenced by an `extends:`,
76 /// `template: x@alias`, or `checkout: alias` consumer resolves with no
77 /// `ref:` (default branch) or a mutable branch ref (`refs/heads/<name>`).
78 /// Whoever owns that branch can inject steps into the consuming pipeline.
79 TemplateExtendsUnpinnedBranch,
80 /// Pipeline step uses an Azure VM remote-exec primitive (Set-AzVMExtension /
81 /// CustomScriptExtension, Invoke-AzVMRunCommand, az vm run-command, az vm extension set)
82 /// where the executed command line interpolates a pipeline secret or a SAS token —
83 /// pipeline-to-VM lateral movement primitive logged in plaintext to the VM and ARM.
84 VmRemoteExecViaPipelineSecret,
85 /// A SAS token freshly minted in-pipeline is interpolated into a CLI argument
86 /// (commandToExecute / scriptArguments / --arguments / -ArgumentList) instead of
87 /// passed via env var or stdin — argv ends up in /proc/*/cmdline, ETW, ARM status.
88 ShortLivedSasInCommandLine,
89 /// Pipeline secret value assigned to a shell variable inside an inline
90 /// script (`export VAR=$(SECRET)`, `$X = "$(SECRET)"`). Once the value
91 /// transits a shell variable, ADO's `$(SECRET)` log mask no longer
92 /// applies — transcripts (`Start-Transcript`, `bash -x`, terraform debug
93 /// logs) print the cleartext.
94 SecretToInlineScriptEnvExport,
95 /// Pipeline secret value written to a file under the agent workspace
96 /// (`$(System.DefaultWorkingDirectory)`, `$(Build.SourcesDirectory)`,
97 /// or relative paths) without `secureFile` task or chmod 600. The file
98 /// persists in the agent workspace and is uploaded by
99 /// `PublishPipelineArtifact` and crawlable by later steps.
100 SecretMaterialisedToWorkspaceFile,
101 /// PowerShell pulls a Key Vault secret with `-AsPlainText` (or
102 /// `ConvertFrom-SecureString -AsPlainText`, or older
103 /// `.SecretValueText` syntax) into a non-`SecureString` variable. The
104 /// value never traverses the ADO variable-group boundary, so verbose
105 /// Az/PS logging and error stack traces print the credential.
106 ///
107 /// Rule id is `keyvault_secret_to_plaintext` (single token "keyvault")
108 /// rather than the snake_case derivation `key_vault_…` — matches the
109 /// docs filename and the convention used in the corpus evidence.
110 #[serde(rename = "keyvault_secret_to_plaintext")]
111 KeyVaultSecretToPlaintext,
112 /// `terraform apply -auto-approve` against a production-named service connection
113 /// without an environment approval gate.
114 TerraformAutoApproveInProd,
115 /// `AzureCLI@2` task with `addSpnToEnvironment: true` AND an inline script —
116 /// the script can launder federated SPN/OIDC tokens into pipeline variables.
117 AddSpnWithInlineScript,
118 /// A `type: string` pipeline parameter (no `values:` allowlist) is interpolated
119 /// via `${{ parameters.X }}` into an inline shell/PowerShell script body —
120 /// shell injection vector for anyone with "queue build".
121 ParameterInterpolationIntoShell,
122 // Reserved — requires ADO/GH API enrichment beyond pipeline YAML
123 /// Requires runtime network telemetry or policy enrichment — not detectable from YAML alone.
124 #[doc(hidden)]
125 EgressBlindspot,
126 /// Requires external audit-sink configuration data — not detectable from YAML alone.
127 #[doc(hidden)]
128 MissingAuditTrail,
129}
130
131/// Routing: scope findings -> TsafeRemediation; isolation findings -> CellosRemediation.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(tag = "type", rename_all = "snake_case")]
134pub enum Recommendation {
135 TsafeRemediation {
136 command: String,
137 explanation: String,
138 },
139 CellosRemediation {
140 reason: String,
141 spec_hint: String,
142 },
143 PinAction {
144 current: String,
145 pinned: String,
146 },
147 ReducePermissions {
148 current: String,
149 minimum: String,
150 },
151 FederateIdentity {
152 static_secret: String,
153 oidc_provider: String,
154 },
155 Manual {
156 action: String,
157 },
158}
159
160/// A finding is a concrete, actionable authority issue.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct Finding {
163 pub severity: Severity,
164 pub category: FindingCategory,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub path: Option<PropagationPath>,
167 pub nodes_involved: Vec<NodeId>,
168 pub message: String,
169 pub recommendation: Recommendation,
170}