@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
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// The patch-level verification record (E3-02): one canonical statement of what was checked about ONE patch, by whichever route produced it.
|
|
2
|
+
//
|
|
3
|
+
// WHY A SECOND RECORD. posture/assurance/verification-record.js records a verdict on a HYPOTHESIS ("this finding is real"): confirmed,
|
|
4
|
+
// refuted, inconclusive. This records what was done to a PATCH ("this change fixes it and breaks nothing"). They are separate axes and the
|
|
5
|
+
// patch record points at the hypothesis it concerns; neither contains the other.
|
|
6
|
+
//
|
|
7
|
+
// HONESTY RULES (enforced by the validator, not by convention)
|
|
8
|
+
// 1. Every check is a leg with one of five statuses: passed, failed, blocked, not_run, not_applicable. `not_run` and `blocked` are never
|
|
9
|
+
// `passed`, and `not_applicable` carries a reason, so "did not apply" cannot hide "was not checked".
|
|
10
|
+
// 2. `verifiedFull` is DERIVED: true only when every required leg passed and none failed. A record that claims it without that is invalid.
|
|
11
|
+
// `degradedLegs` lists every required leg that did not pass, with the reason.
|
|
12
|
+
// 3. A leg may only cite evidence that is in the record, and every evidence item is addressed by its digest.
|
|
13
|
+
// 4. `confidence: proven` needs a named policy and evidence; confidence is never inferred from severity.
|
|
14
|
+
// 5. A route that ran fewer checks produces a record with those legs `not_run`; it does not produce a shorter record.
|
|
15
|
+
//
|
|
16
|
+
// Pure: no fs, no clock, no network. The id is content-derived from an allowlist of semantic fields, so timestamps and tool paths cannot
|
|
17
|
+
// change it.
|
|
18
|
+
import {
|
|
19
|
+
SCHEMA_VERSION, checkCommit, checkDigest, checkEnum, checkFields, checkHeader, checkString, guardObject, isPlainObject, makeCtx, result,
|
|
20
|
+
} from '../posture/assurance/schema-kit.js';
|
|
21
|
+
import { digestOf, semanticId } from '../posture/assurance/identity.js';
|
|
22
|
+
|
|
23
|
+
export const FIX_VERIFICATION_SCHEMA = 'agentic-security/fix-verification-record';
|
|
24
|
+
export const LEG_STATUSES = Object.freeze(['passed', 'failed', 'blocked', 'not_run', 'not_applicable']);
|
|
25
|
+
export const MODES = Object.freeze(['preview', 'verify', 'apply']);
|
|
26
|
+
export const COMPLETENESS = Object.freeze(['FULL', 'MITIGATION', 'WORKAROUND', 'unverified', 'blocked']);
|
|
27
|
+
export const RESIDUAL_LEVELS = Object.freeze(['none', 'low', 'medium', 'high', 'unknown']);
|
|
28
|
+
export const CONFIDENCE_LEVELS = Object.freeze(['proven', 'inferred', 'unknown']);
|
|
29
|
+
|
|
30
|
+
// The legs a record carries, in canonical order. Each is a { status, evidenceIds, reason } object.
|
|
31
|
+
export const LEG_NAMES = Object.freeze([
|
|
32
|
+
'securityBefore', 'securityAfter', 'exploitProofBefore', 'exploitProofAfter', 'newFindingDelta', 'behaviorPreservation',
|
|
33
|
+
'behaviorTests', 'regressionTests', 'lint', 'typecheck', 'dependencyChecks',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// What a leg means when it reads `passed`:
|
|
37
|
+
// securityBefore the weakness was present on the original code (reproduced or statically established)
|
|
38
|
+
// securityAfter the weakness is not present on the patched code
|
|
39
|
+
// exploitProofBefore an exploit succeeded against the original code
|
|
40
|
+
// exploitProofAfter the same exploit failed against the patched code
|
|
41
|
+
// newFindingDelta the patch introduced no new finding of equal or higher severity
|
|
42
|
+
// behaviorPreservation intended functionality survived (authorised use still works, the sensitive operation is not simply gone)
|
|
43
|
+
// behaviorTests the project's tests for the affected scope passed on the PATCHED tree
|
|
44
|
+
// regressionTests the wider project tests passed on the patched tree
|
|
45
|
+
// lint / typecheck the configured linter / type checker passed on the patched tree
|
|
46
|
+
// dependencyChecks dependency manifests and lockfiles are consistent and free of new known advisories
|
|
47
|
+
export const LEG_MEANING = Object.freeze({
|
|
48
|
+
securityBefore: 'the weakness was present on the original code',
|
|
49
|
+
securityAfter: 'the weakness is not present on the patched code',
|
|
50
|
+
exploitProofBefore: 'an exploit succeeded against the original code',
|
|
51
|
+
exploitProofAfter: 'the same exploit failed against the patched code',
|
|
52
|
+
newFindingDelta: 'the patch introduced no new finding of equal or higher severity',
|
|
53
|
+
behaviorPreservation: 'intended functionality survived',
|
|
54
|
+
behaviorTests: 'the affected-scope tests passed on the patched tree',
|
|
55
|
+
regressionTests: 'the wider project tests passed on the patched tree',
|
|
56
|
+
lint: 'the configured linter passed on the patched tree',
|
|
57
|
+
typecheck: 'the configured type checker passed on the patched tree',
|
|
58
|
+
dependencyChecks: 'dependency manifests are consistent and carry no new advisory',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const ALLOWED = ['schema', 'schemaVersion', 'id', 'findingId', 'findingStableId', 'hypothesisId', 'mode', 'route', 'preCommit', 'preTreeHash', 'postPatchHash', 'patchHash',
|
|
62
|
+
'affectedFiles', ...LEG_NAMES, 'requiredLegs', 'coverage', 'degradedLegs', 'verifiedFull', 'fixCompleteness', 'residualRisk', 'confidence', 'tools', 'failures', 'evidence', 'timestamps'];
|
|
63
|
+
const REQUIRED = ['schema', 'schemaVersion', 'id', 'findingId', 'mode', 'route', 'patchHash', 'affectedFiles', ...LEG_NAMES, 'requiredLegs', 'coverage', 'degradedLegs', 'verifiedFull', 'fixCompleteness', 'residualRisk', 'confidence', 'tools', 'failures', 'evidence'];
|
|
64
|
+
|
|
65
|
+
/** Fields that determine identity. Timestamps, tool paths and evidence locations are deliberately absent. */
|
|
66
|
+
export const FIX_RECORD_ID_FIELDS = Object.freeze(['findingId', 'findingStableId', 'preCommit', 'preTreeHash', 'postPatchHash', 'patchHash', 'affectedFiles', ...LEG_NAMES, 'requiredLegs', 'verifiedFull', 'fixCompleteness']);
|
|
67
|
+
|
|
68
|
+
export const fixRecordId = (record) => semanticId('fvrec', record, FIX_RECORD_ID_FIELDS);
|
|
69
|
+
|
|
70
|
+
export const leg = (status, { evidenceIds = [], reason = null } = {}) => ({ status, evidenceIds, reason });
|
|
71
|
+
export const NOT_RUN = (reason) => leg('not_run', { reason });
|
|
72
|
+
|
|
73
|
+
/** Content-addressed evidence item. `sha256` is the digest of the bytes it stands for (a log, a scan result, a patch). */
|
|
74
|
+
export function evidenceItem({ kind, mediaType = 'text/plain', sha256, uri = null, note = null }) {
|
|
75
|
+
const body = { kind, mediaType, sha256 };
|
|
76
|
+
return { id: `ev:${digestOf(body).slice('sha256:'.length, 'sha256:'.length + 16)}`, ...body, ...(uri ? { uri } : {}), ...(note ? { note } : {}) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Required legs that did not pass, as `name: status (reason)`. */
|
|
80
|
+
export function degradedLegsOf(record) {
|
|
81
|
+
return record.requiredLegs.filter((n) => record[n].status !== 'passed').map((n) => `${n}: ${record[n].status}${record[n].reason ? ` (${record[n].reason})` : ''}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True only when every required leg passed and no leg failed. Everything else is not a full verification. */
|
|
85
|
+
export function deriveVerifiedFull(record) {
|
|
86
|
+
if (LEG_NAMES.some((n) => record[n].status === 'failed')) return false;
|
|
87
|
+
if (record.failures.length) return false;
|
|
88
|
+
return record.requiredLegs.every((n) => record[n].status === 'passed');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The completeness tier of a verified patch.
|
|
93
|
+
* blocked a required leg is blocked, or a failure was recorded
|
|
94
|
+
* unverified the weakness is not shown to be gone
|
|
95
|
+
* FULL verifiedFull
|
|
96
|
+
* MITIGATION the weakness is gone but a required check did not run, or the exploit leg did not run
|
|
97
|
+
* WORKAROUND supplied by the caller when the route knows the change reduces exposure without removing the cause
|
|
98
|
+
*/
|
|
99
|
+
export function deriveCompleteness(record, { workaround = false } = {}) {
|
|
100
|
+
if (record.failures.length || record.requiredLegs.some((n) => record[n].status === 'blocked')) return 'blocked';
|
|
101
|
+
if (record.securityAfter.status !== 'passed') return 'unverified';
|
|
102
|
+
if (LEG_NAMES.some((n) => record[n].status === 'failed')) return 'unverified';
|
|
103
|
+
if (workaround) return 'WORKAROUND';
|
|
104
|
+
return deriveVerifiedFull(record) ? 'FULL' : 'MITIGATION';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Residual risk after a patch, from what was and was not established (also used by patch-policy.js).
|
|
108
|
+
export function deriveResidualRiskOf(record) {
|
|
109
|
+
const notes = [...record.degradedLegs];
|
|
110
|
+
const c = record.fixCompleteness;
|
|
111
|
+
let level;
|
|
112
|
+
if (c === 'FULL') level = record.exploitProofAfter.status === 'passed' ? 'low' : 'medium';
|
|
113
|
+
else if (c === 'MITIGATION') level = 'medium';
|
|
114
|
+
else if (c === 'WORKAROUND') level = 'high';
|
|
115
|
+
else level = 'unknown';
|
|
116
|
+
if (c === 'FULL' && record.exploitProofAfter.status !== 'passed') notes.push('no exploit replay confirmed the weakness is closed');
|
|
117
|
+
if (c === 'WORKAROUND') notes.push('exposure is reduced but the underlying cause remains');
|
|
118
|
+
return { level, notes };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function buildFixVerificationRecord(f) {
|
|
122
|
+
const legs = {};
|
|
123
|
+
for (const n of LEG_NAMES) legs[n] = f[n] ? { status: f[n].status, evidenceIds: [...(f[n].evidenceIds || [])], reason: f[n].reason ?? null } : NOT_RUN('the route did not run this check');
|
|
124
|
+
const required = [...new Set(f.requiredLegs || [])].filter((n) => LEG_NAMES.includes(n)).sort((a, b) => LEG_NAMES.indexOf(a) - LEG_NAMES.indexOf(b));
|
|
125
|
+
const rec = {
|
|
126
|
+
schema: FIX_VERIFICATION_SCHEMA, schemaVersion: SCHEMA_VERSION,
|
|
127
|
+
findingId: f.findingId, ...(f.findingStableId ? { findingStableId: f.findingStableId } : {}), ...(f.hypothesisId ? { hypothesisId: f.hypothesisId } : {}),
|
|
128
|
+
mode: f.mode, route: f.route,
|
|
129
|
+
preCommit: f.preCommit ?? null, preTreeHash: f.preTreeHash ?? null, postPatchHash: f.postPatchHash ?? null, patchHash: f.patchHash,
|
|
130
|
+
affectedFiles: [...new Set(f.affectedFiles || [])].sort(),
|
|
131
|
+
...legs,
|
|
132
|
+
requiredLegs: required,
|
|
133
|
+
failures: (f.failures || []).map((x) => ({ leg: x.leg, reason: String(x.reason).slice(0, 400) })),
|
|
134
|
+
evidence: [...(f.evidence || [])].sort((a, b) => (a.id < b.id ? -1 : 1)),
|
|
135
|
+
residualRisk: f.residualRisk || { level: 'unknown', notes: ['not assessed'] },
|
|
136
|
+
confidence: f.confidence || { level: 'unknown', policy: null, evidenceIds: [] },
|
|
137
|
+
tools: f.tools || { engineVersion: null, verifier: 'unspecified' },
|
|
138
|
+
};
|
|
139
|
+
rec.verifiedFull = deriveVerifiedFull(rec);
|
|
140
|
+
rec.degradedLegs = degradedLegsOf(rec);
|
|
141
|
+
rec.coverage = { required: required.length, passed: required.filter((n) => rec[n].status === 'passed').length, notRun: required.filter((n) => rec[n].status === 'not_run').length, blocked: required.filter((n) => rec[n].status === 'blocked').length, failed: required.filter((n) => rec[n].status === 'failed').length };
|
|
142
|
+
rec.fixCompleteness = f.fixCompleteness && f.fixCompleteness !== 'derive' ? f.fixCompleteness : deriveCompleteness(rec, { workaround: f.workaround === true });
|
|
143
|
+
if (f.timestamps) rec.timestamps = f.timestamps;
|
|
144
|
+
if (!f.residualRisk) rec.residualRisk = deriveResidualRiskOf(rec);
|
|
145
|
+
rec.id = fixRecordId(rec);
|
|
146
|
+
return rec;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function validateFixVerificationRecord(record) {
|
|
150
|
+
const g = guardObject(record);
|
|
151
|
+
const ctx = g.ctx;
|
|
152
|
+
if (!g.ok) return result(ctx);
|
|
153
|
+
if (!checkHeader(ctx, record, FIX_VERIFICATION_SCHEMA)) return result(ctx);
|
|
154
|
+
checkFields(ctx, record, ALLOWED, REQUIRED);
|
|
155
|
+
checkString(ctx, 'findingId', record.findingId);
|
|
156
|
+
checkString(ctx, 'route', record.route);
|
|
157
|
+
checkEnum(ctx, 'mode', record.mode, MODES);
|
|
158
|
+
checkEnum(ctx, 'fixCompleteness', record.fixCompleteness, COMPLETENESS);
|
|
159
|
+
checkDigest(ctx, 'patchHash', record.patchHash);
|
|
160
|
+
checkDigest(ctx, 'postPatchHash', record.postPatchHash, { nullable: true });
|
|
161
|
+
checkDigest(ctx, 'preTreeHash', record.preTreeHash, { nullable: true });
|
|
162
|
+
checkCommit(ctx, 'preCommit', record.preCommit);
|
|
163
|
+
if (!Array.isArray(record.affectedFiles) || !record.affectedFiles.length || record.affectedFiles.some((x) => typeof x !== 'string' || !x || x.startsWith('/') || x.split('/').includes('..'))) ctx.err('BAD_TYPE', 'affectedFiles', 'must be a non-empty list of repository-relative paths');
|
|
164
|
+
|
|
165
|
+
const evidenceIds = new Set();
|
|
166
|
+
if (!Array.isArray(record.evidence)) ctx.err('BAD_TYPE', 'evidence', 'must be a list');
|
|
167
|
+
else record.evidence.forEach((e, i) => {
|
|
168
|
+
const p = `evidence[${i}]`;
|
|
169
|
+
if (!isPlainObject(e)) { ctx.err('BAD_TYPE', p, 'must be an object'); return; }
|
|
170
|
+
for (const k of Object.keys(e)) if (!['id', 'kind', 'mediaType', 'sha256', 'uri', 'note'].includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.${k}`, 'not part of an evidence item');
|
|
171
|
+
checkString(ctx, `${p}.kind`, e.kind);
|
|
172
|
+
if (typeof e.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(e.sha256)) ctx.err('BAD_DIGEST', `${p}.sha256`, 'must be 64 lowercase hex characters');
|
|
173
|
+
if (typeof e.id !== 'string' || !/^ev:[0-9a-f]{16}$/.test(e.id)) ctx.err('BAD_ID', `${p}.id`, 'must look like ev:<16 hex>');
|
|
174
|
+
else if (evidenceIds.has(e.id)) ctx.err('DUPLICATE_ID', `${p}.id`, `duplicate evidence id ${e.id}`);
|
|
175
|
+
else evidenceIds.add(e.id);
|
|
176
|
+
if (typeof e.uri === 'string' && (/^[a-z]+:\/\/[^/]*@/i.test(e.uri) || /[?&](token|key|secret|password)=/i.test(e.uri))) ctx.err('RULE_VIOLATION', `${p}.uri`, 'an evidence location must not embed credentials');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
for (const n of LEG_NAMES) {
|
|
180
|
+
const l = record[n];
|
|
181
|
+
const p = n;
|
|
182
|
+
if (!isPlainObject(l)) { ctx.err('BAD_TYPE', p, 'must be a leg object'); continue; }
|
|
183
|
+
for (const k of Object.keys(l)) if (!['status', 'evidenceIds', 'reason'].includes(k)) ctx.err('UNKNOWN_FIELD', `${p}.${k}`, 'not part of a leg');
|
|
184
|
+
checkEnum(ctx, `${p}.status`, l.status, LEG_STATUSES);
|
|
185
|
+
if (!Array.isArray(l.evidenceIds)) ctx.err('BAD_TYPE', `${p}.evidenceIds`, 'must be a list');
|
|
186
|
+
else for (const id of l.evidenceIds) if (!evidenceIds.has(id)) ctx.err('DANGLING_REF', `${p}.evidenceIds`, `cites ${id}, which is not in evidence`);
|
|
187
|
+
if (['not_run', 'not_applicable', 'blocked', 'failed'].includes(l.status) && !(typeof l.reason === 'string' && l.reason.trim())) ctx.err('RULE_VIOLATION', `${p}.reason`, `a leg that is ${l.status} must say why`);
|
|
188
|
+
if (l.status === 'passed' && !l.evidenceIds?.length) ctx.err('RULE_VIOLATION', `${p}.evidenceIds`, 'a leg that passed must cite the evidence of the run');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!Array.isArray(record.requiredLegs) || record.requiredLegs.some((n) => !LEG_NAMES.includes(n))) ctx.err('BAD_TYPE', 'requiredLegs', 'must list leg names');
|
|
192
|
+
else {
|
|
193
|
+
if (new Set(record.requiredLegs).size !== record.requiredLegs.length) ctx.err('DUPLICATE_ID', 'requiredLegs', 'a leg is required once');
|
|
194
|
+
if (!record.requiredLegs.includes('securityAfter')) ctx.err('RULE_VIOLATION', 'requiredLegs', 'securityAfter is always required: a patch that does not show the weakness gone fixes nothing');
|
|
195
|
+
for (const n of record.requiredLegs) if (record[n]?.status === 'not_applicable') ctx.err('RULE_VIOLATION', n, 'a required leg cannot be not_applicable; drop it from requiredLegs with a reason on the leg instead');
|
|
196
|
+
}
|
|
197
|
+
if (!Array.isArray(record.failures)) ctx.err('BAD_TYPE', 'failures', 'must be a list');
|
|
198
|
+
|
|
199
|
+
if (record.requiredLegs && Array.isArray(record.failures) && LEG_NAMES.every((n) => isPlainObject(record[n]))) {
|
|
200
|
+
const expectFull = deriveVerifiedFull(record);
|
|
201
|
+
if (record.verifiedFull !== expectFull) ctx.err('RULE_VIOLATION', 'verifiedFull', `verifiedFull is ${record.verifiedFull} but the legs imply ${expectFull}`);
|
|
202
|
+
const expectDegraded = degradedLegsOf(record);
|
|
203
|
+
if (JSON.stringify(record.degradedLegs) !== JSON.stringify(expectDegraded)) ctx.err('RULE_VIOLATION', 'degradedLegs', 'does not list exactly the required legs that did not pass');
|
|
204
|
+
if (record.fixCompleteness === 'FULL' && !expectFull) ctx.err('RULE_VIOLATION', 'fixCompleteness', 'FULL requires verifiedFull');
|
|
205
|
+
if (record.fixCompleteness === 'FULL' && record.securityAfter.status !== 'passed') ctx.err('RULE_VIOLATION', 'fixCompleteness', 'FULL requires securityAfter to have passed');
|
|
206
|
+
if (['FULL', 'MITIGATION', 'WORKAROUND'].includes(record.fixCompleteness) && record.securityAfter.status !== 'passed') ctx.err('RULE_VIOLATION', 'fixCompleteness', `${record.fixCompleteness} requires securityAfter to have passed`);
|
|
207
|
+
if (record.fixCompleteness !== 'blocked' && record.requiredLegs.some((n) => record[n].status === 'blocked')) ctx.err('RULE_VIOLATION', 'fixCompleteness', 'a blocked required leg makes the completeness blocked');
|
|
208
|
+
if (!isPlainObject(record.coverage)) ctx.err('BAD_TYPE', 'coverage', 'must be an object');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const rr = record.residualRisk;
|
|
212
|
+
if (!isPlainObject(rr)) ctx.err('BAD_TYPE', 'residualRisk', 'must be an object');
|
|
213
|
+
else { checkEnum(ctx, 'residualRisk.level', rr.level, RESIDUAL_LEVELS); if (!Array.isArray(rr.notes)) ctx.err('BAD_TYPE', 'residualRisk.notes', 'must be a list'); }
|
|
214
|
+
const c = record.confidence;
|
|
215
|
+
if (!isPlainObject(c)) ctx.err('BAD_TYPE', 'confidence', 'must be an object');
|
|
216
|
+
else {
|
|
217
|
+
checkEnum(ctx, 'confidence.level', c.level, CONFIDENCE_LEVELS);
|
|
218
|
+
if (c.level === 'proven') {
|
|
219
|
+
if (!(typeof c.policy === 'string' && c.policy)) ctx.err('RULE_VIOLATION', 'confidence.policy', 'proven confidence needs a named proof policy');
|
|
220
|
+
if (!Array.isArray(c.evidenceIds) || !c.evidenceIds.length) ctx.err('RULE_VIOLATION', 'confidence.evidenceIds', 'proven confidence needs evidence');
|
|
221
|
+
else for (const id of c.evidenceIds) if (!evidenceIds.has(id)) ctx.err('DANGLING_REF', 'confidence.evidenceIds', `cites ${id}, which is not in evidence`);
|
|
222
|
+
if (record.exploitProofAfter?.status !== 'passed') ctx.err('RULE_VIOLATION', 'confidence.level', 'proven requires the exploit replay leg to have passed against the patched code');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (!isPlainObject(record.tools)) ctx.err('BAD_TYPE', 'tools', 'must be an object');
|
|
226
|
+
if (typeof record.id === 'string' && LEG_NAMES.every((n) => isPlainObject(record[n])) && Array.isArray(record.affectedFiles) && Array.isArray(record.requiredLegs)) {
|
|
227
|
+
const want = fixRecordId(record);
|
|
228
|
+
if (record.id !== want) ctx.err('ID_MISMATCH', 'id', `id does not follow from the record's content (expected ${want})`);
|
|
229
|
+
}
|
|
230
|
+
return result(ctx);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The shape every pre-existing consumer reads: ok, verified, verifiedFull, degradedLegs. Additive fields are not included. */
|
|
234
|
+
export function legacyViewOf(record) {
|
|
235
|
+
const failedAny = LEG_NAMES.some((n) => record[n].status === 'failed') || record.failures.length > 0;
|
|
236
|
+
return {
|
|
237
|
+
ok: !failedAny && record.securityAfter.status === 'passed',
|
|
238
|
+
verified: !failedAny && record.securityAfter.status === 'passed',
|
|
239
|
+
verifiedFull: record.verifiedFull,
|
|
240
|
+
degradedLegs: [...record.degradedLegs],
|
|
241
|
+
completeness: record.fixCompleteness,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The one verifier every patch route calls (E3-03).
|
|
2
|
+
//
|
|
3
|
+
// Routes used to call verifyFix themselves, with different inputs (only verify_fix looked up the exploit PoC; one branch swapped the
|
|
4
|
+
// verifier when an env flag was set), and each shaped the result its own way. verifyPatch runs verifyFix ONCE with one set of inputs and
|
|
5
|
+
// returns the raw verdict beside the canonical patch-level record, so identical patch and settings give the same record id on every route.
|
|
6
|
+
//
|
|
7
|
+
// Route and mode are NOT part of the record id (see FIX_RECORD_ID_FIELDS): they say who asked, not what was established.
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { verifyFix } from '../posture/fix-verify.js';
|
|
11
|
+
import { verifyLastScan } from '../posture/integrity.js';
|
|
12
|
+
import { stateDir } from '../posture/state-dir.js';
|
|
13
|
+
import { recordFromVerifyFix } from './verification-adapters.js';
|
|
14
|
+
import { replayExploit } from './exploit-replay.js';
|
|
15
|
+
import { checkFootprint } from './footprint.js';
|
|
16
|
+
import { checkSecretPersistence } from './secret-persistence.js';
|
|
17
|
+
import { runTestsOnPatchedCopy } from './test-commands.js';
|
|
18
|
+
import { legacyViewOf } from './verification-record.js';
|
|
19
|
+
|
|
20
|
+
const REQUIRED_LEGS = Object.freeze(['securityAfter', 'newFindingDelta', 'behaviorPreservation', 'behaviorTests']);
|
|
21
|
+
|
|
22
|
+
function readOriginal(scanRoot) {
|
|
23
|
+
return (rel) => { try { return fs.readFileSync(path.join(scanRoot, rel), 'utf8'); } catch { return null; } };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The scan, only when its signature verifies: a stored PoC is code that will be run, so an unsigned scan supplies none. */
|
|
27
|
+
function readSignedScan(scanRoot) {
|
|
28
|
+
const file = path.join(stateDir(scanRoot), 'last-scan.json');
|
|
29
|
+
const body = fs.readFileSync(file, 'utf8');
|
|
30
|
+
return verifyLastScan(body, `${file}.sig`) === true ? JSON.parse(body) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function lookupFinding(scanRoot, stableId, readScan) {
|
|
34
|
+
try {
|
|
35
|
+
const scan = (readScan || readSignedScan)(scanRoot);
|
|
36
|
+
return (scan?.findings || []).find((f) => f.stableId === stableId) || null;
|
|
37
|
+
} catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {{scanRoot:string, finding:{id?:string,stableId:string}, files:Record<string,string>, route:string, mode?:'preview'|'verify'|'apply',
|
|
42
|
+
* fixMeta?:object, poc?:object|null, readScan?:(root:string)=>object|null, verify?:Function, extra?:object}} o
|
|
43
|
+
* @returns {Promise<{verdict:object, fixVerification:object, canonical:object}>}
|
|
44
|
+
*/
|
|
45
|
+
export async function verifyPatch(o) {
|
|
46
|
+
const { scanRoot, finding, files, route, mode = 'verify', fixMeta, readScan, extra } = o;
|
|
47
|
+
const run = o.verify || verifyFix;
|
|
48
|
+
const stored = lookupFinding(scanRoot, finding.stableId, readScan);
|
|
49
|
+
// One PoC rule for every route: the stored one for this finding, unless the caller supplied its own.
|
|
50
|
+
const poc = o.poc !== undefined ? o.poc : (stored?.poc?.code ? { ...stored.poc, finding: stored } : null);
|
|
51
|
+
// An explicit local reproducer turns the replay on for every route alike; without one the leg is not_run and says so.
|
|
52
|
+
const replay = o.reproducer ? await replayExploit({ scanRoot, reproducer: o.reproducer, files, attempts: o.replayAttempts, timeoutMs: o.replayTimeoutMs, attemptFn: o.attemptFn }) : null;
|
|
53
|
+
// The tests run on a copy WITH the patch applied. A suite that was already red says nothing about the patch, so it is reported as such.
|
|
54
|
+
const runTests = () => {
|
|
55
|
+
const t = runTestsOnPatchedCopy({ scanRoot, files, command: o.testCommand || fixMeta?.testCommand, timeoutMs: o.testTimeoutMs });
|
|
56
|
+
return t.preExistingFailure ? { ...t, status: 'skipped', passed: null, skipped: true, reason: 'suite-already-failing-before-the-patch' } : t;
|
|
57
|
+
};
|
|
58
|
+
const read = readOriginal(scanRoot);
|
|
59
|
+
const footprint = checkFootprint({ before: Object.fromEntries(Object.keys(files).map((r) => [r, read(r)])), after: files, cwe: stored?.cwe || o.cwe || null, intentionalRemoval: fixMeta?.intentionalRemoval || null });
|
|
60
|
+
// A secret finding is only fixed when the secret is gone from the source, not when it was re-encoded.
|
|
61
|
+
const isSecret = /CWE-(798|259|321|312)/i.test(String(stored?.cwe || o.cwe || ''));
|
|
62
|
+
const persistence = isSecret && stored?.file && files[stored.file] !== undefined ? checkSecretPersistence({ before: read(stored.file), after: files[stored.file], line: stored.line }) : null;
|
|
63
|
+
let verdict = await run({ scanRoot, originalFindingStableId: finding.stableId, files, fixMeta, runTests, ...(poc ? { poc } : {}), ...(extra || {}) });
|
|
64
|
+
// The finding disappearing is not enough: a patch that removed the feature is rejected here, for every route at once.
|
|
65
|
+
if (persistence?.status === 'failed' && verdict.ok) verdict = { ...verdict, ok: false, rejectedBy: 'secret-persists', summary: `${persistence.reason}\n${verdict.summary || ''}`.trim() };
|
|
66
|
+
if (footprint.status === 'failed' && verdict.ok) verdict = { ...verdict, ok: false, rejectedBy: 'behavior-preservation', summary: `${footprint.reason}\n${verdict.summary || ''}`.trim() };
|
|
67
|
+
const fixVerification = recordFromVerifyFix(verdict, {
|
|
68
|
+
finding: { id: finding.id ?? stored?.id ?? finding.stableId, stableId: finding.stableId },
|
|
69
|
+
files, route, mode, findingPresent: !!(stored || finding.present), preFiles: readOriginal(scanRoot),
|
|
70
|
+
requiredLegs: replay ? [...REQUIRED_LEGS, 'exploitProofAfter'] : REQUIRED_LEGS, replay, footprint, persistence,
|
|
71
|
+
});
|
|
72
|
+
const canonical = legacyViewOf(fixVerification);
|
|
73
|
+
// The record is the authority: a failed leg (a surviving exploit, tests that fail on the patched tree) rejects the patch on every route.
|
|
74
|
+
if (!canonical.ok && verdict.ok) {
|
|
75
|
+
const failed = ['securityAfter', 'exploitProofAfter', 'newFindingDelta', 'behaviorTests', 'behaviorPreservation', 'lint'].filter((n) => fixVerification[n].status === 'failed');
|
|
76
|
+
verdict = { ...verdict, ok: false, rejectedBy: verdict.rejectedBy || `leg:${failed[0] || 'failed'}`, summary: `${failed.map((n) => `${n}: ${fixVerification[n].reason}`).join('; ') || 'a verification leg failed'}\n${verdict.summary || ''}`.trim() };
|
|
77
|
+
}
|
|
78
|
+
return { verdict, fixVerification, canonical, replay, footprint, persistence };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The additive fields every route puts on its result. Existing fields are untouched. */
|
|
82
|
+
export function additiveFields(fixVerification) {
|
|
83
|
+
return {
|
|
84
|
+
fixVerification,
|
|
85
|
+
fixCompleteness: fixVerification.fixCompleteness,
|
|
86
|
+
verifiedFull: fixVerification.verifiedFull,
|
|
87
|
+
degradedLegs: fixVerification.degradedLegs,
|
|
88
|
+
};
|
|
89
|
+
}
|
package/src/ir/CLAUDE.md
CHANGED
|
@@ -266,3 +266,8 @@ down enterprise runners sometimes don't. Targets for retirement:
|
|
|
266
266
|
every fallback to the regex parser is now also a silent loss of Python
|
|
267
267
|
interprocedural analysis, not just the older, vaguer "some constructs get
|
|
268
268
|
dropped" cost.
|
|
269
|
+
|
|
270
|
+
## Semantic graph (`semantic-graph.js`, engineering-excellence E2-01)
|
|
271
|
+
|
|
272
|
+
An additive, typed, versioned view over this IR: `Symbol`, `CallSite`, `Allocation`, `FieldAccess`, `ValueOrigin`, plus the schema-only lists `ServiceBoundary`, `StorageEdge`, `Sanitizer`, `Guard`. Ids are content-derived and never include a line, so a blank line above a function moves no id. It reads the CFG (the one structure every language's IR shares; `writes` exists for JavaScript only). Nothing on the scan path imports it, so findings and `chain[]` cannot change; a test pins that. Columns are `null` (the IR has lines only) and say `locationPrecision: "line"`. Contract and the measured per-language construct matrix: `docs/engineering-excellence/IR_CONTRACT.md` (regenerate with `node scripts/engineering-excellence/ir-matrix.mjs`). Do not add a `site` field: IR site ids come from a process-global counter and are not stable across builds.
|
|
273
|
+
|
package/src/ir/callgraph.js
CHANGED
|
@@ -28,6 +28,8 @@ export function functionRecord(callGraph, resolved) {
|
|
|
28
28
|
return fns.get(resolved) || null;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
import { buildModuleIndex } from './module-resolver.js';
|
|
32
|
+
|
|
31
33
|
export function buildCallGraph(perFileIR, fileContents) {
|
|
32
34
|
const functions = new Map();
|
|
33
35
|
const byNameInFile = new Map();
|
|
@@ -186,11 +188,26 @@ export function buildCallGraph(perFileIR, fileContents) {
|
|
|
186
188
|
return null;
|
|
187
189
|
}
|
|
188
190
|
|
|
191
|
+
// E2-02: import-aware resolution. A call whose head identifier is an import binding is resolved by following the program's own
|
|
192
|
+
// import/export statements (exact, bounded, cycle-safe). It never throws: a failure to build the index leaves the older name-based
|
|
193
|
+
// resolution exactly as it was.
|
|
194
|
+
let moduleIndex = null;
|
|
195
|
+
if (fileContents) { try { moduleIndex = buildModuleIndex(fileContents, perFileIR); } catch { moduleIndex = null; } }
|
|
196
|
+
const viaImport = (callerFile, name) => {
|
|
197
|
+
if (!moduleIndex || !callerFile) return null;
|
|
198
|
+
try { return moduleIndex.resolveCall(callerFile, name); } catch { return null; }
|
|
199
|
+
};
|
|
200
|
+
|
|
189
201
|
// Resolve each call site.
|
|
190
202
|
const edges = []; // { caller, site, callee, ambiguous? }
|
|
191
203
|
for (const fn of functions.values()) {
|
|
192
204
|
for (const c of (fn.calls || [])) {
|
|
193
205
|
if (!c.callee) { edges.push({ caller: fn.qid, site: c.site, callee: null, line: c.line }); continue; }
|
|
206
|
+
// 0. Import binding (E2-02): explicit evidence beats every name-based rule below. A binding to an external package or builtin resolves
|
|
207
|
+
// to nothing, so a same-named function elsewhere in the project can no longer be taken for it.
|
|
208
|
+
const imp = viaImport(fn.file, c.callee);
|
|
209
|
+
if (imp && imp.status === 'resolved') { edges.push({ caller: fn.qid, site: c.site, callee: imp.qid, calleeName: c.callee, line: c.line, via: 'import', hops: imp.hops }); continue; }
|
|
210
|
+
if (imp && imp.status === 'external') { edges.push({ caller: fn.qid, site: c.site, callee: null, calleeName: c.callee, line: c.line, external: true }); continue; }
|
|
194
211
|
// 1. Direct name in same file
|
|
195
212
|
const sameFileMap = byNameInFile.get(fn.file);
|
|
196
213
|
const resolved = sameFileMap?.get(c.callee) ||
|
|
@@ -284,6 +301,11 @@ export function buildCallGraph(perFileIR, fileContents) {
|
|
|
284
301
|
// because the guard lived only in that one file's helper instead of here).
|
|
285
302
|
function _resolveImpl(name, callerFile, allowTailGuess) {
|
|
286
303
|
if (!name || typeof name !== 'string') return null;
|
|
304
|
+
{
|
|
305
|
+
const imp = viaImport(callerFile, name);
|
|
306
|
+
if (imp && imp.status === 'resolved') return imp.qid;
|
|
307
|
+
if (imp && imp.status === 'external') return null;
|
|
308
|
+
}
|
|
287
309
|
// Roadmap #3: same-file preference. A bare name (`handler`, `save`,
|
|
288
310
|
// `query`) defined in several files would otherwise resolve to whichever
|
|
289
311
|
// file Map is iterated first, mis-targeting interprocedural taint to a
|
|
@@ -363,5 +385,5 @@ export function buildCallGraph(perFileIR, fileContents) {
|
|
|
363
385
|
function resolveKnownCallee(name, callerFile) {
|
|
364
386
|
return _resolveImpl(name, callerFile, false);
|
|
365
387
|
}
|
|
366
|
-
return { functions, edges, callersOf, resolve, resolveKnownCallee };
|
|
388
|
+
return { functions, edges, callersOf, resolve, resolveKnownCallee, moduleIndex };
|
|
367
389
|
}
|