diff --git a/CHANGELOG.md b/CHANGELOG.md index eb9814452..0c17e6edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - The LICENSE, README.md, and CHANGELOG.md files are now included in prebuilt binary releases. +- ANSI formatting sequences are now no longer included by default by the `report` command when the output is redirected to a file using the `-o`/`--outfile` parameter ([#55](https://github.com/praetorian-inc/noseyparker/issues/55)). + ### Changes - The `rules check` command invocation now behaves differently. It now no longer requires input paths to be specified. diff --git a/crates/noseyparker-cli/src/cmd_report.rs b/crates/noseyparker-cli/src/cmd_report.rs index 4a4cb5728..252068a0c 100644 --- a/crates/noseyparker-cli/src/cmd_report.rs +++ b/crates/noseyparker-cli/src/cmd_report.rs @@ -1,12 +1,8 @@ use anyhow::{bail, Context, Result}; use bstr::{BStr, ByteSlice}; -use console::Style; use indenter::indented; -use lazy_static::lazy_static; use serde::Serialize; -use serde_sarif::sarif; use std::fmt::{Display, Formatter, Write}; -use tracing::debug; use noseyparker::blob_metadata::BlobMetadata; use noseyparker::bstring_escape::Escaped; @@ -20,23 +16,41 @@ use noseyparker::provenance_set::ProvenanceSet; use crate::args::{GlobalArgs, ReportArgs, ReportOutputFormat}; use crate::reportable::Reportable; -pub fn run(global_args: &GlobalArgs, args: &ReportArgs) -> Result<()> { - debug!("Args:\n{global_args:#?}\n{args:#?}"); +mod human_format; +mod sarif_format; +mod styles; + +use styles::{Styles, StyledObject}; +pub fn run(global_args: &GlobalArgs, args: &ReportArgs) -> Result<()> { let datastore = Datastore::open(&args.datastore, global_args.advanced.sqlite_cache_size) .with_context(|| format!("Failed to open datastore at {}", args.datastore.display()))?; let output = args .output_args .get_writer() .context("Failed to get output writer")?; + let max_matches = if args.max_matches <= 0 { None } else { Some(args.max_matches.try_into().unwrap()) }; + + // enable output styling: + // - if the output destination is not explicitly specified and colors are not disabled + // - if the output destination *is* explicitly specified and colors are forced on + let styles_enabled = if args.output_args.output.is_none() { + global_args.use_color(std::io::stdout()) + } else { + global_args.color == crate::args::Mode::Always + }; + + let styles = Styles::new(styles_enabled); + let reporter = DetailsReporter { datastore, max_matches, + styles, }; reporter.report(args.output_args.format, output) } @@ -44,6 +58,7 @@ pub fn run(global_args: &GlobalArgs, args: &ReportArgs) -> Result<()> { struct DetailsReporter { datastore: Datastore, max_matches: Option, + styles: Styles, } impl DetailsReporter { @@ -56,6 +71,26 @@ impl DetailsReporter { .map(|(p, md, id, m)| ReportMatch { ps: p, md, id, m }) .collect()) } + + fn style_finding_heading(&self, val: D) -> StyledObject { + self.styles.style_finding_heading.apply_to(val) + } + + fn style_rule(&self, val: D) -> StyledObject { + self.styles.style_rule.apply_to(val) + } + + fn style_heading(&self, val: D) -> StyledObject { + self.styles.style_heading.apply_to(val) + } + + fn style_match(&self, val: D) -> StyledObject { + self.styles.style_match.apply_to(val) + } + + fn style_metadata(&self, val: D) -> StyledObject { + self.styles.style_metadata.apply_to(val) + } } impl Reportable for DetailsReporter { @@ -72,27 +107,6 @@ impl Reportable for DetailsReporter { } impl DetailsReporter { - fn human_format(&self, mut writer: W) -> Result<()> { - let datastore = &self.datastore; - let group_metadata = datastore - .get_match_group_metadata() - .context("Failed to get match group metadata from datastore")?; - - let num_findings = group_metadata.len(); - for (finding_num, metadata) in group_metadata.into_iter().enumerate() { - let finding_num = finding_num + 1; - let matches = self.get_matches(&metadata)?; - let match_group = MatchGroup { metadata, matches }; - writeln!( - &mut writer, - "{} {}", - STYLE_FINDING_HEADING.apply_to(format!("Finding {finding_num}/{num_findings}:")), - match_group, - )?; - } - Ok(()) - } - /// Write findings in JSON-like format to `writer`. /// /// If `begin` is supplied, it is written before any finding is. @@ -147,194 +161,6 @@ impl DetailsReporter { self.write_json_findings(writer, None, Some("\n"), Some("\n")) } - fn make_sarif_result(&self, finding: &MatchGroup) -> Result { - let matches = &finding.matches; - let metadata = &finding.metadata; - - let first_match_blob_id = match matches.first() { - Some(entry) => entry.m.blob_id.to_string(), - None => bail!("Failed to get group match data for group {metadata:?}"), - }; - let message = sarif::MessageBuilder::default() - .text(format!( - "Rule {:?} found {} {}.\nFirst blob id matched: {}", - metadata.rule_name, - metadata.num_matches, - if metadata.num_matches == 1 { - "match".to_string() - } else { - "matches".to_string() - }, - first_match_blob_id, - )) - .build()?; - - // Will store every match location for the runs.results.location array property - let locations: Vec = matches - .iter() - .flat_map(|m| { - let ReportMatch { ps, md, m, .. } = m; - ps.iter().map(move |p| { - let source_span = &m.location.source_span; - // let offset_span = &m.location.offset_span; - - let mut additional_properties = - vec![(String::from("blob_metadata"), serde_json::json!(md))]; - - let uri = match p { - Provenance::File(e) => e.path.to_string_lossy().into_owned(), - Provenance::GitRepo(e) => { - if let Some(p) = &e.commit_provenance { - additional_properties.push(( - String::from("commit_provenance"), - serde_json::json!(p), - )); - } - e.repo_path.to_string_lossy().into_owned() - } - }; - - let additional_properties = - std::collections::BTreeMap::from_iter(additional_properties); - let properties = sarif::PropertyBagBuilder::default() - .additional_properties(additional_properties) - .build()?; - - let location = sarif::LocationBuilder::default() - .physical_location( - sarif::PhysicalLocationBuilder::default() - .artifact_location( - sarif::ArtifactLocationBuilder::default().uri(uri).build()?, - ) - // .context_region() FIXME: fill this in with location info of surrounding context - .region( - sarif::RegionBuilder::default() - .start_line(source_span.start.line as i64) - .start_column(source_span.start.column as i64) - .end_line(source_span.end.line as i64) - .end_column(source_span.end.column as i64 + 1) - // FIXME: including byte offsets seems to confuse VSCode SARIF Viewer. Why? - /* - .byte_offset(offset_span.start as i64) - .byte_length(offset_span.len() as i64) - */ - .snippet( - sarif::ArtifactContentBuilder::default() - .text(m.snippet.matching.to_string()) - .build()?, - ) - .build()?, - ) - .build()?, - ) - .logical_locations([sarif::LogicalLocationBuilder::default() - .kind("blob") - .name(m.blob_id.to_string()) - .properties(properties) - .build()?]) - .build()?; - Ok(location) - }) - }) - .collect::>()?; - - let sha1_fingerprint = sha1_hexdigest(&metadata.match_content); - - // Build the result for the match - let result = sarif::ResultBuilder::default() - .rule_id(&metadata.rule_name) - // .occurrence_count(locations.len() as i64) // FIXME: enable? - .message(message) - .kind(sarif::ResultKind::Review.to_string()) - .locations(locations) - .level(sarif::ResultLevel::Warning.to_string()) - .partial_fingerprints([("match_group_content/sha256/v1".to_string(), sha1_fingerprint)]) - .build()?; - Ok(result) - } - - fn sarif_format(&self, mut writer: W) -> Result<()> { - let datastore: &Datastore = &self.datastore; - let group_metadata = datastore - .get_match_group_metadata() - .context("Failed to get match group metadata from datastore")?; - - let mut findings = Vec::with_capacity(group_metadata.len()); - for metadata in group_metadata { - let matches = self.get_matches(&metadata)?; - let match_group = MatchGroup::new(metadata, matches); - findings.push(self.make_sarif_result(&match_group)?); - } - - let run = sarif::RunBuilder::default() - .tool(noseyparker_sarif_tool()?) - .results(findings) - .build()?; - - let sarif = sarif::SarifBuilder::default() - .version(sarif::Version::V2_1_0.to_string()) - // .schema("https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json") - .schema(sarif::SCHEMA_URL) - .runs([run]) - .build()?; - - serde_json::to_writer(&mut writer, &sarif)?; - writeln!(writer)?; - - Ok(()) - } -} - -/// Load the rules used during the scan for the runs.tool.driver.rules array property -fn noseyparker_sarif_rules() -> Result> { - // FIXME: this ignores any non-builtin rules - get_builtin_rules() - .context("Failed to load builtin rules")? - .iter_rules() - .map(|rule| { - let help = sarif::MultiformatMessageStringBuilder::default() - .text(&rule.references.join("\n")) - .build()?; - - // FIXME: add better descriptions to Nosey Parker rules - let description = sarif::MultiformatMessageStringBuilder::default() - .text(&rule.pattern) - .build()?; - - let rule = sarif::ReportingDescriptorBuilder::default() - .id(&rule.name) // FIXME: nosey parker rules need to have stable, unique IDs, preferably without spaces - // .name(&rule.name) // FIXME: populate this once we have proper IDs - .short_description(description) - // .full_description(description) // FIXME: populate this - .help(help) // FIXME: provide better help messages for NP rules that we can include here - // .help_uri() // FIXME: populate this - .build()?; - Ok(rule) - }) - .collect::>>() -} - -fn noseyparker_sarif_tool() -> Result { - sarif::ToolBuilder::default() - .driver( - sarif::ToolComponentBuilder::default() - .name(env!("CARGO_PKG_NAME").to_string()) - .semantic_version(env!("CARGO_PKG_VERSION").to_string()) - .full_name(concat!("Nosey Parker ", env!("CARGO_PKG_VERSION"))) // FIXME: move into cargo.toml metadata, extract here; see https://docs.rs/cargo_metadata/latest/cargo_metadata/ - .organization("Praetorian, Inc") // FIXME: move into cargo.toml metadata, extract here - .information_uri(env!("CARGO_PKG_HOMEPAGE").to_string()) - .download_uri(env!("CARGO_PKG_REPOSITORY").to_string()) - // .full_description() // FIXME: populate with some long description, like the text from the README.md - .short_description( - sarif::MultiformatMessageStringBuilder::default() - .text(env!("CARGO_PKG_DESCRIPTION")) - .build()?, - ) - .rules(noseyparker_sarif_rules()?) - .build()?, - ) - .build() - .map_err(|e| e.into()) } #[derive(Serialize)] @@ -368,18 +194,11 @@ struct ReportMatch { id: MatchId, } -lazy_static! { - static ref STYLE_FINDING_HEADING: Style = Style::new().bold().bright().white(); - static ref STYLE_RULE: Style = Style::new().bright().bold().blue(); - static ref STYLE_HEADING: Style = Style::new().bold(); - static ref STYLE_MATCH: Style = Style::new().yellow(); - static ref STYLE_METADATA: Style = Style::new().bright().blue(); -} - impl MatchGroup { fn new(metadata: MatchGroupMetadata, matches: Vec) -> Self { Self { metadata, matches } } + fn rule_name(&self) -> &str { &self.metadata.rule_name } @@ -396,143 +215,3 @@ impl MatchGroup { self.matches.len() } } - -impl Display for MatchGroup { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - writeln!(f, "{}", STYLE_RULE.apply_to(self.rule_name()),)?; - - // write out status if set - if let Some(status) = self.metadata.status { - let status = match status { - Status::Accept => "Accept", - Status::Reject => "Reject", - }; - writeln!(f, "{} {}", STYLE_HEADING.apply_to("Status:"), status)?; - }; - - // write out comment if set - if let Some(comment) = &self.metadata.comment { - writeln!(f, "{} {}", STYLE_HEADING.apply_to("Comment:"), comment)?; - }; - - // write out the group on one line if it's single-line, and multiple lines otherwise - let g = self.group_input(); - let match_heading = STYLE_HEADING.apply_to("Match:"); - if g.contains(&b'\n') { - writeln!(f, "{match_heading}")?; - writeln!(f)?; - writeln!(indented(f).with_str(" "), "{}", STYLE_MATCH.apply_to(Escaped(g)))?; - writeln!(f)?; - } else { - writeln!(f, "{} {}", match_heading, STYLE_MATCH.apply_to(Escaped(g)))?; - } - - // write out count if not all matches are displayed - if self.num_matches() != self.total_matches() { - writeln!( - f, - "{}", - STYLE_HEADING.apply_to(format!( - "Showing {}/{} occurrences:", - self.num_matches(), - self.total_matches() - )) - )?; - } - writeln!(f)?; - - // write out matches - let mut f = indented(f).with_str(" "); - for (i, ReportMatch { ps, md, m, .. }) in self.matches.iter().enumerate() { - let i = i + 1; - writeln!( - f, - "{}", - STYLE_HEADING.apply_to(format!("Occurrence {i}/{}", self.total_matches())), - )?; - - let blob_metadata = { - format!( - "{} bytes, {}, {}", - md.num_bytes(), - md.mime_essence().unwrap_or("unknown type"), - md.charset().unwrap_or("unknown charset"), - ) - }; - - for p in ps.iter() { - match p { - Provenance::File(e) => { - writeln!( - f, - "{} {}", - STYLE_HEADING.apply_to("File:"), - STYLE_METADATA.apply_to(e.path.display()), - )?; - } - Provenance::GitRepo(e) => { - writeln!( - f, - "{} {}", - STYLE_HEADING.apply_to("Git repo:"), - STYLE_METADATA.apply_to(e.repo_path.display()), - )?; - if let Some(cs) = &e.commit_provenance { - let cmd = &cs.commit_metadata; - let msg = BStr::new(cmd.message.lines().next().unwrap_or(&[])); - let atime = cmd - .author_timestamp - .format(time::macros::format_description!("[year]-[month]-[day]")); - writeln!( - f, - "{} {} in {}", - STYLE_HEADING.apply_to("Commit:"), - cs.commit_kind, - STYLE_METADATA.apply_to(cmd.commit_id), - )?; - writeln!(f)?; - writeln!( - indented(&mut f).with_str(" "), - "{} {} <{}>\n\ - {} {}\n\ - {} {}\n\ - {} {}", - STYLE_HEADING.apply_to("Author:"), - cmd.author_name, - cmd.author_email, - STYLE_HEADING.apply_to("Date:"), - atime, - STYLE_HEADING.apply_to("Summary:"), - msg, - STYLE_HEADING.apply_to("Path:"), - cs.blob_path, - )?; - writeln!(f)?; - } - } - } - } - - writeln!( - f, - "{} {} ({})", - STYLE_HEADING.apply_to("Blob:"), - STYLE_METADATA.apply_to(&m.blob_id), - STYLE_METADATA.apply_to(blob_metadata), - )?; - - writeln!(f, "{} {}", STYLE_HEADING.apply_to("Lines:"), &m.location.source_span,)?; - writeln!(f)?; - writeln!( - indented(&mut f).with_str(" "), - "{}{}{}", - Escaped(&m.snippet.before), - STYLE_MATCH.apply_to(Escaped(&m.snippet.matching)), - Escaped(&m.snippet.after) - )?; - writeln!(f)?; - } - - Ok(()) - } -} diff --git a/crates/noseyparker-cli/src/cmd_report/human_format.rs b/crates/noseyparker-cli/src/cmd_report/human_format.rs new file mode 100644 index 000000000..5ca865af2 --- /dev/null +++ b/crates/noseyparker-cli/src/cmd_report/human_format.rs @@ -0,0 +1,169 @@ +use super::*; + +impl DetailsReporter { + pub fn human_format(&self, mut writer: W) -> Result<()> { + let datastore = &self.datastore; + let group_metadata = datastore + .get_match_group_metadata() + .context("Failed to get match group metadata from datastore")?; + + let num_findings = group_metadata.len(); + for (finding_num, metadata) in group_metadata.into_iter().enumerate() { + let finding_num = finding_num + 1; + let matches = self.get_matches(&metadata)?; + let match_group = MatchGroup { metadata, matches }; + writeln!( + &mut writer, + "{} {}", + self.style_finding_heading(format!("Finding {finding_num}/{num_findings}:")), + PrettyMatchGroup(self, &match_group), + )?; + } + Ok(()) + } +} + + +/// A wrapper type to allow human-oriented pretty-printing of a `MatchGroup`. +pub struct PrettyMatchGroup<'a>(&'a DetailsReporter, &'a MatchGroup); + +impl <'a> Display for PrettyMatchGroup<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let PrettyMatchGroup(reporter, group) = self; + writeln!(f, "{}", reporter.style_rule(group.rule_name()))?; + + // write out status if set + if let Some(status) = group.metadata.status { + let status = match status { + Status::Accept => "Accept", + Status::Reject => "Reject", + }; + writeln!(f, "{} {}", reporter.style_heading("Status:"), status)?; + }; + + // write out comment if set + if let Some(comment) = &group.metadata.comment { + writeln!(f, "{} {}", reporter.style_heading("Comment:"), comment)?; + }; + + // write out the group on one line if it's single-line, and multiple lines otherwise + let g = group.group_input(); + let match_heading = reporter.style_heading("Match:"); + if g.contains(&b'\n') { + writeln!(f, "{match_heading}")?; + writeln!(f)?; + writeln!(indented(f).with_str(" "), "{}", reporter.style_match(Escaped(g)))?; + writeln!(f)?; + } else { + writeln!(f, "{} {}", match_heading, reporter.style_match(Escaped(g)))?; + } + + // write out count if not all matches are displayed + if group.num_matches() != group.total_matches() { + writeln!( + f, + "{}", + reporter.style_heading(format!( + "Showing {}/{} occurrences:", + group.num_matches(), + group.total_matches() + )) + )?; + } + writeln!(f)?; + + // write out matches + let mut f = indented(f).with_str(" "); + for (i, ReportMatch { ps, md, m, .. }) in group.matches.iter().enumerate() { + let i = i + 1; + writeln!( + f, + "{}", + reporter.style_heading(format!("Occurrence {i}/{}", group.total_matches())), + )?; + + let blob_metadata = { + format!( + "{} bytes, {}, {}", + md.num_bytes(), + md.mime_essence().unwrap_or("unknown type"), + md.charset().unwrap_or("unknown charset"), + ) + }; + + for p in ps.iter() { + match p { + Provenance::File(e) => { + writeln!( + f, + "{} {}", + reporter.style_heading("File:"), + reporter.style_metadata(e.path.display()), + )?; + } + Provenance::GitRepo(e) => { + writeln!( + f, + "{} {}", + reporter.style_heading("Git repo:"), + reporter.style_metadata(e.repo_path.display()), + )?; + if let Some(cs) = &e.commit_provenance { + let cmd = &cs.commit_metadata; + let msg = BStr::new(cmd.message.lines().next().unwrap_or(&[])); + let atime = cmd + .author_timestamp + .format(time::macros::format_description!("[year]-[month]-[day]")); + writeln!( + f, + "{} {} in {}", + reporter.style_heading("Commit:"), + cs.commit_kind, + reporter.style_metadata(cmd.commit_id), + )?; + writeln!(f)?; + writeln!( + indented(&mut f).with_str(" "), + "{} {} <{}>\n\ + {} {}\n\ + {} {}\n\ + {} {}", + reporter.style_heading("Author:"), + cmd.author_name, + cmd.author_email, + reporter.style_heading("Date:"), + atime, + reporter.style_heading("Summary:"), + msg, + reporter.style_heading("Path:"), + cs.blob_path, + )?; + writeln!(f)?; + } + } + } + } + + writeln!( + f, + "{} {} ({})", + reporter.style_heading("Blob:"), + reporter.style_metadata(&m.blob_id), + reporter.style_metadata(blob_metadata), + )?; + + writeln!(f, "{} {}", reporter.style_heading("Lines:"), &m.location.source_span,)?; + writeln!(f)?; + writeln!( + indented(&mut f).with_str(" "), + "{}{}{}", + Escaped(&m.snippet.before), + reporter.style_match(Escaped(&m.snippet.matching)), + Escaped(&m.snippet.after) + )?; + writeln!(f)?; + } + + Ok(()) + } +} diff --git a/crates/noseyparker-cli/src/cmd_report/sarif_format.rs b/crates/noseyparker-cli/src/cmd_report/sarif_format.rs new file mode 100644 index 000000000..5cd2fb3fb --- /dev/null +++ b/crates/noseyparker-cli/src/cmd_report/sarif_format.rs @@ -0,0 +1,196 @@ +use serde_sarif::sarif; + +use super::*; + +impl DetailsReporter { + + fn make_sarif_result(&self, finding: &MatchGroup) -> Result { + let matches = &finding.matches; + let metadata = &finding.metadata; + + let first_match_blob_id = match matches.first() { + Some(entry) => entry.m.blob_id.to_string(), + None => bail!("Failed to get group match data for group {metadata:?}"), + }; + let message = sarif::MessageBuilder::default() + .text(format!( + "Rule {:?} found {} {}.\nFirst blob id matched: {}", + metadata.rule_name, + metadata.num_matches, + if metadata.num_matches == 1 { + "match".to_string() + } else { + "matches".to_string() + }, + first_match_blob_id, + )) + .build()?; + + // Will store every match location for the runs.results.location array property + let locations: Vec = matches + .iter() + .flat_map(|m| { + let ReportMatch { ps, md, m, .. } = m; + ps.iter().map(move |p| { + let source_span = &m.location.source_span; + // let offset_span = &m.location.offset_span; + + let mut additional_properties = + vec![(String::from("blob_metadata"), serde_json::json!(md))]; + + let uri = match p { + Provenance::File(e) => e.path.to_string_lossy().into_owned(), + Provenance::GitRepo(e) => { + if let Some(p) = &e.commit_provenance { + additional_properties.push(( + String::from("commit_provenance"), + serde_json::json!(p), + )); + } + e.repo_path.to_string_lossy().into_owned() + } + }; + + let additional_properties = + std::collections::BTreeMap::from_iter(additional_properties); + let properties = sarif::PropertyBagBuilder::default() + .additional_properties(additional_properties) + .build()?; + + let location = sarif::LocationBuilder::default() + .physical_location( + sarif::PhysicalLocationBuilder::default() + .artifact_location( + sarif::ArtifactLocationBuilder::default().uri(uri).build()?, + ) + // .context_region() FIXME: fill this in with location info of surrounding context + .region( + sarif::RegionBuilder::default() + .start_line(source_span.start.line as i64) + .start_column(source_span.start.column as i64) + .end_line(source_span.end.line as i64) + .end_column(source_span.end.column as i64 + 1) + // FIXME: including byte offsets seems to confuse VSCode SARIF Viewer. Why? + /* + .byte_offset(offset_span.start as i64) + .byte_length(offset_span.len() as i64) + */ + .snippet( + sarif::ArtifactContentBuilder::default() + .text(m.snippet.matching.to_string()) + .build()?, + ) + .build()?, + ) + .build()?, + ) + .logical_locations([sarif::LogicalLocationBuilder::default() + .kind("blob") + .name(m.blob_id.to_string()) + .properties(properties) + .build()?]) + .build()?; + Ok(location) + }) + }) + .collect::>()?; + + let sha1_fingerprint = sha1_hexdigest(&metadata.match_content); + + // Build the result for the match + let result = sarif::ResultBuilder::default() + .rule_id(&metadata.rule_name) + // .occurrence_count(locations.len() as i64) // FIXME: enable? + .message(message) + .kind(sarif::ResultKind::Review.to_string()) + .locations(locations) + .level(sarif::ResultLevel::Warning.to_string()) + .partial_fingerprints([("match_group_content/sha256/v1".to_string(), sha1_fingerprint)]) + .build()?; + Ok(result) + } + + pub fn sarif_format(&self, mut writer: W) -> Result<()> { + let datastore: &Datastore = &self.datastore; + let group_metadata = datastore + .get_match_group_metadata() + .context("Failed to get match group metadata from datastore")?; + + let mut findings = Vec::with_capacity(group_metadata.len()); + for metadata in group_metadata { + let matches = self.get_matches(&metadata)?; + let match_group = MatchGroup::new(metadata, matches); + findings.push(self.make_sarif_result(&match_group)?); + } + + let run = sarif::RunBuilder::default() + .tool(noseyparker_sarif_tool()?) + .results(findings) + .build()?; + + let sarif = sarif::SarifBuilder::default() + .version(sarif::Version::V2_1_0.to_string()) + // .schema("https://docs.oasis-open.org/sarif/sarif/v2.1.0/cos02/schemas/sarif-schema-2.1.0.json") + .schema(sarif::SCHEMA_URL) + .runs([run]) + .build()?; + + serde_json::to_writer(&mut writer, &sarif)?; + writeln!(writer)?; + + Ok(()) + } +} + +/// Load the rules used during the scan for the runs.tool.driver.rules array property +fn noseyparker_sarif_rules() -> Result> { + // FIXME: this ignores any non-builtin rules + get_builtin_rules() + .context("Failed to load builtin rules")? + .iter_rules() + .map(|rule| { + let help = sarif::MultiformatMessageStringBuilder::default() + .text(&rule.references.join("\n")) + .build()?; + + // FIXME: add better descriptions to Nosey Parker rules + let description = sarif::MultiformatMessageStringBuilder::default() + .text(&rule.pattern) + .build()?; + + let rule = sarif::ReportingDescriptorBuilder::default() + .id(&rule.name) // FIXME: nosey parker rules need to have stable, unique IDs, preferably without spaces + // .name(&rule.name) // FIXME: populate this once we have proper IDs + .short_description(description) + // .full_description(description) // FIXME: populate this + .help(help) // FIXME: provide better help messages for NP rules that we can include here + // .help_uri() // FIXME: populate this + .build()?; + Ok(rule) + }) + .collect::>>() +} + +fn noseyparker_sarif_tool() -> Result { + sarif::ToolBuilder::default() + .driver( + sarif::ToolComponentBuilder::default() + .name(env!("CARGO_PKG_NAME").to_string()) + .semantic_version(env!("CARGO_PKG_VERSION").to_string()) + .full_name(concat!("Nosey Parker ", env!("CARGO_PKG_VERSION"))) // FIXME: move into cargo.toml metadata, extract here; see https://docs.rs/cargo_metadata/latest/cargo_metadata/ + .organization("Praetorian, Inc") // FIXME: move into cargo.toml metadata, extract here + .information_uri(env!("CARGO_PKG_HOMEPAGE").to_string()) + .download_uri(env!("CARGO_PKG_REPOSITORY").to_string()) + // .full_description() // FIXME: populate with some long description, like the text from the README.md + .short_description( + sarif::MultiformatMessageStringBuilder::default() + .text(env!("CARGO_PKG_DESCRIPTION")) + .build()?, + ) + .rules(noseyparker_sarif_rules()?) + .build()?, + ) + .build() + .map_err(|e| e.into()) +} + diff --git a/crates/noseyparker-cli/src/cmd_report/styles.rs b/crates/noseyparker-cli/src/cmd_report/styles.rs new file mode 100644 index 000000000..90357bbd7 --- /dev/null +++ b/crates/noseyparker-cli/src/cmd_report/styles.rs @@ -0,0 +1,27 @@ +pub use console::{Style, StyledObject}; + +pub struct Styles { + pub style_finding_heading: Style, + pub style_rule: Style, + pub style_heading: Style, + pub style_match: Style, + pub style_metadata: Style, +} + +impl Styles { + pub fn new(styles_enabled: bool) -> Self { + let style_finding_heading = Style::new().bold().bright().white().force_styling(styles_enabled); + let style_rule = Style::new().bright().bold().blue().force_styling(styles_enabled); + let style_heading = Style::new().bold().force_styling(styles_enabled); + let style_match = Style::new().yellow().force_styling(styles_enabled); + let style_metadata = Style::new().bright().blue().force_styling(styles_enabled); + + Self { + style_finding_heading, + style_rule, + style_heading, + style_match, + style_metadata, + } + } +} diff --git a/crates/noseyparker-cli/tests/common/mod.rs b/crates/noseyparker-cli/tests/common/mod.rs index 8b2ac93c6..9ced8b0a9 100644 --- a/crates/noseyparker-cli/tests/common/mod.rs +++ b/crates/noseyparker-cli/tests/common/mod.rs @@ -10,7 +10,6 @@ pub use assert_fs::prelude::*; pub use assert_fs::{fixture::ChildPath, TempDir}; pub use insta::{assert_display_snapshot, assert_json_snapshot, assert_snapshot, with_settings, internals::Redaction}; pub use predicates::str::{RegexPredicate, is_empty}; -pub use pretty_assertions::{assert_eq, assert_ne}; pub use std::path::Path; pub use std::process::Command; diff --git a/crates/noseyparker-cli/tests/scan/basic/mod.rs b/crates/noseyparker-cli/tests/scan/basic/mod.rs index 5c699a9df..35d9916bf 100644 --- a/crates/noseyparker-cli/tests/scan/basic/mod.rs +++ b/crates/noseyparker-cli/tests/scan/basic/mod.rs @@ -1,4 +1,5 @@ use super::*; +pub use pretty_assertions::assert_ne; #[test] fn scan_emptydir() { @@ -247,3 +248,41 @@ fn report_unlimited_matches() { assert_cmd_snapshot!(noseyparker_success!("report", "-d", scan_env.dspath(), "--max-matches", "-1")); }); } + + +/// Test that the `report` command uses colors as expected when *not* running under a pty: +/// +/// - When running with the output explicitly written to a file, colors are not used +/// +/// - When running with with the output explicitly written to a file and `--color=always` +/// specified, colors are used +#[test] +fn report_output_colors1() { + let scan_env = ScanEnv::new(); + let input = scan_env.input_file_with_secret("input.txt"); + + let output1 = scan_env.child("findings.txt"); + let output2 = scan_env.child("findings.colored.txt"); + + noseyparker_success!("scan", "-d", scan_env.dspath(), input.path()) + .stdout(match_scan_stats("104 B", 1, 1, 1)); + + noseyparker_success!("report", "-d", scan_env.dspath(), "-o", output1.path()); + noseyparker_success!("report", "-d", scan_env.dspath(), "-o", output2.path(), "--color=always"); + + let output1_contents = std::fs::read_to_string(output1.path()).unwrap(); + let output2_contents = std::fs::read_to_string(output2.path()).unwrap(); + + assert_ne!(output1_contents, output2_contents); + with_settings!({ + filters => get_report_stdout_filters(), + }, { + assert_snapshot!(output1_contents); + }); + assert_eq!(&output1_contents, &console::strip_ansi_codes(&output2_contents)); +} + +// Test that the `report` command uses colors as expected when running under a pty: +// - When running with the output going to stdout (default), colors are used +// - When running with the explicitly written to a file, colors are not used +// XXX to get a pty, look at the `pty-process` crate: https://docs.rs/pty-process/latest/pty_process/blocking/struct.Command.html diff --git a/crates/noseyparker-cli/tests/scan/basic/snapshots/test_noseyparker__scan__basic__report_output_colors1.snap b/crates/noseyparker-cli/tests/scan/basic/snapshots/test_noseyparker__scan__basic__report_output_colors1.snap new file mode 100644 index 000000000..c18bc5f17 --- /dev/null +++ b/crates/noseyparker-cli/tests/scan/basic/snapshots/test_noseyparker__scan__basic__report_output_colors1.snap @@ -0,0 +1,19 @@ +--- +source: crates/noseyparker-cli/tests/scan/basic/mod.rs +expression: output1_contents +--- +Finding 1/1: GitHub Personal Access Token +Match: ghp_XIxB7KMNdAr3zqWtQqhE94qglHqOzn1D1stg + + Occurrence 1/1 + File: + Blob: + Lines: 3:12-3:51 + + # This is fake configuration data + USERNAME=the_dude + GITHUB_KEY=ghp_XIxB7KMNdAr3zqWtQqhE94qglHqOzn1D1stg + + + +