@clear-capabilities/agentic-security-scanner 0.149.4 → 0.150.2

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.
Files changed (96) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/bin/agentic-security.js +533 -2
  3. package/dist/1122.index.js +16 -16
  4. package/dist/1208.index.js +23 -0
  5. package/dist/1301.index.js +3 -3
  6. package/dist/1310.index.js +3161 -0
  7. package/dist/1379.index.js +3 -3
  8. package/dist/1444.index.js +3 -3
  9. package/dist/1660.index.js +1 -1
  10. package/dist/1700.index.js +5 -5
  11. package/dist/1905.index.js +107 -12
  12. package/dist/1920.index.js +10 -10
  13. package/dist/2238.index.js +4 -4
  14. package/dist/2271.index.js +3 -3
  15. package/dist/2432.index.js +8 -8
  16. package/dist/2659.index.js +1 -1
  17. package/dist/2826.index.js +2 -2
  18. package/dist/2830.index.js +3 -3
  19. package/dist/2923.index.js +1 -1
  20. package/dist/3001.index.js +4 -4
  21. package/dist/3117.index.js +4 -4
  22. package/dist/3180.index.js +9 -9
  23. package/dist/3276.index.js +1 -1
  24. package/dist/3415.index.js +1 -1
  25. package/dist/3499.index.js +1 -1
  26. package/dist/3518.index.js +4 -4
  27. package/dist/3736.index.js +7 -7
  28. package/dist/3839.index.js +4 -4
  29. package/dist/4113.index.js +441 -418
  30. package/dist/4265.index.js +4 -4
  31. package/dist/4384.index.js +8 -3
  32. package/dist/4399.index.js +292 -0
  33. package/dist/4547.index.js +2 -2
  34. package/dist/4863.index.js +6 -6
  35. package/dist/4970.index.js +2 -2
  36. package/dist/5051.index.js +15 -15
  37. package/dist/5144.index.js +4 -4
  38. package/dist/5333.index.js +8 -8
  39. package/dist/5343.index.js +2 -2
  40. package/dist/5350.index.js +6 -6
  41. package/dist/5561.index.js +1 -1
  42. package/dist/5637.index.js +10 -5
  43. package/dist/5724.index.js +21642 -0
  44. package/dist/5756.index.js +588 -0
  45. package/dist/5830.index.js +3 -3
  46. package/dist/6257.index.js +170 -0
  47. package/dist/6626.index.js +5 -5
  48. package/dist/6662.index.js +1 -1
  49. package/dist/6675.index.js +4 -4
  50. package/dist/6730.index.js +10 -10
  51. package/dist/6829.index.js +4 -4
  52. package/dist/6944.index.js +2 -2
  53. package/dist/6994.index.js +143 -0
  54. package/dist/7039.index.js +541 -0
  55. package/dist/7178.index.js +11 -6
  56. package/dist/7227.index.js +3 -3
  57. package/dist/7552.index.js +2 -2
  58. package/dist/7709.index.js +2 -2
  59. package/dist/7838.index.js +1037 -0
  60. package/dist/8218.index.js +4 -4
  61. package/dist/8476.index.js +4 -4
  62. package/dist/8513.index.js +8 -8
  63. package/dist/8520.index.js +1 -1
  64. package/dist/8752.index.js +4 -4
  65. package/dist/9207.index.js +2 -2
  66. package/dist/9220.index.js +5 -5
  67. package/dist/9390.index.js +6 -6
  68. package/dist/9503.index.js +2 -2
  69. package/dist/9560.index.js +3221 -0
  70. package/dist/957.index.js +127 -0
  71. package/dist/9801.index.js +1 -1
  72. package/dist/9824.index.js +5 -5
  73. package/dist/agentic-security.mjs +23 -23
  74. package/dist/agentic-security.mjs.sha256 +1 -1
  75. package/package.json +4 -3
  76. package/src/discovery/disprove.js +6 -1
  77. package/src/discovery/hunter.js +10 -1
  78. package/src/discovery/llm-invoke.js +77 -0
  79. package/src/egress/policy.js +11 -1
  80. package/src/egress/redact.js +1 -1
  81. package/src/engine.js +37 -1
  82. package/src/llm-validator/agent-loop.js +215 -0
  83. package/src/llm-validator/agent-tools.js +271 -0
  84. package/src/llm-validator/explain-proposal.js +106 -0
  85. package/src/llm-validator/fix-proposal.js +149 -0
  86. package/src/llm-validator/index.js +51 -3
  87. package/src/llm-validator/model-capabilities.js +269 -0
  88. package/src/llm-validator/model-probe.js +232 -0
  89. package/src/llm-validator/model-status.js +27 -0
  90. package/src/llm-validator/ollama-provider.js +382 -0
  91. package/src/llm-validator/oom-feedback.js +69 -0
  92. package/src/llm-validator/poc-proposal.js +122 -0
  93. package/src/llm-validator/providers.js +75 -0
  94. package/src/llm-validator/redact.js +177 -11
  95. package/src/report/index.js +33 -0
  96. package/src/sast/java-ast-folding.js +18 -2
@@ -31,6 +31,7 @@
31
31
  // whose entire promise is that nothing leaves the machine.
32
32
 
33
33
  import { localEndpointConfig } from './local-endpoint.js';
34
+ import { ollamaEndpointConfig, DEFAULT_OLLAMA_MODEL } from './ollama-provider.js';
34
35
 
35
36
  // Roles the pipeline dispatches under. Closed set on purpose — see above.
36
37
  export const ROLES = Object.freeze([
@@ -113,6 +114,7 @@ const DEFAULT_MODEL = {
113
114
  openai: 'gpt-4o-mini',
114
115
  gemini: 'gemini-2.0-flash',
115
116
  local: 'local-model',
117
+ ollama: DEFAULT_OLLAMA_MODEL,
116
118
  };
117
119
 
118
120
  function _envKey(role, suffix) {
@@ -156,6 +158,29 @@ export function resolveProvider({ role = 'validate', env = process.env } = {}) {
156
158
  };
157
159
  }
158
160
 
161
+ // 1b. Ollama — a DISTINCT preset from `local` (agentic-security-ollama-
162
+ // offline-prd.md §3.3): `local` keeps its existing generic
163
+ // `{prompt, model}` wire shape forever, so an existing BYO/local server
164
+ // that expects exactly that shape never breaks. `ollama` speaks the
165
+ // real native /api/chat protocol via ollama-provider.js instead of a
166
+ // SHAPES entry — see that module's header for why. `config.shape` is
167
+ // deliberately absent here; callers must check `provider === 'ollama'`
168
+ // and delegate to ollama-provider.js rather than assume every resolved
169
+ // config carries a SHAPES-style shape.
170
+ if (explicit === 'ollama') {
171
+ const r = ollamaEndpointConfig(env);
172
+ if (!r.ok) return { ok: false, reason: r.reason, code: r.code };
173
+ return {
174
+ ok: true,
175
+ config: {
176
+ provider: 'ollama', shape: null, endpoint: r.config.host,
177
+ apiKey: null, model: model || DEFAULT_OLLAMA_MODEL,
178
+ egress: r.config.egress, role,
179
+ ollama: r.config,
180
+ },
181
+ };
182
+ }
183
+
159
184
  // 2. Explicit BYO endpoint — checked BEFORE the vendor presets because that
160
185
  // is the documented precedence: an operator who names an endpoint means
161
186
  // that endpoint, even with a preset also set. Reversing it would silently
@@ -212,6 +237,18 @@ export function buildProviderRequest(config, prompt, maxTokens) {
212
237
  };
213
238
  }
214
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
+
215
252
  /** Which provider each role would use, for reporting. Never includes keys. */
216
253
  export function providerMatrix(env = process.env) {
217
254
  const out = {};
@@ -224,4 +261,42 @@ export function providerMatrix(env = process.env) {
224
261
  return out;
225
262
  }
226
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
+
227
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 quoted string, are treated as
25
- // carrying a credential. Exact identifiers only (word-boundary matched)
26
- // e.g. `password_field` does NOT match `password` because `_` is a word
27
- // character and blocks the trailing \b.
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
- 'apiKey',
30
- 'api_key',
31
+ 'api[_-]?key',
31
32
  'secret',
32
33
  'token',
33
34
  'password',
34
35
  'passwd',
35
- 'client_secret',
36
- 'access_key',
37
- 'private_key',
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
- '(\\b(?:' + SECRET_KEY_NAMES.join('|') + ')\\b)(\\s*[:=]\\s*)([\'"`])([^\'"`]+)\\3',
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];
@@ -1598,6 +1598,39 @@ export function toShipVerdict(scan, options = {}) {
1598
1598
  lines.push(c(' /triage --explain <id> why it fired, the data-flow trace, and the fix', DIM));
1599
1599
  lines.push(c(' scan . --format html -o report.html shareable browser report (charts + filters)', DIM));
1600
1600
  }
1601
+ // ollama-offline-prd.md §26 — "AI Assistance" summary. Only shown when a
1602
+ // model tier actually ran (scan.aiAssistance is null when nothing is
1603
+ // configured — see engine.js's own guard) so a deterministic-only scan's
1604
+ // output is unchanged. Deliberately says "LLM inference was loopback-only"
1605
+ // rather than "this entire scan was fully offline" (§26's own wording
1606
+ // requirement) — this line describes the model calls, not deterministic
1607
+ // network access elsewhere in the scan (OSV/KEV/EPSS), which is a
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.
1616
+ const _ai = scan.aiAssistance;
1617
+ if (_ai) {
1618
+ lines.push('');
1619
+ lines.push(c(` AI Assistance (${_ai.scopeRole || 'validate'} role only)`, BOLD));
1620
+ lines.push(c(` Provider: ${_ai.provider || 'unknown'} Model: ${_ai.model || 'unknown'}`, DIM));
1621
+ lines.push(c(` LLM egress: ${_ai.egress || 'unknown'} Cloud fallback: ${_ai.cloudFallback ? 'enabled' : 'disabled'}`, DIM));
1622
+ for (const [stage, s] of Object.entries(_ai.stages || {})) {
1623
+ const parts = [`${s.calls} call${s.calls === 1 ? '' : 's'}`, `success ${s.success}`];
1624
+ if (s.refused) parts.push(`refused ${s.refused}`);
1625
+ if (s.failed) parts.push(`failed ${s.failed}`);
1626
+ lines.push(c(` ${stage.padEnd(10)} ${parts.join(' ')}`, DIM));
1627
+ }
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
+ }
1633
+ }
1601
1634
  // Coverage-honesty line (#5/#6): show the scan's blind spots — which
1602
1635
  // languages got flow analysis vs pattern-only, what was skipped, and how
1603
1636
  // many dangerous-looking calls had no finding. One concise line, not bloat.
@@ -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
- import { parse } from '#java-parser';
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);