@clear-capabilities/agentic-security-scanner 0.130.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 +247 -0
- package/bin/agentic-security.js +39 -3
- package/dist/113.index.js +294 -5
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +7 -4
- package/dist/238.index.js +218 -0
- package/dist/259.index.js +975 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +2 -2
- package/dist/526.index.js +294 -5
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +18 -57
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +19 -10
- package/src/engine.js +48 -1
- package/src/ir/parser-js.js +8 -0
- 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/mcp/tools.js +2 -2
- package/src/posture/CLAUDE.md +83 -6
- package/src/posture/accuracy-scorecard.js +37 -6
- package/src/posture/attestation.js +7 -4
- package/src/posture/corpus-enroll.js +303 -0
- package/src/posture/corpus-match.js +67 -0
- package/src/posture/custom-rules.js +2 -2
- package/src/posture/execution-proof.js +44 -4
- package/src/posture/fix-metrics.js +197 -0
- package/src/posture/fix-verify.js +76 -2
- 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/root-cause-sweep.js +0 -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/runScan.js +2 -6
- package/src/sandbox/CLAUDE.md +190 -46
- package/src/sandbox/backend-namespace.js +328 -48
- package/src/sandbox/backend-userspace.js +6 -19
- package/src/sandbox/capabilities.js +132 -4
- package/src/sandbox/limits.js +21 -0
- package/src/sandbox/result.js +1 -1
- package/src/sast/CLAUDE.md +4 -0
- package/src/sast/crypto-specialist.js +247 -0
- package/src/util/glob.js +173 -0
package/src/sandbox/result.js
CHANGED
|
@@ -63,7 +63,7 @@ export function buildResult({ backend, spawnResult, unsupported = [] }) {
|
|
|
63
63
|
status,
|
|
64
64
|
denied,
|
|
65
65
|
stdout: r.stdout ?? '',
|
|
66
|
-
stderr: rawStderr + (unsupported.length ? `\n[sandbox]
|
|
66
|
+
stderr: rawStderr + (unsupported.length ? `\n[sandbox] not enforceable here: ${unsupported.join(', ')}` : ''),
|
|
67
67
|
exitCode: r.status ?? null,
|
|
68
68
|
timedOut,
|
|
69
69
|
backend,
|
package/src/sast/CLAUDE.md
CHANGED
|
@@ -25,6 +25,10 @@ SAST detector modules. Each file exports one or more `scan*()` functions returni
|
|
|
25
25
|
|
|
26
26
|
**Cross-cutting vuln classes** — `authz.js`, `csrf.js` (POST/PUT/PATCH/DELETE state-changing routes without CSRF defence; defence-aware suppression covers Express/Fastify/Flask/Django/FastAPI/Spring/Symfony **and Go (gin/echo/mux), Rails routes, ASP.NET MVC** — recognizes `gorilla/csrf`/`protect_from_forgery`/`[ValidateAntiForgeryToken]` defences and exempts token-auth (`[ApiController]`, Bearer scheme); bare ASP.NET `[Authorize]` still flags as cookie auth is CSRF-vulnerable), `code-injection-multilang.js` (CWE-94 for Java/C#/Go/Kotlin — dynamic code/expression evaluators on a NON-LITERAL argument: javax.script `eval`, GroovyShell, Spring SpEL `parseExpression`, MVEL/OGNL, Roslyn `CSharpScript`, `DataTable.Compute`, yaegi `interp.Eval`, `text/template` Parse of a user-controlled body; literal arguments don't match. JS/Python/Ruby eval stay with the flow engine + per-language modules), `csv-injection.js` (formula injection into spreadsheet cells, CWE-1236), `secret-concat.js` (language-agnostic hardcoded-secret SPLIT across concatenated literals — `'AKIA' + 'IOSF…'` / `'ghp' + '_…'` / `'sk' + '_live_…'` — reassembled and matched against provider prefixes; complements the contiguous-token secrets scanner and the C#-only split-concat rule), `host-header.js`, `jndi.js`, `jwt-exp.js`, `ldap-injection.js` (CWE-90 across JS/Java/Python **and** PHP/Go/C#/Ruby/Kotlin — filter built by concat/interpolation; an inline call-guard and a file-level escape-API guard suppress `ldap_escape`/`EscapeFilter`/`escape_filter_chars`/`Net::LDAP::Filter`/`EqualityFilter` forms), `xpath-injection.js` (CWE-643 across Java/Python/JS **and** PHP/Go/Ruby/C#/Kotlin — XPath expression built by concat/interpolation: `DOMXPath->query`, `SelectNodes`, Nokogiri `.xpath`, htmlquery/xmlpath, `XPath.compile`; embedded-quote-tolerant literal matching; parameterized/variable-bound APIs and static literals don't match), `mass-assignment.js`, `mutation-xss.js`, `nosql-injection.js`, `prototype-pollution.js`, `response-splitting.js` (CWE-113 CRLF/header injection across JS/Python/Java/PHP/Go/Ruby/C#/Kotlin — a response header value set from a request source without stripping CR/LF; recognizes CRLF-strip sanitizers — `.replace(/[\r\n]/)`, chained `.replace("\r")`, Ruby `gsub`/`delete`, Go `strings.NewReplacer`, PHP `str_replace` — and a request-scope param heuristic for the JVM/C# single-file shape), `ssrf-cloud-metadata.js`, `xss-reflected-multilang.js` (cross-language reflected XSS for Go/Ruby/PHP/C#/Kotlin/Java — user input written into an HTML response via concat/interpolation, e.g. Java servlet `response.getWriter().write("<…" + q)`, with a per-language escaper exclusion so `htmlspecialchars`/`HtmlEncode`/`template.HTMLEscapeString`/ERB `<%= %>`/OWASP `Encode.forHtml` forms don't match; JS/Python XSS stays with the flow engine + framework structural detectors), `stored-taint.js` (second-order / stored injection — **opt-in** via `AGENTIC_SECURITY_STORED_TAINT=1`), `toctou.js`, `wrong-context-sanitizer.js` (HTML-entity encoder used in a URL context — wrong-context output encoding, CWE-79), `zip-slip.js`, `file-upload.js` (CWE-434 unrestricted file upload for JS/Python — Multer configured with no `fileFilter`/`limits`, and a write whose destination is built from the client-supplied filename (`originalname`/`req.files.*.name`/`.filename`); suppressed by a `basename`/uuid/`secure_filename`/sanitizer in the window).
|
|
27
27
|
|
|
28
|
+
**Specialist audit classes (R16)** — `crypto-specialist.js`. Narrow, high-credibility crypto-hygiene rules that no injection detector will ever find, because nothing is tainted: every value is already trusted and the defect is in how it is *handled*. Two classes, each chosen because it has an unambiguous correct form to point at. **CWE-208 non-constant-time comparison** — a secret compared with `===`/`.equals()`/`memcmp` short-circuits on the first differing byte, leaking how many leading bytes matched; silent whenever the line already uses `timingSafeEqual`/`compare_digest`/`ConstantTimeCompare`/`hash_equals`/`MessageDigest.isEqual`/`FixedTimeEquals`/`CRYPTO_memcmp`. **CWE-316 non-zeroizable secret material** — a Java `String` password (immutable, so it survives in the heap until GC and lands in heap dumps), and a C `memset(secret, 0, …)` that nothing reads afterwards (a dead store an optimiser may delete, which is why `explicit_bzero`/`memset_s` exist); a file already using a guaranteed wipe is left alone.
|
|
29
|
+
|
|
30
|
+
Both key on the SECRET-NESS of the identifier, never on the comparison or the type alone — `if (a === b)` and `String name` are not findings. Length checks (`sig.length === 64`) and sentinel presence checks (`apiKey === null`) are excluded: the first leaks nothing an attacker cannot already measure, and the second is not a secret comparison at all. Keep it that way. A noisy specialist rule is worse than none, because it teaches people to ignore the whole class.
|
|
31
|
+
|
|
28
32
|
**Cloud/infra** — `db-rls.js` (Supabase RLS), `env-hygiene.js` (NEXT_PUBLIC_ leaks, .env.example real values), `mobile-manifest.js`, `pipeline.js` (CI/CD integrity), `rate-limit.js`, `webhook.js`.
|
|
29
33
|
|
|
30
34
|
**LLM / agent** — `llm.js`, `llm-owasp.js`, `llm-trading-agent.js`, `mcp-audit.js`, `model-load.js`, `prompt-firewall.js`, `prompt-template.js`.
|
|
@@ -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
|
+
}
|
package/src/util/glob.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// File discovery on top of the Node standard library only.
|
|
2
|
+
//
|
|
3
|
+
// Two entry points, both replacing a third-party glob package that used to be a
|
|
4
|
+
// production dependency:
|
|
5
|
+
//
|
|
6
|
+
// listFiles(root, {ignore}) — the scan-tree walk behind readScan/readTree.
|
|
7
|
+
// globFiles(pattern, {cwd}) — resolve a user-supplied glob to regular files.
|
|
8
|
+
//
|
|
9
|
+
// `globFiles` is a thin wrapper: `fs.promises.glob` matches the old behaviour
|
|
10
|
+
// for the way those call sites used it (dot:false, onlyFiles:true, symlinks
|
|
11
|
+
// followed), so all it adds is the files-only filter.
|
|
12
|
+
//
|
|
13
|
+
// `listFiles` cannot be a thin wrapper, and the reasons are worth stating
|
|
14
|
+
// precisely, because each one is a way file discovery could silently change:
|
|
15
|
+
//
|
|
16
|
+
// * dot — the built-in glob (like every minimatch-derived matcher) will not
|
|
17
|
+
// let `*` or `**` match a path segment starting with `.`, and exposes no
|
|
18
|
+
// option to change that. The scan tree must include hidden files and must
|
|
19
|
+
// descend into hidden directories, so the walk is done with fs.readdir
|
|
20
|
+
// instead, where visibility is simply not a concept.
|
|
21
|
+
// * onlyFiles — a glob walk yields directories as well; only regular files
|
|
22
|
+
// are wanted. readdir's Dirent gives that directly, with no extra stat.
|
|
23
|
+
// * followSymbolicLinks:false — the built-in glob follows symlinked
|
|
24
|
+
// directories with no option to stop it, which would let a link inside the
|
|
25
|
+
// tree pull unrelated content (or content outside the scan root) into the
|
|
26
|
+
// scan. readdir's Dirent is lstat-derived: a symlink reports isSymbolicLink
|
|
27
|
+
// and neither isDirectory nor isFile, so links are skipped by construction
|
|
28
|
+
// and their targets are never reached.
|
|
29
|
+
// * suppressErrors — an unreadable directory must not abort the walk. Each
|
|
30
|
+
// readdir is individually guarded.
|
|
31
|
+
// * ignore — patterns are still matched with the built-in path.matchesGlob;
|
|
32
|
+
// only the dot-blindness above is compensated for (see unhide()).
|
|
33
|
+
//
|
|
34
|
+
// Ordering matches the previous implementation's breadth-first shape: every
|
|
35
|
+
// entry of one depth is emitted before any entry of the next.
|
|
36
|
+
import * as fs from 'node:fs/promises';
|
|
37
|
+
import * as path from 'node:path';
|
|
38
|
+
|
|
39
|
+
// Two properties of path.matchesGlob have to be corrected, and both are
|
|
40
|
+
// corrected the same way: by rewriting the subject *and* the pattern through
|
|
41
|
+
// the same injective transform, so the built-in matcher does the actual glob
|
|
42
|
+
// work while the transform carries the semantics it will not.
|
|
43
|
+
//
|
|
44
|
+
// 1. Dot-blindness. A wildcard will not match a path segment starting with
|
|
45
|
+
// `.`, and there is no option to change that. Rewriting a segment's leading
|
|
46
|
+
// dot to a control character makes wildcards dot-permissive while keeping
|
|
47
|
+
// an explicitly-named segment literal: pattern `.git` becomes `<1>git`,
|
|
48
|
+
// which still matches a directory named `.git` and still does not match one
|
|
49
|
+
// named `git`. Only applied where dot-permissive matching is wanted.
|
|
50
|
+
// 2. Partial case-folding. The matcher folds case in some positions — e.g.
|
|
51
|
+
// `UPPER/MiXeD.JS` matches `**/*.js` — while `Test/a.js` does not match
|
|
52
|
+
// `**/test/**`. That inconsistency is worse than either rule on its own,
|
|
53
|
+
// and case-folding is not what the previous implementation did. Encoding
|
|
54
|
+
// each upper-case letter as a control character plus its lower-case form
|
|
55
|
+
// makes matching case-sensitive everywhere: no folded comparison can
|
|
56
|
+
// reintroduce the marker.
|
|
57
|
+
//
|
|
58
|
+
// Neither control character can appear in a filename produced by any of the
|
|
59
|
+
// filesystems this runs on, so the transform cannot collide with real content.
|
|
60
|
+
const DOT = String.fromCharCode(1);
|
|
61
|
+
const UP = String.fromCharCode(2);
|
|
62
|
+
const HAS_UPPER = /[A-Z]/;
|
|
63
|
+
const ALL_UPPER = /[A-Z]/g;
|
|
64
|
+
const foldUp = c => UP + c.toLowerCase();
|
|
65
|
+
|
|
66
|
+
function prepPath(p, dot) {
|
|
67
|
+
let s = p;
|
|
68
|
+
if (dot && s.includes('.')) {
|
|
69
|
+
const segs = s.split('/');
|
|
70
|
+
for (let i = 0; i < segs.length; i++) {
|
|
71
|
+
if (segs[i].charCodeAt(0) === 46) segs[i] = DOT + segs[i].slice(1);
|
|
72
|
+
}
|
|
73
|
+
s = segs.join('/');
|
|
74
|
+
}
|
|
75
|
+
return HAS_UPPER.test(s) ? s.replace(ALL_UPPER, foldUp) : s;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* True when `rel` (a '/'-separated relative path) matches any of `patterns`.
|
|
80
|
+
* Case-sensitive; wildcards match hidden segments.
|
|
81
|
+
*/
|
|
82
|
+
export function matchesAnyGlob(rel, patterns) {
|
|
83
|
+
if (!patterns || patterns.length === 0) return false;
|
|
84
|
+
const subject = prepPath(rel, true);
|
|
85
|
+
for (const pat of patterns) {
|
|
86
|
+
if (path.matchesGlob(subject, prepPath(pat, true))) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A directory may be skipped wholesale only when a pattern provably excludes
|
|
92
|
+
// every possible descendant. `<prefix>/**` is exactly that shape: `**` absorbs
|
|
93
|
+
// any number of trailing segments, so if the directory matches `<prefix>` then
|
|
94
|
+
// nothing beneath it can survive. Anything else (`<prefix>/*`, `**/*.min.js`)
|
|
95
|
+
// may exclude some descendants and keep others, so those directories are walked
|
|
96
|
+
// and their files filtered individually. Pruning is therefore only ever a
|
|
97
|
+
// speed-up: correctness comes from the per-file check.
|
|
98
|
+
function prunable(subject, prepared) {
|
|
99
|
+
for (const p of prepared) {
|
|
100
|
+
if (p.subtree && path.matchesGlob(subject, p.subtree)) return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function prepare(patterns) {
|
|
106
|
+
return patterns.map(pat => {
|
|
107
|
+
const glob = prepPath(pat, true);
|
|
108
|
+
return { glob, subtree: glob.endsWith('/**') ? glob.slice(0, -3) : null };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Every regular file under `root`, as '/'-separated paths relative to `root`.
|
|
114
|
+
* Hidden entries included; directories and symlinks excluded; unreadable
|
|
115
|
+
* directories skipped; `ignore` globs applied to each file path.
|
|
116
|
+
*/
|
|
117
|
+
export async function listFiles(root, { ignore = [] } = {}) {
|
|
118
|
+
const prepared = prepare(ignore);
|
|
119
|
+
const out = [];
|
|
120
|
+
let level = [''];
|
|
121
|
+
while (level.length) {
|
|
122
|
+
const next = [];
|
|
123
|
+
for (const dir of level) {
|
|
124
|
+
let entries;
|
|
125
|
+
try {
|
|
126
|
+
entries = await fs.readdir(dir ? path.join(root, dir) : root, { withFileTypes: true });
|
|
127
|
+
} catch {
|
|
128
|
+
continue; // unreadable or vanished — skip it, keep walking
|
|
129
|
+
}
|
|
130
|
+
for (const e of entries) {
|
|
131
|
+
const rel = dir ? `${dir}/${e.name}` : e.name;
|
|
132
|
+
const subject = prepared.length ? prepPath(rel, true) : '';
|
|
133
|
+
if (e.isDirectory()) {
|
|
134
|
+
if (!prunable(subject, prepared)) next.push(rel);
|
|
135
|
+
} else if (e.isFile()) {
|
|
136
|
+
let skip = false;
|
|
137
|
+
for (const p of prepared) {
|
|
138
|
+
if (path.matchesGlob(subject, p.glob)) { skip = true; break; }
|
|
139
|
+
}
|
|
140
|
+
if (!skip) out.push(rel);
|
|
141
|
+
}
|
|
142
|
+
// symlinks, sockets, fifos and block/char devices are not files
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
level = next;
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve a user-supplied glob to regular files, relative to `cwd`. Hidden
|
|
152
|
+
* paths are not matched (the built-in default) and symlinks are followed, both
|
|
153
|
+
* matching the previous behaviour at these call sites.
|
|
154
|
+
*
|
|
155
|
+
* Two corrections are layered on the built-in walk: results are narrowed to
|
|
156
|
+
* regular files (a glob yields directories too), and the partial case-folding
|
|
157
|
+
* described above is undone by re-checking each result against the pattern
|
|
158
|
+
* case-sensitively — a `.SARIF` file must not answer to `*.sarif`. Symlink
|
|
159
|
+
* cycles are the one place this is deliberately not bug-compatible: the
|
|
160
|
+
* built-in stops at a cycle instead of re-enumerating the tree through it.
|
|
161
|
+
*/
|
|
162
|
+
export async function globFiles(pattern, { cwd = process.cwd() } = {}) {
|
|
163
|
+
const strict = prepPath(pattern, false);
|
|
164
|
+
const out = [];
|
|
165
|
+
for await (const p of fs.glob(pattern, { cwd })) {
|
|
166
|
+
if (!path.matchesGlob(prepPath(p, false), strict)) continue;
|
|
167
|
+
try {
|
|
168
|
+
const st = await fs.stat(path.isAbsolute(p) ? p : path.join(cwd, p));
|
|
169
|
+
if (st.isFile()) out.push(p);
|
|
170
|
+
} catch { /* vanished or dangling link — not a file */ }
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|