Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 47 additions & 8 deletions src/audits/behavioral/destructive_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
use crate::runner::HelpOutput;

/// Subcommand names that imply destructive intent — irreversible writes
/// targeted at the agent-managed resource. Case-insensitive substring on
/// the subcommand name (so `delete-key` and `force-delete` both match).
/// targeted at the agent-managed resource. Matched case-insensitively at
/// the start of the name or of a `-`/`_` segment, so `delete-key`,
/// `force-delete` and `dropdb` match while `format` and `confirm` do not.
const DESTRUCTIVE_VERBS: &[&str] = &[
"delete", "remove", "rm", "destroy", "purge", "wipe", "reset", "drop", "clean", "force-",
];
Expand All @@ -35,7 +36,16 @@ pub(crate) fn destructive_subcommands(help: &HelpOutput) -> Vec<&String> {

pub(crate) fn is_destructive(name: &str) -> bool {
let lower = name.to_lowercase();
DESTRUCTIVE_VERBS.iter().any(|v| lower.contains(v))
segment_starts(&lower).any(|segment| DESTRUCTIVE_VERBS.iter().any(|v| segment.starts_with(v)))
}

/// Every suffix of `name` that begins at the name's start or immediately
/// after a `-` or `_` delimiter: `reset-keys` yields `reset-keys` and `keys`.
fn segment_starts(name: &str) -> impl Iterator<Item = &str> {
std::iter::once(name).chain(
name.match_indices(['-', '_'])
.map(|(i, delimiter)| &name[i + delimiter.len()..]),
)
}

pub(crate) fn is_read_verb(name: &str) -> bool {
Expand Down Expand Up @@ -77,11 +87,33 @@ mod tests {
}

#[test]
fn substring_match_for_compound_names() {
// `delete-all` should match — substring is correct here because
// the destructive intent of `delete` carries over.
assert!(is_destructive("delete-all"));
assert!(is_destructive("dropdb"));
fn segment_prefix_keeps_compound_names_destructive() {
for name in &[
"delete-all",
"dropdb",
"rmdir",
"cleanup",
"purgeall",
"reset-keys",
"force-push",
"remove_all",
] {
assert!(is_destructive(name), "{name} should be destructive");
}
}

#[test]
fn verb_after_a_delimiter_is_destructive() {
for name in &[
"queue-purge",
"config-reset",
"session_wipe",
"db-drop",
"cache_clean",
] {
assert!(is_destructive(name), "{name} should be destructive");
}
assert!(!is_destructive("config-firmware"));
}

#[test]
Expand All @@ -91,6 +123,13 @@ mod tests {
}
}

#[test]
fn verb_inside_a_word_is_not_destructive() {
for name in &["format", "transform", "perform", "confirm", "firmware"] {
assert!(!is_destructive(name), "{name} should not be destructive");
}
}

#[test]
fn read_verbs_match_exactly() {
assert!(is_read_verb("list"));
Expand Down
148 changes: 89 additions & 59 deletions src/audits/behavioral/json_output.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::audit::Audit;
use crate::audits::behavioral::subcommand_help::should_skip;
use crate::project::Project;
use crate::runner::{BinaryRunner, RunStatus};
use crate::runner::{BinaryRunner, HelpOutput, RunStatus};
use crate::types::{AuditGroup, AuditLayer, AuditResult, AuditStatus, Confidence};

pub struct JsonOutputAudit;
Expand Down Expand Up @@ -47,7 +48,7 @@ impl Audit for JsonOutputAudit {
} else {
// Flag not in top-level help. Probe subcommands, since most CLIs
// (gh, kubectl, cargo) put --output on subcommands, not top-level.
probe_subcommands(runner, &output)
probe_subcommands(runner, project.help_output())
}
}
_ => AuditStatus::Skip("could not run --help to detect output flags".into()),
Expand All @@ -65,12 +66,13 @@ impl Audit for JsonOutputAudit {
}
}

/// Parse subcommand names from --help output and audit each for --output/--format.
/// Probe each top-level subcommand from the shared help parse for --output/--format.
///
/// Most CLI frameworks (clap, cobra, argparse) list subcommands under a "Commands:"
/// or "Subcommands:" section. We parse those names and probe each one.
fn probe_subcommands(runner: &BinaryRunner, help_output: &str) -> AuditStatus {
let subcommands = parse_subcommand_names(help_output);
/// or "Subcommands:" section, and hand-written help under `... commands:`; the
/// shared parser reads all of them.
fn probe_subcommands(runner: &BinaryRunner, help: Option<&HelpOutput>) -> AuditStatus {
let subcommands = subcommands_to_probe(help);
if subcommands.is_empty() {
return AuditStatus::OptOut(
"no --output/--format flag detected — tool does not ship structured output. \
Expand Down Expand Up @@ -103,47 +105,17 @@ fn probe_subcommands(runner: &BinaryRunner, help_output: &str) -> AuditStatus {
)
}

/// Extract subcommand names from CLI --help output.
///
/// Looks for a "Commands:" or "Subcommands:" section and parses the first word
/// of each indented line. Stops at the next section header or blank line gap.
fn parse_subcommand_names(help_output: &str) -> Vec<String> {
let mut names = Vec::new();
let mut in_commands_section = false;

for line in help_output.lines() {
let trimmed = line.trim();

// Detect the start of a commands section
if trimmed.eq_ignore_ascii_case("commands:")
|| trimmed.eq_ignore_ascii_case("subcommands:")
|| trimmed.starts_with("Commands:")
|| trimmed.starts_with("Subcommands:")
{
in_commands_section = true;
continue;
}

if in_commands_section {
// End of section: non-indented non-empty line (next section header)
if !trimmed.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') {
break;
}
// Skip empty lines within the section
if trimmed.is_empty() {
continue;
}
// Extract the first word as the subcommand name
if let Some(name) = trimmed.split_whitespace().next() {
// Skip "help" subcommand (meta, not a real command)
if name != "help" {
names.push(name.to_string());
}
}
}
}

names
/// Top-level subcommand names worth probing for an output flag: the shared
/// parser's names minus the built-ins (`help`, shell completions) that the
/// subcommand-help helper skips. `help` in particular echoes top-level help,
/// so probing it could move this row on a tool that has no output flag.
fn subcommands_to_probe(help: Option<&HelpOutput>) -> Vec<&str> {
help.map(HelpOutput::subcommands)
.unwrap_or_default()
.iter()
.map(String::as_str)
.filter(|name| !should_skip(name))
.collect()
}

/// Try safe subcommands with the detected flag to validate actual JSON output.
Expand Down Expand Up @@ -376,23 +348,81 @@ esac
}

#[test]
fn parse_subcommand_names_clap_format() {
let help = "Usage: mycli [COMMAND]\n\nCommands:\n audit Run audits\n list List items\n help Print help\n\nOptions:\n -h, --help Print help\n";
let names = parse_subcommand_names(help);
assert_eq!(names, vec!["audit", "list"]);
fn json_output_probes_hand_written_command_block() {
// herdr-shaped help: a `Common commands:` block whose entries all lead
// with the tool name. The `count` subcommand carries the output flag.
let script = r#"
case "$*" in
*count*--output*json*|*count*--output=json*)
echo '{"count":3}';;
*count*--help*)
echo "Usage: test count [--output FORMAT]";;
*--help*)
echo "Usage: test [options]

Common commands:
test Launch interactively
test count Count things
test list List things";;
*)
echo "hello";;
esac
"#;
let project = test_project_with_sh_script(script);
let result = JsonOutputAudit.run(&project).expect("audit should run");
assert_eq!(result.status, AuditStatus::Pass, "got {:?}", result.status);
}

#[test]
fn json_output_does_not_probe_the_help_subcommand() {
// `help --help` advertises --output and would validate as JSON, but
// `help` is a built-in the audit never probes, so the tool opts out.
let script = r#"
case "$*" in
*help*--output*json*|*help*--output=json*)
echo '{"help":true}';;
help*--help*)
echo "Usage: test help [--output FORMAT]";;
*--help*)
echo "Usage: test [COMMAND]

Commands:
audit Run audits
help Print help";;
*)
echo "hello";;
esac
"#;
let project = test_project_with_sh_script(script);
let result = JsonOutputAudit.run(&project).expect("audit should run");
assert!(
matches!(result.status, AuditStatus::OptOut(_)),
"expected OptOut, got {:?}",
result.status
);
}

#[test]
fn subcommands_to_probe_clap_format_drops_help() {
let help = HelpOutput::from_raw(
"Usage: mycli [COMMAND]\n\nCommands:\n audit Run audits\n list List items\n help Print help\n\nOptions:\n -h, --help Print help\n",
);
assert_eq!(subcommands_to_probe(Some(&help)), ["audit", "list"]);
}

#[test]
fn parse_subcommand_names_empty() {
let help = "Usage: mycli [OPTIONS]\n\nOptions:\n -h, --help Print help\n";
let names = parse_subcommand_names(help);
assert!(names.is_empty());
fn subcommands_to_probe_empty_without_block_or_probe() {
let help =
HelpOutput::from_raw("Usage: mycli [OPTIONS]\n\nOptions:\n -h, --help Print help\n");
assert!(subcommands_to_probe(Some(&help)).is_empty());
assert!(subcommands_to_probe(None).is_empty());
}

#[test]
fn parse_subcommand_names_subcommands_header() {
let help = "Subcommands:\n run Execute\n build Compile\n";
let names = parse_subcommand_names(help);
assert_eq!(names, vec!["run", "build"]);
fn subcommands_to_probe_hand_written_block() {
let help = HelpOutput::from_raw(
"Usage: tool [options]\n\nCommon commands:\n tool Launch\n tool run Execute\n tool build Compile\n tool completion zsh Shell completions\n",
);
assert_eq!(subcommands_to_probe(Some(&help)), ["run", "build"]);
}
}
9 changes: 5 additions & 4 deletions src/audits/behavioral/standard_names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,10 @@ impl Audit for StandardNamesAudit {
}
}

/// Core unit for tests. Returns Skip when no subcommands are present (the
/// "if CLI uses subcommands" applicability is vacuously satisfied), Pass when
/// at least the threshold fraction matches the allow-list, Warn otherwise.
/// Core unit for tests. Returns Skip when no subcommand names were parsed
/// from `--help` (the "if CLI uses subcommands" applicability is vacuously
/// satisfied), Pass when at least the threshold fraction matches the
/// allow-list, Warn otherwise.
///
/// `domain_verbs` extends the built-in [`STANDARD_VERBS`] list with per-CLI
/// platform vocabulary (typically loaded from `.anc.toml`). Recognition is
Expand All @@ -232,7 +233,7 @@ pub(crate) fn audit_standard_names(

if subs.is_empty() {
return StandardNamesResult {
status: AuditStatus::Skip("no subcommands parsed from --help".into()),
status: AuditStatus::Skip(help.missing_subcommands_reason().into()),
mitigation: None,
};
}
Expand Down
10 changes: 5 additions & 5 deletions src/audits/behavioral/subcommand_examples.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
//! - Contains the literal `Examples:` / `EXAMPLES` section header.
//!
//! Fail when any non-skipped subcommand misses an example. Vacuous Skip
//! when the binary has no subcommands.
//! when no subcommand names were parsed from the top-level `--help`.

use crate::audit::Audit;
use crate::audits::behavioral::subcommand_help::probe_subcommands;
Expand Down Expand Up @@ -52,10 +52,10 @@ impl Audit for SubcommandExamplesAudit {
fn run(&self, project: &Project) -> anyhow::Result<AuditResult> {
let status = match project.help_output() {
None => AuditStatus::Skip("could not probe --help".into()),
Some(top_help) if top_help.subcommands().is_empty() => AuditStatus::Skip(
"binary has no subcommands; MUST applies conditionally to CLIs that use them."
.into(),
),
Some(top_help) if top_help.subcommands().is_empty() => AuditStatus::Skip(format!(
"{}; the MUST applies conditionally to CLIs that use subcommands.",
top_help.missing_subcommands_reason()
)),
Some(top_help) => {
let runner = project.runner_ref();
let subhelp = probe_subcommands(runner, top_help);
Expand Down
3 changes: 2 additions & 1 deletion src/audits/behavioral/subcommand_help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ pub(crate) fn probe_subcommands(
out
}

fn should_skip(name: &str) -> bool {
/// Whether `name` is a built-in the subcommand probes leave alone.
pub(crate) fn should_skip(name: &str) -> bool {
SKIP_SUBCOMMANDS
.iter()
.any(|s| name.eq_ignore_ascii_case(s))
Expand Down
Loading