use std::collections::BTreeMap;
use std::ops::Range;
use serde::{Deserialize, Serialize};
use crate::policy::PolicyError;
mod backdoor;
mod ci;
mod files;
mod hosts;
mod keys;
mod lockfile;
mod manifest;
pub use hosts::new_hosts;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Level {
Off,
Review,
Deny,
}
#[derive(Debug)]
pub struct Rule {
pub id: &'static str,
pub level: Level,
pub summary: &'static str,
}
pub const PACKAGE_NEAR_MISS: &str = "diff-package-near-miss";
pub const INSTALL_SCRIPT: &str = "diff-install-script";
pub const PACKAGE_SOURCE: &str = "diff-package-source";
pub const LOCK_INTEGRITY: &str = "diff-lock-integrity";
pub const CI_PR_TARGET: &str = "diff-ci-pr-target";
pub const CI_PERMISSIONS: &str = "diff-ci-permissions";
pub const CI_UNPINNED: &str = "diff-ci-unpinned";
pub const CI_INJECTION: &str = "diff-ci-injection";
pub const CI_SECRET_OUT: &str = "diff-ci-secret-out";
pub const PIPE_TO_SHELL: &str = "diff-pipe-to-shell";
pub const DECODE_EXEC: &str = "diff-decode-exec";
pub const PYTHON_STARTUP: &str = "diff-python-startup";
pub const DOCKER_REMOTE_ADD: &str = "diff-docker-remote-add";
pub const ENCODED_BLOB: &str = "diff-encoded-blob";
pub const PRIVATE_KEY: &str = "diff-private-key";
pub const ENV_SECRET: &str = "diff-env-secret";
pub const NEW_HOST: &str = "diff-new-host";
pub const RULES: &[Rule] = &[
Rule {
id: PACKAGE_NEAR_MISS,
level: Level::Review,
summary: "a new dependency whose name is one typo or trick away from a popular package's",
},
Rule {
id: INSTALL_SCRIPT,
level: Level::Review,
summary: "an npm preinstall, install or postinstall script added or changed",
},
Rule {
id: PACKAGE_SOURCE,
level: Level::Review,
summary: "a dependency or locked package fetched from git, plain http or a host that is not its registry",
},
Rule {
id: LOCK_INTEGRITY,
level: Level::Deny,
summary: "a lockfile integrity hash or checksum changed or removed while the version stays",
},
Rule {
id: CI_PR_TARGET,
level: Level::Deny,
summary: "pull_request_target with a checkout of the pull request's code (review: the trigger alone)",
},
Rule {
id: CI_PERMISSIONS,
level: Level::Review,
summary: "permissions: write-all or a write scope added to a workflow",
},
Rule {
id: CI_UNPINNED,
level: Level::Review,
summary: "uses: an action or image at a tag or branch instead of a full commit SHA or digest",
},
Rule {
id: CI_INJECTION,
level: Level::Deny,
summary: "an attacker-written event field expanded inside a script (review: other event fields and inputs)",
},
Rule {
id: CI_SECRET_OUT,
level: Level::Deny,
summary: "a secret printed into the log (review: handed to curl or wget)",
},
Rule {
id: PIPE_TO_SHELL,
level: Level::Review,
summary: "a downloaded script piped into a shell or interpreter",
},
Rule {
id: DECODE_EXEC,
level: Level::Deny,
summary: "base64, hex or compressed text decoded and run as code (review: in tests and fixtures)",
},
Rule {
id: PYTHON_STARTUP,
level: Level::Deny,
summary: "a .pth file with an import line, which every Python start runs (review: in tests and fixtures)",
},
Rule {
id: DOCKER_REMOTE_ADD,
level: Level::Review,
summary: "a Dockerfile ADD of a URL without --checksum",
},
Rule {
id: ENCODED_BLOB,
level: Level::Review,
summary: "a base64 blob of 400 or more characters in source code",
},
Rule {
id: PRIVATE_KEY,
level: Level::Deny,
summary: "a private key (review: in a test fixture, or marked as fake)",
},
Rule {
id: ENV_SECRET,
level: Level::Review,
summary: "a real-looking secret value in a .env file",
},
Rule {
id: NEW_HOST,
level: Level::Review,
summary: "a URL host in code or configuration that the policy's network allow list does not name",
},
];
#[must_use]
pub fn rule(id: &str) -> Option<&'static Rule> {
RULES.iter().find(|r| r.id == id)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Block {
pub at: usize,
pub removed: Vec<String>,
pub added: Range<usize>,
}
#[derive(Debug, Clone, Copy)]
pub struct Change<'a> {
pub path: &'a str,
pub added: &'a [(usize, String)],
pub blocks: &'a [Block],
}
impl<'a> Change<'a> {
fn added_in(&self, block: &Block) -> &'a [(usize, String)] {
self.added.get(block.added.clone()).unwrap_or_default()
}
fn runs(&self) -> Vec<&'a [(usize, String)]> {
if self.blocks.is_empty() {
return vec![self.added];
}
self.blocks
.iter()
.map(|b| self.added_in(b))
.filter(|run| !run.is_empty())
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hit {
pub rule: &'static str,
pub level: Level,
pub line: Option<usize>,
pub reason: String,
}
impl Hit {
fn new(rule: &'static str, level: Level, line: usize, reason: String) -> Self {
Self {
rule,
level,
line: Some(line),
reason,
}
}
}
#[must_use]
pub fn scan(change: &Change<'_>) -> Vec<Hit> {
let file = files::classify(change.path);
let mut hits = Vec::new();
if let Some(manifest) = file.manifest {
manifest::scan(manifest, change, &mut hits);
}
if let Some(lock) = file.lock {
lockfile::scan(lock, change, &mut hits);
}
if file.is(files::Kind::Workflow) {
ci::scan(change, &mut hits);
}
if !file.is(files::Kind::Doc) && file.lock.is_none() {
backdoor::scan(&file, change, &mut hits);
}
keys::scan(&file, change, &mut hits);
hits.sort_by_key(|h| h.line);
hits
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Settings {
#[serde(default)]
pub levels: BTreeMap<String, Level>,
}
impl Settings {
#[must_use]
pub fn level(settings: Option<&Self>, hit: &Hit) -> Level {
settings
.and_then(|s| s.levels.get(hit.rule))
.copied()
.unwrap_or(hit.level)
}
pub fn turned_off(&self) -> impl Iterator<Item = &str> {
self.levels
.iter()
.filter(|(_, l)| **l == Level::Off)
.map(|(id, _)| id.as_str())
}
}
pub(crate) fn check(settings: Option<&Settings>) -> Result<(), PolicyError> {
let Some(unknown) = settings
.into_iter()
.flat_map(|s| s.levels.keys())
.find(|id| rule(id).is_none())
else {
return Ok(());
};
let known: Vec<&str> = RULES.iter().map(|r| r.id).collect();
Err(PolicyError::Rule {
rule: format!("check_diff.levels.{unknown}"),
problem: format!("not a check-diff content rule; one of {}", known.join(", ")),
})
}
fn shown(text: &str) -> String {
let clean: String = text.trim().chars().filter(|c| !c.is_control()).collect();
match clean.char_indices().nth(80) {
Some((cut, _)) => format!("{}…", &clean[..cut]),
None => clean,
}
}
#[cfg(test)]
mod tests;