@clear-capabilities/agentic-security-scanner 0.172.0 → 0.173.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 +29 -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/1336.index.js +101 -101
- 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/7405.index.js +23 -6
- 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 +338 -48
- 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 +28 -28
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +31 -6
- package/src/context-intelligence/CLAUDE.md +4 -0
- package/src/context-intelligence/adapters/haskell-hie.js +28 -1
- package/src/context-intelligence/candidates.js +2 -1
- package/src/context-intelligence/canonical.js +25 -0
- package/src/context-intelligence/capabilities.js +2 -1
- package/src/context-intelligence/cli.js +16 -10
- package/src/context-intelligence/config.js +4 -1
- package/src/context-intelligence/controls.js +108 -10
- package/src/context-intelligence/counterexample.js +200 -0
- package/src/context-intelligence/cross-route.js +111 -0
- package/src/context-intelligence/entrypoints.js +2 -2
- package/src/context-intelligence/evaluation/index.js +11 -4
- package/src/context-intelligence/evaluation/scripted-reader.js +12 -2
- 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 +10 -4
- package/src/context-intelligence/review.js +129 -16
- package/src/dataflow/access-paths.js +11 -0
- package/src/dataflow/analysis-limits.js +66 -0
- package/src/dataflow/engine.js +272 -20
- 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/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/util/string-memo.js +38 -0
|
@@ -116,9 +116,14 @@ export class SummaryCache {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
get(qid, taintedParams, receiverType) {
|
|
119
|
-
|
|
119
|
+
const r = this._cache.get(this._key(qid, taintedParams, receiverType));
|
|
120
|
+
if (r === undefined) this._misses = (this._misses || 0) + 1; else this._hits = (this._hits || 0) + 1;
|
|
121
|
+
return r;
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
/** Lookups that found and did not find a summary (E6-01). */
|
|
125
|
+
stats() { return { hits: this._hits || 0, misses: this._misses || 0, size: this._cache.size }; }
|
|
126
|
+
|
|
122
127
|
set(qid, taintedParams, summary, receiverType) {
|
|
123
128
|
const k = this._key(qid, taintedParams, receiverType);
|
|
124
129
|
this._cache.set(k, summary);
|
|
@@ -163,6 +168,7 @@ export class SummaryCache {
|
|
|
163
168
|
return { returnTainted: false, mutatedParams: new Set(), taintedGlobals: new Set(), findings: [], _recursive: true };
|
|
164
169
|
}
|
|
165
170
|
if (++this._iter > this._maxIter) {
|
|
171
|
+
this._budgetHits = (this._budgetHits || 0) + 1;
|
|
166
172
|
return { returnTainted: false, mutatedParams: new Set(), taintedGlobals: new Set(), findings: [], _budgetExceeded: true };
|
|
167
173
|
}
|
|
168
174
|
this._stack.add(qid);
|
package/src/discovery/judge.js
CHANGED
|
@@ -51,7 +51,8 @@ export function toFindingShape(candidate) {
|
|
|
51
51
|
description: candidate.rationale
|
|
52
52
|
? `${candidate.rationale} (entry point: ${candidate.entryPoint || 'unstated'}; sink: ${candidate.sink || 'unstated'})`
|
|
53
53
|
: `Proposed by the ${candidate.lens} lens; no rationale supplied.`,
|
|
54
|
-
|
|
54
|
+
// a candidate that carries its own concrete remediation (a deterministic finding with a known fix shape) keeps it; every other candidate gets the generic review text
|
|
55
|
+
remediation: (typeof candidate.remediation === 'string' && candidate.remediation) ? candidate.remediation : `Review ${candidate.file}:${candidate.line}. Confirm whether ${candidate.entryPoint || 'attacker-controlled input'} can reach ${candidate.sink || 'this operation'}, and constrain it at the boundary if so.`,
|
|
55
56
|
parser: 'DISCOVERY',
|
|
56
57
|
family: candidate.family || 'other',
|
|
57
58
|
ruleId: `discovery:${candidate.lens}`,
|
package/src/engine.js
CHANGED
|
@@ -58,7 +58,8 @@ import { proofCoverage } from './posture/proof-coverage.js';
|
|
|
58
58
|
import { computeScanHealth, applyFreshness } from './pipeline/scan-health.js';
|
|
59
59
|
import { computeCoverageLedger, summarizeCoverageForScanHealth } from './pipeline/coverage-ledger.js';
|
|
60
60
|
import { runAnnotatorAsync } from './pipeline/annotator-runner.js';
|
|
61
|
-
import { runDetector } from './pipeline/detector-runner.js';
|
|
61
|
+
import { runDetector, setDetectorTimingSink } from './pipeline/detector-runner.js';
|
|
62
|
+
import { createTelemetry, attributeErrors } from './pipeline/telemetry.js';
|
|
62
63
|
import { registerProducer } from './pipeline/producer-registry.js';
|
|
63
64
|
import { collectProducerResult } from './pipeline/producer-collector.js';
|
|
64
65
|
import { completeEnrichment } from './pipeline/enrichment-completion.js';
|
|
@@ -129,6 +130,8 @@ import { scanClientSide } from './sast/client-side.js';
|
|
|
129
130
|
import { scanPromptFirewall } from './sast/prompt-firewall.js';
|
|
130
131
|
import { scanLlmRedteam } from './posture/llm-redteam.js';
|
|
131
132
|
import { assessPrivacyFramework, persistPrivacyFramework } from './posture/privacy-framework.js';
|
|
133
|
+
import { signLastScan } from './posture/integrity.js';
|
|
134
|
+
import { secretCanaryHit } from './posture/runtime-trace-ingest.js';
|
|
132
135
|
import { safeWriteState as _safeWriteState, statePath, statePath as _statePath } from './posture/state-dir.js';
|
|
133
136
|
import { scanContainer } from './sca/container.js';
|
|
134
137
|
import { detectDepConfusion } from './sca/dep-confusion.js';
|
|
@@ -210,6 +213,7 @@ import { applyPathConstraints } from './posture/path-predicates.js';
|
|
|
210
213
|
import { buildProjectIR, buildProjectIRAsync } from './ir/index.js';
|
|
211
214
|
import _v8 from 'node:v8';
|
|
212
215
|
import _vm from 'node:vm';
|
|
216
|
+
import { createStringMemo } from './util/string-memo.js';
|
|
213
217
|
import { collectIrStats, irStatsTarget, writeIrStats } from './ir/ir-stats.js';
|
|
214
218
|
import { runDeepAnalysis } from './dataflow/index.js';
|
|
215
219
|
import { buildLineageGraph } from './lineage/index.js';
|
|
@@ -446,7 +450,11 @@ function _ruleAppliesIn(pat, ctx){
|
|
|
446
450
|
// comments would put the second pass into a stuck string state, blanking the
|
|
447
451
|
// rest of the file. Backtick `${...}` template expressions are preserved
|
|
448
452
|
// verbatim because they contain real source/sink references.
|
|
449
|
-
|
|
453
|
+
// Called by several detectors per file with the same text; the result depends only on the text, so it is computed once per distinct text
|
|
454
|
+
// (util/string-memo.js). AGENTIC_SECURITY_NO_STRING_MEMO=1 turns the memo off.
|
|
455
|
+
const _stripNoiseAndStringsMemo = createStringMemo((c) => _stripNoiseAndStringsRaw(c), { version: 'stripNoiseAndStrings@1', disableEnv: 'AGENTIC_SECURITY_NO_STRING_MEMO' });
|
|
456
|
+
function stripNoiseAndStrings(code){ return _stripNoiseAndStringsMemo(code); }
|
|
457
|
+
function _stripNoiseAndStringsRaw(code){
|
|
450
458
|
const out = code.split('');
|
|
451
459
|
const n = code.length;
|
|
452
460
|
let i = 0;
|
|
@@ -3134,6 +3142,27 @@ function getProjectConstant(name){
|
|
|
3134
3142
|
return hit ? hit.value : null;
|
|
3135
3143
|
}
|
|
3136
3144
|
|
|
3145
|
+
// E6-04: a digest of every input OUTSIDE a single file that the per-file detector cascade reads: the project index (constants, properties, the
|
|
3146
|
+
// e-commerce flag), the Java tainted-method index, and the operator's rules file with its signature. A cached per-file result is reused only when this
|
|
3147
|
+
// is unchanged, so a change to a constant in one file cannot leave another file's cached answer standing.
|
|
3148
|
+
const _ANALYSIS_CACHE_NEVER_RE = /(?:^|[\\/])\.?mcp(?:\.[a-z]+)?\.json$|(?:^|[\\/])\.mcp\.json$/i;
|
|
3149
|
+
function _analysisContextDigest(scanRoot){
|
|
3150
|
+
const h = crypto.createHash('sha256');
|
|
3151
|
+
const mapEntries = (m) => [...(m || new Map()).entries()].map(([k, v]) => [k, v]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
3152
|
+
h.update(JSON.stringify({
|
|
3153
|
+
ecommerce: !!_projectIndex.hasEcommerceModel,
|
|
3154
|
+
byFile: mapEntries(_projectIndex.constantsByFile),
|
|
3155
|
+
byExport: mapEntries(_projectIndex.constantsByExport),
|
|
3156
|
+
props: mapEntries(_projectIndex.javaProperties),
|
|
3157
|
+
javaTainted: [..._GLOBAL_JAVA_TAINTED_METHODS].sort(),
|
|
3158
|
+
}));
|
|
3159
|
+
for (const name of ['rules.yml', 'rules.yaml', 'rules.json', 'rules.yml.sig', 'rules.yaml.sig', 'rules.json.sig']) {
|
|
3160
|
+
h.update(`\n${name}\0`);
|
|
3161
|
+
try { h.update(fs.readFileSync(statePath(scanRoot, name))); } catch { h.update('absent'); }
|
|
3162
|
+
}
|
|
3163
|
+
return h.digest('hex');
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3137
3166
|
const _COUPON_MUTATION_RE = /\b(?:apply|redeem|validate|use|deduct|decrement|increment|update|save|consume|create|destroy|remove|insert)\b/i;
|
|
3138
3167
|
const _REAUTH_PRESENT_RE = /\b(?:bcrypt\.compare|crypto\.timingSafeEqual|currentPassword|recentLogin|verifyTotp|verifyMfa|mfaToken|requireRecentLogin|requireFreshSession|step[-_]?up|requireFreshAuth|verifyPassword)\b/;
|
|
3139
3168
|
|
|
@@ -8859,7 +8888,12 @@ function _gcHint(minHeapMiB) {
|
|
|
8859
8888
|
_gcFn();
|
|
8860
8889
|
}
|
|
8861
8890
|
|
|
8862
|
-
async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined, provenance=true, completeScan=true, skipAnnotators=false, policyExcluded=[], sizeSkipped=[]}, setProgress=()=>{}){
|
|
8891
|
+
async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null, resume=undefined, deep=undefined, deepInCi=undefined, provenance=true, completeScan=true, skipAnnotators=false, policyExcluded=[], sizeSkipped=[]}, setProgress=()=>{}){
|
|
8892
|
+
// E6-01: local, aggregate telemetry for this scan (see pipeline/telemetry.js). Nothing is sent anywhere; the snapshot is a field of the result.
|
|
8893
|
+
const _tel = createTelemetry({ deterministic: isDeterministic() });
|
|
8894
|
+
setDetectorTimingSink((name, ms) => _tel.detector(name, ms));
|
|
8895
|
+
_tel.sample();
|
|
8896
|
+
_resetSuppressions();_tel.time('index', () => _buildProjectIndex(fileContents));await _tel.timeAsync('custom-rules', () => _loadCustomRules(scanRoot));
|
|
8863
8897
|
// Pre-pass: build cross-file Java tainted-method index so per-file taint
|
|
8864
8898
|
// analysis can recognize calls to user-input-returning helper methods
|
|
8865
8899
|
// defined in OTHER files (Juliet's DataflowThruInnerClass / Vector / Stream
|
|
@@ -8885,17 +8919,20 @@ function _deterministicFileTimings(timings) {
|
|
|
8885
8919
|
}
|
|
8886
8920
|
|
|
8887
8921
|
const _fileTimings = [];
|
|
8888
|
-
let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0;
|
|
8922
|
+
let _filesSkipped = 0, _filesTimedOut = 0, _filesDenseSkipped = 0, _filesOversizeSkipped = 0;
|
|
8889
8923
|
const files=Object.keys(fileContents).filter(f=>(shouldScan(f) || isKubernetesManifest(f, fileContents[f]) || isCloudFormationTemplate(f, fileContents[f]) || isInstructionFile(f)) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];const _detectorErrors=[];
|
|
8890
8924
|
// ---- R8: opt-in per-file checkpointing (AGENTIC_SECURITY_RESUME=1, or
|
|
8891
8925
|
// runScan({resume:true})). Default OFF, so existing behaviour is untouched.
|
|
8892
8926
|
// Only this loop is checkpointed; every cross-file pass below re-runs, so
|
|
8893
8927
|
// nothing that depends on the whole tree can be stale on resume.
|
|
8894
8928
|
const _ckptEnabled = (resume === undefined ? process.env.AGENTIC_SECURITY_RESUME === '1' : !!resume) && !!scanRoot;
|
|
8929
|
+
// E6-04: opt-in cross-scan analysis cache (AGENTIC_SECURITY_ANALYSIS_CACHE=1). Same per-file record format as the resume checkpoint, but it is kept after
|
|
8930
|
+
// a completed scan, its records are authenticated with the install key, and the global key also covers the cross-file inputs a file's cascade reads.
|
|
8931
|
+
const _cacheEnabled = !_ckptEnabled && process.env.AGENTIC_SECURITY_ANALYSIS_CACHE === '1' && !!scanRoot;
|
|
8895
8932
|
let _ckpt = null; const _ckptPayloads = new Map(); let _ckptDone = new Set(); let _ckptResumed = 0, _ckptWrites = 0;
|
|
8896
8933
|
const _ckptAbortAfter = parseInt(process.env.AGENTIC_SECURITY_CHECKPOINT_ABORT_AFTER || '0', 10) || 0;
|
|
8897
8934
|
let _ckptInvalidated = [];
|
|
8898
|
-
if (_ckptEnabled) {
|
|
8935
|
+
if (_ckptEnabled || _cacheEnabled) {
|
|
8899
8936
|
try {
|
|
8900
8937
|
const _ckptIdentity = {
|
|
8901
8938
|
engineVersion: _ENGINE_VERSION,
|
|
@@ -8909,8 +8946,9 @@ function _deterministicFileTimings(timings) {
|
|
|
8909
8946
|
// instead, so changing one file no longer discards every other file's
|
|
8910
8947
|
// already-completed work — see scan-checkpoint.js's module header.
|
|
8911
8948
|
_ckpt = openCheckpoint(scanRoot, {
|
|
8912
|
-
globalKey: computeGlobalKey(_ckptIdentity),
|
|
8949
|
+
globalKey: computeGlobalKey(_cacheEnabled ? { ..._ckptIdentity, extra: _analysisContextDigest(scanRoot) } : _ckptIdentity),
|
|
8913
8950
|
meta: globalKeyMeta(_ckptIdentity),
|
|
8951
|
+
...(_cacheEnabled ? { persistent: true, fileName: 'analysis-cache.jsonl', sign: signLastScan } : {}),
|
|
8914
8952
|
// Haskell/Nix sources hash with their import closure, so an imported
|
|
8915
8953
|
// module's change invalidates its importers' checkpointed work too.
|
|
8916
8954
|
fileContents: withLanguageClosure(fileContents, depFileContents),
|
|
@@ -8939,6 +8977,17 @@ function _deterministicFileTimings(timings) {
|
|
|
8939
8977
|
for (const x of (d.ciphersRest || [])) aCiphersRest.push(x);
|
|
8940
8978
|
for (const x of (d.ciphersTransit || [])) aCiphersTransit.push(x);
|
|
8941
8979
|
for (const x of (d.suppressions || [])) _suppressionLog.push(x);
|
|
8980
|
+
// A finding's `source` and `sink` ARE the entries of this file's source and sink lists (one object, two references), and later passes annotate them in
|
|
8981
|
+
// place (flowStatus, riskStatus, ...). Reading a record back from JSON gives each reference its own copy, so the annotation would reach the list entry
|
|
8982
|
+
// and not the finding. Re-pointing the finding at the matching list entry restores the sharing a fresh analysis has.
|
|
8983
|
+
{
|
|
8984
|
+
const srcByJson = new Map(); for (const x of aSrc.slice(s0)) srcByJson.set(JSON.stringify(x), x);
|
|
8985
|
+
const sinkByJson = new Map(); for (const x of aSink.slice(k0)) sinkByJson.set(JSON.stringify(x), x);
|
|
8986
|
+
for (const f of aF.slice(f0)) {
|
|
8987
|
+
if (f && f.source && typeof f.source === 'object') { const m = srcByJson.get(JSON.stringify(f.source)); if (m) f.source = m; }
|
|
8988
|
+
if (f && f.sink && typeof f.sink === 'object') { const m = sinkByJson.get(JSON.stringify(f.sink)); if (m) f.sink = m; }
|
|
8989
|
+
}
|
|
8990
|
+
}
|
|
8942
8991
|
const ta = Object.assign({}, d.pfr || {});
|
|
8943
8992
|
ta.findings = aF.slice(f0, f0 + (d.pfrFindings || 0));
|
|
8944
8993
|
ta.sources = aSrc.slice(s0); ta.sinks = aSink.slice(k0); ta.sanitizers = aSan.slice(n0);
|
|
@@ -8947,9 +8996,10 @@ function _deterministicFileTimings(timings) {
|
|
|
8947
8996
|
_ckptResumed++;
|
|
8948
8997
|
return true;
|
|
8949
8998
|
};
|
|
8999
|
+
let _cacheSecretSkips = 0;
|
|
8950
9000
|
const _ckptRecord = (p, mk, ms, ta) => {
|
|
8951
9001
|
if (!_ckpt || !_ckpt.enabled) return;
|
|
8952
|
-
|
|
9002
|
+
const _payload = {
|
|
8953
9003
|
routes: aR.slice(mk.aR), findings: aF.slice(mk.aF),
|
|
8954
9004
|
sources: aSrc.slice(mk.aSrc), sinks: aSink.slice(mk.aSink), sanitizers: aSan.slice(mk.aSan),
|
|
8955
9005
|
logic: aLogic.slice(mk.aLogic), secrets: aSecrets.slice(mk.aSecrets),
|
|
@@ -8958,7 +9008,10 @@ function _deterministicFileTimings(timings) {
|
|
|
8958
9008
|
pfr: _pfrMetaOnly(ta),
|
|
8959
9009
|
pfrFindings: (ta && Array.isArray(ta.findings)) ? ta.findings.length : 0,
|
|
8960
9010
|
ms,
|
|
8961
|
-
}
|
|
9011
|
+
};
|
|
9012
|
+
// A cache that outlives the scan must not become a second copy of a secret: a file whose result contains a secret-shaped value is analysed afresh each time.
|
|
9013
|
+
if (_cacheEnabled) { let _txt = ''; try { _txt = JSON.stringify(_payload); } catch { /* not serializable: recordFileDone refuses it too */ } if (secretCanaryHit(_txt)) { _cacheSecretSkips++; return; } }
|
|
9014
|
+
recordFileDone(_ckpt, p, _payload);
|
|
8962
9015
|
_ckptWrites++;
|
|
8963
9016
|
// Fault injection for the resume test: a hard exit with no unwinding, which
|
|
8964
9017
|
// is what the checkpoint format has to survive. Never set in normal use.
|
|
@@ -8978,12 +9031,13 @@ function _deterministicFileTimings(timings) {
|
|
|
8978
9031
|
});
|
|
8979
9032
|
}
|
|
8980
9033
|
let i=0;
|
|
9034
|
+
const _tDet = Date.now();
|
|
8981
9035
|
try {
|
|
8982
9036
|
for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});
|
|
8983
9037
|
if(_ckptDone.has(p)&&_ckptReplay(p))continue;
|
|
8984
9038
|
const _mk={aR:aR.length,aF:aF.length,aSrc:aSrc.length,aSink:aSink.length,aSan:aSan.length,aLogic:aLogic.length,aSecrets:aSecrets.length,aCR:aCiphersRest.length,aCT:aCiphersTransit.length,sup:_suppressionLog.length};
|
|
8985
|
-
try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}
|
|
8986
|
-
let _delta;
|
|
9039
|
+
try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;if(c&&c.length>500000)_filesOversizeSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}
|
|
9040
|
+
let _delta; const _errsBefore = _detectorErrors.length;
|
|
8987
9041
|
if (_cascadePool) {
|
|
8988
9042
|
const _res = await _cascadePool.runFile(p, c, scanRoot, _detectorErrors, { timeoutMs: _perFileTimeoutMs });
|
|
8989
9043
|
if (_res.ok) { _delta = _res.result; }
|
|
@@ -8997,7 +9051,9 @@ function _deterministicFileTimings(timings) {
|
|
|
8997
9051
|
const _ftElapsed=Date.now()-_ft0;
|
|
8998
9052
|
if(_ftElapsed>_perFileTimeoutMs){aF.push({id:`file-timeout:${p}`,file:p,line:0,vuln:`File analysis exceeded ${_perFileTimeoutMs}ms (${_ftElapsed}ms)`,severity:'info',parser:'ENGINE',confidence:0.5,_timeout:true});_filesTimedOut++;}
|
|
8999
9053
|
_fileTimings.push({file:p,ms:_ftElapsed});
|
|
9000
|
-
|
|
9054
|
+
// The cache only keeps a file whose analysis finished cleanly: a detector that threw, a timeout, or a file whose analysis reads or writes state outside
|
|
9055
|
+
// itself (the MCP rug-pull baseline) is analysed afresh every time so health reporting and side effects stay exactly as they are.
|
|
9056
|
+
if (!_cacheEnabled || (_detectorErrors.length === _errsBefore && _ftElapsed <= _perFileTimeoutMs && !_ANALYSIS_CACHE_NEVER_RE.test(p))) _ckptRecord(p,_mk,_ftElapsed,_delta.pfr);
|
|
9001
9057
|
}catch(_){_fileTimings.push({file:p,ms:Date.now()-_ft0,error:true});}
|
|
9002
9058
|
if(i%5===0)await new Promise(r=>setTimeout(r,0));}
|
|
9003
9059
|
} finally {
|
|
@@ -9146,6 +9202,8 @@ function _deterministicFileTimings(timings) {
|
|
|
9146
9202
|
};
|
|
9147
9203
|
filterArr(aF); filterArr(aLogic); filterArr(aSecrets); filterArr(aSupply);
|
|
9148
9204
|
}
|
|
9205
|
+
_tel.record('detectors-per-file', Date.now() - _tDet);
|
|
9206
|
+
const _tLink = Date.now();
|
|
9149
9207
|
setProgress({current:i,total:files.length,file:"Cross-file...",phase:"Linking"});const ii=buildImportGraph(fc);const cf=crossFileTaint(pfr,fc,ii);aF.push(...cf);
|
|
9150
9208
|
setProgress({current:i,total:files.length,file:"Stored taint...",phase:"Linking"});const storedRegistry=buildStoredTaintRegistry(fc);const stf=crossStoredTaint(fc,storedRegistry);aF.push(...stf);
|
|
9151
9209
|
setProgress({current:i,total:files.length,file:"Session taint...",phase:"Linking"});const sess=crossSessionTaint(fc);aF.push(...sess);
|
|
@@ -9153,6 +9211,7 @@ function _deterministicFileTimings(timings) {
|
|
|
9153
9211
|
// R19 (PRD §5): cross-route BOLA/BFLA over the aggregated route inventory.
|
|
9154
9212
|
// Convention deviation is PROJECT-scoped by design: the convention is a
|
|
9155
9213
|
// property of the codebase, not of one file (see convention-deviation.js).
|
|
9214
|
+
_tel.record('cross-file-linking', Date.now() - _tLink);
|
|
9156
9215
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanConventionDeviationProject',()=>scanConventionDeviationProject(fc))||[]));
|
|
9157
9216
|
aF.push(...(runDetector(_detectorErrors,'<project>','scanApiBrokenAuthz',()=>scanApiBrokenAuthz(aR))||[]));
|
|
9158
9217
|
// R22 (PRD §5): cross-service edges inferred from code (client call → matched route).
|
|
@@ -9432,6 +9491,7 @@ function _deterministicFileTimings(timings) {
|
|
|
9432
9491
|
const _deepEnabled = _deepRequested && (!_inCi || _deepInCiAllowed);
|
|
9433
9492
|
let _deepCallGraph = null;
|
|
9434
9493
|
let _deepFailure = null;
|
|
9494
|
+
let _deepIncomplete = null;
|
|
9435
9495
|
if (_deepEnabled) {
|
|
9436
9496
|
const budgetMs = parseInt(process.env.AGENTIC_SECURITY_DEEP_TIMEOUT_MS || '300000', 10);
|
|
9437
9497
|
const t0 = Date.now();
|
|
@@ -9462,6 +9522,8 @@ function _deterministicFileTimings(timings) {
|
|
|
9462
9522
|
}),
|
|
9463
9523
|
});
|
|
9464
9524
|
const elapsed = Date.now() - t0;
|
|
9525
|
+
_tel.record('deep-analysis', elapsed);
|
|
9526
|
+
if (irFindings.analysisIncomplete && irFindings._summaryCache && typeof irFindings._summaryCache.stats === 'function') { const st = irFindings._summaryCache.stats(); _tel.set('cache.summary.hits', st.hits); _tel.set('cache.summary.misses', st.misses); }
|
|
9465
9527
|
if (elapsed > budgetMs) {
|
|
9466
9528
|
// We exceeded budget — surface a single info finding so operators see it.
|
|
9467
9529
|
aF.push({
|
|
@@ -9473,6 +9535,7 @@ function _deterministicFileTimings(timings) {
|
|
|
9473
9535
|
confidence: 0.5,
|
|
9474
9536
|
});
|
|
9475
9537
|
}
|
|
9538
|
+
if (Array.isArray(irFindings.analysisIncomplete) && irFindings.analysisIncomplete.length) _deepIncomplete = irFindings.analysisIncomplete;
|
|
9476
9539
|
for (const f of irFindings) {
|
|
9477
9540
|
f.unvalidated = true;
|
|
9478
9541
|
f.validator_verdict = 'unvalidated';
|
|
@@ -9514,6 +9577,8 @@ function _deterministicFileTimings(timings) {
|
|
|
9514
9577
|
? (_inCi ? 'requested, but running in CI without AGENTIC_SECURITY_DEEP_IN_CI=1' : 'unknown')
|
|
9515
9578
|
: (_inCi ? 'not requested (deep analysis defaults to off in CI)' : 'not requested')),
|
|
9516
9579
|
failure: _deepFailure,
|
|
9580
|
+
// E2-05: where the deep analysis stopped short (budget, deadline, function limit, a thrown analysis). Present only when something did.
|
|
9581
|
+
...(_deepIncomplete ? { incomplete: _deepIncomplete } : {}),
|
|
9517
9582
|
};
|
|
9518
9583
|
// Sub-project E, increment 5 — Data Flow Explorer lineage graph.
|
|
9519
9584
|
// DELIBERATELY INDEPENDENT of AGENTIC_SECURITY_DEEP/_deepEnabled above
|
|
@@ -9861,7 +9926,7 @@ function _deterministicFileTimings(timings) {
|
|
|
9861
9926
|
let _annotatorIdx = 0;
|
|
9862
9927
|
const _runAnnotator = (phase, fn) => {
|
|
9863
9928
|
setProgress({ phase: 'Annotating', current: ++_annotatorIdx, total: _ANNOTATOR_TOTAL_ESTIMATE, file: phase });
|
|
9864
|
-
return runAnnotatorAsync(_annotatorErrors, phase, fn);
|
|
9929
|
+
return _tel.timeAsync(`annotator:${phase}`, () => runAnnotatorAsync(_annotatorErrors, phase, fn));
|
|
9865
9930
|
};
|
|
9866
9931
|
await _runAnnotator('annotateStableIds', () => annotateStableIds(finalFindings));
|
|
9867
9932
|
await _runAnnotator("clusterByRootCause", () => { finalFindings = clusterByRootCause(finalFindings); });
|
|
@@ -10740,7 +10805,7 @@ function _deterministicFileTimings(timings) {
|
|
|
10740
10805
|
// seen when only N-of-those-candidates were actually analyzed.
|
|
10741
10806
|
// checkpoint.total intentionally keeps files.length — that field means the
|
|
10742
10807
|
// full candidate set for resume bookkeeping, a different, correct meaning.
|
|
10743
|
-
_scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_deterministicFileTimings(_fileTimings),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),resumed:_ckptResumed,total:files.length,discarded:!!(_ckpt&&_ckpt.discarded),discardReason:(_ckpt&&_ckpt.discarded)?(_ckpt.reason||null):null,invalidatedFileCount:_ckptInvalidated.length,invalidatedFiles:_ckptInvalidated.slice(0,20),invalidatedFilesTruncated:_ckptInvalidated.length>20}};
|
|
10808
|
+
_scanMeta={filesScanned:Object.keys(fc).length,filesSkipped:_filesSkipped,filesOversizeSkipped:_filesOversizeSkipped,filesDenseSkipped:_filesDenseSkipped,filesTimedOut:_filesTimedOut,analysisTier:_analysisTier,unmodeledSinkCandidates:_unmodeledSinks,fileTimings:_deterministicFileTimings(_fileTimings),findingsBySeverity:{critical:finalFindings.filter(f=>f.severity==='critical').length,high:finalFindings.filter(f=>f.severity==='high').length,medium:finalFindings.filter(f=>f.severity==='medium').length,low:finalFindings.filter(f=>f.severity==='low').length,info:finalFindings.filter(f=>f.severity==='info').length},checkpoint:{enabled:!!(_ckpt&&_ckpt.enabled),...(_cacheEnabled?{kind:'analysis-cache',secretSkips:_cacheSecretSkips}:{}),resumed:_ckptResumed,total:files.length,discarded:!!(_ckpt&&_ckpt.discarded),discardReason:(_ckpt&&_ckpt.discarded)?(_ckpt.reason||null):null,invalidatedFileCount:_ckptInvalidated.length,invalidatedFiles:_ckptInvalidated.slice(0,20),invalidatedFilesTruncated:_ckptInvalidated.length>20}};
|
|
10744
10809
|
// R8: the scan completed, so the checkpoint has been fully consumed — remove
|
|
10745
10810
|
// it. Anything that threw before this point leaves it in place to resume from.
|
|
10746
10811
|
try { closeCheckpoint(_ckpt, { complete: true }); } catch (_) {}
|
|
@@ -11163,7 +11228,10 @@ function _deterministicFileTimings(timings) {
|
|
|
11163
11228
|
compliance: _complianceReport ? { stale: _complianceReport.summary?.stale || 0 } : null,
|
|
11164
11229
|
});
|
|
11165
11230
|
} // end if (!skipAnnotators) — FR-PROV-029
|
|
11166
|
-
|
|
11231
|
+
setDetectorTimingSink(null);
|
|
11232
|
+
attributeErrors(_tel, { annotatorErrors: _annotatorErrors, detectorErrors: (typeof _detectorErrors !== 'undefined' ? _detectorErrors : []), deepFailure: _deepFailure, filesTimedOut: (_scanMeta && _scanMeta.filesTimedOut) || 0 });
|
|
11233
|
+
_tel.set('files.scanned', (_scanMeta && _scanMeta.filesScanned) || 0); _tel.set('files.skipped', (_scanMeta && _scanMeta.filesSkipped) || 0);
|
|
11234
|
+
return{telemetry:_tel.snapshot(),entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,proofCoverage:_proofCoverage,kevCatalog:kevCatalogMeta(),routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,linesScanned:Object.values(fc).reduce((_n,_c)=>_n+(typeof _c==='string'?_c.split("\n").length:0),0),crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,depFileContents,suppressions:_getSuppressions(),...(_stageEvidenceOn()?{stageEvidence:{dedupe:[..._stageEvidence.dedupe],guard:[..._stageEvidence.guard]}}:{}),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,detectorErrors:_detectorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,privacyFramework:_privacyFramework,privacyIrBacked:_privacyIrBacked,privacyTaxonomyVersion:_privacyTaxonomyVersion,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary,scanHealth:_scanHealth,coverageLedger:_coverageLedger,lineageGraph:_lineageGraph,lineageStatus:_lineageStatus,languageBridges:_languageBridges,languageBom:_languageBom,aiAssistance:_aiAssistance};}
|
|
11167
11235
|
|
|
11168
11236
|
// Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
|
|
11169
11237
|
// Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
|
|
@@ -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
|
+
}
|