Skip to content

Commit

Permalink
Fix extension columns obfuscation (#15)
Browse files Browse the repository at this point in the history
* Remove DAX escape chars from obfuscation reserved
* Add test for escaped quotation mark in string literal
* Bump version 0.4-beta
  • Loading branch information
albertospelta authored Mar 4, 2024
1 parent b305f72 commit 99c237b
Show file tree
Hide file tree
Showing 13 changed files with 248 additions and 92 deletions.
Binary file removed assets/ObfuscatorTest.pbix
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -35,36 +35,43 @@ internal string DeobfuscateExpression(string expression)
case DaxToken.DELIMITED_COMMENT:
tokenText = _dictionary.GetValue(tokenText);
break;
case DaxToken.COLUMN_OR_MEASURE when token.IsReservedExtensionColumn():
case DaxToken.COLUMN_OR_MEASURE when token.IsReservedTokenName():
tokenText = token.Replace(expression, tokenText);
break;
case DaxToken.STRING_LITERAL when token.IsExtensionColumnName():
tokenText = ReplaceExtensionColumnName(token);
break;
case DaxToken.TABLE_OR_VARIABLE when token.IsVariable():
case DaxToken.TABLE:
case DaxToken.COLUMN_OR_MEASURE:
case DaxToken.STRING_LITERAL:
case DaxToken.UNTERMINATED_COLREF:
case DaxToken.UNTERMINATED_TABLEREF:
case DaxToken.UNTERMINATED_STRING:
tokenText = token.Replace(expression, _dictionary.GetValue(tokenText));
{
if (token.Text.IsFullyQualifiedColumnName())
{
var value = DeobfuscateFullyQualifiedColumnName(tokenText).EscapeDax(token.Type);
tokenText = token.Replace(expression, value);
}
else
{
var value = _dictionary.GetValue(tokenText).EscapeDax(token.Type);
tokenText = token.Replace(expression, value);
}
}
break;
}

builder.Append(tokenText);
}

return builder.ToString();
}

string ReplaceExtensionColumnName(DaxToken token)
{
var (tableName, columnName) = token.GetExtensionColumnNameParts();
tableName = _dictionary.GetValue(tableName);
columnName = _dictionary.GetValue(columnName);
internal string DeobfuscateFullyQualifiedColumnName(string value)
{
var (table, column) = value.GetFullyQualifiedColumnNameParts();
var tableName = _dictionary.GetValue(table);
var columnName = _dictionary.GetValue(column);

var value = $"{tableName.DaxEscape()}[{columnName.DaxEscape()}]";
return token.Replace(expression, value, escape: true);
}
return $"{tableName}[{columnName}]";
}
}
12 changes: 10 additions & 2 deletions src/Dax.Vpax.Obfuscator/DaxModelDeobfuscator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,16 @@ private void Deobfuscate(DaxName name)
{
if (string.IsNullOrWhiteSpace(name?.Name)) return;

var value = _dictionary.GetValue(name!.Name);
name.Name = value;
if (name!.Name.IsFullyQualifiedColumnName())
{
var value = DeobfuscateFullyQualifiedColumnName(name!.Name);
name.Name = value;
}
else
{
var value = _dictionary.GetValue(name!.Name);
name.Name = value;
}
}

private void Deobfuscate(DaxNote note)
Expand Down
37 changes: 22 additions & 15 deletions src/Dax.Vpax.Obfuscator/DaxModelObfuscator.ObfuscateExpression.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using Dax.Vpax.Obfuscator.Extensions;
using System.Text;
using System.Text;
using Dax.Tokenizer;
using Dax.Vpax.Obfuscator.Extensions;

namespace Dax.Vpax.Obfuscator;

Expand Down Expand Up @@ -35,36 +35,43 @@ internal string ObfuscateExpression(string expression)
case DaxToken.DELIMITED_COMMENT:
tokenText = ObfuscateText(new DaxText(tokenText)).ObfuscatedValue;
break;
case DaxToken.COLUMN_OR_MEASURE when token.IsReservedExtensionColumn():
case DaxToken.COLUMN_OR_MEASURE when token.IsReservedTokenName():
tokenText = token.Replace(expression, tokenText);
break;
case DaxToken.STRING_LITERAL when token.IsExtensionColumnName():
tokenText = ReplaceExtensionColumnName(token);
break;
case DaxToken.TABLE_OR_VARIABLE when token.IsVariable():
case DaxToken.TABLE:
case DaxToken.COLUMN_OR_MEASURE:
case DaxToken.STRING_LITERAL:
case DaxToken.UNTERMINATED_COLREF:
case DaxToken.UNTERMINATED_TABLEREF:
case DaxToken.UNTERMINATED_STRING:
tokenText = token.Replace(expression, ObfuscateText(new DaxText(tokenText)));
{
if (token.Text.IsFullyQualifiedColumnName())
{
var value = ObfuscateFullyQualifiedColumnName(tokenText).EscapeDax(token.Type);
tokenText = token.Replace(expression, value);
}
else
{
var value = ObfuscateText(new DaxText(tokenText)).ObfuscatedValue.EscapeDax(token.Type);
tokenText = token.Replace(expression, value);
}
}
break;
}

builder.Append(tokenText);
}

return builder.ToString();
}

string ReplaceExtensionColumnName(DaxToken token)
{
var (tableName, columnName) = token.GetExtensionColumnNameParts();
var tableText = ObfuscateText(new DaxText(tableName));
var columnText = ObfuscateText(new DaxText(columnName));
internal string ObfuscateFullyQualifiedColumnName(string value)
{
var (table, column) = value.GetFullyQualifiedColumnNameParts(obfuscating: true);
var tableName = ObfuscateText(new DaxText(table)).ObfuscatedValue;
var columnName = ObfuscateText(new DaxText(column)).ObfuscatedValue;

var value = $"{tableText.ObfuscatedValue.DaxEscape()}[{columnText.ObfuscatedValue.DaxEscape()}]";
return token.Replace(expression, value, escape: true);
}
return $"{tableName}[{columnName}]";
}
}
24 changes: 17 additions & 7 deletions src/Dax.Vpax.Obfuscator/DaxModelObfuscator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ private void ObfuscateIdentifiers(Column column)

private void ObfuscateIdentifiers(Measure measure)
{
var measureText = Obfuscate(measure.MeasureName) ?? throw new InvalidOperationException($"The measure name is not valid [{measure.MeasureName}].");
var name = measure.MeasureName.Name;
var obfuscatedName = Obfuscate(measure.MeasureName) ?? throw new InvalidOperationException($"The measure name is not valid [{name}].");
CreateKpiMeasure(measure.KpiTargetExpression, "Goal");
CreateKpiMeasure(measure.KpiStatusExpression, "Status");
CreateKpiMeasure(measure.KpiTrendExpression, "Trend");
Expand All @@ -64,8 +65,8 @@ void CreateKpiMeasure(DaxExpression kpi, string type)
{
if (string.IsNullOrWhiteSpace(kpi?.Expression)) return;

var text = new DaxText($"_{measureText.Value} {type}");
text.ObfuscatedValue = $"_{measureText.ObfuscatedValue} {type}";
var text = new DaxText($"_{name} {type}");
text.ObfuscatedValue = $"_{obfuscatedName} {type}";

// It may already exist in case of incremental obfuscation
if (Texts.IsIncrementalObfuscation && Texts.Contains(text))
Expand Down Expand Up @@ -151,13 +152,22 @@ private void Obfuscate(TablePermission tablePermission)
Obfuscate(tablePermission.FilterExpression);
}

private DaxText? Obfuscate(DaxName name)
private string? Obfuscate(DaxName name)
{
if (string.IsNullOrWhiteSpace(name?.Name)) return null;

var text = ObfuscateText(new DaxText(name!.Name));
name.Name = text.ObfuscatedValue;
return text;
if (name!.Name.IsFullyQualifiedColumnName())
{
var value = ObfuscateFullyQualifiedColumnName(name!.Name);
name.Name = value;
}
else
{
var text = ObfuscateText(new DaxText(name!.Name));
name.Name = text.ObfuscatedValue;
}

return name.Name;
}

private void Obfuscate(DaxNote note)
Expand Down
8 changes: 4 additions & 4 deletions src/Dax.Vpax.Obfuscator/DaxTextObfuscator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,11 @@ private static bool IsReservedChar(char @char)
{
// Reserved characters are preserved during obfuscation

switch (@char) {
case '-': // single-line comment char
switch (@char)
{
case ReservedChar_Minus: // single-line comment char
case '/': // multi-line comment char
case '*': // multi-line comment char
case ']': // square bracket escape char e.g. Sales[Rate[%]]]
case '"': // quotation mark escape char e.g. VAR __quotationMarkChar = """"
case '\n': // line feed char e.g. in multi-line comments
case '\r': // carriage return char e.g. in multi-line comments
return true;
Expand All @@ -79,6 +78,7 @@ private static bool IsReservedChar(char @char)
return false;
}

internal const char ReservedChar_Minus = '-';
/// <summary>
/// CALENDAR() [Date] extension column.
/// </summary>
Expand Down
45 changes: 19 additions & 26 deletions src/Dax.Vpax.Obfuscator/Extensions/DaxTokenExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ namespace Dax.Vpax.Obfuscator.Extensions;

internal static class DaxTokenExtensions
{
public static bool IsExtensionColumnName(this DaxToken token)
=> token.Type == DaxToken.STRING_LITERAL && token.Text.EndsWith("]") && token.Text.IndexOf('[') > 0;

public static bool IsVariable(this DaxToken token)
=> token.Type == DaxToken.TABLE_OR_VARIABLE && !IsFunction(token);
{
Debug.Assert(token.Type == DaxToken.TABLE_OR_VARIABLE);
return token.Type == DaxToken.TABLE_OR_VARIABLE && !IsFunction(token);
}

public static bool IsFunction(this DaxToken token)
{
if (token.Type != DaxToken.TABLE_OR_VARIABLE) return false;
Debug.Assert(token.Type == DaxToken.TABLE_OR_VARIABLE);

var current = token.Next;
while (current != null && current.CommentOrWhitespace)
Expand All @@ -22,9 +22,9 @@ public static bool IsFunction(this DaxToken token)
return current != null && current.Type == DaxToken.OPEN_PARENS;
}

public static bool IsReservedExtensionColumn(this DaxToken token)
public static bool IsReservedTokenName(this DaxToken token)
{
if (token.Type != DaxToken.COLUMN_OR_MEASURE) return false;
Debug.Assert(token.Type == DaxToken.COLUMN_OR_MEASURE);

if (token.Text.StartsWith(DaxTextObfuscator.ReservedToken_Value, StringComparison.OrdinalIgnoreCase))
{
Expand All @@ -44,28 +44,21 @@ public static bool IsReservedExtensionColumn(this DaxToken token)
return false;
}

public static (string tableName, string columnName) GetExtensionColumnNameParts(this DaxToken token)
{
Debug.Assert(token.IsExtensionColumnName());

var openIndex = token.Text.IndexOf('[');
var closeIndex = token.Text.LastIndexOf(']');
var tableName = token.Text.Substring(0, openIndex);
var columnName = token.Text.Substring(openIndex + 1, closeIndex - openIndex - 1);
return (tableName, columnName);
}

public static string Replace(this DaxToken token, string expression, DaxText text)
=> Replace(token, expression, text.ObfuscatedValue);

public static string Replace(this DaxToken token, string expression, string value, bool escape = false)
public static string Replace(this DaxToken token, string expression, string value)
{
var substring = expression.Substring(token.StartIndex, token.StopIndex - token.StartIndex + 1);
var tokenText = escape ? token.Text.DaxEscape() : token.Text;

if (substring.IndexOf(tokenText, StringComparison.Ordinal) == -1)
throw new InvalidOperationException($"Failed to replace token >> {token.Type} | {substring} | {tokenText} | {value}");
switch (token.Type)
{
case DaxToken.TABLE:
case DaxToken.STRING_LITERAL:
case DaxToken.COLUMN_OR_MEASURE:
return string.Concat(substring[0], value, substring[substring.Length - 1]);
case DaxToken.UNTERMINATED_TABLEREF:
case DaxToken.UNTERMINATED_COLREF:
return string.Concat(substring[0], value);
}

return substring.Replace(tokenText, value);
return substring.Replace(token.Text, value);
}
}
69 changes: 66 additions & 3 deletions src/Dax.Vpax.Obfuscator/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,70 @@
namespace Dax.Vpax.Obfuscator.Extensions;
using System.Diagnostics;
using Dax.Tokenizer;

namespace Dax.Vpax.Obfuscator.Extensions;

internal static class StringExtensions
{
public static string DaxEscape(this string value)
=> value.Replace("\"", "\"\"").Replace("'", "''");
public static bool IsFullyQualifiedColumnName(this string value)
=> value.TrimEnd().EndsWith("]") && value.IndexOf('[') > 0;

public static (string table, string column) GetFullyQualifiedColumnNameParts(this string value, bool obfuscating = false)
{
Debug.Assert(IsFullyQualifiedColumnName(value));

var openIndex = value.IndexOf('[');
var closeIndex = value.LastIndexOf(']');
var table = value.Substring(0, openIndex);
var column = value.Substring(openIndex + 1, closeIndex - openIndex - 1);

if (obfuscating)
{
table = table.Trim(); // remove any leading or trailing whitespace first

if (IsSquareBraketsRequired(table, column))
{
// Since the plaintext value contains at least one character that results in a fully qualified
// column name that requires square brackets, then, in order to preserve the same semantics
// we must add at least a single char of the same type to the obfuscated value as well.
table = $"{DaxTextObfuscator.ReservedChar_Minus}{table}";
}
}

return (table, column);

static bool IsSquareBraketsRequired(string table, string column)
{
if (table.Length > 0)
{
if ("012345679".Contains(table[0]))
return true; // Table name start with a digit

if (table.Any((c) => c != '_' && !DaxTextObfuscator.CharSet.Contains(c)))
return true; // Table name contains any non-alphabetic characters except for the underscore
}

return column.Contains(']');
}
}

public static string EscapeDax(this string value, int tokenType)
{
// See <TOKEN_TYPE>_action() methods in Dax.Tokenizer.DaxLexer class

switch (tokenType)
{
case DaxToken.TABLE:
case DaxToken.UNTERMINATED_TABLEREF:
return value.Replace("'", "''");

case DaxToken.STRING_LITERAL:
return value.Replace("\"", "\"\"");

case DaxToken.COLUMN_OR_MEASURE:
case DaxToken.UNTERMINATED_COLREF:
return value.Replace("]", "]]");
}

return value;
}
}
2 changes: 1 addition & 1 deletion src/Dax.Vpax.Obfuscator/version.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
"version": "0.3-beta",
"version": "0.4-beta",
"nugetPackageVersion": {
"semVer": 2.0
},
Expand Down
Loading

0 comments on commit 99c237b

Please sign in to comment.