@clear-capabilities/agentic-security-scanner 0.174.0 → 0.176.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 +23 -0
- package/bin/agentic-security.js +199 -28
- package/dist/2066.index.js +850 -0
- package/dist/2199.index.js +1 -1
- package/dist/2584.index.js +3332 -0
- package/dist/2923.index.js +13 -0
- package/dist/2946.index.js +81 -0
- package/dist/3196.index.js +27 -0
- package/dist/3727.index.js +292 -0
- package/dist/4168.index.js +509 -0
- package/dist/4281.index.js +993 -0
- package/dist/4978bec8fdf5c733dc85.json +283 -0
- package/dist/5099.index.js +96 -0
- package/dist/5616.index.js +1471 -0
- package/dist/5620.index.js +2370 -0
- package/dist/5817.index.js +714 -0
- package/dist/6333.index.js +98 -0
- package/dist/6655.index.js +3221 -0
- package/dist/7838.index.js +446 -974
- package/dist/818.index.js +4 -3
- package/dist/83.index.js +159 -0
- package/dist/8551.index.js +82 -0
- package/dist/8633.index.js +93 -0
- package/dist/8714.index.js +76 -76
- package/dist/8825.index.js +84 -0
- package/dist/932.index.js +186 -0
- package/dist/9560.index.js +358 -19
- package/dist/9800.index.js +6 -1
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/df1e8683e7eda7b2b79c.js +29 -0
- package/package.json +7 -6
- package/src/artifact/CLAUDE.md +4 -0
- package/src/artifact/artifact-evidence.js +63 -0
- package/src/artifact/cosign.js +61 -0
- package/src/artifact/engine-source.js +72 -0
- package/src/artifact/oci-layout.js +47 -0
- package/src/artifact/registry.js +213 -0
- package/src/capabilities/tool-registry.js +2 -0
- package/src/language/haskell-security-rules.js +1 -1
- package/src/language/nixos-hardening.js +37 -0
- package/src/lineage/CLAUDE.md +4 -0
- package/src/lineage/identity-token.js +11 -0
- package/src/lineage/observation-adapters.js +13 -0
- package/src/lineage/runtime-edge-states.js +139 -0
- package/src/lineage/runtime-explorer-view.js +82 -0
- package/src/lineage/runtime-observation.js +11 -0
- package/src/mcp/CLAUDE.md +3 -1
- package/src/mcp/proof-tools.js +132 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +3 -1
- package/src/posture/CLAUDE.md +23 -0
- package/src/posture/artifact-registry.js +4 -0
- package/src/posture/dnr/attack-facts.js +31 -0
- package/src/posture/dnr/cli.js +138 -0
- package/src/posture/dnr/detection-ir.js +123 -0
- package/src/posture/dnr/exposure-window.js +134 -0
- package/src/posture/dnr/hunt.js +84 -0
- package/src/posture/dnr/log-reader.js +189 -0
- package/src/posture/dnr/matchers.js +90 -0
- package/src/posture/dnr/outputs.js +262 -0
- package/src/posture/dnr/payload-classes.js +120 -0
- package/src/posture/dnr/redact.js +25 -0
- package/src/posture/dnr/store.js +57 -0
- package/src/posture/dnr/validate.js +64 -0
- package/src/posture/dnr/view.js +51 -0
- package/src/posture/evaluation/lab/arm-ollama.js +88 -0
- package/src/posture/evaluation/lab/arms.js +142 -0
- package/src/posture/evaluation/lab/cli.js +135 -0
- package/src/posture/evaluation/lab/dataset.js +139 -0
- package/src/posture/evaluation/lab/materialize.js +44 -0
- package/src/posture/evaluation/lab/neutralize.js +144 -0
- package/src/posture/evaluation/lab/report.js +176 -0
- package/src/posture/evaluation/lab/run.js +225 -0
- package/src/posture/evaluation/lab/score.js +135 -0
- package/src/posture/evaluation/lab/scoreboard.js +48 -0
- package/src/posture/evaluation/lab/stats.js +41 -0
- package/src/posture/evaluation/lab/store.js +53 -0
- package/src/posture/fleet/cli.js +248 -63
- package/src/posture/fleet/discover-edges.js +135 -0
- package/src/posture/fleet/enterprise-graph.js +148 -0
- package/src/posture/fleet/fleet-graph.js +9 -4
- package/src/posture/fleet/fleet-manifest.js +50 -5
- package/src/posture/fleet/job-runner.js +153 -0
- package/src/posture/fleet/job-store.js +136 -0
- package/src/posture/fleet/org-health.js +56 -0
- package/src/posture/fleet/scan-cache.js +112 -0
- package/src/posture/fleet/scan-child.js +29 -0
- package/src/posture/proof-fabric/cli.js +34 -6
- package/src/posture/proof-fabric/container.js +57 -0
- package/src/posture/proof-fabric/fix-proof.js +2 -1
- package/src/posture/proof-fabric/generate-python.js +129 -0
- package/src/posture/proof-fabric/generate.js +213 -0
- package/src/posture/proof-fabric/reproducer.js +3 -0
- package/src/posture/proof-fabric/runner.js +20 -11
- package/src/posture/proof-fabric/siblings.js +77 -0
- package/src/posture/routing/policy-router.js +103 -0
- package/src/posture/rule-compiler/cli.js +64 -6
- package/src/posture/rule-compiler/corpus.js +10 -2
- package/src/posture/rule-compiler/mutation.js +72 -1
- package/src/posture/rule-compiler/promotion.js +21 -5
- package/src/posture/rule-compiler/proposal.js +132 -0
- package/src/posture/rule-compiler/store.js +16 -0
package/dist/7838.index.js
CHANGED
|
@@ -1,1082 +1,554 @@
|
|
|
1
1
|
export const id = 7838;
|
|
2
|
-
export const ids = [7838
|
|
2
|
+
export const ids = [7838];
|
|
3
3
|
export const modules = {
|
|
4
4
|
|
|
5
|
-
/***/
|
|
5
|
+
/***/ 43556:
|
|
6
6
|
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
7
|
|
|
8
8
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
|
-
/* harmony export */
|
|
9
|
+
/* harmony export */ F: () => (/* binding */ buildCorpus),
|
|
10
|
+
/* harmony export */ T: () => (/* binding */ scoreRule)
|
|
10
11
|
/* harmony export */ });
|
|
11
|
-
/*
|
|
12
|
-
|
|
12
|
+
/* harmony import */ var _mutation_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(85127);
|
|
13
|
+
/* harmony import */ var _ir_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(87539);
|
|
14
|
+
/* harmony import */ var _custom_rules_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(9049);
|
|
15
|
+
// Automatic corpus creation and scoring for ONE candidate rule.
|
|
13
16
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// be caught deterministically, with no LLM and no network:
|
|
17
|
+
// Positive corpus: the original vulnerable snippet plus its generated sibling variants (mutation.js).
|
|
18
|
+
// Negative corpus: an explicit patched/clean variant the caller supplies, nearby clean patterns, protected
|
|
19
|
+
// (sanitizer/guard) variants, and the NEGATIVE mutations of those (same mutators, applied to the safe side) — a
|
|
20
|
+
// candidate rule must decline on ALL of them, not just the one hand-picked negative.
|
|
19
21
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// without naming a concrete remaining vector. A residual you can't name
|
|
23
|
-
// is a residual you're guessing about; reject the guess.
|
|
24
|
-
//
|
|
25
|
-
// 2. An unbacked "this is a false positive / provably safe" verdict. Marking
|
|
26
|
-
// a finding safe is a coverage *reduction* — it must cite a `file:line`
|
|
27
|
-
// that shows why, exactly like the rules-override gate refuses to silently
|
|
28
|
-
// shrink coverage.
|
|
29
|
-
//
|
|
30
|
-
// Plus a conservative fix-tier classifier so a partial remediation can never be
|
|
31
|
-
// labelled FULL: any workaround-only signal (rate-limit, docs, log-without-
|
|
32
|
-
// reject) is WORKAROUND; anything short of (sink signature changed + all callers
|
|
33
|
-
// routed + a discriminating test) is at most MITIGATION; only the full set with
|
|
34
|
-
// no partial-sanitization caveat earns FULL.
|
|
35
|
-
//
|
|
36
|
-
// Pure functions, no side effects, no throwing — safe to call from a command,
|
|
37
|
-
// a hook, or the MCP verify_fix path.
|
|
38
|
-
|
|
39
|
-
// Vague-assurance phrases that a real residual must never hide behind. Matched
|
|
40
|
-
// case-insensitively with word boundaries so "later" doesn't trip on
|
|
41
|
-
// "collateral" and "tbd" doesn't trip on a longer token.
|
|
42
|
-
const BANNED_RESIDUAL_PHRASES = Object.freeze([
|
|
43
|
-
'adequately handled',
|
|
44
|
-
'adequately handles',
|
|
45
|
-
'properly validated',
|
|
46
|
-
'properly handled',
|
|
47
|
-
'handled properly',
|
|
48
|
-
'handled safely',
|
|
49
|
-
'future work',
|
|
50
|
-
'more work needed',
|
|
51
|
-
'to be done',
|
|
52
|
-
'tbd',
|
|
53
|
-
'later',
|
|
54
|
-
]);
|
|
22
|
+
// Scoring reuses the exact runtime matcher (`runRule` in `../custom-rules.js`), never a second implementation —
|
|
23
|
+
// the score a rule gets here is the score it will actually produce once promoted.
|
|
55
24
|
|
|
56
|
-
// A citation shaped like `file:line` — one or more non-space, non-colon chars,
|
|
57
|
-
// a colon, then digits. Unanchored: it need only appear somewhere in the item.
|
|
58
|
-
const CITATION_RE = /[^\s:]+:\d+/;
|
|
59
25
|
|
|
60
|
-
// Verdicts that assert the finding is not real and therefore demand a citation.
|
|
61
|
-
// Compared after normalizing separators (`_`/space → `-`) and lowercasing, so
|
|
62
|
-
// FALSE_POSITIVE, false-positive, and "provably safe" all land here.
|
|
63
|
-
const FP_VERDICTS = Object.freeze(new Set(['false-positive', 'provably-safe', 'safe']));
|
|
64
26
|
|
|
65
|
-
function _escapeRe(s) {
|
|
66
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
67
|
-
}
|
|
68
27
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
* about. A non-empty residual is rejected when it contains any banned phrase;
|
|
74
|
-
* each match yields one violation naming the offending phrase.
|
|
75
|
-
*
|
|
76
|
-
* @param {string} residualText
|
|
77
|
-
* @returns {{ ok: boolean, violations: string[] }}
|
|
78
|
-
*/
|
|
79
|
-
function checkResidualHonesty(residualText) {
|
|
80
|
-
const text = typeof residualText === 'string' ? residualText : '';
|
|
81
|
-
if (text.trim() === '') return { ok: true, violations: [] };
|
|
82
|
-
|
|
83
|
-
const violations = [];
|
|
84
|
-
for (const phrase of BANNED_RESIDUAL_PHRASES) {
|
|
85
|
-
const re = new RegExp(`\\b${_escapeRe(phrase)}\\b`, 'i');
|
|
86
|
-
if (re.test(text)) {
|
|
87
|
-
violations.push(`vague-assurance phrase: "${phrase}"`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return { ok: violations.length === 0, violations };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function _isCitation(item) {
|
|
94
|
-
if (typeof item === 'string') return CITATION_RE.test(item);
|
|
95
|
-
if (item && typeof item === 'object' && typeof item.location === 'string') {
|
|
96
|
-
return CITATION_RE.test(item.location);
|
|
97
|
-
}
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function _normalizeVerdict(verdict) {
|
|
102
|
-
return String(verdict).trim().toLowerCase().replace(/[_\s]+/g, '-');
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Require a file:line citation behind a "this is not real" verdict.
|
|
107
|
-
*
|
|
108
|
-
* For a false-positive / provably-safe / safe verdict (case-insensitive; also
|
|
109
|
-
* accepts FALSE_POSITIVE), at least one evidence item must be a `file:line`
|
|
110
|
-
* citation — either a string matching /[^\s:]+:\d+/ or an object
|
|
111
|
-
* `{ location: "file:line" }`. Any other verdict passes unconditionally.
|
|
112
|
-
*
|
|
113
|
-
* @param {string} verdict
|
|
114
|
-
* @param {Array|string|object} evidence
|
|
115
|
-
* @returns {{ ok: boolean, violations: string[] }}
|
|
116
|
-
*/
|
|
117
|
-
function requireCitedEvidence(verdict, evidence) {
|
|
118
|
-
if (typeof verdict !== 'string' || !FP_VERDICTS.has(_normalizeVerdict(verdict))) {
|
|
119
|
-
return { ok: true, violations: [] };
|
|
120
|
-
}
|
|
121
|
-
const items = Array.isArray(evidence)
|
|
122
|
-
? evidence
|
|
123
|
-
: evidence == null
|
|
124
|
-
? []
|
|
125
|
-
: [evidence];
|
|
126
|
-
if (items.some(_isCitation)) return { ok: true, violations: [] };
|
|
28
|
+
function toRuntimeRule(ir) {
|
|
29
|
+
const { ok, doc, errs } = (0,_ir_js__WEBPACK_IMPORTED_MODULE_1__/* .compileToCustomRule */ .Mo)(ir);
|
|
30
|
+
if (!ok) throw new Error(`cannot build corpus for an invalid rule IR: ${errs.join('; ')}`);
|
|
31
|
+
const regexes = doc.match.allOf ? doc.match.allOf.map((p) => new RegExp(p, 'g')) : [new RegExp(doc.match.pattern, 'g')];
|
|
127
32
|
return {
|
|
128
|
-
|
|
129
|
-
|
|
33
|
+
id: doc.id, title: doc.title, severity: doc.severity, cwe: doc.cwe, languages: doc.languages, message: doc.message,
|
|
34
|
+
regexes, requireAll: !!doc.match.allOf, windowLines: doc.match.window ?? 5,
|
|
35
|
+
notMatch: doc.match.notMatch ? new RegExp(doc.match.notMatch) : null,
|
|
130
36
|
};
|
|
131
37
|
}
|
|
132
38
|
|
|
133
|
-
|
|
134
|
-
* Classify a fix into FULL | MITIGATION | WORKAROUND, conservative-first.
|
|
135
|
-
*
|
|
136
|
-
* @param {object} signals
|
|
137
|
-
* @param {boolean} signals.sinkSignatureChanged
|
|
138
|
-
* @param {boolean} signals.allCallersRouted
|
|
139
|
-
* @param {boolean} signals.testDiscriminates - a test that fails pre-fix, passes post-fix
|
|
140
|
-
* @param {boolean} [signals.rateLimitOnly]
|
|
141
|
-
* @param {boolean} [signals.docsOnly]
|
|
142
|
-
* @param {boolean} [signals.logOnlyNoReject]
|
|
143
|
-
* @param {boolean} [signals.partialSanitization]
|
|
144
|
-
* @returns {'FULL'|'MITIGATION'|'WORKAROUND'}
|
|
145
|
-
*/
|
|
146
|
-
function computeFixTier(signals) {
|
|
147
|
-
const s = signals && typeof signals === 'object' ? signals : {};
|
|
148
|
-
if (s.rateLimitOnly || s.docsOnly || s.logOnlyNoReject) return 'WORKAROUND';
|
|
149
|
-
const complete = s.sinkSignatureChanged && s.allCallersRouted && s.testDiscriminates;
|
|
150
|
-
if (s.partialSanitization || !complete) return 'MITIGATION';
|
|
151
|
-
return 'FULL';
|
|
152
|
-
}
|
|
39
|
+
const EXT = { javascript: 'x.js', typescript: 'x.ts', python: 'x.py', go: 'x.go', ruby: 'x.rb', java: 'x.java', rust: 'x.rs', csharp: 'x.cs', php: 'x.php', any: 'x.txt' };
|
|
153
40
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
* self-reported — this module cannot compute sinkSignatureChanged /
|
|
158
|
-
* allCallersRouted / testDiscriminates itself (see the header above: "the
|
|
159
|
-
* gate can only run against claims the AGENT self-reports... nothing here
|
|
160
|
-
* is server-computable"). `pocLeg` is different: fix-verify.js's PoC leg is
|
|
161
|
-
* a REAL execution result (posture/CLAUDE.md's execution-proof tiers), not
|
|
162
|
-
* a claim. When it is available and shows the proof-of-concept STILL
|
|
163
|
-
* demonstrates the vulnerability against the patch, a self-reported FULL
|
|
164
|
-
* tier is not merely internally inconsistent — it is REFUTED by fact. This
|
|
165
|
-
* is the literal "a mitigation or workaround cannot be represented as a
|
|
166
|
-
* full fix" acceptance criterion, now backed by mechanical evidence where
|
|
167
|
-
* it exists rather than by self-report consistency alone.
|
|
168
|
-
*
|
|
169
|
-
* A `pocLeg` of `not-requested` or `inconclusive` carries no mechanical
|
|
170
|
-
* signal either way and is a no-op here — this check can only ever ADD a
|
|
171
|
-
* violation on real contrary evidence, never manufacture one from absence.
|
|
172
|
-
*
|
|
173
|
-
* @param {string} tier
|
|
174
|
-
* @param {{status: string, reason?: string}|null} pocLeg
|
|
175
|
-
* @returns {{ ok: boolean, violations: string[] }}
|
|
176
|
-
*/
|
|
177
|
-
function checkMechanicalTierEvidence(tier, pocLeg) {
|
|
178
|
-
if (!pocLeg || typeof pocLeg !== 'object') return { ok: true, violations: [] };
|
|
179
|
-
if (tier === 'FULL' && pocLeg.status === 'still-exploitable') {
|
|
180
|
-
return {
|
|
181
|
-
ok: false,
|
|
182
|
-
violations: [`tier 'FULL' is refuted by mechanical evidence: the proof-of-concept still demonstrates the vulnerability against the patch${pocLeg.reason ? ` (${pocLeg.reason})` : ''}`],
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
return { ok: true, violations: [] };
|
|
186
|
-
}
|
|
41
|
+
function buildCorpus(ir, { originalCode, patchedCode = null, nearbyCleanCode = [], protectedVariantsCode = [] } = {}) {
|
|
42
|
+
const positives = [{ label: 'original', code: originalCode }];
|
|
43
|
+
for (const v of (0,_mutation_js__WEBPACK_IMPORTED_MODULE_0__/* .generateVariants */ .G4)(originalCode)) positives.push({ label: `sibling:${v.mutator}`, code: v.code });
|
|
187
44
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
* evidence ok, further constrained by the tier/residual consistency
|
|
193
|
-
* invariant:
|
|
194
|
-
* - a FULL tier must NOT carry a residual (a full fix has nothing left);
|
|
195
|
-
* - a non-FULL tier MUST document a residual (say what's still open).
|
|
196
|
-
*
|
|
197
|
-
* @param {{ residual?: string, verdict?: string, evidence?: any, signals?: object }} input
|
|
198
|
-
* @param {{ pocLeg?: object|null }} [mechanical] - FR-308: optional real
|
|
199
|
-
* execution evidence (fix-verify.js's pocLeg) to cross-check the
|
|
200
|
-
* self-reported tier against. Omitted entirely by any caller that has no
|
|
201
|
-
* PoC leg to offer — this parameter never REQUIRES mechanical evidence,
|
|
202
|
-
* it only USES it when present.
|
|
203
|
-
* @returns {{ ok: boolean, tier: string, violations: string[] }}
|
|
204
|
-
*/
|
|
205
|
-
function gateFixOutput({ residual, verdict, evidence, signals } = {}, { pocLeg = null } = {}) {
|
|
206
|
-
const tier = computeFixTier(signals);
|
|
207
|
-
const residualCheck = checkResidualHonesty(residual);
|
|
208
|
-
const evidenceCheck = requireCitedEvidence(verdict, evidence);
|
|
209
|
-
const mechanicalCheck = checkMechanicalTierEvidence(tier, pocLeg);
|
|
210
|
-
|
|
211
|
-
const violations = [...residualCheck.violations, ...evidenceCheck.violations, ...mechanicalCheck.violations];
|
|
212
|
-
let ok = residualCheck.ok && evidenceCheck.ok && mechanicalCheck.ok;
|
|
213
|
-
|
|
214
|
-
const residualEmpty = typeof residual !== 'string' || residual.trim() === '';
|
|
215
|
-
if (tier === 'FULL' && !residualEmpty) {
|
|
216
|
-
violations.push('FULL tier cannot carry a residual');
|
|
217
|
-
ok = false;
|
|
218
|
-
}
|
|
219
|
-
if (tier !== 'FULL' && residualEmpty) {
|
|
220
|
-
violations.push('non-FULL tier must document a residual');
|
|
221
|
-
ok = false;
|
|
45
|
+
const negatives = [];
|
|
46
|
+
if (patchedCode) {
|
|
47
|
+
negatives.push({ label: 'patched', code: patchedCode });
|
|
48
|
+
for (const v of (0,_mutation_js__WEBPACK_IMPORTED_MODULE_0__/* .generateVariants */ .G4)(patchedCode)) negatives.push({ label: `patched-sibling:${v.mutator}`, code: v.code });
|
|
222
49
|
}
|
|
50
|
+
// The safe side is mutated too (rename, wrapper, equivalent API, ALTERNATE SANITIZER, framework variant, ...): a rule keyed to one
|
|
51
|
+
// sanitizer's name or one framework's accessor fires on the sibling that uses another, and that is a protected-negative failure.
|
|
52
|
+
nearbyCleanCode.forEach((code, i) => {
|
|
53
|
+
negatives.push({ label: `nearby-clean-${i}`, code });
|
|
54
|
+
for (const v of (0,_mutation_js__WEBPACK_IMPORTED_MODULE_0__/* .generateVariants */ .G4)(code)) negatives.push({ label: `nearby-clean-${i}-sibling:${v.mutator}`, code: v.code });
|
|
55
|
+
});
|
|
56
|
+
protectedVariantsCode.forEach((code, i) => {
|
|
57
|
+
negatives.push({ label: `protected-${i}`, code });
|
|
58
|
+
for (const v of (0,_mutation_js__WEBPACK_IMPORTED_MODULE_0__/* .generateVariants */ .G4)(code)) negatives.push({ label: `protected-${i}-sibling:${v.mutator}`, code: v.code });
|
|
59
|
+
});
|
|
223
60
|
|
|
224
|
-
return {
|
|
61
|
+
return { positives, negatives };
|
|
225
62
|
}
|
|
226
63
|
|
|
227
|
-
|
|
64
|
+
function scoreRule(ir, corpus) {
|
|
65
|
+
const rule = toRuntimeRule(ir);
|
|
66
|
+
const lang = ir.languages[0] || 'javascript';
|
|
67
|
+
const file = EXT[lang] || 'x.txt';
|
|
68
|
+
const run = (code) => (0,_custom_rules_js__WEBPACK_IMPORTED_MODULE_2__/* .runRule */ .OA)(rule, file, code).length > 0;
|
|
69
|
+
|
|
70
|
+
const posResults = corpus.positives.map((p) => ({ ...p, fired: run(p.code) }));
|
|
71
|
+
const negResults = corpus.negatives.map((n) => ({ ...n, fired: run(n.code) }));
|
|
72
|
+
|
|
73
|
+
const tp = posResults.filter((p) => p.fired).length;
|
|
74
|
+
const fn = posResults.length - tp;
|
|
75
|
+
const fp = negResults.filter((n) => n.fired).length;
|
|
76
|
+
const tn = negResults.length - fp;
|
|
77
|
+
const precision = tp + fp === 0 ? null : tp / (tp + fp);
|
|
78
|
+
const recall = corpus.positives.length === 0 ? null : tp / corpus.positives.length;
|
|
79
|
+
// "Protected negative failures" per PRD 3: a rule firing on a sanitizer/guard-carrying variant specifically.
|
|
80
|
+
const protectedFailures = negResults.filter((n) => n.label.startsWith('protected-') && n.fired).length;
|
|
81
|
+
|
|
82
|
+
return { precision, recall, tp, fp, tn, fn, protectedFailures, positives: posResults, negatives: negResults };
|
|
83
|
+
}
|
|
228
84
|
|
|
229
85
|
|
|
230
86
|
/***/ }),
|
|
231
87
|
|
|
232
|
-
/***/
|
|
88
|
+
/***/ 87539:
|
|
233
89
|
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
234
90
|
|
|
235
91
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
236
|
-
/* harmony export */
|
|
237
|
-
/* harmony export */
|
|
238
|
-
/* harmony export */
|
|
239
|
-
/* harmony export */
|
|
92
|
+
/* harmony export */ GE: () => (/* binding */ ruleIRDigest),
|
|
93
|
+
/* harmony export */ Mo: () => (/* binding */ compileToCustomRule),
|
|
94
|
+
/* harmony export */ UQ: () => (/* binding */ RULE_STATES),
|
|
95
|
+
/* harmony export */ g: () => (/* binding */ validateRuleIR),
|
|
96
|
+
/* harmony export */ pc: () => (/* binding */ draftRuleIR)
|
|
240
97
|
/* harmony export */ });
|
|
241
|
-
/*
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
// `verifyFix` already RUNS the stages and `test-runner.js` already times the
|
|
248
|
-
// slowest one. What did not exist was anything durable to read afterwards, so
|
|
249
|
-
// "how long does a fix actually take to validate" had no answer from real runs
|
|
250
|
-
// — only an estimate (`time-to-fix.js` guesses engineering hours from family
|
|
251
|
-
// and patch shape, before anything runs). This module is the opposite: it
|
|
252
|
-
// records what the pipeline observed and reports the distribution.
|
|
253
|
-
//
|
|
254
|
-
// THE HONESTY RULES, which are most of why this file is longer than a mean:
|
|
98
|
+
/* harmony import */ var _proof_fabric_reproducer_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(37055);
|
|
99
|
+
// Rule intermediate representation: compiles DOWN into the existing pattern-rule engine
|
|
100
|
+
// (`../custom-rules.js`), never a parallel matcher. "Compile" here means "emit a
|
|
101
|
+
// custom-rules.js-compatible document" — detection at runtime is 100% the already-proven
|
|
102
|
+
// regex/allOf/notMatch/window engine; this module only adds PROVENANCE metadata around it.
|
|
255
103
|
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
104
|
+
// A rule's match semantics are deliberately the same closed vocabulary custom-rules.js
|
|
105
|
+
// already supports (pattern | allOf+window | notMatch) — no new matching primitive is
|
|
106
|
+
// introduced, so a promoted rule is exactly as auditable as a hand-written one.
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
const RULE_IR_SCHEMA = 'agentic-security/rule-ir';
|
|
110
|
+
const RULE_IR_VERSION = 1;
|
|
111
|
+
const RULE_STATES = Object.freeze(['DRAFT', 'CANDIDATE', 'VALIDATED', 'PROMOTED', 'DEPRECATED', 'ROLLED_BACK']);
|
|
112
|
+
const VALID_LANGS = new Set(['javascript', 'typescript', 'python', 'go', 'ruby', 'java', 'rust', 'csharp', 'php', 'any']);
|
|
113
|
+
|
|
114
|
+
const isStr = (v) => typeof v === 'string' && v.length > 0;
|
|
115
|
+
|
|
116
|
+
/** Problems with a draft rule IR; an empty list means it is well-formed (not yet validated/promoted). */
|
|
117
|
+
function validateRuleIR(ir) {
|
|
118
|
+
const errs = [];
|
|
119
|
+
if (!ir || typeof ir !== 'object') return ['rule IR must be an object'];
|
|
120
|
+
if (ir.schema !== RULE_IR_SCHEMA) errs.push('unknown schema');
|
|
121
|
+
if (ir.version !== RULE_IR_VERSION) errs.push('unsupported rule IR version');
|
|
122
|
+
if (!isStr(ir.id) || !/^[a-z][a-z0-9-]{2,79}$/.test(ir.id)) errs.push('id must be a lowercase kebab-case string');
|
|
123
|
+
if (!isStr(ir.title)) errs.push('title required');
|
|
124
|
+
if (!['critical', 'high', 'medium', 'low'].includes(ir.severity)) errs.push('severity must be critical|high|medium|low');
|
|
125
|
+
if (!isStr(ir.cwe) || !/^CWE-\d+$/.test(ir.cwe)) errs.push('cwe must look like CWE-###');
|
|
126
|
+
if (!isStr(ir.family)) errs.push('family required');
|
|
127
|
+
if (!Array.isArray(ir.languages) || !ir.languages.length || !ir.languages.every((l) => VALID_LANGS.has(l))) errs.push('languages must be a non-empty array from the supported set');
|
|
128
|
+
const m = ir.match;
|
|
129
|
+
if (!m || typeof m !== 'object') errs.push('match required');
|
|
130
|
+
else {
|
|
131
|
+
const hasPattern = isStr(m.pattern);
|
|
132
|
+
const hasAllOf = Array.isArray(m.allOf) && m.allOf.length > 0 && m.allOf.every(isStr);
|
|
133
|
+
if (!hasPattern && !hasAllOf) errs.push('match needs a pattern or a non-empty allOf');
|
|
134
|
+
if (m.notMatch != null && !isStr(m.notMatch)) errs.push('notMatch must be a string');
|
|
135
|
+
if (m.window != null && !(Number.isInteger(m.window) && m.window > 0 && m.window <= 200)) errs.push('window must be an integer in 1..200');
|
|
136
|
+
for (const re of [m.pattern, ...(m.allOf || []), m.notMatch].filter(isStr)) {
|
|
137
|
+
try { new RegExp(re); } catch { errs.push(`invalid regex: ${re}`); }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Declarative-only hint fields (sink/source/propagator/sanitizer/dominatingGuard/frameworkConstraint): free text,
|
|
141
|
+
// never executable, carried for provenance/review only and NOT consulted by the matcher.
|
|
142
|
+
for (const k of ['sink', 'source', 'propagator', 'sanitizer', 'dominatingGuard', 'frameworkConstraint']) {
|
|
143
|
+
if (ir[k] != null && !isStr(ir[k])) errs.push(`${k} must be a string when present`);
|
|
144
|
+
}
|
|
145
|
+
if (ir.exclusions != null && !(Array.isArray(ir.exclusions) && ir.exclusions.every(isStr))) errs.push('exclusions must be an array of strings');
|
|
146
|
+
if (!ir.provenance || typeof ir.provenance !== 'object') errs.push('provenance required');
|
|
147
|
+
else {
|
|
148
|
+
if (!isStr(ir.provenance.originatingFindingId)) errs.push('provenance.originatingFindingId required');
|
|
149
|
+
if (ir.provenance.originatingCommit != null && !isStr(ir.provenance.originatingCommit)) errs.push('provenance.originatingCommit must be a string');
|
|
150
|
+
if (!['execution-proven', 'taint-proven', 'deterministic-corroboration'].includes(ir.provenance.evidenceState)) errs.push('provenance.evidenceState must be execution-proven|taint-proven|deterministic-corroboration');
|
|
151
|
+
}
|
|
152
|
+
return errs;
|
|
153
|
+
}
|
|
280
154
|
|
|
155
|
+
function ruleIRDigest(ir) {
|
|
156
|
+
const { state: _s, ...rest } = ir;
|
|
157
|
+
return (0,_proof_fabric_reproducer_js__WEBPACK_IMPORTED_MODULE_0__/* .sha256 */ .sc)(JSON.stringify(rest, Object.keys(rest).sort()));
|
|
158
|
+
}
|
|
281
159
|
|
|
160
|
+
/** Build a DRAFT rule IR. Caller supplies the candidate pattern — this module never invents one. */
|
|
161
|
+
function draftRuleIR({ id, title, severity, cwe, family, languages, match, remediation, originatingFindingId, originatingCommit, evidenceState, model = null, promptConfigVersion = null, ...hints }) {
|
|
162
|
+
const ir = {
|
|
163
|
+
schema: RULE_IR_SCHEMA, version: RULE_IR_VERSION, id, title, severity, cwe, family, languages, match,
|
|
164
|
+
remediation: remediation ?? null, state: 'DRAFT',
|
|
165
|
+
...hints,
|
|
166
|
+
provenance: { originatingFindingId, originatingCommit: originatingCommit ?? null, evidenceState, model, promptConfigVersion },
|
|
167
|
+
};
|
|
168
|
+
return ir;
|
|
169
|
+
}
|
|
282
170
|
|
|
283
|
-
|
|
171
|
+
/** Compile a rule IR into a custom-rules.js-compatible document. Caller writes the YAML. */
|
|
172
|
+
function compileToCustomRule(ir) {
|
|
173
|
+
const errs = validateRuleIR(ir);
|
|
174
|
+
if (errs.length) return { ok: false, errs };
|
|
175
|
+
const doc = {
|
|
176
|
+
id: `rule-compiler/${ir.id}`,
|
|
177
|
+
title: ir.title,
|
|
178
|
+
severity: ir.severity,
|
|
179
|
+
cwe: ir.cwe,
|
|
180
|
+
languages: ir.languages,
|
|
181
|
+
match: { ...(ir.match.pattern ? { pattern: ir.match.pattern } : {}), ...(ir.match.allOf ? { allOf: ir.match.allOf, window: ir.match.window ?? 5 } : {}), ...(ir.match.notMatch ? { notMatch: ir.match.notMatch } : {}) },
|
|
182
|
+
message: ir.remediation || `${ir.title} (${ir.cwe}, compiled from ${ir.provenance.originatingFindingId})`,
|
|
183
|
+
};
|
|
184
|
+
return { ok: true, doc };
|
|
185
|
+
}
|
|
284
186
|
|
|
285
|
-
// Below this many samples a percentile is an artifact of the sample, not a
|
|
286
|
-
// property of the pipeline. Reported anyway (hiding it invites re-deriving it
|
|
287
|
-
// wrong downstream) but flagged, so a caller cannot quote it as settled.
|
|
288
|
-
const RELIABLE_N = 10;
|
|
289
187
|
|
|
290
|
-
|
|
291
|
-
// the summariser cannot drift apart on stage naming.
|
|
292
|
-
const FIX_STAGES = Object.freeze(['rescan', 'lint', 'tests', 'honesty', 'poc']);
|
|
188
|
+
/***/ }),
|
|
293
189
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
190
|
+
/***/ 85127:
|
|
191
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
297
192
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
193
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
194
|
+
/* harmony export */ G4: () => (/* binding */ generateVariants),
|
|
195
|
+
/* harmony export */ xv: () => (/* binding */ mutatorCoverage)
|
|
196
|
+
/* harmony export */ });
|
|
197
|
+
/* unused harmony exports variableRename, wrapInFunction, equivalentApi, alternateSanitizer, frameworkVariant, reorderStatements, MUTATOR_NAMES */
|
|
198
|
+
// Bounded structural mutation for ONE candidate rule's own corpus — not bench/mutation's engine-wide verdict-flip
|
|
199
|
+
// harness (a different scope: the whole deterministic+taint engine against curated adversarial cases). This module
|
|
200
|
+
// exists only to answer PRD 3's "unseen sibling" question for a single rule: does the candidate still fire on a
|
|
201
|
+
// structurally different variant of the same vulnerable shape, and does it still decline on a structurally
|
|
202
|
+
// different variant of a mitigated shape.
|
|
203
|
+
//
|
|
204
|
+
// Mutations are SYNTACTIC, deterministic, seed-free and language-agnostic: variable rename, wrap-in-function,
|
|
205
|
+
// reorder-adjacent-statements, insert-indirection (a pass-through local variable). No semantics are changed on
|
|
206
|
+
// purpose — a mutation that silently changed behavior would make a "same vulnerability, different shape" claim
|
|
207
|
+
// false.
|
|
208
|
+
const RESERVED = new Set(['if', 'else', 'for', 'while', 'return', 'function', 'const', 'let', 'var', 'import', 'from', 'def', 'class', 'req', 'res']);
|
|
209
|
+
|
|
210
|
+
function identifiers(code) {
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
for (const m of code.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\b/g)) if (!RESERVED.has(m[1]) && !/^[A-Z_]+$/.test(m[1])) seen.add(m[1]);
|
|
213
|
+
return [...seen];
|
|
316
214
|
}
|
|
317
215
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
*/
|
|
322
|
-
function loadFixAttempts(scanRoot) {
|
|
323
|
-
try {
|
|
324
|
-
const raw = node_fs__WEBPACK_IMPORTED_MODULE_0__.readFileSync(_logPath(scanRoot), 'utf8');
|
|
325
|
-
const out = [];
|
|
326
|
-
for (const line of raw.split('\n')) {
|
|
327
|
-
if (!line.trim()) continue;
|
|
328
|
-
try {
|
|
329
|
-
const rec = JSON.parse(line);
|
|
330
|
-
if (rec && typeof rec === 'object' && typeof rec.totalMs === 'number') out.push(rec);
|
|
331
|
-
} catch { /* torn or hand-edited line — drop it, keep the rest */ }
|
|
332
|
-
}
|
|
333
|
-
return out;
|
|
334
|
-
} catch { return []; }
|
|
216
|
+
function renameOne(code, from, to) {
|
|
217
|
+
if (!from || code.includes(to)) return null;
|
|
218
|
+
return code.replace(new RegExp(`\\b${from}\\b`, 'g'), to);
|
|
335
219
|
}
|
|
336
220
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const rank = Math.ceil((p / 100) * sorted.length);
|
|
343
|
-
return sorted[Math.min(sorted.length - 1, Math.max(0, rank - 1))];
|
|
221
|
+
function variableRename(code, { suffix = '_v2' } = {}) {
|
|
222
|
+
const ids = identifiers(code);
|
|
223
|
+
const target = ids.find((id) => id.length > 1);
|
|
224
|
+
if (!target) return null;
|
|
225
|
+
return renameOne(code, target, `${target}${suffix}`);
|
|
344
226
|
}
|
|
345
227
|
|
|
346
|
-
function
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
return {
|
|
351
|
-
n: v.length,
|
|
352
|
-
minMs: v[0],
|
|
353
|
-
p50Ms: _pct(v, 50),
|
|
354
|
-
p90Ms: _pct(v, 90),
|
|
355
|
-
maxMs: v[v.length - 1],
|
|
356
|
-
meanMs: Math.round(sum / v.length),
|
|
357
|
-
// Says whether the percentiles above may be quoted, not whether the count
|
|
358
|
-
// is real. n and min/max/mean are exact at any sample size.
|
|
359
|
-
reliable: v.length >= RELIABLE_N,
|
|
360
|
-
};
|
|
228
|
+
function wrapInFunction(code, { name = 'wrapped_v2' } = {}) {
|
|
229
|
+
if (!String(code).trim()) return null;
|
|
230
|
+
const indented = code.split('\n').map((l) => (l ? ` ${l}` : l)).join('\n');
|
|
231
|
+
return `function ${name}() {\n${indented}\n}\n${name}();\n`;
|
|
361
232
|
}
|
|
362
233
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
if (!
|
|
368
|
-
|
|
234
|
+
/** Insert a pass-through local for the first simple right-hand-side identifier assigned with `=` (indirection,
|
|
235
|
+
* no behavior change): `x = y;` becomes `const indirectN = y; x = indirectN;`. */
|
|
236
|
+
function insertIndirection(code) {
|
|
237
|
+
const m = /^(\s*)([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*([A-Za-z_][A-Za-z0-9_.]*)\s*;/m.exec(code);
|
|
238
|
+
if (!m) return null;
|
|
239
|
+
const [whole, indent, lhs, rhs] = m;
|
|
240
|
+
const varName = `indirect_${Math.abs(_hash(rhs)) % 1000}`;
|
|
241
|
+
if (code.includes(varName)) return null;
|
|
242
|
+
return code.replace(whole, `${indent}const ${varName} = ${rhs};\n${indent}${lhs} = ${varName};`);
|
|
369
243
|
}
|
|
370
244
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
return {
|
|
392
|
-
attempts: all.length,
|
|
393
|
-
counts: {
|
|
394
|
-
validated: buckets.validated.length,
|
|
395
|
-
validatedWithoutTests: buckets.validatedWithoutTests.length,
|
|
396
|
-
failed: buckets.failed.length,
|
|
397
|
-
},
|
|
398
|
-
timeToValidatedFix: _dist(buckets.validated.map(a => a.totalMs)),
|
|
399
|
-
timeToValidatedFixWithoutTests: _dist(buckets.validatedWithoutTests.map(a => a.totalMs)),
|
|
400
|
-
timeToFailure: _dist(buckets.failed.map(a => a.totalMs)),
|
|
401
|
-
byStage,
|
|
402
|
-
reliableAtOrAbove: RELIABLE_N,
|
|
403
|
-
};
|
|
245
|
+
function _hash(s) { let h = 0; for (const c of s) h = (h * 31 + c.charCodeAt(0)) | 0; return h; }
|
|
246
|
+
|
|
247
|
+
// ---- the four mutators the PRD lists beyond rename / wrapper / indirection --------------------------------------------------
|
|
248
|
+
// Each is a deterministic, table-driven textual rewrite that returns null when it does not apply. Tables are deliberately
|
|
249
|
+
// small and conservative: an entry is here only when both sides are the same API family used the same way, so the claim "same
|
|
250
|
+
// vulnerability, different shape" stays true. They are applied to the vulnerable side (does the rule still fire?) and to the safe
|
|
251
|
+
// side (does it still decline?); the safe side is where a rule keyed to ONE sanitizer's name is caught.
|
|
252
|
+
|
|
253
|
+
// equivalent APIs: the same operation through a sibling entry point
|
|
254
|
+
const EQUIVALENT_APIS = Object.freeze([
|
|
255
|
+
[/\bchild_process\.exec\(/, 'child_process.execSync('], [/\bexecSync\(/, 'exec('], [/\bos\.system\(/, 'os.popen('],
|
|
256
|
+
[/\bdb\.query\(/, 'db.execute('], [/\bconnection\.query\(/, 'connection.execute('], [/\bcursor\.execute\(/, 'cursor.executemany('],
|
|
257
|
+
[/\bfs\.readFile\(/, 'fs.readFileSync('], [/\bfs\.readFileSync\(/, 'fs.readFile('], [/\bfs\.writeFile\(/, 'fs.writeFileSync('],
|
|
258
|
+
[/\bhttp\.get\(/, 'https.get('], [/\baxios\.get\(/, 'axios.request('], [/\bres\.send\(/, 'res.end('],
|
|
259
|
+
[/\bos\.Open\(/, 'os.OpenFile('], [/\bsubprocess\.call\(/, 'subprocess.run('], [/\bsubprocess\.run\(/, 'subprocess.call('],
|
|
260
|
+
]);
|
|
261
|
+
function equivalentApi(code) {
|
|
262
|
+
for (const [from, to] of EQUIVALENT_APIS) if (from.test(code)) return code.replace(from, to);
|
|
263
|
+
return null;
|
|
404
264
|
}
|
|
405
265
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
266
|
+
// alternate sanitizers: a different, equally valid way to neutralize the same input
|
|
267
|
+
const ALTERNATE_SANITIZERS = Object.freeze([
|
|
268
|
+
[/\bescapeHtml\(/, 'validator.escape('], [/\bvalidator\.escape\(/, 'escapeHtml('], [/\bDOMPurify\.sanitize\(/, 'sanitizeHtml('], [/\bsanitizeHtml\(/, 'DOMPurify.sanitize('],
|
|
269
|
+
[/\bshlex\.quote\(/, 'pipes.quote('], [/\bhtml\.escape\(/, 'markupsafe.escape('], [/\bhtml\.EscapeString\(/, 'template.HTMLEscapeString('],
|
|
270
|
+
[/\bencodeURIComponent\(/, 'encodeURI('], [/\bpath\.basename\(/, 'path.parse('], [/\bparseInt\(/, 'Number('], [/\bNumber\(/, 'parseInt('],
|
|
271
|
+
[/\bstrconv\.Atoi\(/, 'strconv.ParseInt('], [/\bint\(/, 'abs('],
|
|
272
|
+
]);
|
|
273
|
+
function alternateSanitizer(code) {
|
|
274
|
+
for (const [from, to] of ALTERNATE_SANITIZERS) if (from.test(code)) return code.replace(from, to);
|
|
275
|
+
return null;
|
|
409
276
|
}
|
|
410
277
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
278
|
+
// framework variants: the same untrusted input reached through another framework's accessor
|
|
279
|
+
const FRAMEWORK_VARIANTS = Object.freeze([
|
|
280
|
+
[/\breq\.body\./g, 'ctx.request.body.'], [/\breq\.query\./g, 'ctx.query.'], [/\breq\.params\./g, 'ctx.params.'],
|
|
281
|
+
[/\brequest\.args\.get\(/g, 'request.form.get('], [/\brequest\.form\.get\(/g, 'request.args.get('], [/\$_GET\[/g, '$_REQUEST['], [/\$_POST\[/g, '$_REQUEST['],
|
|
282
|
+
[/\br\.URL\.Query\(\)\.Get\(/g, 'r.FormValue('], [/\brequest\.getParameter\(/g, 'request.getHeader('],
|
|
283
|
+
]);
|
|
284
|
+
function frameworkVariant(code) {
|
|
285
|
+
for (const [from, to] of FRAMEWORK_VARIANTS) if (from.test(code)) { from.lastIndex = 0; return code.replace(from, to); }
|
|
286
|
+
return null;
|
|
414
287
|
}
|
|
415
288
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
function
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
parts.push('time-to-validated-fix: no fix has both verified and had its test suite run yet');
|
|
289
|
+
// reordering: swap the first pair of ADJACENT simple statements that share no identifier, so neither feeds the other. This is a
|
|
290
|
+
// syntactic independence test, not a data-flow proof; it is applied to rule-corpus snippets, where a swap of two unrelated
|
|
291
|
+
// statements is what a different author's version of the same code would plausibly look like.
|
|
292
|
+
const CONTROL = /^\s*(?:if|else|for|while|switch|case|return|try|catch|finally|do|break|continue|throw|function|def|class|import|from|package|\}|\{|@|\/\/|#)\b|^\s*[}{]\s*$/;
|
|
293
|
+
function reorderStatements(code) {
|
|
294
|
+
const lines = code.split('\n');
|
|
295
|
+
const simple = (l) => l.trim() && !CONTROL.test(l) && /[;)]\s*$|^[^=]*=[^=].*[^{]$/.test(l.trim()) && !/[{]\s*$/.test(l);
|
|
296
|
+
const ids = (l) => new Set([...l.matchAll(/\b[A-Za-z_][A-Za-z0-9_]*\b/g)].map((m) => m[0]).filter((x) => !RESERVED.has(x)));
|
|
297
|
+
for (let i = 0; i + 1 < lines.length; i++) {
|
|
298
|
+
if (!simple(lines[i]) || !simple(lines[i + 1])) continue;
|
|
299
|
+
const a = ids(lines[i]); const b = ids(lines[i + 1]);
|
|
300
|
+
if ([...a].some((x) => b.has(x))) continue;
|
|
301
|
+
const out = [...lines]; [out[i], out[i + 1]] = [out[i + 1], out[i]];
|
|
302
|
+
return out.join('\n');
|
|
431
303
|
}
|
|
432
|
-
|
|
433
|
-
parts.push(`${sum.counts.validatedWithoutTests} verified with no detectable test suite (excluded from the median above)`);
|
|
434
|
-
}
|
|
435
|
-
if (sum.counts.failed) {
|
|
436
|
-
parts.push(`${sum.counts.failed} failed verification, median ${_ms(sum.timeToFailure.p50Ms)} (counted separately)`);
|
|
437
|
-
}
|
|
438
|
-
return parts.join('; ') + '.';
|
|
304
|
+
return null;
|
|
439
305
|
}
|
|
440
306
|
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
//
|
|
457
|
-
// Reporting the axes separately makes that visible. `aOnly` is the number that
|
|
458
|
-
// matters most and the one nobody was publishing: attempts that satisfied ONLY
|
|
459
|
-
// the disappearance axis. A high aOnly with a high headline is the shape of a
|
|
460
|
-
// remediation feature that is deleting code and calling it a fix.
|
|
461
|
-
function summarizeFixAxes(attempts) {
|
|
462
|
-
const list = Array.isArray(attempts) ? attempts.filter(Boolean) : [];
|
|
463
|
-
const d = list.length;
|
|
464
|
-
|
|
465
|
-
const rate = (pred) => ({ n: list.filter(pred).length, d });
|
|
466
|
-
|
|
467
|
-
// Each axis is judged INDEPENDENTLY of the overall verdict, so a leg that
|
|
468
|
-
// passed inside a failed attempt still counts for its own axis. Reading them
|
|
469
|
-
// off `ok` would make the three axes three copies of the same number.
|
|
470
|
-
const findingDisappeared = rate((a) => a.rescanOk === true || (a.ok === true && a.rescanOk !== false));
|
|
471
|
-
const testsStillPass = rate((a) => a.testsRan === true && a.testsOk !== false);
|
|
472
|
-
const verifierAgrees = rate((a) => a.pocOk === true);
|
|
473
|
-
|
|
474
|
-
const satisfiesAll = rate((a) =>
|
|
475
|
-
(a.rescanOk === true || (a.ok === true && a.rescanOk !== false))
|
|
476
|
-
&& a.testsRan === true && a.testsOk !== false
|
|
477
|
-
&& a.pocOk === true);
|
|
478
|
-
|
|
479
|
-
// The honesty number: disappearance WITHOUT either corroborating axis.
|
|
480
|
-
const aOnly = rate((a) => {
|
|
481
|
-
const disappeared = a.rescanOk === true || (a.ok === true && a.rescanOk !== false);
|
|
482
|
-
const corroborated = (a.testsRan === true && a.testsOk !== false) || a.pocOk === true;
|
|
483
|
-
return disappeared && !corroborated;
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
return {
|
|
487
|
-
total: d,
|
|
488
|
-
findingDisappeared,
|
|
489
|
-
testsStillPass,
|
|
490
|
-
verifierAgrees,
|
|
491
|
-
satisfiesAll,
|
|
492
|
-
aOnly,
|
|
493
|
-
meaning:
|
|
494
|
-
'findingDisappeared = the detector went quiet; testsStillPass = the project suite ran AND passed; '
|
|
495
|
-
+ 'verifierAgrees = an independent PoC re-check confirmed the hole is shut. '
|
|
496
|
-
+ 'aOnly counts attempts that satisfied ONLY disappearance — the shape a code-deleting "fix" produces.',
|
|
497
|
-
caveat: d === 0
|
|
498
|
-
? 'no attempts recorded; every rate is 0/0 and means nothing'
|
|
499
|
-
: 'rates carry {n,d}; a small d is indicative, not settled',
|
|
500
|
-
};
|
|
307
|
+
const MUTATORS = Object.freeze({
|
|
308
|
+
'variable-rename': variableRename, 'wrap-in-function': wrapInFunction, 'insert-indirection': insertIndirection,
|
|
309
|
+
'equivalent-api': equivalentApi, 'reorder-statements': reorderStatements, 'alternate-sanitizer': alternateSanitizer, 'framework-variant': frameworkVariant,
|
|
310
|
+
});
|
|
311
|
+
const MUTATOR_NAMES = Object.freeze(Object.keys(MUTATORS));
|
|
312
|
+
|
|
313
|
+
/** Every mutator applied to `code`; mutators that don't apply (return null) are omitted, never silently skipped without saying so. */
|
|
314
|
+
function generateVariants(code) {
|
|
315
|
+
const out = [];
|
|
316
|
+
for (const [name, fn] of Object.entries(MUTATORS)) {
|
|
317
|
+
let variant = null;
|
|
318
|
+
try { variant = fn(code); } catch { variant = null; }
|
|
319
|
+
if (variant && variant !== code) out.push({ mutator: name, code: variant });
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
501
322
|
}
|
|
502
323
|
|
|
503
|
-
/**
|
|
504
|
-
function
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
return [
|
|
508
|
-
'| Axis | Rate | Meaning |',
|
|
509
|
-
'|---|---|---|',
|
|
510
|
-
row('(a) finding disappeared', sum.findingDisappeared, 'the detector went quiet'),
|
|
511
|
-
row('(b) project tests pass', sum.testsStillPass, 'the suite RAN and passed'),
|
|
512
|
-
row('(c) verifier agrees', sum.verifierAgrees, 'an independent PoC re-check confirmed it'),
|
|
513
|
-
row('all three', sum.satisfiesAll, 'the only row that means "fixed"'),
|
|
514
|
-
row('(a) ALONE', sum.aOnly, 'satisfiable by deleting code — watch this number'),
|
|
515
|
-
'',
|
|
516
|
-
].join('\n');
|
|
324
|
+
/** Which of the seven mutators produced a variant for this code, and which did not (so a report can say what was not exercised). */
|
|
325
|
+
function mutatorCoverage(code) {
|
|
326
|
+
const applied = new Set(generateVariants(code).map((v) => v.mutator));
|
|
327
|
+
return { applied: MUTATOR_NAMES.filter((n) => applied.has(n)), notApplicable: MUTATOR_NAMES.filter((n) => !applied.has(n)) };
|
|
517
328
|
}
|
|
518
329
|
|
|
519
330
|
|
|
520
331
|
/***/ }),
|
|
521
332
|
|
|
522
|
-
/***/
|
|
523
|
-
/***/ ((
|
|
333
|
+
/***/ 57711:
|
|
334
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
524
335
|
|
|
525
|
-
__webpack_require__.a(__webpack_module__, async (__webpack_handle_async_dependencies__, __webpack_async_result__) => { try {
|
|
526
|
-
__webpack_require__.r(__webpack_exports__);
|
|
527
336
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
528
|
-
/* harmony export */
|
|
529
|
-
/* harmony export */
|
|
530
|
-
/* harmony export */
|
|
337
|
+
/* harmony export */ V2: () => (/* binding */ PROMOTION_DEFAULTS),
|
|
338
|
+
/* harmony export */ kx: () => (/* binding */ evaluateRule),
|
|
339
|
+
/* harmony export */ r_: () => (/* binding */ checkLeakage),
|
|
340
|
+
/* harmony export */ z: () => (/* binding */ gatePromotion)
|
|
531
341
|
/* harmony export */ });
|
|
532
|
-
/* harmony import */ var
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
/* harmony import */ var _test_runner_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(35136);
|
|
538
|
-
/* harmony import */ var _fix_metrics_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(32238);
|
|
539
|
-
/* harmony import */ var _verification_emit_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(85578);
|
|
540
|
-
var __webpack_async_dependencies__ = __webpack_handle_async_dependencies__([_engine_js__WEBPACK_IMPORTED_MODULE_3__]);
|
|
541
|
-
_engine_js__WEBPACK_IMPORTED_MODULE_3__ = (__webpack_async_dependencies__.then ? (await __webpack_async_dependencies__)() : __webpack_async_dependencies__)[0];
|
|
542
|
-
// Closed-loop /fix verification (Sentinel-parity FR-L4-4, FR-L4-5).
|
|
543
|
-
//
|
|
544
|
-
// Given a candidate patch (the new file content + the finding stableId being
|
|
545
|
-
// fixed), verify it:
|
|
546
|
-
//
|
|
547
|
-
// 1. The original finding's stableId no longer fires on the patched file.
|
|
548
|
-
// 2. No new findings at severity ≥ medium were introduced by the patch.
|
|
549
|
-
// 3. The project's existing linter (when present) passes on the patched file.
|
|
550
|
-
// 4. The project's own test suite (when detectable) still passes. This is
|
|
551
|
-
// the R5 gap-closer: a patch that silently deletes the feature would
|
|
552
|
-
// satisfy (1) and (2) just as well as a real fix — only running the
|
|
553
|
-
// tests catches that. See `test-runner.js` for detection + execution.
|
|
554
|
-
//
|
|
555
|
-
// If any of those fail, the caller is expected to NOT apply the patch and
|
|
556
|
-
// instead surface a "fix plan" — a numbered list of steps the engineer can
|
|
557
|
-
// follow — rather than dump a broken patch on the user.
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
342
|
+
/* harmony import */ var _corpus_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(43556);
|
|
343
|
+
// Promotion gate (PRD 3 "Promotion Gate"): precision/recall thresholds, zero protected-negative failures, zero
|
|
344
|
+
// benchmark leakage. A candidate rule does NOT automatically become a production rule — `evaluateRule` only
|
|
345
|
+
// scores; `gatePromotion` only judges; the lifecycle ledger (`lifecycle.js`) is the one place a PROMOTED state is
|
|
346
|
+
// actually recorded, and nothing in this module writes to it.
|
|
561
347
|
|
|
562
348
|
|
|
349
|
+
const PROMOTION_DEFAULTS = Object.freeze({ minPrecision: 0.95, minRecall: 0.80, maxProtectedFailures: 0 });
|
|
563
350
|
|
|
351
|
+
// Deliberately independent of posture/evaluation/custody.js: that module's term list and guard are reserved for
|
|
352
|
+
// the evaluation custodian's sealed-label boundary (QA-002.AC03), and a test enforces that nothing outside
|
|
353
|
+
// posture/evaluation/ may import it. The anti-cheating need here is smaller and different in kind — a rule
|
|
354
|
+
// synthesis prompt/corpus must not embed a benchmark name or an advisory id — so it gets its own small, independent
|
|
355
|
+
// checker rather than reaching across that isolation boundary.
|
|
356
|
+
const ADVISORY_ID = /\b(?:CVE|GHSA|CWE)-[A-Za-z0-9-]+\b/i;
|
|
564
357
|
|
|
358
|
+
// What a synthesis agent must never see (PRD 3, anti-cheating): benchmark answer keys, hidden test labels, CWE-bearing hidden file
|
|
359
|
+
// names, vulnerability comments and protected test metadata. These defaults are always applied; a caller adds its own benchmark names.
|
|
360
|
+
const DEFAULT_BENCHMARK_NAMES = Object.freeze(['owasp benchmark', 'juliet', 'sard test', 'cve-replay', 'corpus-baseline', 'answer key', 'ground truth', 'adjudicated', 'hidden test', 'expectedresults']);
|
|
361
|
+
const CWE_FILENAME = /(^|[\\/])[^\\/]*cwe[-_]?\d+[^\\/]*$/i;
|
|
362
|
+
const ANSWER_KEY_FILENAME = /(^|[\\/])(?:labels?|answers?|expected|ground[-_]?truth|answer[-_]?key|oracle|result)[^\\/]*\.(?:json|jsonl|ya?ml|csv|txt)$/i;
|
|
363
|
+
const VULNERABILITY_COMMENT = /(?:\/\/|#|\/\*|^\s*\*)\s*(?:POTENTIAL FLAW|FLAW\b|INCIDENTAL|BAD\b|GOOD\b|vulnerab\w*|insecure|exploit\w*)/im;
|
|
565
364
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
// patched files are scanned deep, together with their import closure and manifests read from disk.
|
|
585
|
-
let deep;
|
|
586
|
-
if (Object.keys(fileContents).some((f) => /\.(?:l?hs|hs-boot|hsc|nix)$/i.test(f))) {
|
|
587
|
-
const { withLanguageContext } = await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 52603));
|
|
588
|
-
const lc = withLanguageContext(scanRoot, fileContents, depFileContents);
|
|
589
|
-
fileContents = lc.fileContents; depFileContents = lc.depFileContents; deep = true;
|
|
590
|
-
}
|
|
591
|
-
try {
|
|
592
|
-
// `provenance:false` is REQUIRED here, not an optimisation. This scan is
|
|
593
|
-
// deliberately scoped to just the patched file(s), so its finding set is a
|
|
594
|
-
// tiny subset of the project's. updateLifecycle marks every open stableId
|
|
595
|
-
// NOT in the set it is handed as `remediated` — so a single fix
|
|
596
|
-
// verification (every /fix, apply_fix, and autopilot iteration runs one)
|
|
597
|
-
// would mass-mark the rest of the project as remediated, then
|
|
598
|
-
// `reintroduced` on the next real scan. The patched content is also not
|
|
599
|
-
// committed, so there is no history to resolve provenance against anyway.
|
|
600
|
-
scan = await (0,_engine_js__WEBPACK_IMPORTED_MODULE_3__/* .runFullScan */ .wW)({ fileContents, depFileContents, scanRoot, provenance: false, ...(deep ? { deep: true } : {}) }, () => {});
|
|
601
|
-
} catch (e) {
|
|
602
|
-
return { ok: false, reason: 'rescan-failed', error: e.message };
|
|
603
|
-
}
|
|
604
|
-
const findings = (scan && scan.findings) || [];
|
|
605
|
-
const stillHasOriginal = !!originalFindingStableId &&
|
|
606
|
-
findings.some(f => f.stableId === originalFindingStableId);
|
|
607
|
-
if (stillHasOriginal) {
|
|
608
|
-
return { ok: false, reason: 'original-finding-still-present', stableId: originalFindingStableId };
|
|
609
|
-
}
|
|
610
|
-
// With language context in play, files that were only read for context carry findings that predate the patch;
|
|
611
|
-
// only findings in the PATCHED files can be introduced by it.
|
|
612
|
-
const patchedSet = new Set(Object.keys(files));
|
|
613
|
-
const introducedHighOrAbove = findings.filter(f =>
|
|
614
|
-
(SEVERITY_RANK[f.severity] ?? 9) <= SEVERITY_RANK.medium && (!deep || patchedSet.has(f.file)));
|
|
615
|
-
// Don't count findings on lines outside the patched files — but our
|
|
616
|
-
// fileContents map IS the patched files, so every finding is in-scope.
|
|
617
|
-
return {
|
|
618
|
-
ok: introducedHighOrAbove.length === 0,
|
|
619
|
-
reason: introducedHighOrAbove.length === 0 ? 'verified' : 'introduced-new-findings',
|
|
620
|
-
introduced: introducedHighOrAbove.map(f => ({
|
|
621
|
-
vuln: f.vuln, file: f.file, line: f.line, severity: f.severity,
|
|
622
|
-
stableId: f.stableId,
|
|
623
|
-
})),
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// Detect which linter the project uses and run it on the patched files.
|
|
628
|
-
// Returns { ok, runner, output } or { ok: true, runner: 'none' } when no
|
|
629
|
-
// linter is configured (silent pass).
|
|
630
|
-
function runProjectLinter(scanRoot, filePaths) {
|
|
631
|
-
if (!scanRoot || !Array.isArray(filePaths) || filePaths.length === 0) {
|
|
632
|
-
return { ok: true, runner: 'none' };
|
|
633
|
-
}
|
|
634
|
-
const has = (p) => { try { return node_fs__WEBPACK_IMPORTED_MODULE_1__.existsSync(node_path__WEBPACK_IMPORTED_MODULE_2__.join(scanRoot, p)); } catch { return false; } };
|
|
635
|
-
// Pick the linter by config file present in the repo root.
|
|
636
|
-
const jsFiles = filePaths.filter(f => /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i.test(f));
|
|
637
|
-
const pyFiles = filePaths.filter(f => /\.py$/i.test(f));
|
|
638
|
-
const goFiles = filePaths.filter(f => /\.go$/i.test(f));
|
|
639
|
-
const javaFiles = filePaths.filter(f => /\.java$/i.test(f));
|
|
640
|
-
|
|
641
|
-
if (jsFiles.length && (has('.eslintrc') || has('.eslintrc.json') || has('.eslintrc.js') || has('eslint.config.js') || has('eslint.config.mjs'))) {
|
|
642
|
-
return runLinter(scanRoot, 'eslint', ['--no-error-on-unmatched-pattern', ...jsFiles]);
|
|
643
|
-
}
|
|
644
|
-
if (pyFiles.length && (has('pyproject.toml') || has('ruff.toml') || has('.ruff.toml'))) {
|
|
645
|
-
return runLinter(scanRoot, 'ruff', ['check', ...pyFiles]);
|
|
646
|
-
}
|
|
647
|
-
if (pyFiles.length && has('.flake8')) {
|
|
648
|
-
return runLinter(scanRoot, 'flake8', pyFiles);
|
|
649
|
-
}
|
|
650
|
-
if (goFiles.length && (has('.golangci.yml') || has('.golangci.yaml'))) {
|
|
651
|
-
return runLinter(scanRoot, 'golangci-lint', ['run', ...goFiles]);
|
|
652
|
-
}
|
|
653
|
-
if (javaFiles.length && has('checkstyle.xml')) {
|
|
654
|
-
return runLinter(scanRoot, 'checkstyle', ['-c', 'checkstyle.xml', ...javaFiles]);
|
|
655
|
-
}
|
|
656
|
-
return { ok: true, runner: 'none' };
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
function runLinter(cwd, cmd, args) {
|
|
660
|
-
let r;
|
|
661
|
-
try {
|
|
662
|
-
r = (0,node_child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync)(cmd, args, { cwd, encoding: 'utf8', timeout: 60_000 });
|
|
663
|
-
} catch (e) {
|
|
664
|
-
return { ok: true, runner: cmd, skipped: true, reason: 'binary-missing', error: e.message };
|
|
665
|
-
}
|
|
666
|
-
if (r.error && r.error.code === 'ENOENT') {
|
|
667
|
-
return { ok: true, runner: cmd, skipped: true, reason: 'binary-missing' };
|
|
668
|
-
}
|
|
669
|
-
if (r.status === null) {
|
|
670
|
-
return { ok: false, runner: cmd, reason: 'timed-out', output: (r.stderr || r.stdout || '').slice(-2000) };
|
|
671
|
-
}
|
|
672
|
-
return {
|
|
673
|
-
ok: r.status === 0,
|
|
674
|
-
runner: cmd,
|
|
675
|
-
exitCode: r.status,
|
|
676
|
-
output: ((r.stderr || '') + (r.stdout || '')).slice(-2000),
|
|
677
|
-
};
|
|
365
|
+
/**
|
|
366
|
+
* Anti-cheating gate. Fails closed: any hit makes `ok` false, and the synthesis loop will not report a pass.
|
|
367
|
+
* @param {string} promptText everything the synthesis agent would be shown (corpus code, spec text)
|
|
368
|
+
* @param {{benchmarkNames?: string[], fileNames?: string[]}} opts
|
|
369
|
+
*/
|
|
370
|
+
function checkLeakage(promptText, { benchmarkNames = [], fileNames = [] } = {}) {
|
|
371
|
+
const hay = String(promptText ?? '');
|
|
372
|
+
const lower = hay.toLowerCase();
|
|
373
|
+
const offending = [];
|
|
374
|
+
for (const name of [...DEFAULT_BENCHMARK_NAMES, ...benchmarkNames]) if (name && lower.includes(String(name).toLowerCase())) offending.push(`benchmark name: ${name}`);
|
|
375
|
+
const m = ADVISORY_ID.exec(hay);
|
|
376
|
+
if (m) offending.push(`identifier: ${m[0]}`);
|
|
377
|
+
if (VULNERABILITY_COMMENT.test(hay)) offending.push('a comment that describes the vulnerability');
|
|
378
|
+
for (const f of fileNames) {
|
|
379
|
+
if (CWE_FILENAME.test(String(f))) offending.push(`weakness-bearing file name: ${f}`);
|
|
380
|
+
if (ANSWER_KEY_FILENAME.test(String(f))) offending.push(`answer-key file name: ${f}`);
|
|
381
|
+
}
|
|
382
|
+
return { ok: offending.length === 0, offendingTerms: offending };
|
|
678
383
|
}
|
|
679
384
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
// R5 (partial) — the test-suite stage. Runs the target project's own tests,
|
|
691
|
-
// in the target project's own directory, against whatever is currently on
|
|
692
|
-
// disk there. See `test-runner.js`'s header comment for why that run is
|
|
693
|
-
// deliberately NOT routed through the R1 PoC-confinement sandbox: this is
|
|
694
|
-
// the project's own already-trusted suite, not untrusted synthesized code.
|
|
695
|
-
//
|
|
696
|
-
// Caveat that matters for callers: `verifyPatch` above re-scans the
|
|
697
|
-
// candidate patch purely in memory (no write to disk), but a test runner
|
|
698
|
-
// needs real files — there is no cheap way to hand a runner an in-memory
|
|
699
|
-
// overlay. So this leg reports on the CURRENT on-disk tree, not the
|
|
700
|
-
// candidate `files` map, when `verifyFix` is used as a pre-write preview
|
|
701
|
-
// (e.g. the `verify_fix` MCP tool). Callers that apply the patch first and
|
|
702
|
-
// then re-verify get the strongest signal; that ordering is not enforced
|
|
703
|
-
// here — it's the caller's responsibility, same as it already is for the
|
|
704
|
-
// closed-loop `fix-verify-loop.js` path.
|
|
705
|
-
// Does the caller's candidate patch differ from what is on disk right now?
|
|
706
|
-
// If so, any test run necessarily exercised the pre-patch tree. Compared by
|
|
707
|
-
// content so a patch that happens to match disk (already applied) is correctly
|
|
708
|
-
// treated as NOT pre-patch.
|
|
709
|
-
function _candidateDiffersFromDisk(scanRoot, files) {
|
|
710
|
-
if (!files || typeof files !== 'object') return false;
|
|
711
|
-
for (const [rel, content] of Object.entries(files)) {
|
|
712
|
-
if (typeof content !== 'string') continue;
|
|
713
|
-
try {
|
|
714
|
-
const abs = node_path__WEBPACK_IMPORTED_MODULE_2__.resolve(scanRoot, rel);
|
|
715
|
-
if (node_fs__WEBPACK_IMPORTED_MODULE_1__.readFileSync(abs, 'utf8') !== content) return true;
|
|
716
|
-
} catch {
|
|
717
|
-
return true; // candidate file absent on disk -> definitely not applied
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
return false;
|
|
385
|
+
function evaluateRule(ir, corpus, thresholds = PROMOTION_DEFAULTS) {
|
|
386
|
+
const score = (0,_corpus_js__WEBPACK_IMPORTED_MODULE_0__/* .scoreRule */ .T)(ir, corpus);
|
|
387
|
+
const t = { ...PROMOTION_DEFAULTS, ...thresholds };
|
|
388
|
+
const reasons = [];
|
|
389
|
+
if (score.precision === null) reasons.push('precision unmeasured (no positive or negative corpus fired)');
|
|
390
|
+
else if (score.precision < t.minPrecision) reasons.push(`precision ${(score.precision * 100).toFixed(1)}% below ${(t.minPrecision * 100).toFixed(0)}%`);
|
|
391
|
+
if (score.recall === null) reasons.push('recall unmeasured (empty positive corpus)');
|
|
392
|
+
else if (score.recall < t.minRecall) reasons.push(`recall ${(score.recall * 100).toFixed(1)}% below ${(t.minRecall * 100).toFixed(0)}%`);
|
|
393
|
+
if (score.protectedFailures > t.maxProtectedFailures) reasons.push(`${score.protectedFailures} protected-negative failure(s) (max ${t.maxProtectedFailures})`);
|
|
394
|
+
return { pass: reasons.length === 0, reasons, score, thresholds: t };
|
|
721
395
|
}
|
|
722
396
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
poc,
|
|
732
|
-
patchNegative,
|
|
733
|
-
assuranceConfig,
|
|
734
|
-
} = {}) {
|
|
735
|
-
// R5 (reporting half) — time each stage as it runs. Measured here rather
|
|
736
|
-
// than inside each stage because only this function knows the boundaries of
|
|
737
|
-
// one verification ATTEMPT, which is the unit the distribution is over.
|
|
738
|
-
const stages = {};
|
|
739
|
-
const t0 = Date.now();
|
|
740
|
-
let mark = t0;
|
|
741
|
-
const _lap = (name) => { const now = Date.now(); stages[name] = now - mark; mark = now; };
|
|
742
|
-
|
|
743
|
-
const rescan = await verifyPatch({ scanRoot, originalFindingStableId, files, depFileContents });
|
|
744
|
-
_lap('rescan');
|
|
745
|
-
const lint = runProjectLinter(scanRoot, Object.keys(files || {}));
|
|
746
|
-
_lap('lint');
|
|
747
|
-
const tests = (0,_test_runner_js__WEBPACK_IMPORTED_MODULE_5__/* .runProjectTests */ .I)(scanRoot, testTimeoutMs != null ? { timeoutMs: testTimeoutMs } : {});
|
|
748
|
-
_lap('tests');
|
|
749
|
-
// True when a candidate patch was supplied but has not been written, so the
|
|
750
|
-
// suite necessarily ran against the pre-patch tree. Surfaced in the summary
|
|
751
|
-
// and on the result so a caller cannot mistake it for a verified patch.
|
|
752
|
-
const _testedPrePatch = !tests.skipped && _candidateDiffersFromDisk(scanRoot, files);
|
|
753
|
-
const testsOk = tests.skipped ? true : tests.passed === true;
|
|
754
|
-
// R5 — the PoC leg. Re-run the finding's proof-of-concept against the
|
|
755
|
-
// CANDIDATE patch inside R1's sandbox. A patch that still lets the PoC
|
|
756
|
-
// demonstrate the predicted effect has not fixed anything, however green the
|
|
757
|
-
// re-scan looks: the re-scan only proves the DETECTOR stopped firing, which
|
|
758
|
-
// a cosmetic edit can achieve. Execution is the stronger claim.
|
|
759
|
-
//
|
|
760
|
-
// Direction matters and is asymmetric on purpose. `execution-proven` after
|
|
761
|
-
// the patch is a hard FAIL. Anything else is NOT a pass — a PoC that failed
|
|
762
|
-
// to run, or a sandbox that could not start, is recorded as `inconclusive`
|
|
763
|
-
// and left out of the verdict entirely. Treating "could not prove it" as
|
|
764
|
-
// "fixed" is exactly the false confidence this leg exists to prevent.
|
|
765
|
-
//
|
|
766
|
-
// Computed BEFORE the honesty gate (FR-308): a still-exploitable PoC is
|
|
767
|
-
// MECHANICAL evidence, not a self-report, and gateFixOutput cross-checks a
|
|
768
|
-
// self-reported FULL tier against it below — the ordering matters, not
|
|
769
|
-
// just the value.
|
|
770
|
-
let pocLeg = { status: 'not-requested', reason: null, tier: null };
|
|
771
|
-
if (poc?.code) {
|
|
772
|
-
try {
|
|
773
|
-
const { proveFinding } = await Promise.resolve(/* import() */).then(__webpack_require__.bind(__webpack_require__, 17908));
|
|
774
|
-
const proved = await proveFinding({ ...(poc.finding || {}), poc }, { files });
|
|
775
|
-
const tier = proved.proofTier;
|
|
776
|
-
pocLeg = tier === 'execution-proven'
|
|
777
|
-
? { status: 'still-exploitable', tier, reason: proved.proofEvidence?.observed || null }
|
|
778
|
-
: proved.proofEvidence?.ran
|
|
779
|
-
? { status: 'no-longer-proven', tier, reason: proved.proofEvidence?.reason || null }
|
|
780
|
-
: { status: 'inconclusive', tier, reason: proved.proofEvidence?.reason || null };
|
|
781
|
-
} catch (e) {
|
|
782
|
-
pocLeg = { status: 'inconclusive', tier: null, reason: `proof harness error: ${e.message}` };
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
_lap('poc');
|
|
786
|
-
const pocOk = pocLeg.status !== 'still-exploitable';
|
|
787
|
-
|
|
788
|
-
// FR-308: "a mitigation or workaround cannot be represented as a full
|
|
789
|
-
// fix" — gateFixOutput's tier/residual check is a pure self-consistency
|
|
790
|
-
// check (fixMeta.signals is agent-self-reported; see fix-honesty-gate.js's
|
|
791
|
-
// header for why nothing there is server-computable). pocLeg IS
|
|
792
|
-
// server-computable — a real execution result, not a claim — so it is
|
|
793
|
-
// passed through as the one MECHANICAL cross-check available: a
|
|
794
|
-
// self-reported FULL tier is refuted, not just internally inconsistent,
|
|
795
|
-
// when the PoC still demonstrates the vulnerability against the patch.
|
|
796
|
-
// D-0024: `fixMeta` is a shared envelope — FR-307/FR-1002's `approval`
|
|
797
|
-
// key lives alongside FR-308's completeness self-report (`residual`/
|
|
798
|
-
// `verdict`/`evidence`/`signals`). Gating on mere object-truthiness meant
|
|
799
|
-
// a caller supplying ONLY `approval` (a real, common shape once the CLI's
|
|
800
|
-
// --approved-by flag and the MCP schema fix made that reachable) got
|
|
801
|
-
// silently gated on an UNRELATED FR-308 self-consistency check it never
|
|
802
|
-
// engaged with — computeFixTier(undefined) defaults to MITIGATION, which
|
|
803
|
-
// then demands a `residual` nobody was ever asked to supply, blocking an
|
|
804
|
-
// otherwise-genuine, approved fix for a reason that has nothing to do
|
|
805
|
-
// with completeness honesty. Scope the gate to fixMeta shapes that
|
|
806
|
-
// actually make a completeness-adjacent claim.
|
|
807
|
-
const hasHonestyClaim = fixMeta && typeof fixMeta === 'object' &&
|
|
808
|
-
(fixMeta.residual !== undefined || fixMeta.verdict !== undefined ||
|
|
809
|
-
fixMeta.evidence !== undefined || fixMeta.signals !== undefined);
|
|
810
|
-
let honesty = null;
|
|
811
|
-
if (hasHonestyClaim) {
|
|
812
|
-
try { honesty = (0,_fix_honesty_gate_js__WEBPACK_IMPORTED_MODULE_4__/* .gateFixOutput */ ._u)(fixMeta, { pocLeg }); } catch { honesty = null; }
|
|
813
|
-
}
|
|
814
|
-
_lap('honesty');
|
|
815
|
-
|
|
816
|
-
// X-204: patch-negative verification, only when the caller asked for it AND the `patch-negative-verification` feature is
|
|
817
|
-
// on. `patchNegative` is the requester's exploit scenario and functional cases ({ original, functional, commit?, ... }); the
|
|
818
|
-
// candidate `files` are the proposed diff. With the flag off, or no request, nothing here runs and every field below is
|
|
819
|
-
// exactly what it was before.
|
|
820
|
-
let pn = null;
|
|
821
|
-
if (patchNegative && typeof patchNegative === 'object') {
|
|
822
|
-
try {
|
|
823
|
-
const { verifyPatchNegative } = await __webpack_require__.e(/* import() */ 6907).then(__webpack_require__.bind(__webpack_require__, 26907));
|
|
824
|
-
pn = await verifyPatchNegative({
|
|
825
|
-
hypothesisId: originalFindingStableId, ...patchNegative, patch: { files },
|
|
826
|
-
commit: patchNegative.commit ?? (0,_verification_emit_js__WEBPACK_IMPORTED_MODULE_7__.headCommit)(scanRoot),
|
|
827
|
-
}, { config: assuranceConfig });
|
|
828
|
-
} catch (e) {
|
|
829
|
-
pn = { status: 'error', verifiedFix: false, incompleteStep: 'patch-negative', failureCode: 'harness-error', reason: `patch-negative verification failed to run: ${String(e?.message || e).slice(0, 200)}`, summary: '' };
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
const pnActive = pn !== null && pn.status !== 'disabled';
|
|
833
|
-
_lap('patchNegative');
|
|
834
|
-
|
|
835
|
-
const ok = rescan.ok && (lint.ok || lint.skipped) && testsOk && pocOk && (honesty ? honesty.ok : true) && (pnActive ? pn.verifiedFix === true : true);
|
|
836
|
-
|
|
837
|
-
// FR-305 (assurance-hardening PRD): `ok` alone conflates "every leg
|
|
838
|
-
// genuinely ran and passed" with "passed, but a required leg was skipped
|
|
839
|
-
// or unavailable" — `lint.ok`/`testsOk` are both true in the skipped case
|
|
840
|
-
// by design (this codebase does not fail-closed just because a repo has
|
|
841
|
-
// no linter or no detected test runner), so a caller checking only `ok`
|
|
842
|
-
// cannot tell the difference. `lint.skipped` is only ever true when a
|
|
843
|
-
// linter WAS configured but its binary could not be run (missing config
|
|
844
|
-
// entirely returns `runner: 'none'` with no `skipped` field at all — that
|
|
845
|
-
// is a genuine N/A, nothing was required, not a degradation). `verifiedFull`
|
|
846
|
-
// is the honest label: true only when nothing that WAS required was
|
|
847
|
-
// skipped. A caller must never present `ok: true, verifiedFull: false` as
|
|
848
|
-
// "fully verified" — `degradedLegs` names exactly what was skipped so a
|
|
849
|
-
// report can say so plainly instead of a bare pass.
|
|
850
|
-
const degradedLegs = [];
|
|
851
|
-
if (lint.skipped) degradedLegs.push(`lint: ${lint.runner} not installed`);
|
|
852
|
-
if (tests.skipped) degradedLegs.push(`tests: skipped (${tests.reason})`);
|
|
853
|
-
const verifiedFull = ok && degradedLegs.length === 0;
|
|
854
|
-
|
|
855
|
-
const durations = { ...stages, totalMs: Date.now() - t0 };
|
|
856
|
-
const summary = [
|
|
857
|
-
`re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
|
|
858
|
-
`linter: ${lint.runner === 'none' ? 'skipped (no linter config)'
|
|
859
|
-
: lint.skipped ? `${lint.runner} not installed`
|
|
860
|
-
: lint.ok ? `${lint.runner} PASS`
|
|
861
|
-
: `${lint.runner} FAIL (exit ${lint.exitCode})`}`,
|
|
862
|
-
// Say which tree the suite actually ran against. `files` is a candidate
|
|
863
|
-
// patch held in memory; the runner needs real files, so it sees whatever is
|
|
864
|
-
// on disk. Reporting a bare "PASS" here would let a caller believe the
|
|
865
|
-
// PATCH passed the tests when the suite may have run on unpatched code.
|
|
866
|
-
`tests: ${tests.skipped ? `skipped (${tests.reason})`
|
|
867
|
-
: tests.timedOut ? 'FAIL (timed out)'
|
|
868
|
-
: tests.passed ? `PASS${_testedPrePatch ? ' — on the CURRENT on-disk tree, NOT the candidate patch' : ''}`
|
|
869
|
-
: `FAIL (exit ${tests.exitCode})`}`,
|
|
870
|
-
honesty ? `honesty: ${honesty.ok ? `PASS (${honesty.tier})` : 'FAIL — ' + honesty.violations.join('; ')}` : null,
|
|
871
|
-
// Never render `inconclusive` as a pass — say plainly that nothing was proven.
|
|
872
|
-
pocLeg.status === 'not-requested' ? null
|
|
873
|
-
: pocLeg.status === 'still-exploitable' ? `poc: FAIL — the proof-of-concept still demonstrates the vulnerability against the patch`
|
|
874
|
-
: pocLeg.status === 'no-longer-proven' ? 'poc: PASS (ran against the patch and no longer demonstrates the vulnerability)'
|
|
875
|
-
: `poc: inconclusive — not counted either way (${pocLeg.reason || 'no detail reported'})`,
|
|
876
|
-
// FR-305: never let a degraded pass read the same as a full one.
|
|
877
|
-
pnActive ? `patch-negative: ${pn.verifiedFix ? 'PASS (verified-fix)' : `FAIL - not verified-fix, incomplete step '${pn.incompleteStep}' (${pn.failureCode}): ${pn.reason}`}` : null,
|
|
878
|
-
ok && !verifiedFull ? `NOTE: PASSED, but NOT fully verified — ${degradedLegs.join('; ')}` : null,
|
|
879
|
-
].filter(Boolean).join('\n');
|
|
880
|
-
// Persist the attempt so the distribution can be reported from real runs.
|
|
881
|
-
// `testsRan` is the load-bearing field: it is what keeps "verified with no
|
|
882
|
-
// test suite to run" out of the headline time-to-validated-fix bucket.
|
|
883
|
-
// A patch that was never written to disk is recorded too, but flagged — its
|
|
884
|
-
// suite ran against the pre-patch tree, so its timing is real while its
|
|
885
|
-
// verdict is about a different tree.
|
|
886
|
-
if (recordMetrics && scanRoot) {
|
|
887
|
-
(0,_fix_metrics_js__WEBPACK_IMPORTED_MODULE_6__/* .recordFixAttempt */ .I3)(scanRoot, {
|
|
888
|
-
at: new Date().toISOString(),
|
|
889
|
-
stableId: originalFindingStableId || null,
|
|
890
|
-
ok,
|
|
891
|
-
verifiedFull,
|
|
892
|
-
testsRan: !tests.skipped,
|
|
893
|
-
testsPassed: tests.skipped ? null : tests.passed === true,
|
|
894
|
-
testedPrePatch: _testedPrePatch,
|
|
895
|
-
lintRan: !(lint.skipped || lint.runner === 'none'),
|
|
896
|
-
honestyGated: honesty != null,
|
|
897
|
-
pocStatus: pocLeg.status,
|
|
898
|
-
files: Object.keys(files || {}).length,
|
|
899
|
-
stages,
|
|
900
|
-
totalMs: durations.totalMs,
|
|
901
|
-
});
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
// X-201: the one verification record, ADDITIVE. `ok` and every field above stay exactly as they were; the record
|
|
905
|
-
// says what was actually established (a static re-scan is not an exploit oracle, so without a PoC leg it is
|
|
906
|
-
// `not-run`, and nothing here can reach `confirmed`/`refuted`: that takes a trusted oracle adapter).
|
|
907
|
-
const emitted = (0,_verification_emit_js__WEBPACK_IMPORTED_MODULE_7__/* .emitVerification */ .T)('fix-verify', { rescan, lint, tests, poc: pocLeg }, {
|
|
908
|
-
originalFindingStableId, files, scanRoot, finding: poc?.finding,
|
|
909
|
-
});
|
|
910
|
-
const verificationRecord = emitted.ok ? emitted.record : null;
|
|
911
|
-
|
|
912
|
-
return {
|
|
913
|
-
ok, verifiedFull, degradedLegs, rescan, lint, tests, testedPrePatch: _testedPrePatch, honesty, poc: pocLeg, durations, summary, verificationRecord,
|
|
914
|
-
// additive, present only when patch-negative verification was requested (X-204)
|
|
915
|
-
...(pn ? { patchNegative: pn, ...(pnActive ? { fixStatus: pn.verifiedFix ? 'verified-fix' : 'not-verified-fix' } : {}) } : {}),
|
|
916
|
-
};
|
|
397
|
+
/** The one promotion decision. Never auto-applied: a caller still has to act on `pass:true` by recording a
|
|
398
|
+
* lifecycle transition. Localization ("acceptable") is a human/caller judgment this function cannot make from
|
|
399
|
+
* line/column alone; it is surfaced for review, never silently assumed. */
|
|
400
|
+
function gatePromotion({ ir, corpus, leakageCheck, thresholds } = {}) {
|
|
401
|
+
const evalResult = evaluateRule(ir, corpus, thresholds);
|
|
402
|
+
const reasons = [...evalResult.reasons];
|
|
403
|
+
if (!leakageCheck || leakageCheck.ok !== true) reasons.push(leakageCheck ? `leakage check failed: ${leakageCheck.offendingTerms.join(', ')}` : 'leakage check was not run');
|
|
404
|
+
return { pass: reasons.length === 0, reasons, evaluation: evalResult };
|
|
917
405
|
}
|
|
918
406
|
|
|
919
|
-
__webpack_async_result__();
|
|
920
|
-
} catch(e) { __webpack_async_result__(e); } });
|
|
921
407
|
|
|
922
408
|
/***/ }),
|
|
923
409
|
|
|
924
|
-
/***/
|
|
410
|
+
/***/ 47838:
|
|
925
411
|
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
926
412
|
|
|
927
413
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
928
|
-
/* harmony export */
|
|
414
|
+
/* harmony export */ Ry: () => (/* binding */ generalizeLine),
|
|
415
|
+
/* harmony export */ _7: () => (/* binding */ synthesizeCandidate)
|
|
929
416
|
/* harmony export */ });
|
|
930
|
-
/* unused harmony
|
|
931
|
-
/* harmony import */ var
|
|
932
|
-
/* harmony import */ var
|
|
933
|
-
/* harmony import */ var
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
// that the
|
|
417
|
+
/* unused harmony exports proposeCandidates, safeNames */
|
|
418
|
+
/* harmony import */ var _ir_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(87539);
|
|
419
|
+
/* harmony import */ var _corpus_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(43556);
|
|
420
|
+
/* harmony import */ var _promotion_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(57711);
|
|
421
|
+
// The synthesis loop for the rule compiler (PRD 3, requirement 5): propose, compile, test positives, test negatives, mutation test,
|
|
422
|
+
// score, revise, repeat with bounded attempts, emit a candidate.
|
|
423
|
+
//
|
|
424
|
+
// "Models propose rules. Evidence promotes rules." A model's proposal (a `match` in the spec) is tried first when there is one.
|
|
425
|
+
// When there is none, or it fails, this module proposes deterministically from the two pieces of evidence it has: the vulnerable
|
|
426
|
+
// code and its patched twin. The lines that exist only in the vulnerable version say what the flaw looks like; the calls that exist
|
|
427
|
+
// only in the patched version say what a safe version looks like (and become the `notMatch` that keeps the rule quiet on safe files).
|
|
428
|
+
// No model is consulted here and nothing here promotes a rule: the loop returns attempts and a best candidate, and promotion stays
|
|
429
|
+
// with the gate and the lifecycle ledger.
|
|
940
430
|
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
// contain untrusted proof-of-concept exploit code the scanner itself
|
|
945
|
-
// synthesizes — code nobody has vetted, being run for the first time. A
|
|
946
|
-
// project's pre-existing test suite is the opposite case: it is the
|
|
947
|
-
// project's own trusted source, already sitting on disk, and running it is
|
|
948
|
-
// exactly what a human developer does by hand before trusting a fix.
|
|
949
|
-
// Wrapping "npm test" / "pytest" / "go test" in the PoC sandbox's
|
|
950
|
-
// syscall/network/filesystem restrictions would break the large majority of
|
|
951
|
-
// real test suites (they bind local ports, spawn child processes, write temp
|
|
952
|
-
// fixtures, etc.) for no corresponding security benefit.
|
|
431
|
+
// Every attempt is scored against the real corpus (positives with their mutations, negatives with theirs) by the same matcher the
|
|
432
|
+
// rule will run on once promoted. An attempt that does not clear the gate is revised from WHY it failed: low recall generalizes,
|
|
433
|
+
// a negative that fires adds the safe version's names to `notMatch`.
|
|
953
434
|
|
|
954
435
|
|
|
955
436
|
|
|
956
437
|
|
|
438
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&');
|
|
439
|
+
const KEYWORDS = new Set(['if', 'else', 'for', 'while', 'return', 'function', 'const', 'let', 'var', 'new', 'await', 'async', 'try', 'catch', 'throw', 'typeof', 'def', 'class', 'import', 'from', 'with', 'in', 'of', 'and', 'or', 'not', 'is', 'func', 'fn', 'public', 'private', 'static', 'void', 'int', 'string']);
|
|
440
|
+
const SOURCES = [
|
|
441
|
+
String.raw`req(?:uest)?\.(?:body|query|params|headers|cookies)\b`, String.raw`ctx\.(?:query|params|request\.body)\b`, String.raw`request\.(?:args|form|values|json|GET|POST)\b`,
|
|
442
|
+
String.raw`\$_(?:GET|POST|REQUEST|COOKIE)\b`, String.raw`process\.argv\b`, String.raw`os\.Args\b`, String.raw`\bgetParameter\s*\(`, String.raw`r\.(?:URL\.Query\(\)|FormValue)\b`,
|
|
443
|
+
];
|
|
957
444
|
|
|
958
|
-
const
|
|
959
|
-
const NPM_PLACEHOLDER = /Error: no test specified/i;
|
|
445
|
+
const meaningful = (l) => { const t = l.trim(); return t.length >= 8 && !/^(?:\/\/|#|\/\*|\*|[{}()\];,]+$)/.test(t); };
|
|
960
446
|
|
|
961
|
-
|
|
962
|
-
|
|
447
|
+
/** Lines (trimmed) present only in `a`. */
|
|
448
|
+
function onlyIn(a, b) {
|
|
449
|
+
const bs = new Set(String(b ?? '').split('\n').map((l) => l.trim()));
|
|
450
|
+
return String(a ?? '').split('\n').filter((l) => meaningful(l) && !bs.has(l.trim())).map((l) => l.trim());
|
|
963
451
|
}
|
|
964
452
|
|
|
965
|
-
|
|
966
|
-
|
|
453
|
+
/** Call names (a.b.c) in some code, in order of appearance, without keywords. */
|
|
454
|
+
function callNames(code) {
|
|
455
|
+
const out = [];
|
|
456
|
+
for (const m of String(code).matchAll(/\b([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/g)) if (!KEYWORDS.has(m[1]) && !out.includes(m[1])) out.push(m[1]);
|
|
457
|
+
return out;
|
|
967
458
|
}
|
|
968
459
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
460
|
+
/** A regular expression for one source line: the call names stay, strings and the other identifiers become classes. */
|
|
461
|
+
function generalizeLine(line) {
|
|
462
|
+
const keep = new Set(callNames(line));
|
|
463
|
+
let out = ''; let i = 0;
|
|
464
|
+
while (i < line.length) {
|
|
465
|
+
const c = line[i];
|
|
466
|
+
if (/\s/.test(c)) { const before = line[i - 1] ?? ''; while (/\s/.test(line[i] ?? '')) i++; out += /[\w$]/.test(before) && /[\w$]/.test(line[i] ?? '') ? String.raw`\s+` : String.raw`\s*`; continue; }
|
|
467
|
+
if (c === '"' || c === "'" || c === '`') { const q = c; let j = i + 1; while (j < line.length && line[j] !== q) j += line[j] === '\\' ? 2 : 1; out += q === '`' ? '`[^`]*`' : `(?:'[^']*'|"[^"]*")`; i = j + 1; continue; }
|
|
468
|
+
const id = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/.exec(line.slice(i));
|
|
469
|
+
if (id) { const tok = id[0]; const isCall = line[i + tok.length] === '(' || keep.has(tok); out += isCall || KEYWORDS.has(tok) ? esc(tok) : String.raw`[A-Za-z_$][\w$.]*`; i += tok.length; continue; }
|
|
470
|
+
const num = /^\d+(?:\.\d+)?/.exec(line.slice(i));
|
|
471
|
+
if (num) { out += String.raw`\d+(?:\.\d+)?`; i += num[0].length; continue; }
|
|
472
|
+
out += esc(c); i++;
|
|
473
|
+
}
|
|
474
|
+
return out;
|
|
976
475
|
}
|
|
977
476
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
// Python — pytest.ini / pyproject.toml / tox.ini / a tests/ dir, and the
|
|
998
|
-
// `pytest` binary actually available. If pytest isn't installed we do NOT
|
|
999
|
-
// report a python test command — falling through lets a later language
|
|
1000
|
-
// marker (e.g. go.mod in a polyglot repo) still be detected.
|
|
1001
|
-
const pyMarker = _exists(scanRoot, 'pytest.ini') || _exists(scanRoot, 'pyproject.toml') ||
|
|
1002
|
-
_exists(scanRoot, 'tox.ini') || _isDir(scanRoot, 'tests');
|
|
1003
|
-
if (pyMarker && _binaryAvailable('pytest')) {
|
|
1004
|
-
return { cmd: 'pytest', args: ['-q'], kind: 'pytest' };
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
// Go
|
|
1008
|
-
if (_exists(scanRoot, 'go.mod')) {
|
|
1009
|
-
return { cmd: 'go', args: ['test', './...'], kind: 'go' };
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
return null;
|
|
477
|
+
const sinkOf = (line) => callNames(line).filter((n) => !/^(?:console|logger|log)\b/.test(n))[0] ?? null;
|
|
478
|
+
const interpolation = String.raw`(?:\+|\$\{|%s|%d|\.format\s*\(|\bf["']|\|\|)`;
|
|
479
|
+
|
|
480
|
+
/** Ordered proposals, most specific first. Each is {strategy, match}; `match.notMatch` is added by `withSafeNames`. */
|
|
481
|
+
function proposeCandidates({ vulnerableCode, patchedCode = null }) {
|
|
482
|
+
const vuln = onlyIn(vulnerableCode, patchedCode);
|
|
483
|
+
const lines = vuln.length ? vuln : String(vulnerableCode).split('\n').filter(meaningful).map((l) => l.trim()).filter((l) => callNames(l).length);
|
|
484
|
+
const out = []; const seen = new Set();
|
|
485
|
+
const add = (strategy, match) => { const k = JSON.stringify(match); if (!seen.has(k)) { seen.add(k); out.push({ strategy, match }); } };
|
|
486
|
+
for (const l of lines.slice(0, 3)) add('line-shape', { pattern: generalizeLine(l) });
|
|
487
|
+
for (const l of lines.slice(0, 3)) {
|
|
488
|
+
const sink = sinkOf(l);
|
|
489
|
+
if (sink) add('sink-with-interpolation', { pattern: `${esc(sink)}\\s*\\([^)\\n]*${interpolation}` });
|
|
490
|
+
}
|
|
491
|
+
const sink = lines.map(sinkOf).find(Boolean);
|
|
492
|
+
const src = SOURCES.find((s) => new RegExp(s).test(vulnerableCode));
|
|
493
|
+
if (sink && src) add('source-and-sink', { allOf: [`${esc(sink)}\\s*\\(`, src], window: 6 });
|
|
494
|
+
if (sink) add('sink-call', { pattern: `${esc(sink)}\\s*\\(` });
|
|
495
|
+
return out;
|
|
1013
496
|
}
|
|
1014
497
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const command = detectTestCommand(scanRoot);
|
|
1024
|
-
if (!command) {
|
|
1025
|
-
return {
|
|
1026
|
-
status: 'skipped', passed: null, skipped: true,
|
|
1027
|
-
reason: 'no-test-command-detected', exitCode: null, timedOut: false,
|
|
1028
|
-
durationMs: Date.now() - startedAt,
|
|
1029
|
-
};
|
|
1030
|
-
}
|
|
498
|
+
/** Names that appear in the patched code and not in the vulnerable code: the safe version's vocabulary. */
|
|
499
|
+
function safeNames({ vulnerableCode, patchedCode }) {
|
|
500
|
+
if (!patchedCode) return [];
|
|
501
|
+
const v = new Set(callNames(vulnerableCode));
|
|
502
|
+
const names = callNames(patchedCode).filter((n) => !v.has(n));
|
|
503
|
+
if (/\?['"`]?\s*[,)]/.test(patchedCode) && !/\?['"`]?\s*[,)]/.test(vulnerableCode)) names.push('?');
|
|
504
|
+
return names;
|
|
505
|
+
}
|
|
1031
506
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
r = (0,node_child_process__WEBPACK_IMPORTED_MODULE_0__.spawnSync)(command.cmd, command.args, {
|
|
1035
|
-
cwd: scanRoot,
|
|
1036
|
-
encoding: 'utf8',
|
|
1037
|
-
timeout: timeoutMs,
|
|
1038
|
-
env: { ...process.env, CI: '1' },
|
|
1039
|
-
});
|
|
1040
|
-
} catch (e) {
|
|
1041
|
-
// The spawn call itself threw (rare — e.g. cwd vanished). Treat as
|
|
1042
|
-
// "could not run", not "ran and failed".
|
|
1043
|
-
return {
|
|
1044
|
-
status: 'skipped', passed: null, skipped: true,
|
|
1045
|
-
reason: `spawn-error: ${e.message}`, exitCode: null, timedOut: false,
|
|
1046
|
-
durationMs: Date.now() - startedAt,
|
|
1047
|
-
};
|
|
1048
|
-
}
|
|
1049
|
-
const durationMs = Date.now() - startedAt;
|
|
1050
|
-
|
|
1051
|
-
if (r.error && r.error.code === 'ENOENT') {
|
|
1052
|
-
// The detected tool isn't actually installed on this machine. Not a
|
|
1053
|
-
// test failure — the suite never ran.
|
|
1054
|
-
return {
|
|
1055
|
-
status: 'skipped', passed: null, skipped: true,
|
|
1056
|
-
reason: `${command.kind}-not-installed`, exitCode: null, timedOut: false, durationMs,
|
|
1057
|
-
};
|
|
1058
|
-
}
|
|
507
|
+
const PLACEHOLDER_RE = '\\?[\'"`]?\\s*[,)]';
|
|
508
|
+
const safeRegex = (names) => (names.length ? names.map((n) => (n === '?' ? PLACEHOLDER_RE : esc(n))).join('|') : null);
|
|
1059
509
|
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
// another signal; either way the run did not complete, which is a
|
|
1063
|
-
// verification failure, never a skip — we asked for a result and the
|
|
1064
|
-
// process was terminated before producing one.
|
|
1065
|
-
return {
|
|
1066
|
-
status: 'failed', passed: false, skipped: false,
|
|
1067
|
-
reason: 'timed-out', exitCode: null, timedOut: true, durationMs,
|
|
1068
|
-
};
|
|
1069
|
-
}
|
|
510
|
+
const summary = (score) => ({ precision: score.precision, recall: score.recall, tp: score.tp, fp: score.fp, fn: score.fn, tn: score.tn, protectedFailures: score.protectedFailures });
|
|
511
|
+
const quality = (e) => (e.pass ? 10 : 0) + (e.score.precision ?? 0) * (e.score.recall ?? 0) - e.score.protectedFailures * 0.5;
|
|
1070
512
|
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
513
|
+
/**
|
|
514
|
+
* @param {object} o
|
|
515
|
+
* @param {object} o.base the IR fields that are not the match: id, title, severity, cwe, family, languages, remediation, evidence
|
|
516
|
+
* @param {object} o.corpusSpec { originalCode, patchedCode?, nearbyCleanCode?, protectedVariantsCode? }
|
|
517
|
+
* @param {object|null} o.proposed a match supplied by a model or a person, tried first
|
|
518
|
+
* @returns {{attempts: object[], best: object|null, exhausted: boolean}}
|
|
519
|
+
*/
|
|
520
|
+
function synthesizeCandidate({ base, corpusSpec, proposed = null, maxAttempts = 6, thresholds, leakageOk = true }) {
|
|
521
|
+
const cap = Math.max(1, Math.min(12, maxAttempts));
|
|
522
|
+
const queue = [];
|
|
523
|
+
if (proposed) queue.push({ strategy: 'proposed', match: proposed });
|
|
524
|
+
for (const p of proposeCandidates({ vulnerableCode: corpusSpec.originalCode, patchedCode: corpusSpec.patchedCode })) queue.push(p);
|
|
525
|
+
const names = safeNames({ vulnerableCode: corpusSpec.originalCode, patchedCode: corpusSpec.patchedCode });
|
|
526
|
+
const attempts = []; const tried = new Set();
|
|
527
|
+
const tryOne = (strategy, match) => {
|
|
528
|
+
const key = JSON.stringify(match);
|
|
529
|
+
if (tried.has(key) || attempts.length >= cap) return null;
|
|
530
|
+
tried.add(key);
|
|
531
|
+
const ir = (0,_ir_js__WEBPACK_IMPORTED_MODULE_0__/* .draftRuleIR */ .pc)({ ...base, match });
|
|
532
|
+
const errs = (0,_ir_js__WEBPACK_IMPORTED_MODULE_0__/* .validateRuleIR */ .g)(ir);
|
|
533
|
+
if (errs.length) { attempts.push({ n: attempts.length + 1, strategy, match, valid: false, errors: errs, pass: false, reasons: errs }); return null; }
|
|
534
|
+
const corpus = (0,_corpus_js__WEBPACK_IMPORTED_MODULE_1__/* .buildCorpus */ .F)(ir, corpusSpec);
|
|
535
|
+
const ev = (0,_promotion_js__WEBPACK_IMPORTED_MODULE_2__/* .evaluateRule */ .kx)(ir, corpus, thresholds);
|
|
536
|
+
const rec = { n: attempts.length + 1, strategy, match, valid: true, pass: ev.pass && leakageOk, reasons: ev.reasons, score: summary(ev.score), ir, corpusSizes: { positives: corpus.positives.length, negatives: corpus.negatives.length } };
|
|
537
|
+
attempts.push(rec);
|
|
538
|
+
return { rec, ev };
|
|
1079
539
|
};
|
|
540
|
+
for (let qi = 0; qi < queue.length && attempts.length < cap; qi++) {
|
|
541
|
+
const first = tryOne(queue[qi].strategy, queue[qi].match);
|
|
542
|
+
if (!first) continue;
|
|
543
|
+
if (first.rec.pass) break;
|
|
544
|
+
// revise from the failure: a negative that fired means the safe version is not excluded yet
|
|
545
|
+
const fired = first.ev.score.negatives.filter((n) => n.fired);
|
|
546
|
+
const rx = safeRegex(names);
|
|
547
|
+
if (fired.length && rx && !queue[qi].match.notMatch) { const r = tryOne(`${queue[qi].strategy}+exclude-safe`, { ...queue[qi].match, notMatch: rx }); if (r?.rec.pass) break; }
|
|
548
|
+
}
|
|
549
|
+
const valid = attempts.filter((a) => a.valid);
|
|
550
|
+
const best = valid.length ? [...valid].sort((a, b) => quality({ pass: b.pass, score: b.score }) - quality({ pass: a.pass, score: a.score }))[0] : null;
|
|
551
|
+
return { attempts: attempts.map(({ ir, ...rest }) => rest), best: best ? { strategy: best.strategy, match: best.match, pass: best.pass, score: best.score, reasons: best.reasons } : null, exhausted: !attempts.some((a) => a.pass) };
|
|
1080
552
|
}
|
|
1081
553
|
|
|
1082
554
|
|