Skip to main content

greentic_dev/
security_cmd.rs

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}