use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum SinkClass {
Benign,
Unknown,
Reproducible,
}
#[derive(Debug, Clone)]
pub(crate) struct TimestampUse {
pub(crate) line: usize,
pub(crate) col: usize,
pub(crate) len: usize,
pub(crate) var: Option<String>,
pub(crate) class: SinkClass,
pub(crate) sink_line: Option<usize>,
pub(crate) sink_text: Option<String>,
saw_use: bool,
}
pub(crate) fn analyze(source: &str) -> Vec<TimestampUse> {
let skip = quoted_heredoc_lines(source);
let mut st = FlowState::default();
for (idx, line) in source.lines().enumerate() {
let ln = idx + 1;
if !skip.contains(&ln) {
scan_line(&mut st, ln, line);
}
}
finalize(st.uses)
}
fn quoted_heredoc_lines(source: &str) -> HashSet<usize> {
crate::linter::heredoc::quoted_heredoc_lines(source)
}
const ARTIFACT_CMDS: &[&str] = &[
"cp", "mv", "install", "ln", "mkdir", "rmdir", "touch", "tar", "zip", "unzip", "gzip", "bzip2",
"xz", "rsync", "scp", "dd", "cpio", "7z",
];
const HASH_CMDS: &[&str] = &[
"sha1sum",
"sha256sum",
"sha512sum",
"md5sum",
"cksum",
"shasum",
"b2sum",
];
const BUILD_CMDS: &[&str] = &[
"docker", "podman", "buildah", "git", "npm", "cargo", "helm", "gh",
];
const BUILD_SUBCMDS: &[&str] = &[
"build", "tag", "publish", "version", "package", "release", "push", "commit",
];
const PRINT_CMDS: &[&str] = &["echo", "printf", "print"];
const LOG_CMDS: &[&str] = &["logger", "syslog"];
const NEUTRAL_CMDS: &[&str] = &["sleep", "test", "true", "false", ":"];
const CMD_PREFIXES: &[&str] = &["sudo", "env", "command", "nohup", "time", "exec", "eval"];
const KEYWORDS: &[&str] = &[
"if", "elif", "while", "until", "then", "do", "else", "!", "{",
];
const SINKLESS_TARGETS: &[&str] = &["/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty"];
const BUILD_ID_NAMES: &[&str] = &[
"VERSION",
"RELEASE",
"BUILDID",
"BUILDNUMBER",
"BUILDTAG",
"REVISION",
"ARTIFACT",
"ARTIFACTNAME",
"IMAGETAG",
"PKGVERSION",
"PACKAGEVERSION",
"CHECKSUM",
"DIGEST",
"TAG",
];
const DECLARATORS: &[&str] = &["export", "local", "readonly", "declare", "typeset"];
const COND_KEYWORDS: &[&str] = &["if ", "elif ", "while ", "until "];
const DATE_PATTERNS: [(&str, usize); 3] = [("date +%s", 8), ("$(date", 6), ("`date", 5)];
const MARKERS: &[&str] = &[
"intentional: timestamp",
"intentional timestamp",
"timestamp for result tracking",
"timestamp for tracking",
"benchmark result",
"benchmark recording",
"logging timestamp",
"log timestamp",
"metrics recording",
"record metric",
"record-metric",
"metrics timestamp",
"telemetry",
"observability",
];
#[derive(Clone, Copy, PartialEq, Eq)]
enum Ctx {
Code,
CmdSub,
ParamExp,
Backtick,
Single,
Double,
Comment,
}
struct LineMask {
literal: Vec<bool>,
depth: Vec<usize>,
comment: Option<usize>,
}
impl LineMask {
fn is_literal(&self, i: usize) -> bool {
self.literal.get(i).copied().unwrap_or(false)
}
fn depth_at(&self, i: usize) -> usize {
self.depth.get(i).copied().unwrap_or(0)
}
fn code_of<'a>(&self, line: &'a str) -> &'a str {
match self.comment {
Some(c) => &line[..c],
None => line,
}
}
fn is_word_break(&self, b: &[u8], i: usize) -> bool {
b.get(i).is_some_and(u8::is_ascii_whitespace)
&& !self.is_literal(i)
&& self.depth_at(i) == 1
}
}
struct Scanner<'a> {
b: &'a [u8],
i: usize,
mask: LineMask,
stack: Vec<Ctx>,
}
impl<'a> Scanner<'a> {
fn scan(line: &'a str) -> LineMask {
let mut s = Scanner {
b: line.as_bytes(),
i: 0,
mask: LineMask {
literal: vec![false; line.len()],
depth: vec![0; line.len()],
comment: None,
},
stack: vec![Ctx::Code],
};
while s.i < s.b.len() {
s.step();
}
s.mask
}
fn top(&self) -> Ctx {
self.stack.last().copied().unwrap_or(Ctx::Code)
}
fn step(&mut self) {
let ctx = self.top();
if let Some(d) = self.mask.depth.get_mut(self.i) {
*d = self.stack.len();
}
match ctx {
Ctx::Comment => self.step_comment(),
Ctx::Single => self.step_single(),
Ctx::Double => self.step_double(),
_ => self.step_code(),
}
}
fn step_comment(&mut self) {
for m in self.mask.literal.iter_mut().skip(self.i) {
*m = true;
}
self.i = self.b.len();
}
fn step_single(&mut self) {
self.mark_literal(self.i);
if self.b[self.i] == b'\'' {
self.stack.pop();
}
self.i += 1;
}
fn step_double(&mut self) {
let c = self.b[self.i];
if c == b'\\' && self.i + 1 < self.b.len() {
self.mark_literal(self.i);
self.mark_literal(self.i + 1);
self.i += 2;
return;
}
if c == b'"' {
self.mark_literal(self.i);
self.stack.pop();
self.i += 1;
return;
}
if self.open_expansion() {
return;
}
self.mark_literal(self.i);
self.i += 1;
}
fn step_code(&mut self) {
let c = self.b[self.i];
if c == b'\\' && self.i + 1 < self.b.len() {
self.i += 2;
return;
}
if self.close_code() {
self.i += 1;
return;
}
if c == b'\'' || c == b'"' {
self.mark_literal(self.i);
self.stack
.push(if c == b'\'' { Ctx::Single } else { Ctx::Double });
self.i += 1;
return;
}
if self.open_expansion() {
return;
}
if self.is_comment_start() {
self.mask.comment = Some(self.i);
self.stack.push(Ctx::Comment);
return;
}
self.i += 1;
}
fn open_expansion(&mut self) -> bool {
let (ctx, width) = match self.pending_opener() {
Some(v) => v,
None => return false,
};
self.stack.push(ctx);
self.i += width;
true
}
fn pending_opener(&self) -> Option<(Ctx, usize)> {
if starts_with(self.b, self.i, b"$(") {
return Some((Ctx::CmdSub, 2));
}
if starts_with(self.b, self.i, b"${") {
return Some((Ctx::ParamExp, 2));
}
if self.b[self.i] == b'`' {
return Some((Ctx::Backtick, 1));
}
None
}
fn close_code(&mut self) -> bool {
let closes = matches!(
(self.top(), self.b[self.i]),
(Ctx::CmdSub, b')') | (Ctx::ParamExp, b'}') | (Ctx::Backtick, b'`')
);
if closes {
self.stack.pop();
}
closes
}
fn is_comment_start(&self) -> bool {
if self.b[self.i] != b'#' || self.stack.len() != 1 {
return false;
}
self.i == 0 || self.b[self.i - 1].is_ascii_whitespace() || self.b[self.i - 1] == b';'
}
fn mark_literal(&mut self, i: usize) {
if let Some(m) = self.mask.literal.get_mut(i) {
*m = true;
}
}
}
fn starts_with(b: &[u8], i: usize, pat: &[u8]) -> bool {
b.len() >= i + pat.len() && &b[i..i + pat.len()] == pat
}
fn find_from(hay: &[u8], pat: &[u8], from: usize) -> Option<usize> {
if pat.is_empty() || hay.len() < pat.len() {
return None;
}
(from..=hay.len() - pat.len()).find(|&i| &hay[i..i + pat.len()] == pat)
}
fn split_words(s: &str) -> Vec<&str> {
let m = Scanner::scan(s);
let b = s.as_bytes();
let mut out = Vec::new();
let mut start: Option<usize> = None;
for i in 0..b.len() {
if m.is_word_break(b, i) {
if let Some(st) = start.take() {
out.push(&s[st..i]);
}
} else if start.is_none() {
start = Some(i);
}
}
if let Some(st) = start {
out.push(&s[st..]);
}
out
}
fn pipeline_segments(code: &str) -> Vec<&str> {
let m = Scanner::scan(code);
let b = code.as_bytes();
let mut out = Vec::new();
let mut start = 0;
for i in 0..b.len() {
if is_pipe_at(b, i, &m) {
out.push(&code[start..i]);
start = i + 1;
}
}
out.push(&code[start..]);
out
}
fn is_pipe_at(b: &[u8], i: usize, m: &LineMask) -> bool {
b[i] == b'|'
&& !m.is_literal(i)
&& m.depth_at(i) == 1
&& b.get(i + 1) != Some(&b'|')
&& (i == 0 || b[i - 1] != b'|')
}
fn split_name_eq(s: &str) -> Option<(&str, usize)> {
let b = s.as_bytes();
if b.is_empty() || !(b[0].is_ascii_alphabetic() || b[0] == b'_') {
return None;
}
let mut i = 0;
while i < b.len() && (b[i] == b'_' || b[i].is_ascii_alphanumeric()) {
i += 1;
}
let mut j = i;
if b.get(j) == Some(&b'+') {
j += 1;
}
if b.get(j) != Some(&b'=') {
return None;
}
Some((&s[..i], j + 1))
}
fn command_word(seg: &str) -> Option<String> {
for w in split_words(seg) {
if is_skippable_prefix(w) {
continue;
}
return Some(basename(w).to_string());
}
None
}
fn is_skippable_prefix(w: &str) -> bool {
CMD_PREFIXES.contains(&w)
|| KEYWORDS.contains(&w)
|| w.starts_with('-')
|| split_name_eq(w).is_some()
}
fn basename(w: &str) -> &str {
match w.rsplit('/').next() {
Some(x) if !x.is_empty() => x,
_ => w,
}
}
fn unquote(s: &str) -> &str {
let t = s.trim();
for q in ['"', '\''] {
if t.len() >= 2 && t.starts_with(q) && t.ends_with(q) {
return &t[1..t.len() - 1];
}
}
t
}
enum Redirect<'a> {
Truncate(&'a str),
Append(&'a str),
Fd,
}
fn redirect_of(seg: &str) -> Option<Redirect<'_>> {
let m = Scanner::scan(seg);
let b = seg.as_bytes();
let mut found = None;
let mut i = 0;
while i < b.len() {
if b[i] == b'>' && !m.is_literal(i) && m.depth_at(i) == 1 {
let (r, next) = parse_redirect(seg, b, i);
found = Some(r);
i = next;
} else {
i += 1;
}
}
found
}
fn parse_redirect<'a>(seg: &'a str, b: &[u8], i: usize) -> (Redirect<'a>, usize) {
let append = b.get(i + 1) == Some(&b'>');
let mut j = i + if append { 2 } else { 1 };
if b.get(j) == Some(&b'&') {
return (Redirect::Fd, j + 2);
}
while j < b.len() && b[j].is_ascii_whitespace() {
j += 1;
}
let target = split_words(&seg[j..]).first().copied().unwrap_or("");
let end = (j + target.len()).max(i + 1);
let r = if append {
Redirect::Append(target)
} else {
Redirect::Truncate(target)
};
(r, end)
}
fn is_sinkless(target: &str) -> bool {
SINKLESS_TARGETS.contains(&unquote(target))
}
fn has_append_flag(seg: &str) -> bool {
split_words(seg)
.iter()
.any(|w| *w == "-a" || *w == "--append")
}
enum Needle<'a> {
Text(&'a str),
Var(&'a str),
}
impl Needle<'_> {
fn found_in(&self, hay: &str) -> bool {
match self {
Needle::Text(t) => hay.contains(t),
Needle::Var(v) => references(hay, v),
}
}
}
fn references(hay: &str, var: &str) -> bool {
let b = hay.as_bytes();
let vb = var.as_bytes();
let mut i = 0;
while let Some(p) = find_from(b, b"$", i) {
i = p + 1;
let mut j = i;
if b.get(j) == Some(&b'{') {
j += 1;
}
if !starts_with(b, j, vb) {
continue;
}
let after = b.get(j + vb.len());
if !after.is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_') {
return true;
}
}
false
}
fn classify_sink(code: &str, n: &Needle<'_>) -> SinkClass {
command_parts(code)
.into_iter()
.filter_map(|part| classify_part(part, n))
.max()
.unwrap_or(SinkClass::Unknown)
}
fn classify_part(part: &str, n: &Needle<'_>) -> Option<SinkClass> {
let segs = pipeline_segments(part);
let last = segs.len().saturating_sub(1);
let mut carries = false;
let mut class = None;
for (i, seg) in segs.iter().enumerate() {
carries = carries || n.found_in(seg);
if !carries {
continue;
}
if is_reproducible_segment(seg, n) {
return Some(SinkClass::Reproducible);
}
class = Some(if i == last && is_benign_segment(seg, n) {
SinkClass::Benign
} else {
SinkClass::Unknown
});
}
class
}
fn command_parts(code: &str) -> Vec<&str> {
let m = Scanner::scan(code);
let b = code.as_bytes();
let mut out = Vec::new();
let mut start = 0;
for i in 0..b.len() {
if b[i] == b';' && !m.is_literal(i) && m.depth_at(i) == 1 {
out.push(&code[start..i]);
start = i + 1;
}
}
out.push(&code[start..]);
out
}
fn is_reproducible_segment(seg: &str, n: &Needle<'_>) -> bool {
reproducible_by_command(seg, n) || reproducible_by_redirect(seg, n)
}
fn reproducible_by_command(seg: &str, n: &Needle<'_>) -> bool {
let Some(cw) = command_word(seg) else {
return false;
};
if HASH_CMDS.contains(&cw.as_str()) {
return true;
}
if cw == "tee" {
return !has_append_flag(seg);
}
if ARTIFACT_CMDS.contains(&cw.as_str()) {
return n.found_in(seg);
}
is_build_command(seg, &cw) && n.found_in(seg)
}
fn is_build_command(seg: &str, cw: &str) -> bool {
BUILD_CMDS.contains(&cw)
&& split_words(seg)
.iter()
.any(|w| BUILD_SUBCMDS.contains(&unquote(w)))
}
fn reproducible_by_redirect(seg: &str, n: &Needle<'_>) -> bool {
match redirect_of(seg) {
Some(Redirect::Truncate(t)) => !is_sinkless(t),
Some(Redirect::Append(t)) => n.found_in(t),
_ => false,
}
}
fn is_log_target(target: &str) -> bool {
let t = unquote(target).to_ascii_lowercase();
t.starts_with("/dev/")
|| ["log", "journal", "history", "audit", "trace"]
.iter()
.any(|marker| t.contains(marker))
}
fn is_benign_segment(seg: &str, n: &Needle<'_>) -> bool {
if is_test_context(seg, n) {
return true;
}
if matches!(redirect_of(seg), Some(Redirect::Append(t)) if !n.found_in(t) && is_log_target(t)) {
return true;
}
match command_word(seg) {
Some(cw) => benign_command(&cw, seg, n),
None => false,
}
}
fn benign_command(cw: &str, seg: &str, n: &Needle<'_>) -> bool {
if LOG_CMDS.contains(&cw) || NEUTRAL_CMDS.contains(&cw) {
return true;
}
if cw == "tee" {
return has_append_flag(seg) && seg.split_whitespace().any(is_log_target);
}
PRINT_CMDS.contains(&cw) && redirect_is_benign(redirect_of(seg), n)
}
fn redirect_is_benign(r: Option<Redirect<'_>>, n: &Needle<'_>) -> bool {
match r {
None | Some(Redirect::Fd) => true,
Some(Redirect::Append(t)) => !n.found_in(t) && is_log_target(t),
Some(Redirect::Truncate(t)) => is_sinkless(t),
}
}
fn is_test_context(seg: &str, n: &Needle<'_>) -> bool {
if let Some(cond) = condition_part(seg) {
return n.found_in(cond);
}
let t = seg.trim_start();
t.starts_with("[ ") || t.starts_with("[[ ") || t.starts_with("((") || t.starts_with("test ")
}
fn condition_part(seg: &str) -> Option<&str> {
let t = seg.trim_start();
let kw = COND_KEYWORDS.iter().find(|k| t.starts_with(**k))?;
let rest = &t[kw.len()..];
let end = rest.find(';').unwrap_or(rest.len());
Some(&rest[..end])
}
fn is_build_id_name(name: &str) -> bool {
let norm: String = name
.chars()
.filter(|c| *c != '_' && *c != '-')
.flat_map(char::to_uppercase)
.collect();
BUILD_ID_NAMES.contains(&norm.as_str())
}
fn adopts_source_date_epoch(line: &str) -> bool {
line.contains("SOURCE_DATE_EPOCH")
}
fn marker_in_comment(line: &str, mask: &LineMask) -> bool {
let Some(c) = mask.comment else {
return false;
};
let tail = line[c..].to_lowercase();
MARKERS.iter().any(|m| tail.contains(m))
}
fn is_timestamp_for_tracking(line: &str) -> bool {
let line_trimmed = line.trim();
if line_trimmed.starts_with("if ")
|| line_trimmed.starts_with("elif ")
|| line_trimmed.starts_with("while ")
|| line_trimmed.contains("[ $(date")
|| line_trimmed.contains("[[ $(date")
{
return false;
}
line_trimmed.contains('=') && !line_trimmed.starts_with('[')
}
fn is_variable_assignment(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty()
&& !trimmed.starts_with('#')
&& trimmed.contains('=')
&& !trimmed.starts_with('[')
}
fn assignment_target(code: &str) -> Option<(String, usize)> {
let mut off = code.len() - code.trim_start().len();
while let Some(word) = split_words(&code[off..]).first().copied() {
if !DECLARATORS.contains(&word) {
break;
}
off += word.len();
let rest = &code[off..];
off += rest.len() - rest.trim_start().len();
}
let (name, eq) = split_name_eq(&code[off..])?;
let rhs = off + eq;
if !is_pure_assignment(&code[rhs..]) {
return None;
}
Some((name.to_string(), rhs))
}
fn is_pure_assignment(rhs: &str) -> bool {
let words = split_words(rhs);
match words.get(1) {
None => true,
Some(w) => w.starts_with('#'),
}
}
fn find_date(line: &str, mask: &LineMask) -> Option<(usize, &'static str, usize)> {
let b = line.as_bytes();
for (pat, len) in DATE_PATTERNS {
let mut from = 0;
while let Some(col) = find_from(b, pat.as_bytes(), from) {
if !mask.is_literal(col) {
return Some((col, pat, len));
}
from = col + 1;
}
}
None
}
#[derive(Default)]
struct FlowState {
uses: Vec<TimestampUse>,
tainted: Vec<(String, usize)>,
marker_ctx: bool,
}
impl FlowState {
fn taint(&mut self, name: &str, idx: usize) {
self.untaint(name);
self.tainted.push((name.to_string(), idx));
}
fn untaint(&mut self, name: &str) {
self.tainted.retain(|(v, _)| v != name);
}
fn referenced(&self, code: &str) -> Vec<(String, usize)> {
self.tainted
.iter()
.filter(|(v, _)| references(code, v))
.cloned()
.collect()
}
}
fn scan_line(st: &mut FlowState, ln: usize, line: &str) {
let mask = Scanner::scan(line);
if marker_in_comment(line, &mask) {
st.marker_ctx = true;
return;
}
update_marker_ctx(st, line);
let code = mask.code_of(line);
if adopts_source_date_epoch(line) {
clear_sde_target(st, code);
return;
}
match find_date(line, &mask) {
Some(hit) => handle_source(st, ln, code, hit),
None => handle_flow(st, ln, code),
}
}
fn update_marker_ctx(st: &mut FlowState, line: &str) {
let t = line.trim();
if !t.is_empty() && !t.starts_with('#') && !is_variable_assignment(line) {
st.marker_ctx = false;
}
}
fn clear_sde_target(st: &mut FlowState, code: &str) {
if let Some((name, _)) = assignment_target(code) {
st.untaint(&name);
}
}
fn handle_source(st: &mut FlowState, ln: usize, code: &str, hit: (usize, &'static str, usize)) {
if st.marker_ctx && is_timestamp_for_tracking(code) {
return;
}
let (col0, pat, len) = hit;
let var = assignment_target(code).map(|(n, _)| n);
let idx = st.uses.len();
st.uses.push(TimestampUse {
line: ln,
col: col0 + 1,
len,
var: var.clone(),
class: SinkClass::Benign,
sink_line: None,
sink_text: None,
saw_use: false,
});
match var {
Some(name) => start_taint(st, idx, &name, ln, code),
None => record(st, idx, classify_sink(code, &Needle::Text(pat)), ln, code),
}
}
fn start_taint(st: &mut FlowState, idx: usize, name: &str, ln: usize, code: &str) {
st.taint(name, idx);
if is_build_id_name(name) {
record(st, idx, SinkClass::Reproducible, ln, code);
}
}
fn handle_flow(st: &mut FlowState, ln: usize, code: &str) {
match assignment_target(code) {
Some((name, _)) => handle_propagation(st, ln, code, &name),
None => handle_uses(st, ln, code),
}
}
fn handle_propagation(st: &mut FlowState, ln: usize, code: &str, name: &str) {
match st.referenced(code).first() {
Some(&(_, idx)) => {
st.taint(name, idx);
if is_build_id_name(name) {
record(st, idx, SinkClass::Reproducible, ln, code);
}
}
None => st.untaint(name),
}
}
fn handle_uses(st: &mut FlowState, ln: usize, code: &str) {
for (name, idx) in st.referenced(code) {
let class = classify_sink(code, &Needle::Var(&name));
record(st, idx, class, ln, code);
}
}
fn record(st: &mut FlowState, idx: usize, class: SinkClass, ln: usize, code: &str) {
let Some(u) = st.uses.get_mut(idx) else {
return;
};
u.saw_use = true;
if class > u.class {
u.class = class;
}
if class == SinkClass::Reproducible && u.sink_line.is_none() {
u.sink_line = Some(ln);
u.sink_text = Some(code.trim().to_string());
}
}
fn finalize(mut uses: Vec<TimestampUse>) -> Vec<TimestampUse> {
for u in &mut uses {
if !u.saw_use {
u.class = SinkClass::Unknown;
}
}
uses
}
#[cfg(test)]
#[path = "timestamp_flow_tests.rs"]
mod timestamp_flow_tests;