@clear-capabilities/agentic-security-scanner 0.127.0 → 0.130.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/bin/agentic-security.js +33 -0
  3. package/dist/11.index.js +353 -0
  4. package/dist/113.index.js +727 -0
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +217 -0
  7. package/dist/384.index.js +1 -1
  8. package/dist/415.index.js +1 -1
  9. package/dist/435.index.js +19 -8
  10. package/dist/526.index.js +555 -0
  11. package/dist/637.index.js +1 -1
  12. package/dist/826.index.js +4 -1
  13. package/dist/830.index.js +1 -1
  14. package/dist/agentic-security.mjs +113 -163
  15. package/dist/agentic-security.mjs.sha256 +1 -1
  16. package/package.json +23 -15
  17. package/src/dataflow/CLAUDE.md +4 -1
  18. package/src/dataflow/async-sequencing.js +8 -3
  19. package/src/dataflow/catalog.js +278 -11
  20. package/src/dataflow/cross-repo.js +1 -1
  21. package/src/dataflow/cross-service-taint.js +1 -1
  22. package/src/dataflow/engine.js +182 -61
  23. package/src/dataflow/ifds.js +10 -5
  24. package/src/dataflow/index.js +15 -3
  25. package/src/dataflow/points-to.js +8 -2
  26. package/src/dataflow/proof-gate.js +7 -0
  27. package/src/dataflow/sanitizer-gate.js +89 -0
  28. package/src/dataflow/tabulation.js +14 -3
  29. package/src/engine.js +181 -8
  30. package/src/integrations/index.js +1 -1
  31. package/src/integrations/tickets.js +9 -3
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +5 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +17 -6
  49. package/src/posture/CLAUDE.md +122 -0
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +199 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/cross-lang-openapi.js +1 -1
  56. package/src/posture/custom-rules.js +1 -1
  57. package/src/posture/entrypoint-inventory.js +248 -0
  58. package/src/posture/execution-proof.js +52 -0
  59. package/src/posture/exploitability-probability.js +1 -1
  60. package/src/posture/falsification.js +165 -0
  61. package/src/posture/fix-honesty-gate.js +175 -0
  62. package/src/posture/fix-verify.js +71 -3
  63. package/src/posture/license-policy.js +1 -1
  64. package/src/posture/model-routing.js +126 -0
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +262 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/pr-comment.js +3 -1
  76. package/src/report/index.js +11 -0
  77. package/src/runScan.js +3 -1
  78. package/src/sandbox/CLAUDE.md +218 -0
  79. package/src/sandbox/backend-disabled.js +14 -0
  80. package/src/sandbox/backend-namespace.js +83 -0
  81. package/src/sandbox/backend-userspace.js +100 -0
  82. package/src/sandbox/capabilities.js +53 -0
  83. package/src/sandbox/index.js +30 -0
  84. package/src/sandbox/limits.js +42 -0
  85. package/src/sandbox/result.js +104 -0
  86. package/src/sca/dep-confusion.js +1 -1
  87. package/src/util/untrusted.js +148 -0
  88. package/src/util/yaml.js +24 -0
@@ -0,0 +1,727 @@
1
+ export const id = 113;
2
+ export const ids = [113,526];
3
+ export const modules = {
4
+
5
+ /***/ 4113:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
+ /* harmony export */ verifyFixWithTests: () => (/* binding */ verifyFixWithTests)
10
+ /* harmony export */ });
11
+ /* unused harmony export runProjectTests */
12
+ /* harmony import */ var node_child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1421);
13
+ /* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3024);
14
+ /* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(6760);
15
+ /* harmony import */ var _fix_verify_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(3526);
16
+ // Closed-loop fix verification (v0.68).
17
+ //
18
+ // Existing `fix-verify.js` does scan + lint. This module adds the third
19
+ // leg: run the project's test suite against the patched file set. A fix
20
+ // is `verified-clean` only when:
21
+ //
22
+ // 1. Re-scan no longer fires the original finding's stableId
23
+ // 2. No new ≥medium findings introduced
24
+ // 3. Project linter (when present) passes on the patched files
25
+ // 4. Project test runner (when present) exits 0 within budget
26
+ //
27
+ // If the project has no detected test runner, we emit `untested-but-passes`
28
+ // rather than fail-closed — many small repos have no test suite and we
29
+ // don't want to refuse all fixes there. The verdict is honest.
30
+ //
31
+ // Design note: we run the tests against the WRITTEN patch, not an in-
32
+ // memory overlay — most real test runners can't be given an alternate
33
+ // filesystem cheaply. Callers are expected to apply the patch first
34
+ // (typically via fix-history.applyFix which creates a recovery backup),
35
+ // then call this. If verification fails, undoLast() rolls back.
36
+
37
+
38
+
39
+
40
+
41
+
42
+ const DEFAULT_TIMEOUT_MS = 120_000;
43
+
44
+ // Test-runner discovery. Each entry: a sentinel-file check + a command +
45
+ // args. Order matters — JS first (most common), then Python, Go, Rust,
46
+ // Java/Maven, Java/Gradle, Ruby.
47
+ function _detectRunner(scanRoot) {
48
+ const has = (p) => { try { return node_fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(node_path__WEBPACK_IMPORTED_MODULE_2__.join(scanRoot, p)); } catch { return false; } };
49
+ const pkg = (() => {
50
+ try {
51
+ const raw = node_fs__WEBPACK_IMPORTED_MODULE_1__.readFileSync(node_path__WEBPACK_IMPORTED_MODULE_2__.join(scanRoot, 'package.json'), 'utf8');
52
+ return JSON.parse(raw);
53
+ } catch { return null; }
54
+ })();
55
+ if (pkg && pkg.scripts && pkg.scripts.test && !/no test specified/.test(String(pkg.scripts.test))) {
56
+ return { runner: 'npm', cmd: 'npm', args: ['test', '--silent', '--', '--passWithNoTests'] };
57
+ }
58
+ if (has('pytest.ini') || has('pyproject.toml') || has('setup.cfg')) {
59
+ return { runner: 'pytest', cmd: 'pytest', args: ['-q', '--no-header', '-x'] };
60
+ }
61
+ if (has('go.mod')) {
62
+ return { runner: 'go-test', cmd: 'go', args: ['test', './...'] };
63
+ }
64
+ if (has('Cargo.toml')) {
65
+ return { runner: 'cargo-test', cmd: 'cargo', args: ['test', '--quiet'] };
66
+ }
67
+ if (has('Gemfile')) {
68
+ return { runner: 'rspec', cmd: 'bundle', args: ['exec', 'rspec', '--fail-fast'] };
69
+ }
70
+ if (has('pom.xml')) {
71
+ return { runner: 'maven', cmd: 'mvn', args: ['-q', 'test', '-DfailIfNoTests=false'] };
72
+ }
73
+ if (has('build.gradle') || has('build.gradle.kts')) {
74
+ return { runner: 'gradle', cmd: './gradlew', args: ['test', '--quiet', '--no-daemon'] };
75
+ }
76
+ return null;
77
+ }
78
+
79
+ // Run the detected test runner. Honors a walltime budget. Caller may pass
80
+ // `runnerOverride` to force a specific command (rare; mostly for tests).
81
+ function runProjectTests(scanRoot, opts = {}) {
82
+ if (!scanRoot) return { ok: true, runner: 'none', skipped: true };
83
+ const choice = opts.runnerOverride
84
+ ? { runner: opts.runnerOverride.cmd, cmd: opts.runnerOverride.cmd, args: opts.runnerOverride.args || [] }
85
+ : _detectRunner(scanRoot);
86
+ if (!choice) return { ok: true, runner: 'none', skipped: true, reason: 'no-test-runner-detected' };
87
+ const timeout = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
88
+ let r;
89
+ try {
90
+ r = (0,node_child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync)(choice.cmd, choice.args, {
91
+ cwd: scanRoot,
92
+ encoding: 'utf8',
93
+ timeout,
94
+ env: { ...process.env, CI: '1' },
95
+ });
96
+ } catch (e) {
97
+ return { ok: false, runner: choice.runner, reason: 'spawn-failed', error: e.message };
98
+ }
99
+ if (r.error && r.error.code === 'ENOENT') {
100
+ // Runner not installed — different from "tests failed". Don't fail-closed.
101
+ return { ok: true, runner: choice.runner, skipped: true, reason: 'binary-missing' };
102
+ }
103
+ if (r.status === null) {
104
+ return {
105
+ ok: false, runner: choice.runner, reason: 'timed-out',
106
+ output: ((r.stderr || '') + (r.stdout || '')).slice(-2000),
107
+ };
108
+ }
109
+ return {
110
+ ok: r.status === 0,
111
+ runner: choice.runner,
112
+ exitCode: r.status,
113
+ output: ((r.stderr || '') + (r.stdout || '')).slice(-2000),
114
+ };
115
+ }
116
+
117
+ // Closed-loop verification: scan + lint + tests. Returns a single verdict
118
+ // with per-leg detail so the caller can render a precise summary.
119
+ //
120
+ // Returns:
121
+ // {
122
+ // ok: bool,
123
+ // verdict: 'verified-clean' | 'verification-failed' | 'untested-but-passes',
124
+ // legs: { scan: …, lint: …, tests: … },
125
+ // summary: '<human-readable line>',
126
+ // }
127
+ //
128
+ // The `untested-but-passes` verdict is real and intentional: scan+lint
129
+ // passed, but no test runner was found. This is honest signal — callers
130
+ // (the security-fixer agent, downstream MCP tools) can decide whether to
131
+ // require a stronger verdict.
132
+ async function verifyFixWithTests({
133
+ scanRoot,
134
+ originalFindingStableId,
135
+ files,
136
+ depFileContents,
137
+ runTests = true,
138
+ testRunnerOverride,
139
+ testTimeoutMs,
140
+ } = {}) {
141
+ const scanLint = await (0,_fix_verify_js__WEBPACK_IMPORTED_MODULE_3__.verifyFix)({ scanRoot, originalFindingStableId, files, depFileContents });
142
+ const legs = {
143
+ scan: { ok: scanLint.rescan?.ok ?? scanLint.ok, detail: scanLint.rescan ?? scanLint },
144
+ lint: { ok: scanLint.lint?.ok ?? true, detail: scanLint.lint ?? null },
145
+ tests: { ok: true, detail: null, skipped: true, reason: 'not-run' },
146
+ };
147
+ if (!legs.scan.ok || !legs.lint.ok) {
148
+ return {
149
+ ok: false,
150
+ verdict: 'verification-failed',
151
+ legs,
152
+ summary: _summarize(legs, 'verification-failed'),
153
+ };
154
+ }
155
+ if (runTests) {
156
+ const tests = runProjectTests(scanRoot, { runnerOverride: testRunnerOverride, timeoutMs: testTimeoutMs });
157
+ legs.tests = { ok: tests.ok, detail: tests, skipped: !!tests.skipped, reason: tests.reason };
158
+ }
159
+ const allOk = legs.scan.ok && legs.lint.ok && legs.tests.ok;
160
+ const verdict = !allOk
161
+ ? 'verification-failed'
162
+ : (legs.tests.skipped ? 'untested-but-passes' : 'verified-clean');
163
+ return { ok: allOk, verdict, legs, summary: _summarize(legs, verdict) };
164
+ }
165
+
166
+ function _summarize(legs, verdict) {
167
+ const bits = [];
168
+ bits.push(`scan: ${legs.scan.ok ? 'pass' : 'fail'}`);
169
+ bits.push(`lint: ${legs.lint.skipped ? 'skip' : legs.lint.ok ? 'pass' : 'fail'}`);
170
+ bits.push(`tests: ${legs.tests.skipped ? 'skip' : legs.tests.ok ? 'pass' : 'fail'}`);
171
+ return `${verdict} (${bits.join(' · ')})`;
172
+ }
173
+
174
+
175
+ /***/ }),
176
+
177
+ /***/ 3526:
178
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
179
+
180
+ // ESM COMPAT FLAG
181
+ __webpack_require__.r(__webpack_exports__);
182
+
183
+ // EXPORTS
184
+ __webpack_require__.d(__webpack_exports__, {
185
+ runProjectLinter: () => (/* binding */ runProjectLinter),
186
+ verifyFix: () => (/* binding */ verifyFix),
187
+ verifyPatch: () => (/* binding */ verifyPatch)
188
+ });
189
+
190
+ // EXTERNAL MODULE: external "node:child_process"
191
+ var external_node_child_process_ = __webpack_require__(1421);
192
+ // EXTERNAL MODULE: external "node:fs"
193
+ var external_node_fs_ = __webpack_require__(3024);
194
+ // EXTERNAL MODULE: external "node:path"
195
+ var external_node_path_ = __webpack_require__(6760);
196
+ // EXTERNAL MODULE: ./src/engine.js + 595 modules
197
+ var engine = __webpack_require__(4660);
198
+ ;// CONCATENATED MODULE: ./src/posture/fix-honesty-gate.js
199
+ // Deterministic honesty gates on fix / finding output (#7).
200
+ //
201
+ // The project's verification discipline (scanner/CLAUDE.md) exists because
202
+ // several releases shipped broken or false because work was reported as done
203
+ // without confirming the artifact changed. Two of those failure modes are
204
+ // *textual* — they live in the prose an agent emits alongside a fix — and can
205
+ // be caught deterministically, with no LLM and no network:
206
+ //
207
+ // 1. Hand-wave residual-risk prose. "The input is adequately handled",
208
+ // "future work", "tbd", "later" — vague assurances that claim safety
209
+ // without naming a concrete remaining vector. A residual you can't name
210
+ // is a residual you're guessing about; reject the guess.
211
+ //
212
+ // 2. An unbacked "this is a false positive / provably safe" verdict. Marking
213
+ // a finding safe is a coverage *reduction* — it must cite a `file:line`
214
+ // that shows why, exactly like the rules-override gate refuses to silently
215
+ // shrink coverage.
216
+ //
217
+ // Plus a conservative fix-tier classifier so a partial remediation can never be
218
+ // labelled FULL: any workaround-only signal (rate-limit, docs, log-without-
219
+ // reject) is WORKAROUND; anything short of (sink signature changed + all callers
220
+ // routed + a discriminating test) is at most MITIGATION; only the full set with
221
+ // no partial-sanitization caveat earns FULL.
222
+ //
223
+ // Pure functions, no side effects, no throwing — safe to call from a command,
224
+ // a hook, or the MCP verify_fix path.
225
+
226
+ // Vague-assurance phrases that a real residual must never hide behind. Matched
227
+ // case-insensitively with word boundaries so "later" doesn't trip on
228
+ // "collateral" and "tbd" doesn't trip on a longer token.
229
+ const BANNED_RESIDUAL_PHRASES = Object.freeze([
230
+ 'adequately handled',
231
+ 'adequately handles',
232
+ 'properly validated',
233
+ 'properly handled',
234
+ 'handled properly',
235
+ 'handled safely',
236
+ 'future work',
237
+ 'more work needed',
238
+ 'to be done',
239
+ 'tbd',
240
+ 'later',
241
+ ]);
242
+
243
+ // A citation shaped like `file:line` — one or more non-space, non-colon chars,
244
+ // a colon, then digits. Unanchored: it need only appear somewhere in the item.
245
+ const CITATION_RE = /[^\s:]+:\d+/;
246
+
247
+ // Verdicts that assert the finding is not real and therefore demand a citation.
248
+ // Compared after normalizing separators (`_`/space → `-`) and lowercasing, so
249
+ // FALSE_POSITIVE, false-positive, and "provably safe" all land here.
250
+ const FP_VERDICTS = Object.freeze(new Set(['false-positive', 'provably-safe', 'safe']));
251
+
252
+ function _escapeRe(s) {
253
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
254
+ }
255
+
256
+ /**
257
+ * Reject vague-assurance / hand-wave residual-risk prose.
258
+ *
259
+ * An empty or whitespace-only residual is ok — there is no residual to lie
260
+ * about. A non-empty residual is rejected when it contains any banned phrase;
261
+ * each match yields one violation naming the offending phrase.
262
+ *
263
+ * @param {string} residualText
264
+ * @returns {{ ok: boolean, violations: string[] }}
265
+ */
266
+ function checkResidualHonesty(residualText) {
267
+ const text = typeof residualText === 'string' ? residualText : '';
268
+ if (text.trim() === '') return { ok: true, violations: [] };
269
+
270
+ const violations = [];
271
+ for (const phrase of BANNED_RESIDUAL_PHRASES) {
272
+ const re = new RegExp(`\\b${_escapeRe(phrase)}\\b`, 'i');
273
+ if (re.test(text)) {
274
+ violations.push(`vague-assurance phrase: "${phrase}"`);
275
+ }
276
+ }
277
+ return { ok: violations.length === 0, violations };
278
+ }
279
+
280
+ function _isCitation(item) {
281
+ if (typeof item === 'string') return CITATION_RE.test(item);
282
+ if (item && typeof item === 'object' && typeof item.location === 'string') {
283
+ return CITATION_RE.test(item.location);
284
+ }
285
+ return false;
286
+ }
287
+
288
+ function _normalizeVerdict(verdict) {
289
+ return String(verdict).trim().toLowerCase().replace(/[_\s]+/g, '-');
290
+ }
291
+
292
+ /**
293
+ * Require a file:line citation behind a "this is not real" verdict.
294
+ *
295
+ * For a false-positive / provably-safe / safe verdict (case-insensitive; also
296
+ * accepts FALSE_POSITIVE), at least one evidence item must be a `file:line`
297
+ * citation — either a string matching /[^\s:]+:\d+/ or an object
298
+ * `{ location: "file:line" }`. Any other verdict passes unconditionally.
299
+ *
300
+ * @param {string} verdict
301
+ * @param {Array|string|object} evidence
302
+ * @returns {{ ok: boolean, violations: string[] }}
303
+ */
304
+ function requireCitedEvidence(verdict, evidence) {
305
+ if (typeof verdict !== 'string' || !FP_VERDICTS.has(_normalizeVerdict(verdict))) {
306
+ return { ok: true, violations: [] };
307
+ }
308
+ const items = Array.isArray(evidence)
309
+ ? evidence
310
+ : evidence == null
311
+ ? []
312
+ : [evidence];
313
+ if (items.some(_isCitation)) return { ok: true, violations: [] };
314
+ return {
315
+ ok: false,
316
+ violations: ['false-positive/safe verdict requires a file:line citation'],
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Classify a fix into FULL | MITIGATION | WORKAROUND, conservative-first.
322
+ *
323
+ * @param {object} signals
324
+ * @param {boolean} signals.sinkSignatureChanged
325
+ * @param {boolean} signals.allCallersRouted
326
+ * @param {boolean} signals.testDiscriminates - a test that fails pre-fix, passes post-fix
327
+ * @param {boolean} [signals.rateLimitOnly]
328
+ * @param {boolean} [signals.docsOnly]
329
+ * @param {boolean} [signals.logOnlyNoReject]
330
+ * @param {boolean} [signals.partialSanitization]
331
+ * @returns {'FULL'|'MITIGATION'|'WORKAROUND'}
332
+ */
333
+ function computeFixTier(signals) {
334
+ const s = signals && typeof signals === 'object' ? signals : {};
335
+ if (s.rateLimitOnly || s.docsOnly || s.logOnlyNoReject) return 'WORKAROUND';
336
+ const complete = s.sinkSignatureChanged && s.allCallersRouted && s.testDiscriminates;
337
+ if (s.partialSanitization || !complete) return 'MITIGATION';
338
+ return 'FULL';
339
+ }
340
+
341
+ /**
342
+ * Compose the three gates for a single fix's output.
343
+ *
344
+ * ok = residual-honesty ok AND evidence-citation ok, further constrained by the
345
+ * tier/residual consistency invariant:
346
+ * - a FULL tier must NOT carry a residual (a full fix has nothing left);
347
+ * - a non-FULL tier MUST document a residual (say what's still open).
348
+ *
349
+ * @param {{ residual?: string, verdict?: string, evidence?: any, signals?: object }} input
350
+ * @returns {{ ok: boolean, tier: string, violations: string[] }}
351
+ */
352
+ function gateFixOutput({ residual, verdict, evidence, signals } = {}) {
353
+ const tier = computeFixTier(signals);
354
+ const residualCheck = checkResidualHonesty(residual);
355
+ const evidenceCheck = requireCitedEvidence(verdict, evidence);
356
+
357
+ const violations = [...residualCheck.violations, ...evidenceCheck.violations];
358
+ let ok = residualCheck.ok && evidenceCheck.ok;
359
+
360
+ const residualEmpty = typeof residual !== 'string' || residual.trim() === '';
361
+ if (tier === 'FULL' && !residualEmpty) {
362
+ violations.push('FULL tier cannot carry a residual');
363
+ ok = false;
364
+ }
365
+ if (tier !== 'FULL' && residualEmpty) {
366
+ violations.push('non-FULL tier must document a residual');
367
+ ok = false;
368
+ }
369
+
370
+ return { ok, tier, violations };
371
+ }
372
+
373
+ const _internals = Object.freeze({ BANNED_RESIDUAL_PHRASES, CITATION_RE, FP_VERDICTS });
374
+
375
+ ;// CONCATENATED MODULE: ./src/posture/test-runner.js
376
+ // R5 (partial, roadmap) — the project's own test suite as a verification
377
+ // stage for `verifyFix()` (see `fix-verify.js`). Closes the gap where a
378
+ // "verified" fix only proved a finding's stableId stopped firing — a patch
379
+ // that deletes the feature entirely would satisfy that just as well as a
380
+ // real fix. Running the project's own tests is the cheapest available check
381
+ // that the application still works.
382
+ //
383
+ // Execution-safety note: this spawns the TARGET PROJECT's own test command
384
+ // in the target project's own directory. That is deliberately NOT routed
385
+ // through the R1 confinement sandbox (`../sandbox/`). That sandbox exists to
386
+ // contain untrusted proof-of-concept exploit code the scanner itself
387
+ // synthesizes — code nobody has vetted, being run for the first time. A
388
+ // project's pre-existing test suite is the opposite case: it is the
389
+ // project's own trusted source, already sitting on disk, and running it is
390
+ // exactly what a human developer does by hand before trusting a fix.
391
+ // Wrapping "npm test" / "pytest" / "go test" in the PoC sandbox's
392
+ // syscall/network/filesystem restrictions would break the large majority of
393
+ // real test suites (they bind local ports, spawn child processes, write temp
394
+ // fixtures, etc.) for no corresponding security benefit.
395
+
396
+
397
+
398
+
399
+
400
+ const DEFAULT_TIMEOUT_MS = 300_000;
401
+ const NPM_PLACEHOLDER = /Error: no test specified/i;
402
+
403
+ function _exists(scanRoot, rel) {
404
+ try { return external_node_fs_.existsSync(external_node_path_.join(scanRoot, rel)); } catch { return false; }
405
+ }
406
+
407
+ function _isDir(scanRoot, rel) {
408
+ try { return external_node_fs_.statSync(external_node_path_.join(scanRoot, rel)).isDirectory(); } catch { return false; }
409
+ }
410
+
411
+ function _binaryAvailable(cmd) {
412
+ try {
413
+ const r = (0,external_node_child_process_.spawnSync)(cmd, ['--version'], { timeout: 5_000, stdio: 'ignore' });
414
+ return !(r.error && r.error.code === 'ENOENT');
415
+ } catch {
416
+ return false;
417
+ }
418
+ }
419
+
420
+ // Detect the project's test command. Read-only — never spawns the actual
421
+ // test run, only (optionally) a cheap `--version` probe to confirm a tool
422
+ // like `pytest` is actually installed before committing to it. Returns
423
+ // `null` when nothing detectable is found — most scanned repos will hit
424
+ // this path, and that must not be treated as a failure by callers.
425
+ function detectTestCommand(scanRoot) {
426
+ if (!scanRoot) return null;
427
+
428
+ // JS/TS — package.json with a real (non-placeholder) `scripts.test`.
429
+ let pkg = null;
430
+ try { pkg = JSON.parse(external_node_fs_.readFileSync(external_node_path_.join(scanRoot, 'package.json'), 'utf8')); } catch { pkg = null; }
431
+ const testScript = pkg && pkg.scripts && pkg.scripts.test;
432
+ if (testScript && !NPM_PLACEHOLDER.test(String(testScript))) {
433
+ if (_exists(scanRoot, 'pnpm-lock.yaml')) return { cmd: 'pnpm', args: ['test'], kind: 'pnpm' };
434
+ if (_exists(scanRoot, 'yarn.lock')) return { cmd: 'yarn', args: ['test'], kind: 'yarn' };
435
+ if (_exists(scanRoot, 'bun.lockb') || _exists(scanRoot, 'bun.lock')) return { cmd: 'bun', args: ['test'], kind: 'bun' };
436
+ return { cmd: 'npm', args: ['test', '--silent'], kind: 'npm' };
437
+ }
438
+
439
+ // Python — pytest.ini / pyproject.toml / tox.ini / a tests/ dir, and the
440
+ // `pytest` binary actually available. If pytest isn't installed we do NOT
441
+ // report a python test command — falling through lets a later language
442
+ // marker (e.g. go.mod in a polyglot repo) still be detected.
443
+ const pyMarker = _exists(scanRoot, 'pytest.ini') || _exists(scanRoot, 'pyproject.toml') ||
444
+ _exists(scanRoot, 'tox.ini') || _isDir(scanRoot, 'tests');
445
+ if (pyMarker && _binaryAvailable('pytest')) {
446
+ return { cmd: 'pytest', args: ['-q'], kind: 'pytest' };
447
+ }
448
+
449
+ // Go
450
+ if (_exists(scanRoot, 'go.mod')) {
451
+ return { cmd: 'go', args: ['test', './...'], kind: 'go' };
452
+ }
453
+
454
+ return null;
455
+ }
456
+
457
+ // Run the detected test command with a walltime budget. Always returns a
458
+ // result object — never throws. Distinguishes four outcomes:
459
+ // - no detectable/runnable command -> status: 'skipped' (does NOT fail)
460
+ // - ran and exited 0 -> status: 'passed'
461
+ // - ran and exited non-zero -> status: 'failed'
462
+ // - ran past the timeout budget -> status: 'failed', timedOut: true
463
+ function runProjectTests(scanRoot, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
464
+ const startedAt = Date.now();
465
+ const command = detectTestCommand(scanRoot);
466
+ if (!command) {
467
+ return {
468
+ status: 'skipped', passed: null, skipped: true,
469
+ reason: 'no-test-command-detected', exitCode: null, timedOut: false,
470
+ durationMs: Date.now() - startedAt,
471
+ };
472
+ }
473
+
474
+ let r;
475
+ try {
476
+ r = (0,external_node_child_process_.spawnSync)(command.cmd, command.args, {
477
+ cwd: scanRoot,
478
+ encoding: 'utf8',
479
+ timeout: timeoutMs,
480
+ env: { ...process.env, CI: '1' },
481
+ });
482
+ } catch (e) {
483
+ // The spawn call itself threw (rare — e.g. cwd vanished). Treat as
484
+ // "could not run", not "ran and failed".
485
+ return {
486
+ status: 'skipped', passed: null, skipped: true,
487
+ reason: `spawn-error: ${e.message}`, exitCode: null, timedOut: false,
488
+ durationMs: Date.now() - startedAt,
489
+ };
490
+ }
491
+ const durationMs = Date.now() - startedAt;
492
+
493
+ if (r.error && r.error.code === 'ENOENT') {
494
+ // The detected tool isn't actually installed on this machine. Not a
495
+ // test failure — the suite never ran.
496
+ return {
497
+ status: 'skipped', passed: null, skipped: true,
498
+ reason: `${command.kind}-not-installed`, exitCode: null, timedOut: false, durationMs,
499
+ };
500
+ }
501
+
502
+ if (r.status === null) {
503
+ // spawnSync sets status:null both on timeout-kill and on being killed by
504
+ // another signal; either way the run did not complete, which is a
505
+ // verification failure, never a skip — we asked for a result and the
506
+ // process was terminated before producing one.
507
+ return {
508
+ status: 'failed', passed: false, skipped: false,
509
+ reason: 'timed-out', exitCode: null, timedOut: true, durationMs,
510
+ };
511
+ }
512
+
513
+ return {
514
+ status: r.status === 0 ? 'passed' : 'failed',
515
+ passed: r.status === 0,
516
+ skipped: false,
517
+ reason: r.status === 0 ? null : 'test-failures',
518
+ exitCode: r.status,
519
+ timedOut: false,
520
+ durationMs,
521
+ };
522
+ }
523
+
524
+ ;// CONCATENATED MODULE: ./src/posture/fix-verify.js
525
+ // Closed-loop /fix verification (Sentinel-parity FR-L4-4, FR-L4-5).
526
+ //
527
+ // Given a candidate patch (the new file content + the finding stableId being
528
+ // fixed), verify it:
529
+ //
530
+ // 1. The original finding's stableId no longer fires on the patched file.
531
+ // 2. No new findings at severity ≥ medium were introduced by the patch.
532
+ // 3. The project's existing linter (when present) passes on the patched file.
533
+ // 4. The project's own test suite (when detectable) still passes. This is
534
+ // the R5 gap-closer: a patch that silently deletes the feature would
535
+ // satisfy (1) and (2) just as well as a real fix — only running the
536
+ // tests catches that. See `test-runner.js` for detection + execution.
537
+ //
538
+ // If any of those fail, the caller is expected to NOT apply the patch and
539
+ // instead surface a "fix plan" — a numbered list of steps the engineer can
540
+ // follow — rather than dump a broken patch on the user.
541
+
542
+
543
+
544
+
545
+
546
+
547
+
548
+
549
+ const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
550
+
551
+ // Run a focused re-scan over just the patched file(s) using the in-memory
552
+ // engine. No filesystem write needed — we hand the new content in via the
553
+ // fileContents map.
554
+ async function verifyPatch({
555
+ scanRoot,
556
+ originalFindingStableId,
557
+ files, // { [relPath]: newContent }
558
+ depFileContents = {},
559
+ } = {}) {
560
+ if (!files || typeof files !== 'object') return { ok: false, reason: 'no-files-provided' };
561
+ const fileContents = { ...files };
562
+ let scan;
563
+ try {
564
+ scan = await (0,engine/* runFullScan */.wW)({ fileContents, depFileContents, scanRoot }, () => {});
565
+ } catch (e) {
566
+ return { ok: false, reason: 'rescan-failed', error: e.message };
567
+ }
568
+ const findings = (scan && scan.findings) || [];
569
+ const stillHasOriginal = !!originalFindingStableId &&
570
+ findings.some(f => f.stableId === originalFindingStableId);
571
+ if (stillHasOriginal) {
572
+ return { ok: false, reason: 'original-finding-still-present', stableId: originalFindingStableId };
573
+ }
574
+ const introducedHighOrAbove = findings.filter(f =>
575
+ (SEVERITY_RANK[f.severity] ?? 9) <= SEVERITY_RANK.medium);
576
+ // Don't count findings on lines outside the patched files — but our
577
+ // fileContents map IS the patched files, so every finding is in-scope.
578
+ return {
579
+ ok: introducedHighOrAbove.length === 0,
580
+ reason: introducedHighOrAbove.length === 0 ? 'verified' : 'introduced-new-findings',
581
+ introduced: introducedHighOrAbove.map(f => ({
582
+ vuln: f.vuln, file: f.file, line: f.line, severity: f.severity,
583
+ stableId: f.stableId,
584
+ })),
585
+ };
586
+ }
587
+
588
+ // Detect which linter the project uses and run it on the patched files.
589
+ // Returns { ok, runner, output } or { ok: true, runner: 'none' } when no
590
+ // linter is configured (silent pass).
591
+ function runProjectLinter(scanRoot, filePaths) {
592
+ if (!scanRoot || !Array.isArray(filePaths) || filePaths.length === 0) {
593
+ return { ok: true, runner: 'none' };
594
+ }
595
+ const has = (p) => { try { return external_node_fs_.existsSync(external_node_path_.join(scanRoot, p)); } catch { return false; } };
596
+ // Pick the linter by config file present in the repo root.
597
+ const jsFiles = filePaths.filter(f => /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(f));
598
+ const pyFiles = filePaths.filter(f => /\.py$/i.test(f));
599
+ const goFiles = filePaths.filter(f => /\.go$/i.test(f));
600
+ const javaFiles = filePaths.filter(f => /\.java$/i.test(f));
601
+
602
+ if (jsFiles.length && (has('.eslintrc') || has('.eslintrc.json') || has('.eslintrc.js') || has('eslint.config.js') || has('eslint.config.mjs'))) {
603
+ return runLinter(scanRoot, 'eslint', ['--no-error-on-unmatched-pattern', ...jsFiles]);
604
+ }
605
+ if (pyFiles.length && (has('pyproject.toml') || has('ruff.toml') || has('.ruff.toml'))) {
606
+ return runLinter(scanRoot, 'ruff', ['check', ...pyFiles]);
607
+ }
608
+ if (pyFiles.length && has('.flake8')) {
609
+ return runLinter(scanRoot, 'flake8', pyFiles);
610
+ }
611
+ if (goFiles.length && (has('.golangci.yml') || has('.golangci.yaml'))) {
612
+ return runLinter(scanRoot, 'golangci-lint', ['run', ...goFiles]);
613
+ }
614
+ if (javaFiles.length && has('checkstyle.xml')) {
615
+ return runLinter(scanRoot, 'checkstyle', ['-c', 'checkstyle.xml', ...javaFiles]);
616
+ }
617
+ return { ok: true, runner: 'none' };
618
+ }
619
+
620
+ function runLinter(cwd, cmd, args) {
621
+ let r;
622
+ try {
623
+ r = (0,external_node_child_process_.spawnSync)(cmd, args, { cwd, encoding: 'utf8', timeout: 60_000 });
624
+ } catch (e) {
625
+ return { ok: true, runner: cmd, skipped: true, reason: 'binary-missing', error: e.message };
626
+ }
627
+ if (r.error && r.error.code === 'ENOENT') {
628
+ return { ok: true, runner: cmd, skipped: true, reason: 'binary-missing' };
629
+ }
630
+ if (r.status === null) {
631
+ return { ok: false, runner: cmd, reason: 'timed-out', output: (r.stderr || r.stdout || '').slice(-2000) };
632
+ }
633
+ return {
634
+ ok: r.status === 0,
635
+ runner: cmd,
636
+ exitCode: r.status,
637
+ output: ((r.stderr || '') + (r.stdout || '')).slice(-2000),
638
+ };
639
+ }
640
+
641
+ // Top-level verify: re-scan + lint. Returns the combined verdict + a
642
+ // human-readable summary string suitable for surfacing to the user.
643
+ // Addition #7 — deterministic honesty gates on fix output. When the caller
644
+ // supplies `fixMeta` ({ residual, verdict, evidence, signals }) — e.g. the
645
+ // security-fixer agent's residual-risk text + completeness signals — the fix's
646
+ // claims are checked mechanically (no hand-wave residual prose, a cited
647
+ // file:line for any FP/safe verdict, and a FULL/MITIGATION/WORKAROUND tier). A
648
+ // dishonest or over-claiming fix fails the gate. When `fixMeta` is absent
649
+ // (the deterministic MCP write path, which has no claims to check) the honesty
650
+ // gate is skipped and behavior is unchanged.
651
+ // R5 (partial) — the test-suite stage. Runs the target project's own tests,
652
+ // in the target project's own directory, against whatever is currently on
653
+ // disk there. See `test-runner.js`'s header comment for why that run is
654
+ // deliberately NOT routed through the R1 PoC-confinement sandbox: this is
655
+ // the project's own already-trusted suite, not untrusted synthesized code.
656
+ //
657
+ // Caveat that matters for callers: `verifyPatch` above re-scans the
658
+ // candidate patch purely in memory (no write to disk), but a test runner
659
+ // needs real files — there is no cheap way to hand a runner an in-memory
660
+ // overlay. So this leg reports on the CURRENT on-disk tree, not the
661
+ // candidate `files` map, when `verifyFix` is used as a pre-write preview
662
+ // (e.g. the `verify_fix` MCP tool). Callers that apply the patch first and
663
+ // then re-verify get the strongest signal; that ordering is not enforced
664
+ // here — it's the caller's responsibility, same as it already is for the
665
+ // closed-loop `fix-verify-loop.js` path.
666
+ // Does the caller's candidate patch differ from what is on disk right now?
667
+ // If so, any test run necessarily exercised the pre-patch tree. Compared by
668
+ // content so a patch that happens to match disk (already applied) is correctly
669
+ // treated as NOT pre-patch.
670
+ function _candidateDiffersFromDisk(scanRoot, files) {
671
+ if (!files || typeof files !== 'object') return false;
672
+ for (const [rel, content] of Object.entries(files)) {
673
+ if (typeof content !== 'string') continue;
674
+ try {
675
+ const abs = external_node_path_.resolve(scanRoot, rel);
676
+ if (external_node_fs_.readFileSync(abs, 'utf8') !== content) return true;
677
+ } catch {
678
+ return true; // candidate file absent on disk -> definitely not applied
679
+ }
680
+ }
681
+ return false;
682
+ }
683
+
684
+ async function verifyFix({
685
+ scanRoot,
686
+ originalFindingStableId,
687
+ files,
688
+ depFileContents,
689
+ fixMeta,
690
+ testTimeoutMs,
691
+ } = {}) {
692
+ const rescan = await verifyPatch({ scanRoot, originalFindingStableId, files, depFileContents });
693
+ const lint = runProjectLinter(scanRoot, Object.keys(files || {}));
694
+ const tests = runProjectTests(scanRoot, testTimeoutMs != null ? { timeoutMs: testTimeoutMs } : {});
695
+ // True when a candidate patch was supplied but has not been written, so the
696
+ // suite necessarily ran against the pre-patch tree. Surfaced in the summary
697
+ // and on the result so a caller cannot mistake it for a verified patch.
698
+ const _testedPrePatch = !tests.skipped && _candidateDiffersFromDisk(scanRoot, files);
699
+ const testsOk = tests.skipped ? true : tests.passed === true;
700
+ let honesty = null;
701
+ if (fixMeta && typeof fixMeta === 'object') {
702
+ try { honesty = gateFixOutput(fixMeta); } catch { honesty = null; }
703
+ }
704
+ const ok = rescan.ok && (lint.ok || lint.skipped) && testsOk && (honesty ? honesty.ok : true);
705
+ const summary = [
706
+ `re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
707
+ `linter: ${lint.runner === 'none' ? 'skipped (no linter config)'
708
+ : lint.skipped ? `${lint.runner} not installed`
709
+ : lint.ok ? `${lint.runner} PASS`
710
+ : `${lint.runner} FAIL (exit ${lint.exitCode})`}`,
711
+ // Say which tree the suite actually ran against. `files` is a candidate
712
+ // patch held in memory; the runner needs real files, so it sees whatever is
713
+ // on disk. Reporting a bare "PASS" here would let a caller believe the
714
+ // PATCH passed the tests when the suite may have run on unpatched code.
715
+ `tests: ${tests.skipped ? `skipped (${tests.reason})`
716
+ : tests.timedOut ? 'FAIL (timed out)'
717
+ : tests.passed ? `PASS${_testedPrePatch ? ' — on the CURRENT on-disk tree, NOT the candidate patch' : ''}`
718
+ : `FAIL (exit ${tests.exitCode})`}`,
719
+ honesty ? `honesty: ${honesty.ok ? `PASS (${honesty.tier})` : 'FAIL — ' + honesty.violations.join('; ')}` : null,
720
+ ].filter(Boolean).join('\n');
721
+ return { ok, rescan, lint, tests, testedPrePatch: _testedPrePatch, honesty, summary };
722
+ }
723
+
724
+
725
+ /***/ })
726
+
727
+ };