diff --git a/integration/hurl/tests_failed/assert_secret.err b/integration/hurl/tests_failed/assert_secret.err deleted file mode 100644 index 9b86e932861..00000000000 --- a/integration/hurl/tests_failed/assert_secret.err +++ /dev/null @@ -1,9 +0,0 @@ -error: Assert body value - --> tests_failed/assert_secret.hurl:3:1 - | - | GET http://localhost:8000/secret-failed - | ... - 3 | "Hello ***" - | ^ actual value is - | - diff --git a/integration/hurl/tests_failed/assert_secret.err.pattern b/integration/hurl/tests_failed/assert_secret.err.pattern new file mode 100644 index 00000000000..93412da4c87 --- /dev/null +++ b/integration/hurl/tests_failed/assert_secret.err.pattern @@ -0,0 +1,19 @@ +HTTP/1.1 200 +Server: Werkzeug/<<<.*?>>> Python/<<<.*?>>> +Date: <<<.*?>>> +Content-Type: text/html; charset=utf-8 +Content-Length: 9 +Server: Flask Server +Connection: close + +Hello *** + +error: Assert body value + --> tests_failed/assert_secret.hurl:3:1 + | + | GET http://localhost:8000/secret-failed + | ... + 3 | "Hello ***" + | ^ actual value is + | + diff --git a/integration/hurl/tests_failed/assert_secret.ps1 b/integration/hurl/tests_failed/assert_secret.ps1 index 88789fa293b..1148cfe2e51 100644 --- a/integration/hurl/tests_failed/assert_secret.ps1 +++ b/integration/hurl/tests_failed/assert_secret.ps1 @@ -1,4 +1,35 @@ Set-StrictMode -Version latest $ErrorActionPreference = 'Stop' -hurl --secret name=Alice tests_failed/assert_secret.hurl +if (Test-Path -Path build/assert_secret) { + Remove-Item -Recurse build/assert_secret +} + +# We want to check leaks and do not stop at the first error +$ErrorActionPreference = 'Continue' + +hurl --secret name1=Alice ` + --secret name2=Bob ` + --error-format long ` + --report-html build/assert_secret/report-html ` + --report-json build/assert_secret/report-json ` + tests_failed/assert_secret.hurl + +$secrets = @("Alice", "Bob") + +$files = @(Get-ChildItem -Filter *.html -Recurse build/assert_secret/report-html) +$files += @(Get-ChildItem -Filter *.json build/assert_secret/) +$files += @(Get-ChildItem tests_failed/assert_secret.err.pattern) + +foreach ($secret in $secrets) { + foreach ($file in $files) { + # Don't search leaks in sources + if ($file.name.EndsWith("source.html")) { + continue + } + if (Get-Content $file | Select-String -CaseSensitive $secret) { + echo "Secret <$secret> have leaked in $file" + exit 1 + } + } +} diff --git a/integration/hurl/tests_failed/assert_secret.sh b/integration/hurl/tests_failed/assert_secret.sh index f9b3c3f5785..7a6d7b37056 100755 --- a/integration/hurl/tests_failed/assert_secret.sh +++ b/integration/hurl/tests_failed/assert_secret.sh @@ -1,4 +1,39 @@ #!/bin/bash set -Eeuo pipefail -hurl --secret name=Alice tests_failed/assert_secret.hurl +rm -rf build/assert_secret + +# We want to check leaks and do not stop at the first error +set +euo pipefail + +hurl --secret name1=Alice \ + --secret name2=Bob \ + --error-format long \ + --report-html build/assert_secret/report-html \ + --report-json build/assert_secret/report-json \ + tests_failed/assert_secret.hurl + +ret=$? + +secrets=("Alice" "Bob") + +files=$(find build/assert_secret/report-html/*.html \ + build/assert_secret/report-html/**/*.html \ + build/assert_secret/report-json/*.json \ + tests_failed/assert_secret.err.pattern) + +for secret in "${secrets[@]}"; do + for file in $files; do + # Don't search leaks in sources + if [[ "$file" == *source.html ]]; then + continue + fi + if grep -q "$secret" "$file"; then + echo "Secret <$secret> have leaked in $file" + exit 1 + fi + done +done + +# We use the exit code of the Hurl command +exit $ret diff --git a/integration/hurl/tests_ok/secret.err.pattern b/integration/hurl/tests_ok/secret.err.pattern index 8a9067b80ac..489bbf64b18 100644 --- a/integration/hurl/tests_ok/secret.err.pattern +++ b/integration/hurl/tests_ok/secret.err.pattern @@ -119,4 +119,5 @@ * start_transfer: <<<\d+>>> µs * total: <<<\d+>>> µs * -* Writing HTML report to build/secret +* Writing HTML report to build/secret/report-html +* Writing JSON report to build/secret/report-json diff --git a/integration/hurl/tests_ok/secret.ps1 b/integration/hurl/tests_ok/secret.ps1 index 35165ecd0be..cc765431e3d 100644 --- a/integration/hurl/tests_ok/secret.ps1 +++ b/integration/hurl/tests_ok/secret.ps1 @@ -9,12 +9,15 @@ hurl --very-verbose ` --secret a=secret1 ` --secret b=secret2 ` --secret c=12345678 ` - --report-html build/secret ` + --report-html build/secret/report-html ` + --report-json build/secret/report-json ` tests_ok/secret.hurl -$secrets = @("secret1", "secret2", "secret3", 12345678) +$secrets = @("secret1", "secret2", "secret3", "12345678") -$files = Get-ChildItem -Filter *.html -Recurse build/secret +$files = @(Get-ChildItem -Filter *.html -Recurse build/secret/report-html) +$files += @(Get-ChildItem -Filter *.json build/secret/report-json) +$files += @(Get-ChildItem tests_ok/secret.err.pattern) foreach ($secret in $secrets) { foreach ($file in $files) { diff --git a/integration/hurl/tests_ok/secret.sh b/integration/hurl/tests_ok/secret.sh index d44bcea6331..4f814db6be0 100755 --- a/integration/hurl/tests_ok/secret.sh +++ b/integration/hurl/tests_ok/secret.sh @@ -7,12 +7,16 @@ hurl --very-verbose \ --secret a=secret1 \ --secret b=secret2 \ --secret c=12345678 \ - --report-html build/secret \ + --report-html build/secret/report-html \ + --report-json build/secret/report-json \ tests_ok/secret.hurl secrets=("secret1" "secret2" "secret3" "12345678") -files=$(find build/secret/*.html build/secret/**/*.html tests_ok/secret.err.pattern) +files=$(find build/secret/report-html/*.html \ + build/secret/report-html/**/*.html \ + build/secret/report-json/*.json \ + tests_ok/secret.err.pattern) for secret in "${secrets[@]}"; do for file in $files; do diff --git a/packages/hurl/src/json/result.rs b/packages/hurl/src/json/result.rs index a0e6f27a614..90b027f41dd 100644 --- a/packages/hurl/src/json/result.rs +++ b/packages/hurl/src/json/result.rs @@ -32,6 +32,7 @@ use crate::http::{ ResponseCookie, Timings, }; use crate::runner::{AssertResult, CaptureResult, EntryResult, HurlResult}; +use crate::util::redacted::Redact; impl HurlResult { /// Serializes an [`HurlResult`] to a JSON representation. @@ -40,14 +41,16 @@ impl HurlResult { /// and columns). This parameter will be removed soon and the original content will be /// accessible through the [`HurlResult`] instance. /// An optional directory `response_dir` can be used to save HTTP response. + /// `secrets` strings are redacted from the JSON fields. pub fn to_json( &self, content: &str, filename: &Input, response_dir: Option<&Path>, + secrets: &[&str], ) -> Result { - let result = HurlResultJson::from_result(self, content, filename, response_dir)?; - let value = serde_json::to_value(result).unwrap(); + let result = HurlResultJson::from_result(self, content, filename, response_dir, secrets)?; + let value = serde_json::to_value(result)?; Ok(value) } @@ -199,16 +202,17 @@ impl HurlResultJson { content: &str, filename: &Input, response_dir: Option<&Path>, + secrets: &[&str], ) -> Result { let entries = result .entries .iter() - .map(|e| EntryResultJson::from_entry(e, content, filename, response_dir)) + .map(|e| EntryResultJson::from_entry(e, content, filename, response_dir, secrets)) .collect::, _>>()?; let cookies = result .cookies .iter() - .map(CookieJson::from_cookie) + .map(|c| CookieJson::from_cookie(c, secrets)) .collect::>(); Ok(HurlResultJson { filename: filename.to_string(), @@ -226,21 +230,22 @@ impl EntryResultJson { content: &str, filename: &Input, response_dir: Option<&Path>, + secrets: &[&str], ) -> Result { let calls = entry .calls .iter() - .map(|c| CallJson::from_call(c, response_dir)) + .map(|c| CallJson::from_call(c, response_dir, secrets)) .collect::, _>>()?; let captures = entry .captures .iter() - .map(CaptureJson::from_capture) + .map(|c| CaptureJson::from_capture(c, secrets)) .collect::>(); let asserts = entry .asserts .iter() - .map(|a| AssertJson::from_assert(a, content, filename, entry.source_info)) + .map(|a| AssertJson::from_assert(a, content, filename, entry.source_info, secrets)) .collect::>(); Ok(EntryResultJson { index: entry.entry_index, @@ -249,13 +254,13 @@ impl EntryResultJson { captures, asserts, time: entry.transfer_duration.as_millis() as u64, - curl_cmd: entry.curl_cmd.to_string(), + curl_cmd: entry.curl_cmd.redact(secrets), }) } } impl CookieJson { - fn from_cookie(c: &Cookie) -> Self { + fn from_cookie(c: &Cookie, secrets: &[&str]) -> Self { CookieJson { domain: c.domain.clone(), include_subdomain: c.include_subdomain.clone(), @@ -263,15 +268,19 @@ impl CookieJson { https: c.https.clone(), expires: c.expires.clone(), name: c.name.clone(), - value: c.value.clone(), + value: c.value.redact(secrets), } } } impl CallJson { - fn from_call(call: &Call, response_dir: Option<&Path>) -> Result { - let request = RequestJson::from_request(&call.request); - let response = ResponseJson::from_response(&call.response, response_dir)?; + fn from_call( + call: &Call, + response_dir: Option<&Path>, + secrets: &[&str], + ) -> Result { + let request = RequestJson::from_request(&call.request, secrets); + let response = ResponseJson::from_response(&call.response, response_dir, secrets)?; let timings = TimingsJson::from_timings(&call.timings); Ok(CallJson { request, @@ -282,26 +291,26 @@ impl CallJson { } impl RequestJson { - fn from_request(request: &Request) -> Self { + fn from_request(request: &Request, secrets: &[&str]) -> Self { let headers = request .headers .iter() - .map(HeaderJson::from_header) + .map(|h| HeaderJson::from_header(h, secrets)) .collect::>(); let cookies = request .cookies() .iter() - .map(RequestCookieJson::from_cookie) + .map(|c| RequestCookieJson::from_cookie(c, secrets)) .collect::>(); let query_string = request .url .query_params() .iter() - .map(ParamJson::from_param) + .map(|p| ParamJson::from_param(p, secrets)) .collect::>(); RequestJson { method: request.method.clone(), - url: request.url.to_string(), + url: request.url.redact(secrets), headers, cookies, query_string, @@ -310,7 +319,11 @@ impl RequestJson { } impl ResponseJson { - fn from_response(response: &Response, response_dir: Option<&Path>) -> Result { + fn from_response( + response: &Response, + response_dir: Option<&Path>, + secrets: &[&str], + ) -> Result { let http_version = match response.version { HttpVersion::Http10 => "HTTP/1.0", HttpVersion::Http11 => "HTTP/1.1", @@ -320,12 +333,12 @@ impl ResponseJson { let headers = response .headers .iter() - .map(HeaderJson::from_header) + .map(|h| HeaderJson::from_header(h, secrets)) .collect::>(); let cookies = response .cookies() .iter() - .map(ResponseCookieJson::from_cookie) + .map(|c| ResponseCookieJson::from_cookie(c, secrets)) .collect::>(); let certificate = response .certificate @@ -385,37 +398,37 @@ impl TimingsJson { } impl HeaderJson { - fn from_header(h: &Header) -> Self { + fn from_header(h: &Header, secrets: &[&str]) -> Self { HeaderJson { name: h.name.clone(), - value: h.value.clone(), + value: h.value.redact(secrets), } } } impl RequestCookieJson { - fn from_cookie(c: &RequestCookie) -> Self { + fn from_cookie(c: &RequestCookie, secrets: &[&str]) -> Self { RequestCookieJson { name: c.name.clone(), - value: c.value.clone(), + value: c.value.redact(secrets), } } } impl ParamJson { - fn from_param(p: &Param) -> Self { + fn from_param(p: &Param, secrets: &[&str]) -> Self { ParamJson { name: p.name.clone(), - value: p.value.clone(), + value: p.value.redact(secrets), } } } impl ResponseCookieJson { - fn from_cookie(c: &ResponseCookie) -> Self { + fn from_cookie(c: &ResponseCookie, secrets: &[&str]) -> Self { ResponseCookieJson { name: c.name.clone(), - value: c.value.clone(), + value: c.value.redact(secrets), expires: c.expires(), max_age: c.max_age().map(|m| m.to_string()), domain: c.domain(), @@ -431,7 +444,7 @@ impl CertificateJson { fn from_certificate(c: &Certificate) -> Self { CertificateJson { subject: c.subject.clone(), - issuer: c.issuer.to_string(), + issuer: c.issuer.clone(), start_date: c.start_date.to_string(), expire_date: c.expire_date.to_string(), serial_number: c.serial_number.to_string(), @@ -440,10 +453,10 @@ impl CertificateJson { } impl CaptureJson { - fn from_capture(c: &CaptureResult) -> Self { + fn from_capture(c: &CaptureResult, secrets: &[&str]) -> Self { CaptureJson { name: c.name.clone(), - value: c.value.to_json(), + value: c.value.to_json(secrets), } } } @@ -454,6 +467,7 @@ impl AssertJson { content: &str, filename: &Input, entry_src_info: SourceInfo, + secrets: &[&str], ) -> Self { let message = a.error().map(|err| { err.to_string( @@ -463,6 +477,7 @@ impl AssertJson { OutputFormat::Plain, ) }); + let message = message.map(|m| m.redact(secrets)); AssertJson { success: a.error().is_none(), message, diff --git a/packages/hurl/src/json/value.rs b/packages/hurl/src/json/value.rs index 2258d2d59c9..5734ce03182 100644 --- a/packages/hurl/src/json/value.rs +++ b/packages/hurl/src/json/value.rs @@ -21,26 +21,28 @@ use base64::engine::general_purpose; use base64::Engine; use crate::runner::{Number, Value}; +use crate::util::redacted::Redact; /// Serializes a [`Value`] to JSON, used in captures serialization. /// /// Natural JSON types are used to represent captures: if a [`Value::List`] is captured, /// the serialized data will be a JSON list. +/// `secrets` are redacted from string values. impl Value { - pub fn to_json(&self) -> serde_json::Value { + pub fn to_json(&self, secrets: &[&str]) -> serde_json::Value { match self { Value::Bool(v) => serde_json::Value::Bool(*v), Value::Date(v) => serde_json::Value::String(v.to_string()), Value::Number(v) => v.to_json(), - Value::String(s) => serde_json::Value::String(s.clone()), + Value::String(s) => serde_json::Value::String(s.redact(secrets)), Value::List(values) => { - let values = values.iter().map(|v| v.to_json()).collect(); + let values = values.iter().map(|v| v.to_json(secrets)).collect(); serde_json::Value::Array(values) } Value::Object(key_values) => { let mut map = serde_json::Map::new(); for (key, value) in key_values { - map.insert(key.to_string(), value.to_json()); + map.insert(key.to_string(), value.to_json(secrets)); } serde_json::Value::Object(map) } diff --git a/packages/hurl/src/main.rs b/packages/hurl/src/main.rs index a5a95ccb6b5..bc356d5ed30 100644 --- a/packages/hurl/src/main.rs +++ b/packages/hurl/src/main.rs @@ -178,7 +178,7 @@ fn export_results( } if let Some(dir) = &opts.json_report_dir { logger.debug(&format!("Writing JSON report to {}", dir.display())); - create_json_report(runs, dir)?; + create_json_report(runs, dir, &secrets)?; } if let Some(file) = &opts.cookie_output_file { logger.debug(&format!("Writing cookies to {}", file.display())); @@ -232,7 +232,7 @@ fn create_html_report(runs: &[HurlRun], dir_path: &Path, secrets: &[&str]) -> Re } /// Creates an JSON report for this run. -fn create_json_report(runs: &[HurlRun], dir_path: &Path) -> Result<(), CliError> { +fn create_json_report(runs: &[HurlRun], dir_path: &Path, secrets: &[&str]) -> Result<(), CliError> { // We ensure that the containing folder exists. let store_path = dir_path.join("store"); std::fs::create_dir_all(&store_path)?; @@ -243,7 +243,7 @@ fn create_json_report(runs: &[HurlRun], dir_path: &Path) -> Result<(), CliError> .collect::>(); let index_path = dir_path.join("report.json"); - json::write_report(&index_path, &testcases, &store_path)?; + json::write_report(&index_path, &testcases, &store_path, secrets)?; Ok(()) } diff --git a/packages/hurl/src/output/json.rs b/packages/hurl/src/output/json.rs index 966424ac91f..a46b02cf5cf 100644 --- a/packages/hurl/src/output/json.rs +++ b/packages/hurl/src/output/json.rs @@ -36,8 +36,13 @@ pub fn write_json( stdout: &mut Stdout, append: bool, ) -> Result<(), io::Error> { - let json_result = hurl_result.to_json(content, filename_in, None)?; - let serialized = serde_json::to_string(&json_result).unwrap(); + let response_dir = None; + // Secrets are only redacted from standard error and reports. In this cas, we want to output a + // response in a structured way. We do not change the value of the response output as it may be + // used for processing, contrary to the standard error that should be used for debug/log/messages. + let secrets = []; + let json_result = hurl_result.to_json(content, filename_in, response_dir, &secrets)?; + let serialized = serde_json::to_string(&json_result)?; let bytes = format!("{serialized}\n"); let bytes = bytes.into_bytes(); match filename_out { diff --git a/packages/hurl/src/report/html/testcase.rs b/packages/hurl/src/report/html/testcase.rs index 3ca44943d12..ef3be93064a 100644 --- a/packages/hurl/src/report/html/testcase.rs +++ b/packages/hurl/src/report/html/testcase.rs @@ -63,7 +63,7 @@ impl Testcase { /// - an HTML timeline view of the executed entries (with potential errors, waterfall) /// - an HTML view of the executed run (headers, cookies, etc...) /// - /// `secrets` strings are redacted from teh produced HTML. + /// `secrets` strings are redacted from the produced HTML. pub fn write_html( &self, content: &str, diff --git a/packages/hurl/src/report/json/mod.rs b/packages/hurl/src/report/json/mod.rs index 1d17009f808..2b87473a706 100644 --- a/packages/hurl/src/report/json/mod.rs +++ b/packages/hurl/src/report/json/mod.rs @@ -46,11 +46,12 @@ use crate::runner::HurlResult; /// Exports a list of [`Testcase`] to a JSON file `filename`. /// /// Response file are saved under the `response_dir` directory and referenced by path in JSON report -/// file. +/// file. `secrets` strings are redacted from the JSON report fields. pub fn write_report( filename: &Path, testcases: &[Testcase], response_dir: &Path, + secrets: &[&str], ) -> Result<(), ReportError> { // We parse any potential existing report. let mut report = deserialize::parse_json_report(filename)?; @@ -58,11 +59,11 @@ pub fn write_report( // Serialize the new report, extended any exiting one. let json = testcases .iter() - .map(|t| t.to_json(response_dir)) + .map(|t| t.to_json(response_dir, secrets)) .collect::, _>>()?; report.extend(json); - let serialized = serde_json::to_string(&report).unwrap(); + let serialized = serde_json::to_string(&report)?; let bytes = format!("{serialized}\n"); let bytes = bytes.into_bytes(); let mut file_out = File::create(filename)?; @@ -94,8 +95,14 @@ impl<'a> Testcase<'a> { } /// Serializes this testcase to JSON. - fn to_json(&self, response_dir: &Path) -> Result { + /// + /// `secrets` strings are redacted from the JSON fields. + fn to_json( + &self, + response_dir: &Path, + secrets: &[&str], + ) -> Result { self.result - .to_json(self.content, self.filename, Some(response_dir)) + .to_json(self.content, self.filename, Some(response_dir), secrets) } } diff --git a/packages/hurl/src/util/logger.rs b/packages/hurl/src/util/logger.rs index d55092e30f9..299aefb2e9d 100644 --- a/packages/hurl/src/util/logger.rs +++ b/packages/hurl/src/util/logger.rs @@ -138,7 +138,7 @@ impl Logger { /// Prints a given message to this logger [`Stderr`] instance, no matter what is the verbosity. pub fn info(&mut self, message: &str) { - self.stderr.eprintln(message); + self.eprintln(message); } /// Prints a given debug message to this logger [`Stderr`] instance, in verbose and very verbose mode.