use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub project: ProjectConfig,
pub scan: ScanConfig,
pub rules: RulesConfig,
pub languages: LanguagesConfig,
pub security: SecurityConfig,
pub klr: KlrConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LanguagesConfig {
#[serde(flatten)]
pub enabled: BTreeMap<String, bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SecurityConfig {
pub level: String,
}
impl Default for SecurityConfig {
fn default() -> Self {
SecurityConfig {
level: "standard".to_string(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct KlrConfig {
pub directory: Option<String>,
pub base_url: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ProjectConfig {
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ScanConfig {
pub ignore: Vec<String>,
pub large_file_threshold: usize,
}
impl Default for ScanConfig {
fn default() -> Self {
ScanConfig {
ignore: Vec::new(),
large_file_threshold: 1000,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RulesConfig {
pub secret_detection: bool,
pub dangerous_commands: bool,
pub weak_crypto: bool,
pub large_files: bool,
pub todo_tracker: bool,
pub duplicate_code: bool,
}
impl Default for RulesConfig {
fn default() -> Self {
RulesConfig {
secret_detection: true,
dangerous_commands: true,
weak_crypto: true,
large_files: true,
todo_tracker: true,
duplicate_code: true,
}
}
}
pub const CONFIG_FILE_NAME: &str = ".killer.toml";
impl Config {
pub fn load(root: &Path) -> Result<Config> {
let path = root.join(CONFIG_FILE_NAME);
if !path.exists() {
return Ok(Config::default());
}
let text = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let config: Config =
toml::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))?;
Ok(config)
}
pub fn default_file_contents() -> &'static str {
DEFAULT_CONFIG_TEMPLATE
}
pub fn starter_klr() -> &'static str {
STARTER_KLR_TEMPLATE
}
}
pub const SCAFFOLD_DIR: &str = "security-tests";
pub const SCAFFOLD_FILE: &str = "getting-started.klr";
const STARTER_KLR_TEMPLATE: &str = r#"# Starter Killer security tests.
#
# Run the dynamic attacks against a live server: killer test
# Run only the static code rules (no server): killer ci
#
# A .klr file describes how a SECURE system should behave: a test PASSES when
# the target defends itself and FAILS when an attack succeeds.
suite "Getting Started" {
# A dynamic attack — needs a server running at the configured base_url.
attack sql_login_bypass {
request POST "/login"
send {
username = "' OR 1=1 --"
password = "x"
}
expect {
status != 200
response does_not_contain "token"
}
severity critical
message: "SQL injection authentication bypass"
}
}
# A static code rule — runs against your source, no server required.
rule "Possible hard-coded credential"
when function contains "password ="
without sanitization
severity high
report: "Move secrets to environment variables or a secret manager"
"#;
const DEFAULT_CONFIG_TEMPLATE: &str = r#"# Killer configuration
# https://github.com/martin-k-m/killer
[project]
# Display name for reports. Defaults to the directory name.
# name = "my-app"
[scan]
# Extra paths to ignore, on top of the built-in defaults
# (.git, node_modules, target, dist, build, and other common folders).
ignore = [
"tests",
"vendor",
]
# Files longer than this many lines are flagged as "large".
large_file_threshold = 1000
[rules]
# Toggle individual rules on or off.
secret_detection = true
dangerous_commands = true
weak_crypto = true
large_files = true
todo_tracker = true
duplicate_code = true
[languages]
# Languages to focus on (informational in this phase).
rust = true
typescript = true
python = true
[security]
# Security posture: relaxed | standard | strict.
level = "standard"
[klr]
# Where `killer test` looks for .klr files when no path is given.
directory = "./security-tests"
# Base URL that relative attack targets (e.g. "/api/login") resolve against.
base_url = "http://127.0.0.1:8080"
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_enable_all_rules() {
let c = Config::default();
assert!(c.rules.secret_detection);
assert!(c.rules.duplicate_code);
assert_eq!(c.scan.large_file_threshold, 1000);
}
#[test]
fn parses_partial_config() {
let text = r#"
[project]
name = "demo"
[rules]
duplicate_code = false
"#;
let c: Config = toml::from_str(text).unwrap();
assert_eq!(c.project.name.as_deref(), Some("demo"));
assert!(!c.rules.duplicate_code);
assert!(c.rules.secret_detection);
assert_eq!(c.scan.large_file_threshold, 1000);
}
#[test]
fn default_template_parses() {
let c: Config = toml::from_str(Config::default_file_contents()).unwrap();
assert!(c.rules.secret_detection);
}
#[test]
fn starter_klr_parses() {
let program = crate::klr::parse(Config::starter_klr()).unwrap();
assert_eq!(program.all_attacks().len(), 1);
assert_eq!(program.rules.len(), 1);
}
}