Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/bootstrap/onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

## Runner Policy

- Run `apw --json doctor` first on a new local checkout or self-hosted runner to confirm the Rust, Xcode, secret-scan, signing, and runner-environment diagnostics before extended validation.
- Shell-safe jobs may use `[self-hosted, synology, shell-only, public]`.
- Docker, service-container, browser, and `container:` workloads stay on GitHub-hosted runners.
- Keep PR checks cheap. Add heavy validation to `scripts/ci/run-extended-validation.sh` instead of the PR lane.
Expand Down
156 changes: 155 additions & 1 deletion rust/src/native_app.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::error::{APWError, Result};
use crate::logging;
use crate::types::{ExternalFallbackProvider, Status, MAX_MESSAGE_BYTES, VERSION};
use crate::utils::{read_config_file, validate_external_provider_path};
use crate::utils::{read_config_file, read_config_file_or_empty, validate_external_provider_path};
use serde_json::{json, Value};
use std::env;
use std::fs;
Expand Down Expand Up @@ -32,6 +32,20 @@ const EXTERNAL_FALLBACK_CLI_TIMEOUT_MS: u64 = 10_000;
#[cfg(test)]
const EXTERNAL_FALLBACK_CLI_TIMEOUT_MS: u64 = 500;

fn diagnostic(
status: &str,
id: &str,
message: impl Into<String>,
hint: impl Into<String>,
) -> Value {
json!({
"id": id,
"status": status,
"message": message.into(),
"hint": hint.into(),
})
}

fn home_dir() -> PathBuf {
match env::var("HOME").or_else(|_| env::var("USERPROFILE")) {
Ok(dir) => PathBuf::from(dir),
Expand Down Expand Up @@ -222,6 +236,128 @@ fn load_status_file() -> Option<Value> {
serde_json::from_str(&fs::read_to_string(native_app_status_path()).ok()?).ok()
}

fn command_output(command: &str, args: &[&str]) -> std::io::Result<std::process::Output> {
Command::new(command).args(args).output()
}

fn command_version_check(id: &str, command: &str, args: &[&str], hint: &str) -> Value {
match command_output(command, args) {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let first_line = stdout
.lines()
.chain(stderr.lines())
.find(|line| !line.trim().is_empty())
.unwrap_or("available")
.trim()
.to_string();
diagnostic("OK", id, first_line, "")
}
Ok(output) => diagnostic(
"FAIL",
id,
format!("{command} exited with status {}", output.status),
hint,
),
Err(_) => diagnostic("FAIL", id, format!("{command} was not found"), hint),
}
}

fn code_signing_identity_check() -> Value {
match command_output("security", &["find-identity", "-v", "-p", "codesigning"]) {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("Developer ID Application") {
diagnostic(
"OK",
"developer_id_identity",
"Developer ID Application identity found",
"",
)
} else {
diagnostic(
"WARN",
"developer_id_identity",
"No Developer ID Application signing identity was found",
"Install the release signing certificate before notarized release builds.",
)
}
}
Ok(output) => diagnostic(
"WARN",
"developer_id_identity",
format!(
"security find-identity exited with status {}",
output.status
),
"Run `security find-identity -v -p codesigning` on the release runner.",
),
Err(_) => diagnostic(
"WARN",
"developer_id_identity",
"security CLI was not found",
"Run release signing diagnostics on macOS with Xcode command line tools installed.",
),
}
}

fn ci_runner_environment_check() -> Value {
if env::var("CI").map(|value| value == "true").unwrap_or(false) {
let runner_os = env::var("RUNNER_OS").unwrap_or_else(|_| "unknown".to_string());
let runner_arch = env::var("RUNNER_ARCH").unwrap_or_else(|_| "unknown".to_string());
let runner_labels = env::var("RUNNER_LABELS")
.or_else(|_| env::var("APW_RUNNER_LABELS"))
.unwrap_or_default();
if runner_labels.is_empty() {
return diagnostic(
"WARN",
"runner_labels",
format!("CI runner reports os={runner_os}, arch={runner_arch}, but labels are not exposed"),
"Confirm the workflow runs on the documented self-hosted labels in docs/bootstrap/onboarding.md.",
);
}
return diagnostic(
"OK",
"runner_labels",
format!("CI runner labels: {runner_labels}; os={runner_os}; arch={runner_arch}"),
"",
);
}

diagnostic(
"WARN",
"runner_labels",
"Not running in GitHub Actions; runner labels cannot be verified locally",
"In CI, confirm shell-safe jobs use [self-hosted, synology, shell-only, public] and extended macOS validation uses [self-hosted, private, macOS, ARM64, xcode].",
)
}

fn ci_diagnostic_checks() -> Vec<Value> {
vec![
command_version_check(
"xcodebuild",
"xcodebuild",
&["-version"],
"Install Xcode and select it with `sudo xcode-select -s /Applications/Xcode.app`.",
),
command_version_check(
"cargo",
"cargo",
&["--version"],
"Install Rust from https://rustup.rs/ before running Rust or release validation.",
),
command_version_check(
"detect_secrets",
"detect-secrets",
&["--version"],
"Install detect-secrets or run the repo's configured secret scan environment.",
Comment thread
jmcte marked this conversation as resolved.
),
code_signing_identity_check(),
ci_runner_environment_check(),
]
}

fn rotate_broker_log_if_needed(path: &Path) -> Result<()> {
let metadata = match fs::metadata(path) {
Ok(value) => value,
Expand Down Expand Up @@ -577,6 +713,7 @@ pub fn native_app_doctor() -> Result<Value> {
format!("Inspect broker logs at {}.", native_app_broker_log_path().display())
]),
);
object.insert("ciDiagnostics".to_string(), json!(ci_diagnostic_checks()));
}
Ok(doctor)
}
Expand Down Expand Up @@ -1226,6 +1363,23 @@ mod tests {
payload["frameworks"]["authenticationServicesLinked"],
json!(true)
);
let diagnostics = payload["ciDiagnostics"].as_array().unwrap();
for id in [
"xcodebuild",
"cargo",
"detect_secrets",
"developer_id_identity",
"runner_labels",
] {
assert!(
diagnostics.iter().any(|entry| entry["id"] == json!(id)),
"missing diagnostic {id}: {diagnostics:#?}"
);
}
assert!(diagnostics.iter().all(|entry| entry["status"]
.as_str()
.map(|status| ["OK", "WARN", "FAIL"].contains(&status))
.unwrap_or(false)));
assert!(!native_app_credentials_path().exists());
});
});
Expand Down
44 changes: 36 additions & 8 deletions rust/tests/security_regressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,15 @@ fn login_rejects_relative_external_provider_path() {
install_native_app_no_results(home);
write_fallback_provider_config(home, "bw");

let (status, stdout, stderr) =
run_command(home, &["--json", "login", "https://vault.example.com"]);
let (status, stdout, stderr) = run_command(
home,
&[
"--json",
"login",
"--external-fallback",
"https://vault.example.com",
],
);

assert_eq!(
status, 102,
Expand All @@ -247,8 +254,15 @@ fn login_rejects_tilde_external_provider_path() {
install_native_app_no_results(home);
write_fallback_provider_config(home, "~/bin/bw");

let (status, stdout, stderr) =
run_command(home, &["--json", "login", "https://vault.example.com"]);
let (status, stdout, stderr) = run_command(
home,
&[
"--json",
"login",
"--external-fallback",
"https://vault.example.com",
],
);

assert_eq!(
status, 102,
Expand All @@ -274,8 +288,15 @@ fn login_rejects_world_writable_external_provider_path() {
.expect("failed to chmod fallback provider");
write_fallback_provider_config(home, &provider_path.display().to_string());

let (status, stdout, stderr) =
run_command(home, &["--json", "login", "https://vault.example.com"]);
let (status, stdout, stderr) = run_command(
home,
&[
"--json",
"login",
"--external-fallback",
"https://vault.example.com",
],
);

assert_eq!(
status, 102,
Expand Down Expand Up @@ -303,8 +324,15 @@ fn login_rejects_external_provider_symlink_to_insecure_target() {
symlink(&provider_path, &provider_link).expect("failed to create provider symlink");
write_fallback_provider_config(home, &provider_link.display().to_string());

let (status, stdout, stderr) =
run_command(home, &["--json", "login", "https://vault.example.com"]);
let (status, stdout, stderr) = run_command(
home,
&[
"--json",
"login",
"--external-fallback",
"https://vault.example.com",
],
);

assert_eq!(
status, 102,
Expand Down
Loading