use std::path::PathBuf;
use anyhow::{Context, Result};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::Utc;
use colored::Colorize;
use ed25519_dalek::SigningKey;
use serde::Serialize;
use tsafe_attest::enforce::{enforce, write_run, EnforceOptions};
use tsafe_attest::{scan_repo, FindingKind, ScanReport, Severity};
use tsafe_core::keyring_store;
use tsafe_core::run_evidence::RunEvidence;
use tsafe_core::sign::{
decode_verifying_key, sign_evidence, signed_from_run_evidence, verify_evidence,
verify_signed_evidence, VerifyError,
};
use tsafe_core::trust_store::TrustStore;
use crate::cli::{AttestAction, AttestKeyAction, AttestScanFormat, AttestTrustAction};
pub const VERIFY_EXIT_SIGNATURE_ABSENT: i32 = 5;
pub const VERIFY_EXIT_SIGNATURE_FAILED: i32 = 6;
pub const VERIFY_EXIT_NOT_PINNED: i32 = 7;
pub(crate) fn cmd_attest(profile: &str, action: AttestAction) -> Result<()> {
match action {
AttestAction::Scan {
path,
strict,
extra_paths,
format,
output,
} => cmd_attest_scan(path, strict, extra_paths, format, output),
AttestAction::Run {
contract,
emit_run_evidence,
audit_trail,
allow_command_override,
sign_run_evidence,
no_sign,
command,
} => cmd_attest_run(
profile,
contract,
emit_run_evidence,
audit_trail,
allow_command_override,
sign_run_evidence,
no_sign,
command,
),
AttestAction::Verify {
evidence,
pubkey,
require_pinned,
} => cmd_attest_verify(evidence, pubkey, require_pinned),
AttestAction::Key { action } => cmd_attest_key(profile, action),
AttestAction::Trust { action } => cmd_attest_trust(action),
}
}
fn resolve_signing_decision(
profile: &str,
sign_run_evidence: bool,
no_sign: bool,
) -> SigningDecision {
if no_sign {
SigningDecision::Disabled
} else if sign_run_evidence {
SigningDecision::EnabledExplicit
} else if keyring_store::has_attest_signing_key(profile) {
SigningDecision::EnabledDefault
} else {
SigningDecision::EnabledAutoGenerate
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SigningDecision {
Disabled,
EnabledExplicit,
EnabledDefault,
EnabledAutoGenerate,
}
impl SigningDecision {
fn should_sign(self) -> bool {
!matches!(self, SigningDecision::Disabled)
}
}
#[allow(clippy::too_many_arguments)]
fn cmd_attest_run(
profile: &str,
contract: Option<PathBuf>,
emit_run_evidence: Option<PathBuf>,
audit_trail: Option<PathBuf>,
allow_command_override: bool,
sign_run_evidence: bool,
no_sign: bool,
command: Vec<String>,
) -> Result<()> {
if command.is_empty() {
anyhow::bail!("tsafe attest run requires a command after `--` (see --help)");
}
let contract_path = contract.unwrap_or_else(|| PathBuf::from("tsafe.contract.yaml"));
let output_path = emit_run_evidence.unwrap_or_else(|| PathBuf::from("tsafe-run.json"));
let audit_events_path =
audit_trail.unwrap_or_else(|| PathBuf::from("tsafe-audit-events.ndjson"));
let decision = resolve_signing_decision(profile, sign_run_evidence, no_sign);
let signing_key: Option<SigningKey> = if decision.should_sign() {
Some(resolve_signing_key(profile, decision)?)
} else {
None
};
let options = EnforceOptions {
contract_path,
output_path: output_path.clone(),
audit_events_path,
command,
allow_command_override,
};
let (evidence, exit_code) = enforce(options).context("tsafe attest run enforcement failed")?;
if let Some(key) = signing_key {
let signed = sign_evidence(&evidence, &key).context("Phase 5: sign emitted RunEvidence")?;
write_run(&signed.evidence, &output_path)
.context("Phase 5: re-write signed RunEvidence")?;
} else {
eprintln!(
"{} RunEvidence emitted without an Ed25519 signature (--no-sign).",
"note:".yellow()
);
}
if exit_code != 0 {
std::process::exit(exit_code);
}
Ok(())
}
fn resolve_signing_key(profile: &str, decision: SigningDecision) -> Result<SigningKey> {
match decision {
SigningDecision::EnabledExplicit | SigningDecision::EnabledDefault => {
let stored = keyring_store::retrieve_attest_signing_key(profile)
.context("read tsafe-attest signing key from OS credential store")?;
match stored {
Some(b64url) => {
decode_signing_key(&b64url).context("decode persisted tsafe-attest signing key")
}
None => {
eprintln!(
"{} no tsafe-attest signing key found for profile '{profile}'; \
generating one now. To pin out of band, run \
`tsafe attest key pubkey` after this command.",
"warning:".yellow().bold()
);
generate_and_persist(profile)
}
}
}
SigningDecision::EnabledAutoGenerate => {
eprintln!(
"{} no tsafe-attest signing key found for profile '{profile}'; \
generating one on first use (default-sign behaviour). \
Pass --no-sign to skip, or pre-provision via \
`tsafe attest key generate`.",
"warning:".yellow().bold()
);
generate_and_persist(profile)
}
SigningDecision::Disabled => unreachable!("Disabled is handled by the caller"),
}
}
fn generate_and_persist(profile: &str) -> Result<SigningKey> {
use rand::rngs::OsRng;
let key = SigningKey::generate(&mut OsRng);
let encoded = URL_SAFE_NO_PAD.encode(key.to_bytes());
keyring_store::store_attest_signing_key(profile, &encoded)
.context("store auto-generated tsafe-attest signing key")?;
Ok(key)
}
fn decode_signing_key(b64url: &str) -> Result<SigningKey> {
let bytes = URL_SAFE_NO_PAD
.decode(b64url)
.context("base64url-decode signing key")?;
if bytes.len() != ed25519_dalek::SECRET_KEY_LENGTH {
anyhow::bail!(
"tsafe-attest signing key length mismatch: expected {} bytes, got {}",
ed25519_dalek::SECRET_KEY_LENGTH,
bytes.len()
);
}
let array: [u8; ed25519_dalek::SECRET_KEY_LENGTH] =
bytes.as_slice().try_into().expect("length-checked above");
Ok(SigningKey::from_bytes(&array))
}
fn cmd_attest_verify(
evidence_path: PathBuf,
pubkey: Option<String>,
require_pinned: bool,
) -> Result<()> {
let bytes = std::fs::read(&evidence_path)
.with_context(|| format!("read RunEvidence artifact: {}", evidence_path.display()))?;
let evidence: RunEvidence = serde_json::from_slice(&bytes)
.with_context(|| format!("parse RunEvidence artifact: {}", evidence_path.display()))?;
let signed = match signed_from_run_evidence(evidence) {
Some(signed) => signed,
None => {
eprintln!(
"{} {} has no `signature` field (exit code {VERIFY_EXIT_SIGNATURE_ABSENT}).",
"error:".red().bold(),
evidence_path.display()
);
std::process::exit(VERIFY_EXIT_SIGNATURE_ABSENT);
}
};
let result = match &pubkey {
Some(operator_pubkey) => {
let key =
decode_verifying_key(operator_pubkey).context("decode operator-supplied --pubkey")?;
verify_evidence(&signed, &key)
}
None => {
if !require_pinned {
eprintln!(
"{} verifying with the pubkey embedded in the artifact (TOFU). \
Pass --require-pinned (and pin the key with `tsafe attest trust add`) \
for a stronger trust posture.",
"note:".yellow()
);
}
verify_signed_evidence(&signed)
}
};
if require_pinned && matches!(result, Ok(())) {
let store = TrustStore::load_default().context("load pinned-pubkey trust store")?;
match store.identity_for_signature(&signed.signature) {
Some(pin) => {
eprintln!(
"{} signer matches pinned identity '{}'.",
"trust:".green().bold(),
pin.name
);
}
None => {
if store.is_empty() {
eprintln!(
"{} --require-pinned was set but the trust store is empty. \
Pin the expected signer with `tsafe attest trust add <name> <pubkey>` \
(exit code {VERIFY_EXIT_NOT_PINNED}).",
"error:".red().bold()
);
} else {
eprintln!(
"{} signature is cryptographically valid but the signing key is NOT a \
pinned trusted identity (exit code {VERIFY_EXIT_NOT_PINNED}). \
Pin it via `tsafe attest trust add <name> {}` if you trust this producer.",
"error:".red().bold(),
signed.signature.pubkey
);
}
std::process::exit(VERIFY_EXIT_NOT_PINNED);
}
}
}
match result {
Ok(()) => {
println!(
"{} {} verified.",
"ok:".green().bold(),
evidence_path.display()
);
Ok(())
}
Err(VerifyError::SignatureAbsent) => {
eprintln!(
"{} {} has no `signature` field (exit code {VERIFY_EXIT_SIGNATURE_ABSENT}).",
"error:".red().bold(),
evidence_path.display()
);
std::process::exit(VERIFY_EXIT_SIGNATURE_ABSENT);
}
Err(other) => {
eprintln!(
"{} {}: {other} (exit code {VERIFY_EXIT_SIGNATURE_FAILED}).",
"error:".red().bold(),
evidence_path.display()
);
std::process::exit(VERIFY_EXIT_SIGNATURE_FAILED);
}
}
}
fn cmd_attest_key(profile: &str, action: AttestKeyAction) -> Result<()> {
match action {
AttestKeyAction::Generate { force } => {
if keyring_store::has_attest_signing_key(profile) && !force {
anyhow::bail!(
"tsafe attest key generate: profile '{profile}' already has a signing key. \
Pass --force to overwrite. (`tsafe attest key pubkey` prints the current one.)"
);
}
let key = generate_and_persist(profile)?;
let pubkey = URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes());
println!("{pubkey}");
eprintln!(
"{} generated tsafe-attest signing key for profile '{profile}'. \
Pubkey above; pin it out of band for downstream verification.",
"ok:".green().bold()
);
Ok(())
}
AttestKeyAction::Pubkey => {
let stored = keyring_store::retrieve_attest_signing_key(profile)
.context("read tsafe-attest signing key from OS credential store")?;
let Some(b64url) = stored else {
anyhow::bail!(
"tsafe attest key pubkey: no signing key for profile '{profile}'. \
Generate one with `tsafe attest key generate`."
);
};
let key = decode_signing_key(&b64url)?;
let pubkey = URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes());
println!("{pubkey}");
Ok(())
}
AttestKeyAction::Remove => {
keyring_store::remove_attest_signing_key(profile)
.context("remove tsafe-attest signing key from OS credential store")?;
eprintln!(
"{} removed tsafe-attest signing key for profile '{profile}'.",
"ok:".green().bold()
);
Ok(())
}
}
}
fn cmd_attest_trust(action: AttestTrustAction) -> Result<()> {
use tsafe_core::sign::SIG_ALGO_ED25519;
let path = TrustStore::default_path();
match action {
AttestTrustAction::Add { name, pubkey } => {
let mut store = TrustStore::load(&path).context("load trust store")?;
store
.add(&name, SIG_ALGO_ED25519, &pubkey)
.context("pin identity")?;
store.save(&path).context("persist trust store")?;
eprintln!(
"{} pinned identity '{name}' in {}.",
"ok:".green().bold(),
path.display()
);
Ok(())
}
AttestTrustAction::List => {
let store = TrustStore::load(&path).context("load trust store")?;
if store.is_empty() {
eprintln!(
"{} no pinned identities (store: {}).",
"note:".yellow(),
path.display()
);
} else {
for pin in &store.pins {
println!("{}\t{}\t{}", pin.name, pin.algo, pin.pubkey);
}
}
Ok(())
}
AttestTrustAction::Remove { name } => {
let mut store = TrustStore::load(&path).context("load trust store")?;
store.remove(&name).context("unpin identity")?;
store.save(&path).context("persist trust store")?;
eprintln!(
"{} removed pinned identity '{name}'.",
"ok:".green().bold()
);
Ok(())
}
}
}
fn cmd_attest_scan(
path: Option<PathBuf>,
strict: bool,
extra_paths: Vec<PathBuf>,
format: AttestScanFormat,
output: Option<PathBuf>,
) -> Result<()> {
let primary = path.unwrap_or_else(|| PathBuf::from("."));
let mut targets = vec![primary];
targets.extend(extra_paths);
let mut merged: Option<ScanReport> = None;
for target in &targets {
let report = scan_repo(target)
.with_context(|| format!("scan failed for path {}", target.display()))?;
merged = Some(match merged {
None => report,
Some(mut acc) => {
acc.findings.extend(report.findings);
acc.observed_env_reads.extend(report.observed_env_reads);
acc.ci_secret_references.extend(report.ci_secret_references);
acc.summary.total_findings = acc.findings.len();
acc.summary.critical = acc
.findings
.iter()
.filter(|f| matches!(f.severity, Severity::Critical))
.count();
acc.summary.high = acc
.findings
.iter()
.filter(|f| matches!(f.severity, Severity::High))
.count();
acc.summary.medium = acc
.findings
.iter()
.filter(|f| matches!(f.severity, Severity::Medium))
.count();
acc.summary.low = acc
.findings
.iter()
.filter(|f| matches!(f.severity, Severity::Low))
.count();
acc.summary.risk_score = acc
.findings
.iter()
.map(|f| f.severity.weight())
.sum::<u32>()
.min(100);
acc
}
});
}
let report = merged.expect("at least one target was scanned");
emit_scan_cloudevent(&report);
match format {
AttestScanFormat::Json => {
let json = serde_json::to_string_pretty(&report)?;
if let Some(out) = output {
tsafe_attest::write_scan(&report, &out)?;
println!("Wrote scan report to {}", out.display());
} else {
println!("{json}");
}
}
AttestScanFormat::Markdown => {
let md = render_markdown(&report);
if let Some(out) = output {
std::fs::write(&out, &md)?;
println!("Wrote scan report to {}", out.display());
} else {
println!("{md}");
}
}
AttestScanFormat::Text => {
tsafe_attest::print_summary(&report);
}
}
if strict && has_secret_finding(&report) {
std::process::exit(2);
}
Ok(())
}
fn has_secret_finding(report: &ScanReport) -> bool {
report.findings.iter().any(|f| {
matches!(
f.kind,
FindingKind::EnvFile | FindingKind::HardcodedSecret | FindingKind::PrivateKey
)
})
}
#[derive(Serialize)]
struct ScanCloudEvent<'a> {
specversion: &'a str,
id: String,
source: &'a str,
#[serde(rename = "type")]
event_type: &'a str,
time: chrono::DateTime<chrono::Utc>,
datacontenttype: &'a str,
data: ScanCloudEventData<'a>,
}
#[derive(Serialize)]
struct ScanCloudEventData<'a> {
repo_path: &'a str,
repo_commit: Option<&'a str>,
schema: &'a str,
scanner_version: &'a str,
total_findings: usize,
risk_score: u32,
}
fn emit_scan_cloudevent(report: &ScanReport) {
let event = ScanCloudEvent {
specversion: "1.0",
id: uuid::Uuid::new_v4().to_string(),
source: "tsafe/attest",
event_type: tsafe_attest::SCAN_SCHEMA,
time: Utc::now(),
datacontenttype: "application/json",
data: ScanCloudEventData {
repo_path: &report.repo_path,
repo_commit: report.repo_commit.as_deref(),
schema: &report.schema,
scanner_version: &report.scanner_version,
total_findings: report.summary.total_findings,
risk_score: report.summary.risk_score,
},
};
if let Ok(json) = serde_json::to_string(&event) {
eprintln!("{json}");
}
}
fn render_markdown(report: &ScanReport) -> String {
let mut out = String::new();
out.push_str("# tsafe attest scan\n\n");
out.push_str(&format!("- **Repo**: `{}`\n", report.repo_path));
out.push_str(&format!(
"- **Commit**: `{}`\n",
report.repo_commit.as_deref().unwrap_or("unknown")
));
out.push_str(&format!(
"- **Scanned at**: {}\n",
report.scanned_at.to_rfc3339()
));
out.push_str(&format!(
"- **Scanner version**: `{}`\n",
report.scanner_version
));
out.push_str(&format!("- **Schema**: `{}`\n", report.schema));
out.push_str("\n## Summary\n\n");
out.push_str(&format!(
"- Total findings: {}\n",
report.summary.total_findings
));
out.push_str(&format!(
"- Critical: {} | High: {} | Medium: {} | Low: {}\n",
report.summary.critical, report.summary.high, report.summary.medium, report.summary.low
));
out.push_str(&format!(
"- Risk score: {}/100\n",
report.summary.risk_score
));
if !report.findings.is_empty() {
out.push_str("\n## Findings\n\n");
out.push_str("| Severity | Kind | File:Line | Name | Message |\n");
out.push_str("|----------|------|-----------|------|---------|\n");
for finding in &report.findings {
let kind = match finding.kind {
FindingKind::EnvFile => "ENV_FILE",
FindingKind::HardcodedSecret => "HARDCODED_SECRET",
FindingKind::PrivateKey => "PRIVATE_KEY",
FindingKind::CiSecretReference => "CI_SECRET_REFERENCE",
FindingKind::RuntimeEnvRead => "RUNTIME_ENV_READ",
FindingKind::UnsafeExport => "UNSAFE_EXPORT",
FindingKind::RiskyEnvPropagation => "RISKY_ENV_PROPAGATION",
FindingKind::SecretPlaceholder => "SECRET_PLACEHOLDER",
};
out.push_str(&format!(
"| {} | `{}` | `{}:{}` | `{}` | {} |\n",
finding.severity.label(),
kind,
finding.file,
finding.line,
finding.name.as_deref().unwrap_or("-"),
finding.message.replace('|', "\\|"),
));
}
}
out
}