@saiteja1123/mcp-server 1.1.4 → 1.1.5
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/package.json +59 -55
- package/src/api-scan.mjs +362 -93
- package/src/cli.js +713 -322
- package/src/deep-scan/contracts.js +201 -0
- package/src/deep-scan/deterministic-scan.js +337 -0
- package/src/deep-scan/index.js +109 -0
- package/src/deep-scan/project-map.js +507 -0
- package/src/deep-scan/ralph-accept.js +510 -0
- package/src/deep-scan/ralph-compare.js +498 -0
- package/src/deep-scan/ralph-tasks.js +598 -0
- package/src/deep-scan/ralph-track.js +548 -0
- package/src/deep-scan/registry.js +159 -0
- package/src/deep-scan/runtime.js +275 -0
- package/src/deep-scan/sample-steppers.js +128 -0
- package/src/deep-scan/sourceSafe.js +73 -0
- package/src/deep-scan/status.js +70 -0
- package/src/deep-scan/store.js +57 -0
- package/src/deep-scan/test-plan.js +760 -0
- package/src/index.js +6 -5
- package/src/lock.mjs +55 -14
- package/src/middleware/governance.js +135 -0
- package/src/orchestrator/runScan.js +211 -0
- package/src/project-bindings.mjs +215 -0
- package/src/rule-engine/index.js +2 -1
- package/src/rule-engine/localScan.js +39 -12
- package/src/rule-engine/metadata.js +20 -0
- package/src/rule-engine/prompt.js +6 -5
- package/src/rule-engine/rules.js +71 -43
- package/src/rule-engine/score.js +5 -4
- package/src/security/pathGuard.js +170 -0
- package/src/selftest.js +2473 -0
- package/src/server.js +109 -150
- package/src/tools/deepScan.js +286 -0
- package/src/tools/localScan.js +85 -0
- package/src/tools/projects.js +124 -0
- package/src/tools/scanFile.js +131 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { createArtifactRef, validateRunIdSegment } from './contracts.js';
|
|
5
|
+
import { assertSourceSafePayload, cloneSourceSafe, findRawSourceField } from './sourceSafe.js';
|
|
6
|
+
|
|
7
|
+
export const SECURITY_TEST_PLAN_SCHEMA_VERSION = 'security_test_plan.v1';
|
|
8
|
+
export const SECURITY_TEST_PLAN_STEPPER_ID = 'tests.plan';
|
|
9
|
+
export const SECURITY_TEST_PLAN_STEPPER_VERSION = '1.0.0';
|
|
10
|
+
export const SECURITY_TEST_PLAN_STATUSES = Object.freeze(['planned', 'runnable', 'blocked', 'manual']);
|
|
11
|
+
export const SECURITY_TEST_PLAN_STATUS_SEMANTICS = Object.freeze({
|
|
12
|
+
planned: 'Evidence-linked candidate that still needs a human or agent to create or identify a local test harness.',
|
|
13
|
+
runnable: 'Existing local test command or harness signal was discovered; Vibesecur has not generated or executed a test.',
|
|
14
|
+
blocked: 'Candidate cannot run until the missing local harness, environment, or dependency is supplied.',
|
|
15
|
+
manual: 'Candidate requires human local review because credentials, session context, or secret values must stay outside artifacts.',
|
|
16
|
+
});
|
|
17
|
+
export const SECURITY_TEST_PLAN_STABLE_CANDIDATE_FIELDS = Object.freeze([
|
|
18
|
+
'id',
|
|
19
|
+
'title',
|
|
20
|
+
'category',
|
|
21
|
+
'status',
|
|
22
|
+
'priority',
|
|
23
|
+
'evidence',
|
|
24
|
+
'sourceArtifactRefs',
|
|
25
|
+
'sourceFindingIds',
|
|
26
|
+
'suggestedCommand',
|
|
27
|
+
'blockedReason',
|
|
28
|
+
'manualReason',
|
|
29
|
+
'rank',
|
|
30
|
+
'dedupeKey',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const nowIso = () => new Date().toISOString();
|
|
34
|
+
const SHA256 = /^[a-f0-9]{64}$/i;
|
|
35
|
+
const UUIDISH = /^[a-zA-Z0-9_.:-]+$/;
|
|
36
|
+
const TEST_PLAN_FILE = 'security-test-plan.json';
|
|
37
|
+
|
|
38
|
+
const SEVERITY_WEIGHT = Object.freeze({
|
|
39
|
+
critical: 400,
|
|
40
|
+
high: 300,
|
|
41
|
+
medium: 200,
|
|
42
|
+
low: 100,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const STATUS_WEIGHT = Object.freeze({
|
|
46
|
+
runnable: 40,
|
|
47
|
+
planned: 30,
|
|
48
|
+
manual: 20,
|
|
49
|
+
blocked: 10,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const LOCAL_TEST_COMMAND_PATTERNS = Object.freeze([
|
|
53
|
+
/\bnode\s+--test\b/i,
|
|
54
|
+
/\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b/i,
|
|
55
|
+
/\b(?:vitest|jest|mocha|ava|tap|c8)\b/i,
|
|
56
|
+
/\bplaywright\s+test\b/i,
|
|
57
|
+
/\bcypress\s+run\b/i,
|
|
58
|
+
/\bpytest\b/i,
|
|
59
|
+
/\bpython3?\s+-m\s+pytest\b/i,
|
|
60
|
+
/\bgo\s+test\b/i,
|
|
61
|
+
/\bcargo\s+test\b/i,
|
|
62
|
+
/\b(?:mvn|gradle)\s+test\b/i,
|
|
63
|
+
/\brspec\b/i,
|
|
64
|
+
/\bphpunit\b/i,
|
|
65
|
+
/\bdotnet\s+test\b/i,
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const FINDING_CATEGORY_MAP = [
|
|
69
|
+
[/secret|credential|password|token|key|jwt/i, 'secrets_config'],
|
|
70
|
+
[/sql|injection|command|subprocess|eval/i, 'injection'],
|
|
71
|
+
[/xss|html|script/i, 'input_validation_xss'],
|
|
72
|
+
[/ssrf|request|fetch|metadata/i, 'ssrf'],
|
|
73
|
+
[/path|traversal|file/i, 'path_traversal'],
|
|
74
|
+
[/cors|rate|auth|admin|payment|billing/i, 'api_abuse'],
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
function stableSort(value) {
|
|
78
|
+
if (Array.isArray(value)) return value.map(stableSort);
|
|
79
|
+
if (!value || typeof value !== 'object') return value;
|
|
80
|
+
return Object.keys(value)
|
|
81
|
+
.sort()
|
|
82
|
+
.reduce((acc, key) => {
|
|
83
|
+
acc[key] = stableSort(value[key]);
|
|
84
|
+
return acc;
|
|
85
|
+
}, {});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stableStringify(value) {
|
|
89
|
+
return JSON.stringify(stableSort(value));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sha256(value) {
|
|
93
|
+
return crypto.createHash('sha256').update(value).digest('hex');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requireString(value, name, { max = 500, pattern = null } = {}) {
|
|
97
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} must be a non-empty string`);
|
|
98
|
+
if (value.length > max) throw new Error(`${name} must be ${max} characters or fewer`);
|
|
99
|
+
if (pattern && !pattern.test(value)) throw new Error(`${name} has an invalid format`);
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function optionalString(value, name, options) {
|
|
104
|
+
if (value === undefined || value === null) return undefined;
|
|
105
|
+
return requireString(value, name, options);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function requireIsoDate(value, name) {
|
|
109
|
+
requireString(value, name, { max: 40 });
|
|
110
|
+
if (Number.isNaN(Date.parse(value))) throw new Error(`${name} must be an ISO timestamp`);
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function asArray(value, name) {
|
|
115
|
+
if (value === undefined) return [];
|
|
116
|
+
if (!Array.isArray(value)) throw new Error(`${name} must be an array`);
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function asObject(value, name) {
|
|
121
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
122
|
+
throw new Error(`${name} must be an object`);
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function safeText(value, fallback, max = 240) {
|
|
128
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
129
|
+
if (!text) return fallback;
|
|
130
|
+
if (findRawSourceField(text, 'testPlan.text')) return fallback;
|
|
131
|
+
return text;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function safeIdSegment(value, fallback = 'item') {
|
|
135
|
+
const token = String(value || '')
|
|
136
|
+
.toLowerCase()
|
|
137
|
+
.replace(/[^a-z0-9_.:-]+/g, '_')
|
|
138
|
+
.replace(/^_+|_+$/g, '')
|
|
139
|
+
.slice(0, 80);
|
|
140
|
+
return token || fallback;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isRecognizedLocalTestCommand(command) {
|
|
144
|
+
const normalized = String(command || '').trim();
|
|
145
|
+
if (!normalized || /\[redacted\]/i.test(normalized)) return false;
|
|
146
|
+
return LOCAL_TEST_COMMAND_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function firstRunnableCommand(projectMap = {}) {
|
|
150
|
+
const command = (projectMap.testCommands || [])
|
|
151
|
+
.find((item) => typeof item?.command === 'string' && isRecognizedLocalTestCommand(item.command));
|
|
152
|
+
return command?.command;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function mapFindingCategory(finding = {}) {
|
|
156
|
+
const haystack = [
|
|
157
|
+
finding.category,
|
|
158
|
+
finding.ruleId,
|
|
159
|
+
finding.ruleName,
|
|
160
|
+
finding.title,
|
|
161
|
+
].filter(Boolean).join(' ');
|
|
162
|
+
const mapped = FINDING_CATEGORY_MAP.find(([pattern]) => pattern.test(haystack));
|
|
163
|
+
return mapped ? mapped[1] : safeIdSegment(finding.category || 'security_regression', 'security_regression');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeRisk(value) {
|
|
167
|
+
const risk = safeIdSegment(value || 'medium', 'medium');
|
|
168
|
+
return SEVERITY_WEIGHT[risk] ? risk : 'medium';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function makeEvidence({ type, label, sourceArtifactRef, preview, summary, metadata = {} }) {
|
|
172
|
+
const evidence = {
|
|
173
|
+
type: requireString(type, 'evidence.type', { max: 80, pattern: UUIDISH }),
|
|
174
|
+
label: requireString(label, 'evidence.label', { max: 180 }),
|
|
175
|
+
sourceArtifactRef: requireString(sourceArtifactRef, 'evidence.sourceArtifactRef', { max: 160, pattern: UUIDISH }),
|
|
176
|
+
preview: optionalString(preview, 'evidence.preview', { max: 300 }),
|
|
177
|
+
summary: optionalString(summary, 'evidence.summary', { max: 500 }),
|
|
178
|
+
metadata: cloneSourceSafe(metadata, 'testPlan.evidence.metadata'),
|
|
179
|
+
};
|
|
180
|
+
assertSourceSafePayload(evidence, 'testPlan.evidence');
|
|
181
|
+
return evidence;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function candidateId(dedupeKey) {
|
|
185
|
+
return `test-${sha256(dedupeKey).slice(0, 16)}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function defaultStatusSemantics() {
|
|
189
|
+
return { ...SECURITY_TEST_PLAN_STATUS_SEMANTICS };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function defaultDownstreamContract() {
|
|
193
|
+
return {
|
|
194
|
+
schemaVersion: SECURITY_TEST_PLAN_SCHEMA_VERSION,
|
|
195
|
+
consumerIntent: 'Ralph tasks, reports, passports, and release governance consume metadata refs only.',
|
|
196
|
+
stableCandidateFields: [...SECURITY_TEST_PLAN_STABLE_CANDIDATE_FIELDS],
|
|
197
|
+
executionProof: {
|
|
198
|
+
planned: false,
|
|
199
|
+
runnable: false,
|
|
200
|
+
blocked: false,
|
|
201
|
+
manual: false,
|
|
202
|
+
},
|
|
203
|
+
sourceSafety: {
|
|
204
|
+
rawSourceRequired: false,
|
|
205
|
+
evidenceUsesRefsOnly: true,
|
|
206
|
+
generatedTestBodiesStored: false,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeStatusSemantics(input) {
|
|
212
|
+
const semantics = cloneSourceSafe(input || defaultStatusSemantics(), 'testPlan.statusSemantics');
|
|
213
|
+
for (const status of SECURITY_TEST_PLAN_STATUSES) {
|
|
214
|
+
requireString(semantics[status], `testPlan.statusSemantics.${status}`, { max: 240 });
|
|
215
|
+
}
|
|
216
|
+
return semantics;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function normalizeDownstreamContract(input) {
|
|
220
|
+
const contract = cloneSourceSafe(input || defaultDownstreamContract(), 'testPlan.downstreamContract');
|
|
221
|
+
const stableCandidateFields = asArray(
|
|
222
|
+
contract.stableCandidateFields,
|
|
223
|
+
'testPlan.downstreamContract.stableCandidateFields',
|
|
224
|
+
).map((field, index) =>
|
|
225
|
+
requireString(field, `testPlan.downstreamContract.stableCandidateFields[${index}]`, {
|
|
226
|
+
max: 80,
|
|
227
|
+
pattern: UUIDISH,
|
|
228
|
+
}));
|
|
229
|
+
for (const field of SECURITY_TEST_PLAN_STABLE_CANDIDATE_FIELDS) {
|
|
230
|
+
if (!stableCandidateFields.includes(field)) {
|
|
231
|
+
throw new Error(`testPlan.downstreamContract.stableCandidateFields must include ${field}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
...contract,
|
|
236
|
+
schemaVersion: requireString(
|
|
237
|
+
contract.schemaVersion || SECURITY_TEST_PLAN_SCHEMA_VERSION,
|
|
238
|
+
'testPlan.downstreamContract.schemaVersion',
|
|
239
|
+
{ max: 80, pattern: UUIDISH },
|
|
240
|
+
),
|
|
241
|
+
stableCandidateFields,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function makeCandidate(input) {
|
|
246
|
+
const dedupeKey = requireString(input.dedupeKey, 'testCase.dedupeKey', { max: 240 });
|
|
247
|
+
const status = requireString(input.status, 'testCase.status', { max: 40, pattern: UUIDISH });
|
|
248
|
+
if (!SECURITY_TEST_PLAN_STATUSES.includes(status)) {
|
|
249
|
+
throw new Error(`testCase.status must be one of ${SECURITY_TEST_PLAN_STATUSES.join(', ')}`);
|
|
250
|
+
}
|
|
251
|
+
const commandHint = optionalString(input.commandHint ?? input.suggestedCommand, 'testCase.commandHint', { max: 300 });
|
|
252
|
+
const blockedReason = optionalString(input.blockedReason, 'testCase.blockedReason', { max: 400 });
|
|
253
|
+
const manualReason = optionalString(input.manualReason, 'testCase.manualReason', { max: 400 });
|
|
254
|
+
const risk = normalizeRisk(input.risk);
|
|
255
|
+
const testCase = {
|
|
256
|
+
id: candidateId(dedupeKey),
|
|
257
|
+
version: SECURITY_TEST_PLAN_SCHEMA_VERSION,
|
|
258
|
+
title: requireString(input.title, 'testCase.title', { max: 220 }),
|
|
259
|
+
purpose: requireString(input.purpose, 'testCase.purpose', { max: 500 }),
|
|
260
|
+
category: requireString(input.category, 'testCase.category', { max: 80, pattern: UUIDISH }),
|
|
261
|
+
risk,
|
|
262
|
+
priority: normalizeRisk(input.priority || risk),
|
|
263
|
+
status,
|
|
264
|
+
runner: requireString(input.runner, 'testCase.runner', { max: 80, pattern: UUIDISH }),
|
|
265
|
+
commandHint: commandHint || null,
|
|
266
|
+
suggestedCommand: commandHint || null,
|
|
267
|
+
blockedReason: blockedReason || null,
|
|
268
|
+
manualReason: manualReason || null,
|
|
269
|
+
targetSurface: {
|
|
270
|
+
type: requireString(input.targetSurface?.type, 'testCase.targetSurface.type', { max: 80, pattern: UUIDISH }),
|
|
271
|
+
files: asArray(input.targetSurface?.files, 'testCase.targetSurface.files')
|
|
272
|
+
.map((file, index) => requireString(file, `testCase.targetSurface.files[${index}]`, { max: 300 })),
|
|
273
|
+
routes: asArray(input.targetSurface?.routes, 'testCase.targetSurface.routes')
|
|
274
|
+
.map((route, index) => requireString(route, `testCase.targetSurface.routes[${index}]`, { max: 160 })),
|
|
275
|
+
},
|
|
276
|
+
sourceFindingIds: asArray(input.sourceFindingIds, 'testCase.sourceFindingIds')
|
|
277
|
+
.map((id, index) => requireString(id, `testCase.sourceFindingIds[${index}]`, { max: 160, pattern: UUIDISH })),
|
|
278
|
+
sourceArtifactRefs: asArray(input.sourceArtifactRefs, 'testCase.sourceArtifactRefs')
|
|
279
|
+
.map((id, index) => requireString(id, `testCase.sourceArtifactRefs[${index}]`, { max: 160, pattern: UUIDISH })),
|
|
280
|
+
evidence: asArray(input.evidence, 'testCase.evidence'),
|
|
281
|
+
setup: asArray(input.setup, 'testCase.setup')
|
|
282
|
+
.map((item, index) => requireString(item, `testCase.setup[${index}]`, { max: 300 })),
|
|
283
|
+
steps: asArray(input.steps, 'testCase.steps')
|
|
284
|
+
.map((item, index) => requireString(item, `testCase.steps[${index}]`, { max: 400 })),
|
|
285
|
+
assertion: requireString(input.assertion, 'testCase.assertion', { max: 500 }),
|
|
286
|
+
dedupeKey,
|
|
287
|
+
rank: Number.isInteger(input.rank) && input.rank > 0 ? input.rank : 1,
|
|
288
|
+
metadata: cloneSourceSafe(input.metadata || {}, 'testPlan.testCase.metadata'),
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
if (testCase.sourceArtifactRefs.length === 0) throw new Error('testCase.sourceArtifactRefs must include evidence refs');
|
|
292
|
+
if (testCase.evidence.length === 0) throw new Error('testCase.evidence must include at least one evidence item');
|
|
293
|
+
if (status === 'runnable' && !testCase.commandHint) throw new Error('runnable tests require commandHint');
|
|
294
|
+
if (status === 'blocked' && !testCase.blockedReason) throw new Error('blocked tests require blockedReason');
|
|
295
|
+
if (status === 'manual' && !testCase.manualReason) throw new Error('manual tests require manualReason');
|
|
296
|
+
assertSourceSafePayload(testCase, 'testPlan.testCase');
|
|
297
|
+
return testCase;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function findingCandidate({ finding, sourceRef, commandHint }) {
|
|
301
|
+
const filePath = safeText(finding.source?.filePath, 'unknown-file', 300);
|
|
302
|
+
const ruleId = safeIdSegment(finding.ruleId || 'rule');
|
|
303
|
+
const category = mapFindingCategory(finding);
|
|
304
|
+
const risk = normalizeRisk(finding.severity);
|
|
305
|
+
const status = commandHint ? 'runnable' : 'planned';
|
|
306
|
+
const dedupeKey = `finding:${finding.fingerprint || finding.id || ruleId}:${category}`;
|
|
307
|
+
return makeCandidate({
|
|
308
|
+
dedupeKey,
|
|
309
|
+
title: `Regression coverage for ${safeText(finding.ruleId || 'scanner finding', 'scanner finding', 80)} in ${filePath}`,
|
|
310
|
+
purpose: `Prove the referenced ${risk} finding is fixed or intentionally accepted before release.`,
|
|
311
|
+
category,
|
|
312
|
+
risk,
|
|
313
|
+
status,
|
|
314
|
+
runner: commandHint ? 'local_test_command' : 'agent_written_test',
|
|
315
|
+
commandHint,
|
|
316
|
+
targetSurface: { type: 'finding', files: [filePath], routes: [] },
|
|
317
|
+
sourceFindingIds: [finding.id || `finding-${sha256(dedupeKey).slice(0, 12)}`],
|
|
318
|
+
sourceArtifactRefs: [sourceRef],
|
|
319
|
+
evidence: [
|
|
320
|
+
makeEvidence({
|
|
321
|
+
type: 'deterministic_finding',
|
|
322
|
+
label: `${safeText(finding.ruleId || 'finding', 'finding', 80)} finding evidence`,
|
|
323
|
+
sourceArtifactRef: sourceRef,
|
|
324
|
+
preview: `${filePath}:${finding.source?.lineNumber || 1}`,
|
|
325
|
+
summary: safeText(finding.title || finding.ruleName || 'Deterministic scanner finding.', 'Deterministic scanner finding.'),
|
|
326
|
+
metadata: {
|
|
327
|
+
findingId: finding.id,
|
|
328
|
+
ruleId: finding.ruleId,
|
|
329
|
+
severity: risk,
|
|
330
|
+
filePath,
|
|
331
|
+
lineNumber: finding.source?.lineNumber || null,
|
|
332
|
+
},
|
|
333
|
+
}),
|
|
334
|
+
],
|
|
335
|
+
setup: commandHint
|
|
336
|
+
? ['Use the local test command discovered in the Project Map artifact.']
|
|
337
|
+
: ['Add or identify a local test harness before executing this regression check.'],
|
|
338
|
+
steps: [
|
|
339
|
+
'Create or update a focused security regression test for the referenced file and rule.',
|
|
340
|
+
'Run the local regression command or keep the test item planned if no harness exists yet.',
|
|
341
|
+
'Rerun Vibesecur deterministic scan and confirm the linked finding is resolved or explicitly accepted.',
|
|
342
|
+
],
|
|
343
|
+
assertion: 'The risky behavior is removed or safely rejected, and the deterministic finding no longer appears on rerun.',
|
|
344
|
+
metadata: {
|
|
345
|
+
generatedFrom: 'deterministic_scan',
|
|
346
|
+
ruleId: finding.ruleId,
|
|
347
|
+
fingerprint: finding.fingerprint,
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function routeCandidate({ routeFile, sourceRef, commandHint }) {
|
|
353
|
+
const filePath = safeText(routeFile.path, 'unknown-route', 300);
|
|
354
|
+
const status = commandHint ? 'runnable' : 'blocked';
|
|
355
|
+
return makeCandidate({
|
|
356
|
+
dedupeKey: `route:${filePath}:api_abuse`,
|
|
357
|
+
title: `Security coverage for route-like file ${filePath}`,
|
|
358
|
+
purpose: 'Verify route access, validation, and abuse controls for an observed project entry point.',
|
|
359
|
+
category: 'api_abuse',
|
|
360
|
+
risk: 'medium',
|
|
361
|
+
status,
|
|
362
|
+
runner: commandHint ? 'local_test_command' : 'blocked_no_harness',
|
|
363
|
+
commandHint,
|
|
364
|
+
blockedReason: status === 'blocked' ? 'No local test command was discovered in the Project Map artifact.' : undefined,
|
|
365
|
+
targetSurface: { type: 'route_file', files: [filePath], routes: [] },
|
|
366
|
+
sourceFindingIds: [],
|
|
367
|
+
sourceArtifactRefs: [sourceRef],
|
|
368
|
+
evidence: [
|
|
369
|
+
makeEvidence({
|
|
370
|
+
type: 'project_map_route',
|
|
371
|
+
label: 'Observed route-like file',
|
|
372
|
+
sourceArtifactRef: sourceRef,
|
|
373
|
+
preview: filePath,
|
|
374
|
+
summary: `Project Map classified this file as ${safeText(routeFile.kind, 'route_like', 80)}.`,
|
|
375
|
+
metadata: { filePath, kind: routeFile.kind || 'route_like' },
|
|
376
|
+
}),
|
|
377
|
+
],
|
|
378
|
+
setup: commandHint
|
|
379
|
+
? ['Use the local test command discovered in the Project Map artifact.']
|
|
380
|
+
: ['Add a local route test harness before this check can run.'],
|
|
381
|
+
steps: [
|
|
382
|
+
'Exercise the observed route surface through the local test harness.',
|
|
383
|
+
'Assert unauthenticated, malformed, and over-broad requests fail safely.',
|
|
384
|
+
'Keep the case linked to the Project Map route evidence for reruns.',
|
|
385
|
+
],
|
|
386
|
+
assertion: 'The route rejects unauthorized or malformed access with an explicit safe failure.',
|
|
387
|
+
metadata: { generatedFrom: 'project_map', routeKind: routeFile.kind || 'route_like' },
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isAuthSessionRoute(routeFile = {}) {
|
|
392
|
+
return /(^|[/._-])(auth|login|logout|session|sessions|jwt|oauth|saml|callback)([/._-]|$)/i
|
|
393
|
+
.test(String(routeFile.path || ''));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function authSessionCandidate({ routeFile, sourceRef }) {
|
|
397
|
+
const filePath = safeText(routeFile.path, 'unknown-auth-route', 300);
|
|
398
|
+
return makeCandidate({
|
|
399
|
+
dedupeKey: `auth-session:${filePath}:manual`,
|
|
400
|
+
title: `Manual auth/session review for ${filePath}`,
|
|
401
|
+
purpose: 'Verify login, session, token, and authorization behavior using local credentials and browser or API state only.',
|
|
402
|
+
category: 'auth_session',
|
|
403
|
+
risk: 'high',
|
|
404
|
+
status: 'manual',
|
|
405
|
+
runner: 'manual_review',
|
|
406
|
+
manualReason: 'Auth/session checks require local credentials, cookies, or tokens that must not be copied into Vibesecur artifacts.',
|
|
407
|
+
targetSurface: { type: 'auth_session', files: [filePath], routes: [] },
|
|
408
|
+
sourceFindingIds: [],
|
|
409
|
+
sourceArtifactRefs: [sourceRef],
|
|
410
|
+
evidence: [
|
|
411
|
+
makeEvidence({
|
|
412
|
+
type: 'project_map_route',
|
|
413
|
+
label: 'Auth/session route-like file',
|
|
414
|
+
sourceArtifactRef: sourceRef,
|
|
415
|
+
preview: filePath,
|
|
416
|
+
summary: `Project Map classified this auth/session file as ${safeText(routeFile.kind, 'route_like', 80)}.`,
|
|
417
|
+
metadata: { filePath, kind: routeFile.kind || 'route_like', manualOnly: true },
|
|
418
|
+
}),
|
|
419
|
+
],
|
|
420
|
+
setup: ['Prepare local credentials, cookies, or session fixtures without copying values into Vibesecur artifacts.'],
|
|
421
|
+
steps: [
|
|
422
|
+
'Exercise login, logout, session refresh, and unauthorized access behavior locally.',
|
|
423
|
+
'Confirm credentials, provider keys, install tokens, JWTs, and cookies are never pasted into Vibesecur outputs.',
|
|
424
|
+
'Record pass/fail evidence as redacted metadata or a local-only report reference.',
|
|
425
|
+
],
|
|
426
|
+
assertion: 'Auth and session controls fail closed without exposing credential or token values in Vibesecur artifacts.',
|
|
427
|
+
metadata: { generatedFrom: 'project_map', routeKind: routeFile.kind || 'route_like', manualOnly: true },
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function envCandidate({ envFile, sourceRef }) {
|
|
432
|
+
const filePath = safeText(envFile.path, 'env-file', 300);
|
|
433
|
+
return makeCandidate({
|
|
434
|
+
dedupeKey: `env:${filePath}:secrets_config`,
|
|
435
|
+
title: `Manual secret-handling check for ${filePath}`,
|
|
436
|
+
purpose: 'Confirm environment values stay local and are represented only by filenames or hashes in Vibesecur artifacts.',
|
|
437
|
+
category: 'secrets_config',
|
|
438
|
+
risk: 'high',
|
|
439
|
+
status: 'manual',
|
|
440
|
+
runner: 'manual_review',
|
|
441
|
+
manualReason: 'Environment filenames are visible but values are intentionally unread; human review must happen locally.',
|
|
442
|
+
targetSurface: { type: 'env_file', files: [filePath], routes: [] },
|
|
443
|
+
sourceFindingIds: [],
|
|
444
|
+
sourceArtifactRefs: [sourceRef],
|
|
445
|
+
evidence: [
|
|
446
|
+
makeEvidence({
|
|
447
|
+
type: 'project_map_env_file',
|
|
448
|
+
label: 'Environment filename observed',
|
|
449
|
+
sourceArtifactRef: sourceRef,
|
|
450
|
+
preview: filePath,
|
|
451
|
+
summary: 'Project Map recorded the env filename with valuesCaptured:false.',
|
|
452
|
+
metadata: { filePath, valuesCaptured: envFile.valuesCaptured === true },
|
|
453
|
+
}),
|
|
454
|
+
],
|
|
455
|
+
setup: ['Review local env handling without copying values into Vibesecur artifacts.'],
|
|
456
|
+
steps: [
|
|
457
|
+
'Confirm the env file is ignored by version control or intentionally templated.',
|
|
458
|
+
'Confirm runtime secrets are supplied through the deployment secret manager.',
|
|
459
|
+
'Confirm no Vibesecur artifact includes environment values.',
|
|
460
|
+
],
|
|
461
|
+
assertion: 'Secret values remain outside source control and outside hosted Vibesecur storage.',
|
|
462
|
+
metadata: { generatedFrom: 'project_map', valuesCaptured: false },
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function deploymentCandidate({ deploymentFile, sourceRef }) {
|
|
467
|
+
const filePath = safeText(deploymentFile.path, 'deployment-file', 300);
|
|
468
|
+
return makeCandidate({
|
|
469
|
+
dedupeKey: `deployment:${filePath}:deployment_config`,
|
|
470
|
+
title: `Deployment configuration review for ${filePath}`,
|
|
471
|
+
purpose: 'Verify deployment config does not bypass the local-first and no-secret-retention trust model.',
|
|
472
|
+
category: 'deployment_config',
|
|
473
|
+
risk: 'medium',
|
|
474
|
+
status: 'planned',
|
|
475
|
+
runner: 'static_config_review',
|
|
476
|
+
targetSurface: { type: 'deployment_file', files: [filePath], routes: [] },
|
|
477
|
+
sourceFindingIds: [],
|
|
478
|
+
sourceArtifactRefs: [sourceRef],
|
|
479
|
+
evidence: [
|
|
480
|
+
makeEvidence({
|
|
481
|
+
type: 'project_map_deployment_file',
|
|
482
|
+
label: 'Deployment file observed',
|
|
483
|
+
sourceArtifactRef: sourceRef,
|
|
484
|
+
preview: filePath,
|
|
485
|
+
summary: `Project Map classified this as ${safeText(deploymentFile.type, 'deployment', 80)} config.`,
|
|
486
|
+
metadata: { filePath, deploymentType: deploymentFile.type || 'deployment' },
|
|
487
|
+
}),
|
|
488
|
+
],
|
|
489
|
+
setup: ['Review the deployment file locally with source and secrets excluded from Vibesecur storage.'],
|
|
490
|
+
steps: [
|
|
491
|
+
'Check security headers, secret references, and environment variable handling in the deployment config.',
|
|
492
|
+
'Record any required local follow-up as a Ralph task or accepted risk.',
|
|
493
|
+
],
|
|
494
|
+
assertion: 'Deployment config preserves safe secret handling and does not expose source or provider credentials.',
|
|
495
|
+
metadata: { generatedFrom: 'project_map', deploymentType: deploymentFile.type || 'deployment' },
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function candidateScore(candidate) {
|
|
500
|
+
return (SEVERITY_WEIGHT[candidate.risk] || 0)
|
|
501
|
+
+ (STATUS_WEIGHT[candidate.status] || 0)
|
|
502
|
+
+ (candidate.sourceFindingIds.length > 0 ? 20 : 0)
|
|
503
|
+
+ (candidate.targetSurface.files.length > 0 ? 5 : 0);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function dedupeAndRank(candidates) {
|
|
507
|
+
const byKey = new Map();
|
|
508
|
+
for (const candidate of candidates) {
|
|
509
|
+
const current = byKey.get(candidate.dedupeKey);
|
|
510
|
+
if (!current || candidateScore(candidate) > candidateScore(current)) {
|
|
511
|
+
byKey.set(candidate.dedupeKey, candidate);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return [...byKey.values()]
|
|
515
|
+
.sort((a, b) => candidateScore(b) - candidateScore(a) || a.dedupeKey.localeCompare(b.dedupeKey))
|
|
516
|
+
.map((candidate, index) => ({ ...candidate, rank: index + 1 }));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function countsBy(items, key) {
|
|
520
|
+
return items.reduce((acc, item) => {
|
|
521
|
+
const value = item[key] || 'unknown';
|
|
522
|
+
acc[value] = (acc[value] || 0) + 1;
|
|
523
|
+
return acc;
|
|
524
|
+
}, {});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function buildSources({ projectMap = {}, deterministicScan = {}, sourceRefs }) {
|
|
528
|
+
return {
|
|
529
|
+
projectMap: {
|
|
530
|
+
artifactRef: sourceRefs.projectMap,
|
|
531
|
+
schemaVersion: projectMap.schemaVersion || null,
|
|
532
|
+
rootPathHash: projectMap.root?.pathHash || null,
|
|
533
|
+
routeFileCount: (projectMap.routeFiles || []).length,
|
|
534
|
+
envFileCount: (projectMap.envFiles || []).length,
|
|
535
|
+
deploymentFileCount: (projectMap.deploymentFiles || []).length,
|
|
536
|
+
testCommandCount: (projectMap.testCommands || []).length,
|
|
537
|
+
},
|
|
538
|
+
deterministicScan: {
|
|
539
|
+
artifactRef: sourceRefs.deterministicScan,
|
|
540
|
+
schemaVersion: deterministicScan.schemaVersion || null,
|
|
541
|
+
rootPathHash: deterministicScan.root?.pathHash || null,
|
|
542
|
+
findingCount: (deterministicScan.findings || []).length,
|
|
543
|
+
deterministicRuleCount: deterministicScan.engine?.counts?.deterministic || null,
|
|
544
|
+
checklistCount: deterministicScan.engine?.counts?.checklist || null,
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function buildSecurityTestPlanArtifact({
|
|
550
|
+
projectMap,
|
|
551
|
+
deterministicScan,
|
|
552
|
+
generatedAt = nowIso(),
|
|
553
|
+
sourceRefs = {},
|
|
554
|
+
} = {}) {
|
|
555
|
+
const safeProjectMap = cloneSourceSafe(projectMap || {}, 'testPlan.projectMapInput');
|
|
556
|
+
const safeDeterministicScan = cloneSourceSafe(deterministicScan || {}, 'testPlan.deterministicScanInput');
|
|
557
|
+
const projectMapRef = sourceRefs.projectMap || 'project_map';
|
|
558
|
+
const deterministicScanRef = sourceRefs.deterministicScan || 'deterministic_scan';
|
|
559
|
+
const commandHint = firstRunnableCommand(safeProjectMap);
|
|
560
|
+
const routeFiles = safeProjectMap.routeFiles || [];
|
|
561
|
+
|
|
562
|
+
const candidates = [
|
|
563
|
+
...(safeDeterministicScan.findings || []).map((finding) =>
|
|
564
|
+
findingCandidate({ finding, sourceRef: deterministicScanRef, commandHint })),
|
|
565
|
+
...routeFiles.map((routeFile) =>
|
|
566
|
+
routeCandidate({ routeFile, sourceRef: projectMapRef, commandHint })),
|
|
567
|
+
...routeFiles.filter(isAuthSessionRoute).map((routeFile) =>
|
|
568
|
+
authSessionCandidate({ routeFile, sourceRef: projectMapRef })),
|
|
569
|
+
...(safeProjectMap.envFiles || []).map((envFile) =>
|
|
570
|
+
envCandidate({ envFile, sourceRef: projectMapRef })),
|
|
571
|
+
...(safeProjectMap.deploymentFiles || []).map((deploymentFile) =>
|
|
572
|
+
deploymentCandidate({ deploymentFile, sourceRef: projectMapRef })),
|
|
573
|
+
];
|
|
574
|
+
|
|
575
|
+
const testCases = dedupeAndRank(candidates);
|
|
576
|
+
const artifact = {
|
|
577
|
+
schemaVersion: SECURITY_TEST_PLAN_SCHEMA_VERSION,
|
|
578
|
+
generatedAt: requireIsoDate(generatedAt, 'testPlan.generatedAt'),
|
|
579
|
+
root: {
|
|
580
|
+
name: safeProjectMap.root?.name || safeDeterministicScan.root?.name || 'project',
|
|
581
|
+
pathHash: safeProjectMap.root?.pathHash || safeDeterministicScan.root?.pathHash || null,
|
|
582
|
+
},
|
|
583
|
+
sources: buildSources({
|
|
584
|
+
projectMap: safeProjectMap,
|
|
585
|
+
deterministicScan: safeDeterministicScan,
|
|
586
|
+
sourceRefs: { projectMap: projectMapRef, deterministicScan: deterministicScanRef },
|
|
587
|
+
}),
|
|
588
|
+
statusSemantics: defaultStatusSemantics(),
|
|
589
|
+
downstreamContract: defaultDownstreamContract(),
|
|
590
|
+
summary: {
|
|
591
|
+
totalTests: testCases.length,
|
|
592
|
+
byStatus: countsBy(testCases, 'status'),
|
|
593
|
+
byCategory: countsBy(testCases, 'category'),
|
|
594
|
+
highestRisk: testCases[0]?.risk || null,
|
|
595
|
+
runnableCount: testCases.filter((testCase) => testCase.status === 'runnable').length,
|
|
596
|
+
blockedCount: testCases.filter((testCase) => testCase.status === 'blocked').length,
|
|
597
|
+
manualCount: testCases.filter((testCase) => testCase.status === 'manual').length,
|
|
598
|
+
plannedCount: testCases.filter((testCase) => testCase.status === 'planned').length,
|
|
599
|
+
},
|
|
600
|
+
testCommands: cloneSourceSafe(safeProjectMap.testCommands || [], 'testPlan.testCommands'),
|
|
601
|
+
testCases,
|
|
602
|
+
privacy: {
|
|
603
|
+
rawSourceStored: false,
|
|
604
|
+
secretValuesCaptured: false,
|
|
605
|
+
evidenceUsesRefsOnly: true,
|
|
606
|
+
artifactStorage: 'local_metadata_and_hash_refs',
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
return validateSecurityTestPlanArtifact(artifact);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export function validateSecurityTestPlanArtifact(input = {}) {
|
|
614
|
+
assertSourceSafePayload(input, 'securityTestPlanArtifact');
|
|
615
|
+
const artifact = {
|
|
616
|
+
schemaVersion: requireString(input.schemaVersion, 'testPlan.schemaVersion', { max: 80, pattern: UUIDISH }),
|
|
617
|
+
generatedAt: requireIsoDate(input.generatedAt, 'testPlan.generatedAt'),
|
|
618
|
+
root: {
|
|
619
|
+
name: requireString(input.root?.name || 'project', 'testPlan.root.name', { max: 180 }),
|
|
620
|
+
pathHash: optionalString(input.root?.pathHash, 'testPlan.root.pathHash', { max: 64, pattern: SHA256 }),
|
|
621
|
+
},
|
|
622
|
+
sources: cloneSourceSafe(asObject(input.sources || {}, 'testPlan.sources'), 'testPlan.sources'),
|
|
623
|
+
statusSemantics: normalizeStatusSemantics(input.statusSemantics),
|
|
624
|
+
downstreamContract: normalizeDownstreamContract(input.downstreamContract),
|
|
625
|
+
summary: cloneSourceSafe(asObject(input.summary || {}, 'testPlan.summary'), 'testPlan.summary'),
|
|
626
|
+
testCommands: cloneSourceSafe(asArray(input.testCommands, 'testPlan.testCommands'), 'testPlan.testCommands'),
|
|
627
|
+
testCases: asArray(input.testCases, 'testPlan.testCases').map((testCase) => makeCandidate(testCase)),
|
|
628
|
+
privacy: cloneSourceSafe(asObject(input.privacy || {}, 'testPlan.privacy'), 'testPlan.privacy'),
|
|
629
|
+
};
|
|
630
|
+
if (artifact.schemaVersion !== SECURITY_TEST_PLAN_SCHEMA_VERSION) {
|
|
631
|
+
throw new Error(`testPlan.schemaVersion must be ${SECURITY_TEST_PLAN_SCHEMA_VERSION}`);
|
|
632
|
+
}
|
|
633
|
+
assertSourceSafePayload(artifact, 'securityTestPlanArtifact');
|
|
634
|
+
return artifact;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export function hashSecurityTestPlanArtifact(artifact) {
|
|
638
|
+
assertSourceSafePayload(artifact, 'securityTestPlanArtifact');
|
|
639
|
+
return sha256(stableStringify(validateSecurityTestPlanArtifact(artifact)));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function readLocalArtifact({ rootPath, ref, type }) {
|
|
643
|
+
if (!ref || ref.type !== type || ref.storage !== 'local' || !ref.uri) {
|
|
644
|
+
throw new Error(`Security Test Plan requires a local ${type} artifact reference`);
|
|
645
|
+
}
|
|
646
|
+
const resolvedRoot = path.resolve(rootPath);
|
|
647
|
+
const target = path.resolve(resolvedRoot, ref.uri);
|
|
648
|
+
const relative = path.relative(resolvedRoot, target);
|
|
649
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
650
|
+
throw new Error(`Security Test Plan artifact ref for ${type} escapes the bound root`);
|
|
651
|
+
}
|
|
652
|
+
const raw = await fs.readFile(target, 'utf8');
|
|
653
|
+
const parsed = JSON.parse(raw);
|
|
654
|
+
assertSourceSafePayload(parsed, `securityTestPlan.${type}`);
|
|
655
|
+
return parsed;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function findArtifactRef(state, type, preferredKeys = []) {
|
|
659
|
+
const artifacts = state?.artifacts || {};
|
|
660
|
+
for (const key of preferredKeys) {
|
|
661
|
+
if (artifacts[key]?.type === type) return artifacts[key];
|
|
662
|
+
}
|
|
663
|
+
return Object.values(artifacts).find((ref) => ref?.type === type) || null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export async function writeSecurityTestPlanArtifact({ rootPath, runId, artifact }) {
|
|
667
|
+
const safeArtifact = validateSecurityTestPlanArtifact(artifact);
|
|
668
|
+
const safeRunId = validateRunIdSegment(runId, 'runId');
|
|
669
|
+
const relativeUri = `.vibesecur/deep-scans/${safeRunId}/${TEST_PLAN_FILE}`;
|
|
670
|
+
const target = path.join(path.resolve(rootPath), relativeUri);
|
|
671
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
672
|
+
const serialized = `${JSON.stringify(stableSort(safeArtifact), null, 2)}\n`;
|
|
673
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
674
|
+
await fs.writeFile(tmp, serialized, 'utf8');
|
|
675
|
+
await fs.rename(tmp, target);
|
|
676
|
+
return {
|
|
677
|
+
uri: relativeUri,
|
|
678
|
+
hash: sha256(serialized),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function securityTestPlanStepper() {
|
|
683
|
+
return {
|
|
684
|
+
id: SECURITY_TEST_PLAN_STEPPER_ID,
|
|
685
|
+
version: SECURITY_TEST_PLAN_STEPPER_VERSION,
|
|
686
|
+
title: 'Security Test Plan Stepper',
|
|
687
|
+
category: 'test_plan_generation',
|
|
688
|
+
requiredInputs: ['project_map', 'deterministic_scan'],
|
|
689
|
+
producedArtifacts: ['security_test_plan'],
|
|
690
|
+
defaultTimeoutMs: 30000,
|
|
691
|
+
async run({ runId, config = {}, state = {}, tools = {} }) {
|
|
692
|
+
const rootPath = tools.rootPath || config.rootPath || '.';
|
|
693
|
+
const startedAt = nowIso();
|
|
694
|
+
const projectMapRef = findArtifactRef(state, 'project_map', ['project.map']);
|
|
695
|
+
const deterministicScanRef = findArtifactRef(state, 'deterministic_scan', ['rules.scan']);
|
|
696
|
+
const projectMap = await readLocalArtifact({ rootPath, ref: projectMapRef, type: 'project_map' });
|
|
697
|
+
const deterministicScan = await readLocalArtifact({
|
|
698
|
+
rootPath,
|
|
699
|
+
ref: deterministicScanRef,
|
|
700
|
+
type: 'deterministic_scan',
|
|
701
|
+
});
|
|
702
|
+
const artifact = buildSecurityTestPlanArtifact({
|
|
703
|
+
projectMap,
|
|
704
|
+
deterministicScan,
|
|
705
|
+
generatedAt: config.generatedAt || startedAt,
|
|
706
|
+
sourceRefs: {
|
|
707
|
+
projectMap: projectMapRef.id,
|
|
708
|
+
deterministicScan: deterministicScanRef.id,
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
const contentHash = hashSecurityTestPlanArtifact(artifact);
|
|
712
|
+
const written = await writeSecurityTestPlanArtifact({ rootPath, runId, artifact });
|
|
713
|
+
const safeRunId = validateRunIdSegment(runId, 'runId');
|
|
714
|
+
const ref = createArtifactRef({
|
|
715
|
+
id: `artifact-${safeRunId}-security-test-plan`,
|
|
716
|
+
type: 'security_test_plan',
|
|
717
|
+
storage: 'local',
|
|
718
|
+
uri: written.uri,
|
|
719
|
+
hash: written.hash,
|
|
720
|
+
preview: 'Security Test Plan metadata: evidence-linked planned, runnable, blocked, and manual tests only.',
|
|
721
|
+
metadata: {
|
|
722
|
+
schemaVersion: SECURITY_TEST_PLAN_SCHEMA_VERSION,
|
|
723
|
+
contentHash,
|
|
724
|
+
generatedBy: SECURITY_TEST_PLAN_STEPPER_ID,
|
|
725
|
+
totalTests: artifact.summary.totalTests,
|
|
726
|
+
runnableCount: artifact.summary.runnableCount,
|
|
727
|
+
blockedCount: artifact.summary.blockedCount,
|
|
728
|
+
manualCount: artifact.summary.manualCount,
|
|
729
|
+
plannedCount: artifact.summary.plannedCount,
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
return {
|
|
734
|
+
stepperId: SECURITY_TEST_PLAN_STEPPER_ID,
|
|
735
|
+
version: SECURITY_TEST_PLAN_STEPPER_VERSION,
|
|
736
|
+
status: 'passed',
|
|
737
|
+
startedAt,
|
|
738
|
+
finishedAt: nowIso(),
|
|
739
|
+
evidence: [{
|
|
740
|
+
type: 'hash',
|
|
741
|
+
label: 'Security Test Plan artifact hash',
|
|
742
|
+
hash: ref.hash,
|
|
743
|
+
preview: 'Evidence-linked security test plan artifact written locally.',
|
|
744
|
+
summary: `${artifact.summary.totalTests} test candidate(s), ${artifact.summary.runnableCount} runnable.`,
|
|
745
|
+
metadata: {
|
|
746
|
+
artifactId: ref.id,
|
|
747
|
+
contentHash,
|
|
748
|
+
projectMapArtifactId: projectMapRef.id,
|
|
749
|
+
deterministicScanArtifactId: deterministicScanRef.id,
|
|
750
|
+
},
|
|
751
|
+
}],
|
|
752
|
+
findings: [],
|
|
753
|
+
artifacts: [ref],
|
|
754
|
+
receipts: [],
|
|
755
|
+
summary: `Security Test Plan generated: ${artifact.summary.totalTests} candidate(s), ${artifact.summary.runnableCount} runnable, ${artifact.summary.blockedCount} blocked, ${artifact.summary.manualCount} manual.`,
|
|
756
|
+
nextActions: ['Use the test plan artifact for Ralph tasks, reports, passports, and local rerun planning.'],
|
|
757
|
+
};
|
|
758
|
+
},
|
|
759
|
+
};
|
|
760
|
+
}
|