Skip to content

Commit 8aeca88

Browse files
authored
feat: adding rule for sequential characters (#54)
2 parents 266707c + 541e777 commit 8aeca88

File tree

5 files changed

+307
-1
lines changed

5 files changed

+307
-1
lines changed

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,16 @@ Password Sheriff includes some default rules:
114114
});
115115
```
116116

117+
* `sequentialChars`: Passwords should not contain more than `max` sequential (increasing or decreasing) alphanumeric characters.
118+
```js
119+
var sequentialCharsPolicy = new PasswordPolicy({
120+
sequentialChars: { max: 3 }
121+
});
122+
// 'abcd' -> false (4 sequential > 3)
123+
// 'dcba' -> false (4 sequential > 3)
124+
// 'abce' -> true (sequence breaks)
125+
```
126+
117127
See the [default-rules example](examples/default-rules.js) section for more information.
118128

119129
## Issue Reporting
@@ -129,4 +139,4 @@ If you have found a bug or if you have a feature request, please report them at
129139
This project is licensed under the MIT license. See the [LICENSE](LICENSE) file for more info.
130140

131141

132-
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fauth0%2Fpassword-sheriff.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fauth0%2Fpassword-sheriff?ref=badge_large)
142+
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fauth0%2Fpassword-sheriff.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fauth0%2Fpassword-sheriff?ref=badge_large)

examples/default-rules.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ var PasswordPolicy = require('..').PasswordPolicy;
77
* * contains
88
* * containsAtLeast
99
* * identicalChars
10+
* * sequentialChars
1011
*/
1112

1213
/*
@@ -101,3 +102,18 @@ assert.equal(true, identitcalCharsPolicy.check('helllo'));
101102
assert.equal(false, identitcalCharsPolicy.check('hellllo'));
102103
assert.equal(false, identitcalCharsPolicy.check('123333334'));
103104

105+
/*
106+
* sequentialChars
107+
*
108+
* Parameters: max :: Integer
109+
*
110+
* Passwords should not contain more than `max` sequential (increasing or decreasing) characters.
111+
*/
112+
var sequentialCharsPolicy = new PasswordPolicy({
113+
sequentialChars: { max: 3 }
114+
});
115+
116+
assert.equal(true, sequentialCharsPolicy.check('abce')); // sequence breaks before exceeding 3
117+
assert.equal(true, sequentialCharsPolicy.check('cbaZ')); // descending sequence of length 3 allowed
118+
assert.equal(false, sequentialCharsPolicy.check('abcd')); // ascending length 4 not allowed
119+
assert.equal(false, sequentialCharsPolicy.check('dcba1')); // descending length 4 not allowed

lib/policy.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ var defaultRuleset = {
1111
contains: require('./rules/contains'),
1212
containsAtLeast: require('./rules/containsAtLeast'),
1313
identicalChars: require('./rules/identicalChars'),
14+
sequentialChars: require('./rules/sequentialChars')
1415
};
1516

1617
function flatDescriptions (descriptions, index) {

lib/rules/sequentialChars.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
var _ = require('../helper');
2+
3+
/**
4+
* @readonly
5+
* @enum {number}
6+
*/
7+
var Direction = {
8+
Ascending: 1,
9+
Descending: -1,
10+
None: 0
11+
};
12+
13+
/**
14+
* @readonly
15+
* @enum {number}
16+
*/
17+
var CharacterCodes = {
18+
LowerCaseA: 'a'.charCodeAt(0),
19+
LowerCaseZ: 'z'.charCodeAt(0),
20+
Zero: '0'.charCodeAt(0),
21+
Nine: '9'.charCodeAt(0),
22+
UpperCaseA: 'A'.charCodeAt(0),
23+
UpperCaseZ: 'Z'.charCodeAt(0),
24+
};
25+
26+
/**
27+
* Determines if a character is alphanumeric
28+
*
29+
* @param {number} code character code
30+
* @return {boolean}
31+
*/
32+
function isAlphanumeric(code) {
33+
if (!_.isNumber(code) || _.isNaN(code)) {
34+
return false;
35+
}
36+
37+
if (code >= CharacterCodes.LowerCaseA && code <= CharacterCodes.LowerCaseZ) {
38+
return true;
39+
} else if (code >= CharacterCodes.UpperCaseA && code <= CharacterCodes.UpperCaseZ) {
40+
return true;
41+
} else if (code >= CharacterCodes.Zero && code <= CharacterCodes.Nine) {
42+
return true;
43+
}
44+
45+
return false;
46+
}
47+
48+
/**
49+
* Returns true if password has more sequential characters the configured max allowed
50+
*
51+
* @param {{max: number}} options
52+
* @param {string} password
53+
* @return {boolean}
54+
*/
55+
function assert(options, password) {
56+
if (!password) {
57+
return false;
58+
}
59+
60+
var prevCode = password.charCodeAt(0);
61+
var seqLen = isAlphanumeric(prevCode) ? 1 : 0;
62+
var currentDirection = Direction.None;
63+
64+
for (var i = 1; i < password.length; i++) {
65+
var currentCode = password.charCodeAt(i);
66+
if (!isAlphanumeric(currentCode) || !isAlphanumeric(prevCode)) {
67+
currentDirection = Direction.None;
68+
seqLen = 1;
69+
prevCode = currentCode;
70+
continue;
71+
}
72+
73+
var diff = currentCode - prevCode;
74+
prevCode = currentCode;
75+
76+
if (diff === Direction.Ascending || diff === Direction.Descending) {
77+
if (currentDirection === diff) {
78+
seqLen += 1;
79+
} else {
80+
// start a new potential sequence of length 2 (prev + current)
81+
currentDirection = diff;
82+
seqLen = 2;
83+
}
84+
} else {
85+
currentDirection = Direction.None;
86+
seqLen = 1;
87+
}
88+
89+
if (seqLen > options.max) {
90+
return false;
91+
}
92+
}
93+
94+
return true;
95+
}
96+
97+
/**
98+
* @param {{max: number}} options
99+
* @param {boolean} verified
100+
* @return {boolean}
101+
*/
102+
function explain(options, verified) {
103+
var example = '';
104+
for (var i = 0; i < options.max + 1; i++) {
105+
example += String.fromCharCode('a'.charCodeAt(0) + i);
106+
}
107+
var d = {
108+
message: 'No more than %d sequential alphanumeric characters (e.g., "%s" not allowed)', // updated
109+
code: 'sequentialChars',
110+
format: [options.max, example]
111+
};
112+
if (verified !== undefined) {
113+
d.verified = verified;
114+
}
115+
return d;
116+
}
117+
118+
/**
119+
* @param {{max: number}} options
120+
* @return {boolean}
121+
*/
122+
function validate(options) {
123+
if (!_.isObject(options)) {
124+
throw new Error('options should be an object');
125+
}
126+
127+
if (!_.isNumber(options.max) || _.isNaN(options.max) || options.max < 2) {
128+
throw new Error('max should be a number greater than or equal to 2');
129+
}
130+
131+
if (!_.isNumber(options.max) || _.isNaN(options.max) || options.max > 26) {
132+
throw new Error('max should be a number less than or equal to 26');
133+
}
134+
135+
return true;
136+
}
137+
138+
module.exports = {
139+
validate,
140+
explain,
141+
missing: function (options, password) {
142+
return explain(options, assert(options, password));
143+
},
144+
assert
145+
};

test/rules/sequentialChars.js

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
var expect = require('chai').expect;
2+
3+
var sequentialChars = require('../../lib/rules/sequentialChars');
4+
5+
function sequentialCharsMessage(x, verified) {
6+
var example = '';
7+
for (var i = 0; i < x + 1; i++) {
8+
example += String.fromCharCode('a'.charCodeAt(0) + i);
9+
}
10+
var msg = 'No more than %d sequential alphanumeric characters (e.g., "%s" not allowed)'; // updated wording
11+
var d = {message: msg, format: [x, example], code: 'sequentialChars'};
12+
if (verified !== undefined) {
13+
d.verified = verified;
14+
}
15+
return d;
16+
}
17+
18+
function sequentialCharsValidate (max) {
19+
return function () {
20+
return sequentialChars.validate({max: max});
21+
};
22+
}
23+
24+
describe('"sequential characters" rule (alphanumeric only)', function () {
25+
describe('validate', function () {
26+
it ('should fail if max is not a number or less than 2', function () {
27+
const errorRegex = /max should be a number greater than or equal to 2/;
28+
29+
expect(sequentialCharsValidate(false)).to.throw(errorRegex);
30+
expect(sequentialCharsValidate(0)).to.throw(errorRegex);
31+
expect(sequentialCharsValidate(1)).to.throw(errorRegex);
32+
expect(sequentialCharsValidate('hello')).to.throw(errorRegex);
33+
expect(sequentialCharsValidate(undefined)).to.throw(errorRegex);
34+
});
35+
36+
it('should work otherwise', function () {
37+
expect(sequentialCharsValidate(2)).not.to.throw();
38+
expect(sequentialCharsValidate(5)).not.to.throw();
39+
});
40+
});
41+
42+
describe('explain', function () {
43+
it('should return description with message', function () {
44+
expect(sequentialChars.explain({max: 3})).to.be.deep.equal(sequentialCharsMessage(3));
45+
});
46+
});
47+
48+
describe('missing', function () {
49+
it('should inform that the rule is not verified for ascending sequences', function () {
50+
expect(sequentialChars.missing({max: 3}, 'abcd')).to.be.deep.equal(sequentialCharsMessage(3, false));
51+
});
52+
53+
it('should inform that the rule is not verified for descending sequences', function () {
54+
expect(sequentialChars.missing({max: 3}, 'dcba')).to.be.deep.equal(sequentialCharsMessage(3, false));
55+
});
56+
57+
it('should work otherwise', function () {
58+
expect(sequentialChars.missing({max: 3}, 'abce')).to.be.deep.equal(sequentialCharsMessage(3, true));
59+
expect(sequentialChars.missing({max: 3}, 'acbd')).to.be.deep.equal(sequentialCharsMessage(3, true));
60+
expect(sequentialChars.missing({max: 2}, 'abc')).to.be.deep.equal(sequentialCharsMessage(2, false));
61+
expect(sequentialChars.missing({max: 2}, 'abd')).to.be.deep.equal(sequentialCharsMessage(2, true));
62+
});
63+
});
64+
65+
describe('assert', function () {
66+
it('should return false on ascending letters', function () {
67+
expect(sequentialChars.assert({max: 2}, 'abcd')).to.be.equal(false);
68+
});
69+
70+
it('should return false on descending letters', function () {
71+
expect(sequentialChars.assert({max: 2}, 'dcba')).to.be.equal(false);
72+
});
73+
74+
it('should return true on success for letters', function () {
75+
expect(sequentialChars.assert({max: 3}, 'abce')).to.be.equal(true);
76+
expect(sequentialChars.assert({max: 3}, 'acbd')).to.be.equal(true);
77+
expect(sequentialChars.assert({max: 2}, 'abd')).to.be.equal(true);
78+
});
79+
80+
it('should return false on ascending digits', function () {
81+
expect(sequentialChars.assert({max: 2}, '012')).to.be.equal(false);
82+
});
83+
84+
it('should return false on descending digits', function () {
85+
expect(sequentialChars.assert({max: 2}, '654')).to.be.equal(false);
86+
});
87+
88+
it('should return true on success for digits', function () {
89+
expect(sequentialChars.assert({max: 3}, '0324')).to.be.equal(true);
90+
expect(sequentialChars.assert({max: 3}, '5321')).to.be.equal(true);
91+
expect(sequentialChars.assert({max: 2}, '134')).to.be.equal(true);
92+
});
93+
});
94+
95+
describe('non-alphanumeric', function () {
96+
it('should treat hyphen as a break in sequence', function () {
97+
expect(sequentialChars.assert({max: 2}, 'ab-cd')).to.be.equal(true); // 'ab' len2 ok, '-' break, 'cd' len2 ok
98+
});
99+
100+
it('should treat underscore as a break in sequence', function () {
101+
expect(sequentialChars.assert({max: 2}, 'ab_cd')).to.be.equal(true);
102+
});
103+
104+
it('should break at leading non-alphanumeric', function () {
105+
expect(sequentialChars.assert({max: 2}, '#ab')).to.be.equal(true); // only 'ab' len2
106+
});
107+
108+
it('should still fail if post-break sequence exceeds max', function () {
109+
expect(sequentialChars.assert({max: 2}, '#abc')).to.be.equal(false); // '#'(break) then 'abc' len3 > max
110+
});
111+
112+
it('should allow separated digit sequences each within limit', function () {
113+
expect(sequentialChars.assert({max: 2}, '01-23')).to.be.equal(true); // '01' len2 ok, '-' break, '23' len2 ok
114+
});
115+
});
116+
117+
describe('preceding non-alphanumeric with +/- 1 charCode', function () {
118+
it('should not count preceding "`" (charCode 96) as part of ascending sequence starting at a (97)', function () {
119+
expect(sequentialChars.assert({max: 2}, '`ab')).to.be.equal(true); // only 'ab' counted, length 2 OK
120+
});
121+
122+
it('should fail only due to actual sequence after break, not including preceding "`"', function () {
123+
expect(sequentialChars.assert({max: 2}, '`abc')).to.be.equal(false); // 'abc' len3 > max; '`' ignored
124+
});
125+
126+
it('should not count preceding "{" (charCode 123) as part of descending sequence starting at z (122)', function () {
127+
expect(sequentialChars.assert({max: 2}, '{zy')).to.be.equal(true); // 'zy' len2 OK
128+
});
129+
130+
it('should fail when descending sequence after break exceeds max, excluding preceding "{"', function () {
131+
expect(sequentialChars.assert({max: 2}, '{zyx')).to.be.equal(false); // 'zyx' len3 > max
132+
});
133+
});
134+
});

0 commit comments

Comments
 (0)