OXYGENHUB

Coverage / crates/hub/src/secrets.rs 90.1%

91 of 101 lines run, 23 of 39 functions · run #522 on main · run missed

1
//! Secrets for OxygenFlow jobs (hub spec §13.2, oxygenhub#107): named
2
//! values kept per site, organisation and repository, sealed at rest
3
//! under `keys/secrets.key`. A job gets only the secrets its pipeline
4
//! declares with `secret "name" { mount-path … }`, as files Flow mounts
5
//! (OxygenFlow spec §9), never in the environment; the hub masks each
6
//! value in the job's log as it arrives. Inner levels override outer by
7
//! name, as variables do.
8
9
use crate::db::{now_ms, Pool};
10
use crate::levels::{self, Scope};
11
use crate::Shared;
12
use sqlx::FromRow;
13
14
/// How many a level may hold.
15
pub const MAX_PER_LEVEL: usize = 100;
16
/// The longest value, in bytes.
17
pub const MAX_VALUE: usize = 64 * 1024;
18
/// Values shorter than this are not masked in logs: too many false hits.
19
const MIN_MASKED: usize = 4;
20
21
#[derive(Clone, Debug, FromRow)]
22
pub struct Secret {
23
    pub scope: String,
24
    pub scope_id: i64,
25
    pub name: String,
26
    /// Sealed; `App.secret_key` opens it.
27
    pub value: Vec<u8>,
28
    pub updated_ms: i64,
29
}
30
31
/// A secret's name, as a pipeline names it: 1 to 64 of `A-Z a-z 0-9 . _ -`.
328×
pub fn name_ok(name: &str) -> bool {
3351×
    !name.is_empty() && name.len() <= 64 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b))
348×
}
35
36759×
pub async fn list(pool: &Pool, scope: Scope) -> sqlx::Result<Vec<Secret>> {
37759×
    sqlx::query_as("SELECT * FROM flow_secrets WHERE scope = $1 AND scope_id = $2 ORDER BY name")
38759×
        .bind(scope.kind())
39759×
        .bind(scope.id())
40759×
        .fetch_all(pool)
41759×
        .await
42759×
}
43
44
/// Set a secret at a level, new or changed; `sealed` is the value sealed.
452×
pub async fn set(pool: &Pool, scope: Scope, name: &str, sealed: &[u8]) -> sqlx::Result<()> {
462×
    sqlx::query(
472×
        "INSERT INTO flow_secrets (scope, scope_id, name, value, updated_ms) VALUES ($1, $2, $3, $4, $5) \
482×
         ON CONFLICT (scope, scope_id, name) DO UPDATE SET value = excluded.value, updated_ms = excluded.updated_ms",
492×
    )
502×
    .bind(scope.kind())
512×
    .bind(scope.id())
522×
    .bind(name)
532×
    .bind(sealed)
542×
    .bind(now_ms())
552×
    .execute(pool)
562×
    .await?;
572×
    Ok(())
582×
}
59
60
/// Remove a secret from a level; false when it was not there.
610×
pub async fn delete(pool: &Pool, scope: Scope, name: &str) -> sqlx::Result<bool> {
620×
    let r = sqlx::query("DELETE FROM flow_secrets WHERE scope = $1 AND scope_id = $2 AND name = $3")
630×
        .bind(scope.kind())
640×
        .bind(scope.id())
650×
        .bind(name)
660×
        .execute(pool)
670×
        .await?;
680×
    Ok(r.rows_affected() == 1)
690×
}
70
71
/// Forget a level's secrets, when the organisation or repository goes.
721×
pub async fn delete_scope(pool: &Pool, scope: Scope) -> sqlx::Result<()> {
731×
    sqlx::query("DELETE FROM flow_secrets WHERE scope = $1 AND scope_id = $2").bind(scope.kind()).bind(scope.id()).execute(pool).await?;
741×
    Ok(())
751×
}
76
77
/// A secret as a level sees it: where from, and whether an inner level
78
/// sets the same name. The value stays sealed.
79
#[derive(Clone, Debug)]
80
pub struct Seen {
81
    pub secret: Secret,
82
    /// `the site`, `the acme organisation`, or empty for the level's own.
83
    pub from: String,
84
    pub overridden: bool,
85
}
86
87
/// Every secret a level sees, outermost first.
88420×
pub async fn seen(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<Seen>> {
89420×
    let mut lvls = levels::above(pool, scope, owner).await?;
90420×
    lvls.push((scope, String::new()));
91420×
    let mut out: Vec<Seen> = vec![];
92756×
    for (level, from) in lvls {
93756×
        for s in list(pool, level).await? {
9411×
            for o in out.iter_mut().filter(|o| o.secret.name == s.name) {
954×
                o.overridden = true;
964×
            }
9711×
            out.push(Seen { secret: s, from: from.clone(), overridden: false });
98
        }
99
    }
100420×
    Ok(out)
101420×
}
102
103
/// The values of the named secrets as a repository's jobs get them:
104
/// `(name, value)` for each that is set and opens; names not set are
105
/// left out, as are values sealed under another key.
106160×
pub async fn for_job(app: &Shared, repo: &crate::db::repos::RepoRow, names: &[String]) -> sqlx::Result<Vec<(String, String)>> {
107160×
    let all = seen(&app.db, Scope::Repo(repo.id), &repo.owner).await?;
108160×
    let mut out = vec![];
109160×
    for name in names {
1108×
        if let Some(s) = all.iter().find(|s| !s.overridden && s.secret.name == *name) {
1112×
            if let Some(value) = app.secret_key.open(&s.secret.value) {
1122×
                out.push((name.clone(), value));
1132×
            }
1142×
        }
115
    }
116160×
    Ok(out)
117160×
}
118
119
/// `text` with every secret value (and its standard base64) replaced by
120
/// `***`, for a job's log.
1213×
pub fn mask(text: &str, values: &[String]) -> String {
1223×
    let mut out = text.to_string();
1234×
    for v in values {
1244×
        if v.len() < MIN_MASKED {
1251×
            continue;
1263×
        }
1273×
        out = out.replace(v.as_str(), "***");
1283×
        let b64 = base64_standard(v.as_bytes());
1293×
        if b64.len() >= MIN_MASKED {
1303×
            out = out.replace(&b64, "***");
1313×
        }
132
    }
1333×
    out
1343×
}
135
1364×
fn base64_standard(bytes: &[u8]) -> String {
137
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1384×
    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
13920×
    for chunk in bytes.chunks(3) {
14054×
        let n = chunk.iter().fold(0u32, |acc, b| (acc << 8) | *b as u32) << (8 * (3 - chunk.len()));
14180×
        for i in 0..4 {
14280×
            if i <= chunk.len() {
14374×
                out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
14474×
            } else {
1456×
                out.push('=');
1466×
            }
147
        }
148
    }
1494×
    out
1504×
}
151
152
#[cfg(test)]
153
mod tests {
154
    use super::*;
155
156
    #[test]
1571×
    fn values_and_their_base64_are_masked_in_logs() {
1581×
        assert_eq!(base64_standard(b"hunter22"), "aHVudGVyMjI=");
1591×
        let line = "token=hunter22 b64=aHVudGVyMjI= short=ab done";
1601×
        assert_eq!(mask(line, &["hunter22".into(), "ab".into()]), "token=*** b64=*** short=ab done");
1611×
        assert!(name_ok("deploy-token") && name_ok("aws.key_1") && !name_ok("") && !name_ok("a b") && !name_ok(&"x".repeat(65)));
1621×
    }
163
}