@clear-capabilities/agentic-security-scanner 0.172.1 → 0.174.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 +26 -0
- package/bin/agentic-security.js +37 -0
- package/dist/119.index.js +197 -0
- package/dist/128.index.js +129 -0
- package/dist/1293.index.js +5689 -0
- package/dist/1351.index.js +133 -0
- package/dist/1387.index.js +271 -0
- package/dist/1437.index.js +23 -0
- package/dist/1445.index.js +110 -0
- package/dist/1505.index.js +151 -0
- package/dist/1557.index.js +293 -0
- package/dist/1621.index.js +311 -0
- package/dist/1751.index.js +160 -0
- package/dist/1765.index.js +727 -0
- package/dist/1861.index.js +690 -0
- package/dist/1898.index.js +116 -0
- package/dist/1906.index.js +433 -0
- package/dist/1920.index.js +1 -291
- package/dist/208.index.js +1631 -0
- package/dist/2199.index.js +0 -239
- package/dist/2223.index.js +159 -0
- package/dist/2530.index.js +152 -0
- package/dist/2565.index.js +116 -0
- package/dist/2574.index.js +127 -0
- package/dist/2620.index.js +345 -0
- package/dist/2773.index.js +98 -0
- package/dist/2775.index.js +32 -0
- package/dist/2966.index.js +171 -0
- package/dist/2972.index.js +87 -0
- package/dist/2991.index.js +131 -0
- package/dist/3063.index.js +833 -0
- package/dist/3075.index.js +227 -0
- package/dist/3121.index.js +297 -0
- package/dist/3314.index.js +263 -0
- package/dist/3480.index.js +1822 -0
- package/dist/3481.index.js +24 -0
- package/dist/3506.index.js +268 -0
- package/dist/3533.index.js +591 -0
- package/dist/3653.index.js +450 -0
- package/dist/3822.index.js +131 -0
- package/dist/3868.index.js +292 -0
- package/dist/3917.index.js +152 -0
- package/dist/3918.index.js +192 -0
- package/dist/4090.index.js +288 -0
- package/dist/4121.index.js +1190 -0
- package/dist/4142.index.js +0 -239
- package/dist/4336.index.js +436 -0
- package/dist/4387.index.js +532 -0
- package/dist/4413.index.js +302 -0
- package/dist/4476.index.js +4510 -0
- package/dist/4491.index.js +133 -0
- package/dist/4538.index.js +185 -0
- package/dist/4684.index.js +319 -0
- package/dist/4740.index.js +165 -0
- package/dist/4780.index.js +209 -0
- package/dist/4802.index.js +225 -0
- package/dist/4812.index.js +379 -0
- package/dist/4835.index.js +126 -0
- package/dist/494.index.js +226 -0
- package/dist/4991.index.js +549 -0
- package/dist/5199.index.js +305 -0
- package/dist/5216.index.js +109 -0
- package/dist/5439.index.js +301 -0
- package/dist/5443.index.js +97 -0
- package/dist/5496.index.js +377 -0
- package/dist/550.index.js +2 -1
- package/dist/5801.index.js +588 -0
- package/dist/5858.index.js +223 -0
- package/dist/5933.index.js +143 -0
- package/dist/601.index.js +23 -0
- package/dist/6173.index.js +273 -0
- package/dist/6291.index.js +1871 -0
- package/dist/6312.index.js +666 -0
- package/dist/634.index.js +16182 -0
- package/dist/644.index.js +87 -0
- package/dist/6508.index.js +283 -0
- package/dist/6560.index.js +645 -0
- package/dist/6576.index.js +422 -0
- package/dist/6579.index.js +271 -0
- package/dist/6745.index.js +305 -0
- package/dist/6818.index.js +21642 -0
- package/dist/6972.index.js +167 -0
- package/dist/6995.index.js +163 -0
- package/dist/7249.index.js +191 -0
- package/dist/7372.index.js +1197 -0
- package/dist/7546.index.js +541 -0
- package/dist/76.index.js +448 -0
- package/dist/7879.index.js +163 -0
- package/dist/8025.index.js +130 -0
- package/dist/8054.index.js +248 -0
- package/dist/8097.index.js +201 -0
- package/dist/8126.index.js +191 -0
- package/dist/8178.index.js +78 -0
- package/dist/818.index.js +212 -0
- package/dist/8382.index.js +93 -0
- package/dist/8408.index.js +261 -0
- package/dist/8540.index.js +174 -0
- package/dist/8635.index.js +802 -0
- package/dist/8688.index.js +410 -0
- package/dist/8699.index.js +779 -0
- package/dist/8891.index.js +457 -0
- package/dist/8916.index.js +885 -0
- package/dist/9146.index.js +170 -0
- package/dist/9168.index.js +841 -0
- package/dist/9292.index.js +369 -0
- package/dist/9382.index.js +135 -11
- package/dist/9422.index.js +866 -0
- package/dist/9440.index.js +453 -0
- package/dist/9479.index.js +152 -0
- package/dist/9481.index.js +220 -0
- package/dist/9539.index.js +100 -0
- package/dist/9547.index.js +103 -0
- package/dist/9560.index.js +41 -61
- package/dist/9692.index.js +90 -0
- package/dist/9800.index.js +213 -1
- package/dist/9899.index.js +138 -0
- package/dist/9981.index.js +206 -0
- package/dist/999.index.js +117 -0
- package/dist/agentic-security.mjs +27 -27
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +31 -6
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +2 -0
- package/src/context-intelligence/config.js +3 -1
- package/src/context-intelligence/controls.js +49 -1
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/findings.js +166 -0
- package/src/context-intelligence/invariants.js +197 -0
- package/src/context-intelligence/queue.js +6 -1
- package/src/context-intelligence/reachability.js +2 -2
- package/src/context-intelligence/review.js +68 -4
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +282 -24
- package/src/dataflow/service-edges.js +174 -0
- package/src/dataflow/summaries.js +7 -1
- package/src/discovery/judge.js +2 -1
- package/src/engine.js +82 -14
- package/src/fix/apply-fix-service.js +55 -8
- package/src/fix/batch-journal.js +84 -0
- package/src/fix/exploit-replay.js +51 -0
- package/src/fix/footprint.js +65 -0
- package/src/fix/patch-policy.js +49 -0
- package/src/fix/secret-persistence.js +39 -0
- package/src/fix/test-commands.js +84 -0
- package/src/fix/verification-adapters.js +97 -0
- package/src/fix/verification-record.js +243 -0
- package/src/fix/verify-patch.js +89 -0
- package/src/ir/CLAUDE.md +5 -0
- package/src/ir/callgraph.js +23 -1
- package/src/ir/module-resolver.js +315 -0
- package/src/ir/parser-js.js +21 -2
- package/src/ir/semantic-graph.js +245 -0
- package/src/language/haskell-findings.js +2 -0
- package/src/language/haskell-guards.js +1 -1
- package/src/language/haskell-ir.js +3 -1
- package/src/language/haskell-models.js +94 -0
- package/src/language/haskell-security-rules.js +20 -0
- package/src/language/nixos-hardening.js +72 -0
- package/src/lineage/build-attested-correlation.js +63 -0
- package/src/lineage/deployment-identity.js +76 -0
- package/src/mcp/tools.js +13 -34
- package/src/pipeline/canonical-output.js +64 -0
- package/src/pipeline/detector-runner.js +7 -0
- package/src/pipeline/scan-health.js +14 -0
- package/src/pipeline/supervised-analyzers.js +125 -0
- package/src/pipeline/supervised-child.js +17 -0
- package/src/pipeline/telemetry.js +79 -0
- package/src/posture/accuracy-scorecard.js +98 -9
- package/src/posture/artifact-registry.js +5 -0
- package/src/posture/assurance/evidence-registry.js +215 -0
- package/src/posture/assurance/release-package-verify.js +135 -0
- package/src/posture/assurance/release-package.js +118 -0
- package/src/posture/assurance/release-verdict.js +94 -0
- package/src/posture/assurance/waiver.js +200 -0
- package/src/posture/compliance-truth.js +100 -0
- package/src/posture/evaluation/scan-child.js +3 -1
- package/src/posture/evaluation/why-missed.js +3 -1
- package/src/posture/exploitability-evidence.js +141 -0
- package/src/posture/fix-verify.js +4 -2
- package/src/posture/fleet/exposure-ranking.js +115 -0
- package/src/posture/fleet/service-chain-graph.js +150 -0
- package/src/posture/graph-reconciliation.js +154 -0
- package/src/posture/provenance/coverage.js +103 -0
- package/src/posture/provenance/git-evidence.js +84 -0
- package/src/posture/provenance/origin-resolver.js +7 -1
- package/src/posture/provenance/predicate-replay.js +14 -4
- package/src/posture/runtime-correlation.js +61 -15
- package/src/posture/runtime-explanation.js +116 -0
- package/src/posture/runtime-trace-ingest.js +194 -0
- package/src/posture/scan-checkpoint.js +38 -10
- package/src/posture/waf-ingest.js +7 -2
- package/src/report/index.js +4 -0
- package/src/report/oscal.js +38 -0
- package/src/report/perspectives.js +150 -0
- package/src/sast/_comment-strip.js +8 -1
- package/src/sast/ruby.js +26 -0
- package/src/sast/zip-slip.js +79 -0
- package/src/util/string-memo.js +38 -0
|
@@ -39,6 +39,10 @@ import * as fsp from 'node:fs/promises';
|
|
|
39
39
|
import * as path from 'node:path';
|
|
40
40
|
import * as crypto from 'node:crypto';
|
|
41
41
|
import { verifyLastScan } from '../posture/integrity.js';
|
|
42
|
+
import { verifyPatch, additiveFields } from './verify-patch.js';
|
|
43
|
+
import { decidePatchApplication } from './patch-policy.js';
|
|
44
|
+
import { beginBatch, commitBatch, abandonBatch, recoverBatches, faultPoint } from './batch-journal.js';
|
|
45
|
+
import { spawnSync } from 'node:child_process';
|
|
42
46
|
import { stateDir } from '../posture/state-dir.js';
|
|
43
47
|
import { applyFix as applyFixHistory, revertEntryById as revertFixEntry } from '../posture/fix-history.js';
|
|
44
48
|
import { classifyFixMaterialRisk } from '../posture/material-change.js';
|
|
@@ -209,7 +213,7 @@ async function getVerifyFixCore() {
|
|
|
209
213
|
* configured, tests when a runner is detected — genuinely ran and passed, not silently skipped.
|
|
210
214
|
* `verified:true, verifiedFull:false` is a real, honest state: applied, but on a degraded pass.
|
|
211
215
|
*/
|
|
212
|
-
export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = null, dryRun = false, skipVerification = false, patchNegative = null, assuranceConfig } = {}) {
|
|
216
|
+
export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = null, dryRun = false, skipVerification = false, patchNegative = null, assuranceConfig, applyMode = 'requested', policy } = {}) {
|
|
213
217
|
if (!scanRoot) return { ok: false, applied: false, reason: 'scanRoot required' };
|
|
214
218
|
if (!finding) return { ok: false, applied: false, reason: 'finding required' };
|
|
215
219
|
if (!files || typeof files !== 'object' || !Object.keys(files).length) {
|
|
@@ -231,6 +235,9 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
|
|
|
231
235
|
return { ok: false, applied: false, reason: `last-scan.json integrity check failed: ${integrityStatus}` };
|
|
232
236
|
}
|
|
233
237
|
|
|
238
|
+
// A previous run that died part-way through a multi-file write is settled first, so everything below sees the tree it was in before that run.
|
|
239
|
+
const settledBatches = dryRun ? [] : await recoverBatches(scanRoot);
|
|
240
|
+
|
|
234
241
|
const confined = {};
|
|
235
242
|
for (const [rel, content] of Object.entries(files)) {
|
|
236
243
|
let abs;
|
|
@@ -253,6 +260,12 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
|
|
|
253
260
|
} catch (e) {
|
|
254
261
|
return { ok: false, applied: false, reason: `could not read current content of ${rel} to detect concurrent changes: ${e.message}` };
|
|
255
262
|
}
|
|
263
|
+
// Binary content cannot be patched as text: reading it as UTF-8 and writing it back would corrupt it.
|
|
264
|
+
if (String(content).includes('\0')) return { ok: false, applied: false, reason: `binary content refused: the replacement for ${rel} contains NUL bytes` };
|
|
265
|
+
if (fs.existsSync(abs)) {
|
|
266
|
+
const raw = fs.readFileSync(abs);
|
|
267
|
+
if (raw.includes(0) || Buffer.from(raw.toString('utf8'), 'utf8').compare(raw) !== 0) return { ok: false, applied: false, reason: `binary file refused: ${rel} is not valid UTF-8 text, so a text patch would corrupt it` };
|
|
268
|
+
}
|
|
256
269
|
confined[rel] = { abs, content: String(content), baselineHash };
|
|
257
270
|
}
|
|
258
271
|
|
|
@@ -323,6 +336,7 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
|
|
|
323
336
|
}
|
|
324
337
|
|
|
325
338
|
let verify = null;
|
|
339
|
+
let fixVerification = null;
|
|
326
340
|
let pn = null;
|
|
327
341
|
let repairRecords = [];
|
|
328
342
|
const pnView = (r) => ({ status: r.status, verifiedFix: r.verifiedFix, incompleteStep: r.incompleteStep, failureCode: r.failureCode, reason: r.reason, steps: r.steps, patchDigest: r.patchDigest, summary: r.summary, verificationRecord: r.verificationRecord });
|
|
@@ -332,14 +346,14 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
|
|
|
332
346
|
}
|
|
333
347
|
let verdict;
|
|
334
348
|
try {
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
scanRoot,
|
|
338
|
-
originalFindingStableId: finding.stableId,
|
|
349
|
+
const central = await verifyPatch({
|
|
350
|
+
scanRoot, finding, route: 'apply-fix-service', mode: dryRun ? 'preview' : 'apply',
|
|
339
351
|
files: Object.fromEntries(Object.entries(confined).map(([rel, v]) => [rel, v.content])),
|
|
340
|
-
fixMeta,
|
|
341
|
-
...(patchNegative ? { patchNegative, assuranceConfig } : {}),
|
|
352
|
+
fixMeta, verify: await getVerifyFixCore(),
|
|
353
|
+
...(patchNegative ? { extra: { patchNegative, assuranceConfig } } : {}),
|
|
342
354
|
});
|
|
355
|
+
verdict = central.verdict;
|
|
356
|
+
fixVerification = central.fixVerification;
|
|
343
357
|
} catch (e) {
|
|
344
358
|
return { ok: false, applied: false, reason: `verification failed: ${e.message}` };
|
|
345
359
|
}
|
|
@@ -357,12 +371,25 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
|
|
|
357
371
|
reason: `rejected by verifier: ${verdict.summary || verdict.rescan?.reason || 'did not verify'}`,
|
|
358
372
|
verify: { rescan: verdict.rescan, lint: verdict.lint ? { runner: verdict.lint.runner, ok: verdict.lint.ok } : null, honesty: verdict.honesty || null },
|
|
359
373
|
...(pn ? { patchNegative: pnView(pn), repairRecords } : {}),
|
|
374
|
+
...(fixVerification ? additiveFields(fixVerification) : {}),
|
|
360
375
|
};
|
|
361
376
|
}
|
|
362
377
|
}
|
|
363
378
|
|
|
379
|
+
// E3-06: may this be written? `requested` (a person asked) proceeds on any verified tier and carries the warnings; `automatic` needs the
|
|
380
|
+
// policy to enable it and every required check to have passed, otherwise the patch is only proposed.
|
|
381
|
+
const uncommitted = uncommittedFiles(scanRoot, Object.keys(confined));
|
|
382
|
+
let policyDecision = fixVerification ? decidePatchApplication({ record: fixVerification, finding, materialClassification, mode: applyMode, policy }) : null;
|
|
383
|
+
if (policyDecision && uncommitted.length) {
|
|
384
|
+
policyDecision = { ...policyDecision, warnings: [...policyDecision.warnings, `uncommitted changes in ${uncommitted.join(', ')}`] };
|
|
385
|
+
if (applyMode === 'automatic' && policyDecision.decision === 'apply') policyDecision = { ...policyDecision, decision: 'propose', reasons: [`uncommitted changes in ${uncommitted.join(', ')}: an automatic write would mix with unsaved work`] };
|
|
386
|
+
}
|
|
387
|
+
if (policyDecision && policyDecision.decision !== 'apply' && !dryRun) {
|
|
388
|
+
return { ok: true, applied: false, proposed: policyDecision.decision === 'propose', reason: `not applied: ${policyDecision.reasons[0]}`, files: Object.keys(confined), verify, materialClassification, policyDecision, ...additiveFields(fixVerification) };
|
|
389
|
+
}
|
|
390
|
+
|
|
364
391
|
if (dryRun) {
|
|
365
|
-
return { ok: true, applied: false, dryRun: true, verified: !skipVerification, files: Object.keys(confined), verify, materialClassification, ...(pn ? { patchNegative: pnView(pn), repairRecords } : {}) };
|
|
392
|
+
return { ok: true, applied: false, dryRun: true, ...(policyDecision ? { policyDecision } : {}), verified: !skipVerification, files: Object.keys(confined), verify, materialClassification, ...(pn ? { patchNegative: pnView(pn), repairRecords } : {}), ...(fixVerification ? additiveFields(fixVerification) : {}) };
|
|
366
393
|
}
|
|
367
394
|
|
|
368
395
|
// FR-304: re-check every file against its pre-verification baseline hash,
|
|
@@ -375,8 +402,11 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
|
|
|
375
402
|
}
|
|
376
403
|
|
|
377
404
|
const written = [];
|
|
405
|
+
const batch = await beginBatch(scanRoot, { findingId: finding.id || finding.findingId, files: Object.entries(confined).map(([rel, v]) => ({ file: rel, content: v.content, existed: fs.existsSync(v.abs) })) });
|
|
378
406
|
try {
|
|
407
|
+
let n = 0;
|
|
379
408
|
for (const [rel, v] of Object.entries(confined)) {
|
|
409
|
+
faultPoint(`before-file-${n}`);
|
|
380
410
|
const fileExisted = fs.existsSync(v.abs);
|
|
381
411
|
const originalContent = fileExisted ? await fsp.readFile(v.abs, 'utf8') : '';
|
|
382
412
|
const entry = await applyFixHistory({
|
|
@@ -388,7 +418,10 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
|
|
|
388
418
|
findingProvenance: finding.findingProvenance || null,
|
|
389
419
|
});
|
|
390
420
|
written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath, attemptOrdinal: entry.attemptOrdinal });
|
|
421
|
+
faultPoint(`after-file-${n}`);
|
|
422
|
+
n++;
|
|
391
423
|
}
|
|
424
|
+
await commitBatch(scanRoot, batch);
|
|
392
425
|
} catch (e) {
|
|
393
426
|
// FR-306: "injected write failure restores all files" — applyFixHistory
|
|
394
427
|
// already rolled back the ONE file that just failed; this rolls back
|
|
@@ -400,6 +433,7 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
|
|
|
400
433
|
for (const w of written) {
|
|
401
434
|
try { await revertFixEntry(scanRoot, w.historyId); } catch { /* best-effort; original error still propagates below */ }
|
|
402
435
|
}
|
|
436
|
+
await abandonBatch(scanRoot, batch).catch(() => {});
|
|
403
437
|
// X-204: the rollback is its own record, after the promotion it undoes (a write that failed part-way)
|
|
404
438
|
if (pn) {
|
|
405
439
|
const rolled = appendRepairRecord(repairRecords, { kind: 'rolled-back', hypothesisId: finding.stableId, revision: pn.promotion?.revision ?? null, diffDigest: pn.patchDigest, reason: `the write failed and every file written in this batch was restored: ${String(e?.message || e).slice(0, 160)}` });
|
|
@@ -429,6 +463,19 @@ export async function applyVerifiedFix({ scanRoot, finding, files, fixMeta = nul
|
|
|
429
463
|
written,
|
|
430
464
|
verify: verify ? { summary: verify.summary, verifiedFull: verify.verifiedFull, degradedLegs: verify.degradedLegs || [] } : null,
|
|
431
465
|
materialClassification,
|
|
466
|
+
...(policyDecision ? { policyDecision } : {}),
|
|
467
|
+
...(settledBatches.length ? { recoveredBatches: settledBatches } : {}),
|
|
432
468
|
...(pn ? { patchNegative: pnView(pn), repairRecords } : {}),
|
|
469
|
+
// the canonical record is the authority for verifiedFull and degradedLegs: it also knows when the tests ran before the patch existed
|
|
470
|
+
...(fixVerification ? additiveFields(fixVerification) : {}),
|
|
433
471
|
};
|
|
434
472
|
}
|
|
473
|
+
|
|
474
|
+
/** Files among `rels` with uncommitted changes in a git work tree. [] when not a git repo or git is unavailable (unknown is not clean, but is not blocking). */
|
|
475
|
+
function uncommittedFiles(scanRoot, rels) {
|
|
476
|
+
try {
|
|
477
|
+
const r = spawnSync('git', ['status', '--porcelain', '--', ...rels], { cwd: scanRoot, encoding: 'utf8', timeout: 8000 });
|
|
478
|
+
if (r.status !== 0 || !r.stdout) return [];
|
|
479
|
+
return r.stdout.split('\n').filter(Boolean).map((l) => l.slice(3).trim()).filter((f) => rels.includes(f));
|
|
480
|
+
} catch { return []; }
|
|
481
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// All-or-nothing multi-file application that survives a crash (E3-07).
|
|
2
|
+
//
|
|
3
|
+
// fix-history.js already makes ONE file's write crash-safe: backup, pending log entry, atomic rename, promote. A patch that touches
|
|
4
|
+
// several files was only safe against an exception: if the process died after file A was promoted and before file B was written, A stayed
|
|
5
|
+
// patched and B did not, with nothing that said so. This journal closes that gap. Before the first write the batch is recorded as
|
|
6
|
+
// `writing`; after the last file is promoted it is `committed`. On the next run, `recoverBatches` finds any batch still `writing`, settles
|
|
7
|
+
// the per-file history first, then reverts every file of the batch that reached its patched content, so the tree returns to what it was.
|
|
8
|
+
//
|
|
9
|
+
// A fault seam (`faultPoint`) lets a test kill the process at a named point. It does nothing unless BOTH environment variables are set,
|
|
10
|
+
// so it cannot fire in normal use.
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as fsp from 'node:fs/promises';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import * as crypto from 'node:crypto';
|
|
15
|
+
import { statePath } from '../posture/state-dir.js';
|
|
16
|
+
import { readLog, recover, revertEntryById } from '../posture/fix-history.js';
|
|
17
|
+
|
|
18
|
+
const dirOf = (root) => statePath(root, 'fix-batches');
|
|
19
|
+
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
20
|
+
|
|
21
|
+
export function faultPoint(name) {
|
|
22
|
+
if (process.env.AGENTIC_SECURITY_TEST_FAULTS !== '1') return;
|
|
23
|
+
if (process.env.AGENTIC_SECURITY_FAULT_AT === name) process.kill(process.pid, 'SIGKILL');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function writeJson(file, value) {
|
|
27
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
28
|
+
const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(3).toString('hex')}`;
|
|
29
|
+
const h = await fsp.open(tmp, 'w');
|
|
30
|
+
try { await h.writeFile(JSON.stringify(value, null, 2)); await h.sync(); } finally { await h.close(); }
|
|
31
|
+
await fsp.rename(tmp, file);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function beginBatch(scanRoot, { files, findingId }) {
|
|
35
|
+
const id = `batch-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
|
|
36
|
+
const batch = { id, state: 'writing', findingId: findingId ?? null, startedAt: new Date().toISOString(), files: files.map((f) => ({ file: f.file, newSha: sha(f.content), existed: f.existed })) };
|
|
37
|
+
await writeJson(path.join(dirOf(scanRoot), `${id}.json`), batch);
|
|
38
|
+
return batch;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function commitBatch(scanRoot, batch) {
|
|
42
|
+
await writeJson(path.join(dirOf(scanRoot), `${batch.id}.json`), { ...batch, state: 'committed', committedAt: new Date().toISOString() });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function abandonBatch(scanRoot, batch, state = 'rolled-back') {
|
|
46
|
+
await writeJson(path.join(dirOf(scanRoot), `${batch.id}.json`), { ...batch, state, settledAt: new Date().toISOString() });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const STRAY_TMP = /^\..+\.tmp-\d+-[0-9a-f]{8}$/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Settle every batch that was still `writing` when a previous run stopped. Safe to call at any time; a no-op when nothing is pending.
|
|
53
|
+
* @returns {Promise<{batch:string, reverted:string[], untouched:string[], failed:string[]}[]>}
|
|
54
|
+
*/
|
|
55
|
+
export async function recoverBatches(scanRoot) {
|
|
56
|
+
const dir = dirOf(scanRoot);
|
|
57
|
+
let names = [];
|
|
58
|
+
try { names = (await fsp.readdir(dir)).filter((n) => n.endsWith('.json')); } catch { return []; }
|
|
59
|
+
const out = [];
|
|
60
|
+
let settled = false;
|
|
61
|
+
for (const n of names.sort()) {
|
|
62
|
+
let batch; try { batch = JSON.parse(await fsp.readFile(path.join(dir, n), 'utf8')); } catch { continue; }
|
|
63
|
+
if (batch.state !== 'writing') continue;
|
|
64
|
+
if (!settled) { await recover(scanRoot); settled = true; }
|
|
65
|
+
const log = readLog(scanRoot);
|
|
66
|
+
const reverted = []; const untouched = []; const failed = [];
|
|
67
|
+
for (const f of batch.files) {
|
|
68
|
+
const hits = log.filter((e) => e.file === f.file && !e.reverted && e.newSha === f.newSha.slice(0, 16) && e.appliedAt >= batch.startedAt && (e.status === 'applied' || e.status === 'applied-stale'));
|
|
69
|
+
if (!hits.length) { untouched.push(f.file); continue; }
|
|
70
|
+
for (const e of hits.reverse()) {
|
|
71
|
+
const r = await revertEntryById(scanRoot, e.id);
|
|
72
|
+
if (r && !r.error) reverted.push(f.file); else failed.push(f.file);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// an interrupted atomic write leaves a hidden temp file beside its target
|
|
76
|
+
for (const f of batch.files) {
|
|
77
|
+
const d = path.dirname(path.join(path.resolve(scanRoot), f.file));
|
|
78
|
+
try { for (const t of await fsp.readdir(d)) if (STRAY_TMP.test(t)) await fsp.unlink(path.join(d, t)).catch(() => {}); } catch { /* directory gone */ }
|
|
79
|
+
}
|
|
80
|
+
await abandonBatch(scanRoot, batch, failed.length ? 'rollback-incomplete' : 'rolled-back');
|
|
81
|
+
out.push({ batch: batch.id, reverted: [...new Set(reverted)], untouched, failed });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Controlled pre/post exploit replay for a candidate patch (E3-04).
|
|
2
|
+
//
|
|
3
|
+
// Runs the SAME local reproducer against the pristine code and then against the code with the patch overlaid, using the proof fabric's
|
|
4
|
+
// confined runner (confined filesystem, scrubbed environment, bounded time and output, no external network, repository sources read-only
|
|
5
|
+
// and confined to the scan root). It targets only files under `scanRoot`; there is no URL input.
|
|
6
|
+
//
|
|
7
|
+
// What each outcome means:
|
|
8
|
+
// before every attempt completed and the oracle fired in all of them -> the exploit is real on the original code
|
|
9
|
+
// after every attempt completed and the oracle fired in none -> the patch blocks it
|
|
10
|
+
// anything that did not complete (timeout, build failure, no sandbox, invalid reproducer) is `not_run`/inconclusive. It is NEVER read
|
|
11
|
+
// as "the exploit failed", so an unavailable replay cannot masquerade as a fix.
|
|
12
|
+
//
|
|
13
|
+
// `executionProven` is true only when the precondition held (before) AND the postcondition failed on completed runs (after). It says the
|
|
14
|
+
// exploit stopped; whether the intended behaviour survived is a different question (functional preservation, E3-05).
|
|
15
|
+
import { runAttempt as defaultAttempt } from '../posture/proof-fabric/runner.js';
|
|
16
|
+
import { validateReproducer, reproducerDigest } from '../posture/proof-fabric/reproducer.js';
|
|
17
|
+
|
|
18
|
+
const clampAttempts = (n) => Math.max(1, Math.min(5, Number.isInteger(n) ? n : 3));
|
|
19
|
+
|
|
20
|
+
async function series(reproducer, n, opts, attemptFn) {
|
|
21
|
+
const rows = [];
|
|
22
|
+
for (let i = 0; i < n; i++) {
|
|
23
|
+
if (opts.signal?.aborted) break;
|
|
24
|
+
const r = await attemptFn(structuredClone(reproducer), opts);
|
|
25
|
+
rows.push({ status: r.status, fired: r.oracle?.fired === true, inconclusive: r.oracle?.inconclusive === true || r.status !== 'completed', reasons: r.reasons || [], degraded: r.degraded || [] });
|
|
26
|
+
}
|
|
27
|
+
return rows;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const summarize = (rows) => ({ attempts: rows.length, completed: rows.filter((r) => !r.inconclusive).length, fired: rows.filter((r) => r.fired && !r.inconclusive).length, statuses: rows.map((r) => r.status), reasons: [...new Set(rows.flatMap((r) => r.reasons))].slice(0, 5) });
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{scanRoot:string, reproducer:object, files:Record<string,string>, attempts?:number, timeoutMs?:number, signal?:AbortSignal, attemptFn?:Function}} o
|
|
34
|
+
*/
|
|
35
|
+
export async function replayExploit({ scanRoot, reproducer, files, attempts, timeoutMs, signal, attemptFn = defaultAttempt }) {
|
|
36
|
+
const base = { schema: 'agentic-security/exploit-replay', version: 1, reproducerDigest: null };
|
|
37
|
+
const errs = validateReproducer(reproducer);
|
|
38
|
+
if (errs.length) return { ...base, before: null, after: null, beforeStatus: 'not_run', afterStatus: 'not_run', executionProven: false, reason: `the reproducer is invalid: ${errs[0]}` };
|
|
39
|
+
base.reproducerDigest = reproducerDigest(reproducer);
|
|
40
|
+
const n = clampAttempts(attempts);
|
|
41
|
+
const common = { sourceRoot: scanRoot, requireContainer: false, signal, limits: timeoutMs ? { timeoutMs } : undefined };
|
|
42
|
+
|
|
43
|
+
const before = summarize(await series(reproducer, n, common, attemptFn));
|
|
44
|
+
if (before.completed < before.attempts || before.attempts === 0) return { ...base, before, after: null, beforeStatus: 'not_run', afterStatus: 'not_run', executionProven: false, reason: `the original-code replay did not complete (${before.reasons[0] || before.statuses.join(',')}), so nothing can be said about the patch` };
|
|
45
|
+
if (before.fired < before.attempts) return { ...base, before, after: null, beforeStatus: 'not_run', afterStatus: 'not_run', executionProven: false, reason: `the exploit did not succeed on the original code in all ${before.attempts} attempts (${before.fired}), so the precondition is not established` };
|
|
46
|
+
|
|
47
|
+
const after = summarize(await series(reproducer, n, { ...common, overlay: files }, attemptFn));
|
|
48
|
+
if (after.fired > 0) return { ...base, before, after, beforeStatus: 'passed', afterStatus: 'failed', executionProven: false, reason: `the exploit still succeeds against the patched code (${after.fired} of ${after.attempts})` };
|
|
49
|
+
if (after.completed < after.attempts || after.attempts === 0) return { ...base, before, after, beforeStatus: 'passed', afterStatus: 'not_run', executionProven: false, reason: `the patched replay did not complete (${after.reasons[0] || after.statuses.join(',')}); an incomplete replay is not a blocked exploit` };
|
|
50
|
+
return { ...base, before, after, beforeStatus: 'passed', afterStatus: 'passed', executionProven: true, mechanism: 'the oracle did not fire in any completed replay of the patched code', reason: null };
|
|
51
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Functional footprint of a patch (E3-05): did the patch fix the weakness, or remove the feature?
|
|
2
|
+
//
|
|
3
|
+
// A rescan only proves the finding stopped firing. A patch that deletes the vulnerable behaviour (a blanket 403 on an IDOR route, a 501 on
|
|
4
|
+
// the SQL endpoint, an emptied file) satisfies that just as well, so it is checked separately: the kinds of sensitive operation the
|
|
5
|
+
// changed files performed BEFORE the patch must still be performed AFTER it. A parameterised query is still a database read; an
|
|
6
|
+
// execFile call is still a process execution; a deny-all handler is neither.
|
|
7
|
+
//
|
|
8
|
+
// This is a HEURISTIC over source text, not a proof of equivalence. It catches the removal of a whole operation kind, not a subtler
|
|
9
|
+
// behaviour change. When it cannot see any operation kind in the original it says `unobservable` and the leg is not_run, never passed.
|
|
10
|
+
const KINDS = {
|
|
11
|
+
'process-exec': /require\s*\(\s*['"](?:node:)?child_process['"]\s*\)|from\s+['"](?:node:)?child_process['"]|\b(?:execSync|execFileSync|execFile|spawnSync|spawn|exec|system|popen|Runtime\s*\.\s*getRuntime\s*\(\s*\)\s*\.\s*exec|ProcessBuilder|subprocess\s*\.\s*\w+|os\s*\.\s*system|shell_exec|passthru|proc_open)\s*\(/,
|
|
12
|
+
'data-access': /\b(?:SELECT|INSERT\s+INTO|UPDATE|DELETE\s+FROM)\b|\.(?:find|findOne|findById|findAll|findUnique|findFirst|query|execute|executeQuery|executemany|prepare|raw|where|get_object_or_404|createQuery|queryForObject)\s*\(|\b(?:db|database|conn|connection|pool|cursor|collection|repository|repo|prisma|knex|sequelize|session)\w*\s*\.\s*\w+\s*\(/i,
|
|
13
|
+
'file-access': /\b(?:readFile|readFileSync|createReadStream|writeFile|writeFileSync|createWriteStream|sendFile|readdir|readdirSync|unlink|open|os\.Open|ioutil\.ReadFile|file_get_contents|File\s*\(|FileInputStream|FileReader|Files\s*\.\s*\w+)\s*\(/,
|
|
14
|
+
'network-out': /\b(?:fetch|axios|got|request|urlopen|requests\s*\.\s*\w+|http\s*\.\s*(?:get|request)|https\s*\.\s*(?:get|request)|HttpClient|http\.Get|curl_exec)\s*\(/,
|
|
15
|
+
'html-output': /\b(?:res|response|reply|ctx)\b[^;\n]*\.\s*(?:send|write|render|json|end)\s*\(|\b(?:render_template|HttpResponse|JsonResponse|jsonify|innerHTML|echo|ResponseEntity|c\.JSON|c\.String)\b/,
|
|
16
|
+
};
|
|
17
|
+
const DENIAL = /\b(?:status\s*\(\s*(?:401|403|404|405|410|501|503)\s*\)|(?:abort|HTTPException|http_error|ResponseStatusException)\s*\(\s*(?:401|403|404|501)|(?:PermissionDenied|Forbidden|NotImplemented|AccessDenied|Unauthorized)\b|(?:forbidden|not implemented|access denied|disabled)\b|return\s+(?:403|401|501)\b|StatusForbidden|StatusNotImplemented)/i;
|
|
18
|
+
// Which kinds a CWE's fix must keep. Unlisted weaknesses are held to every kind the original had.
|
|
19
|
+
const CWE_KINDS = {
|
|
20
|
+
'CWE-78': ['process-exec'], 'CWE-77': ['process-exec'], 'CWE-89': ['data-access'], 'CWE-564': ['data-access'], 'CWE-22': ['file-access'], 'CWE-73': ['file-access'],
|
|
21
|
+
'CWE-918': ['network-out'], 'CWE-79': ['html-output'], 'CWE-639': ['data-access'], 'CWE-284': ['data-access'], 'CWE-285': ['data-access'], 'CWE-862': ['data-access'], 'CWE-863': ['data-access'], 'CWE-566': ['data-access'],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const stripComments = (t) => String(t || '').replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:\\])\/\/[^\n]*/g, '$1').replace(/(^|\s)#[^\n!][^\n]*/g, '$1');
|
|
25
|
+
const codeLines = (t) => stripComments(t).split('\n').filter((l) => l.trim() && !/^\s*[{}();,]*\s*$/.test(l)).length;
|
|
26
|
+
|
|
27
|
+
export function kindsOf(text) {
|
|
28
|
+
const src = stripComments(text);
|
|
29
|
+
return Object.entries(KINDS).filter(([, re]) => re.test(src)).map(([k]) => k).sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{before:Record<string,string|null>, after:Record<string,string>, cwe?:string|null, intentionalRemoval?:{reason:string}|null}} o
|
|
34
|
+
* @returns {{status:'passed'|'failed'|'not_run', reason:string|null, files:object[], lost:string[]}}
|
|
35
|
+
*/
|
|
36
|
+
export function checkFootprint({ before, after, cwe = null, intentionalRemoval = null }) {
|
|
37
|
+
const required = CWE_KINDS[String(cwe || '').toUpperCase()] || null;
|
|
38
|
+
const files = [];
|
|
39
|
+
const lostAll = new Set();
|
|
40
|
+
let observed = false;
|
|
41
|
+
for (const [rel, afterText] of Object.entries(after)) {
|
|
42
|
+
const b = before[rel];
|
|
43
|
+
if (b === null || b === undefined) { files.push({ file: rel, note: 'new file' }); continue; }
|
|
44
|
+
const kb = kindsOf(b); const ka = kindsOf(afterText);
|
|
45
|
+
const held = required ? kb.filter((k) => required.includes(k)) : kb;
|
|
46
|
+
if (held.length) observed = true;
|
|
47
|
+
let lost = held.filter((k) => !ka.includes(k));
|
|
48
|
+
const emptied = codeLines(afterText) === 0 && codeLines(b) > 0;
|
|
49
|
+
const denialOnly = lost.length > 0 && DENIAL.test(stripComments(afterText)) && !DENIAL.test(stripComments(b));
|
|
50
|
+
// With no known weakness class, losing an operation kind is not by itself a defect (an auth fix may legitimately drop a response path).
|
|
51
|
+
// What marks feature REMOVAL is the signature of it: the file emptied, or the lost operation replaced by a deny response.
|
|
52
|
+
if (!required && !emptied && !denialOnly) lost = [];
|
|
53
|
+
const shrunk = codeLines(b) >= 6 && codeLines(afterText) <= Math.floor(codeLines(b) * 0.25);
|
|
54
|
+
files.push({ file: rel, kindsBefore: kb, kindsAfter: ka, lost, emptied, denialOnly, shrunk });
|
|
55
|
+
if (emptied) { lost.push('everything'); observed = true; }
|
|
56
|
+
for (const k of lost) lostAll.add(k);
|
|
57
|
+
}
|
|
58
|
+
if (!observed && ![...lostAll].length) return { status: 'not_run', reason: 'unobservable: no sensitive operation was recognised in the original files, so removal of the feature cannot be judged', files, lost: [] };
|
|
59
|
+
if (lostAll.size) {
|
|
60
|
+
if (intentionalRemoval && typeof intentionalRemoval.reason === 'string' && intentionalRemoval.reason.trim()) return { status: 'not_run', reason: `the patch removes ${[...lostAll].join(', ')} and the caller declared that intentional (${intentionalRemoval.reason.trim().slice(0, 200)}); a person must confirm it`, files, lost: [...lostAll] };
|
|
61
|
+
const why = files.filter((f) => f.lost?.length).map((f) => `${f.file}: no longer performs ${f.lost.join(', ')}${f.denialOnly ? ' (replaced by a deny response)' : ''}${f.emptied ? ' (file emptied)' : ''}`).join('; ');
|
|
62
|
+
return { status: 'failed', reason: `the patch removes functionality instead of fixing it: ${why}`, files, lost: [...lostAll] };
|
|
63
|
+
}
|
|
64
|
+
return { status: 'passed', reason: null, files, lost: [] };
|
|
65
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Risk-tiered patch policy (E3-06): given a verified-patch record, may it be applied without a person, proposed for a person, or refused?
|
|
2
|
+
//
|
|
3
|
+
// Two modes. `requested` is a person (or an agent acting for one) who asked for the write: it proceeds on any tier that is not
|
|
4
|
+
// unverified or blocked, and the result carries the degraded legs as warnings so nothing about the pass is hidden. `automatic` is
|
|
5
|
+
// anything that would write with nobody asking: it is OFF unless the policy enables it, and even then it applies only a configured
|
|
6
|
+
// low-risk patch whose every REQUIRED check passed. Absent tests, a replay that did not run, an unobservable footprint, or any other
|
|
7
|
+
// degraded leg make an automatic write a proposal, never a silent write. Preview and undo are never removed by this module.
|
|
8
|
+
//
|
|
9
|
+
// Pure: no fs, no clock.
|
|
10
|
+
const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
11
|
+
const AUTH_CATEGORIES = /auth|crypto|permission|access|privilege|session|tenant/i;
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_POLICY = Object.freeze({
|
|
14
|
+
automatic: Object.freeze({ enabled: false, maxSeverity: 'medium', allowedMaterialRisk: Object.freeze(['low']), requireExploitProof: false, maxResidualRisk: 'low' }),
|
|
15
|
+
});
|
|
16
|
+
const RESIDUAL_RANK = { none: 0, low: 1, medium: 2, high: 3, unknown: 4 };
|
|
17
|
+
|
|
18
|
+
export { deriveResidualRiskOf as deriveResidualRisk } from './verification-record.js';
|
|
19
|
+
|
|
20
|
+
const decide = (decision, record, reasons, extra = {}) => ({ decision, tier: record.fixCompleteness, verifiedFull: record.verifiedFull, residualRisk: record.residualRisk, reasons, warnings: record.degradedLegs, preview: true, undo: true, ...extra });
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{record:object, finding?:{severity?:string}, materialClassification?:{highImpactCategories?:string[], risk?:string}, mode?:'requested'|'automatic', policy?:object}} o
|
|
24
|
+
*/
|
|
25
|
+
export function decidePatchApplication({ record, finding = {}, materialClassification = {}, mode = 'requested', policy = DEFAULT_POLICY }) {
|
|
26
|
+
const tier = record.fixCompleteness;
|
|
27
|
+
if (tier === 'blocked') return decide('refuse', record, ['a required check was blocked or a failure was recorded, so the patch cannot be applied']);
|
|
28
|
+
if (tier === 'unverified') return decide('refuse', record, ['the weakness is not shown to be gone, or a check failed']);
|
|
29
|
+
if (mode === 'requested') return decide('apply', record, [`${tier}: applied at the request of a person`]);
|
|
30
|
+
|
|
31
|
+
const a = { ...DEFAULT_POLICY.automatic, ...(policy?.automatic || {}) };
|
|
32
|
+
if (!a.enabled) return decide('propose', record, ['automatic application is not enabled by policy; the patch is proposed for a person to apply']);
|
|
33
|
+
const reasons = [];
|
|
34
|
+
if (tier !== 'FULL') reasons.push(`completeness is ${tier}, automatic application needs FULL`);
|
|
35
|
+
if (!record.verifiedFull) reasons.push('not every required check passed');
|
|
36
|
+
for (const d of record.degradedLegs) reasons.push(`degraded: ${d}`);
|
|
37
|
+
if (a.requireExploitProof && record.exploitProofAfter.status !== 'passed') reasons.push('policy requires an exploit replay and none confirmed the fix');
|
|
38
|
+
for (const leg of ['behaviorTests', 'behaviorPreservation']) if (record[leg].status !== 'passed') reasons.push(`${leg} did not pass (${record[leg].status})`);
|
|
39
|
+
const sev = SEVERITY_RANK[String(finding.severity || '').toLowerCase()];
|
|
40
|
+
if (sev === undefined) reasons.push('the finding severity is unknown');
|
|
41
|
+
else if (sev > SEVERITY_RANK[a.maxSeverity]) reasons.push(`severity ${finding.severity} is above the automatic limit (${a.maxSeverity})`);
|
|
42
|
+
const cats = materialClassification.highImpactCategories || [];
|
|
43
|
+
if (cats.length) reasons.push(`high-impact change (${cats.join(', ')}) needs a person`);
|
|
44
|
+
if (cats.some((c) => AUTH_CATEGORIES.test(c))) reasons.push('the change alters authentication, authorization or cryptography');
|
|
45
|
+
if (materialClassification.risk && !a.allowedMaterialRisk.includes(materialClassification.risk)) reasons.push(`material risk ${materialClassification.risk} is not in the allowed set`);
|
|
46
|
+
if ((RESIDUAL_RANK[record.residualRisk.level] ?? 4) > RESIDUAL_RANK[a.maxResidualRisk]) reasons.push(`residual risk ${record.residualRisk.level} is above ${a.maxResidualRisk}`);
|
|
47
|
+
if (reasons.length) return decide('propose', record, reasons);
|
|
48
|
+
return decide('apply', record, ['FULL, low risk, every required check passed']);
|
|
49
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Did the patch REMOVE a hard-coded secret, or only change how it is written? (E3-08)
|
|
2
|
+
//
|
|
3
|
+
// Moving `const k = 'abc123...'` to `const k = atob('YWJjMTIz...')`, splitting it in two literals, reversing it or hex-encoding it all
|
|
4
|
+
// make a pattern-based scanner stop seeing a secret that is still in the source. This looks for the original literal after the patch in
|
|
5
|
+
// the forms a lazy "fix" uses. It cannot catch encryption with a key kept next to it or other transformations; it says what it checked.
|
|
6
|
+
const MIN_LEN = 8;
|
|
7
|
+
|
|
8
|
+
const literalsOnLines = (text, lines) => {
|
|
9
|
+
const out = new Set();
|
|
10
|
+
const all = String(text).split('\n');
|
|
11
|
+
for (const n of lines) {
|
|
12
|
+
const l = all[n - 1];
|
|
13
|
+
if (!l) continue;
|
|
14
|
+
for (const m of l.matchAll(/(["'`])((?:\\.|(?!\1).){8,})\1/g)) out.add(m[2]);
|
|
15
|
+
}
|
|
16
|
+
return [...out].filter((s) => s.length >= MIN_LEN);
|
|
17
|
+
};
|
|
18
|
+
const squash = (t) => String(t).replace(/["'`+\s]/g, '');
|
|
19
|
+
const forms = (v) => {
|
|
20
|
+
const b64 = Buffer.from(v).toString('base64');
|
|
21
|
+
return [v, [...v].reverse().join(''), Buffer.from(v).toString('hex'), b64, b64.replace(/=+$/, ''), encodeURIComponent(v)].filter((x) => x.length >= MIN_LEN);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{before:string, after:string, line:number|null}} o
|
|
26
|
+
* @returns {{status:'passed'|'failed'|'not_run', reason:string|null, checked:number}}
|
|
27
|
+
*/
|
|
28
|
+
export function checkSecretPersistence({ before, after, line }) {
|
|
29
|
+
if (!Number.isInteger(line) || line < 1) return { status: 'not_run', reason: 'the finding has no line, so the secret literal cannot be located', checked: 0 };
|
|
30
|
+
const lits = literalsOnLines(before, [line - 1, line, line + 1].filter((n) => n >= 1));
|
|
31
|
+
if (!lits.length) return { status: 'not_run', reason: 'no string literal of 8 or more characters near the finding line', checked: 0 };
|
|
32
|
+
const flat = squash(after);
|
|
33
|
+
for (const lit of lits) {
|
|
34
|
+
for (const f of forms(lit)) {
|
|
35
|
+
if (String(after).includes(f) || flat.includes(squash(f))) return { status: 'failed', reason: `the secret is still in the source (${f === lit ? 'unchanged' : 'as a re-encoded or split form'}); moving or encoding it does not remove it`, checked: lits.length };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { status: 'passed', reason: null, checked: lits.length };
|
|
39
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Run the project's tests against the PATCHED tree, in a throwaway copy (E3-05).
|
|
2
|
+
//
|
|
3
|
+
// verifyFix used to run the tests on the tree as it was before the patch was written, which says nothing about the patch. Here the
|
|
4
|
+
// patch is overlaid on a copy and the tests run there; the real tree is never touched.
|
|
5
|
+
//
|
|
6
|
+
// Runner discovery covers what the existing detector does (npm/pnpm/yarn/bun scripts, pytest, go test) plus cargo, dotnet, maven,
|
|
7
|
+
// gradle and rspec, and an explicit argv from the caller (`command`) which always wins. Commands are argv arrays, never a shell string.
|
|
8
|
+
// When the patched run fails the pristine copy is run too, so "the suite was already red" is not mistaken for "the patch broke it".
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as os from 'node:os';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { detectTestCommand } from '../posture/test-runner.js';
|
|
14
|
+
import { STATE_DIR_NAME } from '../posture/state-dir.js';
|
|
15
|
+
|
|
16
|
+
const MAX_COPY_BYTES = 200 * 1024 * 1024;
|
|
17
|
+
const SKIP_DIRS = new Set(['.git', STATE_DIR_NAME, 'node_modules', 'target', 'dist', 'build', '.venv', '__pycache__', 'bin', 'obj']);
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
19
|
+
|
|
20
|
+
const has = (root, rel) => { try { return fs.existsSync(path.join(root, rel)); } catch { return false; } };
|
|
21
|
+
const available = (cmd) => { try { const r = spawnSync(cmd, ['--version'], { timeout: 8000, stdio: 'ignore' }); return !(r.error && r.error.code === 'ENOENT'); } catch { return false; } };
|
|
22
|
+
|
|
23
|
+
/** @returns {{cmd:string,args:string[],kind:string}|null} */
|
|
24
|
+
export function discoverTestCommand(root, { available: avail = available } = {}) {
|
|
25
|
+
const base = detectTestCommand(root);
|
|
26
|
+
if (base) return base;
|
|
27
|
+
if (has(root, 'Cargo.toml') && avail('cargo')) return { cmd: 'cargo', args: ['test', '--quiet'], kind: 'cargo' };
|
|
28
|
+
try { if (fs.readdirSync(root).some((f) => /\.(sln|csproj)$/.test(f)) && avail('dotnet')) return { cmd: 'dotnet', args: ['test', '--nologo'], kind: 'dotnet' }; } catch { /* unreadable root */ }
|
|
29
|
+
if (has(root, 'pom.xml') && avail('mvn')) return { cmd: 'mvn', args: ['-q', 'test'], kind: 'maven' };
|
|
30
|
+
if ((has(root, 'build.gradle') || has(root, 'build.gradle.kts')) && avail('gradle')) return { cmd: 'gradle', args: ['test', '--quiet'], kind: 'gradle' };
|
|
31
|
+
if ((has(root, '.rspec') || has(root, 'spec')) && has(root, 'Gemfile') && avail('bundle')) return { cmd: 'bundle', args: ['exec', 'rspec'], kind: 'rspec' };
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function copyTree(root, dest) {
|
|
36
|
+
let bytes = 0;
|
|
37
|
+
fs.cpSync(root, dest, {
|
|
38
|
+
recursive: true, dereference: false, verbatimSymlinks: true,
|
|
39
|
+
filter: (src) => {
|
|
40
|
+
const rel = path.relative(root, src);
|
|
41
|
+
if (!rel) return true;
|
|
42
|
+
if (rel.split(path.sep).some((s) => SKIP_DIRS.has(s))) return false;
|
|
43
|
+
try { const st = fs.lstatSync(src); if (st.isFile()) { bytes += st.size; if (bytes > MAX_COPY_BYTES) throw new Error('tree-too-large'); } } catch (e) { if (e.message === 'tree-too-large') throw e; }
|
|
44
|
+
return true;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
// dependencies are linked, not copied: tests need them, and the copy must not be able to change them
|
|
48
|
+
const nm = path.join(root, 'node_modules');
|
|
49
|
+
if (fs.existsSync(nm)) { try { fs.symlinkSync(nm, path.join(dest, 'node_modules'), 'dir'); } catch { /* best effort */ } }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function run(cwd, cmd, args, timeoutMs) {
|
|
53
|
+
const t0 = Date.now();
|
|
54
|
+
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: timeoutMs, env: { ...process.env, CI: '1' } });
|
|
55
|
+
const durationMs = Date.now() - t0;
|
|
56
|
+
if (r.error && r.error.code === 'ENOENT') return { status: 'skipped', passed: null, skipped: true, reason: `${cmd}-not-installed`, exitCode: null, timedOut: false, durationMs };
|
|
57
|
+
if (r.status === null) return { status: 'failed', passed: false, skipped: false, reason: 'timed-out', exitCode: null, timedOut: true, durationMs };
|
|
58
|
+
return { status: r.status === 0 ? 'passed' : 'failed', passed: r.status === 0, skipped: false, reason: r.status === 0 ? null : `exit-${r.status}`, exitCode: r.status, timedOut: false, durationMs };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {{scanRoot:string, files:Record<string,string>, command?:string[], timeoutMs?:number}} o
|
|
63
|
+
*/
|
|
64
|
+
export function runTestsOnPatchedCopy({ scanRoot, files, command, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
65
|
+
const spec = command && command.length ? { cmd: command[0], args: command.slice(1), kind: 'explicit' } : discoverTestCommand(scanRoot);
|
|
66
|
+
if (!spec) return { status: 'skipped', passed: null, skipped: true, reason: 'no-test-command-detected', exitCode: null, timedOut: false, durationMs: 0, onPatchedTree: false };
|
|
67
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'patched-tests-'));
|
|
68
|
+
try {
|
|
69
|
+
const patched = path.join(work, 'patched');
|
|
70
|
+
try { copyTree(scanRoot, patched); } catch (e) { return { status: 'skipped', passed: null, skipped: true, reason: e.message === 'tree-too-large' ? 'tree-too-large-to-copy' : `copy-failed: ${e.message}`, exitCode: null, timedOut: false, durationMs: 0, onPatchedTree: false }; }
|
|
71
|
+
for (const [rel, text] of Object.entries(files || {})) {
|
|
72
|
+
const abs = path.resolve(patched, rel);
|
|
73
|
+
if (abs !== patched && !abs.startsWith(patched + path.sep)) return { status: 'skipped', passed: null, skipped: true, reason: 'patch-path-escapes-the-copy', exitCode: null, timedOut: false, durationMs: 0, onPatchedTree: false };
|
|
74
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
75
|
+
fs.writeFileSync(abs, String(text));
|
|
76
|
+
}
|
|
77
|
+
const result = { ...run(patched, spec.cmd, spec.args, timeoutMs), runner: spec.kind, onPatchedTree: true, preExistingFailure: false };
|
|
78
|
+
if (result.status === 'failed') {
|
|
79
|
+
const pristine = path.join(work, 'pristine');
|
|
80
|
+
try { copyTree(scanRoot, pristine); const base = run(pristine, spec.cmd, spec.args, timeoutMs); if (base.status === 'failed') result.preExistingFailure = true; } catch { /* attribution is best effort */ }
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
} finally { fs.rmSync(work, { recursive: true, force: true }); }
|
|
84
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Adapters from the results the existing routes already produce to the patch-level verification record (E3-02).
|
|
2
|
+
//
|
|
3
|
+
// An adapter only RELAYS: it maps what a route observed onto legs, and anything the route did not observe becomes `not_run` with the
|
|
4
|
+
// reason. It never upgrades a result. In particular a test run that happened before the patch was written (verifyFix sets
|
|
5
|
+
// `testedPrePatch`) is recorded as `not_run` for the patched tree, not as passed.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { digestOf } from '../posture/assurance/identity.js';
|
|
8
|
+
import { buildFixVerificationRecord, evidenceItem, leg } from './verification-record.js';
|
|
9
|
+
|
|
10
|
+
const sha256Hex = (s) => createHash('sha256').update(s).digest('hex');
|
|
11
|
+
|
|
12
|
+
/** Digest of a patch: the files it writes, by path and content digest. Independent of key order. */
|
|
13
|
+
export function patchHashOf(files) {
|
|
14
|
+
const rows = Object.keys(files || {}).sort().map((p) => [p, sha256Hex(String(files[p]))]);
|
|
15
|
+
return digestOf({ files: rows });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Digest of the files as they were before the patch, for the paths the patch touches. `read(path)` returns the text or null. */
|
|
19
|
+
export function treeHashOf(paths, read) {
|
|
20
|
+
const rows = [...paths].sort().map((p) => { const t = read(p); return [p, t === null || t === undefined ? null : sha256Hex(String(t))]; });
|
|
21
|
+
return digestOf({ files: rows });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const asEvidence = (kind, value, note = null) => evidenceItem({ kind, mediaType: 'application/json', sha256: digestOf(value).slice('sha256:'.length), note });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} v the object verifyFix returns
|
|
28
|
+
* @param {object} ctx { finding:{id,stableId}, files, route, mode, preCommit, preFiles(read), tools, requiredLegs, findingPresent }
|
|
29
|
+
*/
|
|
30
|
+
export function recordFromVerifyFix(v, ctx) {
|
|
31
|
+
const evidence = [];
|
|
32
|
+
const add = (kind, value, note) => { const e = asEvidence(kind, value, note); if (!evidence.some((x) => x.id === e.id)) evidence.push(e); return e.id; };
|
|
33
|
+
const failures = [];
|
|
34
|
+
|
|
35
|
+
const rescanId = v.rescan ? add('rescan-result', v.rescan, 'verifyFix rescan leg') : null;
|
|
36
|
+
const securityAfter = !v.rescan ? leg('not_run', { reason: 'the route returned no rescan result' })
|
|
37
|
+
: v.rescan.ok ? leg('passed', { evidenceIds: [rescanId] }) : leg('failed', { evidenceIds: [rescanId], reason: `the original finding is still present (${v.rescan.reason})` });
|
|
38
|
+
if (ctx.persistence?.status === 'failed') {
|
|
39
|
+
const id = add('secret-persistence', ctx.persistence, 'the original secret literal searched for in the patched source');
|
|
40
|
+
securityAfter.status = 'failed'; securityAfter.evidenceIds = [...securityAfter.evidenceIds, id]; securityAfter.reason = ctx.persistence.reason;
|
|
41
|
+
}
|
|
42
|
+
const introduced = v.rescan?.introduced || [];
|
|
43
|
+
const newFindingDelta = !v.rescan ? leg('not_run', { reason: 'the route returned no rescan result' })
|
|
44
|
+
: introduced.length ? leg('failed', { evidenceIds: [rescanId], reason: `${introduced.length} finding(s) introduced by the patch` }) : leg('passed', { evidenceIds: [rescanId] });
|
|
45
|
+
|
|
46
|
+
let lint;
|
|
47
|
+
if (!v.lint) lint = leg('not_run', { reason: 'the route returned no lint result' });
|
|
48
|
+
else if (v.lint.runner === 'none') lint = leg('not_applicable', { reason: 'no linter is configured for this project' });
|
|
49
|
+
else lint = v.lint.ok ? leg('passed', { evidenceIds: [add('lint-result', v.lint)] }) : leg('failed', { evidenceIds: [add('lint-result', v.lint)], reason: 'the linter reported errors on the patched files' });
|
|
50
|
+
|
|
51
|
+
let behaviorTests;
|
|
52
|
+
if (!v.tests) behaviorTests = leg('not_run', { reason: 'the route returned no test result' });
|
|
53
|
+
else if (v.tests.skipped) behaviorTests = leg('not_run', { reason: `tests were skipped (${v.tests.reason || 'no reason recorded'})` });
|
|
54
|
+
else if (v.tests.preExistingFailure || v.tests.reason === 'suite-already-failing-before-the-patch') behaviorTests = leg('not_run', { reason: 'the project test suite was already failing before the patch, so it cannot show the patch preserved behaviour' });
|
|
55
|
+
else if (v.testedPrePatch) behaviorTests = leg('not_run', { evidenceIds: [], reason: 'the tests ran on the tree BEFORE the patch was written, so they say nothing about the patch' });
|
|
56
|
+
else behaviorTests = v.tests.passed ? leg('passed', { evidenceIds: [add('test-result', v.tests)] }) : leg('failed', { evidenceIds: [add('test-result', v.tests)], reason: 'the project tests failed on the patched tree' });
|
|
57
|
+
|
|
58
|
+
const poc = v.poc?.status;
|
|
59
|
+
const pocId = v.poc && poc !== 'not-requested' ? add('exploit-replay', v.poc) : null;
|
|
60
|
+
const replay = ctx.replay || null;
|
|
61
|
+
const replayId = replay ? add('exploit-replay', replay, 'pre/post replay of the same reproducer') : null;
|
|
62
|
+
const replayLeg = (status, ok, bad) => status === 'passed' ? leg('passed', { evidenceIds: [replayId] }) : status === 'failed' ? leg('failed', { evidenceIds: [replayId], reason: bad }) : leg('not_run', { reason: replay.reason || ok });
|
|
63
|
+
const exploitProofAfter = replay ? replayLeg(replay.afterStatus, 'the replay did not run', replay.reason) : !v.poc || poc === 'not-requested' ? leg('not_run', { reason: 'no exploit replay was requested by this route' })
|
|
64
|
+
: poc === 'no-longer-proven' ? leg('passed', { evidenceIds: [pocId] })
|
|
65
|
+
: poc === 'still-exploitable' ? leg('failed', { evidenceIds: [pocId], reason: 'the exploit still works against the patched code' })
|
|
66
|
+
: leg('not_run', { evidenceIds: [], reason: `the exploit replay was inconclusive (${v.poc.reason || 'no reason recorded'})` });
|
|
67
|
+
const exploitProofBefore = replay ? replayLeg(replay.beforeStatus, 'the replay did not run', replay.reason) : !v.poc || poc === 'not-requested' ? leg('not_run', { reason: 'no exploit replay was requested by this route' })
|
|
68
|
+
: poc === 'inconclusive' ? leg('not_run', { reason: 'the exploit could not be reproduced on the original code' })
|
|
69
|
+
: leg('passed', { evidenceIds: [pocId] });
|
|
70
|
+
|
|
71
|
+
const findingEv = ctx.findingPresent === true && ctx.finding ? add('finding', { id: ctx.finding.id, stableId: ctx.finding.stableId ?? null }, 'the finding in the signed scan') : null;
|
|
72
|
+
const securityBefore = findingEv ? leg('passed', { evidenceIds: [findingEv] }) : leg('not_run', { reason: 'the finding was not shown to be present on the original code' });
|
|
73
|
+
|
|
74
|
+
if (v.honesty && v.honesty.ok === false) failures.push({ leg: 'securityAfter', reason: 'the honesty gate refused the claimed fix quality' });
|
|
75
|
+
|
|
76
|
+
const fp = ctx.footprint || null;
|
|
77
|
+
const footprintLeg = !fp ? leg('not_run', { reason: 'this route does not check that intended functionality survived' })
|
|
78
|
+
: fp.status === 'passed' ? leg('passed', { evidenceIds: [add('footprint', fp, 'sensitive operation kinds before and after the patch')] })
|
|
79
|
+
: fp.status === 'failed' ? leg('failed', { evidenceIds: [add('footprint', fp, 'sensitive operation kinds before and after the patch')], reason: fp.reason })
|
|
80
|
+
: leg('not_run', { reason: fp.reason });
|
|
81
|
+
const files = ctx.files || {};
|
|
82
|
+
return buildFixVerificationRecord({
|
|
83
|
+
findingId: ctx.finding?.id ?? 'unknown', findingStableId: ctx.finding?.stableId, mode: ctx.mode || 'verify', route: ctx.route || 'verify-fix',
|
|
84
|
+
preCommit: ctx.preCommit ?? null, preTreeHash: ctx.preFiles ? treeHashOf(Object.keys(files), ctx.preFiles) : null, postPatchHash: patchHashOf(files), patchHash: patchHashOf(files),
|
|
85
|
+
affectedFiles: Object.keys(files),
|
|
86
|
+
securityBefore, securityAfter, exploitProofBefore, exploitProofAfter, newFindingDelta, behaviorTests, lint,
|
|
87
|
+
behaviorPreservation: footprintLeg,
|
|
88
|
+
regressionTests: leg('not_run', { reason: 'this route runs a single test leg' }),
|
|
89
|
+
typecheck: leg('not_run', { reason: 'this route does not run a type checker' }),
|
|
90
|
+
dependencyChecks: leg('not_applicable', { reason: 'a source patch changes no dependency' }),
|
|
91
|
+
requiredLegs: ctx.requiredLegs || ['securityAfter', 'newFindingDelta', 'behaviorTests'],
|
|
92
|
+
failures, evidence, tools: ctx.tools || { engineVersion: null, verifier: 'verifyFix' },
|
|
93
|
+
residualRisk: ctx.residualRisk,
|
|
94
|
+
confidence: ctx.confidence || (replay?.executionProven ? { level: 'proven', policy: 'exploit-replay-v1', evidenceIds: [replayId] } : undefined),
|
|
95
|
+
...(ctx.timestamps ? { timestamps: ctx.timestamps } : {}),
|
|
96
|
+
});
|
|
97
|
+
}
|