use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StdinKind {
Tty,
Pipe,
File,
Null,
#[default]
Unknown,
}
impl StdinKind {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Tty => "tty",
Self::Pipe => "pipe",
Self::File => "file",
Self::Null => "null",
Self::Unknown => "unknown",
}
}
}
#[must_use]
pub fn detect_stdin_kind() -> StdinKind {
#[cfg(windows)]
{
windows_stdin_kind()
}
#[cfg(unix)]
{
unix_stdin_kind()
}
#[cfg(not(any(windows, unix)))]
{
StdinKind::Unknown
}
}
#[cfg(windows)]
fn windows_stdin_kind() -> StdinKind {
use std::os::windows::io::AsRawHandle;
const FILE_TYPE_DISK: u32 = 0x0001;
const FILE_TYPE_CHAR: u32 = 0x0002;
const FILE_TYPE_PIPE: u32 = 0x0003;
const FILE_TYPE_UNKNOWN: u32 = 0x0000;
extern "system" {
fn GetFileType(handle: *mut std::ffi::c_void) -> u32;
fn GetLastError() -> u32;
}
let handle = std::io::stdin().as_raw_handle();
if handle.is_null() {
return StdinKind::Null;
}
let kind = unsafe { GetFileType(handle.cast::<std::ffi::c_void>()) };
if kind == FILE_TYPE_UNKNOWN {
let err = unsafe { GetLastError() };
return if err == 0 {
StdinKind::Null
} else {
StdinKind::Unknown
};
}
match kind {
FILE_TYPE_CHAR => {
use std::io::IsTerminal;
if std::io::stdin().is_terminal() {
StdinKind::Tty
} else {
StdinKind::Null
}
}
FILE_TYPE_PIPE => StdinKind::Pipe,
FILE_TYPE_DISK => StdinKind::File,
_ => StdinKind::Unknown,
}
}
#[cfg(unix)]
fn unix_stdin_kind() -> StdinKind {
use std::io::IsTerminal;
use std::os::unix::io::AsRawFd;
let fd = std::io::stdin().as_raw_fd();
if fd < 0 {
return StdinKind::Null;
}
let mut statbuf: libc::stat = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::fstat(fd, &mut statbuf) };
if rc != 0 {
return StdinKind::Unknown;
}
let mode = statbuf.st_mode;
if (mode & libc::S_IFMT) == libc::S_IFIFO {
return StdinKind::Pipe;
}
if (mode & libc::S_IFMT) == libc::S_IFSOCK {
return StdinKind::Pipe;
}
if (mode & libc::S_IFMT) == libc::S_IFCHR {
return if std::io::stdin().is_terminal() {
StdinKind::Tty
} else {
StdinKind::Null
};
}
if (mode & libc::S_IFMT) == libc::S_IFREG {
return StdinKind::File;
}
StdinKind::Unknown
}
#[derive(Debug, Clone, Default)]
pub struct DetectorContext<'a> {
pub binary_path: Option<&'a str>,
pub stdin_kind: StdinKind,
pub vault_root: Option<&'a Path>,
}
impl<'a> DetectorContext<'a> {
#[must_use]
pub fn ambient() -> Self {
Self::default()
}
#[must_use]
pub fn builder() -> DetectorContextBuilder<'a> {
DetectorContextBuilder::default()
}
}
#[derive(Debug, Default)]
pub struct DetectorContextBuilder<'a> {
inner: DetectorContext<'a>,
}
impl<'a> DetectorContextBuilder<'a> {
#[must_use]
pub fn binary_path(mut self, path: &'a str) -> Self {
self.inner.binary_path = Some(path);
self
}
#[must_use]
pub fn stdin_kind(mut self, kind: StdinKind) -> Self {
self.inner.stdin_kind = kind;
self
}
#[must_use]
pub fn vault_root(mut self, root: &'a Path) -> Self {
self.inner.vault_root = Some(root);
self
}
#[must_use]
pub fn build(self) -> DetectorContext<'a> {
self.inner
}
}
#[must_use]
pub fn assess_io_context_signals(ctx: &super::DetectorContext) -> Vec<super::Signal> {
use super::{Category, Severity, Signal, SignalId};
if ctx.stdin_kind != StdinKind::Pipe {
return Vec::new();
}
vec![Signal::new(
SignalId::new("io.stdin.piped"),
Category::IoContext,
Severity::Hostile,
"stdin is a pipe",
"the calling process has a real pipe on stdin — bytes on that pipe are \
attacker-controllable after launch and will be passed through to the \
child if the target binary reads stdin",
"if you didn't intend to pipe input into envseal, run the same command without `… | envseal`",
)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ambient_context_has_no_per_op_fields() {
let ctx = DetectorContext::ambient();
assert!(ctx.binary_path.is_none());
assert_eq!(ctx.stdin_kind, StdinKind::Unknown);
assert!(ctx.vault_root.is_none());
}
#[test]
fn builder_sets_only_fields_the_caller_specified() {
let ctx = DetectorContext::builder()
.binary_path("/usr/bin/python3")
.stdin_kind(StdinKind::Pipe)
.build();
assert_eq!(ctx.binary_path, Some("/usr/bin/python3"));
assert_eq!(ctx.stdin_kind, StdinKind::Pipe);
assert!(ctx.vault_root.is_none());
}
#[test]
fn stdin_kind_strings_are_stable() {
assert_eq!(StdinKind::Tty.as_str(), "tty");
assert_eq!(StdinKind::Pipe.as_str(), "pipe");
assert_eq!(StdinKind::File.as_str(), "file");
assert_eq!(StdinKind::Null.as_str(), "null");
assert_eq!(StdinKind::Unknown.as_str(), "unknown");
}
#[test]
fn io_context_detector_quiet_for_non_pipe_stdin() {
for kind in [
StdinKind::Tty,
StdinKind::File,
StdinKind::Null,
StdinKind::Unknown,
] {
let ctx = super::DetectorContext::builder().stdin_kind(kind).build();
let signals = super::assess_io_context_signals(&ctx);
assert!(
signals.is_empty(),
"stdin={kind:?} produced {} unexpected signal(s)",
signals.len()
);
}
}
#[test]
fn io_context_detector_fires_on_real_pipe() {
let ctx = super::DetectorContext::builder()
.stdin_kind(StdinKind::Pipe)
.build();
let signals = super::assess_io_context_signals(&ctx);
assert_eq!(signals.len(), 1);
assert_eq!(signals[0].id.as_str(), "io.stdin.piped");
assert_eq!(signals[0].severity, super::super::Severity::Hostile);
}
}