Skip to content

Commit

Permalink
Add more debugging info
Browse files Browse the repository at this point in the history
Print the each expression evaluation result; e.g., with chai:

      2) A valid set of security rules and data can have write errors:
         AssertionError: Expected a user authenticated via Password Login to be able to write true to users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent, but the rules denied the write.
    /users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3: write "root.child('users').child(auth.uid).child('king').val() === true"
        with [
          auth = {"uid":"password:500f6e96-92c6-4f60-ad5d-207253aee4d3","id":1,"provider":"password"}
          auth.uid = "password:500f6e96-92c6-4f60-ad5d-207253aee4d3"
          root = {"path":"","exists":true}
          root.child('users') = {"path":"users","exists":true}
          root.child('users').child(auth.uid) = {"path":"users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3","exists":true}
          root.child('users').child(auth.uid).child('king') = {"path":"users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/king","exists":false}
          root.child('users').child(auth.uid).child('king').val() = null
        ] => false
    /users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent: validate "data.parent().child('on-fire').val() === false"
        with [
          data = {"path":"users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/innocent","exists":false}
          data.parent() = {"path":"users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3","exists":true}
          data.parent().child('on-fire') = {"path":"users/password:500f6e96-92c6-4f60-ad5d-207253aee4d3/on-fire","exists":false}
          data.parent().child('on-fire').val() = null
        ] => false
    No .write rule allowed the operation.
    No .validate rule allowed the operation.
    write was denied.
          at Assertion.<anonymous> (plugins/chai.js:98:12)
          at Assertion.ctx.(anonymous function) [as path] (node_modules/chai/lib/chai/utils/addMethod.js:41:25)
          at Context.<anonymous> (docs/chai/examples/failing.js:26:9)
  • Loading branch information
dinoboff committed Nov 14, 2016
1 parent b81e30d commit 13ae7ba
Show file tree
Hide file tree
Showing 13 changed files with 220 additions and 64 deletions.
1 change: 1 addition & 0 deletions docs/chai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ mocha examples/<name of example>.js
- `chaiTargaryen.chai`: The plugin object. Load this using `chai.use(chaiTargaryen.chai)` before running any tests.
- `chaiTargaryen.setFirebaseData(data)`: Set the mock data to be used as the existing Firebase data, i.e., `root` and `data`.
- `chaiTargaryen.setFirebaseRules(rules)`: Set the security rules to be tested against. Throws if there's a syntax error in your rules.
- `chaiTargaryen.setDebug(level)`: Set the similation result debug level: `0` to turn it off, `1` (default) to print each rule evaluation, `2` to print each rule and expression evaluation.
- `chaiTargaryen.users`: A set of authentication objects you can use as the subject of the assertions. Has the following keys:
- `unauthenticated`: an unauthenticated user, i.e., `auth === null`.
- `anonymous`: a user authenticated using Firebase anonymous sessions.
Expand Down
1 change: 1 addition & 0 deletions docs/chai/examples/failing.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ describe('A valid set of security rules and data', function() {
before(function() {
targaryen.setFirebaseData(require('./data.json'));
targaryen.setFirebaseRules(require('./rules.json'));
targaryen.setDebug(targaryen.VERBOSE);
});

it('can have read errors', function() {
Expand Down
1 change: 1 addition & 0 deletions docs/jasmine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ jasmine spec/security/<name of example>.js
- `jasmineTargaryen.matchers`: The plugin object. Load this using `jasmine.addMatchers(jasmineTargaryen.matchers)` before running any tests.
- `jasmineTargaryen.setFirebaseData(data)`: Set the mock data to be used as the existing Firebase data, i.e., `root` and `data`.
- `jasmineTargaryen.setFirebaseRules(rules)`: Set the security rules to be tested against. Throws if there's a syntax error in your rules.
- `jasmineTargaryen.setDebug(level)`: Set the similation result debug level: `0` to turn it off, `1` (default) to print each rule evaluation, `2` to print each rule and expression evaluation.
- `jasmineTargaryen.users`: A set of authentication objects you can use as the subject of the assertions. Has the following keys:
- `unauthenticated`: an unauthenticated user, i.e., `auth === null`.
- `anonymous`: a user authenticated using Firebase anonymous sessions.
Expand Down
1 change: 1 addition & 0 deletions docs/jasmine/examples/spec/security/failing.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const users = targaryen.users;

targaryen.setFirebaseData(require('./data.json'));
targaryen.setFirebaseRules(require('./rules.json'));
targaryen.setDebug(targaryen.VERBOSE);

describe('A valid set of security rules and data', function() {

Expand Down
11 changes: 10 additions & 1 deletion lib/database.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,14 +318,23 @@ class RuleDataSnapshot {
}

/**
* Return the snapshot path.
* Returns the snapshot path.
*
* @return {string}
*/
toString() {
return this[pathKey];
}

/**
* Returns a representation of the snapshot for JSON.stringify.
*
* @return {{path: string, exists: boolean}}
*/
toJSON() {
return {path: this[pathKey], exists: this.exists()};
}

}

/**
Expand Down
80 changes: 55 additions & 25 deletions lib/parser/rule.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,48 +62,78 @@ function Rule(ruleStr, wildchildren, isWrite) {

}

Rule.prototype.evaluate = function(state, skipOnNoValue) {
Rule.prototype.evaluation = function(state, skipOnNoValue) {
return new RuleEvaluator(state).evaluate(this._ast, skipOnNoValue);
};

Rule.prototype.evaluate = function(state, skipOnNoValue) {
return this.evaluation(state, skipOnNoValue).result;
};

Rule.prototype.toString = function() {
return this._str;
};

function RuleEvaluator(state) {
this.state = state;
this.expressions = new Map();
this.result = undefined;
}

RuleEvaluator.prototype.evaluate = function(node, skipOnNoValue) {
RuleEvaluator.prototype.evaluate = function(root, skipOnNoValue) {
this.result = this._eval(root, skipOnNoValue);

Object.freeze(this);

return this;
};

RuleEvaluator.prototype._eval = function(node, skipOnNoValue) {
if (skipOnNoValue && (typeof node.value === 'undefined' || node.value === null)) {
return true;
}

let value;

switch (node.type) {
case 'ExpressionStatement':
return this.evaluate(node.expression);
value = this._eval(node.expression, skipOnNoValue);
break;
case 'LogicalExpression':
return this._evalLogicalExpression(node);
value = this._evalLogicalExpression(node);
break;
case 'UnaryExpression':
return this._evalUnaryExpression(node);
value = this._evalUnaryExpression(node);
break;
case 'BinaryExpression':
return this._evalBinaryExpression(node);
value = this._evalBinaryExpression(node);
break;
case 'ConditionalExpression':
return this._evalConditionalExpression(node);
value = this._evalConditionalExpression(node);
break;
case 'MemberExpression':
return this._evalMemberExpression(node);
value = this._evalMemberExpression(node);
break;
case 'CallExpression':
return this._evalCallExpression(node);
value = this._evalCallExpression(node);
break;
case 'ArrayExpression':
return this._evalArrayExpression(node);
value = this._evalArrayExpression(node);
break;
case 'Literal':
return this._evalLiteral(node);
value = this._evalLiteral(node);
break;
case 'Identifier':
return this._evalIdentifier(node);
value = this._evalIdentifier(node);
break;
default:
throw new Error('Unexpected ' + node.type);
}

this.expressions.set(`${node.type}:${node.original}`, {source: node.original, type: node.type, value});

return value;

};

RuleEvaluator.prototype._evalIdentifier = function(node) {
Expand All @@ -122,9 +152,9 @@ RuleEvaluator.prototype._evalLogicalExpression = function(node) {

switch (node.operator) {
case '&&':
return this.evaluate(node.left) && this.evaluate(node.right);
return this._eval(node.left) && this._eval(node.right);
case '||':
return this.evaluate(node.left) || this.evaluate(node.right);
return this._eval(node.left) || this._eval(node.right);
default:
throw nodeError(node, 'unknown logical operator ' + node.operator);
}
Expand All @@ -133,8 +163,8 @@ RuleEvaluator.prototype._evalLogicalExpression = function(node) {

RuleEvaluator.prototype._evalBinaryExpression = function(node) {

const left = this.evaluate(node.left);
const right = this.evaluate(node.right);
const left = this._eval(node.left);
const right = this._eval(node.right);

switch (node.operator) {
case '+':
Expand Down Expand Up @@ -171,25 +201,25 @@ RuleEvaluator.prototype._evalUnaryExpression = function(node) {

switch (node.operator) {
case '!':
return !this.evaluate(node.argument);
return !this._eval(node.argument);
case '-':
return -this.evaluate(node.argument);
return -this._eval(node.argument);
default:
throw nodeError(node, 'unknown unary operator ' + node.operator);
}

};

RuleEvaluator.prototype._evalConditionalExpression = function(node) {
const test = this.evaluate(node.test);
const test = this._eval(node.test);

return test ? this.evaluate(node.consequent) : this.evaluate(node.alternate);
return test ? this._eval(node.consequent) : this._eval(node.alternate);
};

RuleEvaluator.prototype._evalMemberExpression = function(node) {

const object = this.evaluate(node.object);
const key = node.computed ? this.evaluate(node.property) : node.property.name;
const object = this._eval(node.object);
const key = node.computed ? this._eval(node.property) : node.property.name;
const isPatched = typeof object === 'string' && stringMethods[key];
const property = isPatched ? stringMethods[key] : (object && object[key]);

Expand All @@ -207,8 +237,8 @@ RuleEvaluator.prototype._evalMemberExpression = function(node) {

RuleEvaluator.prototype._evalCallExpression = function(node) {

const methodArguments = node.arguments.map(arg => this.evaluate(arg));
const method = this.evaluate(node.callee);
const methodArguments = node.arguments.map(arg => this._eval(arg));
const method = this._eval(node.callee);

if (typeof method !== 'function') {
throw nodeError(node, method + ' is not a function or method');
Expand All @@ -220,7 +250,7 @@ RuleEvaluator.prototype._evalCallExpression = function(node) {
RuleEvaluator.prototype._evalArrayExpression = function(node) {

return node.elements.map(function(element) {
return this.evaluate(element);
return this._eval(element);
}, this);

};
Expand Down
60 changes: 50 additions & 10 deletions lib/results.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@

'use strict';

const paths = require('./paths');

const verboseType = new Set([
'MemberExpression',
'CallExpression',
'ArrayExpression',
'Identifier'
]);

/**
* Hold an evaluation result.
*/
Expand Down Expand Up @@ -34,10 +43,32 @@ class Result {
return this.readPermitted && this.writePermitted && this.validated;
}

get info() {
getInfo(verbose) {
let logs = this.logs
.map(r => `/${r.path}: ${r.kind} "${r.rule}"\n => ${r.result}`)
.join('\n');
.map(r => {
const expressions = verbose === false ? [] : r.expressions
.filter(exp => verboseType.has(exp.type) && typeof exp.value !== 'function')
.sort((a, b) => a.source.localeCompare(b.source))
.map(exp => `${exp.source} = ${JSON.stringify(exp.value)}`);

let extra = '';

switch (expressions.length) {

case 0:
break;

case 1:
extra = `with [${expressions[0]}] `;
break;

default:
extra = `with [\n ${expressions.join('\n ')}\n ] `;

}

return `/${paths.trim(r.path)}: ${r.kind} "${r.rule}"\n ${extra}=> ${r.success}`;
}).join('\n');

if (this.allowed) {
return `${logs}\n${this.type} was allowed.`;
Expand All @@ -58,6 +89,14 @@ class Result {
return `${logs}\n${this.type} was denied.`;
}

get info() {
return this.getInfo(false);
}

get verbose() {
return this.getInfo(true);
}

get root() {
return this.database.snapshot('/');
}
Expand All @@ -77,15 +116,16 @@ class Result {
/**
* Logs the evaluation result.
*
* @param {string} path The rule path
* @param {string} kind The rule kind
* @param {NodeRule} rule The rule
* @param {boolean|Error} result The evaluation result
* @param {string} path The rule path
* @param {string} kind The rule kind
* @param {NodeRule} rule The rule
* @param {RuleEvaluator|Error} evaluation The evaluation result
*/
add(path, kind, rule, result) {
this.logs.push({path, kind, result, rule: rule.toString()});
add(path, kind, rule, evaluation) {
const success = evaluation instanceof Error ? false : evaluation.result;
const expressions = evaluation.expressions ? Array.from(evaluation.expressions.values()) : [];

const success = result instanceof Error ? false : result;
this.logs.push({path, kind, expressions, success, rule: rule.toString()});

switch (kind) {

Expand Down
4 changes: 2 additions & 2 deletions lib/ruleset.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,9 @@ class Ruleset {
}

try {
const allowed = rule.evaluate(state);
const evaluation = rule.evaluation(state);

result.add(path, kind, rule, allowed);
result.add(path, kind, rule, evaluation);
} catch (e) {
result.add(path, kind, rule, e);
}
Expand Down
Loading

0 comments on commit 13ae7ba

Please sign in to comment.