blob: b3de75b306c8fe53b68536f51c5c637f47c41dda [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
#include "service/query-profile-redaction.h"
#include "service/query-profile-size-limit-util.h"
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstring>
#include <iterator>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <arpa/inet.h>
#include <boost/algorithm/string.hpp>
#include <rapidjson/document.h>
#include <rapidjson/stringbuffer.h>
#include <rapidjson/writer.h>
#include <re2/re2.h>
#include "common/logging.h"
#include "util/json-util.h"
using namespace std;
using rapidjson::Document;
using rapidjson::StringBuffer;
using rapidjson::Value;
using rapidjson::Writer;
namespace impala {
static constexpr const char* REDACTED_SQL_STATEMENT = "[REDACTED_SQL_STATEMENT]";
static constexpr int64_t DEFAULT_REDACTION_PROFILE_SIZE_LIMIT_MAX_BYTES =
256L * 1024L * 1024L;
static constexpr int64_t DEFAULT_REDACTION_PROFILE_SIZE_LIMIT_PERCENTAGE = 1;
// Matches hostnames followed by a port (e.g. coordinator.example.com:22000).
static const re2::RE2 HOST_WITH_PORT_RE(
R"(\b([A-Za-z][A-Za-z0-9-]*(?:\.[A-Za-z0-9-]+)*)\:(\d{2,5})\b)");
// Matches the analyzed query subsection embedded in the textual plan.
static const re2::RE2 ANALYZED_RE(
R"(Analyzed query:\s*([\s\S]*?)\n\nF\d+:PLAN FRAGMENT)");
// Matches fully-qualified identifiers with at least one dot (e.g. db.tbl.col).
static const re2::RE2 QUALIFIED_ID_RE(
R"(\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+)\b)");
// Matches table tokens following FROM/JOIN clauses.
static const re2::RE2 FROM_JOIN_TABLE_RE(
R"((?i:\b(?:from|join)\s+([A-Za-z_][A-Za-z0-9_\.]*)\b))");
// Matches snake_case identifiers that are candidates for column tokens.
static const re2::RE2 SNAKE_CASE_ID_RE(
R"(\b([A-Za-z_][A-Za-z0-9_]*_[A-Za-z0-9_]*)\b)");
// Matches e-mail addresses.
static const re2::RE2 EMAIL_RE(
R"(([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}))");
// Matches user/userid key-value pairs like user=alice.
static const re2::RE2 USER_KV_RE(
R"((?i:\b(?:user|uid)=([A-Za-z0-9._@-]+)\b))");
// Matches IPv4 addresses.
static const re2::RE2 IPV4_RE(
R"((\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3})"
R"((?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b))");
// Builds deterministic SQL placeholders while keeping the first token stable.
static string BuildRedactedSqlPlaceholder(size_t index) {
if (index <= 1) return REDACTED_SQL_STATEMENT;
return string("[REDACTED_SQL_STATEMENT_") + std::to_string(index) + "]";
}
// Escapes a string so it can be safely matched within JSON-serialized text.
static string JsonEscapeString(const string_view& input) {
StringBuffer buffer;
Writer<StringBuffer> writer(buffer);
writer.String(input.data(), static_cast<rapidjson::SizeType>(input.size()));
const char* p = buffer.GetString();
const size_t n = buffer.GetSize();
const size_t start = n >= 1 && p[0] == '"' ? 1 : 0;
const size_t end = n >= 1 && p[n - 1] == '"' ? n - 1 : n;
if (end < start) return "";
return string(p + start, end - start);
}
// Collects unique regex matches from text using the first capture group.
static vector<string> CollectRegexMatchesInternal(
const string_view& text, const re2::RE2& pattern) {
unordered_set<string_view> seen_matches;
vector<string> results;
re2::StringPiece remaining(text.data(), text.size());
re2::StringPiece token_match;
while (re2::RE2::FindAndConsume(&remaining, pattern, &token_match)) {
const string_view token(token_match.data(), token_match.size());
if (!seen_matches.insert(token).second) continue;
results.emplace_back(token);
}
return results;
}
// Validates an IPv6 candidate with inet_pton() to avoid false positives like timestamps.
static bool IsValidIpv6Address(const string& candidate) {
unsigned char address[16];
return inet_pton(AF_INET6, candidate.c_str(), address) == 1;
}
// Collects valid IPv6 tokens from text by scanning contiguous hex/colon ranges.
static vector<string> CollectIpv6Matches(const string_view& text) {
unordered_set<string_view> seen_matches;
vector<string> results;
auto is_ipv6_char = [](char c) -> bool {
return std::isxdigit(static_cast<unsigned char>(c)) || c == ':';
};
size_t pos = 0;
while (pos < text.size()) {
if (!is_ipv6_char(text[pos])) {
++pos;
continue;
}
const size_t start = pos;
bool has_colon = false;
while (pos < text.size() && is_ipv6_char(text[pos])) {
if (text[pos] == ':') has_colon = true;
++pos;
}
if (!has_colon) continue;
const string_view candidate(text.data() + start, pos - start);
if (!seen_matches.insert(candidate).second) continue;
string candidate_text(candidate);
if (!IsValidIpv6Address(candidate_text)) continue;
results.emplace_back(move(candidate_text));
}
return results;
}
// Adds a prefixed alias set into a global map while tracking reverse lookups.
template <typename TokenCollection>
static size_t AddAliasesByPrefix(const TokenCollection& tokens, const string_view& prefix,
unordered_map<string, string>* global_aliases,
unordered_map<string, string>* alias_to_original) {
DCHECK(global_aliases != nullptr);
DCHECK(alias_to_original != nullptr);
size_t idx = 0;
size_t inserted_count = 0;
for (const string& token : tokens) {
if (token.empty() || global_aliases->find(token) != global_aliases->end()) continue;
string alias(prefix);
alias.push_back('_');
string idx_string = std::to_string(++idx);
if (idx_string.size() < 3) alias.append(3 - idx_string.size(), '0');
alias.append(idx_string);
global_aliases->emplace(token, alias);
alias_to_original->emplace(move(alias), token);
++inserted_count;
}
return inserted_count;
}
// Materializes replacement map entries as string_views and sorts them
// deterministically for safe replacement order.
static vector<pair<string_view, string_view>> GetSortedReplacementEntries(
const unordered_map<string, string>& replacements) {
vector<pair<string_view, string_view>> entries;
entries.reserve(replacements.size());
for (const auto& entry : replacements) {
entries.emplace_back(entry.first, entry.second);
}
// Sort longer source tokens first so nested/overlapping names are replaced atomically
// (e.g. replacing "table.column" before "table") and avoid partial matches.
sort(entries.begin(), entries.end(),
[](const auto& a, const auto& b) {
if (a.first.size() != b.first.size()) return a.first.size() > b.first.size();
return a.first < b.first;
});
return entries;
}
// Returns true if the character is part of a SQL identifier token.
static inline bool IsIdentifierChar(char c) {
return std::isalnum(static_cast<unsigned char>(c)) || c == '_';
}
// Returns true if text[idx] is the escaped character in a JSON escape sequence.
// Example: for "\\n", idx should point to 'n'.
static inline bool IsEscapedJsonChar(const string_view& text, size_t idx) {
if (idx == 0 || idx >= text.size()) return false;
if (text[idx - 1] != '\\') return false;
switch (text[idx]) {
case '"':
case '\\':
case '/':
case 'b':
case 'f':
case 'n':
case 'r':
case 't':
return true;
case 'u': {
if (idx + 4 >= text.size()) return false;
for (size_t i = idx + 1; i <= idx + 4; ++i) {
if (!std::isxdigit(static_cast<unsigned char>(text[i]))) return false;
}
return true;
}
default:
return false;
}
}
// Applies a full alias map to a text blob in a single left-to-right pass.
static Status ApplyAliasMap(const unordered_map<string, string>& alias_map,
const string_view& text, string* output) {
DCHECK(output != nullptr);
if (alias_map.empty()) {
*output = string(text);
return Status::OK();
}
const auto entries = GetSortedReplacementEntries(alias_map);
string result;
result.reserve(text.size());
size_t i = 0;
while (i < text.size()) {
bool matched = false;
for (const auto& [from, to] : entries) {
if (from.empty() || text.size() - i < from.size()) continue;
if (text.compare(i, from.size(), from) != 0) continue;
bool start_boundary_ok = (i == 0) || !IsIdentifierChar(text[i - 1]);
if (!start_boundary_ok && IsEscapedJsonChar(text, i - 1)) {
start_boundary_ok = true;
}
const size_t after_idx = i + from.size();
const bool end_boundary_ok =
(after_idx == text.size()) || !IsIdentifierChar(text[after_idx]);
if (!start_boundary_ok || !end_boundary_ok) continue;
result.append(to.data(), to.size());
i += from.size();
matched = true;
break;
}
if (!matched) {
result.push_back(text[i]);
++i;
}
}
*output = move(result);
return Status::OK();
}
// Extracts the analyzed query section from a plan text block.
static string ExtractAnalyzedQueryFromPlanText(const string_view& plan_text) {
re2::StringPiece input(plan_text.data(), plan_text.size());
string extracted_query;
if (!re2::RE2::PartialMatch(input, ANALYZED_RE, &extracted_query)) {
return string();
}
return boost::algorithm::trim_copy(extracted_query);
}
// Parses an info_strings entry and returns validated key/value pointers.
static pair<const char*, const char*> ParseInfoStringEntry(const Value& entry) {
if (!entry.IsObject() || !entry.HasMember("key") || !entry["key"].IsString()
|| !entry.HasMember("value") || !entry["value"].IsString()) {
return {nullptr, nullptr};
}
return {entry["key"].GetString(), entry["value"].GetString()};
}
// Recursive helper used by CollectIdentifierContextsFromJsonProfile().
static void CollectIdentifierContextsFromJsonProfileImpl(
const Value& node, vector<string>* contexts) {
DCHECK(contexts != nullptr);
if (node.IsArray()) {
for (const auto& item : node.GetArray()) {
CollectIdentifierContextsFromJsonProfileImpl(item, contexts);
}
return;
}
if (!node.IsObject()) return;
for (auto it = node.MemberBegin(); it != node.MemberEnd(); ++it) {
if (strcmp(it->name.GetString(), "info_strings") == 0) {
if (!it->value.IsArray()) continue;
for (const auto& entry : it->value.GetArray()) {
const auto [key, value] = ParseInfoStringEntry(entry);
if (key == nullptr || value == nullptr) continue;
if (strcmp(key, "Select Columns") == 0 || strcmp(key, "Where Columns") == 0
|| strcmp(key, "Join Columns") == 0
|| strcmp(key, "Analyzed query") == 0) {
if (*value != '\0') contexts->emplace_back(value);
} else if (strcmp(key, "Plan") == 0) {
string analyzed_query = ExtractAnalyzedQueryFromPlanText(value);
if (!analyzed_query.empty()) contexts->emplace_back(move(analyzed_query));
}
}
continue;
}
CollectIdentifierContextsFromJsonProfileImpl(it->value, contexts);
}
}
// Recursively collects profile text contexts that contain query identifiers.
static vector<string> CollectIdentifierContextsFromJsonProfile(const Value& node) {
vector<string> contexts;
CollectIdentifierContextsFromJsonProfileImpl(node, &contexts);
return contexts;
}
// Recursive helper used by CollectInfoStringValuesByKeys().
static void CollectInfoStringValuesByKeysImpl(const Value& node,
const unordered_set<string_view>& target_keys, vector<string>* values) {
DCHECK(values != nullptr);
if (node.IsArray()) {
for (const auto& item : node.GetArray()) {
CollectInfoStringValuesByKeysImpl(item, target_keys, values);
}
return;
}
if (!node.IsObject()) return;
for (auto it = node.MemberBegin(); it != node.MemberEnd(); ++it) {
if (strcmp(it->name.GetString(), "info_strings") == 0) {
if (!it->value.IsArray()) continue;
for (const auto& entry : it->value.GetArray()) {
const auto [key, value] = ParseInfoStringEntry(entry);
if (key == nullptr || value == nullptr) continue;
if (target_keys.find(key) == target_keys.end()) continue;
if (*value != '\0') values->emplace_back(value);
}
continue;
}
CollectInfoStringValuesByKeysImpl(it->value, target_keys, values);
}
}
// Recursively collects info_strings values whose keys match the target set.
static vector<string> CollectInfoStringValuesByKeys(
const Value& node, const unordered_set<string_view>& target_keys) {
vector<string> values;
CollectInfoStringValuesByKeysImpl(node, target_keys, &values);
return values;
}
// Extracts hostnames from specific profile sections that include host:port lists.
static vector<string> ExtractHostTokensFromPerHostSections(const Value& source_json) {
static const unordered_set<string_view> HOST_SECTION_KEYS = {
"Per Host Min Memory Reservation"};
vector<string> host_tokens;
vector<string> host_sections =
CollectInfoStringValuesByKeys(source_json, HOST_SECTION_KEYS);
for (const string& section : host_sections) {
vector<string> section_hosts =
CollectRegexMatchesInternal(section, HOST_WITH_PORT_RE);
host_tokens.insert(host_tokens.end(), make_move_iterator(section_hosts.begin()),
make_move_iterator(section_hosts.end()));
}
return host_tokens;
}
// Extracts candidate table and column tokens from SQL-like context strings.
static pair<vector<string>, vector<string>> ExtractTableAndColumnTokens(
const vector<string>& contexts) {
unordered_set<string_view> table_set;
unordered_set<string_view> column_set;
unordered_set<string_view> table_leaf_set;
vector<string> table_tokens;
vector<string> column_tokens;
for (const string& context : contexts) {
re2::StringPiece qualified_remaining(context.data(), context.size());
re2::StringPiece qualified_match;
while (re2::RE2::FindAndConsume(&qualified_remaining, QUALIFIED_ID_RE,
&qualified_match)) {
const string_view fq(qualified_match.data(), qualified_match.size());
const size_t last_dot = fq.rfind('.');
if (last_dot == string_view::npos || last_dot == 0
|| last_dot + 1 >= fq.size()) {
continue;
}
const string_view table = fq.substr(0, last_dot);
const string_view col = fq.substr(last_dot + 1);
if (table_set.insert(table).second) table_tokens.emplace_back(table);
if (column_set.insert(col).second) column_tokens.emplace_back(col);
const size_t table_last_dot = table.rfind('.');
table_leaf_set.insert(
table_last_dot == string_view::npos ? table : table.substr(table_last_dot + 1));
}
re2::StringPiece table_remaining(context.data(), context.size());
re2::StringPiece table_match;
while (re2::RE2::FindAndConsume(&table_remaining, FROM_JOIN_TABLE_RE, &table_match)) {
const string_view table(table_match.data(), table_match.size());
if (table_set.insert(table).second) table_tokens.emplace_back(table);
const size_t table_last_dot = table.rfind('.');
table_leaf_set.insert(
table_last_dot == string_view::npos ? table : table.substr(table_last_dot + 1));
}
re2::StringPiece snake_case_remaining(context.data(), context.size());
re2::StringPiece snake_case_match;
while (re2::RE2::FindAndConsume(&snake_case_remaining, SNAKE_CASE_ID_RE,
&snake_case_match)) {
const string_view token(snake_case_match.data(), snake_case_match.size());
if (table_set.find(token) != table_set.end()) continue;
if (table_leaf_set.find(token) != table_leaf_set.end()) continue;
if (column_set.insert(token).second) column_tokens.emplace_back(token);
}
}
return {table_tokens, column_tokens};
}
// Redacts sensitive profile values and optionally records alias-to-original mappings.
static Status RedactQueryProfileWithAliases(const string_view& profile_text,
const Value& source_json,
unordered_map<string, string>& alias_to_original, string* redacted) {
DCHECK(redacted != nullptr);
unordered_map<string, string> global_aliases;
const vector<string> sql_statements =
CollectInfoStringValuesByKeys(source_json, {"Sql Statement"});
for (size_t idx = 0; idx < sql_statements.size(); ++idx) {
const string& sql_statement = sql_statements[idx];
if (sql_statement.empty()) continue;
const string placeholder = BuildRedactedSqlPlaceholder(idx + 1);
const string escaped_sql = JsonEscapeString(sql_statement);
global_aliases.emplace(escaped_sql, placeholder);
global_aliases.emplace(sql_statement, placeholder);
// Unredaction runs against serialized JSON text, so restore escaped representation.
alias_to_original.emplace(placeholder, escaped_sql);
}
vector<string> user_values = CollectInfoStringValuesByKeys(
source_json, {"User", "Connected User", "Delegated User"});
vector<string> emails = CollectRegexMatchesInternal(profile_text, EMAIL_RE);
vector<string> user_kvs = CollectRegexMatchesInternal(profile_text, USER_KV_RE);
user_values.insert(user_values.end(), make_move_iterator(emails.begin()),
make_move_iterator(emails.end()));
user_values.insert(user_values.end(), make_move_iterator(user_kvs.begin()),
make_move_iterator(user_kvs.end()));
const size_t username_count =
AddAliasesByPrefix(user_values, "user", &global_aliases, &alias_to_original);
vector<string> all_ip_tokens = CollectRegexMatchesInternal(profile_text, IPV4_RE);
vector<string> ipv6_tokens = CollectIpv6Matches(profile_text);
all_ip_tokens.insert(all_ip_tokens.end(), make_move_iterator(ipv6_tokens.begin()),
make_move_iterator(ipv6_tokens.end()));
const size_t ip_count =
AddAliasesByPrefix(all_ip_tokens, "ip", &global_aliases, &alias_to_original);
vector<string> contexts = CollectIdentifierContextsFromJsonProfile(source_json);
auto [table_tokens, column_tokens] = ExtractTableAndColumnTokens(contexts);
const size_t table_count =
AddAliasesByPrefix(table_tokens, "table", &global_aliases, &alias_to_original);
const size_t column_count =
AddAliasesByPrefix(column_tokens, "column", &global_aliases, &alias_to_original);
const vector<string> host_tokens = ExtractHostTokensFromPerHostSections(source_json);
const size_t host_count =
AddAliasesByPrefix(host_tokens, "host", &global_aliases, &alias_to_original);
Status status = ApplyAliasMap(global_aliases, profile_text, redacted);
if (!status.ok()) return status;
VLOG(1) << "Query profile redaction complete. Extracted items : "
<< "SQL statements: " << sql_statements.size() << ", "
<< "Users: " << username_count << ", "
<< "IPs: " << ip_count << ", "
<< "Tables: " << table_count << ", "
<< "Columns: " << column_count << ", "
<< "Hosts: " << host_count << ". "
<< "Total aliases generated: " << alias_to_original.size();
return Status::OK();
}
// Restores aliased placeholders in text using a reverse alias map.
static string UnredactTextWithAliases(
const string_view& text, const unordered_map<string, string>& alias_to_original) {
if (alias_to_original.empty()) return string(text);
string unredacted;
Status status = ApplyAliasMap(alias_to_original, text, &unredacted);
if (!status.ok()) {
LOG(WARNING) << "Failed to unredact profile text: " << status.GetDetail();
return string(text);
}
return unredacted;
}
static Status RedactSourceJson(const Value& source_json, int64_t profile_size_limit_bytes,
unordered_map<string, string>* alias_to_original, Document* redacted_profile_json) {
DCHECK(alias_to_original != nullptr);
DCHECK(redacted_profile_json != nullptr);
const string profile_text = JsonToString(source_json);
if (profile_text.size() > static_cast<size_t>(profile_size_limit_bytes)) {
LOG(WARNING) << "Profile redaction failed because input size " << profile_text.size()
<< " bytes exceeds configured profile size limit "
<< profile_size_limit_bytes << " bytes";
return Status("Query profile size exceeds configured redaction profile size limit");
}
alias_to_original->clear();
string redacted_profile_text;
RETURN_IF_ERROR(RedactQueryProfileWithAliases(
profile_text, source_json, *alias_to_original, &redacted_profile_text));
redacted_profile_json->Parse(redacted_profile_text.data(),
static_cast<rapidjson::SizeType>(redacted_profile_text.size()));
if (redacted_profile_json->HasParseError()) {
LOG(WARNING) << "Profile redaction failed while parsing redacted output JSON with "
<< "RapidJSON error code: "
<< redacted_profile_json->GetParseError() << " at offset: "
<< redacted_profile_json->GetErrorOffset();
return Status("Redacted query profile must be a valid JSON object");
}
if (!redacted_profile_json->IsObject()) {
LOG(WARNING)
<< "Profile redaction failed because redacted JSON root is not an object";
return Status("Redacted query profile must be a valid JSON object");
}
return Status::OK();
}
QueryProfileRedactor::QueryProfileRedactor(int64_t profile_size_limit_bytes)
: profile_size_limit_bytes_(
profile_size_limit_bytes > 0
? profile_size_limit_bytes
: ComputeDefaultProfileSizeLimitBytes(
DEFAULT_REDACTION_PROFILE_SIZE_LIMIT_MAX_BYTES,
DEFAULT_REDACTION_PROFILE_SIZE_LIMIT_PERCENTAGE)) {}
Status QueryProfileRedactor::Redact(const Value& profile_json) {
DCHECK(redacted_profile_json_.IsNull()) << "Cannot call Redact function more than once";
if (!profile_json.IsObject()) {
LOG(WARNING)
<< "Profile redaction failed because input JSON root is not an object";
return Status("Query profile input must be a valid JSON object");
}
return RedactSourceJson(profile_json, profile_size_limit_bytes_,
&alias_to_original_, &redacted_profile_json_);
}
string QueryProfileRedactor::Unredact(const string_view& text) const {
DCHECK(redacted_profile_json_.IsObject())
<< "Redact function has not been called, no profile to unredact";
return UnredactTextWithAliases(text, alias_to_original_);
}
namespace test {
vector<string> CollectRegexMatches(string_view text, const re2::RE2& pattern) {
return CollectRegexMatchesInternal(text, pattern);
}
} // namespace test
} // namespace impala