use std::ffi::OsString;
use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use sha2::{Digest as _, Sha256};
use tirith_core::policy::Policy;
use tirith_core::provenance::npm::{
self as core_npm, InstalledInventory, NpmAssessment, NpmAttestOutcome, NpmAuditEnvironment,
NpmAuditInvocation, NpmAuditMode, NpmAuditReport, NpmAuditSignaturesContract, NpmLockfile,
NpmPartialReason, NpmProvenanceReceipt, NpmReceiptFacts, NpmReceiptSubject, NpmToolIdentity,
};
use tirith_core::trusted_child::{
self, ChildLimits, ChildOutcome, ChildSpec, TrustedExecutable, TrustedExecutableError,
};
use super::{sanitize_for_human_output, write_json_stdout};
use url::Url;
const AUDIT_TIMEOUT: Duration = Duration::from_secs(120);
const AUDIT_OUTPUT_CAP: usize = 8 * 1024 * 1024;
const VERSION_TIMEOUT: Duration = Duration::from_secs(10);
const VERSION_OUTPUT_CAP: usize = 16 * 1024;
const MAX_RECORDED_STDERR: usize = 4096;
const MAX_LAUNCHER_PROBE_BYTES: u64 = 1024 * 1024;
#[cfg(unix)]
const INHERITED_ENV: &[&str] = &["TMPDIR", "LANG", "LC_ALL"];
#[cfg(not(unix))]
const INHERITED_ENV: &[&str] = &["SystemRoot", "TEMP", "TMP"];
const AUDIT_MODE_ENV: &str = "TIRITH_NPM_AUDIT_MODE";
const PRIVATE_REGISTRY_ENV: &str = "TIRITH_NPM_REGISTRY";
const PRIVATE_AUTH_SOURCE_ENV: &str = "TIRITH_NPM_AUTH_SOURCE";
const PRIVATE_CA_FILE_ENV: &str = "TIRITH_NPM_CA_FILE";
const PRIVATE_PROXY_ENV: &str = "TIRITH_NPM_PROXY";
const MAX_AUDIT_ORIGIN_BYTES: usize = 2048;
#[derive(Debug, Clone, Default)]
pub struct AttestNpmArgs {
pub project: Option<PathBuf>,
pub out: Option<PathBuf>,
pub require_provenance: bool,
pub json: bool,
}
pub fn run(args: AttestNpmArgs) -> i32 {
let project = match resolve_project(args.project.as_deref()) {
Ok(project) => project,
Err(message) => return usage_error(args.json, &message),
};
let lockfile_path = project.join("package-lock.json");
let (lockfile, lockfile_sha256) = match read_lockfile(&lockfile_path) {
Ok(pair) => pair,
Err((reason, detail)) => return finish(&args, Assembly::partial(reason, detail)),
};
let npmrc_inspection = project_npmrc_override(&project);
let npmrc_present = !matches!(npmrc_inspection, Ok(None));
let inventory = match core_npm::walk_installed_tree(&project) {
Some(inventory) => inventory,
None => {
return finish(
&args,
Assembly::without_audit(
&lockfile,
lockfile_sha256.clone(),
&InstalledInventory {
packages: Vec::new(),
capped: false,
symlinked_entries: 0,
},
npmrc_present,
NpmAttestOutcome::Partial {
reason: NpmPartialReason::MissingInstallTree,
detail: "the project has no node_modules directory, so there is no \
install tree for npm to audit; no audit command was run"
.to_string(),
},
),
);
}
};
let npmrc = match npmrc_inspection {
Ok(value) => value,
Err(detail) => {
return finish(
&args,
Assembly::without_audit(
&lockfile,
lockfile_sha256.clone(),
&inventory,
true,
NpmAttestOutcome::Partial {
reason: NpmPartialReason::AuditConfigurationInvalid,
detail,
},
),
);
}
};
let base = |outcome: NpmAttestOutcome| -> Assembly {
Assembly::without_audit(
&lockfile,
lockfile_sha256.clone(),
&inventory,
npmrc.is_some(),
outcome,
)
};
if let Some(key) = npmrc.as_deref() {
return finish(
&args,
base(NpmAttestOutcome::Partial {
reason: NpmPartialReason::ProjectNpmrcOverride,
detail: format!(
"the project's own .npmrc sets '{key}', which reconfigures what npm verifies \
or where it verifies it from; the audit would be configured by the project \
it audits, so no audit command was run"
),
}),
);
}
if super::offline_env_active() {
return finish(
&args,
base(NpmAttestOutcome::Partial {
reason: NpmPartialReason::Offline,
detail: "offline mode is active, so npm was neither resolved nor run; \
npm's signature audit needs the registry"
.to_string(),
}),
);
}
if cfg!(windows) {
return finish(
&args,
base(NpmAttestOutcome::Partial {
reason: NpmPartialReason::UnsupportedPlatform,
detail: "npm on Windows is a batch launcher (npm.cmd), which tirith's trusted \
executable validator refuses to run; no audit command was run"
.to_string(),
}),
);
}
let audit_environment = match AuditEnvironment::from_process() {
Ok(environment) => environment,
Err(detail) => {
return finish(
&args,
base(NpmAttestOutcome::Partial {
reason: NpmPartialReason::AuditConfigurationInvalid,
detail,
}),
);
}
};
let launcher = match resolve_launcher() {
Ok(launcher) => launcher,
Err(reason) => {
return finish(
&args,
base(NpmAttestOutcome::Partial {
reason: NpmPartialReason::NpmNotResolved,
detail: reason,
}),
);
}
};
let version = match probe_version(&launcher) {
Ok(version) => version,
Err(reason) => {
let mut assembly = base(NpmAttestOutcome::Partial {
reason: NpmPartialReason::AuditCommandFailed,
detail: reason,
});
assembly.tools = launcher.identity(None);
return finish(&args, assembly);
}
};
let Some(contract) = core_npm::select_contract(&version) else {
let mut assembly = base(NpmAttestOutcome::Partial {
reason: NpmPartialReason::UnsupportedNpmVersion,
detail: format!(
"npm {version} is outside every entry of tirith's closed audit contract table, \
so no audit command was run; tirith never passes a flag to an npm whose output \
shape it has not characterized"
),
});
assembly.tools = launcher.identity(Some(version));
return finish(&args, assembly);
};
let spawned = run_audit(&launcher, contract, &project, &audit_environment);
let tools = launcher.identity(Some(version));
let assembly = match spawned {
AuditRun::Failed { reason, detail } => {
let mut assembly = base(NpmAttestOutcome::Partial { reason, detail });
assembly.tools = tools;
assembly.invocation = Some(invocation_of(
contract,
&audit_environment.binding,
None,
None,
));
assembly
}
AuditRun::NotStarted { reason, detail } => {
let mut assembly = base(NpmAttestOutcome::Partial { reason, detail });
assembly.tools = tools;
assembly
}
AuditRun::Completed {
exit_code,
stdout,
stderr,
} => {
let stderr = redact_child_stderr(&stderr);
let invocation = invocation_of(
contract,
&audit_environment.binding,
Some(exit_code),
stderr,
);
match core_npm::parse_audit_report(&stdout, contract.schema) {
Err(error) => {
let mut assembly = base(NpmAttestOutcome::Partial {
reason: error.partial_reason(),
detail: format!("{} (npm exited {exit_code})", error.detail()),
});
assembly.tools = tools;
assembly.invocation = Some(invocation);
assembly
}
Ok(report) => {
let mut assembly = Assembly::with_audit(
&lockfile,
lockfile_sha256.clone(),
&inventory,
&report,
args.require_provenance,
);
assembly.tools = tools;
assembly.invocation = Some(invocation);
assembly
}
}
}
};
finish(&args, assembly)
}
fn resolve_project(requested: Option<&Path>) -> Result<PathBuf, String> {
let candidate = match requested {
Some(path) => path.to_path_buf(),
None => {
let cwd = std::env::current_dir()
.map_err(|error| format!("the working directory is unreadable: {error}"))?;
tirith_core::policy::find_repo_root(cwd.to_str()).unwrap_or(cwd)
}
};
if !candidate.is_dir() {
return Err(format!(
"--project must name a readable directory; '{}' is not one",
candidate.display()
));
}
Ok(candidate)
}
fn read_lockfile(path: &Path) -> Result<(NpmLockfile, Option<String>), (NpmPartialReason, String)> {
use tirith_core::util::{open_regular_capped, sha256_from_handle, HashOutcome};
let missing = |detail: String| (NpmPartialReason::MissingLockfile, detail);
let handle = open_regular_capped(path, core_npm::MAX_LOCKFILE_BYTES).map_err(|error| {
missing(format!(
"the project has no readable package-lock.json to bind to ({error:?})"
))
})?;
let cloned = handle.try_clone().map_err(|error| {
missing(format!(
"package-lock.json could not be read twice: {error}"
))
})?;
let digest = match sha256_from_handle(handle, core_npm::MAX_LOCKFILE_BYTES) {
Ok(HashOutcome::Digest(digest)) => Some(digest),
Ok(HashOutcome::BudgetExceeded) => None,
Err(error) => {
return Err(missing(format!(
"package-lock.json could not be hashed: {error}"
)))
}
};
let mut reader = cloned;
{
use std::io::Seek as _;
reader
.rewind()
.map_err(|error| missing(format!("package-lock.json could not be rewound: {error}")))?;
}
let mut bytes = Vec::new();
{
use std::io::Read as _;
(&mut reader)
.take(core_npm::MAX_LOCKFILE_BYTES)
.read_to_end(&mut bytes)
.map_err(|error| missing(format!("package-lock.json could not be read: {error}")))?;
}
let text = String::from_utf8(bytes)
.map_err(|_| missing("package-lock.json is not valid UTF-8".to_string()))?;
let lockfile = core_npm::parse_package_lock(&text).map_err(|error| {
let reason = match error {
core_npm::NpmLockfileError::TooManyEntries(_) => NpmPartialReason::LockfileTooLarge,
_ => NpmPartialReason::MissingLockfile,
};
(reason, error.to_string())
})?;
Ok((lockfile, digest))
}
const MAX_NPMRC_BYTES: u64 = 1024 * 1024;
fn project_npmrc_override(project: &Path) -> Result<Option<String>, String> {
let path = project.join(".npmrc");
match fs::symlink_metadata(&path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(format!(
"the project's .npmrc could not be inspected safely: {error}"
));
}
Ok(_) => {}
}
let bytes =
tirith_core::util::read_regular_capped(&path, MAX_NPMRC_BYTES).map_err(|error| {
format!("the project's .npmrc is not a bounded no-follow regular file ({error:?})")
})?;
let text = std::str::from_utf8(&bytes)
.map_err(|_| "the project's .npmrc is not valid UTF-8".to_string())?;
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
let key = line
.split_once('=')
.map_or("<unparsed-setting>", |(key, _)| key);
let key = key.trim();
let key = sanitize_for_human_output(key, false);
let key = tirith_core::util::truncate_bytes(&key, 128);
return Ok(Some(
key.rsplit(':')
.next()
.unwrap_or(key.as_str())
.trim()
.to_ascii_lowercase(),
));
}
Ok(None)
}
struct AuditEnvironment {
binding: NpmAuditEnvironment,
child_env: Vec<(OsString, OsString)>,
_scratch: tempfile::TempDir,
}
impl AuditEnvironment {
fn from_process() -> Result<Self, String> {
match optional_utf8_env(AUDIT_MODE_ENV)?.as_deref() {
None | Some("") | Some("public") => Self::public(),
Some("trusted-private") => Self::trusted_private(),
Some(_) => Err(format!(
"{AUDIT_MODE_ENV} must be 'public' or 'trusted-private'; no audit command was run"
)),
}
}
fn public() -> Result<Self, String> {
for name in [
PRIVATE_REGISTRY_ENV,
PRIVATE_AUTH_SOURCE_ENV,
PRIVATE_CA_FILE_ENV,
PRIVATE_PROXY_ENV,
] {
if optional_utf8_env(name)?.is_some_and(|value| !value.trim().is_empty()) {
return Err(format!(
"{name} was set without {AUDIT_MODE_ENV}=trusted-private; refusing an \
ambiguous registry configuration"
));
}
}
let scratch = isolated_npm_scratch()?;
let registry = core_npm::PUBLIC_NPM_REGISTRY_ORIGIN.to_string();
let child_env = base_npm_environment(&scratch, ®istry, None, None, None)?;
Ok(Self {
binding: NpmAuditEnvironment {
mode: NpmAuditMode::HermeticPublicRegistry,
registry_origin: registry,
strict_tls: true,
tls_ca_identity: "system_roots".to_string(),
proxy_identity: "direct".to_string(),
auth_source_identity: "none".to_string(),
},
child_env,
_scratch: scratch,
})
}
fn trusted_private() -> Result<Self, String> {
let registry = required_utf8_env(PRIVATE_REGISTRY_ENV)?;
let registry = canonical_https_origin(®istry, PRIVATE_REGISTRY_ENV)?;
if registry == core_npm::PUBLIC_NPM_REGISTRY_ORIGIN {
return Err(format!(
"{PRIVATE_REGISTRY_ENV} selects the public npm registry; use \
{AUDIT_MODE_ENV}=public so ambient credentials cannot be introduced"
));
}
let auth_source = PathBuf::from(required_utf8_env(PRIVATE_AUTH_SOURCE_ENV)?);
let (auth_bytes, auth_identity) = read_private_auth_source(&auth_source)?;
let scratch = isolated_npm_scratch()?;
let auth_snapshot = scratch.path().join("private-auth.npmrc");
write_owner_only(&auth_snapshot, &auth_bytes)?;
let (ca_snapshot, tls_ca_identity) = match optional_utf8_env(PRIVATE_CA_FILE_ENV)? {
Some(value) if !value.trim().is_empty() => {
let source = PathBuf::from(value);
if !source.is_absolute() {
return Err(format!("{PRIVATE_CA_FILE_ENV} must be an absolute path"));
}
let bytes = read_public_config_source(&source, PRIVATE_CA_FILE_ENV)?;
let identity = format!("sha256:{}", hex_sha256(&bytes));
let snapshot = scratch.path().join("private-ca.pem");
write_owner_only(&snapshot, &bytes)?;
(Some(snapshot), identity)
}
_ => (None, "system_roots".to_string()),
};
let (proxy, proxy_identity) = match optional_utf8_env(PRIVATE_PROXY_ENV)? {
Some(value) if !value.trim().is_empty() && value != "direct" => {
let canonical = canonical_https_origin(&value, PRIVATE_PROXY_ENV)?;
let identity = format!("sha256:{}", hex_sha256(canonical.as_bytes()));
(Some(canonical), identity)
}
_ => (None, "direct".to_string()),
};
let child_env = base_npm_environment(
&scratch,
®istry,
Some(&auth_snapshot),
ca_snapshot.as_deref(),
proxy.as_deref(),
)?;
Ok(Self {
binding: NpmAuditEnvironment {
mode: NpmAuditMode::TrustedPrivateRegistry,
registry_origin: registry,
strict_tls: true,
tls_ca_identity,
proxy_identity,
auth_source_identity: auth_identity,
},
child_env,
_scratch: scratch,
})
}
fn apply(&self, mut spec: ChildSpec) -> ChildSpec {
for (name, value) in &self.child_env {
spec = spec.env(name, value);
}
spec
}
}
fn optional_utf8_env(name: &str) -> Result<Option<String>, String> {
match std::env::var_os(name) {
None => Ok(None),
Some(value) => value.into_string().map(Some).map_err(|_| {
format!("{name} is not valid UTF-8, so its audit meaning cannot be bound")
}),
}
}
fn required_utf8_env(name: &str) -> Result<String, String> {
optional_utf8_env(name)?
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
format!(
"{AUDIT_MODE_ENV}=trusted-private requires a non-empty {name}; no audit command was run"
)
})
}
fn canonical_https_origin(raw: &str, name: &str) -> Result<String, String> {
if raw.len() > MAX_AUDIT_ORIGIN_BYTES {
return Err(format!("{name} exceeds the audit-origin size limit"));
}
let mut url = Url::parse(raw).map_err(|_| {
format!("{name} must be a canonical HTTPS origin; no audit command was run")
})?;
if url.scheme() != "https"
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| !matches!(url.path(), "" | "/")
{
return Err(format!(
"{name} must be an HTTPS origin with no credentials, path, query, or fragment"
));
}
url.set_path("/");
Ok(url.to_string())
}
fn isolated_npm_scratch() -> Result<tempfile::TempDir, String> {
let scratch = tempfile::Builder::new()
.prefix("tirith-npm-audit-")
.tempdir()
.map_err(|error| format!("cannot create an isolated npm audit directory: {error}"))?;
fs::create_dir(scratch.path().join("cache"))
.map_err(|error| format!("cannot create the isolated npm cache: {error}"))?;
write_owner_only(&scratch.path().join("empty-user.npmrc"), b"")?;
write_owner_only(&scratch.path().join("empty-global.npmrc"), b"")?;
Ok(scratch)
}
fn base_npm_environment(
scratch: &tempfile::TempDir,
registry: &str,
auth_source: Option<&Path>,
ca_file: Option<&Path>,
proxy: Option<&str>,
) -> Result<Vec<(OsString, OsString)>, String> {
let userconfig = auth_source
.map(Path::to_path_buf)
.unwrap_or_else(|| scratch.path().join("empty-user.npmrc"));
let mut env = vec![
(
OsString::from("HOME"),
scratch.path().as_os_str().to_os_string(),
),
(
OsString::from("NPM_CONFIG_USERCONFIG"),
userconfig.into_os_string(),
),
(
OsString::from("NPM_CONFIG_GLOBALCONFIG"),
scratch.path().join("empty-global.npmrc").into_os_string(),
),
(
OsString::from("NPM_CONFIG_CACHE"),
scratch.path().join("cache").into_os_string(),
),
(
OsString::from("NPM_CONFIG_REGISTRY"),
OsString::from(registry),
),
(
OsString::from("NPM_CONFIG_STRICT_SSL"),
OsString::from("true"),
),
(OsString::from("NPM_CONFIG_OMIT"), OsString::new()),
(
OsString::from("NPM_CONFIG_PROXY"),
proxy.map_or_else(OsString::new, OsString::from),
),
(
OsString::from("NPM_CONFIG_HTTPS_PROXY"),
proxy.map_or_else(OsString::new, OsString::from),
),
];
if let Some(path) = ca_file {
env.push((
OsString::from("NPM_CONFIG_CAFILE"),
path.as_os_str().to_os_string(),
));
}
Ok(env)
}
fn write_owner_only(path: &Path, bytes: &[u8]) -> Result<(), String> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options
.open(path)
.map_err(|error| format!("cannot create isolated npm config: {error}"))?;
file.write_all(bytes)
.and_then(|()| file.sync_all())
.map_err(|error| format!("cannot publish isolated npm config: {error}"))
}
fn read_public_config_source(path: &Path, name: &str) -> Result<Vec<u8>, String> {
tirith_core::util::read_regular_capped(path, MAX_NPMRC_BYTES)
.map_err(|error| format!("{name} is not a readable bounded regular file: {error:?}"))
}
fn read_private_auth_source(path: &Path) -> Result<(Vec<u8>, String), String> {
if !path.is_absolute() {
return Err(format!(
"{PRIVATE_AUTH_SOURCE_ENV} must be an absolute path"
));
}
let mut handle = tirith_core::util::open_regular_capped(path, MAX_NPMRC_BYTES)
.map_err(|error| format!("cannot inspect {PRIVATE_AUTH_SOURCE_ENV}: {error:?}"))?;
let metadata = handle
.metadata()
.map_err(|error| format!("cannot inspect {PRIVATE_AUTH_SOURCE_ENV}: {error}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
let current_uid = unsafe { libc::geteuid() };
if metadata.uid() != current_uid || metadata.mode() & 0o077 != 0 {
return Err(format!(
"{PRIVATE_AUTH_SOURCE_ENV} must be owned by the current user and owner-only \
(mode 0600 or stricter)"
));
}
}
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut handle, &mut bytes)
.map_err(|error| format!("cannot read {PRIVATE_AUTH_SOURCE_ENV}: {error}"))?;
if let Some(key) = disallowed_private_auth_key(&bytes) {
let key = sanitize_for_human_output(&key, false);
let key = tirith_core::util::truncate_bytes(&key, 128);
return Err(format!(
"{PRIVATE_AUTH_SOURCE_ENV} contains non-credential setting {key:?}; private mode \
binds registry, TLS, and proxy separately"
));
}
Ok((bytes, auth_source_metadata_identity(&metadata)))
}
fn disallowed_private_auth_key(bytes: &[u8]) -> Option<String> {
let text = match std::str::from_utf8(bytes) {
Ok(text) => text,
Err(_) => return Some("<non-utf8>".to_string()),
};
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
let Some((key, _)) = line.split_once('=') else {
return Some("<unparsed>".to_string());
};
let bare = key
.trim()
.rsplit(':')
.next()
.unwrap_or(key)
.to_ascii_lowercase();
if !matches!(
bare.as_str(),
"_authtoken" | "_auth" | "_password" | "username" | "email" | "always-auth"
) {
return Some(bare);
}
}
None
}
fn auth_source_metadata_identity(metadata: &fs::Metadata) -> String {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
format!(
"sha256:{}",
hex_sha256(
format!(
"dev={};ino={};uid={};gid={};mode={};len={};mtime={};mtime_ns={};ctime={};ctime_ns={}",
metadata.dev(),
metadata.ino(),
metadata.uid(),
metadata.gid(),
metadata.mode(),
metadata.len(),
metadata.mtime(),
metadata.mtime_nsec(),
metadata.ctime(),
metadata.ctime_nsec()
)
.as_bytes()
)
)
}
#[cfg(not(unix))]
format!(
"sha256:{}",
hex_sha256(format!("len={}", metadata.len()).as_bytes())
)
}
fn hex_sha256(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
struct Launcher {
npm: TrustedExecutable,
npm_sha256: Option<String>,
interpreter: Option<Interpreter>,
}
struct Interpreter {
name: String,
executable: TrustedExecutable,
sha256: Option<String>,
}
impl Launcher {
fn program(&self) -> &TrustedExecutable {
match &self.interpreter {
Some(interpreter) => &interpreter.executable,
None => &self.npm,
}
}
fn argv(&self, args: &[&str]) -> Vec<std::ffi::OsString> {
let mut argv: Vec<std::ffi::OsString> = Vec::new();
if self.interpreter.is_some() {
argv.push(self.npm.path().as_os_str().to_os_string());
}
argv.extend(args.iter().map(|arg| std::ffi::OsString::from(*arg)));
argv
}
fn revalidate_auxiliary(&self) -> Result<(), String> {
if self.interpreter.is_none() {
return Ok(());
}
self.npm
.revalidate()
.map_err(|error| format!("the npm script's identity changed before launch: {error}"))
}
fn identity(&self, version: Option<String>) -> NpmToolIdentity {
NpmToolIdentity {
npm_sha256: self.npm_sha256.clone(),
npm_version: version,
interpreter_sha256: self
.interpreter
.as_ref()
.and_then(|interpreter| interpreter.sha256.clone()),
interpreter_name: self
.interpreter
.as_ref()
.map(|interpreter| interpreter.name.clone()),
}
}
}
fn resolve_launcher() -> Result<Launcher, String> {
let npm = trusted_child::resolve_ambient("npm").map_err(describe_resolve_error)?;
let npm_sha256 = Some(file_sha256(npm.path()).ok_or_else(|| {
"the resolved npm executable could not be hashed, so its identity cannot be bound"
.to_string()
})?);
let interpreter = match shebang_interpreter_name(npm.path()) {
Some(name) => {
let executable = trusted_child::resolve_ambient(&name).map_err(|error| {
format!(
"npm is a script whose interpreter '{name}' could not be resolved as a \
trusted executable: {}",
describe_resolve_error(error)
)
})?;
let sha256 = Some(file_sha256(executable.path()).ok_or_else(|| {
format!(
"npm's resolved interpreter '{name}' could not be hashed, so its identity \
cannot be bound"
)
})?);
Some(Interpreter {
name,
executable,
sha256,
})
}
None => None,
};
Ok(Launcher {
npm,
npm_sha256,
interpreter,
})
}
fn describe_resolve_error(error: TrustedExecutableError) -> String {
format!("npm could not be resolved as a trusted executable: {error}")
}
fn shebang_interpreter_name(path: &Path) -> Option<String> {
let bytes = tirith_core::util::read_regular_capped(path, MAX_LAUNCHER_PROBE_BYTES).ok()?;
if !bytes.starts_with(b"#!") {
return None;
}
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]);
let name = tirith_core::script_analysis::detect_interpreter(&head);
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
fn file_sha256(path: &Path) -> Option<String> {
use tirith_core::util::{open_regular_capped, sha256_from_handle, HashOutcome};
let handle = open_regular_capped(path, 512 * 1024 * 1024).ok()?;
match sha256_from_handle(handle, 512 * 1024 * 1024) {
Ok(HashOutcome::Digest(digest)) => Some(digest),
_ => None,
}
}
fn spec_for(args: Vec<std::ffi::OsString>, limits: ChildLimits) -> ChildSpec {
let mut spec = ChildSpec::new(args, limits).inherit_env(INHERITED_ENV);
if let Some(path) = trusted_child::sanitized_ambient_path() {
spec = spec.env("PATH", path);
}
spec
}
fn probe_version(launcher: &Launcher) -> Result<String, String> {
launcher.revalidate_auxiliary()?;
let spec = spec_for(
launcher.argv(&["--version"]),
ChildLimits::new(VERSION_TIMEOUT, VERSION_OUTPUT_CAP, VERSION_OUTPUT_CAP),
);
match trusted_child::run(launcher.program(), &spec) {
ChildOutcome::Completed { status, stdout, .. } if status.success() => {
let text = String::from_utf8_lossy(&stdout).to_string();
core_npm::parse_npm_version(&text).ok_or_else(|| {
"npm --version did not print a plain numeric release version, so no contract \
could be selected and no audit command was run"
.to_string()
})
}
ChildOutcome::Completed { status, stderr, .. } => Err(format!(
"npm --version exited {:?}: {}",
status.code(),
redact_child_stderr(&stderr).unwrap_or_default()
)),
ChildOutcome::Timeout { .. } => {
Err("npm --version exceeded its 10 second budget".to_string())
}
ChildOutcome::OutputLimitExceeded { .. } => {
Err("npm --version exceeded its output limit".to_string())
}
ChildOutcome::SpawnError(reason)
| ChildOutcome::WaitError(reason)
| ChildOutcome::CleanupError(reason) => {
Err(format!("npm --version could not be run: {reason}"))
}
}
}
enum AuditRun {
Completed {
exit_code: i32,
stdout: String,
stderr: Vec<u8>,
},
Failed {
reason: NpmPartialReason,
detail: String,
},
NotStarted {
reason: NpmPartialReason,
detail: String,
},
}
fn run_audit(
launcher: &Launcher,
contract: &NpmAuditSignaturesContract,
project: &Path,
environment: &AuditEnvironment,
) -> AuditRun {
if let Err(detail) = launcher.revalidate_auxiliary() {
return AuditRun::NotStarted {
reason: NpmPartialReason::AuditCommandFailed,
detail,
};
}
let spec = environment
.apply(spec_for(
launcher.argv(contract.argv),
ChildLimits::new(AUDIT_TIMEOUT, AUDIT_OUTPUT_CAP, AUDIT_OUTPUT_CAP),
))
.cwd(project);
match trusted_child::run(launcher.program(), &spec) {
ChildOutcome::Completed {
status,
stdout,
stderr,
} => AuditRun::Completed {
exit_code: status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&stdout).to_string(),
stderr,
},
ChildOutcome::Timeout { .. } => AuditRun::Failed {
reason: NpmPartialReason::Timeout,
detail: "npm audit signatures exceeded its 120 second budget".to_string(),
},
ChildOutcome::OutputLimitExceeded { stream, .. } => AuditRun::Failed {
reason: NpmPartialReason::OutputLimitExceeded,
detail: format!(
"npm audit signatures exceeded the {AUDIT_OUTPUT_CAP}-byte {stream:?} cap, so its \
output was not parsed"
),
},
ChildOutcome::SpawnError(reason) => AuditRun::NotStarted {
reason: NpmPartialReason::AuditCommandFailed,
detail: format!("npm audit signatures could not be started: {reason}"),
},
ChildOutcome::WaitError(reason) | ChildOutcome::CleanupError(reason) => AuditRun::Failed {
reason: NpmPartialReason::AuditCommandFailed,
detail: format!("npm audit signatures could not be run: {reason}"),
},
}
}
const NPMRC_CREDENTIAL_KEYS: &[&str] = &["_authtoken", "_auth", "_password", "_secret"];
fn redact_npmrc_credentials(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut cursor = 0usize;
let bytes = text.as_bytes();
while let Some(offset) = text[cursor..].find('=') {
let equals = cursor + offset;
let key_start = text[cursor..equals]
.rfind(|c: char| c.is_ascii_whitespace())
.map_or(cursor, |index| cursor + index + 1);
let key = &text[key_start..equals];
let bare = key.rsplit(':').next().unwrap_or(key).to_ascii_lowercase();
if !NPMRC_CREDENTIAL_KEYS.contains(&bare.as_str()) {
out.push_str(&text[cursor..=equals]);
cursor = equals + 1;
continue;
}
let value_end = bytes[equals + 1..]
.iter()
.position(|byte| byte.is_ascii_whitespace())
.map_or(text.len(), |index| equals + 1 + index);
out.push_str(&text[cursor..=equals]);
out.push_str("[REDACTED:npmrc_credential]");
cursor = value_end;
}
out.push_str(&text[cursor..]);
out
}
fn redact_child_stderr(stderr: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(stderr);
if text.trim().is_empty() {
return None;
}
let keyed = redact_npmrc_credentials(&text);
let redacted = tirith_core::redact::redact_blocked_output(&keyed);
let pathless = tirith_core::capsule_receipt::redact_host_paths(&redacted);
let bounded = tirith_core::util::truncate_bytes(pathless.trim(), MAX_RECORDED_STDERR);
if bounded.trim().is_empty() {
None
} else {
Some(bounded)
}
}
fn invocation_of(
contract: &NpmAuditSignaturesContract,
environment: &NpmAuditEnvironment,
exit_code: Option<i32>,
stderr: Option<String>,
) -> NpmAuditInvocation {
NpmAuditInvocation {
contract_id: contract.id.to_string(),
version_range: contract.version_range.to_string(),
argv: contract.argv.iter().map(|arg| (*arg).to_string()).collect(),
attestation_bundles_available: contract.attestation_bundles_available,
environment: Some(environment.clone()),
exit_code,
stderr,
}
}
struct Assembly {
outcome: NpmAttestOutcome,
subject: NpmReceiptSubject,
tools: NpmToolIdentity,
invocation: Option<NpmAuditInvocation>,
assessment: NpmAssessment,
}
impl Assembly {
fn partial(reason: NpmPartialReason, detail: String) -> Self {
Self {
outcome: NpmAttestOutcome::Partial { reason, detail },
subject: NpmReceiptSubject {
lockfile_name: "package-lock.json".to_string(),
lockfile_sha256: None,
lockfile_version: None,
project_name: None,
registry_hosts: Vec::new(),
audit_registry_hosts: Vec::new(),
project_npmrc_present: false,
installed_package_count: 0,
},
tools: NpmToolIdentity::default(),
invocation: None,
assessment: NpmAssessment {
records: Vec::new(),
coverage: core_npm::NpmCoverage {
accounted_installed: 0,
unaccounted_installed: 0,
unaccounted_locations: Vec::new(),
registry_entries: 0,
unsupported_source_entries: 0,
inventory_capped: false,
symlinked_entries_skipped: 0,
unmatched_audit_entries: 0,
signature_only_derived_by_subtraction: false,
},
},
}
}
fn without_audit(
lockfile: &NpmLockfile,
lockfile_sha256: Option<String>,
inventory: &InstalledInventory,
npmrc_present: bool,
outcome: NpmAttestOutcome,
) -> Self {
let assessment = core_npm::reconcile(lockfile, inventory, None);
Self {
outcome,
subject: subject_of(
lockfile,
lockfile_sha256,
inventory,
npmrc_present,
&assessment,
),
tools: NpmToolIdentity::default(),
invocation: None,
assessment,
}
}
fn with_audit(
lockfile: &NpmLockfile,
lockfile_sha256: Option<String>,
inventory: &InstalledInventory,
report: &NpmAuditReport,
require_provenance: bool,
) -> Self {
let assessment = core_npm::reconcile(lockfile, inventory, Some(report));
let unbound = if lockfile_sha256.is_none() {
Some(
"package-lock.json could not be digested, so the answer is not bound to its exact \
bytes"
.to_string(),
)
} else {
None
};
let gap = assessment.coverage_gap().or(unbound);
let outcome = core_npm::apply_coverage_gap(
core_npm::overall_outcome(&assessment.statuses(), require_provenance),
gap.as_deref(),
);
Self {
outcome,
subject: subject_of(lockfile, lockfile_sha256, inventory, false, &assessment),
tools: NpmToolIdentity::default(),
invocation: None,
assessment,
}
}
}
fn subject_of(
lockfile: &NpmLockfile,
lockfile_sha256: Option<String>,
inventory: &InstalledInventory,
npmrc_present: bool,
assessment: &NpmAssessment,
) -> NpmReceiptSubject {
NpmReceiptSubject {
lockfile_name: "package-lock.json".to_string(),
lockfile_sha256,
lockfile_version: Some(lockfile.lockfile_version),
project_name: lockfile.root_name.clone(),
registry_hosts: lockfile.registry_hosts(),
audit_registry_hosts: assessment.audit_registry_hosts(),
project_npmrc_present: npmrc_present,
installed_package_count: inventory.packages.len(),
}
}
fn finish(args: &AttestNpmArgs, assembly: Assembly) -> i32 {
let cwd = std::env::current_dir()
.ok()
.map(|path| path.display().to_string());
let policy = Policy::discover_local_only(cwd.as_deref());
let receipt = NpmProvenanceReceipt::new(NpmReceiptFacts {
policy_projection_hash: policy.security_projection_hash(),
outcome: assembly.outcome,
require_provenance: args.require_provenance,
subject: assembly.subject,
tools: assembly.tools,
invocation: assembly.invocation,
assessment: assembly.assessment,
});
if let Err(error) = receipt.validate() {
eprintln!("tirith pkg attest-npm: {error}");
return 2;
}
if let Some(path) = args.out.as_deref() {
if let Err(error) = receipt.write_to(path) {
eprintln!(
"tirith pkg attest-npm: cannot write the receipt to {}: {error}",
path.display()
);
return 2;
}
}
let exit = receipt.outcome.exit_code();
if args.json {
if !write_json_stdout(&receipt, "tirith pkg attest-npm: failed to write JSON") {
return 2;
}
return exit;
}
print_human(&receipt, args.out.as_deref());
exit
}
fn print_human(receipt: &NpmProvenanceReceipt, out: Option<&Path>) {
let short = &receipt.receipt_id[..receipt.receipt_id.len().min(16)];
eprintln!(
"tirith pkg attest-npm: {} (receipt {short})",
receipt.outcome.label()
);
match &receipt.outcome {
NpmAttestOutcome::Clean => {
eprintln!(" npm verified a registry signature for every eligible package.");
}
NpmAttestOutcome::Partial { reason, detail } => {
eprintln!(" not a complete answer ({}):", reason.label());
eprintln!(" {}", sanitize_for_human_output(detail, false));
}
NpmAttestOutcome::Mismatch { detail } => {
eprintln!(" SIGNATURE OR PROVENANCE MISMATCH");
eprintln!(" {}", sanitize_for_human_output(detail, false));
}
}
if let Some(version) = receipt.tools.npm_version.as_deref() {
eprintln!(
" npm version: {}",
sanitize_for_human_output(version, false)
);
}
match receipt.invocation.as_ref() {
Some(invocation) => {
eprintln!(" contract: {}", invocation.contract_id);
eprintln!(" argv: npm {}", invocation.argv.join(" "));
if let Some(environment) = invocation.environment.as_ref() {
eprintln!(
" audit mode: {} ({})",
environment.mode.label(),
sanitize_for_human_output(&environment.registry_origin, false)
);
}
match invocation.exit_code {
Some(code) => eprintln!(" exit code: {code}"),
None => eprintln!(" exit code: none; the command did not complete"),
}
}
None => eprintln!(" contract: none selected; no audit command was run"),
}
if let Some(digest) = receipt.subject.lockfile_sha256.as_deref() {
eprintln!(" lockfile: package-lock.json sha256 {digest}");
}
if !receipt.subject.registry_hosts.is_empty() {
eprintln!(
" registries: {} (from package-lock.json)",
sanitize_for_human_output(&receipt.subject.registry_hosts.join(", "), false)
);
}
if !receipt.subject.audit_registry_hosts.is_empty() {
eprintln!(
" npm audited: {} (the registry npm itself reported)",
sanitize_for_human_output(&receipt.subject.audit_registry_hosts.join(", "), false)
);
}
if receipt.subject.project_npmrc_present {
eprintln!(" PROJECT .npmrc: the audited project carries its own npm configuration");
}
let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for record in &receipt.packages {
*counts.entry(record.status.label()).or_default() += 1;
}
if !counts.is_empty() {
eprintln!(" packages:");
for (label, count) in counts {
eprintln!(" {label}: {count}");
}
}
if receipt.coverage.unaccounted_installed > 0 {
eprintln!(
" UNACCOUNTED: {} installed package(s) have no package-lock.json entry",
receipt.coverage.unaccounted_installed
);
for location in &receipt.coverage.unaccounted_locations {
eprintln!(" {}", sanitize_for_human_output(location, false));
}
}
if let Some(stderr) = receipt
.invocation
.as_ref()
.and_then(|invocation| invocation.stderr.as_deref())
{
eprintln!(" npm stderr (redacted):");
for line in stderr.split('\n') {
eprintln!(" | {}", sanitize_for_human_output(line, false));
}
}
for caveat in &receipt.caveats {
eprintln!(" NOTE: {}", sanitize_for_human_output(caveat, false));
}
if let Some(path) = out {
eprintln!(" receipt written to {}", path.display());
}
}
fn usage_error(json: bool, message: &str) -> i32 {
if json {
let envelope = serde_json::json!({
"command": "pkg attest-npm",
"status": "error",
"error": message,
});
let _ = write_json_stdout(&envelope, "tirith pkg attest-npm: failed to write JSON");
} else {
eprintln!("tirith pkg attest-npm: {message}");
eprintln!(" try: tirith pkg attest-npm --project .");
}
2
}
#[cfg(test)]
mod tests {
use super::*;
trait NpmInvoker {
fn invoke(&mut self, environment: &AuditEnvironment);
}
#[derive(Default)]
struct RecordingNpmInvoker {
mode: Option<NpmAuditMode>,
registry: Option<String>,
}
impl NpmInvoker for RecordingNpmInvoker {
fn invoke(&mut self, environment: &AuditEnvironment) {
self.mode = Some(environment.binding.mode);
self.registry = Some(environment.binding.registry_origin.clone());
}
}
fn invoke_for_test(invoker: &mut dyn NpmInvoker, environment: &AuditEnvironment) {
invoker.invoke(environment);
}
#[test]
fn the_npm_invoker_seam_is_test_only_and_observes_the_frozen_binding() {
let scratch = isolated_npm_scratch().expect("isolated npm config");
let environment = AuditEnvironment {
binding: NpmAuditEnvironment {
mode: NpmAuditMode::HermeticPublicRegistry,
registry_origin: "https://registry.npmjs.org/".to_string(),
strict_tls: true,
tls_ca_identity: "system_roots".to_string(),
proxy_identity: "direct".to_string(),
auth_source_identity: "none".to_string(),
},
child_env: base_npm_environment(
&scratch,
"https://registry.npmjs.org/",
None,
None,
None,
)
.expect("public environment"),
_scratch: scratch,
};
let mut invoker = RecordingNpmInvoker::default();
invoke_for_test(&mut invoker, &environment);
assert_eq!(invoker.mode, Some(NpmAuditMode::HermeticPublicRegistry));
assert_eq!(
invoker.registry.as_deref(),
Some("https://registry.npmjs.org/")
);
}
#[test]
fn stderr_redaction_drops_tokens_and_absolute_paths() {
let raw = b"npm error code E401\nnpm error /Users/example/.npmrc\n\
//registry.example/:_authToken=npm_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ\n";
let redacted = redact_child_stderr(raw).expect("non-empty stderr is retained");
assert!(
!redacted.contains("npm_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"),
"an auth token must not survive into a durable receipt: {redacted}"
);
assert!(
!redacted.contains("/Users/example"),
"an absolute host path must not survive: {redacted}"
);
assert!(
redacted.contains("E401"),
"the diagnosis survives: {redacted}"
);
}
#[test]
fn npmrc_credential_keys_are_blanked_wherever_they_appear_on_a_line() {
let token_key = "_auth";
let uuid_secret = "deadbeef-0000-4000-8000-feedfacecafe";
let basic_secret = "ZGVwbG95LXVzZXI6czNjcjN0LXA0c3N3MHJk";
let raw = format!(
"npm error registry=https://registry.example/ {token_key}Token={uuid_secret} \
verbose=true\nnpm error {token_key}={basic_secret}\nnpm error code E401\n"
);
let redacted = redact_child_stderr(raw.as_bytes()).expect("non-empty stderr is retained");
for secret in [uuid_secret, basic_secret] {
assert!(
!redacted.contains(secret),
"a keyed .npmrc credential must not survive: {redacted}"
);
}
assert!(
redacted.contains("_authToken=[REDACTED:npmrc_credential]"),
"the key survives so the operator knows which credential failed: {redacted}"
);
assert!(
redacted.contains("_auth=[REDACTED:npmrc_credential]"),
"{redacted}"
);
assert!(
redacted.contains("verbose=true") && redacted.contains("E401"),
"the rest of the line and the diagnosis survive: {redacted}"
);
}
#[test]
fn empty_stderr_is_omitted_entirely() {
assert!(redact_child_stderr(b"").is_none());
assert!(redact_child_stderr(b" \n\t\n").is_none());
}
#[test]
fn the_recorded_stderr_is_bounded() {
let raw = "npm error ".repeat(4096);
let redacted = redact_child_stderr(raw.as_bytes()).expect("non-empty");
assert!(
redacted.len() <= MAX_RECORDED_STDERR,
"recorded stderr must stay bounded, got {} bytes",
redacted.len()
);
}
#[test]
fn a_missing_project_directory_is_a_usage_error_not_a_partial() {
let error = resolve_project(Some(Path::new("/definitely/not/a/directory/for/c17")))
.expect_err("a non-directory must be refused");
assert!(error.contains("--project"), "{error}");
}
#[test]
fn the_early_partial_shape_carries_the_caveat_and_no_invocation() {
let assembly =
Assembly::partial(NpmPartialReason::MissingLockfile, "no lockfile".to_string());
assert!(assembly.invocation.is_none());
assert_eq!(assembly.subject.lockfile_sha256, None);
assert!(matches!(
assembly.outcome,
NpmAttestOutcome::Partial {
reason: NpmPartialReason::MissingLockfile,
..
}
));
}
}