1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
use crate::graph::NodeId;
use crate::propagation::PropagationPath;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Critical,
High,
Medium,
Low,
Info,
}
impl Severity {
fn rank(self) -> u8 {
match self {
Severity::Critical => 0,
Severity::High => 1,
Severity::Medium => 2,
Severity::Low => 3,
Severity::Info => 4,
}
}
}
impl Ord for Severity {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
impl PartialOrd for Severity {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
/// MVP categories (1-5) are derivable from pipeline YAML alone.
/// Stretch categories (6-9) need heuristics or metadata enrichment.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingCategory {
// MVP
AuthorityPropagation,
OverPrivilegedIdentity,
UnpinnedAction,
UntrustedWithAuthority,
ArtifactBoundaryCrossing,
// Stretch — implemented
FloatingImage,
LongLivedCredential,
/// Credential written to disk by a step (e.g. `persistCredentials: true` on a checkout).
/// Disk-persisted credentials are accessible to all subsequent steps and any process
/// with filesystem access, unlike runtime-only `HasAccessTo` authority.
PersistedCredential,
/// Dangerous trigger type (pull_request_target / pr) combined with secret/identity access.
TriggerContextMismatch,
/// Authority (secret/identity) flows into an opaque external workflow via DelegatesTo.
CrossWorkflowAuthorityChain,
/// Circular DelegatesTo chain — workflow calls itself transitively.
AuthorityCycle,
/// Privileged workflow (OIDC/broad identity) with no provenance attestation step.
UpliftWithoutAttestation,
/// Step writes to the environment gate ($GITHUB_ENV, pipeline variables) — authority can propagate.
SelfMutatingPipeline,
/// PR-triggered pipeline checks out the repository — attacker-controlled fork code lands on the runner.
CheckoutSelfPrExposure,
/// ADO variable group consumed by a PR-triggered job, crossing trust boundary.
VariableGroupInPrJob,
/// Self-hosted agent pool used in a PR-triggered job that also checks out the repository.
SelfHostedPoolPrHijack,
/// Broad-scope ADO service connection reachable from a PR-triggered job without OIDC.
ServiceConnectionScopeMismatch,
/// ADO `resources.repositories[]` entry referenced by an `extends:`,
/// `template: x@alias`, or `checkout: alias` consumer resolves with no
/// `ref:` (default branch) or a mutable branch ref (`refs/heads/<name>`).
/// Whoever owns that branch can inject steps into the consuming pipeline.
TemplateExtendsUnpinnedBranch,
/// Pipeline step uses an Azure VM remote-exec primitive (Set-AzVMExtension /
/// CustomScriptExtension, Invoke-AzVMRunCommand, az vm run-command, az vm extension set)
/// where the executed command line interpolates a pipeline secret or a SAS token —
/// pipeline-to-VM lateral movement primitive logged in plaintext to the VM and ARM.
VmRemoteExecViaPipelineSecret,
/// A SAS token freshly minted in-pipeline is interpolated into a CLI argument
/// (commandToExecute / scriptArguments / --arguments / -ArgumentList) instead of
/// passed via env var or stdin — argv ends up in /proc/*/cmdline, ETW, ARM status.
ShortLivedSasInCommandLine,
/// Pipeline secret value assigned to a shell variable inside an inline
/// script (`export VAR=$(SECRET)`, `$X = "$(SECRET)"`). Once the value
/// transits a shell variable, ADO's `$(SECRET)` log mask no longer
/// applies — transcripts (`Start-Transcript`, `bash -x`, terraform debug
/// logs) print the cleartext.
SecretToInlineScriptEnvExport,
/// Pipeline secret value written to a file under the agent workspace
/// (`$(System.DefaultWorkingDirectory)`, `$(Build.SourcesDirectory)`,
/// or relative paths) without `secureFile` task or chmod 600. The file
/// persists in the agent workspace and is uploaded by
/// `PublishPipelineArtifact` and crawlable by later steps.
SecretMaterialisedToWorkspaceFile,
/// PowerShell pulls a Key Vault secret with `-AsPlainText` (or
/// `ConvertFrom-SecureString -AsPlainText`, or older
/// `.SecretValueText` syntax) into a non-`SecureString` variable. The
/// value never traverses the ADO variable-group boundary, so verbose
/// Az/PS logging and error stack traces print the credential.
///
/// Rule id is `keyvault_secret_to_plaintext` (single token "keyvault")
/// rather than the snake_case derivation `key_vault_…` — matches the
/// docs filename and the convention used in the corpus evidence.
#[serde(rename = "keyvault_secret_to_plaintext")]
KeyVaultSecretToPlaintext,
/// `terraform apply -auto-approve` against a production-named service connection
/// without an environment approval gate.
TerraformAutoApproveInProd,
/// `AzureCLI@2` task with `addSpnToEnvironment: true` AND an inline script —
/// the script can launder federated SPN/OIDC tokens into pipeline variables.
AddSpnWithInlineScript,
/// A `type: string` pipeline parameter (no `values:` allowlist) is interpolated
/// via `${{ parameters.X }}` into an inline shell/PowerShell script body —
/// shell injection vector for anyone with "queue build".
ParameterInterpolationIntoShell,
// Reserved — requires ADO/GH API enrichment beyond pipeline YAML
/// Requires runtime network telemetry or policy enrichment — not detectable from YAML alone.
#[doc(hidden)]
EgressBlindspot,
/// Requires external audit-sink configuration data — not detectable from YAML alone.
#[doc(hidden)]
MissingAuditTrail,
}
/// Routing: scope findings -> TsafeRemediation; isolation findings -> CellosRemediation.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Recommendation {
TsafeRemediation {
command: String,
explanation: String,
},
CellosRemediation {
reason: String,
spec_hint: String,
},
PinAction {
current: String,
pinned: String,
},
ReducePermissions {
current: String,
minimum: String,
},
FederateIdentity {
static_secret: String,
oidc_provider: String,
},
Manual {
action: String,
},
}
/// A finding is a concrete, actionable authority issue.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub severity: Severity,
pub category: FindingCategory,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<PropagationPath>,
pub nodes_involved: Vec<NodeId>,
pub message: String,
pub recommendation: Recommendation,
}