Skip to content

Commit

Permalink
JS-359 Create rule S6418 (hardcoded-secrets): Hard-coded secrets ar…
Browse files Browse the repository at this point in the history
…e security-sensitive (broken) (#4920)
  • Loading branch information
kebetsi authored Nov 26, 2024
1 parent 1024c59 commit ffa208c
Show file tree
Hide file tree
Showing 21 changed files with 607 additions and 12 deletions.
6 changes: 6 additions & 0 deletions its/ruling/src/test/expected/jsts/Ghost/javascript-S6418.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"Ghost:core/client/app/mirage/config.js": [
59,
61
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"searchkit:examples/next/components/sdk-example/index.jsx": [
34
]
}
53 changes: 53 additions & 0 deletions packages/jsts/src/rules/S6418/cb.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
function func() {
const token = 'rf6acB24J//1FZLRrKpjmBUYSnUX5CHlt/iD5vVVcgVuAIOB6hzcWjDnv16V6hDLevW0Qs4hKPbP1M4YfuDI16sZna1/VGRLkAbTk6xMPs4epH6A3ZqSyyI-H92y' // Noncompliant
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
let api_key = 'not enough entropy'
api_key = 'rf6acB24J//1FZLRrKpjmBUYSnUX5CHlt/iD5vVVcgVuAIOB6hzcWjDnv16V6hDLevW0Qs4hKPbP1M4YfuDI16sZna1/VGRLkAbTk6xMPs4epH6A3ZqSyyI-H92y' // Noncompliant
}
function entropyTooLow() {
const token = 'rf6acB24J//1FZLRrKpjmBUYSnUX5CHlt/iD5vVaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
}
class MyClass {
secret = '1IfHMPanImzX8ZxC-Ud6+YhXiLwlXq$f_-3v~.=' // Noncompliant
}

function inFunctionCall() {
callWithSecret({ secret: '1IfHMPanImzX8ZxC-Ud6+YhXiLwlXq$f_-3v~.=' }) // Noncompliant

function callWithSecret({}) {}
}
function functionWithSecret({ secret = '1IfHMPanImzX8ZxC-Ud6+YhXiLwlXq$f_-3v~.=' }) { // Noncompliant
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
}
function cleanFunction(someArg, parameter='a string', anotherParameter: 42, ...args) {
another_call(42, 'a string', parameter, { a_keyword: 42 }, args)

function another_call(...foo) {}
}

const someObject = {
secret: '1IfHMPanImzX8ZxC-Ud6+YhXiLwlXq$f_-3v~.=', // Noncompliant
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
not_a_problem: 'not_a_secret',
42: 'forty-two'
}

function multipleAssignment() {
let nothing = 1, secret = '1IfHMPanImzX8ZxC-Ud6+YhXiLwlXq$f_-3v.~=', nothing_else = 2; // Noncompliant
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
}
function assignmentWithType() {
const secret: string = '1IfHMPanImzX8ZxC-Ud6+YhXiLwlXq$f_-3v~.=' // Noncompliant
let someVar: string;
const anotherVar: number = 42
}

function defaultValues(foo) {
let secret;
secret = foo || '1IfHMPanImzX8ZxC-Ud6+YhXiLwlXq$f_-3v~.='; // Noncompliant
secret = foo ?? '1IfHMPanImzX8ZxC-Ud6+YhXiLwlXq$f_-3v~.='; // Noncompliant
}

function customSecretWord() {
const yolo = '1IfHMPanImzX8ZxC-Ud6+YhXiLwlXq$f_-3v~.='; // Noncompliant
}
6 changes: 6 additions & 0 deletions packages/jsts/src/rules/S6418/cb.options.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[
{
"secretWords": "api[_.-]?key,auth,credential,secret,token,yolo",
"randomnessSensibility": 5.0
}
]
26 changes: 26 additions & 0 deletions packages/jsts/src/rules/S6418/cb.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* SonarQube JavaScript Plugin
* Copyright (C) 2011-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
import { check } from '../../../tests/tools/index.js';
import { rule } from './index.js';
import path from 'path';
import { describe } from 'node:test';

const sonarId = path.basename(import.meta.dirname);

describe('Rule S6418', () => {
check(sonarId, rule, import.meta.dirname);
});
17 changes: 17 additions & 0 deletions packages/jsts/src/rules/S6418/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* SonarQube JavaScript Plugin
* Copyright (C) 2011-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
export { rule } from './rule.js';
29 changes: 29 additions & 0 deletions packages/jsts/src/rules/S6418/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* SonarQube JavaScript Plugin
* Copyright (C) 2011-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
// DO NOT EDIT! This file is autogenerated by "npm run generate-meta"

export const meta = {
type: 'problem',
docs: {
description: 'Hard-coded secrets are security-sensitive',
recommended: true,
url: 'https://sonarsource.github.io/rspec/#/rspec/S6418/javascript',
requiresTypeChecking: false,
},
};

export const sonarKey = 'S6418';
212 changes: 212 additions & 0 deletions packages/jsts/src/rules/S6418/rule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/*
* SonarQube JavaScript Plugin
* Copyright (C) 2011-2024 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
// https://sonarsource.github.io/rspec/#/rspec/S6418/javascript

import type { Rule } from 'eslint';
import {
generateMeta,
isIdentifier,
isLogicalExpression,
isStringLiteral,
} from '../helpers/index.js';
import { meta } from './meta.js';
import { JSONSchema4 } from '@typescript-eslint/utils/json-schema';
import { FromSchema } from 'json-schema-to-ts';
import estree from 'estree';
import { TSESTree } from '@typescript-eslint/utils';

const DEFAULT_SECRET_WORDS = 'api[_.-]?key,auth,credential,secret,token';
const DEFAULT_RANDOMNESS_SENSIBILITY = 5.0;
const POSTVALIDATION_PATTERN =
/[a-zA-Z0-9_.+/~$-]([a-zA-Z0-9_.+/=~$-]|\\\\\\\\(?![ntr"])){14,1022}[a-zA-Z0-9_.+/=~$-]/;

function message(name: string): string {
return `"${name}" detected here, make sure this is not a hard-coded secret.`;
}

let randomnessSensibility: number;
let secretWordRegexps: RegExp[];

const schema = {
type: 'array',
minItems: 0,
maxItems: 1,
items: [
{
type: 'object',
properties: {
secretWords: {
type: 'string',
},
randomnessSensibility: {
type: 'number',
},
},
additionalProperties: false,
},
],
} as const satisfies JSONSchema4;

export const rule: Rule.RuleModule = {
meta: generateMeta(
meta as Rule.RuleMetaData,
{ schema },
false /* true if secondary locations */,
),
create(context: Rule.RuleContext) {
// get typed rule options with FromSchema helper
const secretWords =
(context.options as FromSchema<typeof schema>)[0]?.['secretWords'] ?? DEFAULT_SECRET_WORDS;
secretWordRegexps = buildSecretWordRegexps(secretWords);
randomnessSensibility =
(context.options as FromSchema<typeof schema>)[0]?.['randomnessSensibility'] ??
DEFAULT_RANDOMNESS_SENSIBILITY;

return {
AssignmentExpression(node) {
handleAssignmentExpression(context, node);
},
AssignmentPattern(node) {
handleAssignmentPattern(context, node);
},
Property(node) {
handlePropertyAndPropertyDefinition(context, node);
},
PropertyDefinition(node) {
handlePropertyAndPropertyDefinition(context, node);
},
VariableDeclarator(node) {
handleVariableDeclarator(context, node);
},
};
},
};

function handleAssignmentExpression(context: Rule.RuleContext, node: estree.AssignmentExpression) {
const keySuspect = findKeySuspect(node.left);
const valueSuspect = findValueSuspect(extractDefaultOperatorIfNeeded(node));
if (keySuspect && valueSuspect) {
context.report({
node: node.right,
message: message(keySuspect),
});
}
function extractDefaultOperatorIfNeeded(node: estree.AssignmentExpression): estree.Node {
const defaultOperators = ['??', '||'];
if (
isLogicalExpression(node.right as TSESTree.Node) &&
defaultOperators.includes((node.right as estree.LogicalExpression).operator)
) {
return (node.right as estree.LogicalExpression).right;
} else {
return node.right;
}
}
}
function handleAssignmentPattern(context: Rule.RuleContext, node: estree.AssignmentPattern) {
const keySuspect = findKeySuspect(node.left);
const valueSuspect = findValueSuspect(node.right);
if (keySuspect && valueSuspect) {
context.report({
node: node.right,
message: message(keySuspect),
});
}
}
function handlePropertyAndPropertyDefinition(
context: Rule.RuleContext,
node: estree.Property | estree.PropertyDefinition,
) {
const keySuspect = findKeySuspect(node.key);
const valueSuspect = findValueSuspect(node.value);
if (keySuspect && valueSuspect) {
context.report({
node: node.value as estree.Literal,
message: message(keySuspect),
});
}
}
function handleVariableDeclarator(context: Rule.RuleContext, node: estree.VariableDeclarator) {
const keySuspect = findKeySuspect(node.id);
const valueSuspect = findValueSuspect(node.init);
if (keySuspect && valueSuspect) {
context.report({
node: node.init as estree.Literal,
message: message(keySuspect),
});
}
}

function findKeySuspect(node: estree.Node): string | undefined {
if (isIdentifier(node) && secretWordRegexps.some(pattern => pattern.test(node.name))) {
return node.name;
} else {
return undefined;
}
}

function findValueSuspect(node: estree.Node | undefined | null): estree.Node | undefined {
if (
node &&
isStringLiteral(node) &&
valuePassesPostValidation(node.value) &&
entropyShouldRaise(node.value)
) {
return node;
} else {
return undefined;
}
}

function valuePassesPostValidation(value: string): boolean {
return POSTVALIDATION_PATTERN.test(value);
}

function buildSecretWordRegexps(secretWords: string) {
try {
return secretWords.split(',').map(word => new RegExp(`(${word})`, 'i'));
} catch (e) {
console.error(
`Invalid characters provided to rule S6418 'hardcoded-secrets' parameter "secretWords": "${secretWords}" falling back to default: "${DEFAULT_SECRET_WORDS}". Error: ${e}`,
);
return buildSecretWordRegexps(DEFAULT_SECRET_WORDS);
}
}

function entropyShouldRaise(value: string): boolean {
return ShannonEntropy.calculate(value) > randomnessSensibility;
}

const ShannonEntropy = {
calculate: (str: string): number => {
if (!str) {
return 0;
}
const lettersTotal = str.length;
const occurences: Record<string, number> = {};
for (const letter of [...str]) {
occurences[letter] = (occurences[letter] ?? 0) + 1;
}
const values = Object.values(occurences);
return (
values
.map(count => count / lettersTotal)
.map(frequency => -frequency * Math.log(frequency))
.reduce((acc, entropy) => acc + entropy, 0) / Math.log(2)
);
},
};
Loading

0 comments on commit ffa208c

Please sign in to comment.