Skip to main content

skillx/scanner/
mod.rs

1pub mod binary_analyzer;
2pub mod compiled_rules;
3pub mod markdown_analyzer;
4pub mod report;
5pub mod resource_analyzer;
6pub mod rules;
7pub mod script_analyzer;
8
9use serde::{Deserialize, Serialize};
10use std::fmt;
11use std::path::Path;
12
13use crate::error::Result;
14
15/// Risk level for scan findings.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum RiskLevel {
19    Pass,
20    Info,
21    Warn,
22    Danger,
23    Block,
24}
25
26impl fmt::Display for RiskLevel {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            RiskLevel::Pass => write!(f, "PASS"),
30            RiskLevel::Info => write!(f, "INFO"),
31            RiskLevel::Warn => write!(f, "WARN"),
32            RiskLevel::Danger => write!(f, "DANGER"),
33            RiskLevel::Block => write!(f, "BLOCK"),
34        }
35    }
36}
37
38impl std::str::FromStr for RiskLevel {
39    type Err = String;
40
41    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
42        match s.to_lowercase().as_str() {
43            "pass" => Ok(RiskLevel::Pass),
44            "info" => Ok(RiskLevel::Info),
45            "warn" => Ok(RiskLevel::Warn),
46            "danger" => Ok(RiskLevel::Danger),
47            "block" => Ok(RiskLevel::Block),
48            _ => Err(format!("invalid risk level: '{s}'")),
49        }
50    }
51}
52
53/// A single finding from the scan.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Finding {
56    pub rule_id: String,
57    pub level: RiskLevel,
58    pub message: String,
59    pub file: String,
60    pub line: Option<usize>,
61    pub context: Option<String>,
62}
63
64/// Scan report containing all findings.
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct ScanReport {
67    pub findings: Vec<Finding>,
68}
69
70impl ScanReport {
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// Overall risk level (max of all findings).
76    pub fn overall_level(&self) -> RiskLevel {
77        self.findings
78            .iter()
79            .map(|f| f.level)
80            .max()
81            .unwrap_or(RiskLevel::Pass)
82    }
83
84    pub fn add(&mut self, finding: Finding) {
85        self.findings.push(finding);
86    }
87
88    pub fn merge(&mut self, other: ScanReport) {
89        self.findings.extend(other.findings);
90    }
91}
92
93/// The main scan engine that orchestrates all analyzers.
94pub struct ScanEngine;
95
96impl ScanEngine {
97    /// Scan a skill directory and return a report.
98    pub fn scan(skill_dir: &Path) -> Result<ScanReport> {
99        let mut report = ScanReport::new();
100
101        // Scan SKILL.md
102        let skill_md = skill_dir.join("SKILL.md");
103        if skill_md.exists() {
104            let content = std::fs::read_to_string(&skill_md).map_err(|e| {
105                crate::error::SkillxError::Scan(format!("failed to read SKILL.md: {e}"))
106            })?;
107            let md_report = markdown_analyzer::MarkdownAnalyzer::analyze(&content, "SKILL.md");
108            report.merge(md_report);
109        }
110
111        // Scan scripts
112        let scripts_dir = skill_dir.join("scripts");
113        if scripts_dir.is_dir() {
114            Self::scan_directory(&scripts_dir, skill_dir, &mut report, true)?;
115        }
116
117        // Scan references
118        let refs_dir = skill_dir.join("references");
119        if refs_dir.is_dir() {
120            Self::scan_directory(&refs_dir, skill_dir, &mut report, false)?;
121        }
122
123        // Also scan any other files in root (not SKILL.md which is already scanned)
124        Self::scan_root_files(skill_dir, &mut report)?;
125
126        Ok(report)
127    }
128
129    fn scan_directory(
130        dir: &Path,
131        skill_dir: &Path,
132        report: &mut ScanReport,
133        is_scripts: bool,
134    ) -> Result<()> {
135        let entries = std::fs::read_dir(dir)
136            .map_err(|e| crate::error::SkillxError::Scan(format!("failed to read dir: {e}")))?;
137
138        for entry in entries {
139            let entry = entry
140                .map_err(|e| crate::error::SkillxError::Scan(format!("dir entry error: {e}")))?;
141            let path = entry.path();
142            let rel_path = path
143                .strip_prefix(skill_dir)
144                .unwrap_or(&path)
145                .to_string_lossy()
146                .to_string();
147
148            if path.is_dir() {
149                Self::scan_directory(&path, skill_dir, report, is_scripts)?;
150                continue;
151            }
152
153            if is_scripts {
154                // Script analysis (binary detection + content analysis)
155                let script_report = script_analyzer::ScriptAnalyzer::analyze(&path, &rel_path)?;
156                report.merge(script_report);
157            } else {
158                // Resource analysis
159                let res_report = resource_analyzer::ResourceAnalyzer::analyze(&path, &rel_path)?;
160                report.merge(res_report);
161            }
162        }
163
164        Ok(())
165    }
166
167    fn scan_root_files(skill_dir: &Path, report: &mut ScanReport) -> Result<()> {
168        let entries = std::fs::read_dir(skill_dir)
169            .map_err(|e| crate::error::SkillxError::Scan(format!("failed to read dir: {e}")))?;
170
171        for entry in entries {
172            let entry = entry
173                .map_err(|e| crate::error::SkillxError::Scan(format!("dir entry error: {e}")))?;
174            let path = entry.path();
175            if path.is_dir() {
176                continue;
177            }
178            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
179            if name == "SKILL.md" {
180                continue; // Already scanned
181            }
182
183            let rel_path = path
184                .strip_prefix(skill_dir)
185                .unwrap_or(&path)
186                .to_string_lossy()
187                .to_string();
188
189            // Check if it's a script-like file
190            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
191            if matches!(
192                ext,
193                "py" | "sh" | "bash" | "js" | "ts" | "rb" | "pl" | "ps1"
194            ) {
195                let script_report = script_analyzer::ScriptAnalyzer::analyze(&path, &rel_path)?;
196                report.merge(script_report);
197            }
198        }
199
200        Ok(())
201    }
202}