@clear-capabilities/agentic-security-scanner 0.132.0 → 0.133.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/CHANGELOG.md +125 -0
- package/bin/agentic-security.js +20 -1
- package/dist/113.index.js +3 -3
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/526.index.js +3 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +22 -22
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -3
- package/src/engine.js +32 -1
- package/src/llm-validator/cost-ceiling.js +199 -0
- package/src/llm-validator/index.js +241 -12
- package/src/llm-validator/local-endpoint.js +90 -0
- package/src/posture/accuracy-scorecard.js +37 -6
- package/src/posture/corpus-match.js +29 -14
- package/src/posture/integrity.js +42 -9
- package/src/posture/learning.js +8 -1
- package/src/posture/model-routing.js +26 -0
- package/src/posture/model-trust.js +174 -0
- package/src/posture/poc-inprocess.js +165 -0
- package/src/posture/prove-findings.js +148 -0
- package/src/posture/rule-overrides.js +64 -3
- package/src/posture/state-dir.js +25 -0
- package/src/posture/vuln-archaeology.js +231 -0
- package/src/report/index.js +7 -0
- package/src/sandbox/CLAUDE.md +27 -5
- package/src/sandbox/backend-namespace.js +39 -11
- package/src/sandbox/backend-userspace.js +4 -0
- package/src/sast/CLAUDE.md +4 -0
- package/src/sast/crypto-specialist.js +247 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// R16 — specialist audit classes.
|
|
2
|
+
//
|
|
3
|
+
// Narrow, high-credibility crypto-hygiene checks that a general injection
|
|
4
|
+
// detector will never find, because nothing about the code is "tainted": every
|
|
5
|
+
// value here is already trusted, and the defect is in HOW it is handled.
|
|
6
|
+
//
|
|
7
|
+
// Two classes, chosen because each has an unambiguous correct form to point at,
|
|
8
|
+
// which is what keeps a specialist rule credible:
|
|
9
|
+
//
|
|
10
|
+
// 1. TIMING-UNSAFE COMPARISON (CWE-208). Comparing a secret with `===`,
|
|
11
|
+
// `.equals()` or `memcmp` short-circuits on the first differing byte, so
|
|
12
|
+
// the time taken leaks how many leading bytes were right. Repeated across
|
|
13
|
+
// requests that recovers the secret a byte at a time. Every language here
|
|
14
|
+
// ships a constant-time comparison; the fix is to call it.
|
|
15
|
+
//
|
|
16
|
+
// 2. NON-ZEROIZABLE SECRET MATERIAL (CWE-316). A secret in immutable storage
|
|
17
|
+
// cannot be erased after use, so it lingers in memory until GC and lands
|
|
18
|
+
// in heap dumps, core files and swap. Java's `String` is the classic case
|
|
19
|
+
// — the reason `char[]` exists in the JCA password APIs.
|
|
20
|
+
//
|
|
21
|
+
// PRECISION DISCIPLINE. Both rules key on the SECRET-NESS of the identifier,
|
|
22
|
+
// not on the comparison or the type alone: `if (a === b)` is not a finding, and
|
|
23
|
+
// neither is every `String`. That keeps the rules narrow enough to be worth
|
|
24
|
+
// believing. The correct constant-time form must never match — each detector
|
|
25
|
+
// checks for the safe API first and stays silent when it is present.
|
|
26
|
+
|
|
27
|
+
import { blankComments } from './_comment-strip.js';
|
|
28
|
+
|
|
29
|
+
// Identifiers that denote a secret worth constant-time treatment. Deliberately
|
|
30
|
+
// specific: "token"/"key" alone are too common in ordinary code (a map key, a
|
|
31
|
+
// pagination token), so they are only counted with a qualifying prefix.
|
|
32
|
+
const SECRET_IDENT = /\b(?:hmac|signature|sig|mac|digest|password|passwd|pwd|secret|api_?key|apikey|auth_?token|access_?token|session_?token|csrf_?token|otp|totp|nonce_?hash|password_?hash|expected_?hash|challenge_?response)\b/i;
|
|
33
|
+
|
|
34
|
+
// Constant-time comparison APIs, per ecosystem. Presence of any of these on a
|
|
35
|
+
// line means the author already did the right thing.
|
|
36
|
+
const CONSTANT_TIME = /\b(?:timingSafeEqual|compare_digest|ConstantTimeCompare|hash_equals|MessageDigest\.isEqual|CryptographicOperations\.FixedTimeEquals|secure_compare|sodium_memcmp|CRYPTO_memcmp|NSData.*isEqualToData|constantTimeAreEqual|slowEquals)\b/;
|
|
37
|
+
|
|
38
|
+
// An unsafe comparison of two expressions, where at least one names a secret.
|
|
39
|
+
const UNSAFE_COMPARE = [
|
|
40
|
+
// JS/TS, Python, Go, PHP, Ruby: ==, ===, !=, !==
|
|
41
|
+
{ re: /([A-Za-z_$][\w$.\[\]'"]*)\s*(?:===|==|!==|!=)\s*([A-Za-z_$][\w$.\[\]'"()]*)/g, form: 'equality operator' },
|
|
42
|
+
// Java/C#/Kotlin/Scala: a.equals(b) / a.Equals(b). The capital form is the
|
|
43
|
+
// .NET spelling and was a real miss — a C# fixture scored pre:FN on it.
|
|
44
|
+
{ re: /([A-Za-z_$][\w$.]*)\s*\.\s*[Ee]quals\s*\(\s*([A-Za-z_$][\w$.()]*)\s*\)/g, form: '.equals()' },
|
|
45
|
+
// C/C++: memcmp / strcmp / strncmp
|
|
46
|
+
{ re: /\b(?:mem|str|strn)cmp\s*\(\s*([A-Za-z_][\w.\->\[\]]*)\s*,\s*([A-Za-z_][\w.\->\[\]]*)/g, form: 'memcmp/strcmp' },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const REMEDIATION = {
|
|
50
|
+
js: 'Use crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)) — it compares in constant time. Guard the length first, since it throws on a length mismatch.',
|
|
51
|
+
py: 'Use hmac.compare_digest(a, b).',
|
|
52
|
+
go: 'Use subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1.',
|
|
53
|
+
java: 'Use MessageDigest.isEqual(a, b) on byte[].',
|
|
54
|
+
cs: 'Use CryptographicOperations.FixedTimeEquals(a, b).',
|
|
55
|
+
php: 'Use hash_equals($known, $user).',
|
|
56
|
+
rb: 'Use ActiveSupport::SecurityUtils.secure_compare(a, b), or OpenSSL.secure_compare.',
|
|
57
|
+
c: 'Use CRYPTO_memcmp (OpenSSL) or sodium_memcmp — never memcmp/strcmp on secrets.',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const EXT_LANG = {
|
|
61
|
+
'.js': 'js', '.mjs': 'js', '.cjs': 'js', '.ts': 'js', '.tsx': 'js', '.jsx': 'js',
|
|
62
|
+
'.py': 'py', '.go': 'go', '.java': 'java', '.kt': 'java', '.scala': 'java',
|
|
63
|
+
'.cs': 'cs', '.php': 'php', '.rb': 'rb',
|
|
64
|
+
'.c': 'c', '.h': 'c', '.cc': 'c', '.cpp': 'c', '.cxx': 'c', '.hpp': 'c',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function _lang(file) {
|
|
68
|
+
const m = String(file).toLowerCase().match(/\.[a-z]+$/);
|
|
69
|
+
return m ? EXT_LANG[m[0]] || null : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function _lineAt(text, index) {
|
|
73
|
+
return text.slice(0, index).split('\n').length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* CWE-208 — comparison of secret material in non-constant time.
|
|
78
|
+
*/
|
|
79
|
+
// JavaScript is already covered TWICE — by `sast/comparison-safety.js` and by
|
|
80
|
+
// the `Timing Oracle` rule in engine.js — so firing here made a third finding
|
|
81
|
+
// on the same line. The synthetic F1 bench counted the duplicates as false
|
|
82
|
+
// positives and the strict gate failed on a 0.34pp F1 regression. That is the
|
|
83
|
+
// mistake this module's own header warns about: a noisy specialist rule is
|
|
84
|
+
// worse than none.
|
|
85
|
+
//
|
|
86
|
+
// Excluded for JS ONLY, and the scope was checked rather than assumed. The
|
|
87
|
+
// first attempt also excluded Python and Go on the theory that
|
|
88
|
+
// `comparison-safety.js` covers them — it nominally does, but its
|
|
89
|
+
// TIMING_SENSITIVE vocabulary uses word boundaries (`\btoken\b`), so
|
|
90
|
+
// `access_token == secret` matches nothing: `token` is not on a word boundary
|
|
91
|
+
// inside `access_token`, and bare `secret` is not in its list at all. Excluding
|
|
92
|
+
// Python would therefore have removed the ONLY coverage of that shape. Verified
|
|
93
|
+
// by scanning the fixture: zero findings without this rule.
|
|
94
|
+
const ALREADY_COVERED_LANGS = new Set(['js']);
|
|
95
|
+
|
|
96
|
+
export function scanTimingUnsafeComparison(file, content) {
|
|
97
|
+
const lang = _lang(file);
|
|
98
|
+
if (!lang || typeof content !== 'string') return [];
|
|
99
|
+
if (ALREADY_COVERED_LANGS.has(lang)) return [];
|
|
100
|
+
const body = blankComments(content, file);
|
|
101
|
+
const out = [];
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
|
|
104
|
+
for (const { re, form } of UNSAFE_COMPARE) {
|
|
105
|
+
const rx = new RegExp(re.source, re.flags);
|
|
106
|
+
let m;
|
|
107
|
+
while ((m = rx.exec(body))) {
|
|
108
|
+
const [whole, left, right] = m;
|
|
109
|
+
if (!SECRET_IDENT.test(left || '') && !SECRET_IDENT.test(right || '')) continue;
|
|
110
|
+
|
|
111
|
+
const line = _lineAt(body, m.index);
|
|
112
|
+
const lineText = body.split('\n')[line - 1] || '';
|
|
113
|
+
// Already constant-time on this line: the author did it right.
|
|
114
|
+
if (CONSTANT_TIME.test(lineText)) continue;
|
|
115
|
+
// A length check is not a secret comparison — `sig.length === 64` leaks
|
|
116
|
+
// nothing an attacker cannot already measure.
|
|
117
|
+
if (/\.(?:length|size|len)\b/i.test(whole) || /\blen\s*\(/i.test(whole)) continue;
|
|
118
|
+
// Comparisons against a null/undefined/empty sentinel are presence
|
|
119
|
+
// checks, not secret comparisons. Tested on the OPERANDS, not on the
|
|
120
|
+
// whole match: the right-hand capture can swallow a trailing paren, and
|
|
121
|
+
// an end-anchored test against the raw match then silently misses.
|
|
122
|
+
const _sentinel = (x) => /^(?:null|nil|None|undefined|""|''|0|false|true)$/i
|
|
123
|
+
.test(String(x || '').replace(/[)\s;,]+$/, '').trim());
|
|
124
|
+
if (_sentinel(left) || _sentinel(right)) continue;
|
|
125
|
+
|
|
126
|
+
const key = `${file}:${line}`;
|
|
127
|
+
if (seen.has(key)) continue;
|
|
128
|
+
seen.add(key);
|
|
129
|
+
|
|
130
|
+
out.push({
|
|
131
|
+
id: `timing-unsafe-compare-${file}-${line}`,
|
|
132
|
+
severity: 'medium',
|
|
133
|
+
file,
|
|
134
|
+
line,
|
|
135
|
+
vuln: 'Non-Constant-Time Comparison of Secret',
|
|
136
|
+
cwe: 'CWE-208',
|
|
137
|
+
family: 'timing-side-channel',
|
|
138
|
+
parser: 'REGEX',
|
|
139
|
+
description:
|
|
140
|
+
`A secret is compared with ${form}, which returns as soon as two bytes differ. `
|
|
141
|
+
+ 'The time taken therefore depends on how many leading bytes matched, and an attacker '
|
|
142
|
+
+ 'who can measure it recovers the value one byte at a time.',
|
|
143
|
+
remediation: REMEDIATION[lang],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Java-family immutable secret storage. `String` cannot be overwritten, so a
|
|
151
|
+
// password held in one survives until GC and appears in heap dumps.
|
|
152
|
+
//
|
|
153
|
+
// NARROWED after the real-world bench measured the cost of the broad form. The
|
|
154
|
+
// first version matched ANY `String <secretish> = …`, including a hardcoded
|
|
155
|
+
// literal — which is the single most common shape in a large public Java
|
|
156
|
+
// vulnerability corpus (its hard-coded-password cases number in the thousands).
|
|
157
|
+
// Every one produced a CWE-316 finding, dropping that corpus's F1 to 89.2%
|
|
158
|
+
// against a 95% floor. A Java-heavy benchmark WITHOUT that shape scored 99.6%
|
|
159
|
+
// in the same run, which is what isolated the cause.
|
|
160
|
+
//
|
|
161
|
+
// Two reasons the literal case must not fire here. It is already the
|
|
162
|
+
// hardcoded-secret detectors' job, so this was a duplicate — the same mistake
|
|
163
|
+
// as the JS timing rule. And the CWE-316 claim is about secret material living
|
|
164
|
+
// in memory that cannot be wiped; a literal is in the class file either way, so
|
|
165
|
+
// `char[]` buys nothing. The defect only exists when a RUNTIME secret is loaded
|
|
166
|
+
// into an immutable String.
|
|
167
|
+
const JAVA_STRING_SECRET = /\bString\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+);/g;
|
|
168
|
+
|
|
169
|
+
// Initializers that actually load a secret at runtime.
|
|
170
|
+
const RUNTIME_SECRET_SOURCE = /\b(?:System\s*\.\s*getenv|System\s*\.\s*getProperty|readPassword|readLine|getPassword|getSecret|getToken|getApiKey|getCredential|vault|keystore|getConnectionPassword)\b/i;
|
|
171
|
+
|
|
172
|
+
// C-family zeroization that the compiler is permitted to delete. A `memset`
|
|
173
|
+
// whose buffer is never read afterwards is a dead store, and optimisers remove
|
|
174
|
+
// it — the canonical reason `explicit_bzero`/`memset_s` exist.
|
|
175
|
+
const C_MEMSET_SECRET = /\bmemset\s*\(\s*([A-Za-z_][\w.\->\[\]]*)\s*,\s*0\s*,/g;
|
|
176
|
+
const C_SAFE_ZERO = /\b(?:explicit_bzero|memset_s|SecureZeroMemory|sodium_memzero|OPENSSL_cleanse)\b/;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* CWE-316 — secret material that cannot be erased after use.
|
|
180
|
+
*/
|
|
181
|
+
export function scanMissingZeroization(file, content) {
|
|
182
|
+
const lang = _lang(file);
|
|
183
|
+
if (!lang || typeof content !== 'string') return [];
|
|
184
|
+
const body = blankComments(content, file);
|
|
185
|
+
const out = [];
|
|
186
|
+
|
|
187
|
+
if (lang === 'java') {
|
|
188
|
+
let m;
|
|
189
|
+
const rx = new RegExp(JAVA_STRING_SECRET.source, JAVA_STRING_SECRET.flags);
|
|
190
|
+
while ((m = rx.exec(body))) {
|
|
191
|
+
const name = m[1];
|
|
192
|
+
const init = m[2] || '';
|
|
193
|
+
if (!SECRET_IDENT.test(name)) continue;
|
|
194
|
+
// A literal is the hardcoded-secret detectors' finding, not this one.
|
|
195
|
+
if (!RUNTIME_SECRET_SOURCE.test(init)) continue;
|
|
196
|
+
const line = _lineAt(body, m.index);
|
|
197
|
+
out.push({
|
|
198
|
+
id: `non-zeroizable-secret-${file}-${line}`,
|
|
199
|
+
severity: 'low',
|
|
200
|
+
file,
|
|
201
|
+
line,
|
|
202
|
+
vuln: 'Secret Held in Non-Zeroizable Storage',
|
|
203
|
+
cwe: 'CWE-316',
|
|
204
|
+
family: 'key-hygiene',
|
|
205
|
+
parser: 'REGEX',
|
|
206
|
+
description:
|
|
207
|
+
`'${name}' holds secret material in a String. Strings are immutable, so the value cannot be `
|
|
208
|
+
+ 'overwritten after use: it stays readable in the heap until garbage collection and can '
|
|
209
|
+
+ 'surface in heap dumps, core files and swap. This is why the JCA password APIs take char[].',
|
|
210
|
+
remediation: 'Hold the secret in a char[] or byte[] and overwrite it with java.util.Arrays.fill(buf, (char) 0) in a finally block once it is no longer needed.',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (lang === 'c') {
|
|
216
|
+
if (C_SAFE_ZERO.test(body)) return out; // the author already uses a guaranteed wipe
|
|
217
|
+
let m;
|
|
218
|
+
const rx = new RegExp(C_MEMSET_SECRET.source, C_MEMSET_SECRET.flags);
|
|
219
|
+
while ((m = rx.exec(body))) {
|
|
220
|
+
const name = m[1];
|
|
221
|
+
if (!SECRET_IDENT.test(name)) continue;
|
|
222
|
+
const line = _lineAt(body, m.index);
|
|
223
|
+
out.push({
|
|
224
|
+
id: `removable-zeroization-${file}-${line}`,
|
|
225
|
+
severity: 'medium',
|
|
226
|
+
file,
|
|
227
|
+
line,
|
|
228
|
+
vuln: 'Secret Wipe Removable by the Optimiser',
|
|
229
|
+
cwe: 'CWE-316',
|
|
230
|
+
family: 'key-hygiene',
|
|
231
|
+
parser: 'REGEX',
|
|
232
|
+
description:
|
|
233
|
+
`'${name}' is zeroed with memset, but nothing reads the buffer afterwards. That makes the `
|
|
234
|
+
+ 'write a dead store, which an optimising compiler is entitled to delete outright — so the '
|
|
235
|
+
+ 'secret is left in memory in exactly the build where it matters most.',
|
|
236
|
+
remediation: 'Use explicit_bzero (BSD/glibc), memset_s (C11 Annex K), SecureZeroMemory (Windows), sodium_memzero or OPENSSL_cleanse — each is guaranteed not to be optimised away.',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Both specialist classes in one call, for the engine. */
|
|
245
|
+
export function scanCryptoSpecialist(file, content) {
|
|
246
|
+
return [...scanTimingUnsafeComparison(file, content), ...scanMissingZeroization(file, content)];
|
|
247
|
+
}
|