@sikkuumiii/defuse 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +235 -0
- package/README.md +561 -2
- package/dist/src/cli.d.ts +20 -0
- package/dist/src/cli.js +619 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/core/analyze.d.ts +178 -0
- package/dist/src/core/analyze.js +834 -0
- package/dist/src/core/analyze.js.map +1 -0
- package/dist/src/core/coverage.d.ts +87 -0
- package/dist/src/core/coverage.js +181 -0
- package/dist/src/core/coverage.js.map +1 -0
- package/dist/src/core/finding.d.ts +196 -0
- package/dist/src/core/finding.js +178 -0
- package/dist/src/core/finding.js.map +1 -0
- package/dist/src/core/licence.d.ts +50 -0
- package/dist/src/core/licence.js +56 -0
- package/dist/src/core/licence.js.map +1 -0
- package/dist/src/core/paths.d.ts +21 -0
- package/dist/src/core/paths.js +58 -0
- package/dist/src/core/paths.js.map +1 -0
- package/dist/src/core/score.d.ts +137 -0
- package/dist/src/core/score.js +232 -0
- package/dist/src/core/score.js.map +1 -0
- package/dist/src/dast/targets.d.ts +30 -0
- package/dist/src/dast/targets.js +79 -0
- package/dist/src/dast/targets.js.map +1 -0
- package/dist/src/dast/zap.d.ts +42 -0
- package/dist/src/dast/zap.js +184 -0
- package/dist/src/dast/zap.js.map +1 -0
- package/dist/src/engine/scan.d.ts +80 -0
- package/dist/src/engine/scan.js +129 -0
- package/dist/src/engine/scan.js.map +1 -0
- package/dist/src/engine/shapes.d.ts +47 -0
- package/dist/src/engine/shapes.js +328 -0
- package/dist/src/engine/shapes.js.map +1 -0
- package/dist/src/engine/walk.d.ts +60 -0
- package/dist/src/engine/walk.js +156 -0
- package/dist/src/engine/walk.js.map +1 -0
- package/dist/src/parse/astDump.d.ts +31 -0
- package/dist/src/parse/astDump.js +66 -0
- package/dist/src/parse/astDump.js.map +1 -0
- package/dist/src/parse/languages.d.ts +70 -0
- package/dist/src/parse/languages.js +174 -0
- package/dist/src/parse/languages.js.map +1 -0
- package/dist/src/parse/location.d.ts +17 -0
- package/dist/src/parse/location.js +38 -0
- package/dist/src/parse/location.js.map +1 -0
- package/dist/src/parse/node-grammars.d.ts +12 -0
- package/dist/src/parse/node-grammars.js +35 -0
- package/dist/src/parse/node-grammars.js.map +1 -0
- package/dist/src/parse/parser.d.ts +126 -0
- package/dist/src/parse/parser.js +202 -0
- package/dist/src/parse/parser.js.map +1 -0
- package/dist/src/parse/tree-store.d.ts +139 -0
- package/dist/src/parse/tree-store.js +236 -0
- package/dist/src/parse/tree-store.js.map +1 -0
- package/dist/src/platform/dependency-rule.d.ts +27 -0
- package/dist/src/platform/dependency-rule.js +21 -0
- package/dist/src/platform/dependency-rule.js.map +1 -0
- package/dist/src/platform/deps.d.ts +86 -0
- package/dist/src/platform/deps.js +304 -0
- package/dist/src/platform/deps.js.map +1 -0
- package/dist/src/platform/php-facts.d.ts +49 -0
- package/dist/src/platform/php-facts.js +67 -0
- package/dist/src/platform/php-facts.js.map +1 -0
- package/dist/src/platform/runtime-version.d.ts +78 -0
- package/dist/src/platform/runtime-version.js +573 -0
- package/dist/src/platform/runtime-version.js.map +1 -0
- package/dist/src/report/colors.d.ts +50 -0
- package/dist/src/report/colors.js +97 -0
- package/dist/src/report/colors.js.map +1 -0
- package/dist/src/report/coverage-table.d.ts +90 -0
- package/dist/src/report/coverage-table.js +291 -0
- package/dist/src/report/coverage-table.js.map +1 -0
- package/dist/src/report/html-report.d.ts +66 -0
- package/dist/src/report/html-report.js +489 -0
- package/dist/src/report/html-report.js.map +1 -0
- package/dist/src/report/human.d.ts +44 -0
- package/dist/src/report/human.js +575 -0
- package/dist/src/report/human.js.map +1 -0
- package/dist/src/report/json.d.ts +24 -0
- package/dist/src/report/json.js +178 -0
- package/dist/src/report/json.js.map +1 -0
- package/dist/src/report/mark.d.ts +17 -0
- package/dist/src/report/mark.js +39 -0
- package/dist/src/report/mark.js.map +1 -0
- package/dist/src/report/pricing.d.ts +33 -0
- package/dist/src/report/pricing.js +343 -0
- package/dist/src/report/pricing.js.map +1 -0
- package/dist/src/report/sarif.d.ts +39 -0
- package/dist/src/report/sarif.js +219 -0
- package/dist/src/report/sarif.js.map +1 -0
- package/dist/src/report/site.d.ts +87 -0
- package/dist/src/report/site.js +742 -0
- package/dist/src/report/site.js.map +1 -0
- package/dist/src/rules/code-injection.d.ts +47 -0
- package/dist/src/rules/code-injection.js +326 -0
- package/dist/src/rules/code-injection.js.map +1 -0
- package/dist/src/rules/command-injection.d.ts +30 -0
- package/dist/src/rules/command-injection.js +286 -0
- package/dist/src/rules/command-injection.js.map +1 -0
- package/dist/src/rules/contract.d.ts +198 -0
- package/dist/src/rules/contract.js +39 -0
- package/dist/src/rules/contract.js.map +1 -0
- package/dist/src/rules/format-string.d.ts +46 -0
- package/dist/src/rules/format-string.js +146 -0
- package/dist/src/rules/format-string.js.map +1 -0
- package/dist/src/rules/hardcoded-secret.d.ts +38 -0
- package/dist/src/rules/hardcoded-secret.js +440 -0
- package/dist/src/rules/hardcoded-secret.js.map +1 -0
- package/dist/src/rules/lib/guards.d.ts +168 -0
- package/dist/src/rules/lib/guards.js +1179 -0
- package/dist/src/rules/lib/guards.js.map +1 -0
- package/dist/src/rules/lib/html-safety.d.ts +75 -0
- package/dist/src/rules/lib/html-safety.js +755 -0
- package/dist/src/rules/lib/html-safety.js.map +1 -0
- package/dist/src/rules/lib/local-build.d.ts +59 -0
- package/dist/src/rules/lib/local-build.js +320 -0
- package/dist/src/rules/lib/local-build.js.map +1 -0
- package/dist/src/rules/lib/strings.d.ts +208 -0
- package/dist/src/rules/lib/strings.js +1970 -0
- package/dist/src/rules/lib/strings.js.map +1 -0
- package/dist/src/rules/path-traversal.d.ts +33 -0
- package/dist/src/rules/path-traversal.js +96 -0
- package/dist/src/rules/path-traversal.js.map +1 -0
- package/dist/src/rules/registry.d.ts +19 -0
- package/dist/src/rules/registry.js +77 -0
- package/dist/src/rules/registry.js.map +1 -0
- package/dist/src/rules/sql-injection.d.ts +36 -0
- package/dist/src/rules/sql-injection.js +299 -0
- package/dist/src/rules/sql-injection.js.map +1 -0
- package/dist/src/rules/ssrf.d.ts +39 -0
- package/dist/src/rules/ssrf.js +111 -0
- package/dist/src/rules/ssrf.js.map +1 -0
- package/dist/src/rules/unbounded-copy.d.ts +37 -0
- package/dist/src/rules/unbounded-copy.js +147 -0
- package/dist/src/rules/unbounded-copy.js.map +1 -0
- package/dist/src/rules/unsafe-deserialization.d.ts +43 -0
- package/dist/src/rules/unsafe-deserialization.js +276 -0
- package/dist/src/rules/unsafe-deserialization.js.map +1 -0
- package/dist/src/rules/weak-hash.d.ts +37 -0
- package/dist/src/rules/weak-hash.js +197 -0
- package/dist/src/rules/weak-hash.js.map +1 -0
- package/dist/src/rules/xss.d.ts +38 -0
- package/dist/src/rules/xss.js +527 -0
- package/dist/src/rules/xss.js.map +1 -0
- package/dist/src/taint/content-type.d.ts +19 -0
- package/dist/src/taint/content-type.js +20 -0
- package/dist/src/taint/content-type.js.map +1 -0
- package/dist/src/taint/dictionaries.d.ts +23 -0
- package/dist/src/taint/dictionaries.js +1778 -0
- package/dist/src/taint/dictionaries.js.map +1 -0
- package/dist/src/taint/fold.d.ts +59 -0
- package/dist/src/taint/fold.js +1177 -0
- package/dist/src/taint/fold.js.map +1 -0
- package/dist/src/taint/java-scope.d.ts +61 -0
- package/dist/src/taint/java-scope.js +212 -0
- package/dist/src/taint/java-scope.js.map +1 -0
- package/dist/src/taint/local-map.d.ts +79 -0
- package/dist/src/taint/local-map.js +603 -0
- package/dist/src/taint/local-map.js.map +1 -0
- package/dist/src/taint/project.d.ts +172 -0
- package/dist/src/taint/project.js +617 -0
- package/dist/src/taint/project.js.map +1 -0
- package/dist/src/taint/tracer.d.ts +265 -0
- package/dist/src/taint/tracer.js +3817 -0
- package/dist/src/taint/tracer.js.map +1 -0
- package/dist/src/taint/types.d.ts +484 -0
- package/dist/src/taint/types.js +47 -0
- package/dist/src/taint/types.js.map +1 -0
- package/dist/src/taint/visibility.d.ts +25 -0
- package/dist/src/taint/visibility.js +96 -0
- package/dist/src/taint/visibility.js.map +1 -0
- package/docs/examples/defuse.json +20 -0
- package/docs/examples/dvwa-urls.json +14 -0
- package/docs/examples/github-code-scanning.yml +54 -0
- package/docs/examples/tiny.js +4 -0
- package/package.json +73 -4
- package/src/cli.ts +747 -0
- package/src/core/analyze.ts +1072 -0
- package/src/core/coverage.ts +269 -0
- package/src/core/finding.ts +311 -0
- package/src/core/licence.ts +61 -0
- package/src/core/paths.ts +59 -0
- package/src/core/score.ts +353 -0
- package/src/dast/targets.ts +121 -0
- package/src/dast/zap.ts +246 -0
- package/src/engine/scan.ts +200 -0
- package/src/engine/shapes.ts +348 -0
- package/src/engine/walk.ts +196 -0
- package/src/parse/astDump.ts +85 -0
- package/src/parse/languages.ts +232 -0
- package/src/parse/location.ts +44 -0
- package/src/parse/node-grammars.ts +41 -0
- package/src/parse/parser.ts +262 -0
- package/src/parse/tree-store.ts +301 -0
- package/src/platform/dependency-rule.ts +33 -0
- package/src/platform/deps.ts +374 -0
- package/src/platform/php-facts.ts +85 -0
- package/src/platform/runtime-version.ts +710 -0
- package/src/report/colors.ts +107 -0
- package/src/report/coverage-table.ts +359 -0
- package/src/report/html-report.ts +601 -0
- package/src/report/human.ts +811 -0
- package/src/report/json.ts +189 -0
- package/src/report/mark.ts +47 -0
- package/src/report/pricing.ts +374 -0
- package/src/report/sarif.ts +235 -0
- package/src/report/site.ts +760 -0
- package/src/rules/code-injection.ts +346 -0
- package/src/rules/command-injection.ts +296 -0
- package/src/rules/contract.ts +220 -0
- package/src/rules/format-string.ts +158 -0
- package/src/rules/hardcoded-secret.ts +485 -0
- package/src/rules/lib/guards.ts +1235 -0
- package/src/rules/lib/html-safety.ts +793 -0
- package/src/rules/lib/local-build.ts +329 -0
- package/src/rules/lib/strings.ts +2034 -0
- package/src/rules/path-traversal.ts +101 -0
- package/src/rules/registry.ts +90 -0
- package/src/rules/sql-injection.ts +311 -0
- package/src/rules/ssrf.ts +118 -0
- package/src/rules/unbounded-copy.ts +162 -0
- package/src/rules/unsafe-deserialization.ts +291 -0
- package/src/rules/weak-hash.ts +226 -0
- package/src/rules/xss.ts +572 -0
- package/src/taint/content-type.ts +20 -0
- package/src/taint/dictionaries.ts +1855 -0
- package/src/taint/fold.ts +1206 -0
- package/src/taint/java-scope.ts +200 -0
- package/src/taint/local-map.ts +594 -0
- package/src/taint/project.ts +774 -0
- package/src/taint/tracer.ts +4298 -0
- package/src/taint/types.ts +511 -0
- package/src/taint/visibility.ts +100 -0
- package/tsconfig.json +61 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HARDCODED CREDENTIALS (CWE-798, OWASP A07:2025 - Authentication
|
|
3
|
+
* Authentication Failures; also A05 Security Misconfiguration)
|
|
4
|
+
*
|
|
5
|
+
* WHAT THE BUG IS, in plain language:
|
|
6
|
+
* A password, API key or private key written directly into source code is a
|
|
7
|
+
* secret that has already partly escaped. It is in your git history forever
|
|
8
|
+
* (deleting the line does not remove it - `git log -p` still has it), it is on
|
|
9
|
+
* every laptop that ever cloned the repo, it is in your CI logs and your
|
|
10
|
+
* backups, and every contractor who read the code knows it. You also cannot
|
|
11
|
+
* rotate it without a code change and a deploy.
|
|
12
|
+
*
|
|
13
|
+
* This is the least glamorous vulnerability class and, empirically, one of the
|
|
14
|
+
* most exploited: attackers scan public repositories for key formats
|
|
15
|
+
* continuously, and a leaked cloud key can become a bill or a breach in minutes.
|
|
16
|
+
*
|
|
17
|
+
* THE FIX:
|
|
18
|
+
* Keep secrets outside the code - environment variables, a secrets manager
|
|
19
|
+
* (Vault, AWS Secrets Manager, Doppler), or a config file that is gitignored.
|
|
20
|
+
* The code should read a NAME, never a VALUE. And if a secret was ever
|
|
21
|
+
* committed, rotate it; scrubbing history is not enough, because you cannot
|
|
22
|
+
* un-copy something.
|
|
23
|
+
*
|
|
24
|
+
* HOW THIS RULE DECIDES - three signals, deliberately separate:
|
|
25
|
+
* 1. KNOWN FORMAT - the text matches a real vendor's key shape
|
|
26
|
+
* (AKIA..., ghp_..., sk_live_...). Very strong.
|
|
27
|
+
* 2. SUSPICIOUS NAME - the variable is called `password`, `apiKey`,
|
|
28
|
+
* `secret`... and holds a literal string. Strong.
|
|
29
|
+
* 3. HIGH ENTROPY - a long, random-looking literal with no meaningful
|
|
30
|
+
* name. Weak - this is the one that produces false
|
|
31
|
+
* positives, so it gets lower severity and the finding
|
|
32
|
+
* says explicitly that it is an entropy guess.
|
|
33
|
+
* Reporting all three at the same confidence would be exactly the dishonesty
|
|
34
|
+
* this project rejects, so they carry different severities and different
|
|
35
|
+
* reasoning text.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { Node } from 'web-tree-sitter';
|
|
39
|
+
import type { LanguageId } from '../parse/languages.js';
|
|
40
|
+
import { asAssignment, asCall, type Rule, type RuleHit } from './contract.js';
|
|
41
|
+
import {
|
|
42
|
+
isLikelyPlaceholder,
|
|
43
|
+
looksLikeSql,
|
|
44
|
+
looksLikeProse,
|
|
45
|
+
echoesItsOwnName,
|
|
46
|
+
isStructuralIdentifier,
|
|
47
|
+
isStringLiteral,
|
|
48
|
+
isTestPath,
|
|
49
|
+
looksLikeToken,
|
|
50
|
+
matchKnownSecret,
|
|
51
|
+
shannonEntropy,
|
|
52
|
+
unquote,
|
|
53
|
+
} from './lib/strings.js';
|
|
54
|
+
|
|
55
|
+
/** Variable/field names that suggest the value is a credential. */
|
|
56
|
+
const SECRET_NAME = new RegExp(
|
|
57
|
+
[
|
|
58
|
+
'pass(word|wd|phrase)?',
|
|
59
|
+
'secret',
|
|
60
|
+
'token',
|
|
61
|
+
'api[_-]?key',
|
|
62
|
+
'apikey',
|
|
63
|
+
'access[_-]?key',
|
|
64
|
+
'secret[_-]?key',
|
|
65
|
+
'private[_-]?key',
|
|
66
|
+
'client[_-]?secret',
|
|
67
|
+
'encryption[_-]?key',
|
|
68
|
+
'signing[_-]?key',
|
|
69
|
+
// The lookahead matters: without it, "auth" matches inside "author",
|
|
70
|
+
// "authorize" and Gin's "AuthUserKey". Found by scanning real repositories.
|
|
71
|
+
'auth[_-]?(token|key|header|secret)?(?![A-Za-z])',
|
|
72
|
+
'credential',
|
|
73
|
+
'bearer',
|
|
74
|
+
'jwt[_-]?secret',
|
|
75
|
+
'session[_-]?secret',
|
|
76
|
+
'connection[_-]?string',
|
|
77
|
+
'conn[_-]?str',
|
|
78
|
+
'dsn',
|
|
79
|
+
].join('|'),
|
|
80
|
+
'i',
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Names that MATCH the pattern above but are not secrets: they hold the name of
|
|
85
|
+
* a secret, its location, or a boolean about it. Checked first.
|
|
86
|
+
*
|
|
87
|
+
* `prefix`, `suffix` and `source` came from Django, where all three were HIGH
|
|
88
|
+
* findings: SECRET_KEY_INSECURE_PREFIX = "django-insecure-" is the prefix the
|
|
89
|
+
* code checks keys FOR, and token_source = "POST" is where a token is read
|
|
90
|
+
* from (tests/fixtures/safe/secret-shaped-names.py).
|
|
91
|
+
*/
|
|
92
|
+
const NOT_A_SECRET =
|
|
93
|
+
/(_?(name|field|label|header|type|env|var|path|file|url|uri|prompt|placeholder|examples?|samples?|hash|algo|algorithm|regex|pattern|length|expiry|ttl|enabled|required|prefix|suffix|source)$)|^(has|is|use|should|allow|require)/i;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A VALUE THAT NAMES A KEY SLOT, not one that fills it:
|
|
97
|
+
*
|
|
98
|
+
* INTERNAL_RESET_SESSION_TOKEN = "_password_reset_token"
|
|
99
|
+
*
|
|
100
|
+
* is the session key Django stores the reset token UNDER. Lowercase
|
|
101
|
+
* snake_case, letters only, at least two words, one of which is a slot noun.
|
|
102
|
+
* Anything with a digit or a capital still reports - generated values carry
|
|
103
|
+
* one or the other - and so does snake_case without a slot word
|
|
104
|
+
* (`DB_PASSWORD = "admin_pass"`, tests/fixtures/vulnerable/secret-near-misses.py).
|
|
105
|
+
* The cost: a human-chosen secret spelled like a key name, `my_secret_token`,
|
|
106
|
+
* is not reported, and the rule's limitations say so.
|
|
107
|
+
*/
|
|
108
|
+
const KEY_SLOT_NAME = /^_?[a-z]+(?:_[a-z]+)+$/;
|
|
109
|
+
const SLOT_NOUNS = new Set(['password', 'passwd', 'token', 'secret', 'key', 'session', 'csrf', 'cookie', 'nonce']);
|
|
110
|
+
function namesAKeySlot(value: string): boolean {
|
|
111
|
+
if (!KEY_SLOT_NAME.test(value)) return false;
|
|
112
|
+
return value.split('_').some((word) => SLOT_NOUNS.has(word));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A STATEMENT OR TEMPLATE THE SECRET IS SUBSTITUTED INTO:
|
|
117
|
+
*
|
|
118
|
+
* set_password = 'ALTER USER %(user)s IDENTIFIED BY "%(password)s"'
|
|
119
|
+
*
|
|
120
|
+
* from Django's Oracle backend. The password is the placeholder, filled in
|
|
121
|
+
* later; the string is SQL. A Spring `{noop}hunter22` is not a template - the
|
|
122
|
+
* brace form is not read here at all.
|
|
123
|
+
*/
|
|
124
|
+
/**
|
|
125
|
+
* A LEXER'S TOKEN, NOT AN AUTH TOKEN.
|
|
126
|
+
*
|
|
127
|
+
* Django's template engine builds operator tokens for `{% if %}` -
|
|
128
|
+
* `token = "is not"`, `token = "not in"` in django/template/smartif.py - and
|
|
129
|
+
* both were reported as hardcoded credentials because of the variable name.
|
|
130
|
+
*
|
|
131
|
+
* The test is narrow on purpose. The name's only credential word must be
|
|
132
|
+
* `token` (`sessionToken` is a credential slot whatever it holds), and EVERY
|
|
133
|
+
* word of the value must be a programming-language keyword or an operator.
|
|
134
|
+
* One ordinary word - "manny is cool" - and it is a passphrase again.
|
|
135
|
+
*/
|
|
136
|
+
const LANGUAGE_WORDS = new Set([
|
|
137
|
+
'is', 'not', 'in', 'and', 'or', 'if', 'else', 'elif', 'for', 'while', 'def', 'class',
|
|
138
|
+
'return', 'true', 'false', 'none', 'null', 'nil', 'undefined', 'new', 'of', 'as', 'with',
|
|
139
|
+
'from', 'import', 'lambda', 'yield', 'await', 'async', 'try', 'except', 'finally', 'raise',
|
|
140
|
+
'throw', 'catch', 'case', 'switch', 'default', 'break', 'continue', 'pass', 'do', 'then',
|
|
141
|
+
'end', 'endif', 'endfor', 'block', 'endblock', 'let', 'var', 'const', 'typeof', 'instanceof',
|
|
142
|
+
]);
|
|
143
|
+
const OTHER_CREDENTIAL_WORD = /pass|secret|key|auth|credential|bearer|jwt|session|access|refresh|csrf|api/i;
|
|
144
|
+
|
|
145
|
+
function isLanguageToken(name: string, value: string): boolean {
|
|
146
|
+
if (!/token/i.test(name) || OTHER_CREDENTIAL_WORD.test(name)) return false;
|
|
147
|
+
const words = value.trim().split(/\s+/).filter(Boolean);
|
|
148
|
+
return words.length > 0 && words.every((w) => LANGUAGE_WORDS.has(w.toLowerCase()) || /^[^\w\s]+$/.test(w));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isStatementOrTemplate(value: string): boolean {
|
|
152
|
+
return looksLikeSql(value) || /%\([A-Za-z_]\w*\)[sdr]/.test(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Entropy threshold above which a long literal starts looking machine-generated. */
|
|
156
|
+
const ENTROPY_THRESHOLD = 4.0;
|
|
157
|
+
const MIN_ENTROPY_LENGTH = 24;
|
|
158
|
+
|
|
159
|
+
interface LiteralValue {
|
|
160
|
+
readonly node: Node;
|
|
161
|
+
readonly text: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function literalOf(node: Node, language: LanguageId): LiteralValue | null {
|
|
165
|
+
if (!isStringLiteral(node, language)) return null;
|
|
166
|
+
const text = unquote((node.text ?? '').trim());
|
|
167
|
+
return { node, text };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* PUBLIC BY DESIGN: a blockchain address, or a file name.
|
|
172
|
+
*
|
|
173
|
+
* Juice Shop's Web3 pages produced twenty "credentials": contract and wallet
|
|
174
|
+
* ADDRESSES - `BeeTokenAddress = '0x3643...'`, Bitcoin `address: '1AbK...'` -
|
|
175
|
+
* which are published on a public ledger and are the opposite of secret, and a
|
|
176
|
+
* map of Solidity compiler file names. An address is only let off when the
|
|
177
|
+
* name says address or does not say credential at all: `secretKey = '0x' + 40
|
|
178
|
+
* hex digits` still reports. A wallet's PRIVATE key is 64 hex digits, not 40,
|
|
179
|
+
* so it never matches here (tests/fixtures/vulnerable/secret-arguments.js).
|
|
180
|
+
*/
|
|
181
|
+
const ETH_ADDRESS = /^0x[0-9a-fA-F]{40}$/;
|
|
182
|
+
const BASE58_ADDRESS = /^(?:[13][1-9A-HJ-NP-Za-km-z]{25,34}|X[1-9A-HJ-NP-Za-km-z]{33}|(?:bc|tb)1[02-9ac-hj-np-z]{11,71})$/;
|
|
183
|
+
const ADDRESS_NAME = /address|addr|wallet|contract|recipient|payee/i;
|
|
184
|
+
const FILE_NAME = /^[\w.+@-]+\.(?:js|mjs|cjs|json|wasm|css|map|html?|png|jpe?g|gif|svg|ico|txt|md|xml|ya?ml|zip|gz|tgz|tar|pdf|woff2?|ttf)$/i;
|
|
185
|
+
function isPublicIdentifier(name: string, value: string): boolean {
|
|
186
|
+
if (FILE_NAME.test(value)) return true;
|
|
187
|
+
const addressShaped = ETH_ADDRESS.test(value) || BASE58_ADDRESS.test(value);
|
|
188
|
+
return addressShaped && (ADDRESS_NAME.test(name) || !SECRET_NAME.test(name));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A KEY HANDED STRAIGHT TO THE FUNCTION THAT USES IT.
|
|
193
|
+
*
|
|
194
|
+
* Every other path in this rule needs a NAME - `const key = '...'`. Juice
|
|
195
|
+
* Shop's lib/insecurity.ts writes `crypto.createHmac('sha256', 'pa4q...')`:
|
|
196
|
+
* no name at all, and it was missed. These are the calls whose argument at
|
|
197
|
+
* that position IS a key; a string literal there is a key written in the code.
|
|
198
|
+
* A placeholder-looking value is still reported - `jwt.sign(p, 'secret')` is
|
|
199
|
+
* a real key, just a weak one - but a template reference (`${KEY}`) is not.
|
|
200
|
+
*/
|
|
201
|
+
type KeySlot = { readonly callee: string; readonly receiver?: RegExp; readonly index: number };
|
|
202
|
+
const JS_KEY_ARGUMENTS: readonly KeySlot[] = [
|
|
203
|
+
{ callee: 'createHmac', index: 1 },
|
|
204
|
+
{ callee: 'createCipheriv', index: 1 },
|
|
205
|
+
{ callee: 'createDecipheriv', index: 1 },
|
|
206
|
+
{ callee: 'createSecretKey', index: 0 },
|
|
207
|
+
{ callee: 'sign', receiver: /^(jwt|jsonwebtoken)$/i, index: 1 },
|
|
208
|
+
{ callee: 'verify', receiver: /^(jwt|jsonwebtoken)$/i, index: 1 },
|
|
209
|
+
];
|
|
210
|
+
const KEY_ARGUMENTS: Partial<Record<LanguageId, readonly KeySlot[]>> = {
|
|
211
|
+
javascript: JS_KEY_ARGUMENTS,
|
|
212
|
+
typescript: JS_KEY_ARGUMENTS,
|
|
213
|
+
python: [
|
|
214
|
+
{ callee: 'new', receiver: /^hmac$/, index: 0 },
|
|
215
|
+
{ callee: 'encode', receiver: /^jwt$/, index: 1 },
|
|
216
|
+
{ callee: 'decode', receiver: /^jwt$/, index: 1 },
|
|
217
|
+
],
|
|
218
|
+
php: [
|
|
219
|
+
{ callee: 'hash_hmac', index: 2 },
|
|
220
|
+
{ callee: 'openssl_encrypt', index: 2 },
|
|
221
|
+
{ callee: 'openssl_decrypt', index: 2 },
|
|
222
|
+
{ callee: 'encode', receiver: /(^|\\)JWT$/, index: 1 },
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
const TEMPLATE_REFERENCE = /^\$\{?\w+\}?$|^%\(\w+\)s$|^<[^>]+>$|^\{\{.*\}\}$/;
|
|
226
|
+
|
|
227
|
+
export const hardcodedSecretRule: Rule = {
|
|
228
|
+
id: 'hardcoded-secret',
|
|
229
|
+
name: 'Credential written directly into source code',
|
|
230
|
+
cwe: 'CWE-798',
|
|
231
|
+
owasp: 'A07:2025 Authentication Failures',
|
|
232
|
+
severity: 'high',
|
|
233
|
+
summary: 'A password or key is written in the code, so anyone with the code or its git history has it.',
|
|
234
|
+
fix:
|
|
235
|
+
'Move it to an environment variable or secrets manager, read it by name, and rotate the exposed one.',
|
|
236
|
+
explanation:
|
|
237
|
+
'A password or API key written into source code lives in git history forever, on ' +
|
|
238
|
+
'every machine that cloned the repo, and in every CI log. It cannot be rotated ' +
|
|
239
|
+
'without a deploy, and public repositories are scanned for key formats ' +
|
|
240
|
+
'continuously. Secrets belong in environment variables or a secrets manager; the ' +
|
|
241
|
+
'code should reference a name, never a value.',
|
|
242
|
+
limitations:
|
|
243
|
+
'UNVERIFIED (signature-based): we matched a name pattern, a known vendor key format, ' +
|
|
244
|
+
'or a randomness score. We cannot tell a real production key from a test fixture, ' +
|
|
245
|
+
'a rotated-and-dead key, or a deliberately fake example. Entropy-only hits in ' +
|
|
246
|
+
'particular are a guess about how random text looks, nothing more. ' +
|
|
247
|
+
'DELIBERATE GAP: values matching a vendor format but ending in EXAMPLE, or with ' +
|
|
248
|
+
'the random portion filled by 0000/1234567890, are treated as published ' +
|
|
249
|
+
'documentation placeholders (AWS prints AKIAIOSFODNN7EXAMPLE in its own docs) and ' +
|
|
250
|
+
'are NOT reported. A real key that happens to end in EXAMPLE would be missed. ' +
|
|
251
|
+
'Also not reported: a value under a name ending in prefix, suffix or source; a value ' +
|
|
252
|
+
'that is an SQL statement or a %(name)s template; and a lowercase snake_case value ' +
|
|
253
|
+
'that names a key slot (`_password_reset_token`) - so a human-chosen secret spelled ' +
|
|
254
|
+
'like one, `my_secret_token`, would be missed. Nor a value under a name whose only ' +
|
|
255
|
+
'credential word is `token` when every word of it is a programming keyword or operator ' +
|
|
256
|
+
'(`token = "is not"` in a template lexer) - a secret made only of such words would be missed. ' +
|
|
257
|
+
'Nor a blockchain address (`0x` and 40 hex digits, or a Bitcoin-style address) under a name that ' +
|
|
258
|
+
'says address or does not say credential, nor a value that is a file name (`soljson-v0.8.21.js`): ' +
|
|
259
|
+
'both are public by design. A key passed straight to a call is reported only for the calls listed ' +
|
|
260
|
+
'in KEY_ARGUMENTS (createHmac, createCipheriv, jwt.sign / verify, hmac.new, jwt.encode / decode, ' +
|
|
261
|
+
'hash_hmac, openssl_encrypt / decrypt, JWT::encode); any other function that takes a key is not read.',
|
|
262
|
+
shapes: ['assignment', 'call'],
|
|
263
|
+
support: {
|
|
264
|
+
javascript: {
|
|
265
|
+
status: 'implemented',
|
|
266
|
+
note: 'Covers variable declarations, assignments and object-literal entries ({ apiKey: "..." }).',
|
|
267
|
+
},
|
|
268
|
+
typescript: {
|
|
269
|
+
status: 'implemented',
|
|
270
|
+
note: 'Additionally covers class property definitions (`private token = "..."`).',
|
|
271
|
+
},
|
|
272
|
+
python: {
|
|
273
|
+
status: 'implemented',
|
|
274
|
+
note: 'Covers assignments, dict literals and keyword arguments (connect(password="...")).',
|
|
275
|
+
},
|
|
276
|
+
java: {
|
|
277
|
+
status: 'implemented',
|
|
278
|
+
note: 'Covers field and local variable initialisers, including `static final String`. Values read from .properties files are not analysed - this tool parses code, not config.',
|
|
279
|
+
},
|
|
280
|
+
go: {
|
|
281
|
+
status: 'implemented',
|
|
282
|
+
note: 'Covers :=, var, const and struct literal fields (Config{ApiKey: "..."}).',
|
|
283
|
+
},
|
|
284
|
+
php: {
|
|
285
|
+
status: 'implemented',
|
|
286
|
+
note: 'Covers $var = "..." and class property declarations. NOT covered: define(\'API_KEY\', \'...\') and const declarations, which are a different shape - a documented gap, not a silent one.',
|
|
287
|
+
},
|
|
288
|
+
c: {
|
|
289
|
+
status: 'implemented',
|
|
290
|
+
note:
|
|
291
|
+
'String literals and initialisers are analysed exactly as in every other language - C has no interpolation, so a credential is either a literal or it is not. NOT COVERED: a secret assembled by the preprocessor (#define KEY "...") is invisible, because the preprocessor is not run.',
|
|
292
|
+
},
|
|
293
|
+
cpp: {
|
|
294
|
+
status: 'implemented',
|
|
295
|
+
note:
|
|
296
|
+
'Every C entry applies unchanged, since C++ inherits the whole libc surface and real code still uses it. Namespace-qualified calls (std::system) and method calls on objects are recognised in addition.',
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
check(shape, ctx): RuleHit | null {
|
|
300
|
+
const language = ctx.language;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Test and example files get a severity downgrade, not a free pass.
|
|
304
|
+
* Measured on five real repositories, 167 of 179 raw findings were in test
|
|
305
|
+
* trees. Dropping them silently would be dishonest (a committed key is
|
|
306
|
+
* committed wherever it lives) and reporting them as HIGH buries the real
|
|
307
|
+
* ones. So: keep the finding, lower the severity, say why in the reasoning.
|
|
308
|
+
*
|
|
309
|
+
* THE SEVERITY PART OF THIS NOW HAPPENS IN core/analyze.ts, for every rule
|
|
310
|
+
* rather than only this one - see the note there. What stays here is the
|
|
311
|
+
* sentence only a credential rule can write: that the VALUE is likely a
|
|
312
|
+
* stand-in. `eval()` in a test is still a real eval; `password = 'x'` in a
|
|
313
|
+
* test is usually not a real password, and that difference is worth saying.
|
|
314
|
+
*/
|
|
315
|
+
const inTests = isTestPath(ctx.file.path);
|
|
316
|
+
const testNote = inTests
|
|
317
|
+
? ' NOTE: in a test or fixture file the value is usually a stand-in rather than a ' +
|
|
318
|
+
'live credential. It is still committed to git history, and fixtures do get ' +
|
|
319
|
+
'copy-pasted into production.'
|
|
320
|
+
: '';
|
|
321
|
+
const downgrade = (severity: 'critical' | 'high' | 'medium'): 'critical' | 'high' | 'medium' =>
|
|
322
|
+
severity;
|
|
323
|
+
|
|
324
|
+
/* ---- Any string literal argument that matches a known vendor format ----
|
|
325
|
+
* e.g. client.init("AKIAIOSFODNN7EXAMPLE"). No variable name to go on, but
|
|
326
|
+
* the shape of the value alone is conclusive enough to report. */
|
|
327
|
+
const call = asCall(shape);
|
|
328
|
+
if (call) {
|
|
329
|
+
for (const arg of call.args) {
|
|
330
|
+
const literal = literalOf(arg, language);
|
|
331
|
+
if (!literal) continue;
|
|
332
|
+
const known = matchKnownSecret(literal.text);
|
|
333
|
+
if (!known) continue;
|
|
334
|
+
return {
|
|
335
|
+
node: arg,
|
|
336
|
+
severity: downgrade('critical'),
|
|
337
|
+
message: `${known.label} passed literally to ${call.calleeText}()`,
|
|
338
|
+
reasoning:
|
|
339
|
+
`The string passed to \`${call.calleeText}()\` matches the published format of ` +
|
|
340
|
+
`a ${known.label}. Vendor key formats are distinctive enough that automated ` +
|
|
341
|
+
`scrapers search public code for exactly this pattern. Treat this key as ` +
|
|
342
|
+
`already compromised and rotate it, then move it to an environment variable.` +
|
|
343
|
+
testNote,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
for (const slot of KEY_ARGUMENTS[language] ?? []) {
|
|
347
|
+
if (call.calleeName !== slot.callee) continue;
|
|
348
|
+
if (slot.receiver && !slot.receiver.test(call.receiverText)) continue;
|
|
349
|
+
const arg = call.args[slot.index];
|
|
350
|
+
if (!arg || !isStringLiteral(arg, language)) continue;
|
|
351
|
+
const value = unquote((arg.text ?? '').trim());
|
|
352
|
+
if (value.length < 4 || TEMPLATE_REFERENCE.test(value)) continue;
|
|
353
|
+
return {
|
|
354
|
+
node: arg,
|
|
355
|
+
severity: downgrade('high'),
|
|
356
|
+
message: `Key written into the code, passed straight to ${call.calleeText}()`,
|
|
357
|
+
reasoning:
|
|
358
|
+
`Argument ${slot.index + 1} of \`${call.calleeText}()\` is the key it signs, encrypts or ` +
|
|
359
|
+
`verifies with, and here it is a fixed string literal (${value.length} characters) rather ` +
|
|
360
|
+
`than a value read from the environment or a secrets manager. Anyone who can read the ` +
|
|
361
|
+
`code - or its git history - can forge what this key protects.` +
|
|
362
|
+
testNote,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const assignment = asAssignment(shape);
|
|
369
|
+
if (!assignment) return null;
|
|
370
|
+
|
|
371
|
+
const literal = literalOf(assignment.value, language);
|
|
372
|
+
if (!literal) return null; // `password = getSecret()` is the correct pattern.
|
|
373
|
+
|
|
374
|
+
const value = literal.text;
|
|
375
|
+
const name = assignment.targetName;
|
|
376
|
+
|
|
377
|
+
/* ---- Signal 1: a known vendor key format. Strongest. ---- */
|
|
378
|
+
const known = matchKnownSecret(value);
|
|
379
|
+
if (known) {
|
|
380
|
+
return {
|
|
381
|
+
node: assignment.node,
|
|
382
|
+
severity: downgrade('critical'),
|
|
383
|
+
message: `${known.label} hardcoded in \`${name}\``,
|
|
384
|
+
reasoning:
|
|
385
|
+
`The literal assigned to \`${name}\` matches the published format of a ` +
|
|
386
|
+
`${known.label}. This is not a name-based guess - the value itself is ` +
|
|
387
|
+
`structurally identifiable as a credential. Rotate it (assume it is already ` +
|
|
388
|
+
`public if this repo has ever been shared) and load it from the environment.` +
|
|
389
|
+
testNote,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (isLikelyPlaceholder(value)) return null;
|
|
394
|
+
/*
|
|
395
|
+
* A SECRET IS NOT A SENTENCE. cal.com stores its user-facing error strings
|
|
396
|
+
* under credential-shaped names - `encryption_key_missing`, `api_key_invalid`
|
|
397
|
+
* - so the name pattern matched and the value was an error message. See
|
|
398
|
+
* looksLikeProse(), which is deliberately word-shaped rather than
|
|
399
|
+
* space-shaped so that a diceware passphrase still reports.
|
|
400
|
+
*/
|
|
401
|
+
if (looksLikeProse(name, value)) return null;
|
|
402
|
+
// A value that only restates its own variable name is a lookup key, not a
|
|
403
|
+
// credential. See echoesItsOwnName() - this was 152 findings on Keycloak.
|
|
404
|
+
if (echoesItsOwnName(name, value)) return null;
|
|
405
|
+
if (isStatementOrTemplate(value)) return null;
|
|
406
|
+
if (isLanguageToken(name, value)) return null;
|
|
407
|
+
if (isPublicIdentifier(name, value)) return null;
|
|
408
|
+
|
|
409
|
+
/* ---- Signal 2: the variable name says "credential". ---- */
|
|
410
|
+
if (SECRET_NAME.test(name) && !NOT_A_SECRET.test(name)) {
|
|
411
|
+
if (value.length < 4) return null; // "", "x" - not a real secret
|
|
412
|
+
// A suspicious NAME cannot outvote a value that is structurally not a
|
|
413
|
+
// key. `token: 'delimiter.curly'` is a theme scope, not a credential.
|
|
414
|
+
if (isStructuralIdentifier(value)) return null;
|
|
415
|
+
if (namesAKeySlot(value)) return null;
|
|
416
|
+
return {
|
|
417
|
+
node: assignment.node,
|
|
418
|
+
severity: downgrade('high'),
|
|
419
|
+
message: `Credential-looking value hardcoded in \`${name}\``,
|
|
420
|
+
reasoning:
|
|
421
|
+
`\`${name}\` matches the naming pattern for a credential and is assigned a ` +
|
|
422
|
+
`fixed string literal (${value.length} characters) rather than being read from ` +
|
|
423
|
+
`the environment or a secrets manager. Anything committed here is in git ` +
|
|
424
|
+
`history permanently, even if the line is deleted later.` + testNote,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/* ---- Signal 3: entropy only. Weakest - and labelled as such. ---- */
|
|
429
|
+
// The structural gate comes FIRST: entropy alone cannot tell a key from a
|
|
430
|
+
// MIME type. See looksLikeToken() for the measurements that motivated this.
|
|
431
|
+
/*
|
|
432
|
+
* AN IDENTIFIER IS NOT A CREDENTIAL, however random it looks.
|
|
433
|
+
*
|
|
434
|
+
* Mattermost's TypeScript produced 266 hardcoded-secret findings and 160
|
|
435
|
+
* came down this path, nearly all of them shaped like
|
|
436
|
+
*
|
|
437
|
+
* id: 'owsyt8n43jfxjpzh9np93mx1wa'
|
|
438
|
+
* group_id: 'cpa9q4w7m2x5c8v1b6n3k0jr5h'
|
|
439
|
+
*
|
|
440
|
+
* in `.test.tsx` fixtures. Those are Mattermost's 26-character record ids.
|
|
441
|
+
* The entropy heuristic is not wrong about them - they ARE random, that is
|
|
442
|
+
* the point of an id - it is just answering a question nobody asked.
|
|
443
|
+
*
|
|
444
|
+
* Unlike the name path, the entropy path had no name check whatsoever: it
|
|
445
|
+
* fired on any variable at all. A name that says "identifier" is the
|
|
446
|
+
* cheapest possible signal that randomness is expected here, and this is
|
|
447
|
+
* the WEAKEST of the three signals to begin with, so a hardcoded session id
|
|
448
|
+
* lost to it costs a `medium` guess rather than a real detection.
|
|
449
|
+
*/
|
|
450
|
+
if (/(^|_)(id|ids|uuid|guid|sid|gid|uid)$/i.test(name) || /Id$/.test(name)) return null;
|
|
451
|
+
/*
|
|
452
|
+
* A PROPERTY NAMED `example` IS DOCUMENTATION, and it is high-entropy on
|
|
453
|
+
* purpose - a sample ID or JWT has to look real to be useful. cal.com's
|
|
454
|
+
* OpenAPI output schemas are built from them. The entropy path had no name
|
|
455
|
+
* check at all until Mattermost's record ids forced one directly above;
|
|
456
|
+
* this is the same idea, and `example` is the OpenAPI keyword rather than a
|
|
457
|
+
* guess at intent.
|
|
458
|
+
*/
|
|
459
|
+
if (/(^|[._-])examples?$|(^|[._-])samples?$/i.test(name)) return null;
|
|
460
|
+
|
|
461
|
+
if (value.length >= MIN_ENTROPY_LENGTH && looksLikeToken(value)) {
|
|
462
|
+
const entropy = shannonEntropy(value);
|
|
463
|
+
if (entropy >= ENTROPY_THRESHOLD) {
|
|
464
|
+
return {
|
|
465
|
+
node: assignment.node,
|
|
466
|
+
severity: downgrade('medium'),
|
|
467
|
+
message: `High-entropy literal assigned to \`${name}\` (possible secret)`,
|
|
468
|
+
reasoning:
|
|
469
|
+
`The value assigned to \`${name}\` is ${value.length} characters long with a ` +
|
|
470
|
+
`Shannon entropy of ${entropy.toFixed(2)} bits/character. Ordinary English text ` +
|
|
471
|
+
`scores around 2.5-3.5; generated keys and tokens usually score above 4. ` +
|
|
472
|
+
`THIS IS A RANDOMNESS HEURISTIC, NOT AN IDENTIFICATION - a hash, a UUID, a ` +
|
|
473
|
+
`base64 test fixture or a minified blob will score the same. Severity is ` +
|
|
474
|
+
`deliberately lower than a name or format match for that reason.` + testNote,
|
|
475
|
+
limitations:
|
|
476
|
+
'UNVERIFIED (signature-based, entropy heuristic): this finding rests ONLY on how ' +
|
|
477
|
+
'random the text looks. There is no name evidence and no format evidence. ' +
|
|
478
|
+
'Expect false positives and treat it as "worth a glance", not "a secret".',
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return null;
|
|
484
|
+
},
|
|
485
|
+
};
|