1use std::collections::{BTreeMap, BTreeSet};
2use std::ffi::OsString;
3use std::fs;
4use std::path::Path;
5use std::process::Command;
6
7use serde::{Deserialize, Serialize};
8use serde_yaml_bw::{Mapping as YamlMapping, Sequence as YamlSequence, Value as YamlValue};
9
10use crate::cli::{SecurityArgs, SecurityFormat};
11
12const EXIT_SUCCESS: i32 = 0;
13const EXIT_POLICY_VIOLATION: i32 = 2;
14const EXIT_OPERATIONAL_ERROR: i32 = 3;
15const FOXGUARD_REPORT_PATH: &str = "target/greentic-security/foxguard-report.md";
16const FOXGUARD_CONFIG_PATH: &str = ".foxguard.yml";
17const DEFAULT_FOXGUARD_EXCLUDE_DIRS: &[&str] = &[
18 "tests",
19 "benches",
20 "docs",
21 "examples",
22 "fixtures",
23 "testdata",
24 "generated",
25];
26const DEFAULT_FOXGUARD_IGNORE_RULES: &[&str] = &[
27 "rs/no-command-injection",
28 "rs/no-path-traversal",
29 "rs/no-unwrap-in-lib",
30 "rs/unsafe-block",
31];
32
33pub fn run(args: SecurityArgs) -> i32 {
34 match run_inner(&ShellRunner, args) {
35 Ok(outcome) => {
36 eprintln!("{}", outcome.policy_notice);
37 println!("{}", outcome.output.trim_end());
38 outcome.exit_code
39 }
40 Err(err) => {
41 eprintln!("{err}");
42 EXIT_OPERATIONAL_ERROR
43 }
44 }
45}
46
47fn run_inner(runner: &dyn Runner, args: SecurityArgs) -> Result<RunOutcome, OperationalError> {
48 let options = QueryOptions::from_args(&args)?;
49 let repository = match args.repo.as_deref() {
50 Some(raw) => parse_owner_repo(raw)?,
51 None => {
52 let remote = runner.git(&["remote", "get-url", "origin"])?;
53 parse_github_remote(remote.trim())?
54 }
55 };
56 let branch = match args.branch.as_deref() {
57 Some(branch) => branch.to_string(),
58 None => {
59 let branch = runner.git(&["branch", "--show-current"])?;
60 let branch = branch.trim();
61 if branch.is_empty() {
62 return Err(OperationalError::new(
63 "detached HEAD detected; pass --branch BRANCH to query code scanning alerts",
64 ));
65 }
66 branch.to_string()
67 }
68 };
69 let commit = runner.git(&["rev-parse", "HEAD"])?.trim().to_string();
70
71 let mut alerts = Vec::new();
72 let mut warnings = Vec::new();
73 match fetch_code_scanning_alerts(runner, &repository, &branch, &options) {
74 Ok(source_alerts) => alerts.extend(source_alerts),
75 Err(err) => warnings.push(format!("code_scanning skipped: {err}")),
76 }
77 match fetch_dependabot_alerts(runner, &repository, &options) {
78 Ok(source_alerts) => alerts.extend(source_alerts),
79 Err(err) => warnings.push(format!("dependabot skipped: {err}")),
80 }
81 match fetch_secret_scanning_alerts(runner, &repository, &options) {
82 Ok(source_alerts) => alerts.extend(source_alerts),
83 Err(err) => warnings.push(format!("secret_scanning skipped: {err}")),
84 }
85 match runner.ensure_foxguard_config() {
86 Ok(Some(message)) => warnings.push(message),
87 Ok(None) => {}
88 Err(err) => warnings.push(format!("foxguard config update skipped: {err}")),
89 }
90 match fetch_foxguard_alerts(runner) {
91 Ok(source_alerts) => alerts.extend(source_alerts),
92 Err(err) => warnings.push(format!("foxguard skipped: {err}")),
93 }
94
95 let alerts = alerts
96 .into_iter()
97 .filter(|alert| options.matches(alert))
98 .collect::<Vec<_>>();
99 let foxguard_report_path = write_foxguard_report(runner, &alerts)?;
100 let report = Report::new(
101 ReportMetadata {
102 repository: repository.to_string(),
103 branch,
104 commit,
105 state_filter: options.states.clone(),
106 severity_filter: options.severities.clone(),
107 security_severity_filter: options.security_severities.clone(),
108 warnings,
109 foxguard_report_path,
110 },
111 alerts,
112 );
113 let output = if args.prompt {
114 render_prompt(&report)
115 } else {
116 match args.format {
117 SecurityFormat::Json => serde_json::to_string_pretty(&report).map_err(|err| {
118 OperationalError::new(format!("failed to render JSON report: {err}"))
119 })?,
120 SecurityFormat::Markdown => render_markdown(&report),
121 }
122 };
123 let exit_code = if args.no_errors || !report.policy.has_blocking_issues {
124 EXIT_SUCCESS
125 } else {
126 EXIT_POLICY_VIOLATION
127 };
128
129 let policy_notice = render_policy_notice(&report.policy, args.no_errors);
130
131 Ok(RunOutcome {
132 output,
133 exit_code,
134 policy_notice,
135 })
136}
137
138fn fetch_code_scanning_alerts(
139 runner: &dyn Runner,
140 repository: &OwnerRepo,
141 branch: &str,
142 options: &QueryOptions,
143) -> Result<Vec<Alert>, OperationalError> {
144 let mut alerts = Vec::new();
145 for state in options.states_for(AlertSource::CodeScanning) {
146 let endpoint = format!(
147 "/repos/{}/code-scanning/alerts?state={}&ref=refs/heads/{}",
148 repository, state, branch
149 );
150 let body = match runner.gh_api(&endpoint) {
151 Ok(body) => body,
152 Err(err) if err.is_feature_unavailable() => return Ok(alerts),
153 Err(err) => return Err(err),
154 };
155 let api_alerts: Vec<CodeScanningAlert> = serde_json::from_str(&body).map_err(|err| {
156 OperationalError::new(format!("failed to parse code scanning API response: {err}"))
157 })?;
158 alerts.extend(api_alerts.into_iter().map(normalize_code_scanning_alert));
159 }
160 Ok(alerts)
161}
162
163fn fetch_dependabot_alerts(
164 runner: &dyn Runner,
165 repository: &OwnerRepo,
166 options: &QueryOptions,
167) -> Result<Vec<Alert>, OperationalError> {
168 let mut alerts = Vec::new();
169 for state in options.states_for(AlertSource::Dependabot) {
170 let endpoint = format!("/repos/{}/dependabot/alerts?state={}", repository, state);
171 let body = match runner.gh_api(&endpoint) {
172 Ok(body) => body,
173 Err(err) if err.is_feature_unavailable() => return Ok(alerts),
174 Err(err) => return Err(err),
175 };
176 let api_alerts: Vec<DependabotAlert> = serde_json::from_str(&body).map_err(|err| {
177 OperationalError::new(format!("failed to parse Dependabot API response: {err}"))
178 })?;
179 alerts.extend(api_alerts.into_iter().map(normalize_dependabot_alert));
180 }
181 Ok(alerts)
182}
183
184fn fetch_secret_scanning_alerts(
185 runner: &dyn Runner,
186 repository: &OwnerRepo,
187 options: &QueryOptions,
188) -> Result<Vec<Alert>, OperationalError> {
189 let mut alerts = Vec::new();
190 for state in options.states_for(AlertSource::SecretScanning) {
191 let endpoint = format!(
192 "/repos/{}/secret-scanning/alerts?state={}",
193 repository, state
194 );
195 let body = match runner.gh_api(&endpoint) {
196 Ok(body) => body,
197 Err(err) if err.is_feature_unavailable() => return Ok(alerts),
198 Err(err) => return Err(err),
199 };
200 let api_alerts: Vec<SecretScanningAlert> = serde_json::from_str(&body).map_err(|err| {
201 OperationalError::new(format!(
202 "failed to parse secret scanning API response: {err}"
203 ))
204 })?;
205 alerts.extend(api_alerts.into_iter().map(normalize_secret_scanning_alert));
206 }
207 Ok(alerts)
208}
209
210fn fetch_foxguard_alerts(runner: &dyn Runner) -> Result<Vec<Alert>, OperationalError> {
211 let mut alerts = Vec::new();
212 let scan_args = foxguard_scan_args();
213 let scan = match runner.local_command("foxguard", &scan_args) {
214 Ok(output) => output,
215 Err(err) if err.is_feature_unavailable() => return Ok(alerts),
216 Err(err) => return Err(err),
217 };
218 alerts.extend(parse_foxguard_findings(&scan, "scan")?);
219
220 let secrets = match runner.local_command("foxguard", &["secrets", "--format", "json", "."]) {
221 Ok(output) => output,
222 Err(err) if err.is_feature_unavailable() => return Ok(alerts),
223 Err(err) => return Err(err),
224 };
225 alerts.extend(parse_foxguard_findings(&secrets, "secrets")?);
226 Ok(alerts)
227}
228
229fn foxguard_scan_args() -> Vec<&'static str> {
230 let mut args = vec![".", "--format", "json"];
231 for dir in DEFAULT_FOXGUARD_EXCLUDE_DIRS {
232 args.push("--exclude");
233 args.push(dir);
234 }
235 args
236}
237
238fn parse_foxguard_findings(raw: &str, mode: &str) -> Result<Vec<Alert>, OperationalError> {
239 let findings: Vec<FoxguardFinding> = serde_json::from_str(raw).map_err(|err| {
240 OperationalError::new(format!(
241 "failed to parse foxguard {mode} JSON response: {err}"
242 ))
243 })?;
244 Ok(findings
245 .into_iter()
246 .map(|finding| normalize_foxguard_finding(finding, mode))
247 .collect())
248}
249
250fn write_foxguard_report(
251 runner: &dyn Runner,
252 alerts: &[Alert],
253) -> Result<Option<String>, OperationalError> {
254 let foxguard_alerts = alerts
255 .iter()
256 .filter(|alert| alert.source == AlertSource::Foxguard)
257 .cloned()
258 .collect::<Vec<_>>();
259 if foxguard_alerts.is_empty() {
260 return Ok(None);
261 }
262 let content = render_foxguard_report(&foxguard_alerts);
263 runner
264 .write_report_file(FOXGUARD_REPORT_PATH, &content)
265 .map(Some)
266}
267
268struct RunOutcome {
269 output: String,
270 exit_code: i32,
271 policy_notice: String,
272}
273
274trait Runner {
275 fn git(&self, args: &[&str]) -> Result<String, OperationalError>;
276 fn gh_api(&self, endpoint: &str) -> Result<String, OperationalError>;
277 fn ensure_foxguard_config(&self) -> Result<Option<String>, OperationalError>;
278 fn write_report_file(&self, path: &str, content: &str) -> Result<String, OperationalError>;
279 fn local_command(&self, program: &str, args: &[&str]) -> Result<String, OperationalError>;
280}
281
282struct ShellRunner;
283
284impl Runner for ShellRunner {
285 fn git(&self, args: &[&str]) -> Result<String, OperationalError> {
286 run_command("git", args, "git")
287 }
288
289 fn gh_api(&self, endpoint: &str) -> Result<String, OperationalError> {
290 run_command("gh", &["api", endpoint], "GitHub CLI (gh)")
291 }
292
293 fn ensure_foxguard_config(&self) -> Result<Option<String>, OperationalError> {
294 ensure_default_foxguard_config_at(Path::new(FOXGUARD_CONFIG_PATH))
295 }
296
297 fn write_report_file(&self, path: &str, content: &str) -> Result<String, OperationalError> {
298 let path = Path::new(path);
299 if let Some(parent) = path.parent() {
300 fs::create_dir_all(parent).map_err(|err| {
301 OperationalError::new(format!("failed to create {}: {err}", parent.display()))
302 })?;
303 }
304 fs::write(path, content).map_err(|err| {
305 OperationalError::new(format!("failed to write {}: {err}", path.display()))
306 })?;
307 Ok(path.display().to_string())
308 }
309
310 fn local_command(&self, program: &str, args: &[&str]) -> Result<String, OperationalError> {
311 match run_command_accepting(program, args, program, &[0, 1]) {
312 Ok(output) => Ok(output),
313 Err(err) if program == "foxguard" && err.is_missing_foxguard() => {
314 install_foxguard()?;
315 run_command_accepting(program, args, program, &[0, 1]).or_else(|retry_err| {
316 if retry_err.is_missing_foxguard() {
317 run_foxguard_with_cargo_bin(args, &[0, 1])
318 } else {
319 Err(retry_err)
320 }
321 })
322 }
323 Err(err) => Err(err),
324 }
325 }
326}
327
328fn install_foxguard() -> Result<(), OperationalError> {
329 let output = Command::new("cargo")
330 .args(["binstall", "foxguard"])
331 .output()
332 .map_err(|err| OperationalError::new(format!("failed to execute cargo binstall: {err}")))?;
333 if output.status.success() {
334 return Ok(());
335 }
336 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
337 Err(OperationalError::new(format!(
338 "failed to install foxguard with `cargo binstall foxguard`.{}",
339 suffix_stderr(&stderr)
340 )))
341}
342
343fn foxguard_cargo_bin_dir() -> Result<OsString, OperationalError> {
344 let Some(home) = dirs::home_dir() else {
345 return Err(OperationalError::new(
346 "foxguard was installed but the home directory could not be resolved",
347 ));
348 };
349 Ok(home.join(".cargo").join("bin").into_os_string())
350}
351
352fn ensure_default_foxguard_config_at(path: &Path) -> Result<Option<String>, OperationalError> {
353 let ignore_paths = default_foxguard_config_paths();
354
355 if !path.exists() {
356 fs::write(path, default_foxguard_config(&ignore_paths)).map_err(|err| {
357 OperationalError::new(format!("failed to write {}: {err}", path.display()))
358 })?;
359 return Ok(Some(format!(
360 "foxguard config created: {} with default non-distributed path ignores",
361 path.display()
362 )));
363 }
364
365 let raw = fs::read_to_string(path).map_err(|err| {
366 OperationalError::new(format!("failed to read {}: {err}", path.display()))
367 })?;
368 let mut document = if raw.trim().is_empty() {
369 YamlValue::Mapping(YamlMapping::new())
370 } else {
371 serde_yaml_bw::from_str::<YamlValue>(&raw).map_err(|err| {
372 OperationalError::new(format!("failed to parse {}: {err}", path.display()))
373 })?
374 };
375 let changed = merge_default_foxguard_ignores(&mut document, &ignore_paths)?;
376 if !changed {
377 return Ok(None);
378 }
379 let rendered = serde_yaml_bw::to_string(&document).map_err(|err| {
380 OperationalError::new(format!(
381 "failed to render updated {}: {err}",
382 path.display()
383 ))
384 })?;
385 fs::write(path, rendered).map_err(|err| {
386 OperationalError::new(format!("failed to write {}: {err}", path.display()))
387 })?;
388 Ok(Some(format!(
389 "foxguard config updated: {} with default non-distributed path ignores",
390 path.display()
391 )))
392}
393
394fn default_foxguard_config_paths() -> Vec<String> {
395 DEFAULT_FOXGUARD_EXCLUDE_DIRS
396 .iter()
397 .map(|dir| format!("{dir}/"))
398 .collect()
399}
400
401fn default_foxguard_config(ignore_paths: &[String]) -> String {
402 render_foxguard_config(ignore_paths, DEFAULT_FOXGUARD_IGNORE_RULES)
403}
404
405fn render_foxguard_config(paths: &[String], rules: &[&str]) -> String {
406 let mut out = String::from("scan:\n ignore_rules:\n");
407 for path in paths {
408 out.push_str(&format!(" - path: {path}\n"));
409 out.push_str(" rules:\n");
410 for rule in rules {
411 out.push_str(&format!(" - {rule}\n"));
412 }
413 }
414 out
415}
416
417fn merge_default_foxguard_ignores(
418 document: &mut YamlValue,
419 ignore_paths: &[String],
420) -> Result<bool, OperationalError> {
421 let root = ensure_yaml_mapping(document, "root")?;
422 let scan = ensure_child_mapping(root, "scan")?;
423 let ignore_rules = ensure_child_sequence(scan, "ignore_rules")?;
424 let mut changed = false;
425 for path in ignore_paths {
426 changed |= ensure_foxguard_ignore_entry(ignore_rules, path, DEFAULT_FOXGUARD_IGNORE_RULES)?;
427 }
428 Ok(changed)
429}
430
431fn ensure_yaml_mapping<'a>(
432 value: &'a mut YamlValue,
433 label: &str,
434) -> Result<&'a mut YamlMapping, OperationalError> {
435 match value {
436 YamlValue::Mapping(mapping) => Ok(mapping),
437 YamlValue::Null(_) => {
438 *value = YamlValue::Mapping(YamlMapping::new());
439 match value {
440 YamlValue::Mapping(mapping) => Ok(mapping),
441 _ => unreachable!("value was just replaced with a mapping"),
442 }
443 }
444 _ => Err(OperationalError::new(format!(
445 ".foxguard.yml {label} must be a YAML mapping"
446 ))),
447 }
448}
449
450fn ensure_child_mapping<'a>(
451 parent: &'a mut YamlMapping,
452 key: &str,
453) -> Result<&'a mut YamlMapping, OperationalError> {
454 let key_value = yaml_string_value(key);
455 if !parent.contains_key(&key_value) {
456 parent.insert(key_value.clone(), YamlValue::Mapping(YamlMapping::new()));
457 }
458 let Some(value) = parent.get_mut(&key_value) else {
459 unreachable!("key was just inserted if missing");
460 };
461 ensure_yaml_mapping(value, key)
462}
463
464fn ensure_child_sequence<'a>(
465 parent: &'a mut YamlMapping,
466 key: &str,
467) -> Result<&'a mut Vec<YamlValue>, OperationalError> {
468 let key_value = yaml_string_value(key);
469 if !parent.contains_key(&key_value) {
470 parent.insert(key_value.clone(), YamlValue::Sequence(YamlSequence::new()));
471 }
472 let Some(value) = parent.get_mut(&key_value) else {
473 unreachable!("key was just inserted if missing");
474 };
475 match value {
476 YamlValue::Sequence(sequence) => Ok(sequence),
477 _ => Err(OperationalError::new(format!(
478 ".foxguard.yml {key} must be a YAML list"
479 ))),
480 }
481}
482
483fn ensure_foxguard_ignore_entry(
484 entries: &mut Vec<YamlValue>,
485 path: &str,
486 rules: &[&str],
487) -> Result<bool, OperationalError> {
488 for entry in entries.iter_mut() {
489 let mapping = ensure_yaml_mapping(entry, "scan.ignore_rules entry")?;
490 if yaml_string(mapping, "path") == Some(path) {
491 return merge_rule_list(mapping, rules);
492 }
493 }
494
495 let mut mapping = YamlMapping::new();
496 mapping.insert(yaml_string_value("path"), yaml_string_value(path));
497 mapping.insert(
498 yaml_string_value("rules"),
499 YamlValue::Sequence(YamlSequence {
500 anchor: None,
501 elements: rules.iter().map(|rule| yaml_string_value(rule)).collect(),
502 }),
503 );
504 entries.push(YamlValue::Mapping(mapping));
505 Ok(true)
506}
507
508fn merge_rule_list(mapping: &mut YamlMapping, rules: &[&str]) -> Result<bool, OperationalError> {
509 let sequence = ensure_child_sequence(mapping, "rules")?;
510 let mut existing = sequence
511 .iter()
512 .filter_map(|value| value.as_str().map(str::to_string))
513 .collect::<BTreeSet<_>>();
514 let mut changed = false;
515 for rule in rules {
516 if existing.insert((*rule).to_string()) {
517 sequence.push(yaml_string_value(rule));
518 changed = true;
519 }
520 }
521 Ok(changed)
522}
523
524fn yaml_string<'a>(mapping: &'a YamlMapping, key: &str) -> Option<&'a str> {
525 mapping
526 .get(yaml_string_value(key))
527 .and_then(YamlValue::as_str)
528}
529
530fn yaml_string_value(value: &str) -> YamlValue {
531 YamlValue::String(value.to_string(), None)
532}
533
534fn run_command(program: &str, args: &[&str], label: &str) -> Result<String, OperationalError> {
535 run_command_accepting(program, args, label, &[0])
536}
537
538fn run_command_accepting(
539 program: &str,
540 args: &[&str],
541 label: &str,
542 success_codes: &[i32],
543) -> Result<String, OperationalError> {
544 let output =
545 run_known_program(program, args).map_err(|err| command_error(program, label, err))?;
546 command_output_to_string(program, label, success_codes, output)
547}
548
549fn run_foxguard_with_cargo_bin(
550 args: &[&str],
551 success_codes: &[i32],
552) -> Result<String, OperationalError> {
553 let output = Command::new("foxguard")
554 .args(args)
555 .env("PATH", path_with_cargo_bin()?)
556 .output()
557 .map_err(|err| command_error("foxguard", "foxguard", err))?;
558 command_output_to_string("foxguard", "foxguard", success_codes, output)
559}
560
561fn path_with_cargo_bin() -> Result<OsString, OperationalError> {
562 let cargo_bin = foxguard_cargo_bin_dir()?;
563 let existing = std::env::var_os("PATH").unwrap_or_default();
564 let mut entries = vec![cargo_bin];
565 entries.extend(std::env::split_paths(&existing).map(|path| path.into_os_string()));
566 std::env::join_paths(entries).map_err(|err| {
567 OperationalError::new(format!(
568 "foxguard was installed but PATH could not be updated for this process: {err}"
569 ))
570 })
571}
572
573fn command_output_to_string(
574 program: &str,
575 label: &str,
576 success_codes: &[i32],
577 output: std::process::Output,
578) -> Result<String, OperationalError> {
579 if output
580 .status
581 .code()
582 .map(|code| success_codes.contains(&code))
583 .unwrap_or(false)
584 {
585 return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
586 }
587
588 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
589 if program == "gh" && looks_like_auth_error(&stderr) {
590 return Err(OperationalError::new(format!(
591 "GitHub CLI authentication failed. Run gh auth login.{}",
592 suffix_stderr(&stderr)
593 )));
594 }
595 Err(OperationalError::new(format!(
596 "{label} command failed with status {}.{}",
597 output.status,
598 suffix_stderr(&stderr)
599 )))
600}
601
602fn run_known_program(program: &str, args: &[&str]) -> std::io::Result<std::process::Output> {
603 match program {
604 "git" => Command::new("git").args(args).output(),
605 "gh" => Command::new("gh").args(args).output(),
606 "foxguard" => Command::new("foxguard").args(args).output(),
607 other => Err(std::io::Error::new(
608 std::io::ErrorKind::InvalidInput,
609 format!("unsupported security helper `{other}`"),
610 )),
611 }
612}
613
614fn command_error(program: &str, label: &str, err: std::io::Error) -> OperationalError {
615 if err.kind() == std::io::ErrorKind::NotFound && program == "gh" {
616 OperationalError::new("GitHub CLI (gh) is required. Install it and run gh auth login.")
617 } else if err.kind() == std::io::ErrorKind::NotFound && program == "foxguard" {
618 OperationalError::new("foxguard is not installed")
619 } else {
620 OperationalError::new(format!("failed to execute {label}: {err}"))
621 }
622}
623
624fn looks_like_auth_error(stderr: &str) -> bool {
625 let lower = stderr.to_ascii_lowercase();
626 lower.contains("401")
627 || lower.contains("authentication")
628 || lower.contains("auth login")
629 || lower.contains("not logged")
630}
631
632fn suffix_stderr(stderr: &str) -> String {
633 if stderr.is_empty() {
634 String::new()
635 } else {
636 format!(" {stderr}")
637 }
638}
639
640#[derive(Debug)]
641struct OperationalError {
642 message: String,
643}
644
645impl OperationalError {
646 fn new(message: impl Into<String>) -> Self {
647 Self {
648 message: message.into(),
649 }
650 }
651
652 fn is_feature_unavailable(&self) -> bool {
653 let lower = self.message.to_ascii_lowercase();
654 lower.contains("advanced security must be enabled")
655 || lower.contains("code scanning is not enabled")
656 || lower.contains("secret scanning is disabled")
657 || lower.contains("dependabot alerts are disabled")
658 || lower.contains("repository vulnerability alerts are disabled")
659 }
660
661 fn is_missing_foxguard(&self) -> bool {
662 self.message
663 .to_ascii_lowercase()
664 .contains("foxguard is not installed")
665 }
666}
667
668impl std::fmt::Display for OperationalError {
669 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
670 f.write_str(&self.message)
671 }
672}
673
674#[derive(Clone, Debug, Eq, PartialEq)]
675struct OwnerRepo {
676 owner: String,
677 repo: String,
678}
679
680impl std::fmt::Display for OwnerRepo {
681 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
682 write!(f, "{}/{}", self.owner, self.repo)
683 }
684}
685
686fn parse_owner_repo(raw: &str) -> Result<OwnerRepo, OperationalError> {
687 let trimmed = raw.trim().trim_end_matches(".git");
688 let parts = trimmed.split('/').collect::<Vec<_>>();
689 if parts.len() == 2 && parts.iter().all(|part| !part.is_empty()) {
690 return Ok(OwnerRepo {
691 owner: parts[0].to_string(),
692 repo: parts[1].to_string(),
693 });
694 }
695 Err(OperationalError::new(
696 "invalid repository; expected --repo OWNER/REPO",
697 ))
698}
699
700fn parse_github_remote(remote: &str) -> Result<OwnerRepo, OperationalError> {
701 if let Some(rest) = remote.strip_prefix("https://github.com/") {
702 return parse_owner_repo(rest);
703 }
704 if let Some(rest) = remote.strip_prefix("git@github.com:") {
705 return parse_owner_repo(rest);
706 }
707 if let Some(rest) = remote.strip_prefix("ssh://git@github.com/") {
708 return parse_owner_repo(rest);
709 }
710 Err(OperationalError::new(
711 "origin remote is not a supported GitHub URL; pass --repo OWNER/REPO",
712 ))
713}
714
715#[derive(Clone, Debug)]
716struct QueryOptions {
717 states: Vec<String>,
718 severities: Vec<String>,
719 security_severities: Vec<String>,
720}
721
722impl QueryOptions {
723 fn from_args(args: &SecurityArgs) -> Result<Self, OperationalError> {
724 let states = parse_filter_list(
725 &args.state,
726 &["open", "dismissed", "fixed", "resolved", "auto_dismissed"],
727 "state",
728 )?;
729 let severities = match args.severity.as_deref() {
730 Some(raw) => parse_filter_list(raw, &["error", "warning", "note"], "severity")?,
731 None => Vec::new(),
732 };
733 let security_severities = match args.security_severity.as_deref() {
734 Some(raw) => parse_filter_list(
735 raw,
736 &["critical", "high", "medium", "low"],
737 "security severity",
738 )?,
739 None => Vec::new(),
740 };
741 Ok(Self {
742 states,
743 severities,
744 security_severities,
745 })
746 }
747
748 fn states_for(&self, source: AlertSource) -> Vec<String> {
749 let allowed = match source {
750 AlertSource::CodeScanning => &["open", "dismissed", "fixed"][..],
751 AlertSource::Dependabot => &["open", "dismissed", "fixed", "auto_dismissed"][..],
752 AlertSource::SecretScanning => &["open", "resolved"][..],
753 AlertSource::Foxguard => &["open"][..],
754 };
755 self.states
756 .iter()
757 .filter(|state| allowed.contains(&state.as_str()))
758 .cloned()
759 .collect()
760 }
761
762 fn matches(&self, alert: &Alert) -> bool {
763 matches_filter(&self.states, Some(&alert.state))
764 && matches_filter(&self.severities, alert.severity.as_ref())
765 && matches_filter(&self.security_severities, alert.security_severity.as_ref())
766 }
767}
768
769fn parse_filter_list(
770 raw: &str,
771 allowed: &[&str],
772 label: &str,
773) -> Result<Vec<String>, OperationalError> {
774 let mut values = Vec::new();
775 for value in raw.split(',') {
776 let normalized = value.trim().to_ascii_lowercase();
777 if normalized.is_empty() {
778 continue;
779 }
780 if !allowed.contains(&normalized.as_str()) {
781 return Err(OperationalError::new(format!(
782 "invalid {label} `{normalized}`; expected one of {}",
783 allowed.join(", ")
784 )));
785 }
786 if !values.contains(&normalized) {
787 values.push(normalized);
788 }
789 }
790 Ok(values)
791}
792
793fn matches_filter(filter: &[String], value: Option<&String>) -> bool {
794 filter.is_empty()
795 || value
796 .map(|value| filter.iter().any(|item| item.eq_ignore_ascii_case(value)))
797 .unwrap_or(false)
798}
799
800#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
801#[serde(rename_all = "snake_case")]
802enum AlertSource {
803 CodeScanning,
804 Dependabot,
805 SecretScanning,
806 Foxguard,
807}
808
809impl std::fmt::Display for AlertSource {
810 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
811 match self {
812 AlertSource::CodeScanning => f.write_str("code_scanning"),
813 AlertSource::Dependabot => f.write_str("dependabot"),
814 AlertSource::SecretScanning => f.write_str("secret_scanning"),
815 AlertSource::Foxguard => f.write_str("foxguard"),
816 }
817 }
818}
819
820#[derive(Clone, Debug, Deserialize)]
821struct CodeScanningAlert {
822 number: Option<u64>,
823 state: Option<String>,
824 html_url: Option<String>,
825 rule: Option<CodeScanningRule>,
826 tool: Option<ApiTool>,
827 most_recent_instance: Option<CodeScanningInstance>,
828}
829
830#[derive(Clone, Debug, Deserialize)]
831struct CodeScanningRule {
832 id: Option<String>,
833 name: Option<String>,
834 description: Option<String>,
835 severity: Option<String>,
836 security_severity_level: Option<String>,
837}
838
839#[derive(Clone, Debug, Deserialize)]
840struct ApiTool {
841 name: Option<String>,
842}
843
844#[derive(Clone, Debug, Deserialize)]
845struct CodeScanningInstance {
846 message: Option<ApiMessage>,
847 location: Option<ApiLocation>,
848 tool: Option<ApiTool>,
849}
850
851#[derive(Clone, Debug, Deserialize)]
852struct ApiMessage {
853 text: Option<String>,
854}
855
856#[derive(Clone, Debug, Deserialize)]
857struct ApiLocation {
858 path: Option<String>,
859 start_line: Option<u64>,
860 start_column: Option<u64>,
861 end_line: Option<u64>,
862 end_column: Option<u64>,
863}
864
865#[derive(Clone, Debug, Deserialize)]
866struct DependabotAlert {
867 number: Option<u64>,
868 state: Option<String>,
869 html_url: Option<String>,
870 dependency: Option<DependabotDependency>,
871 security_advisory: Option<SecurityAdvisory>,
872 security_vulnerability: Option<SecurityVulnerability>,
873}
874
875#[derive(Clone, Debug, Deserialize)]
876struct DependabotDependency {
877 package: Option<Package>,
878 manifest_path: Option<String>,
879}
880
881#[derive(Clone, Debug, Deserialize)]
882struct Package {
883 ecosystem: Option<String>,
884 name: Option<String>,
885}
886
887#[derive(Clone, Debug, Deserialize)]
888struct SecurityAdvisory {
889 ghsa_id: Option<String>,
890 cve_id: Option<String>,
891 summary: Option<String>,
892 description: Option<String>,
893 severity: Option<String>,
894}
895
896#[derive(Clone, Debug, Deserialize)]
897struct SecurityVulnerability {
898 package: Option<Package>,
899 vulnerable_version_range: Option<String>,
900 first_patched_version: Option<PatchedVersion>,
901 severity: Option<String>,
902}
903
904#[derive(Clone, Debug, Deserialize)]
905struct PatchedVersion {
906 identifier: Option<String>,
907}
908
909#[derive(Clone, Debug, Deserialize)]
910struct SecretScanningAlert {
911 number: Option<u64>,
912 state: Option<String>,
913 html_url: Option<String>,
914 secret_type: Option<String>,
915 secret_type_display_name: Option<String>,
916}
917
918#[derive(Clone, Debug, Deserialize)]
919struct FoxguardFinding {
920 rule_id: Option<String>,
921 severity: Option<String>,
922 cwe: Option<String>,
923 description: Option<String>,
924 file: Option<String>,
925 line: Option<u64>,
926 column: Option<u64>,
927 end_line: Option<u64>,
928 end_column: Option<u64>,
929 snippet: Option<String>,
930}
931
932#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
933struct Alert {
934 number: Option<u64>,
935 source: AlertSource,
936 state: String,
937 tool: Option<String>,
938 severity: Option<String>,
939 security_severity: Option<String>,
940 rule_id: Option<String>,
941 rule_name: Option<String>,
942 rule_description: Option<String>,
943 message: Option<String>,
944 package: Option<String>,
945 ecosystem: Option<String>,
946 vulnerable_version_range: Option<String>,
947 first_patched_version: Option<String>,
948 secret_type: Option<String>,
949 path: Option<String>,
950 start_line: Option<u64>,
951 start_column: Option<u64>,
952 end_line: Option<u64>,
953 end_column: Option<u64>,
954 html_url: Option<String>,
955}
956
957fn normalize_code_scanning_alert(alert: CodeScanningAlert) -> Alert {
958 let tool = alert
959 .tool
960 .as_ref()
961 .and_then(|tool| tool.name.clone())
962 .or_else(|| {
963 alert
964 .most_recent_instance
965 .as_ref()
966 .and_then(|instance| instance.tool.as_ref())
967 .and_then(|tool| tool.name.clone())
968 });
969 let rule = alert.rule;
970 let instance = alert.most_recent_instance;
971 let location = instance
972 .as_ref()
973 .and_then(|instance| instance.location.as_ref());
974 Alert {
975 number: alert.number,
976 source: AlertSource::CodeScanning,
977 state: lower_string(alert.state).unwrap_or_else(|| "unknown".to_string()),
978 tool,
979 severity: rule
980 .as_ref()
981 .and_then(|rule| lower_string(rule.severity.clone())),
982 security_severity: rule
983 .as_ref()
984 .and_then(|rule| lower_string(rule.security_severity_level.clone())),
985 rule_id: rule.as_ref().and_then(|rule| rule.id.clone()),
986 rule_name: rule.as_ref().and_then(|rule| rule.name.clone()),
987 rule_description: rule.as_ref().and_then(|rule| rule.description.clone()),
988 message: instance
989 .as_ref()
990 .and_then(|instance| instance.message.as_ref())
991 .and_then(|message| message.text.clone()),
992 package: None,
993 ecosystem: None,
994 vulnerable_version_range: None,
995 first_patched_version: None,
996 secret_type: None,
997 path: location.and_then(|location| location.path.clone()),
998 start_line: location.and_then(|location| location.start_line),
999 start_column: location.and_then(|location| location.start_column),
1000 end_line: location.and_then(|location| location.end_line),
1001 end_column: location.and_then(|location| location.end_column),
1002 html_url: alert.html_url,
1003 }
1004}
1005
1006fn normalize_dependabot_alert(alert: DependabotAlert) -> Alert {
1007 let advisory = alert.security_advisory;
1008 let vulnerability = alert.security_vulnerability;
1009 let dependency = alert.dependency;
1010 let package = vulnerability
1011 .as_ref()
1012 .and_then(|vuln| vuln.package.as_ref())
1013 .or_else(|| dependency.as_ref().and_then(|dep| dep.package.as_ref()));
1014 let package_name = package.and_then(|package| package.name.clone());
1015 let ecosystem = package.and_then(|package| package.ecosystem.clone());
1016 let rule_id = advisory
1017 .as_ref()
1018 .and_then(|advisory| advisory.ghsa_id.clone())
1019 .or_else(|| {
1020 advisory
1021 .as_ref()
1022 .and_then(|advisory| advisory.cve_id.clone())
1023 });
1024 let severity = advisory
1025 .as_ref()
1026 .and_then(|advisory| lower_string(advisory.severity.clone()))
1027 .or_else(|| {
1028 vulnerability
1029 .as_ref()
1030 .and_then(|vuln| lower_string(vuln.severity.clone()))
1031 });
1032 Alert {
1033 number: alert.number,
1034 source: AlertSource::Dependabot,
1035 state: lower_string(alert.state).unwrap_or_else(|| "unknown".to_string()),
1036 tool: Some("Dependabot".to_string()),
1037 severity: None,
1038 security_severity: severity,
1039 rule_id,
1040 rule_name: advisory
1041 .as_ref()
1042 .and_then(|advisory| advisory.summary.clone()),
1043 rule_description: advisory
1044 .as_ref()
1045 .and_then(|advisory| advisory.description.clone()),
1046 message: dependabot_message(
1047 package_name.as_deref(),
1048 ecosystem.as_deref(),
1049 &vulnerability,
1050 ),
1051 package: package_name,
1052 ecosystem,
1053 vulnerable_version_range: vulnerability
1054 .as_ref()
1055 .and_then(|vuln| vuln.vulnerable_version_range.clone()),
1056 first_patched_version: vulnerability
1057 .as_ref()
1058 .and_then(|vuln| vuln.first_patched_version.as_ref())
1059 .and_then(|version| version.identifier.clone()),
1060 secret_type: None,
1061 path: dependency.and_then(|dependency| dependency.manifest_path),
1062 start_line: None,
1063 start_column: None,
1064 end_line: None,
1065 end_column: None,
1066 html_url: alert.html_url,
1067 }
1068}
1069
1070fn normalize_secret_scanning_alert(alert: SecretScanningAlert) -> Alert {
1071 Alert {
1072 number: alert.number,
1073 source: AlertSource::SecretScanning,
1074 state: lower_string(alert.state).unwrap_or_else(|| "unknown".to_string()),
1075 tool: Some("GitHub secret scanning".to_string()),
1076 severity: None,
1077 security_severity: Some("critical".to_string()),
1078 rule_id: alert.secret_type.clone(),
1079 rule_name: alert.secret_type_display_name.clone(),
1080 rule_description: None,
1081 message: alert
1082 .secret_type_display_name
1083 .as_ref()
1084 .map(|name| format!("Secret scanning detected {name}.")),
1085 package: None,
1086 ecosystem: None,
1087 vulnerable_version_range: None,
1088 first_patched_version: None,
1089 secret_type: alert.secret_type,
1090 path: None,
1091 start_line: None,
1092 start_column: None,
1093 end_line: None,
1094 end_column: None,
1095 html_url: alert.html_url,
1096 }
1097}
1098
1099fn normalize_foxguard_finding(finding: FoxguardFinding, mode: &str) -> Alert {
1100 let mut message_parts = Vec::new();
1101 if let Some(description) = &finding.description {
1102 message_parts.push(description.clone());
1103 }
1104 if let Some(cwe) = &finding.cwe {
1105 message_parts.push(format!("CWE: {cwe}"));
1106 }
1107 if let Some(snippet) = &finding.snippet {
1108 message_parts.push(format!("Snippet: {snippet}"));
1109 }
1110 Alert {
1111 number: None,
1112 source: AlertSource::Foxguard,
1113 state: "open".to_string(),
1114 tool: Some(format!("foxguard {mode}")),
1115 severity: None,
1116 security_severity: finding
1117 .severity
1118 .and_then(|severity| lower_string(Some(severity))),
1119 rule_id: finding.rule_id,
1120 rule_name: finding.cwe,
1121 rule_description: finding.description,
1122 message: if message_parts.is_empty() {
1123 None
1124 } else {
1125 Some(message_parts.join("\n"))
1126 },
1127 package: None,
1128 ecosystem: None,
1129 vulnerable_version_range: None,
1130 first_patched_version: None,
1131 secret_type: None,
1132 path: finding.file,
1133 start_line: finding.line,
1134 start_column: finding.column,
1135 end_line: finding.end_line,
1136 end_column: finding.end_column,
1137 html_url: None,
1138 }
1139}
1140
1141fn dependabot_message(
1142 package: Option<&str>,
1143 ecosystem: Option<&str>,
1144 vulnerability: &Option<SecurityVulnerability>,
1145) -> Option<String> {
1146 let mut parts = Vec::new();
1147 if let Some(package) = package {
1148 parts.push(format!("Package: {package}"));
1149 }
1150 if let Some(ecosystem) = ecosystem {
1151 parts.push(format!("Ecosystem: {ecosystem}"));
1152 }
1153 if let Some(range) = vulnerability
1154 .as_ref()
1155 .and_then(|vuln| vuln.vulnerable_version_range.as_deref())
1156 {
1157 parts.push(format!("Vulnerable range: {range}"));
1158 }
1159 if let Some(patched) = vulnerability
1160 .as_ref()
1161 .and_then(|vuln| vuln.first_patched_version.as_ref())
1162 .and_then(|version| version.identifier.as_deref())
1163 {
1164 parts.push(format!("First patched version: {patched}"));
1165 }
1166 if parts.is_empty() {
1167 None
1168 } else {
1169 Some(parts.join("\n"))
1170 }
1171}
1172
1173fn lower_string(value: Option<String>) -> Option<String> {
1174 value.map(|value| value.to_ascii_lowercase())
1175}
1176
1177#[derive(Debug, Serialize)]
1178struct Report {
1179 repository: String,
1180 branch: String,
1181 commit: String,
1182 state_filter: Vec<String>,
1183 severity_filter: Vec<String>,
1184 security_severity_filter: Vec<String>,
1185 warnings: Vec<String>,
1186 foxguard_report_path: Option<String>,
1187 alerts: Vec<Alert>,
1188 summary: Summary,
1189 #[serde(skip)]
1190 policy: PolicySummary,
1191}
1192
1193struct ReportMetadata {
1194 repository: String,
1195 branch: String,
1196 commit: String,
1197 state_filter: Vec<String>,
1198 severity_filter: Vec<String>,
1199 security_severity_filter: Vec<String>,
1200 warnings: Vec<String>,
1201 foxguard_report_path: Option<String>,
1202}
1203
1204impl Report {
1205 fn new(metadata: ReportMetadata, alerts: Vec<Alert>) -> Self {
1206 let summary = Summary::from_alerts(&alerts);
1207 let policy = PolicySummary::from_alerts(&alerts);
1208 Self {
1209 repository: metadata.repository,
1210 branch: metadata.branch,
1211 commit: metadata.commit,
1212 state_filter: metadata.state_filter,
1213 severity_filter: metadata.severity_filter,
1214 security_severity_filter: metadata.security_severity_filter,
1215 warnings: metadata.warnings,
1216 foxguard_report_path: metadata.foxguard_report_path,
1217 alerts,
1218 summary,
1219 policy,
1220 }
1221 }
1222}
1223
1224#[derive(Debug, Serialize)]
1225struct Summary {
1226 count: usize,
1227 by_source: BTreeMap<String, usize>,
1228 by_severity: BTreeMap<String, usize>,
1229 by_security_severity: BTreeMap<String, usize>,
1230}
1231
1232impl Summary {
1233 fn from_alerts(alerts: &[Alert]) -> Self {
1234 let mut by_source = BTreeMap::new();
1235 let mut by_severity = BTreeMap::new();
1236 let mut by_security_severity = BTreeMap::new();
1237 for alert in alerts {
1238 *by_source.entry(alert.source.to_string()).or_insert(0) += 1;
1239 if let Some(severity) = &alert.severity {
1240 *by_severity.entry(severity.clone()).or_insert(0) += 1;
1241 }
1242 if let Some(severity) = &alert.security_severity {
1243 *by_security_severity.entry(severity.clone()).or_insert(0) += 1;
1244 }
1245 }
1246 Self {
1247 count: alerts.len(),
1248 by_source,
1249 by_severity,
1250 by_security_severity,
1251 }
1252 }
1253}
1254
1255#[derive(Debug, Serialize)]
1256struct PolicySummary {
1257 has_blocking_issues: bool,
1258 github_issue_count: usize,
1259 critical_count: usize,
1260 critical_foxguard_count: usize,
1261 high_foxguard_needs_investigation_count: usize,
1262}
1263
1264impl PolicySummary {
1265 fn from_alerts(alerts: &[Alert]) -> Self {
1266 let github_issue_count = alerts
1267 .iter()
1268 .filter(|alert| alert.source != AlertSource::Foxguard)
1269 .count();
1270 let critical_count = alerts
1271 .iter()
1272 .filter(|alert| security_severity_is(alert, "critical"))
1273 .count();
1274 let critical_foxguard_count = alerts
1275 .iter()
1276 .filter(|alert| {
1277 alert.source == AlertSource::Foxguard && security_severity_is(alert, "critical")
1278 })
1279 .count();
1280 let high_foxguard_needs_investigation_count = alerts
1281 .iter()
1282 .filter(|alert| {
1283 alert.source == AlertSource::Foxguard
1284 && security_severity_is(alert, "high")
1285 && categorize_foxguard_alert(alert)
1286 == FoxguardTriageCategory::NeedsInvestigation
1287 })
1288 .count();
1289 Self {
1290 has_blocking_issues: github_issue_count > 0
1291 || critical_foxguard_count > 0
1292 || high_foxguard_needs_investigation_count > 0,
1293 github_issue_count,
1294 critical_count,
1295 critical_foxguard_count,
1296 high_foxguard_needs_investigation_count,
1297 }
1298 }
1299}
1300
1301fn security_severity_is(alert: &Alert, severity: &str) -> bool {
1302 alert
1303 .security_severity
1304 .as_deref()
1305 .map(|value| value.eq_ignore_ascii_case(severity))
1306 .unwrap_or(false)
1307}
1308
1309fn render_policy_notice(policy: &PolicySummary, ignore_errors: bool) -> String {
1310 let status = if policy.has_blocking_issues {
1311 if ignore_errors { "ignored" } else { "fail" }
1312 } else {
1313 "pass"
1314 };
1315 format!(
1316 "Security policy: {status} (GitHub-hosted findings: {}, critical findings: {}, critical FoxGuard findings: {}, high FoxGuard findings needing investigation: {})",
1317 policy.github_issue_count,
1318 policy.critical_count,
1319 policy.critical_foxguard_count,
1320 policy.high_foxguard_needs_investigation_count
1321 )
1322}
1323
1324fn render_markdown(report: &Report) -> String {
1325 render_markdown_with_instructions(
1326 report,
1327 "Triage and address the following GitHub security and quality findings. Do not change runtime behavior without explicit user agreement. Routine non-behavioral refactors, tests, formatting, FoxGuard config updates, and documented suppressions for proven false positives are pre-authorised. Avoid destructive changes.",
1328 )
1329}
1330
1331fn render_prompt(report: &Report) -> String {
1332 render_markdown_with_instructions(
1333 report,
1334 "You are a coding agent working in this repository. Triage and address the following GitHub security and quality findings end-to-end. Do not change runtime behavior without explicit user agreement. Make routine non-behavioral refactors, add or update tests, run focused verification, create or update FoxGuard config, and add documented suppressions for proven false positives. Stop for permission before destructive, credential-sensitive, or behavior-changing actions.",
1335 )
1336}
1337
1338#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1339enum FoxguardTriageCategory {
1340 NeedsHumanConsent,
1341 LikelyFalsePositive,
1342 SafeCleanup,
1343 NeedsInvestigation,
1344}
1345
1346impl FoxguardTriageCategory {
1347 const ALL: [Self; 4] = [
1348 Self::NeedsHumanConsent,
1349 Self::LikelyFalsePositive,
1350 Self::SafeCleanup,
1351 Self::NeedsInvestigation,
1352 ];
1353
1354 fn title(self) -> &'static str {
1355 match self {
1356 Self::NeedsHumanConsent => "Needs Human Consent",
1357 Self::LikelyFalsePositive => "Likely False Positive",
1358 Self::SafeCleanup => "Safe Cleanup",
1359 Self::NeedsInvestigation => "Needs Investigation",
1360 }
1361 }
1362
1363 fn strategy(self) -> &'static str {
1364 match self {
1365 Self::NeedsHumanConsent => {
1366 "Strategy: inspect the data flow and propose the smallest safe fix, but stop before changing runtime behavior, accepted inputs, network destinations, command execution, credential handling, or path access without explicit human consent."
1367 }
1368 Self::LikelyFalsePositive => {
1369 "Strategy: verify the flagged expression is not attacker-controlled. If it is a false positive, add a short accepted-risk comment and an exact inline FoxGuard directive such as `// foxguard: ignore[rule-id]` immediately before the flagged line."
1370 }
1371 Self::SafeCleanup => {
1372 "Strategy: prefer a small non-behavioral code cleanup, such as propagating an existing error path, narrowing a test-only/non-distributed exclusion, or documenting an invariant. If the cleanup would alter observable behavior, move it to `Needs Human Consent`."
1373 }
1374 Self::NeedsInvestigation => {
1375 "Strategy: inspect the surrounding code before editing. Classify it into one of the other categories if possible; otherwise document the uncertainty and ask before making behavior-changing or credential-sensitive changes."
1376 }
1377 }
1378 }
1379}
1380
1381fn categorize_foxguard_alert(alert: &Alert) -> FoxguardTriageCategory {
1382 let text = foxguard_alert_text(alert);
1383
1384 if contains_any(&text, &["map.get", "source_versions.get", "responses.get"]) {
1385 return FoxguardTriageCategory::LikelyFalsePositive;
1386 }
1387
1388 if alert
1389 .tool
1390 .as_deref()
1391 .map(|tool| tool.contains("secrets"))
1392 .unwrap_or(false)
1393 || contains_any(&text, &["secret", "token", "credential", "private key"])
1394 {
1395 return FoxguardTriageCategory::NeedsHumanConsent;
1396 }
1397
1398 match alert.rule_id.as_deref() {
1399 Some("rs/no-ssrf" | "rs/no-command-injection" | "rs/no-path-traversal") => {
1400 FoxguardTriageCategory::NeedsHumanConsent
1401 }
1402 Some("rs/no-unwrap-in-lib") => FoxguardTriageCategory::SafeCleanup,
1403 Some("rs/unsafe-block") => FoxguardTriageCategory::NeedsInvestigation,
1404 _ => FoxguardTriageCategory::NeedsInvestigation,
1405 }
1406}
1407
1408fn foxguard_alert_text(alert: &Alert) -> String {
1409 [
1410 alert.rule_id.as_deref(),
1411 alert.rule_name.as_deref(),
1412 alert.rule_description.as_deref(),
1413 alert.message.as_deref(),
1414 alert.path.as_deref(),
1415 ]
1416 .into_iter()
1417 .flatten()
1418 .collect::<Vec<_>>()
1419 .join("\n")
1420 .to_ascii_lowercase()
1421}
1422
1423fn contains_any(text: &str, needles: &[&str]) -> bool {
1424 needles.iter().any(|needle| text.contains(needle))
1425}
1426
1427fn render_foxguard_report(alerts: &[Alert]) -> String {
1428 let mut out = String::new();
1429 let summary = Summary::from_alerts(alerts);
1430 out.push_str("# FoxGuard Security And Quality Findings\n\n");
1431 out.push_str(&format!(
1432 "Found {} FoxGuard finding{}.\n\n",
1433 summary.count,
1434 if summary.count == 1 { "" } else { "s" }
1435 ));
1436 out.push_str("## Instructions For Codex\n\n");
1437 out.push_str("- Triage every finding before editing code.\n");
1438 out.push_str("- Make the smallest possible safe change.\n");
1439 out.push_str("- Do not change runtime behavior without explicit human consent.\n");
1440 out.push_str("- If a finding is a false positive, add a short normal comment explaining the accepted risk and then an exact FoxGuard directive on the line immediately before the flagged code, for example `// foxguard: ignore[rule-id]`.\n");
1441 out.push_str("- Do not add trailing text to a FoxGuard directive; FoxGuard v0.7.1 requires exact directive text.\n");
1442 out.push_str("- Use `.foxguard.yml` or FoxGuard excludes only for test-only or non-distributed paths. Do not hide production `src/` findings in config unless the file is demonstrably not packaged or executed in production.\n");
1443 out.push_str("- Do not suppress secret findings with inline comments; remove references and tell the user which credential must be revoked.\n\n");
1444
1445 out.push_str("## Triage Categories\n\n");
1446 for category in FoxguardTriageCategory::ALL {
1447 let count = alerts
1448 .iter()
1449 .filter(|alert| categorize_foxguard_alert(alert) == category)
1450 .count();
1451 out.push_str(&format!(
1452 "- {}: {} finding{}. {}\n",
1453 category.title(),
1454 count,
1455 if count == 1 { "" } else { "s" },
1456 category.strategy()
1457 ));
1458 }
1459 out.push('\n');
1460
1461 for category in FoxguardTriageCategory::ALL {
1462 let category_alerts = alerts
1463 .iter()
1464 .filter(|alert| categorize_foxguard_alert(alert) == category)
1465 .collect::<Vec<_>>();
1466 out.push_str(&format!("## {}\n\n", category.title()));
1467 out.push_str(category.strategy());
1468 out.push_str("\n\n");
1469
1470 if category_alerts.is_empty() {
1471 out.push_str("No findings in this category.\n\n");
1472 continue;
1473 }
1474
1475 for (idx, alert) in category_alerts.into_iter().enumerate() {
1476 render_foxguard_alert(&mut out, idx + 1, alert);
1477 }
1478 }
1479
1480 out
1481}
1482
1483fn render_foxguard_alert(out: &mut String, idx: usize, alert: &Alert) {
1484 out.push_str(&format!(
1485 "### {}. {}\n\n",
1486 idx,
1487 display_opt(alert.rule_id.as_deref())
1488 ));
1489 out.push_str(&format!("Source: {}\n", alert.source));
1490 if let Some(tool) = &alert.tool {
1491 out.push_str(&format!("Tool: {tool}\n"));
1492 }
1493 out.push_str(&format!("State: {}\n", alert.state));
1494 if alert.path.is_some() || alert.start_line.is_some() {
1495 out.push_str(&format!("File: {}\n", display_location(alert)));
1496 }
1497 if let Some(security_severity) = &alert.security_severity {
1498 out.push_str(&format!("Security severity: {security_severity}\n"));
1499 }
1500 if let Some(name) = &alert.rule_name {
1501 out.push_str(&format!("Name: {name}\n"));
1502 }
1503 if let Some(description) = &alert.rule_description {
1504 out.push_str(&format!("Description: {description}\n"));
1505 }
1506 if let Some(message) = &alert.message {
1507 out.push_str("\nMessage:\n");
1508 out.push_str(message);
1509 out.push('\n');
1510 }
1511 out.push_str("\nHandling:\n");
1512 out.push_str("Classify this finding as real, false positive, test-only, non-distributed, or behavior-changing. Fix real findings with the smallest safe non-behavioral change. If behavior would change, stop and ask for explicit human consent. For proven false positives, use an accepted-risk comment plus an exact `foxguard: ignore[rule-id]` directive.\n\n");
1513}
1514
1515fn render_markdown_with_instructions(report: &Report, instructions: &str) -> String {
1516 let mut out = String::new();
1517 out.push_str("# GitHub Security And Quality Issues\n\n");
1518 out.push_str(&format!("Repository: {}\n", report.repository));
1519 out.push_str(&format!("Branch: {}\n", report.branch));
1520 out.push_str(&format!("Commit: {}\n", report.commit));
1521 out.push_str(&format!(
1522 "State filter: {}\n",
1523 display_filter(&report.state_filter)
1524 ));
1525 out.push_str(&format!(
1526 "Severity filter: {}\n",
1527 display_filter(&report.severity_filter)
1528 ));
1529 out.push_str(&format!(
1530 "Security severity filter: {}\n\n",
1531 display_filter(&report.security_severity_filter)
1532 ));
1533 if !report.warnings.is_empty() {
1534 out.push_str("Warnings:\n");
1535 for warning in &report.warnings {
1536 out.push_str(&format!("- {warning}\n"));
1537 }
1538 out.push('\n');
1539 }
1540 out.push_str(&format!(
1541 "Found {} security alert{}.\n\n",
1542 report.summary.count,
1543 if report.summary.count == 1 { "" } else { "s" }
1544 ));
1545 if report.summary.count > 0 {
1546 out.push_str("## Copy/Paste Instructions For Codex\n\n");
1547 out.push_str("Triage every finding listed below. Treat GitHub code scanning, Dependabot, secret scanning, and FoxGuard findings as actionable unless the code proves a finding is a false positive. Make the smallest safe non-behavioral changes, run focused verification, and summarize any residual risk. Do not change runtime behavior without explicit user agreement. Do not modify secrets or credentials directly; for secret findings, remove references and tell the user which credential must be revoked.\n\n");
1548 out.push_str("FoxGuard workflow:\n");
1549 out.push_str("- `greentic-dev security` creates or updates `.foxguard.yml` before running FoxGuard and adds default `scan.ignore_rules` for test-only or non-distributed paths such as `tests/`, `benches/`, fixtures, examples, generated artifacts, and docs.\n");
1550 out.push_str("- If additional test-only or non-distributed paths are discovered, add them to `.foxguard.yml` `scan.ignore_rules` when those findings do not affect packaged runtime behavior.\n");
1551 out.push_str("- Do not hide production `src/` findings in `.foxguard.yml` unless the file is demonstrably not packaged or not executed in production.\n");
1552 out.push_str("- For individual false positives in production code, add a short normal comment explaining the accepted risk and an exact inline directive on its own line immediately before the flagged code, for example `// foxguard: ignore[rule-id]`.\n");
1553 out.push_str("- Do not add trailing text to a FoxGuard inline directive; FoxGuard v0.7.1 requires the directive text to be exact.\n");
1554 out.push_str("- Do not suppress `foxguard secrets` findings with inline comments; remove references and tell the user which credential must be revoked.\n\n");
1555 if let Some(path) = &report.foxguard_report_path {
1556 out.push_str("FoxGuard findings are written to a separate report:\n\n");
1557 out.push_str("Before using the linked report, triage each finding, prefer the smallest possible safe change, do not change runtime behavior without explicit human consent, and resolve proven false positives with exact inline FoxGuard comments.\n\n");
1558 out.push_str(&format!("- `{path}`\n\n"));
1559 out.push_str("Open that file for the full FoxGuard output.\n\n");
1560 }
1561 if let Some(config) = suggested_foxguard_config(report) {
1562 out.push_str("Suggested starter `.foxguard.yml` for non-distributed findings detected in this report:\n\n");
1563 out.push_str("```yaml\n");
1564 out.push_str(&config);
1565 out.push_str("```\n\n");
1566 out.push_str("Review this before applying it. It is intended for test-only or non-distributed code paths, not production `src/` findings.\n\n");
1567 }
1568 }
1569 out.push_str("## Coding-agent instructions\n\n");
1570 out.push_str(instructions);
1571 out.push('\n');
1572
1573 let inline_alerts = report
1574 .alerts
1575 .iter()
1576 .filter(|alert| {
1577 report.foxguard_report_path.is_none() || alert.source != AlertSource::Foxguard
1578 })
1579 .collect::<Vec<_>>();
1580 if inline_alerts.is_empty() && report.foxguard_report_path.is_some() {
1581 out.push_str("\nNo GitHub-hosted findings are available in this environment. See the FoxGuard report linked above for local findings.\n");
1582 }
1583 for (idx, alert) in inline_alerts.into_iter().enumerate() {
1584 out.push_str(&format!(
1585 "\n### {}. {} - {}\n\n",
1586 idx + 1,
1587 alert.source,
1588 display_opt(alert.rule_id.as_deref())
1589 ));
1590 out.push_str(&format!("Source: {}\n", alert.source));
1591 if let Some(tool) = &alert.tool {
1592 out.push_str(&format!("Tool: {tool}\n"));
1593 }
1594 out.push_str(&format!("State: {}\n", alert.state));
1595 if alert.path.is_some() || alert.start_line.is_some() {
1596 out.push_str(&format!("File: {}\n", display_location(alert)));
1597 }
1598 if let Some(package) = &alert.package {
1599 out.push_str(&format!("Package: {package}\n"));
1600 }
1601 if let Some(ecosystem) = &alert.ecosystem {
1602 out.push_str(&format!("Ecosystem: {ecosystem}\n"));
1603 }
1604 if let Some(severity) = &alert.severity {
1605 out.push_str(&format!("Severity: {severity}\n"));
1606 }
1607 if let Some(security_severity) = &alert.security_severity {
1608 out.push_str(&format!("Security severity: {security_severity}\n"));
1609 }
1610 if let Some(name) = &alert.rule_name {
1611 out.push_str(&format!("Name: {name}\n"));
1612 }
1613 if let Some(description) = &alert.rule_description {
1614 out.push_str(&format!("Description: {description}\n"));
1615 }
1616 if let Some(message) = &alert.message {
1617 out.push_str("\nMessage:\n");
1618 out.push_str(message);
1619 out.push('\n');
1620 }
1621 out.push_str("\nSuggested fix:\n");
1622 out.push_str("Inspect the alert and classify it as real, false positive, test-only, non-distributed, or behavior-changing. Apply the smallest safe non-behavioral change that resolves real findings. If a fix would change behavior, stop and ask the user before implementing. For FoxGuard false positives, prefer exact inline suppressions with an adjacent accepted-risk comment; for test-only or non-distributed paths, prefer `.foxguard.yml` `scan.ignore_rules`. For Dependabot, update or replace the vulnerable dependency when compatible. For secret scanning, revoke the secret and remove references from history or configuration.\n");
1623 if let Some(url) = &alert.html_url {
1624 out.push_str("\nAlert:\n");
1625 out.push_str(url);
1626 out.push('\n');
1627 }
1628 }
1629
1630 out
1631}
1632
1633fn suggested_foxguard_config(report: &Report) -> Option<String> {
1634 let mut ignores = BTreeMap::<String, BTreeSet<String>>::new();
1635 for alert in &report.alerts {
1636 if alert.source != AlertSource::Foxguard || alert.tool.as_deref() != Some("foxguard scan") {
1637 continue;
1638 }
1639 let Some(rule_id) = alert.rule_id.as_deref() else {
1640 continue;
1641 };
1642 let Some(path) = alert
1643 .path
1644 .as_deref()
1645 .and_then(non_distributed_foxguard_path)
1646 else {
1647 continue;
1648 };
1649 ignores
1650 .entry(path.to_string())
1651 .or_default()
1652 .insert(rule_id.to_string());
1653 }
1654 if ignores.is_empty() {
1655 return None;
1656 }
1657
1658 let mut out = String::from("scan:\n ignore_rules:\n");
1659 for (path, rules) in ignores {
1660 out.push_str(&format!(" - path: {path}\n"));
1661 out.push_str(" rules:\n");
1662 for rule in rules {
1663 out.push_str(&format!(" - {rule}\n"));
1664 }
1665 }
1666 Some(out)
1667}
1668
1669fn non_distributed_foxguard_path(path: &str) -> Option<&'static str> {
1670 let path = path.strip_prefix("./").unwrap_or(path);
1671 [
1672 "tests/",
1673 "benches/",
1674 "docs/",
1675 "examples/",
1676 "fixtures/",
1677 "testdata/",
1678 "generated/",
1679 ]
1680 .into_iter()
1681 .find(|prefix| path.starts_with(prefix))
1682}
1683
1684fn display_filter(values: &[String]) -> String {
1685 if values.is_empty() {
1686 "(none)".to_string()
1687 } else {
1688 values.join(",")
1689 }
1690}
1691
1692fn display_opt(value: Option<&str>) -> &str {
1693 value.unwrap_or("(unknown)")
1694}
1695
1696fn display_location(alert: &Alert) -> String {
1697 let Some(path) = &alert.path else {
1698 return "(unknown)".to_string();
1699 };
1700 match alert.start_line {
1701 Some(line) => format!("{path}:{line}"),
1702 None => path.clone(),
1703 }
1704}
1705
1706#[cfg(test)]
1707mod tests {
1708 use super::*;
1709
1710 fn args() -> SecurityArgs {
1711 SecurityArgs {
1712 format: SecurityFormat::Markdown,
1713 prompt: false,
1714 no_errors: false,
1715 severity: None,
1716 security_severity: None,
1717 state: "open".to_string(),
1718 repo: Some("greenticai/greentic-dev".to_string()),
1719 branch: Some("feature/foo".to_string()),
1720 }
1721 }
1722
1723 #[test]
1724 fn parses_owner_repo() {
1725 assert_eq!(
1726 parse_owner_repo("greenticai/greentic-dev").unwrap(),
1727 OwnerRepo {
1728 owner: "greenticai".to_string(),
1729 repo: "greentic-dev".to_string()
1730 }
1731 );
1732 }
1733
1734 #[test]
1735 fn parses_github_https_remote() {
1736 assert_eq!(
1737 parse_github_remote("https://github.com/greenticai/greentic-dev.git")
1738 .unwrap()
1739 .to_string(),
1740 "greenticai/greentic-dev"
1741 );
1742 }
1743
1744 #[test]
1745 fn parses_github_ssh_remotes() {
1746 assert_eq!(
1747 parse_github_remote("git@github.com:greenticai/greentic-dev.git")
1748 .unwrap()
1749 .to_string(),
1750 "greenticai/greentic-dev"
1751 );
1752 assert_eq!(
1753 parse_github_remote("ssh://git@github.com/greenticai/greentic-dev.git")
1754 .unwrap()
1755 .to_string(),
1756 "greenticai/greentic-dev"
1757 );
1758 }
1759
1760 #[test]
1761 fn rejects_non_github_remotes() {
1762 assert!(parse_github_remote("https://example.com/owner/repo.git").is_err());
1763 }
1764
1765 #[test]
1766 fn parses_comma_separated_filters() {
1767 assert_eq!(
1768 parse_filter_list("error, warning,error", &["error", "warning"], "severity").unwrap(),
1769 vec!["error", "warning"]
1770 );
1771 }
1772
1773 #[test]
1774 fn ignore_errors_does_not_change_query_filters() {
1775 let mut args = args();
1776 args.no_errors = true;
1777 args.state = "dismissed".to_string();
1778 let options = QueryOptions::from_args(&args).unwrap();
1779 assert_eq!(options.states, vec!["dismissed"]);
1780 assert!(options.severities.is_empty());
1781 }
1782
1783 #[test]
1784 fn source_specific_states_are_selected() {
1785 let mut args = args();
1786 args.state = "open,dismissed,resolved,auto_dismissed".to_string();
1787 let options = QueryOptions::from_args(&args).unwrap();
1788 assert_eq!(
1789 options.states_for(AlertSource::CodeScanning),
1790 vec!["open", "dismissed"]
1791 );
1792 assert_eq!(
1793 options.states_for(AlertSource::Dependabot),
1794 vec!["open", "dismissed", "auto_dismissed"]
1795 );
1796 assert_eq!(
1797 options.states_for(AlertSource::SecretScanning),
1798 vec!["open", "resolved"]
1799 );
1800 }
1801
1802 #[test]
1803 fn parses_code_scanning_alerts_without_tool_filtering() {
1804 let alerts: Vec<CodeScanningAlert> = serde_json::from_str(code_scanning_alerts()).unwrap();
1805 let normalized = alerts
1806 .into_iter()
1807 .map(normalize_code_scanning_alert)
1808 .collect::<Vec<_>>();
1809 assert_eq!(normalized.len(), 2);
1810 assert_eq!(normalized[0].source, AlertSource::CodeScanning);
1811 assert_eq!(normalized[0].tool.as_deref(), Some("CodeQL"));
1812 assert_eq!(normalized[1].tool.as_deref(), Some("OtherScanner"));
1813 }
1814
1815 #[test]
1816 fn parses_dependabot_alerts() {
1817 let alerts: Vec<DependabotAlert> = serde_json::from_str(dependabot_alerts()).unwrap();
1818 let normalized = alerts
1819 .into_iter()
1820 .map(normalize_dependabot_alert)
1821 .collect::<Vec<_>>();
1822 assert_eq!(normalized.len(), 1);
1823 assert_eq!(normalized[0].source, AlertSource::Dependabot);
1824 assert_eq!(normalized[0].security_severity.as_deref(), Some("high"));
1825 assert_eq!(normalized[0].package.as_deref(), Some("openssl"));
1826 }
1827
1828 #[test]
1829 fn parses_secret_scanning_alerts() {
1830 let alerts: Vec<SecretScanningAlert> =
1831 serde_json::from_str(secret_scanning_alerts()).unwrap();
1832 let normalized = alerts
1833 .into_iter()
1834 .map(normalize_secret_scanning_alert)
1835 .collect::<Vec<_>>();
1836 assert_eq!(normalized.len(), 1);
1837 assert_eq!(normalized[0].source, AlertSource::SecretScanning);
1838 assert_eq!(normalized[0].security_severity.as_deref(), Some("critical"));
1839 }
1840
1841 #[test]
1842 fn parses_foxguard_findings() {
1843 let normalized = parse_foxguard_findings(foxguard_findings(), "scan").unwrap();
1844 assert_eq!(normalized.len(), 1);
1845 assert_eq!(normalized[0].source, AlertSource::Foxguard);
1846 assert_eq!(normalized[0].tool.as_deref(), Some("foxguard scan"));
1847 assert_eq!(normalized[0].security_severity.as_deref(), Some("medium"));
1848 assert_eq!(normalized[0].path.as_deref(), Some("./src/main.rs"));
1849 }
1850
1851 #[test]
1852 fn creates_default_foxguard_config() {
1853 let tmp = tempfile::TempDir::new().unwrap();
1854 let path = tmp.path().join(".foxguard.yml");
1855 let message = ensure_default_foxguard_config_at(&path)
1856 .unwrap()
1857 .expect("config should be created");
1858 assert!(message.contains("created"));
1859 let config = fs::read_to_string(path).unwrap();
1860 assert!(config.contains("path: tests/"));
1861 assert!(config.contains("path: benches/"));
1862 assert!(config.contains("rs/no-unwrap-in-lib"));
1863 assert!(config.contains("rs/no-path-traversal"));
1864 assert!(!config.contains("path: src/"));
1865 }
1866
1867 #[test]
1868 fn updates_existing_foxguard_config_without_dropping_existing_rules() {
1869 let tmp = tempfile::TempDir::new().unwrap();
1870 let path = tmp.path().join(".foxguard.yml");
1871 fs::write(
1872 &path,
1873 "secrets:\n baseline: .foxguard/secrets-baseline.json\nscan:\n ignore_rules:\n - path: tests/\n rules:\n - custom/rule\n",
1874 )
1875 .unwrap();
1876 let message = ensure_default_foxguard_config_at(&path)
1877 .unwrap()
1878 .expect("config should be updated");
1879 assert!(message.contains("updated"));
1880 let config = fs::read_to_string(path).unwrap();
1881 assert!(config.contains("baseline: .foxguard/secrets-baseline.json"));
1882 assert!(config.contains("path: tests/"));
1883 assert!(config.contains("custom/rule"));
1884 assert!(config.contains("rs/no-unwrap-in-lib"));
1885 assert!(config.contains("path: benches/"));
1886 }
1887
1888 #[test]
1889 fn filters_by_severity_and_security_severity() {
1890 let options = QueryOptions {
1891 states: vec!["open".to_string()],
1892 severities: vec!["error".to_string()],
1893 security_severities: vec!["high".to_string()],
1894 };
1895 let alert = normalize_code_scanning_alert(
1896 serde_json::from_str::<Vec<CodeScanningAlert>>(code_scanning_alerts()).unwrap()[0]
1897 .clone(),
1898 );
1899 assert!(options.matches(&alert));
1900 let dep = normalize_dependabot_alert(
1901 serde_json::from_str::<Vec<DependabotAlert>>(dependabot_alerts()).unwrap()[0].clone(),
1902 );
1903 assert!(!options.matches(&dep));
1904 }
1905
1906 #[test]
1907 fn renders_empty_alert_list() {
1908 let report = Report::new(
1909 report_metadata(
1910 "greenticai/greentic-dev",
1911 "main",
1912 "abc123",
1913 vec!["open".to_string()],
1914 None,
1915 ),
1916 Vec::new(),
1917 );
1918 let markdown = render_markdown(&report);
1919 assert!(markdown.contains("Found 0 security alerts."));
1920 }
1921
1922 #[test]
1923 fn renders_markdown_report() {
1924 let report = sample_report();
1925 let markdown = render_markdown(&report);
1926 assert!(markdown.contains("# GitHub Security And Quality Issues"));
1927 assert!(markdown.contains("Copy/Paste Instructions For Codex"));
1928 assert!(markdown.contains("Source: dependabot"));
1929 assert!(markdown.contains(FOXGUARD_REPORT_PATH));
1930 assert!(markdown.contains("FoxGuard findings are written to a separate report"));
1931 assert!(!markdown.contains("## Policy Flags"));
1932 assert!(markdown.contains("Package: openssl"));
1933 assert!(
1934 markdown.contains("Do not change runtime behavior without explicit user agreement")
1935 );
1936 assert!(markdown.contains("creates or updates `.foxguard.yml` before running FoxGuard"));
1937 assert!(markdown.contains("scan.ignore_rules"));
1938 assert!(markdown.contains("FoxGuard v0.7.1 requires the directive text to be exact"));
1939 }
1940
1941 #[test]
1942 fn renders_prompt_report() {
1943 let prompt = render_prompt(&sample_report());
1944 assert!(prompt.contains("You are a coding agent"));
1945 assert!(prompt.contains("Triage and address"));
1946 assert!(prompt.contains("Stop for permission"));
1947 }
1948
1949 #[test]
1950 fn renders_foxguard_report_with_safety_instructions() {
1951 let alerts = parse_foxguard_findings(foxguard_findings(), "scan").unwrap();
1952 let report = render_foxguard_report(&alerts);
1953 assert!(report.contains("# FoxGuard Security And Quality Findings"));
1954 assert!(!report.contains("## Policy Flags"));
1955 assert!(report.contains("Do not change runtime behavior without explicit human consent"));
1956 assert!(report.contains("Make the smallest possible safe change"));
1957 assert!(report.contains("foxguard: ignore[rule-id]"));
1958 assert!(report.contains("## Triage Categories"));
1959 assert!(report.contains("## Safe Cleanup"));
1960 assert!(report.contains("Source: foxguard"));
1961 }
1962
1963 #[test]
1964 fn renders_foxguard_report_grouped_by_triage_category() {
1965 let alerts = parse_foxguard_findings(foxguard_category_findings(), "scan").unwrap();
1966 let report = render_foxguard_report(&alerts);
1967 assert!(report.contains("Needs Human Consent: 1 finding"));
1968 assert!(report.contains("Likely False Positive: 1 finding"));
1969 assert!(report.contains("Safe Cleanup: 1 finding"));
1970 assert!(report.contains("Needs Investigation: 1 finding"));
1971 assert!(report.contains("stop before changing runtime behavior"));
1972 assert!(report.contains("add a short accepted-risk comment"));
1973 assert!(report.contains("propagating an existing error path"));
1974 assert!(report.contains("Classify it into one of the other categories"));
1975 }
1976
1977 #[test]
1978 fn renders_json_report() {
1979 let json = serde_json::to_value(sample_report()).unwrap();
1980 assert_eq!(json["summary"]["count"], 5);
1981 assert_eq!(json["summary"]["by_source"]["dependabot"], 1);
1982 assert_eq!(json["summary"]["by_source"]["foxguard"], 1);
1983 assert_eq!(json["alerts"][0]["source"], "code_scanning");
1984 assert!(json.get("policy").is_none());
1985 }
1986
1987 #[test]
1988 fn prompt_overrides_json_format() {
1989 let mut args = args();
1990 args.prompt = true;
1991 args.format = SecurityFormat::Json;
1992 let runner = MockRunner::default();
1993 let outcome = run_inner(&runner, args).unwrap();
1994 assert!(
1995 outcome
1996 .output
1997 .starts_with("# GitHub Security And Quality Issues")
1998 );
1999 }
2000
2001 #[test]
2002 fn policy_exit_code_2_when_blocking_issues_exist() {
2003 let runner = MockRunner::default();
2004 let outcome = run_inner(&runner, args()).unwrap();
2005 assert_eq!(outcome.exit_code, EXIT_POLICY_VIOLATION);
2006 assert!(outcome.policy_notice.starts_with("Security policy: fail"));
2007 assert!(outcome.policy_notice.contains("GitHub-hosted findings: 4"));
2008 }
2009
2010 #[test]
2011 fn ignore_errors_returns_success_when_blocking_issues_exist() {
2012 let mut args = args();
2013 args.no_errors = true;
2014 let runner = MockRunner::default();
2015 let outcome = run_inner(&runner, args).unwrap();
2016 assert_eq!(outcome.exit_code, EXIT_SUCCESS);
2017 assert!(
2018 outcome
2019 .policy_notice
2020 .starts_with("Security policy: ignored")
2021 );
2022 }
2023
2024 #[test]
2025 fn success_exit_code_0_when_no_policy_blocking_alerts_exist() {
2026 let mut args = args();
2027 args.security_severity = Some("low".to_string());
2028 let runner = MockRunner::default();
2029 let outcome = run_inner(&runner, args).unwrap();
2030 assert_eq!(outcome.exit_code, EXIT_SUCCESS);
2031 assert!(outcome.policy_notice.starts_with("Security policy: pass"));
2032 }
2033
2034 #[test]
2035 fn policy_blocks_high_foxguard_findings_needing_investigation() {
2036 let alerts =
2037 parse_foxguard_findings(foxguard_high_investigation_findings(), "scan").unwrap();
2038 let report = Report::new(
2039 report_metadata(
2040 "greenticai/greentic-dev",
2041 "feature/foo",
2042 "abc123",
2043 vec!["open".to_string()],
2044 Some(FOXGUARD_REPORT_PATH.to_string()),
2045 ),
2046 alerts,
2047 );
2048 assert!(report.policy.has_blocking_issues);
2049 assert_eq!(report.policy.high_foxguard_needs_investigation_count, 1);
2050 let notice = render_policy_notice(&report.policy, false);
2051 assert!(notice.contains("high FoxGuard findings needing investigation: 1"));
2052 }
2053
2054 #[test]
2055 fn operational_error_for_git_failures() {
2056 let runner = MockRunner {
2057 git_error: Some("git failed".to_string()),
2058 ..Default::default()
2059 };
2060 assert!(run_inner(&runner, args()).is_err());
2061 }
2062
2063 #[test]
2064 fn unavailable_code_scanning_does_not_abort_other_sources() {
2065 let runner = MockRunner {
2066 code_scanning_error: Some("gh: Advanced Security must be enabled for this repository to use code scanning. (HTTP 403)".to_string()),
2067 ..Default::default()
2068 };
2069 let mut args = args();
2070 args.no_errors = true;
2071 let outcome = run_inner(&runner, args).unwrap();
2072 assert_eq!(outcome.exit_code, EXIT_SUCCESS);
2073 assert!(outcome.output.contains("dependabot"));
2074 assert!(outcome.output.contains("secret_scanning"));
2075 }
2076
2077 fn sample_report() -> Report {
2078 let mut alerts = Vec::new();
2079 alerts.extend(
2080 serde_json::from_str::<Vec<CodeScanningAlert>>(code_scanning_alerts())
2081 .unwrap()
2082 .into_iter()
2083 .map(normalize_code_scanning_alert),
2084 );
2085 alerts.extend(
2086 serde_json::from_str::<Vec<DependabotAlert>>(dependabot_alerts())
2087 .unwrap()
2088 .into_iter()
2089 .map(normalize_dependabot_alert),
2090 );
2091 alerts.extend(
2092 serde_json::from_str::<Vec<SecretScanningAlert>>(secret_scanning_alerts())
2093 .unwrap()
2094 .into_iter()
2095 .map(normalize_secret_scanning_alert),
2096 );
2097 alerts.extend(parse_foxguard_findings(foxguard_findings(), "scan").unwrap());
2098 Report::new(
2099 report_metadata(
2100 "greenticai/greentic-dev",
2101 "feature/foo",
2102 "abc123",
2103 vec!["open".to_string()],
2104 Some(FOXGUARD_REPORT_PATH.to_string()),
2105 ),
2106 alerts,
2107 )
2108 }
2109
2110 fn report_metadata(
2111 repository: &str,
2112 branch: &str,
2113 commit: &str,
2114 state_filter: Vec<String>,
2115 foxguard_report_path: Option<String>,
2116 ) -> ReportMetadata {
2117 ReportMetadata {
2118 repository: repository.to_string(),
2119 branch: branch.to_string(),
2120 commit: commit.to_string(),
2121 state_filter,
2122 severity_filter: Vec::new(),
2123 security_severity_filter: Vec::new(),
2124 warnings: Vec::new(),
2125 foxguard_report_path,
2126 }
2127 }
2128
2129 fn code_scanning_alerts() -> &'static str {
2130 r#"[
2131 {
2132 "number": 1,
2133 "state": "open",
2134 "html_url": "https://github.com/OWNER/REPO/security/code-scanning/1",
2135 "tool": {"name": "CodeQL"},
2136 "rule": {
2137 "id": "rust/path-injection",
2138 "name": "Path injection",
2139 "description": "User-controlled data flows into a filesystem path.",
2140 "severity": "error",
2141 "security_severity_level": "high"
2142 },
2143 "most_recent_instance": {
2144 "message": {"text": "User-controlled data flows into a filesystem path."},
2145 "location": {
2146 "path": "crates/foo/src/bar.rs",
2147 "start_line": 44,
2148 "start_column": 12,
2149 "end_line": 44,
2150 "end_column": 31
2151 }
2152 }
2153 },
2154 {
2155 "number": 2,
2156 "state": "open",
2157 "html_url": "https://github.com/OWNER/REPO/security/code-scanning/2",
2158 "tool": {"name": "OtherScanner"},
2159 "rule": {
2160 "id": "custom/style",
2161 "name": "Style issue",
2162 "severity": "warning"
2163 }
2164 }
2165 ]"#
2166 }
2167
2168 fn dependabot_alerts() -> &'static str {
2169 r#"[
2170 {
2171 "number": 3,
2172 "state": "open",
2173 "html_url": "https://github.com/OWNER/REPO/security/dependabot/3",
2174 "dependency": {
2175 "package": {"ecosystem": "cargo", "name": "openssl"},
2176 "manifest_path": "Cargo.lock"
2177 },
2178 "security_advisory": {
2179 "ghsa_id": "GHSA-xxxx-yyyy-zzzz",
2180 "cve_id": "CVE-2026-0001",
2181 "summary": "openssl vulnerability",
2182 "description": "A vulnerable dependency is present.",
2183 "severity": "high"
2184 },
2185 "security_vulnerability": {
2186 "package": {"ecosystem": "cargo", "name": "openssl"},
2187 "vulnerable_version_range": "< 1.0.0",
2188 "first_patched_version": {"identifier": "1.0.0"},
2189 "severity": "high"
2190 }
2191 }
2192 ]"#
2193 }
2194
2195 fn secret_scanning_alerts() -> &'static str {
2196 r#"[
2197 {
2198 "number": 4,
2199 "state": "open",
2200 "html_url": "https://github.com/OWNER/REPO/security/secret-scanning/4",
2201 "secret_type": "github_personal_access_token",
2202 "secret_type_display_name": "GitHub personal access token",
2203 "secret": "ghp_example"
2204 }
2205 ]"#
2206 }
2207
2208 fn foxguard_findings() -> &'static str {
2209 r#"[
2210 {
2211 "rule_id": "rs/no-unwrap-in-lib",
2212 "severity": "medium",
2213 "cwe": "CWE-248",
2214 "description": ".unwrap() can panic at runtime",
2215 "file": "./src/main.rs",
2216 "line": 10,
2217 "column": 20,
2218 "end_line": 10,
2219 "end_column": 28,
2220 "snippet": "value.unwrap()"
2221 }
2222 ]"#
2223 }
2224
2225 fn foxguard_category_findings() -> &'static str {
2226 r#"[
2227 {
2228 "rule_id": "rs/no-ssrf",
2229 "severity": "high",
2230 "description": "User-controlled URL passed to HTTP client",
2231 "file": "./src/install.rs",
2232 "line": 10,
2233 "snippet": "client.get(url)"
2234 },
2235 {
2236 "rule_id": "rs/no-ssrf",
2237 "severity": "high",
2238 "description": "Possible SSRF",
2239 "file": "./src/distributor.rs",
2240 "line": 20,
2241 "snippet": "map.get(key)"
2242 },
2243 {
2244 "rule_id": "rs/no-unwrap-in-lib",
2245 "severity": "medium",
2246 "description": ".unwrap() can panic at runtime",
2247 "file": "./src/main.rs",
2248 "line": 30,
2249 "snippet": "value.unwrap()"
2250 },
2251 {
2252 "rule_id": "rs/unsafe-block",
2253 "severity": "medium",
2254 "description": "unsafe block requires review",
2255 "file": "./src/platform.rs",
2256 "line": 40,
2257 "snippet": "unsafe { call() }"
2258 }
2259 ]"#
2260 }
2261
2262 fn foxguard_high_investigation_findings() -> &'static str {
2263 r#"[
2264 {
2265 "rule_id": "rs/unsafe-block",
2266 "severity": "high",
2267 "description": "unsafe block requires review",
2268 "file": "./src/platform.rs",
2269 "line": 40,
2270 "snippet": "unsafe { call() }"
2271 }
2272 ]"#
2273 }
2274
2275 #[derive(Default)]
2276 struct MockRunner {
2277 git_error: Option<String>,
2278 code_scanning_error: Option<String>,
2279 }
2280
2281 impl Runner for MockRunner {
2282 fn git(&self, args: &[&str]) -> Result<String, OperationalError> {
2283 if let Some(err) = &self.git_error {
2284 return Err(OperationalError::new(err));
2285 }
2286 match args {
2287 ["rev-parse", "HEAD"] => Ok("abc123\n".to_string()),
2288 _ => Ok(String::new()),
2289 }
2290 }
2291
2292 fn gh_api(&self, endpoint: &str) -> Result<String, OperationalError> {
2293 if endpoint.contains("code-scanning") {
2294 if let Some(err) = &self.code_scanning_error {
2295 return Err(OperationalError::new(err));
2296 }
2297 Ok(code_scanning_alerts().to_string())
2298 } else if endpoint.contains("dependabot") {
2299 Ok(dependabot_alerts().to_string())
2300 } else if endpoint.contains("secret-scanning") {
2301 Ok(secret_scanning_alerts().to_string())
2302 } else {
2303 Err(OperationalError::new(format!(
2304 "unexpected endpoint {endpoint}"
2305 )))
2306 }
2307 }
2308
2309 fn ensure_foxguard_config(&self) -> Result<Option<String>, OperationalError> {
2310 Ok(None)
2311 }
2312
2313 fn write_report_file(
2314 &self,
2315 _path: &str,
2316 _content: &str,
2317 ) -> Result<String, OperationalError> {
2318 Ok(FOXGUARD_REPORT_PATH.to_string())
2319 }
2320
2321 fn local_command(&self, program: &str, args: &[&str]) -> Result<String, OperationalError> {
2322 assert_eq!(program, "foxguard");
2323 if args.first() == Some(&"secrets") {
2324 Ok("[]".to_string())
2325 } else {
2326 Ok(foxguard_findings().to_string())
2327 }
2328 }
2329 }
2330}