1mod hotspots;
2mod roots;
3
4use std::collections::{BTreeMap, BTreeSet};
5use std::path::Path;
6
7use crate::config::ScanPathFilterSummary;
8use crate::error::ShieldError;
9use crate::ir::Language;
10use crate::rules::{AttackCategory, Finding, Severity};
11use crate::ScanReport;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum CoverageConfidence {
15 High,
16 Medium,
17 Low,
18}
19
20impl CoverageConfidence {
21 fn label(self) -> &'static str {
22 match self {
23 Self::High => "High",
24 Self::Medium => "Medium",
25 Self::Low => "Low",
26 }
27 }
28
29 fn reason(self) -> &'static str {
30 match self {
31 Self::High => "known adapter(s) matched and source files were parsed",
32 Self::Medium => "known adapter(s) matched, but code parsing coverage is limited",
33 Self::Low => "no supported agent extension surface was detected",
34 }
35 }
36}
37
38#[derive(Debug, Clone)]
39pub struct ExplainOptions {
40 pub ignore_tests: bool,
41}
42
43#[derive(Debug, Clone)]
44pub struct CiInstallOptions<'a> {
45 pub fail_on: &'a str,
46 pub ignore_tests: bool,
47 pub scan_path: &'a str,
48 pub baseline_path: Option<&'a str>,
49 pub upload_sarif: bool,
50}
51
52pub fn quickstart_config_toml(fail_on: Severity, ignore_tests: bool) -> String {
53 format!(
54 r#"# AgentShield configuration
55# Generated by `agentshield quickstart`.
56
57[policy]
58fail_on = "{fail_on}"
59
60[scan]
61ignore_tests = {ignore_tests}
62
63[runtime.proxy]
64fail_on = "block"
65"#
66 )
67}
68
69pub fn github_actions_workflow(options: &CiInstallOptions<'_>) -> String {
70 let baseline_input = options
71 .baseline_path
72 .map(|path| format!(" baseline: \"{path}\"\n"))
73 .unwrap_or_default();
74
75 format!(
76 r#"name: AgentShield
77
78on:
79 pull_request:
80 push:
81 branches: [main]
82
83permissions:
84 contents: read
85 security-events: write
86
87jobs:
88 agentshield:
89 runs-on: ubuntu-latest
90 steps:
91 - uses: actions/checkout@v4
92 - uses: aiconnai/agentshield@main
93 with:
94 path: "{scan_path}"
95 fail-on: "{fail_on}"
96 ignore-tests: {ignore_tests}
97{baseline_input} upload-sarif: {upload_sarif}
98"#,
99 scan_path = options.scan_path,
100 fail_on = options.fail_on,
101 ignore_tests = options.ignore_tests,
102 baseline_input = baseline_input,
103 upload_sarif = options.upload_sarif,
104 )
105}
106
107pub fn render_explain(report: &ScanReport, options: &ExplainOptions) -> String {
108 let coverage = coverage_summary(report);
109 let confidence = confidence_for_report(report);
110 let runtime_findings: Vec<&Finding> = report
111 .findings
112 .iter()
113 .filter(|finding| finding.attack_category != AttackCategory::SupplyChain)
114 .collect();
115 let supply_chain_findings: Vec<&Finding> = report
116 .findings
117 .iter()
118 .filter(|finding| finding.attack_category == AttackCategory::SupplyChain)
119 .collect();
120
121 let mut output = String::new();
122 output.push_str("AgentShield explain\n");
123 output.push_str("===================\n\n");
124 output.push_str(&format!(
125 "Gate: {}\n",
126 if report.verdict.pass { "PASS" } else { "FAIL" }
127 ));
128 output.push_str(&format!("Reason: {}\n", gate_reason(report)));
129 output.push_str(&format!(
130 "Security confidence: {} - {}\n\n",
131 confidence.label(),
132 confidence.reason()
133 ));
134
135 output.push_str("Coverage:\n");
136 output.push_str(&format!(
137 "- Adapters: {}\n",
138 display_list(&coverage.frameworks, "none")
139 ));
140 output.push_str(&roots::render(report));
141 output.push_str(&format!("- Targets: {}\n", coverage.targets));
142 output.push_str(&format!(
143 "- Source files parsed: {} ({})\n",
144 coverage.source_files,
145 display_list(&coverage.languages, "no code parser coverage")
146 ));
147 output.push_str(&format!("- Tools discovered: {}\n", coverage.tools));
148 output.push_str(&format!(
149 "- Dependencies checked: {}\n",
150 coverage.dependencies
151 ));
152 output.push_str(&format!("- Lockfiles detected: {}\n", coverage.lockfiles));
153 output.push_str(&format!(
154 "- Test file exclusion: {}\n",
155 if options.ignore_tests {
156 "enabled"
157 } else {
158 "disabled"
159 }
160 ));
161 output.push_str(&format!(
162 "- Path filters: {}\n\n",
163 format_path_filters(&report.path_filter_summary)
164 ));
165
166 output.push_str("Findings:\n");
167 output.push_str(&format!(
168 "- Runtime-risk findings: {}\n",
169 finding_group_summary(&runtime_findings)
170 ));
171 output.push_str(&format!(
172 "- Supply-chain hygiene: {}\n",
173 finding_group_summary(&supply_chain_findings)
174 ));
175 output.push_str(&format!(
176 "- Severity counts: {}\n\n",
177 severity_counts(&report.findings)
178 ));
179
180 output.push_str(&hotspots::render(report));
181
182 output.push_str("Next actions:\n");
183 for action in next_actions(report) {
184 output.push_str(&format!("- {action}\n"));
185 }
186
187 output.push_str("\nWhat this does not prove:\n");
188 output.push_str("- This scan does not execute tools or prove absence of vulnerabilities.\n");
189 output.push_str(
190 "- It checks known risky patterns in supported agent surfaces and dependency metadata.\n",
191 );
192
193 output
194}
195
196pub fn render_no_adapter_explain(
197 path: &Path,
198 ignore_tests: bool,
199 path_filters: &ScanPathFilterSummary,
200) -> String {
201 let mut output = String::new();
202 output.push_str("AgentShield explain\n");
203 output.push_str("===================\n\n");
204 output.push_str("Gate: INCONCLUSIVE\n");
205 output.push_str("Reason: no supported agent extension surface was detected.\n");
206 output.push_str(&format!(
207 "Security confidence: {} - {}\n\n",
208 CoverageConfidence::Low.label(),
209 CoverageConfidence::Low.reason()
210 ));
211 output.push_str("Coverage:\n");
212 output.push_str("- Adapters: none\n");
213 output.push_str(&format!("- Target: {}\n", path.display()));
214 output.push_str(&format!(
215 "- Test file exclusion: {}\n",
216 if ignore_tests { "enabled" } else { "disabled" }
217 ));
218 output.push_str(&format!(
219 "- Path filters: {}\n\n",
220 format_path_filters(path_filters)
221 ));
222 output.push_str("Next actions:\n");
223 output.push_str("- Confirm this repository contains an MCP server, OpenClaw skill, Hermes agent, CrewAI/LangChain tool, GPT Action, or Cursor Rules surface.\n");
224 output.push_str("- If it does, add a framework manifest or dependency metadata that AgentShield can detect.\n");
225 output.push_str("- Run `agentshield doctor .` to inspect adapter detection.\n\n");
226 output.push_str("What this does not prove:\n");
227 output.push_str("- This result does not mean the project is safe; it means AgentShield did not find a supported surface to scan.\n");
228 output
229}
230
231pub fn is_no_adapter(error: &ShieldError) -> bool {
232 matches!(error, ShieldError::NoAdapter(_))
233}
234
235#[derive(Debug, Default)]
236struct CoverageSummary {
237 frameworks: BTreeSet<String>,
238 languages: BTreeSet<String>,
239 targets: usize,
240 source_files: usize,
241 tools: usize,
242 dependencies: usize,
243 lockfiles: usize,
244}
245
246fn coverage_summary(report: &ScanReport) -> CoverageSummary {
247 let mut summary = CoverageSummary {
248 targets: report.targets.len(),
249 ..CoverageSummary::default()
250 };
251
252 for target in &report.targets {
253 summary.frameworks.insert(target.framework.to_string());
254 summary.source_files += target.source_files.len();
255 summary.tools += target.tools.len();
256 summary.dependencies += target.dependencies.dependencies.len();
257 if target.dependencies.lockfile.is_some() {
258 summary.lockfiles += 1;
259 }
260 for source in &target.source_files {
261 summary
262 .languages
263 .insert(display_language(source.language).into());
264 }
265 }
266
267 summary
268}
269
270fn confidence_for_report(report: &ScanReport) -> CoverageConfidence {
271 if report.targets.is_empty() {
272 CoverageConfidence::Low
273 } else if report
274 .targets
275 .iter()
276 .any(|target| !target.source_files.is_empty())
277 {
278 CoverageConfidence::High
279 } else {
280 CoverageConfidence::Medium
281 }
282}
283
284fn gate_reason(report: &ScanReport) -> String {
285 if report.verdict.pass {
286 match report.verdict.highest_severity {
287 Some(severity) => format!(
288 "no findings at or above the {} threshold; highest finding is {}",
289 report.verdict.fail_threshold, severity
290 ),
291 None => format!(
292 "no findings remained after policy, suppressions, and baseline filtering; threshold is {}",
293 report.verdict.fail_threshold
294 ),
295 }
296 } else {
297 format!(
298 "at least one finding meets or exceeds the {} threshold; highest finding is {}",
299 report.verdict.fail_threshold,
300 report
301 .verdict
302 .highest_severity
303 .map(|severity| severity.to_string())
304 .unwrap_or_else(|| "unknown".into())
305 )
306 }
307}
308
309fn finding_group_summary(findings: &[&Finding]) -> String {
310 if findings.is_empty() {
311 "none".into()
312 } else {
313 format!("{} ({})", findings.len(), severity_counts_refs(findings))
314 }
315}
316
317fn severity_counts(findings: &[Finding]) -> String {
318 let refs: Vec<&Finding> = findings.iter().collect();
319 severity_counts_refs(&refs)
320}
321
322fn severity_counts_refs(findings: &[&Finding]) -> String {
323 if findings.is_empty() {
324 return "none".into();
325 }
326
327 let mut counts: BTreeMap<Severity, usize> = BTreeMap::new();
328 for finding in findings {
329 *counts.entry(finding.severity).or_default() += 1;
330 }
331
332 [
333 Severity::Critical,
334 Severity::High,
335 Severity::Medium,
336 Severity::Low,
337 Severity::Info,
338 ]
339 .into_iter()
340 .filter_map(|severity| {
341 counts
342 .get(&severity)
343 .map(|count| format!("{count} {severity}"))
344 })
345 .collect::<Vec<_>>()
346 .join(", ")
347}
348
349fn next_actions(report: &ScanReport) -> Vec<String> {
350 if report.findings.is_empty() {
351 return vec![
352 "Add a CI gate with `agentshield ci install`.".into(),
353 "Keep `agentshield scan . --ignore-tests --fail-on high` in the pre-merge path.".into(),
354 ];
355 }
356
357 let mut actions = Vec::new();
358 if !report.verdict.pass {
359 actions.push(format!(
360 "Fix findings at or above `{}` first; they are blocking the security gate.",
361 report.verdict.fail_threshold
362 ));
363 }
364
365 let mut seen_rules = BTreeSet::new();
366 for finding in &report.findings {
367 if !seen_rules.insert(finding.rule_id.clone()) {
368 continue;
369 }
370 if let Some(command) = exact_command_for_finding(finding) {
371 actions.push(command);
372 } else if let Some(remediation) = &finding.remediation {
373 actions.push(remediation.clone());
374 } else {
375 actions.push(format!("Review `{}`: {}", finding.rule_id, finding.message));
376 }
377
378 if actions.len() >= 5 {
379 break;
380 }
381 }
382
383 actions.push("Run `agentshield scan . --explain` again after changes.".into());
384 actions
385}
386
387fn exact_command_for_finding(finding: &Finding) -> Option<String> {
388 let file_name = finding
389 .location
390 .as_ref()
391 .and_then(|location| location.file.file_name())
392 .map(|name| name.to_string_lossy().to_string())
393 .unwrap_or_default();
394
395 match finding.rule_id.as_str() {
396 "SHIELD-009" => {
397 let package = package_name_from_message(&finding.message)?;
398 if file_name == "package.json" {
399 Some(format!(
400 "Pin `{package}` with `npm install {package}@<exact-version> --save-exact`."
401 ))
402 } else if file_name == "requirements.txt" {
403 Some(format!(
404 "Pin `{package}` by changing the requirement to `{package}==<exact-version>`."
405 ))
406 } else if file_name == "pyproject.toml" {
407 Some(format!(
408 "Pin `{package}` to an exact version in `pyproject.toml`, then regenerate the lockfile."
409 ))
410 } else {
411 None
412 }
413 }
414 "SHIELD-012" => {
415 if file_name == "package.json" {
416 Some("Generate an npm lockfile with `npm install`.".into())
417 } else if file_name == "requirements.txt" {
418 Some(
419 "Generate a reproducible Python lockfile with `uv lock` or `poetry lock`."
420 .into(),
421 )
422 } else {
423 None
424 }
425 }
426 _ => None,
427 }
428}
429
430fn package_name_from_message(message: &str) -> Option<&str> {
431 let start = message.find('\'')? + 1;
432 let rest = &message[start..];
433 let end = rest.find('\'')?;
434 Some(&rest[..end])
435}
436
437fn display_list(values: &BTreeSet<String>, empty: &str) -> String {
438 if values.is_empty() {
439 empty.into()
440 } else {
441 values.iter().cloned().collect::<Vec<_>>().join(", ")
442 }
443}
444
445fn format_path_filters(summary: &ScanPathFilterSummary) -> String {
446 if summary.include.is_empty() && summary.exclude.is_empty() {
447 return "disabled".into();
448 }
449
450 let include = if summary.include.is_empty() {
451 "all".into()
452 } else {
453 summary.include.join(", ")
454 };
455 let exclude = if summary.exclude.is_empty() {
456 "none".into()
457 } else {
458 summary.exclude.join(", ")
459 };
460
461 format!("include {include}; exclude {exclude}")
462}
463
464fn display_language(language: Language) -> &'static str {
465 match language {
466 Language::Python => "Python",
467 Language::TypeScript => "TypeScript",
468 Language::JavaScript => "JavaScript",
469 Language::Shell => "Shell",
470 Language::Json => "JSON",
471 Language::Toml => "TOML",
472 Language::Yaml => "YAML",
473 Language::Markdown => "Markdown",
474 Language::Unknown => "Unknown",
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use std::path::PathBuf;
481
482 use crate::ir::{Framework, ScanTarget, SourceFile};
483 use crate::rules::policy::PolicyVerdict;
484 use crate::rules::{AttackCategory, Confidence, Evidence, Finding};
485
486 use super::*;
487
488 fn finding(rule_id: &str, severity: Severity, category: AttackCategory) -> Finding {
489 Finding {
490 rule_id: rule_id.into(),
491 rule_name: "Rule".into(),
492 severity,
493 confidence: Confidence::High,
494 attack_category: category,
495 message: "Dependency '@modelcontextprotocol/sdk' is not pinned: ^1.0.0".into(),
496 location: Some(crate::ir::SourceLocation {
497 file: PathBuf::from("package.json"),
498 line: 1,
499 column: 0,
500 end_line: None,
501 end_column: None,
502 }),
503 evidence: vec![Evidence {
504 description: "evidence".into(),
505 location: None,
506 snippet: None,
507 }],
508 taint_path: None,
509 remediation: Some("fix it".into()),
510 cwe_id: None,
511 }
512 }
513
514 fn report(findings: Vec<Finding>) -> ScanReport {
515 ScanReport {
516 target_name: "fixture".into(),
517 findings,
518 verdict: PolicyVerdict {
519 pass: true,
520 total_findings: 2,
521 effective_findings: 2,
522 highest_severity: Some(Severity::Medium),
523 fail_threshold: Severity::High,
524 },
525 scan_root: PathBuf::from("."),
526 targets: vec![ScanTarget {
527 name: "fixture".into(),
528 framework: Framework::Mcp,
529 root_path: PathBuf::from("."),
530 tools: vec![],
531 execution: Default::default(),
532 data: Default::default(),
533 dependencies: Default::default(),
534 provenance: Default::default(),
535 source_files: vec![SourceFile {
536 path: PathBuf::from("server.py"),
537 language: Language::Python,
538 content: String::new(),
539 size_bytes: 0,
540 content_hash: String::new(),
541 }],
542 }],
543 path_filter_summary: ScanPathFilterSummary::default(),
544 }
545 }
546
547 #[test]
548 fn explain_separates_runtime_and_supply_chain_findings() {
549 let output = render_explain(
550 &report(vec![finding(
551 "SHIELD-009",
552 Severity::Medium,
553 AttackCategory::SupplyChain,
554 )]),
555 &ExplainOptions { ignore_tests: true },
556 );
557
558 assert!(output.contains("Gate: PASS"));
559 assert!(output.contains("Runtime-risk findings: none"));
560 assert!(output.contains("Supply-chain hygiene: 1"));
561 assert!(output.contains("Security confidence: High"));
562 assert!(
563 output.contains("npm install @modelcontextprotocol/sdk@<exact-version> --save-exact")
564 );
565 }
566
567 #[test]
568 fn no_adapter_explain_is_inconclusive() {
569 let output =
570 render_no_adapter_explain(Path::new("."), true, &ScanPathFilterSummary::default());
571
572 assert!(output.contains("Gate: INCONCLUSIVE"));
573 assert!(output.contains("does not mean the project is safe"));
574 }
575
576 #[test]
577 fn ci_workflow_uses_expected_action_inputs() {
578 let workflow = github_actions_workflow(&CiInstallOptions {
579 fail_on: "high",
580 ignore_tests: true,
581 scan_path: ".",
582 baseline_path: None,
583 upload_sarif: true,
584 });
585
586 assert!(workflow.contains("uses: aiconnai/agentshield@main"));
587 assert!(workflow.contains("fail-on: \"high\""));
588 assert!(workflow.contains("ignore-tests: true"));
589 assert!(workflow.contains("upload-sarif: true"));
590 assert!(!workflow.contains("baseline:"));
591 }
592
593 #[test]
594 fn ci_workflow_can_use_baseline_file() {
595 let workflow = github_actions_workflow(&CiInstallOptions {
596 fail_on: "high",
597 ignore_tests: true,
598 scan_path: ".",
599 baseline_path: Some(".agentshield-baseline.json"),
600 upload_sarif: true,
601 });
602
603 assert!(workflow.contains("baseline: \".agentshield-baseline.json\""));
604 assert!(workflow.contains("upload-sarif: true"));
605 }
606
607 #[test]
608 fn quickstart_config_enables_project_defaults() {
609 let config = quickstart_config_toml(Severity::High, true);
610
611 assert!(config.contains("fail_on = \"high\""));
612 assert!(config.contains("ignore_tests = true"));
613 assert!(config.contains("[runtime.proxy]"));
614 }
615}