autumn-cli 0.10.0

CLI tool for the Autumn web framework
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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
//! Deploy-time verification: is the posture manifest in this artifact the one
//! CI acknowledged, and is it the one CI signed?
//!
//! Two independent questions, one command:
//!
//! - **Content** — recompute the manifest's posture digest and compare it to
//!   the digest recorded when the change was acknowledged. Answers "is this the
//!   posture a human approved", and is immune to cosmetic regeneration.
//! - **Signature** — `gh attestation verify`, i.e. exactly the keyless Sigstore
//!   pipeline #1615 already ships. Answers "did this file come out of our CI,
//!   unmodified". No second signing story, no key custody, no new dependency
//!   beyond the `gh` the supply-chain guide already asks for.
//!
//! The signature half is *not* optional by default. A verification that quietly
//! degrades to "well, the digest matched" when `gh` is missing is a verification
//! that proves nothing about provenance, so the escape hatch
//! ([`VerifyOptions::skip_signature`]) is explicit, loud, and documented as
//! air-gapped-only.

use std::process::Command;

use super::model::{ManifestError, PostureManifest};

/// What to verify.
#[derive(Debug, Clone)]
pub struct VerifyOptions<'a> {
    /// Path to the posture manifest to check.
    pub manifest: &'a str,
    /// The digest CI acknowledged. A full 64-character digest: unlike the
    /// acknowledgment marker a human types on a pull request, this one is
    /// machine-recorded and machine-passed, so there is no reason to accept
    /// less than all of it.
    pub expect_digest: Option<&'a str>,
    /// `owner/repo` the attestation was minted by.
    pub repo: Option<&'a str>,
    /// Skip the signature check. Air-gapped use only; says so in the output.
    pub skip_signature: bool,
}

/// Length of a full SHA-256 digest in hex.
pub const FULL_DIGEST_LEN: usize = 64;

/// One thing that was checked.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Check {
    pub name: &'static str,
    pub passed: bool,
    /// Deliberately not performed, on the operator's explicit instruction.
    pub waived: bool,
    pub detail: String,
}

/// The result of a verification run.
#[derive(Debug, Clone)]
pub struct VerifyReport {
    pub manifest: String,
    pub posture_digest: String,
    pub checks: Vec<Check>,
}

impl VerifyReport {
    /// Verification passes only when every check that ran passed **and at
    /// least one actually ran**.
    ///
    /// The second half matters: a run that waives both halves has verified
    /// nothing, and "nothing went wrong" is not the same claim as "this is the
    /// manifest we acknowledged, signed by our CI".
    #[must_use]
    pub fn passed(&self) -> bool {
        self.checks.iter().all(|c| c.waived || c.passed) && self.checks.iter().any(|c| c.passed)
    }
}

/// Whether `provided` is the digest `expected`, in full.
///
/// Case-insensitive and hex-only. Deliberately *not* prefix matching: the
/// acknowledgment marker accepts a 16-character prefix because a human types
/// it into a comment box, but a digest recorded by CI and passed to a deploy
/// script has no such excuse, and a shorter binding is a weaker one.
#[must_use]
pub fn digest_matches(expected: &str, provided: &str) -> bool {
    let provided = provided.trim().to_ascii_lowercase();
    if provided.len() != FULL_DIGEST_LEN || !provided.chars().all(|c| c.is_ascii_hexdigit()) {
        return false;
    }
    expected.trim().to_ascii_lowercase() == provided
}

/// Whether an acknowledgment marker `provided` binds to `expected`.
///
/// This is the human-typed half: a prefix of at least
/// [`SHORT_DIGEST_LEN`](super::ack::SHORT_DIGEST_LEN) characters is enough,
/// which is what the gate prints for pasting. A two-character "prefix" would
/// match one digest in 256, so there is a floor.
#[must_use]
pub fn marker_matches(expected: &str, provided: &str) -> bool {
    let provided = provided.trim().to_ascii_lowercase();
    if !(super::ack::SHORT_DIGEST_LEN..=FULL_DIGEST_LEN).contains(&provided.len())
        || !provided.chars().all(|c| c.is_ascii_hexdigit())
    {
        return false;
    }
    expected.trim().to_ascii_lowercase().starts_with(&provided)
}

/// How the signature half is performed. A trait so the orchestration above it
/// is testable without a network, a GitHub account, or `gh` on PATH.
pub trait SignatureVerifier {
    /// Verify `manifest` was attested by `repo`. `Ok(detail)` on success,
    /// `Err(detail)` with something a human can act on otherwise.
    fn verify(&self, manifest: &str, repo: &str) -> Result<String, String>;
}

/// The real thing: shells out to `gh attestation verify`.
pub struct GhAttestationVerifier;

/// The command `gh attestation verify` is invoked as, as a program plus args.
///
/// Split out so the exact invocation is asserted by a test rather than
/// discovered in production.
#[must_use]
pub fn gh_verify_command(manifest: &str, repo: &str) -> (String, Vec<String>) {
    (
        "gh".to_owned(),
        vec![
            "attestation".to_owned(),
            "verify".to_owned(),
            manifest.to_owned(),
            "--repo".to_owned(),
            repo.to_owned(),
        ],
    )
}

impl SignatureVerifier for GhAttestationVerifier {
    fn verify(&self, manifest: &str, repo: &str) -> Result<String, String> {
        let (program, args) = gh_verify_command(manifest, repo);
        let output = Command::new(&program).args(&args).output().map_err(|e| {
            format!(
                "could not run `gh attestation verify`: {e} — install GitHub CLI \u{2265} 2.49 \
                 (see docs/guide/supply-chain.md), or pass --skip-signature if this host is \
                 deliberately offline"
            )
        })?;
        if output.status.success() {
            Ok(format!("`gh attestation verify` accepted {manifest}"))
        } else {
            let stderr = String::from_utf8_lossy(&output.stderr);
            Err(format!(
                "`gh attestation verify` rejected {manifest}: {}",
                stderr.trim()
            ))
        }
    }
}

/// Run a verification with a caller-supplied signature verifier.
pub fn verify_with(
    opts: &VerifyOptions<'_>,
    verifier: &dyn SignatureVerifier,
) -> Result<VerifyReport, ManifestError> {
    let manifest = PostureManifest::read(opts.manifest)?;
    let posture_digest = manifest.posture_digest();
    let mut checks = Vec::new();

    checks.push(match opts.expect_digest {
        Some(expected) if digest_matches(&posture_digest, expected) => Check {
            name: "acknowledged-posture",
            passed: true,
            waived: false,
            detail: format!("matches the acknowledged digest {expected}"),
        },
        Some(expected) => Check {
            name: "acknowledged-posture",
            passed: false,
            waived: false,
            detail: format!(
                "does NOT match the acknowledged digest {expected} \u{2014} this manifest \
                 describes a different security posture than the one CI recorded"
            ),
        },
        None => Check {
            name: "acknowledged-posture",
            passed: false,
            waived: true,
            detail: "skipped: no --expect-digest given, so nothing ties this manifest to an \
                     acknowledged posture"
                .to_owned(),
        },
    });

    checks.push(if opts.skip_signature {
        Check {
            name: "signature",
            passed: false,
            waived: true,
            detail: "WAIVED by --skip-signature: provenance was NOT checked".to_owned(),
        }
    } else {
        opts.repo.map_or_else(
            || Check {
                name: "signature",
                passed: false,
                waived: false,
                detail: "cannot verify: pass --repo <owner/repo> (or --skip-signature on a \
                         deliberately offline host)"
                    .to_owned(),
            },
            |repo| {
                let (passed, detail) = match verifier.verify(opts.manifest, repo) {
                    Ok(detail) => (true, detail),
                    Err(detail) => (false, detail),
                };
                Check {
                    name: "signature",
                    passed,
                    waived: false,
                    detail,
                }
            },
        )
    });

    Ok(VerifyReport {
        manifest: opts.manifest.to_owned(),
        posture_digest,
        checks,
    })
}

/// Read the manifest at `path`, for callers that only want its digest.
pub fn manifest_digest(path: &str) -> Result<String, ManifestError> {
    Ok(PostureManifest::read(path)?.posture_digest())
}

#[cfg(test)]
mod tests {
    use std::cell::RefCell;

    use super::*;

    /// A manifest with one gated route, written to a temp file.
    fn write_manifest(dir: &std::path::Path, classification: &str) -> String {
        let json = format!(
            r#"{{"schema_version":3,"dimensions":{{
                 "routes":{{"provenance":"provable","source":"m","entries":[
                   {{"path":"/admin","method":"GET","name":"a","classification":"{classification}",
                     "roles":["admin"],"scopes":[],"policy":false,"source":"user","provenance":"provable"}}]}},
                 "csrf":{{"provenance":"declared","source":"c","exempt_paths":[],"entries":[]}},
                 "security_headers":{{"provenance":"declared","source":"c","entries":[]}},
                 "authorization_policies":{{"provenance":"provable","source":"m","runtime_caveat":"x","entries":[]}}
               }},"excluded":[]}}"#
        );
        let path = dir.join(format!("{classification}.json"));
        std::fs::write(&path, json).unwrap();
        path.to_string_lossy().into_owned()
    }

    struct FakeVerifier {
        result: Result<String, String>,
        calls: RefCell<Vec<(String, String)>>,
    }

    impl FakeVerifier {
        fn ok() -> Self {
            Self {
                result: Ok("signed".to_owned()),
                calls: RefCell::new(Vec::new()),
            }
        }
        fn failing() -> Self {
            Self {
                result: Err("no attestations found".to_owned()),
                calls: RefCell::new(Vec::new()),
            }
        }
    }

    impl SignatureVerifier for FakeVerifier {
        fn verify(&self, manifest: &str, repo: &str) -> Result<String, String> {
            self.calls
                .borrow_mut()
                .push((manifest.to_owned(), repo.to_owned()));
            self.result.clone()
        }
    }

    // ── digest comparison ───────────────────────────────────────────────────

    #[test]
    fn a_full_digest_matches_itself_case_insensitively() {
        let d = "a".repeat(64);
        assert!(digest_matches(&d, &d));
        assert!(digest_matches(&d, &d.to_uppercase()));
    }

    /// A machine-recorded digest is compared in full: `--expect-digest` takes
    /// no shortcuts, however long the prefix.
    #[test]
    fn a_prefix_never_satisfies_expect_digest() {
        let d = "0123456789abcdef".to_owned() + &"f".repeat(48);
        assert!(!digest_matches(&d, "0123456789abcdef"));
        assert!(!digest_matches(&d, &d[..63]));
        assert!(digest_matches(&d, &d));
    }

    /// The human-typed marker is the half that may be a prefix — with a floor.
    #[test]
    fn a_long_enough_marker_prefix_matches() {
        let d = "0123456789abcdef".to_owned() + &"f".repeat(48);
        assert!(marker_matches(&d, "0123456789abcdef"));
        assert!(!marker_matches(&d, "0123456789abcde"), "below the floor");
        assert!(!marker_matches(&d, "01"));
        assert!(!marker_matches(&d, "fedcba9876543210"), "not a prefix");
    }

    #[test]
    fn a_non_prefix_does_not_match() {
        let d = "0123456789abcdef".to_owned() + &"f".repeat(48);
        assert!(!digest_matches(&d, "fedcba9876543210"));
    }

    /// A run that verified nothing is not a pass.
    #[test]
    fn waiving_both_halves_fails_rather_than_passing() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_manifest(dir.path(), "gated");
        let report = verify_with(
            &VerifyOptions {
                manifest: &path,
                expect_digest: None,
                repo: None,
                skip_signature: true,
            },
            &FakeVerifier::ok(),
        )
        .unwrap();
        assert!(!report.passed(), "nothing was checked: {report:?}");
    }

    #[test]
    fn a_non_hex_string_never_matches() {
        let d = "0".repeat(64);
        assert!(!digest_matches(&d, "zzzzzzzzzzzzzzzz"));
        assert!(!digest_matches(&d, &"z".repeat(64)));
        assert!(!marker_matches(&d, "zzzzzzzzzzzzzzzz"));
    }

    // ── verification ────────────────────────────────────────────────────────

    #[test]
    fn a_genuine_manifest_with_the_acknowledged_digest_and_a_signature_passes() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_manifest(dir.path(), "gated");
        let digest = manifest_digest(&path).unwrap();
        let verifier = FakeVerifier::ok();
        let report = verify_with(
            &VerifyOptions {
                manifest: &path,
                expect_digest: Some(&digest),
                repo: Some("acme/app"),
                skip_signature: false,
            },
            &verifier,
        )
        .unwrap();
        assert!(report.passed(), "{report:?}");
        assert_eq!(
            verifier.calls.borrow().as_slice(),
            &[(path, "acme/app".to_owned())]
        );
    }

    #[test]
    fn a_tampered_manifest_fails_the_digest_check() {
        let dir = tempfile::tempdir().unwrap();
        let genuine = write_manifest(dir.path(), "gated");
        let digest = manifest_digest(&genuine).unwrap();
        // Someone edits the shipped manifest to claim the route is public.
        let tampered = write_manifest(dir.path(), "public");
        let report = verify_with(
            &VerifyOptions {
                manifest: &tampered,
                expect_digest: Some(&digest),
                repo: Some("acme/app"),
                skip_signature: false,
            },
            &FakeVerifier::ok(),
        )
        .unwrap();
        assert!(!report.passed(), "a tampered manifest must not verify");
        assert!(
            report
                .checks
                .iter()
                .any(|c| c.name == "acknowledged-posture" && !c.passed)
        );
    }

    #[test]
    fn a_failed_signature_fails_the_run_even_when_the_digest_matches() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_manifest(dir.path(), "gated");
        let digest = manifest_digest(&path).unwrap();
        let report = verify_with(
            &VerifyOptions {
                manifest: &path,
                expect_digest: Some(&digest),
                repo: Some("acme/app"),
                skip_signature: false,
            },
            &FakeVerifier::failing(),
        )
        .unwrap();
        assert!(!report.passed());
        assert!(
            report
                .checks
                .iter()
                .any(|c| c.name == "signature" && !c.passed)
        );
    }

    #[test]
    fn skipping_the_signature_is_recorded_as_waived_not_as_passed() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_manifest(dir.path(), "gated");
        let digest = manifest_digest(&path).unwrap();
        let verifier = FakeVerifier::ok();
        let report = verify_with(
            &VerifyOptions {
                manifest: &path,
                expect_digest: Some(&digest),
                repo: None,
                skip_signature: true,
            },
            &verifier,
        )
        .unwrap();
        assert!(report.passed());
        let signature = report
            .checks
            .iter()
            .find(|c| c.name == "signature")
            .expect("the signature check is always reported, even when waived");
        assert!(signature.waived);
        assert!(!signature.passed);
        assert!(
            verifier.calls.borrow().is_empty(),
            "a waived check runs nothing"
        );
    }

    #[test]
    fn a_missing_repo_fails_the_signature_check_rather_than_skipping_it() {
        let dir = tempfile::tempdir().unwrap();
        let path = write_manifest(dir.path(), "gated");
        let report = verify_with(
            &VerifyOptions {
                manifest: &path,
                expect_digest: None,
                repo: None,
                skip_signature: false,
            },
            &FakeVerifier::ok(),
        )
        .unwrap();
        assert!(
            !report.passed(),
            "no --repo and no --skip-signature must not silently pass"
        );
    }

    #[test]
    fn a_missing_manifest_is_an_error_not_a_pass() {
        let report = verify_with(
            &VerifyOptions {
                manifest: "/nonexistent/posture.json",
                expect_digest: None,
                repo: Some("acme/app"),
                skip_signature: true,
            },
            &FakeVerifier::ok(),
        );
        assert!(matches!(report, Err(ManifestError::Io { .. })));
    }

    #[test]
    fn the_gh_invocation_is_the_one_the_supply_chain_guide_documents() {
        let (program, args) = gh_verify_command("posture.json", "acme/app");
        assert_eq!(program, "gh");
        assert_eq!(
            args,
            vec![
                "attestation",
                "verify",
                "posture.json",
                "--repo",
                "acme/app"
            ]
        );
    }
}