@clear-capabilities/agentic-security-scanner 0.150.1 → 0.151.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 +137 -0
- package/bin/agentic-security.js +73 -10
- package/dist/1122.index.js +16 -16
- package/dist/1208.index.js +23 -0
- package/dist/1301.index.js +3 -3
- package/dist/1379.index.js +3 -3
- package/dist/1444.index.js +3 -3
- package/dist/1660.index.js +1 -1
- package/dist/1700.index.js +5 -5
- package/dist/1905.index.js +12 -12
- package/dist/1920.index.js +10 -10
- package/dist/2238.index.js +4 -4
- package/dist/2271.index.js +3 -3
- package/dist/2432.index.js +8 -8
- package/dist/2659.index.js +1 -1
- package/dist/2826.index.js +2 -2
- package/dist/2830.index.js +3 -3
- package/dist/2923.index.js +1 -1
- package/dist/3001.index.js +4 -4
- package/dist/3117.index.js +4 -4
- package/dist/3180.index.js +9 -9
- package/dist/3276.index.js +1 -1
- package/dist/3415.index.js +1 -1
- package/dist/3499.index.js +1 -1
- package/dist/3518.index.js +4 -4
- package/dist/3736.index.js +7 -7
- package/dist/3839.index.js +4 -4
- package/dist/4113.index.js +441 -418
- package/dist/4265.index.js +4 -4
- package/dist/4384.index.js +8 -3
- package/dist/4399.index.js +29 -3
- package/dist/4547.index.js +2 -2
- package/dist/4863.index.js +6 -6
- package/dist/4970.index.js +2 -2
- package/dist/5051.index.js +15 -15
- package/dist/5144.index.js +4 -4
- package/dist/5333.index.js +8 -8
- package/dist/5343.index.js +2 -2
- package/dist/5350.index.js +6 -6
- package/dist/5561.index.js +1 -1
- package/dist/5637.index.js +10 -5
- package/dist/5724.index.js +21642 -0
- package/dist/5756.index.js +99 -489
- package/dist/5830.index.js +3 -3
- package/dist/6257.index.js +21 -8
- package/dist/6626.index.js +5 -5
- package/dist/6662.index.js +1 -1
- package/dist/6675.index.js +4 -4
- package/dist/6730.index.js +10 -10
- package/dist/6829.index.js +4 -4
- package/dist/6944.index.js +2 -2
- package/dist/6994.index.js +5 -5
- package/dist/7039.index.js +83 -19
- package/dist/7178.index.js +11 -6
- package/dist/7227.index.js +3 -3
- package/dist/7552.index.js +2 -2
- package/dist/7709.index.js +2 -2
- package/dist/7838.index.js +1037 -0
- package/dist/8218.index.js +4 -4
- package/dist/8476.index.js +4 -4
- package/dist/8513.index.js +8 -8
- package/dist/8520.index.js +1 -1
- package/dist/8752.index.js +4 -4
- package/dist/9207.index.js +2 -2
- package/dist/9220.index.js +5 -5
- package/dist/9390.index.js +6 -6
- package/dist/9503.index.js +2 -2
- package/dist/9560.index.js +3232 -0
- package/dist/957.index.js +4 -4
- package/dist/9801.index.js +1 -1
- package/dist/9824.index.js +5 -5
- package/dist/agentic-security.mjs +23 -23
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +21 -3
- package/src/dataflow/catalog.js +52 -0
- package/src/egress/redact.js +1 -1
- package/src/engine.js +13 -2
- package/src/lineage/source-registry.js +8 -0
- package/src/llm-validator/agent-loop.js +85 -5
- package/src/llm-validator/fix-proposal.js +16 -3
- package/src/llm-validator/model-capabilities.js +26 -1
- package/src/llm-validator/model-probe.js +46 -8
- package/src/llm-validator/ollama-provider.js +26 -1
- package/src/llm-validator/oom-feedback.js +69 -0
- package/src/llm-validator/providers.js +50 -0
- package/src/llm-validator/redact.js +177 -11
- package/src/posture/deterministic-fix.js +11 -0
- package/src/report/index.js +14 -3
- package/src/sast/java-ast-folding.js +18 -2
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
// never to this module's error path. See ollama-offline-egress.test.js.
|
|
33
33
|
|
|
34
34
|
import { isLoopbackUrl } from './local-endpoint.js';
|
|
35
|
+
import { recordOOMEvent } from './oom-feedback.js';
|
|
35
36
|
|
|
36
37
|
export const DEFAULT_OLLAMA_HOST = 'http://127.0.0.1:11434';
|
|
37
38
|
export const DEFAULT_OLLAMA_MODEL = 'qwen3.5:4b';
|
|
@@ -44,6 +45,7 @@ const DEFAULT_MAX_CONCURRENCY = 1;
|
|
|
44
45
|
// exactly one of these, never an ad-hoc string, so a caller (and a report) can
|
|
45
46
|
// react on `code` instead of parsing prose.
|
|
46
47
|
export const OLLAMA_ERROR_CODES = Object.freeze([
|
|
48
|
+
'ollama-disabled',
|
|
47
49
|
'ollama-not-running',
|
|
48
50
|
'ollama-unreachable',
|
|
49
51
|
'ollama-non-loopback-refused',
|
|
@@ -69,6 +71,18 @@ function _err(code, reason) {
|
|
|
69
71
|
* @returns {{ok:true, config:object} | {ok:false, code:string, reason:string}}
|
|
70
72
|
*/
|
|
71
73
|
export function ollamaEndpointConfig(env = process.env) {
|
|
74
|
+
// Adversarial-review fix (2026-09): no kill switch existed for this whole
|
|
75
|
+
// provider, unlike AGENTIC_SECURITY_MCP_DISABLED for the MCP server —
|
|
76
|
+
// worse, a per-role AGENTIC_SECURITY_LLM_PRESET_<ROLE>=ollama override
|
|
77
|
+
// (providers.js's _forRole) can keep a role calling Ollama even after an
|
|
78
|
+
// operator unsets the GLOBAL preset during an incident, so "just unset
|
|
79
|
+
// the preset" is not reliably enough. This check is here, in the one
|
|
80
|
+
// function every Ollama call path resolves through (resolveProvider's
|
|
81
|
+
// ollama branch, and models/setup's direct callers), so it can never be
|
|
82
|
+
// bypassed by a role-specific override the operator forgot about.
|
|
83
|
+
if (env.AGENTIC_SECURITY_OLLAMA_DISABLED === '1') {
|
|
84
|
+
return _err('ollama-disabled', 'Ollama is disabled (AGENTIC_SECURITY_OLLAMA_DISABLED=1). Unset it to re-enable.');
|
|
85
|
+
}
|
|
72
86
|
const rawHost = env.AGENTIC_SECURITY_OLLAMA_HOST || DEFAULT_OLLAMA_HOST;
|
|
73
87
|
const host = String(rawHost).replace(/\/+$/, '');
|
|
74
88
|
const allowRemote = env.AGENTIC_SECURITY_OLLAMA_ALLOW_REMOTE === '1';
|
|
@@ -220,7 +234,18 @@ export async function callOllamaChat({ host, model, messages, maxTokens, schema,
|
|
|
220
234
|
if (res.status === 404 || /not found/i.test(detail)) {
|
|
221
235
|
return _err('ollama-model-not-installed', `Model '${model}' is not installed. ${detail || ''}`.trim());
|
|
222
236
|
}
|
|
223
|
-
if (/memory|oom/i.test(detail))
|
|
237
|
+
if (/memory|oom/i.test(detail)) {
|
|
238
|
+
// Adversarial-review fix (2026-09): this was a real, defined error
|
|
239
|
+
// code with no reactive call site anywhere — a wrong memory-admission
|
|
240
|
+
// estimate that caused a genuine OOM would repeat the identical wrong
|
|
241
|
+
// decision forever. Record it so recommendAdmission (model-
|
|
242
|
+
// capabilities.js) can warn on the NEXT admission check for this
|
|
243
|
+
// model on this machine, even though the underlying size/KV-cache
|
|
244
|
+
// ESTIMATES themselves stay uncalibrated (that needs real hardware
|
|
245
|
+
// variety a single machine's observed failures can't substitute for).
|
|
246
|
+
recordOOMEvent(model);
|
|
247
|
+
return _err('ollama-model-out-of-memory', detail || `HTTP ${res.status}`);
|
|
248
|
+
}
|
|
224
249
|
if (/context/i.test(detail)) return _err('ollama-context-overflow', detail || `HTTP ${res.status}`);
|
|
225
250
|
return _err('ollama-model-load-failed', detail || `HTTP ${res.status}`);
|
|
226
251
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Adversarial-review fix (2026-09): `ollama-model-out-of-memory` was a real,
|
|
2
|
+
// defined error code (ollama-provider.js's OLLAMA_ERROR_CODES) with ZERO
|
|
3
|
+
// call sites reacting to it anywhere in this codebase — a wrong memory-
|
|
4
|
+
// admission estimate that caused a genuine OOM would silently repeat the
|
|
5
|
+
// identical wrong "admitted: true" decision forever, since nothing recorded
|
|
6
|
+
// the failure for next time.
|
|
7
|
+
//
|
|
8
|
+
// NOT a replacement for real calibration. model-capabilities.js's
|
|
9
|
+
// KNOWN_MODEL_SIZE_GB / ESTIMATED_KV_CACHE_MB_PER_1K_TOKENS /
|
|
10
|
+
// RUNTIME_OVERHEAD_MB remain asserted, unmeasured "best-effort" constants —
|
|
11
|
+
// properly calibrating them needs real hardware variety this session cannot
|
|
12
|
+
// manufacture. This module is the cheapest thing that CAN improve after a
|
|
13
|
+
// wrong estimate without that: a per-machine, per-model OBSERVED-FAILURE
|
|
14
|
+
// ledger. A model that has already OOM'd on THIS machine gets an explicit
|
|
15
|
+
// warning attached to the next admission decision, rather than the same
|
|
16
|
+
// unqualified confidence a first-time estimate gets.
|
|
17
|
+
//
|
|
18
|
+
// Same disk-cache directory convention as model-probe.js's capability
|
|
19
|
+
// cache and sca/sigstore-verify.js's Rekor cache
|
|
20
|
+
// (`~/.claude/agentic-security/<name>/`).
|
|
21
|
+
|
|
22
|
+
import * as fs from 'node:fs';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
import * as os from 'node:os';
|
|
25
|
+
|
|
26
|
+
const LOG_PATH = path.join(os.homedir(), '.claude', 'agentic-security', 'ollama-oom-log.json');
|
|
27
|
+
|
|
28
|
+
function _readLog() {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(fs.readFileSync(LOG_PATH, 'utf8'));
|
|
31
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
32
|
+
} catch { return {}; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _writeLog(log) {
|
|
36
|
+
try {
|
|
37
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
38
|
+
fs.writeFileSync(LOG_PATH, JSON.stringify(log));
|
|
39
|
+
} catch { /* best-effort; a failure here must never break the caller's real request */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Record a real, observed OOM failure for `model` on this machine. Called
|
|
44
|
+
* from ollama-provider.js's callOllamaChat — the single choke point every
|
|
45
|
+
* Ollama HTTP call in this codebase goes through, so every role's OOM
|
|
46
|
+
* failures land in the same ledger regardless of which one hit it.
|
|
47
|
+
*/
|
|
48
|
+
export function recordOOMEvent(model) {
|
|
49
|
+
if (typeof model !== 'string' || !model) return;
|
|
50
|
+
const log = _readLog();
|
|
51
|
+
const entry = log[model] || { count: 0, firstAt: Date.now() };
|
|
52
|
+
entry.count += 1;
|
|
53
|
+
entry.lastAt = Date.now();
|
|
54
|
+
log[model] = entry;
|
|
55
|
+
_writeLog(log);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @returns {{count:number, firstAt:number, lastAt:number} | null} prior OOM
|
|
60
|
+
* history for `model` on this machine, or null if it has never failed
|
|
61
|
+
* this way here before.
|
|
62
|
+
*/
|
|
63
|
+
export function priorOOMFor(model) {
|
|
64
|
+
if (typeof model !== 'string' || !model) return null;
|
|
65
|
+
const log = _readLog();
|
|
66
|
+
return log[model] || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const _internals = { LOG_PATH };
|
|
@@ -237,6 +237,18 @@ export function buildProviderRequest(config, prompt, maxTokens) {
|
|
|
237
237
|
};
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
// Architectural invariant, not a per-scan observation: nothing in this
|
|
241
|
+
// module (or any caller of resolveProvider) ever reacts to a failed/refused
|
|
242
|
+
// resolution by silently trying a DIFFERENT provider — a refusal is refused,
|
|
243
|
+
// full stop (see the local/ollama loopback-refusal branches above, both of
|
|
244
|
+
// which return `{ok:false}` rather than falling through to a vendor preset).
|
|
245
|
+
// Declared once, HERE, so every place in the codebase that asserts "no cloud
|
|
246
|
+
// fallback" (currently only engine.js's AI-Assistance report) reads the same
|
|
247
|
+
// single source of truth instead of each hardcoding its own `false` literal
|
|
248
|
+
// — if this invariant ever needs to become conditional, there is exactly one
|
|
249
|
+
// place to change it and everything downstream updates with it.
|
|
250
|
+
export const NO_CLOUD_FALLBACK = true;
|
|
251
|
+
|
|
240
252
|
/** Which provider each role would use, for reporting. Never includes keys. */
|
|
241
253
|
export function providerMatrix(env = process.env) {
|
|
242
254
|
const out = {};
|
|
@@ -249,4 +261,42 @@ export function providerMatrix(env = process.env) {
|
|
|
249
261
|
return out;
|
|
250
262
|
}
|
|
251
263
|
|
|
264
|
+
// `hunt` is deliberately NOT one of `ROLES` above (discovery/hunter.js's own
|
|
265
|
+
// comment: it has no per-role override of its own, always falling back to
|
|
266
|
+
// whatever the GLOBAL preset resolves to) — which means `providerMatrix()`,
|
|
267
|
+
// built by iterating `ROLES`, structurally never sees it. Adversarial-review
|
|
268
|
+
// fix (2026-09, second pass): that made `otherRemoteRoles` blind to hunt by
|
|
269
|
+
// construction, not by an oversight in its filter — and hunt is exactly the
|
|
270
|
+
// role most likely to diverge from `validate` in the scenario this function
|
|
271
|
+
// exists to catch (a global cloud preset with `validate` given its own
|
|
272
|
+
// `ollama` override: hunt then silently follows the global cloud preset).
|
|
273
|
+
// Checked explicitly, alongside the `ROLES`-iterated matrix, rather than
|
|
274
|
+
// added to `ROLES` itself — doing that would wrongly imply hunt supports a
|
|
275
|
+
// `_HUNT`-suffixed per-role override, which it does not and this fix does
|
|
276
|
+
// not add.
|
|
277
|
+
const ROLES_WITH_NO_PER_ROLE_OVERRIDE = Object.freeze(['hunt']);
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Which roles OTHER than `excludeRole` resolve to a remote (non-loopback)
|
|
281
|
+
* provider right now. Exists so a report scoped to one role (the AI
|
|
282
|
+
* Assistance block's `validate`-only measurement) can disclose when a
|
|
283
|
+
* DIFFERENT role is genuinely configured for a cloud vendor at the same
|
|
284
|
+
* time, instead of leaving that role invisible to a reader who reasonably
|
|
285
|
+
* generalizes a single-role loopback claim onto the whole scan.
|
|
286
|
+
*
|
|
287
|
+
* @returns {{role:string, provider:string}[]}
|
|
288
|
+
*/
|
|
289
|
+
export function otherRemoteRoles(excludeRole, env = process.env) {
|
|
290
|
+
const matrix = providerMatrix(env);
|
|
291
|
+
const out = Object.entries(matrix)
|
|
292
|
+
.filter(([role, r]) => role !== excludeRole && r.provider && r.egress === 'remote')
|
|
293
|
+
.map(([role, r]) => ({ role, provider: r.provider }));
|
|
294
|
+
for (const role of ROLES_WITH_NO_PER_ROLE_OVERRIDE) {
|
|
295
|
+
if (role === excludeRole) continue;
|
|
296
|
+
const r = resolveProvider({ role, env });
|
|
297
|
+
if (r.ok && r.config.egress === 'remote') out.push({ role, provider: r.config.provider });
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
252
302
|
export const _internals = { SHAPES, DEFAULT_MODEL, _forRole };
|
|
@@ -21,28 +21,144 @@
|
|
|
21
21
|
|
|
22
22
|
const REDACTED_PLACEHOLDER = '[REDACTED-SECRET]';
|
|
23
23
|
|
|
24
|
-
// Case-insensitive names that, when assigned a
|
|
25
|
-
// carrying a credential.
|
|
26
|
-
//
|
|
27
|
-
//
|
|
24
|
+
// Case-insensitive names that, when assigned a string, are treated as
|
|
25
|
+
// carrying a credential. `[_-]?` between compound words so a real .env/shell
|
|
26
|
+
// naming convention (`DB_PASSWORD`, `STRIPE_API_KEY`) matches — those are
|
|
27
|
+
// two segments joined by `_`, not one identifier, so a plain `\b` (which
|
|
28
|
+
// treats `_` as a word character) would never see a boundary before
|
|
29
|
+
// `PASSWORD` in `DB_PASSWORD` at all. Handled below via lookaround, not \b.
|
|
28
30
|
const SECRET_KEY_NAMES = [
|
|
29
|
-
'
|
|
30
|
-
'api_key',
|
|
31
|
+
'api[_-]?key',
|
|
31
32
|
'secret',
|
|
32
33
|
'token',
|
|
33
34
|
'password',
|
|
34
35
|
'passwd',
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
36
|
+
'client[_-]?secret',
|
|
37
|
+
'access[_-]?key',
|
|
38
|
+
'private[_-]?key',
|
|
38
39
|
'authorization',
|
|
39
40
|
];
|
|
40
41
|
|
|
42
|
+
// Asymmetric boundary, deliberately not a plain `\b` on either side:
|
|
43
|
+
// BEFORE the name: anything non-alphanumeric counts as a boundary,
|
|
44
|
+
// INCLUDING `_`/`-` — this is what lets `DB_PASSWORD`/`stripe-api-key`
|
|
45
|
+
// match despite `_`/`-` being `\w` characters a real `\b` would treat
|
|
46
|
+
// as "still inside the same word."
|
|
47
|
+
// AFTER the name: only a non-alphanumeric, non-underscore char (or
|
|
48
|
+
// string end) counts — this is a real `\b`-equivalent on the suffix
|
|
49
|
+
// side, which is what keeps `password_field`/`tokenExpiry` from
|
|
50
|
+
// matching (a field ABOUT a secret, not a secret itself). Losing this
|
|
51
|
+
// asymmetry either direction reintroduces one of the two failure
|
|
52
|
+
// modes this module exists to avoid (see the module header).
|
|
53
|
+
const NAME_BOUNDARY = (name) => `(?<![A-Za-z0-9])(?:${name})(?![A-Za-z0-9_])`;
|
|
54
|
+
const KEY_NAME_ALT = SECRET_KEY_NAMES.map(NAME_BOUNDARY).join('|');
|
|
55
|
+
|
|
56
|
+
// The optional quote group right after the key name handles a JSON/YAML
|
|
57
|
+
// QUOTED key (`"password": "value"`) — the boundary lookaheads in
|
|
58
|
+
// NAME_BOUNDARY already treat the closing quote as a valid non-alphanumeric
|
|
59
|
+
// "after" boundary, but without this group the operator match immediately
|
|
60
|
+
// following would fail to consume that quote char before `[:=]`.
|
|
41
61
|
const KEY_VALUE_RE = new RegExp(
|
|
42
|
-
'(
|
|
62
|
+
'(' + KEY_NAME_ALT + ')([\'"`]?\\s*[:=]\\s*)([\'"`])([^\'"`]+)\\3',
|
|
63
|
+
'gi'
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Split-string-concatenation (adversarial-review fix, 2026-09, second pass):
|
|
67
|
+
// `const secret = "Super" +\n "Secret123456";` used to redact only the
|
|
68
|
+
// FIRST segment — KEY_VALUE_RE matches one quoted literal, stops, and the
|
|
69
|
+
// second literal (joined by `+`, possibly on its own line) survives
|
|
70
|
+
// untouched, leaking the tail of the real value. Requires AT LEAST ONE `+`
|
|
71
|
+
// join (`+` one-or-more, not `*`) so this pass targets only the genuinely-
|
|
72
|
+
// concatenated case and never overlaps with — or double-processes — the
|
|
73
|
+
// plain single-literal case KEY_VALUE_RE already owns. The whole matched
|
|
74
|
+
// expression (every segment, every operator) is collapsed to ONE placeholder
|
|
75
|
+
// literal rather than trying to preserve per-segment structure: a
|
|
76
|
+
// concatenation is already an unusual enough shape that this codebase's
|
|
77
|
+
// "value gone, structure survives" ergonomic goal is secondary to just not
|
|
78
|
+
// leaking the tail half of a secret.
|
|
79
|
+
const CONCAT_KEY_VALUE_RE = new RegExp(
|
|
80
|
+
'(' + KEY_NAME_ALT + ')([\'"`]?\\s*[:=]\\s*)([\'"`][^\'"`]*[\'"`](?:\\s*\\+\\s*[\'"`][^\'"`]*[\'"`])+)',
|
|
43
81
|
'gi'
|
|
44
82
|
);
|
|
45
83
|
|
|
84
|
+
// camelCase compound identifiers (adversarial-review fix, 2026-09):
|
|
85
|
+
// `authToken`, `apiSecret`, `userPassword` all leaked through the original
|
|
86
|
+
// fix, which only generalized the `_`/`-`-delimited compound case
|
|
87
|
+
// (`DB_PASSWORD`). A plain `i` flag doesn't help here — case-insensitivity
|
|
88
|
+
// makes "Token" and "token" match the same NAME, but the boundary problem is
|
|
89
|
+
// separate: `(?<![A-Za-z0-9])` still fails when "Token" is preceded by the
|
|
90
|
+
// letters "auth", regardless of case. This is a SEPARATE, case-SENSITIVE
|
|
91
|
+
// regex specifically for "a capitalized key-name segment immediately
|
|
92
|
+
// preceded by a lowercase letter or digit" (the actual camelCase join
|
|
93
|
+
// point), run as its own pass — mixing this into the case-insensitive
|
|
94
|
+
// KEY_VALUE_RE would make it fire on the ALL-CAPS compound case too
|
|
95
|
+
// (`DB_PASSWORD`), which is already handled and correctly rejects the
|
|
96
|
+
// suffix-side `password_field` case using the case-insensitive boundary.
|
|
97
|
+
//
|
|
98
|
+
// Deliberately narrower than the full SECRET_KEY_NAMES list: "Key" alone
|
|
99
|
+
// (for `apiKey`/`accessKey`/`privateKey`) is too generic a camelCase suffix
|
|
100
|
+
// on its own (`primaryKey`, `cacheKey`, `sortKey` are not secrets) — those
|
|
101
|
+
// three stay as their full compound word here, matched case-flexibly on the
|
|
102
|
+
// FIRST letter of each segment only (`[Aa]pi[Kk]ey`, not full case-
|
|
103
|
+
// insensitivity, to keep this pass's own boundary logic exact).
|
|
104
|
+
const CAMEL_CASE_NAMES = [
|
|
105
|
+
'Token', 'Secret', 'Password', 'Passwd', 'Authorization',
|
|
106
|
+
'[Aa]pi[Kk]ey', '[Aa]ccess[Kk]ey', '[Pp]rivate[Kk]ey', '[Cc]lient[Ss]ecret',
|
|
107
|
+
];
|
|
108
|
+
const CAMEL_NAME_ALT = CAMEL_CASE_NAMES
|
|
109
|
+
.map((name) => `(?<=[a-z0-9])(?:${name})(?![A-Za-z0-9_])`)
|
|
110
|
+
.join('|');
|
|
111
|
+
const CAMEL_KEY_VALUE_RE = new RegExp(
|
|
112
|
+
'(' + CAMEL_NAME_ALT + ')([\'"`]?\\s*[:=]\\s*)([\'"`])([^\'"`]+)\\3',
|
|
113
|
+
'g' // NOT case-insensitive — the lowercase-then-uppercase transition IS the signal
|
|
114
|
+
);
|
|
115
|
+
// Same split-string-concatenation fix as CONCAT_KEY_VALUE_RE, for the
|
|
116
|
+
// camelCase name set.
|
|
117
|
+
const CONCAT_CAMEL_KEY_VALUE_RE = new RegExp(
|
|
118
|
+
'(' + CAMEL_NAME_ALT + ')([\'"`]?\\s*[:=]\\s*)([\'"`][^\'"`]*[\'"`](?:\\s*\\+\\s*[\'"`][^\'"`]*[\'"`])+)',
|
|
119
|
+
'g'
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// `.env`/shell-export syntax: `KEY=value` with NO surrounding quotes at all
|
|
123
|
+
// — the shape secret-redaction.test.js already documented as evading
|
|
124
|
+
// KEY_VALUE_RE above (that test's own comment: "Standard .env syntax...
|
|
125
|
+
// evades the... catch-all, which requires a quoted value"). Anchored to
|
|
126
|
+
// the start of a line (optional leading whitespace/`export `) rather than
|
|
127
|
+
// matched anywhere, specifically so an ordinary code expression like
|
|
128
|
+
// `if (password == expected)` or `token = someFn()` embedded mid-statement
|
|
129
|
+
// is never a candidate — only a line whose FIRST token is the secret name
|
|
130
|
+
// itself, exactly the shape a real `.env` file or shell script uses. `(?!=)`
|
|
131
|
+
// after the operator blocks `==`/`===` from being read as `=` plus a
|
|
132
|
+
// leftover comparison; a bare `!=`/`<=`/`>=` already can't match because the
|
|
133
|
+
// operator class only accepts a literal `=`, not the character before it.
|
|
134
|
+
// The optional third group is a non-greedy compound-identifier prefix
|
|
135
|
+
// (`AWS_SECRET_` before `ACCESS_KEY`, `STRIPE_` before `API_KEY`) — the SAME
|
|
136
|
+
// compound-name problem NAME_BOUNDARY solves for a single leading segment,
|
|
137
|
+
// generalized to arbitrarily many, since real .env names commonly stack a
|
|
138
|
+
// vendor/namespace prefix ahead of the sensitive word.
|
|
139
|
+
const ENV_STYLE_RE = new RegExp(
|
|
140
|
+
'^([ \\t]*(?:export\\s+)?)([A-Za-z0-9_-]*?[_-])?(' + KEY_NAME_ALT + ')(\\s*=(?!=)\\s*)([^\\r\\n]+)$',
|
|
141
|
+
'gim'
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// YAML `key: value` (adversarial-review fix, 2026-09): the SAME unquoted
|
|
145
|
+
// shape as .env, but YAML's operator is `:`, which ENV_STYLE_RE never
|
|
146
|
+
// accepted. Deliberately NOT just added to ENV_STYLE_RE's operator class —
|
|
147
|
+
// `:` collides with two extremely common non-secret shapes this codebase
|
|
148
|
+
// already has a pinned regression test for: a TypeScript type annotation
|
|
149
|
+
// (`password: string;`) and a JS object-literal key
|
|
150
|
+
// (`{ password: getSecret() }`). Neither of those is YAML, so this pattern
|
|
151
|
+
// is applied ONLY when the file extension says so (see redactSecrets'
|
|
152
|
+
// `filePath` option below) rather than unconditionally — the risk of the
|
|
153
|
+
// blanket approach is a regression in exactly the case this module's own
|
|
154
|
+
// header calls out ("redact too aggressively and the validator loses the
|
|
155
|
+
// context it needs").
|
|
156
|
+
const YAML_STYLE_RE = new RegExp(
|
|
157
|
+
'^([ \\t]*(?:- )?)([A-Za-z0-9_-]*?[_-])?(' + KEY_NAME_ALT + ')(\\s*:\\s*)([^\\r\\n]+)$',
|
|
158
|
+
'gim'
|
|
159
|
+
);
|
|
160
|
+
const YAML_EXT_RE = /\.ya?ml$/i;
|
|
161
|
+
|
|
46
162
|
// `Authorization: Bearer <blob>` — the blob only, scheme word survives.
|
|
47
163
|
const BEARER_RE = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;
|
|
48
164
|
|
|
@@ -104,7 +220,7 @@ function looksLikeHighEntropySecret(inner) {
|
|
|
104
220
|
// overlap on an already-redacted span is harmless (the value is still gone
|
|
105
221
|
// exactly once; see KEY_VALUE_RE + BEARER_RE interaction for the one case
|
|
106
222
|
// where both can fire on the same literal).
|
|
107
|
-
export function redactSecrets(text) {
|
|
223
|
+
export function redactSecrets(text, { filePath } = {}) {
|
|
108
224
|
if (typeof text !== 'string' || text.length === 0) {
|
|
109
225
|
return { text: typeof text === 'string' ? text : '', redactions: 0 };
|
|
110
226
|
}
|
|
@@ -132,6 +248,16 @@ export function redactSecrets(text) {
|
|
|
132
248
|
return `Bearer ${REDACTED_PLACEHOLDER}`;
|
|
133
249
|
});
|
|
134
250
|
|
|
251
|
+
// 3.5. Split-string-concatenation values — MUST run before pass 4:
|
|
252
|
+
// KEY_VALUE_RE matches the first quoted segment of a concatenation too
|
|
253
|
+
// (it's a valid, if incomplete, match on its own), so if pass 4 ran first
|
|
254
|
+
// it would already have consumed and "fixed" the first segment, leaving
|
|
255
|
+
// this pass nothing to find and the second segment still exposed.
|
|
256
|
+
out = out.replace(CONCAT_KEY_VALUE_RE, (_m, keyName, opWs) => {
|
|
257
|
+
redactions++;
|
|
258
|
+
return `${keyName}${opWs}"${REDACTED_PLACEHOLDER}"`;
|
|
259
|
+
});
|
|
260
|
+
|
|
135
261
|
// 4. `secretName = "value"` / `secretName: "value"` assignments.
|
|
136
262
|
//
|
|
137
263
|
// Special case: `authorization: "Bearer <blob>"` — keep the `Bearer `
|
|
@@ -144,6 +270,46 @@ export function redactSecrets(text) {
|
|
|
144
270
|
return `${keyName}${opWs}${quote}${REDACTED_PLACEHOLDER}${quote}`;
|
|
145
271
|
});
|
|
146
272
|
|
|
273
|
+
// 3.6. Split-string-concatenation, camelCase name set — same ordering
|
|
274
|
+
// requirement as 3.5 (must run before 4a).
|
|
275
|
+
out = out.replace(CONCAT_CAMEL_KEY_VALUE_RE, (_m, keyName, opWs) => {
|
|
276
|
+
redactions++;
|
|
277
|
+
return `${keyName}${opWs}"${REDACTED_PLACEHOLDER}"`;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// 4a. camelCase compound identifiers (`authToken = "..."`, `apiSecret: "..."`)
|
|
281
|
+
// — a case-SENSITIVE pass, separate from pass 4's case-insensitive one for
|
|
282
|
+
// exactly the reason CAMEL_KEY_VALUE_RE's own comment explains.
|
|
283
|
+
out = out.replace(CAMEL_KEY_VALUE_RE, (_m, keyName, opWs, quote, value) => {
|
|
284
|
+
redactions++;
|
|
285
|
+
const bearer = /^(Bearer\s+)(.+)$/i.exec(value);
|
|
286
|
+
if (bearer) return `${keyName}${opWs}${quote}${bearer[1]}${REDACTED_PLACEHOLDER}${quote}`;
|
|
287
|
+
return `${keyName}${opWs}${quote}${REDACTED_PLACEHOLDER}${quote}`;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// 4.5. `.env`/shell-export syntax: `KEY=value`, no quotes at all. Must run
|
|
291
|
+
// AFTER pass 4 (KEY_VALUE_RE): pass 4 already consumed every quoted
|
|
292
|
+
// occurrence, so anything ENV_STYLE_RE still finds on a candidate line is,
|
|
293
|
+
// by construction, unquoted — there is no double-redaction risk between
|
|
294
|
+
// the two passes even though their key-name sets are identical.
|
|
295
|
+
out = out.replace(ENV_STYLE_RE, (_m, lead, prefix, keyName, opWs, value) => {
|
|
296
|
+
redactions++;
|
|
297
|
+
const bearer = /^(Bearer\s+)(.+)$/i.exec(value);
|
|
298
|
+
if (bearer) return `${lead}${prefix || ''}${keyName}${opWs}${bearer[1]}${REDACTED_PLACEHOLDER}`;
|
|
299
|
+
return `${lead}${prefix || ''}${keyName}${opWs}${REDACTED_PLACEHOLDER}`;
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// 4.6. YAML `key: value` — ONLY for a file the caller identifies as YAML
|
|
303
|
+
// (see YAML_STYLE_RE's own comment for why this isn't unconditional).
|
|
304
|
+
if (typeof filePath === 'string' && YAML_EXT_RE.test(filePath)) {
|
|
305
|
+
out = out.replace(YAML_STYLE_RE, (_m, lead, prefix, keyName, opWs, value) => {
|
|
306
|
+
redactions++;
|
|
307
|
+
const bearer = /^(Bearer\s+)(.+)$/i.exec(value);
|
|
308
|
+
if (bearer) return `${lead}${prefix || ''}${keyName}${opWs}${bearer[1]}${REDACTED_PLACEHOLDER}`;
|
|
309
|
+
return `${lead}${prefix || ''}${keyName}${opWs}${REDACTED_PLACEHOLDER}`;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
147
313
|
// 5. Long high-entropy string literals not caught by a more specific rule.
|
|
148
314
|
out = out.replace(QUOTED_STRING_RE, (m) => {
|
|
149
315
|
const q = m[0];
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
const JS_EXT = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
|
|
16
16
|
const PY_EXT = /\.py$/i;
|
|
17
|
+
const JAVA_EXT = /\.java$/i;
|
|
17
18
|
|
|
18
19
|
// Each rule gates on the finding's cwe/family, then rewrites the whole-file
|
|
19
20
|
// content. transform() returns the new content, or null when nothing changed
|
|
@@ -25,12 +26,22 @@ const RULES = [
|
|
|
25
26
|
// swapping them all is safe; the verifier confirms the weak-hash finding is
|
|
26
27
|
// gone and nothing worse appeared.
|
|
27
28
|
applies: (f) => /CWE-(?:327|328|916)/.test(f.cwe || '') || /weak.?hash/i.test(f.family || ''),
|
|
29
|
+
// SARD_AGENTIC_SECURITY_PRD.md Phase 8 bench work found this rule's
|
|
30
|
+
// `applies()` gate matched Java findings (CWE-327/328) by cwe/family, but
|
|
31
|
+
// `transform()` had no Java branch at all — every Java weak-hash finding
|
|
32
|
+
// silently produced `null` (no fix), a coverage gap invisible from the
|
|
33
|
+
// gate alone. `MessageDigest.getInstance("MD5"|"SHA1"|"SHA-1")` ->
|
|
34
|
+
// `"SHA-256"` is the same class of context-independent literal swap as
|
|
35
|
+
// the existing JS/Python branches (the algorithm name is a string
|
|
36
|
+
// literal, not something requiring surrounding-code understanding).
|
|
28
37
|
transform: (content, file) => {
|
|
29
38
|
let out = content;
|
|
30
39
|
if (JS_EXT.test(file)) {
|
|
31
40
|
out = out.replace(/(\bcreateHash\s*\(\s*['"`])(?:md5|sha1)(['"`])/gi, '$1sha256$2');
|
|
32
41
|
} else if (PY_EXT.test(file)) {
|
|
33
42
|
out = out.replace(/\bhashlib\.(?:md5|sha1)\s*\(/g, 'hashlib.sha256(');
|
|
43
|
+
} else if (JAVA_EXT.test(file)) {
|
|
44
|
+
out = out.replace(/(\bMessageDigest\.getInstance\s*\(\s*")(?:MD5|SHA-?1)(")/gi, '$1SHA-256$2');
|
|
34
45
|
}
|
|
35
46
|
return out !== content ? out : null;
|
|
36
47
|
},
|
package/src/report/index.js
CHANGED
|
@@ -1606,19 +1606,30 @@ export function toShipVerdict(scan, options = {}) {
|
|
|
1606
1606
|
// requirement) — this line describes the model calls, not deterministic
|
|
1607
1607
|
// network access elsewhere in the scan (OSV/KEV/EPSS), which is a
|
|
1608
1608
|
// different claim this block must not blur.
|
|
1609
|
+
//
|
|
1610
|
+
// Adversarial-review finding (2026-09): the header used to carry no scope
|
|
1611
|
+
// qualifier and "Cloud fallback: disabled" was a hardcoded string, not
|
|
1612
|
+
// read from `_ai` at all — both fixed here. The header now names the ONE
|
|
1613
|
+
// role this block actually measured (`scopeRole`), and a role that
|
|
1614
|
+
// resolves to a remote/cloud provider is called out explicitly rather than
|
|
1615
|
+
// left for the reader to wrongly generalize the loopback claim onto.
|
|
1609
1616
|
const _ai = scan.aiAssistance;
|
|
1610
1617
|
if (_ai) {
|
|
1611
1618
|
lines.push('');
|
|
1612
|
-
lines.push(c(
|
|
1619
|
+
lines.push(c(` AI Assistance (${_ai.scopeRole || 'validate'} role only)`, BOLD));
|
|
1613
1620
|
lines.push(c(` Provider: ${_ai.provider || 'unknown'} Model: ${_ai.model || 'unknown'}`, DIM));
|
|
1614
|
-
lines.push(c(` LLM egress: ${_ai.egress || 'unknown'} Cloud fallback: disabled`, DIM));
|
|
1621
|
+
lines.push(c(` LLM egress: ${_ai.egress || 'unknown'} Cloud fallback: ${_ai.cloudFallback ? 'enabled' : 'disabled'}`, DIM));
|
|
1615
1622
|
for (const [stage, s] of Object.entries(_ai.stages || {})) {
|
|
1616
1623
|
const parts = [`${s.calls} call${s.calls === 1 ? '' : 's'}`, `success ${s.success}`];
|
|
1617
1624
|
if (s.refused) parts.push(`refused ${s.refused}`);
|
|
1618
1625
|
if (s.failed) parts.push(`failed ${s.failed}`);
|
|
1619
1626
|
lines.push(c(` ${stage.padEnd(10)} ${parts.join(' ')}`, DIM));
|
|
1620
1627
|
}
|
|
1621
|
-
lines.push(c(` ${_ai.egress === 'loopback-only' ?
|
|
1628
|
+
lines.push(c(` ${_ai.egress === 'loopback-only' ? `LLM inference for the ${_ai.scopeRole || 'validate'} role was loopback-only.` : `LLM inference for the ${_ai.scopeRole || 'validate'} role used a remote endpoint.`}`, DIM));
|
|
1629
|
+
if (Array.isArray(_ai.otherRolesRemote) && _ai.otherRolesRemote.length > 0) {
|
|
1630
|
+
const named = _ai.otherRolesRemote.map((r) => `${r.role} (${r.provider})`).join(', ');
|
|
1631
|
+
lines.push(c(` ⚠ other role(s) configured for a remote provider, NOT covered above: ${named}`, DIM));
|
|
1632
|
+
}
|
|
1622
1633
|
}
|
|
1623
1634
|
// Coverage-honesty line (#5/#6): show the scan's blind spots — which
|
|
1624
1635
|
// languages got flow analysis vs pattern-only, what was skipped, and how
|
|
@@ -19,7 +19,23 @@
|
|
|
19
19
|
// - Floating-point arithmetic
|
|
20
20
|
// - long / short / byte coercion edge cases (we treat all as Number)
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// Dynamic + try/caught, not a plain static import: java-ast-folding.js is
|
|
23
|
+
// pulled in by engine.js, which every CLI invocation imports regardless of
|
|
24
|
+
// language — a static import made a transient failure to resolve the
|
|
25
|
+
// vendored '#java-parser' subpath (observed under heavy concurrent test
|
|
26
|
+
// load: many processes resolving the same large vendored tree at once) a
|
|
27
|
+
// module-LINK-time crash of the entire CLI, for every user, for every scan,
|
|
28
|
+
// not just Java ones. This file already treats a `parse()` call failure as
|
|
29
|
+
// non-fatal ("On parse error returns []; callers should fall back to
|
|
30
|
+
// non-AST behavior" — see deadBranchRanges below); extending that same
|
|
31
|
+
// degrade-to-empty philosophy to a load-time failure, instead of letting it
|
|
32
|
+
// crash the process, is the fix.
|
|
33
|
+
let parse = null;
|
|
34
|
+
try {
|
|
35
|
+
({ parse } = await import('#java-parser'));
|
|
36
|
+
} catch {
|
|
37
|
+
parse = null;
|
|
38
|
+
}
|
|
23
39
|
|
|
24
40
|
// ─── CST helpers ──────────────────────────────────────────────────────────
|
|
25
41
|
|
|
@@ -518,7 +534,7 @@ function walkInterfaceDeclaration(id, out) {
|
|
|
518
534
|
/** Parse a Java source file and return dead-branch line ranges.
|
|
519
535
|
* On parse error returns []; callers should fall back to non-AST behavior. */
|
|
520
536
|
export function deadBranchRanges(source) {
|
|
521
|
-
if (!source || source.length === 0 || source.length > 800_000) return [];
|
|
537
|
+
if (!parse || !source || source.length === 0 || source.length > 800_000) return [];
|
|
522
538
|
let cst;
|
|
523
539
|
try {
|
|
524
540
|
cst = parse(source);
|