@dailephd/my-dev-kit-lab 0.2.0 → 0.2.2
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/LICENSE +21 -0
- package/README.md +35 -22
- package/dist/scripts/security/resolveToolRoot.js +16 -0
- package/dist/scripts/security/runCodeql.js +2 -1
- package/dist/scripts/security/runDependencyChecks.js +2 -1
- package/dist/scripts/security/runPackageChecks.js +2 -1
- package/dist/scripts/security/runSemgrep.js +2 -1
- package/dist/scripts/security/validate.js +70 -30
- package/dist/src/core/localProjectTarget.js +4 -0
- package/dist/src/core/runMeasuredCommand.js +10 -6
- package/dist/src/securityValidation/attackScenarios/attackProfile.js +20 -0
- package/dist/src/securityValidation/attackScenarios/attackResult.js +37 -0
- package/dist/src/securityValidation/attackScenarios/attackRunner.js +153 -0
- package/dist/src/securityValidation/attackScenarios/attackScenario.js +21 -0
- package/dist/src/securityValidation/attackScenarios/boundedSourceScan.js +100 -0
- package/dist/src/securityValidation/attackScenarios/exploitEvidence.js +69 -0
- package/dist/src/securityValidation/attackScenarios/payloadCorpus.js +0 -0
- package/dist/src/securityValidation/attackScenarios/profiles/localToolProfile.js +10 -0
- package/dist/src/securityValidation/attackScenarios/profiles/nodeCliPackageProfile.js +20 -0
- package/dist/src/securityValidation/attackScenarios/profiles/npmPackageProfile.js +10 -0
- package/dist/src/securityValidation/attackScenarios/reportSchemaGuard.js +41 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/configInjectionScenario.js +144 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/networkAssumptionScenario.js +129 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/outputBoundaryScenario.js +69 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/packageBoundaryScenario.js +118 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/pathTraversalScenario.js +119 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/reportPoisoningScenario.js +176 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/secretLeakageScenario.js +143 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/subprocessInjectionScenario.js +117 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/targetSandboxScenario.js +118 -0
- package/dist/src/securityValidation/attackScenarios/targetSnapshot.js +70 -0
- package/dist/src/securityValidation/packageChecks/forbiddenPackageContents.js +4 -1
- package/dist/src/securityValidation/report/renderSecurityReport.js +153 -12
- package/dist/src/securityValidation/types.js +7 -0
- package/dist/src/securityValidation/validate/cliOptions.js +182 -0
- package/dist/src/securityValidation/validate/runCliSecuritySuiteCheck.js +124 -0
- package/dist/src/securityValidation/validate/runSecurityValidation.js +135 -67
- package/dist/src/securityValidation/validate/verdict.js +147 -0
- package/package.json +14 -10
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { verdictToHumanLabel } from "../validate/verdict.js";
|
|
2
|
+
import { stripUnsafeControlChars } from "../attackScenarios/exploitEvidence.js";
|
|
2
3
|
// ---------------------------------------------------------------------------
|
|
3
4
|
// Human-readable text report
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
6
|
+
// v0.2.2 Batch 4 — sanitizes text-report content that may originate from
|
|
7
|
+
// scanned source, target output, or an attack-scenario payload before it is
|
|
8
|
+
// printed. Strips ANSI/control bytes (report-poisoning defense); preserves
|
|
9
|
+
// ordinary whitespace. JSON output needs no equivalent helper — JSON.stringify
|
|
10
|
+
// already escapes control characters by construction.
|
|
11
|
+
export function sanitizeForTextReport(raw) {
|
|
12
|
+
return stripUnsafeControlChars(raw);
|
|
13
|
+
}
|
|
5
14
|
function pad(label, width = 36) {
|
|
6
15
|
return label.padEnd(width, " ");
|
|
7
16
|
}
|
|
@@ -24,7 +33,7 @@ function divider(char = "-", width = 72) {
|
|
|
24
33
|
}
|
|
25
34
|
export function renderTextReport(report) {
|
|
26
35
|
const lines = [];
|
|
27
|
-
const { metadata, allChecks, allFindings, verdict, recommendedNextStep } = report;
|
|
36
|
+
const { metadata, allChecks, allFindings, verdict, recommendedNextStep, attackResults, verdictReasonSummary } = report;
|
|
28
37
|
lines.push(divider("="));
|
|
29
38
|
lines.push("SECURITY VALIDATION REPORT");
|
|
30
39
|
lines.push(divider("="));
|
|
@@ -41,6 +50,23 @@ export function renderTextReport(report) {
|
|
|
41
50
|
lines.push(`Commit : ${metadata.commit}`);
|
|
42
51
|
lines.push(`Generated : ${metadata.generatedAt}`);
|
|
43
52
|
lines.push(`Duration : ${formatDuration(metadata.totalDurationMs)}`);
|
|
53
|
+
if (metadata.profile) {
|
|
54
|
+
lines.push(`Profile : ${metadata.profile}`);
|
|
55
|
+
}
|
|
56
|
+
if (metadata.selectedChecks && metadata.selectedChecks.length > 0) {
|
|
57
|
+
lines.push(`Checks : ${metadata.selectedChecks.join(", ")}`);
|
|
58
|
+
}
|
|
59
|
+
if (metadata.failOnThreshold) {
|
|
60
|
+
const breachNote = metadata.failOnBreached === true
|
|
61
|
+
? " — BREACHED"
|
|
62
|
+
: metadata.failOnBreached === false
|
|
63
|
+
? " — not breached"
|
|
64
|
+
: "";
|
|
65
|
+
lines.push(`Fail-on : ${metadata.failOnThreshold}${breachNote}`);
|
|
66
|
+
}
|
|
67
|
+
if (metadata.isFullReleaseGate === false) {
|
|
68
|
+
lines.push(`Scope : NARROWED — selected checks do not include the full classic release gate (deps, package, static, cli-adversarial, fuzz). This run does not represent full release readiness.`);
|
|
69
|
+
}
|
|
44
70
|
lines.push("");
|
|
45
71
|
// --- 1. Executive Summary ---
|
|
46
72
|
lines.push(divider());
|
|
@@ -60,7 +86,7 @@ export function renderTextReport(report) {
|
|
|
60
86
|
lines.push(` ${sev.padEnd(16)} : ${count}`);
|
|
61
87
|
}
|
|
62
88
|
}
|
|
63
|
-
lines.push(`Next step : ${recommendedNextStep}`);
|
|
89
|
+
lines.push(`Next step : ${sanitizeForTextReport(recommendedNextStep)}`);
|
|
64
90
|
lines.push("");
|
|
65
91
|
// --- Check sections ---
|
|
66
92
|
const sectionDefs = [
|
|
@@ -72,7 +98,7 @@ export function renderTextReport(report) {
|
|
|
72
98
|
{ num: 7, title: "npm audit Result", ids: ["npm-audit-full", "npm-audit-runtime"] },
|
|
73
99
|
{ num: 8, title: "OSV-Scanner Result", ids: ["osv-scanner"] },
|
|
74
100
|
{ num: 9, title: "Package Tarball Inspection", ids: ["npm-pack-dry-run"] },
|
|
75
|
-
{ num: 10, title: "File-System / Path Traversal Tests", ids: ["path-traversal-root", "path-traversal-out", "path-traversal-index", "absolute-path-escape", "path-harness-escape-detection"] },
|
|
101
|
+
{ num: 10, title: "CLI / File-System / Path Traversal Tests", ids: ["cli-adversarial-suite", "path-traversal-root", "path-traversal-out", "path-traversal-index", "absolute-path-escape", "path-harness-escape-detection"] },
|
|
76
102
|
{ num: 11, title: "Source Read-Only Boundary Tests", ids: ["source-files-not-modified", "writes-limited-to-output", "index-write-containment", "artifact-cleanup-safe"] },
|
|
77
103
|
{ num: 12, title: "Graphviz / Subprocess Safety Tests", ids: ["graphviz-label-escaping", "subprocess-no-shell-interpolation"] },
|
|
78
104
|
{ num: 13, title: "JSON stdout/stderr Safety Tests", ids: ["json-mode-parseable-output", "stderr-not-in-stdout", "json-failure-error-object", "progress-not-in-json-stdout"] },
|
|
@@ -143,20 +169,98 @@ export function renderTextReport(report) {
|
|
|
143
169
|
continue;
|
|
144
170
|
}
|
|
145
171
|
for (const check of sectionChecks) {
|
|
146
|
-
lines.push(`[${statusIcon(check.status)}] ${pad(check.name)} ${formatDuration(check.durationMs)}`);
|
|
172
|
+
lines.push(`[${statusIcon(check.status)}] ${pad(sanitizeForTextReport(check.name))} ${formatDuration(check.durationMs)}`);
|
|
173
|
+
if (check.command) {
|
|
174
|
+
lines.push(` Command: ${check.command}`);
|
|
175
|
+
}
|
|
176
|
+
if (check.commandCwd) {
|
|
177
|
+
lines.push(` Cwd: ${check.commandCwd}`);
|
|
178
|
+
}
|
|
179
|
+
if (check.exitCode !== undefined) {
|
|
180
|
+
lines.push(` Exit code: ${String(check.exitCode)}`);
|
|
181
|
+
}
|
|
182
|
+
if (check.stdoutSummary) {
|
|
183
|
+
lines.push(` Stdout: ${sanitizeForTextReport(check.stdoutSummary).slice(0, 200)}`);
|
|
184
|
+
}
|
|
185
|
+
if (check.stderrSummary) {
|
|
186
|
+
lines.push(` Stderr: ${sanitizeForTextReport(check.stderrSummary).slice(0, 200)}`);
|
|
187
|
+
}
|
|
147
188
|
if (check.status === "skipped" && check.skippedReason) {
|
|
148
|
-
lines.push(` Reason: ${check.skippedReason}`);
|
|
189
|
+
lines.push(` Reason: ${sanitizeForTextReport(check.skippedReason)}`);
|
|
149
190
|
}
|
|
150
191
|
if (check.findings.length > 0) {
|
|
151
192
|
for (const f of check.findings) {
|
|
152
|
-
lines.push(` [${f.severity.toUpperCase()}] ${f.title}`);
|
|
193
|
+
lines.push(` [${f.severity.toUpperCase()}] ${sanitizeForTextReport(f.title)}`);
|
|
153
194
|
if (f.description)
|
|
154
|
-
lines.push(` ${f.description.slice(0, 120)}`);
|
|
195
|
+
lines.push(` ${sanitizeForTextReport(f.description).slice(0, 120)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
lines.push("");
|
|
200
|
+
}
|
|
201
|
+
// --- Attack Scenario Framework (v0.2.2) ---
|
|
202
|
+
// Only rendered when the run selected attack-scenario-shaped checks.
|
|
203
|
+
// Batch 3 registered concrete scenarios for boundary/subprocess; secrets
|
|
204
|
+
// and network still resolve to an explicit "no scenarios registered"
|
|
205
|
+
// placeholder (scenarioId ending in "-no-scenarios-registered") — the
|
|
206
|
+
// pending-framework note is only shown when at least one such placeholder
|
|
207
|
+
// is present in this run, not unconditionally.
|
|
208
|
+
if (attackResults && attackResults.length > 0) {
|
|
209
|
+
const pendingResults = attackResults.filter((r) => r.scenarioId.endsWith("-no-scenarios-registered"));
|
|
210
|
+
const concreteResults = attackResults.filter((r) => !r.scenarioId.endsWith("-no-scenarios-registered"));
|
|
211
|
+
lines.push(divider());
|
|
212
|
+
lines.push("ATTACK SCENARIO FRAMEWORK (v0.2.2)");
|
|
213
|
+
lines.push(divider());
|
|
214
|
+
if (concreteResults.length > 0) {
|
|
215
|
+
lines.push(`${concreteResults.length} concrete attack scenario(s) executed.`);
|
|
216
|
+
}
|
|
217
|
+
if (pendingResults.length > 0) {
|
|
218
|
+
const pendingChecks = [...new Set(pendingResults.map((r) => r.checkId))].join(", ");
|
|
219
|
+
lines.push(`Concrete attack scenarios are still pending for: ${pendingChecks}. This is not evidence that those check groups are safe.`);
|
|
220
|
+
}
|
|
221
|
+
lines.push("");
|
|
222
|
+
for (const result of attackResults) {
|
|
223
|
+
lines.push(`[${statusIcon(result.status === "blocked" ? "failed" : result.status)}] ${pad(sanitizeForTextReport(result.scenarioTitle))} (check: ${result.checkId}, profile: ${result.profileId})`);
|
|
224
|
+
if (result.skippedReason) {
|
|
225
|
+
lines.push(` Reason: ${sanitizeForTextReport(result.skippedReason)}`);
|
|
226
|
+
}
|
|
227
|
+
if (result.errorSummary) {
|
|
228
|
+
lines.push(` Error: ${sanitizeForTextReport(result.errorSummary)}`);
|
|
229
|
+
}
|
|
230
|
+
if (result.recommendation) {
|
|
231
|
+
lines.push(` Recommendation: ${sanitizeForTextReport(result.recommendation)}`);
|
|
232
|
+
}
|
|
233
|
+
if (result.evidence.length > 0) {
|
|
234
|
+
for (const e of result.evidence.slice(0, 10)) {
|
|
235
|
+
const preview = e.redactedPreview ?? e.observedBehavior ?? "(no preview)";
|
|
236
|
+
lines.push(` [${e.kind}] ${sanitizeForTextReport(preview)}`);
|
|
237
|
+
}
|
|
238
|
+
if (result.evidence.length > 10) {
|
|
239
|
+
lines.push(` ... ${result.evidence.length - 10} more evidence item(s)`);
|
|
155
240
|
}
|
|
156
241
|
}
|
|
157
242
|
}
|
|
158
243
|
lines.push("");
|
|
159
244
|
}
|
|
245
|
+
// --- Verdict Reasoning (v0.2.2 Batch 5) ---
|
|
246
|
+
// Only rendered when the caller supplied the summary (additive; existing
|
|
247
|
+
// report construction that omits it is unaffected).
|
|
248
|
+
if (verdictReasonSummary) {
|
|
249
|
+
lines.push(divider());
|
|
250
|
+
lines.push("VERDICT REASONING (v0.2.2)");
|
|
251
|
+
lines.push(divider());
|
|
252
|
+
lines.push(`Release blockers : ${verdictReasonSummary.releaseBlockerCount}`);
|
|
253
|
+
lines.push(`Target-project blockers : ${verdictReasonSummary.targetProjectBlockerCount}`);
|
|
254
|
+
lines.push(`Tool-framework blockers : ${verdictReasonSummary.toolFrameworkBlockerCount}`);
|
|
255
|
+
lines.push(`Environment inconclusive : ${verdictReasonSummary.environmentInconclusiveCount}`);
|
|
256
|
+
lines.push(`Required evidence missing : ${verdictReasonSummary.requiredEvidenceMissingCount}`);
|
|
257
|
+
lines.push(`Scanner findings : ${verdictReasonSummary.scannerFindingCount}`);
|
|
258
|
+
lines.push(`Adversarial scenario fail : ${verdictReasonSummary.adversarialScenarioFailureCount}`);
|
|
259
|
+
lines.push(`Adversarial scenario skip : ${verdictReasonSummary.adversarialScenarioSkippedCount}`);
|
|
260
|
+
lines.push(`Optional skipped tools : ${verdictReasonSummary.optionalSkippedToolCount}`);
|
|
261
|
+
lines.push(`Informational evidence : ${verdictReasonSummary.informationalEvidenceCount}`);
|
|
262
|
+
lines.push("");
|
|
263
|
+
}
|
|
160
264
|
// --- 20. Findings by Severity ---
|
|
161
265
|
lines.push(divider());
|
|
162
266
|
lines.push("20. FINDINGS BY SEVERITY");
|
|
@@ -171,11 +275,11 @@ export function renderTextReport(report) {
|
|
|
171
275
|
continue;
|
|
172
276
|
lines.push(`${sev.toUpperCase()} (${sevFindings.length}):`);
|
|
173
277
|
for (const f of sevFindings) {
|
|
174
|
-
lines.push(` [${f.id}] ${f.title}`);
|
|
278
|
+
lines.push(` [${f.id}] ${sanitizeForTextReport(f.title)}`);
|
|
175
279
|
if (f.description)
|
|
176
|
-
lines.push(` ${f.description.slice(0, 120)}`);
|
|
280
|
+
lines.push(` ${sanitizeForTextReport(f.description).slice(0, 120)}`);
|
|
177
281
|
if (f.recommendation)
|
|
178
|
-
lines.push(` Recommendation: ${f.recommendation}`);
|
|
282
|
+
lines.push(` Recommendation: ${sanitizeForTextReport(f.recommendation)}`);
|
|
179
283
|
}
|
|
180
284
|
}
|
|
181
285
|
}
|
|
@@ -190,7 +294,7 @@ export function renderTextReport(report) {
|
|
|
190
294
|
lines.push(divider());
|
|
191
295
|
lines.push("22. RECOMMENDED NEXT STEP");
|
|
192
296
|
lines.push(divider());
|
|
193
|
-
lines.push(recommendedNextStep);
|
|
297
|
+
lines.push(sanitizeForTextReport(recommendedNextStep));
|
|
194
298
|
lines.push("");
|
|
195
299
|
lines.push(divider("="));
|
|
196
300
|
return lines.join("\n");
|
|
@@ -199,7 +303,7 @@ export function renderTextReport(report) {
|
|
|
199
303
|
// JSON report
|
|
200
304
|
// ---------------------------------------------------------------------------
|
|
201
305
|
export function renderJsonReport(report) {
|
|
202
|
-
const { metadata, allChecks, allFindings, verdict, recommendedNextStep } = report;
|
|
306
|
+
const { metadata, allChecks, allFindings, verdict, recommendedNextStep, attackResults, verdictReasonSummary } = report;
|
|
203
307
|
const sanitizedChecks = allChecks.map((c) => ({
|
|
204
308
|
id: c.id,
|
|
205
309
|
name: c.name,
|
|
@@ -212,6 +316,10 @@ export function renderJsonReport(report) {
|
|
|
212
316
|
findingCount: c.findings.length,
|
|
213
317
|
skippedReason: c.skippedReason ?? null,
|
|
214
318
|
command: c.command ?? null,
|
|
319
|
+
commandCwd: c.commandCwd ?? null,
|
|
320
|
+
exitCode: c.exitCode ?? null,
|
|
321
|
+
stdoutSummary: c.stdoutSummary ?? null,
|
|
322
|
+
stderrSummary: c.stderrSummary ?? null,
|
|
215
323
|
}));
|
|
216
324
|
const sanitizedFindings = allFindings.map((f) => ({
|
|
217
325
|
id: f.id,
|
|
@@ -224,6 +332,33 @@ export function renderJsonReport(report) {
|
|
|
224
332
|
recommendation: f.recommendation ?? undefined,
|
|
225
333
|
releaseImpact: f.releaseImpact,
|
|
226
334
|
}));
|
|
335
|
+
const sanitizedAttackResults = (attackResults ?? []).map((r) => ({
|
|
336
|
+
scenarioId: r.scenarioId,
|
|
337
|
+
scenarioTitle: r.scenarioTitle,
|
|
338
|
+
checkId: r.checkId,
|
|
339
|
+
profileId: r.profileId,
|
|
340
|
+
status: r.status,
|
|
341
|
+
severity: r.severity,
|
|
342
|
+
confidence: r.confidence,
|
|
343
|
+
category: r.category,
|
|
344
|
+
recommendation: r.recommendation ?? null,
|
|
345
|
+
skippedReason: r.skippedReason ?? null,
|
|
346
|
+
errorSummary: r.errorSummary ?? null,
|
|
347
|
+
evidence: r.evidence.map((e) => ({
|
|
348
|
+
id: e.id,
|
|
349
|
+
kind: e.kind,
|
|
350
|
+
source: e.source,
|
|
351
|
+
filePath: e.filePath ?? null,
|
|
352
|
+
line: e.line ?? null,
|
|
353
|
+
commandCwd: e.commandCwd ?? null,
|
|
354
|
+
commandSummary: e.commandSummary ?? null,
|
|
355
|
+
exitCode: e.exitCode ?? null,
|
|
356
|
+
expectedBehavior: e.expectedBehavior ?? null,
|
|
357
|
+
observedBehavior: e.observedBehavior ?? null,
|
|
358
|
+
redactedPreview: e.redactedPreview ?? null,
|
|
359
|
+
confidence: e.confidence,
|
|
360
|
+
})),
|
|
361
|
+
}));
|
|
227
362
|
const output = {
|
|
228
363
|
schemaVersion: 1,
|
|
229
364
|
metadata,
|
|
@@ -237,12 +372,18 @@ export function renderJsonReport(report) {
|
|
|
237
372
|
blockerFindings: allFindings.filter((f) => f.severity === "blocker").length,
|
|
238
373
|
majorFindings: allFindings.filter((f) => f.severity === "major").length,
|
|
239
374
|
minorFindings: allFindings.filter((f) => f.severity === "minor").length,
|
|
375
|
+
informationalFindings: allFindings.filter((f) => f.severity === "informational").length,
|
|
240
376
|
},
|
|
241
377
|
verdict,
|
|
242
378
|
verdictLabel: verdictToHumanLabel(verdict),
|
|
243
379
|
recommendedNextStep,
|
|
244
380
|
checks: sanitizedChecks,
|
|
245
381
|
findings: sanitizedFindings,
|
|
382
|
+
attackScenarios: {
|
|
383
|
+
count: sanitizedAttackResults.length,
|
|
384
|
+
results: sanitizedAttackResults,
|
|
385
|
+
},
|
|
386
|
+
verdictReasonSummary: verdictReasonSummary ?? null,
|
|
246
387
|
};
|
|
247
388
|
return JSON.stringify(output, null, 2);
|
|
248
389
|
}
|
|
@@ -32,3 +32,10 @@ export const SECURITY_CHECK_CATEGORIES = [
|
|
|
32
32
|
"artifact-safety",
|
|
33
33
|
"report-generation",
|
|
34
34
|
];
|
|
35
|
+
export const VERDICT_IMPACTS = [
|
|
36
|
+
"release-blocker",
|
|
37
|
+
"target-project-blocker",
|
|
38
|
+
"tool-framework-blocker",
|
|
39
|
+
"adversarial-scenario-failure",
|
|
40
|
+
"informational-evidence",
|
|
41
|
+
];
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// v0.2.2 Batch 1 — security:validate command/config surface.
|
|
4
|
+
//
|
|
5
|
+
// This module parses and normalizes --checks, --profile, --format, --fail-on,
|
|
6
|
+
// and --out. It intentionally does not implement attack scenarios, profile
|
|
7
|
+
// behavior, or verdict-policy changes — those are later v0.2.2 batches. This
|
|
8
|
+
// batch only builds a stable, extensible config surface.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
export const SECURITY_CHECK_IDS = [
|
|
11
|
+
"deps",
|
|
12
|
+
"package",
|
|
13
|
+
"static",
|
|
14
|
+
"cli-adversarial",
|
|
15
|
+
"fuzz",
|
|
16
|
+
"boundary",
|
|
17
|
+
"subprocess",
|
|
18
|
+
"secrets",
|
|
19
|
+
"network",
|
|
20
|
+
];
|
|
21
|
+
// Checks with real behavior wired in Batch 1.
|
|
22
|
+
export const IMPLEMENTED_SECURITY_CHECK_IDS = [
|
|
23
|
+
"deps",
|
|
24
|
+
"package",
|
|
25
|
+
"static",
|
|
26
|
+
"cli-adversarial",
|
|
27
|
+
"fuzz",
|
|
28
|
+
];
|
|
29
|
+
// Checks accepted as valid --checks values but not yet implemented. Selecting
|
|
30
|
+
// one of these must never be reported as passed.
|
|
31
|
+
export const PLANNED_SECURITY_CHECK_IDS = [
|
|
32
|
+
"boundary",
|
|
33
|
+
"subprocess",
|
|
34
|
+
"secrets",
|
|
35
|
+
"network",
|
|
36
|
+
];
|
|
37
|
+
export const DEFAULT_SECURITY_CHECKS = IMPLEMENTED_SECURITY_CHECK_IDS;
|
|
38
|
+
export const SECURITY_PROFILE_IDS = ["node-cli-package", "local-tool", "npm-package"];
|
|
39
|
+
export const DEFAULT_SECURITY_PROFILE = "node-cli-package";
|
|
40
|
+
export const SECURITY_OUTPUT_FORMATS = ["text", "json"];
|
|
41
|
+
export const DEFAULT_SECURITY_OUTPUT_FORMATS = ["text", "json"];
|
|
42
|
+
// Distinct vocabulary from SecuritySeverity (blocker/major/minor/informational/skipped).
|
|
43
|
+
// Batch 1 only carries this value through config; verdict-policy integration is later.
|
|
44
|
+
export const SECURITY_FAIL_ON_THRESHOLDS = ["blocker", "high", "medium", "low"];
|
|
45
|
+
export const DEFAULT_SECURITY_FAIL_ON_THRESHOLD = "blocker";
|
|
46
|
+
const FLAGS_WITH_VALUE = {
|
|
47
|
+
"--target": "target",
|
|
48
|
+
"-t": "target",
|
|
49
|
+
"--out": "out",
|
|
50
|
+
"--report-prefix": "reportPrefix",
|
|
51
|
+
"--checks": "checks",
|
|
52
|
+
"--profile": "profile",
|
|
53
|
+
"--format": "format",
|
|
54
|
+
"--fail-on": "failOn",
|
|
55
|
+
};
|
|
56
|
+
export function parseSecurityValidateArgs(argv) {
|
|
57
|
+
const result = {};
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const arg = argv[i];
|
|
60
|
+
const key = FLAGS_WITH_VALUE[arg];
|
|
61
|
+
if (!key)
|
|
62
|
+
continue;
|
|
63
|
+
if (i + 1 >= argv.length) {
|
|
64
|
+
throw new Error(`Missing value for ${arg}. Expected a value after ${arg}.`);
|
|
65
|
+
}
|
|
66
|
+
result[key] = argv[++i];
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
export function normalizeSecurityValidateConfig(raw, toolRoot) {
|
|
71
|
+
const checks = parseChecksOption(raw.checks);
|
|
72
|
+
const profile = parseProfileOption(raw.profile);
|
|
73
|
+
const formats = parseFormatOption(raw.format);
|
|
74
|
+
const failOn = parseFailOnOption(raw.failOn);
|
|
75
|
+
const out = raw.out !== undefined ? resolveOutDir(raw.out) : path.join(toolRoot, "reports", "security");
|
|
76
|
+
return {
|
|
77
|
+
target: raw.target,
|
|
78
|
+
out,
|
|
79
|
+
outWasDefault: raw.out === undefined,
|
|
80
|
+
reportPrefix: raw.reportPrefix,
|
|
81
|
+
checks: checks.value,
|
|
82
|
+
checksWereDefault: checks.wasDefault,
|
|
83
|
+
implementedChecks: checks.value.filter((c) => IMPLEMENTED_SECURITY_CHECK_IDS.includes(c)),
|
|
84
|
+
plannedChecksRequested: checks.value.filter((c) => PLANNED_SECURITY_CHECK_IDS.includes(c)),
|
|
85
|
+
profile: profile.value,
|
|
86
|
+
profileWasDefault: profile.wasDefault,
|
|
87
|
+
formats: formats.value,
|
|
88
|
+
formatsWereDefault: formats.wasDefault,
|
|
89
|
+
failOnThreshold: failOn.value,
|
|
90
|
+
failOnWasDefault: failOn.wasDefault,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// v0.2.2 Batch 5 — profile-aware default checks
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Applies a profile's declared default check ids when the user provided
|
|
97
|
+
// --profile but omitted --checks. Deliberately takes the profile's
|
|
98
|
+
// defaultCheckIds as a plain parameter rather than importing
|
|
99
|
+
// attackScenarios/attackProfile.ts here — that module already imports this
|
|
100
|
+
// one (SecurityProfileId/SECURITY_PROFILE_IDS), so importing it back would
|
|
101
|
+
// create a circular import. Callers (scripts/security/validate.ts) resolve
|
|
102
|
+
// the profile definition via resolveAttackProfile() and pass its
|
|
103
|
+
// defaultCheckIds in.
|
|
104
|
+
//
|
|
105
|
+
// No-op (returns config unchanged) unless BOTH:
|
|
106
|
+
// - the user did not pass --checks (checksWereDefault), AND
|
|
107
|
+
// - the user did pass --profile (profileWasDefault === false)
|
|
108
|
+
// This guarantees the true no-flag case (no --profile, no --checks) and any
|
|
109
|
+
// explicit --checks case are always unaffected.
|
|
110
|
+
export function applyProfileDefaultChecksIfApplicable(config, profileDefaultCheckIds) {
|
|
111
|
+
if (!config.checksWereDefault || config.profileWasDefault) {
|
|
112
|
+
return config;
|
|
113
|
+
}
|
|
114
|
+
const uniqueRequested = new Set(profileDefaultCheckIds);
|
|
115
|
+
const ordered = SECURITY_CHECK_IDS.filter((id) => uniqueRequested.has(id));
|
|
116
|
+
return {
|
|
117
|
+
...config,
|
|
118
|
+
checks: ordered,
|
|
119
|
+
implementedChecks: ordered.filter((c) => IMPLEMENTED_SECURITY_CHECK_IDS.includes(c)),
|
|
120
|
+
plannedChecksRequested: ordered.filter((c) => PLANNED_SECURITY_CHECK_IDS.includes(c)),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function splitAndNormalizeList(raw, flagName) {
|
|
124
|
+
const parts = raw.split(",").map((p) => p.trim());
|
|
125
|
+
if (parts.some((p) => p.length === 0)) {
|
|
126
|
+
throw new Error(`Invalid ${flagName} value: empty entries are not allowed. Got "${raw}".`);
|
|
127
|
+
}
|
|
128
|
+
return parts;
|
|
129
|
+
}
|
|
130
|
+
function parseChecksOption(raw) {
|
|
131
|
+
if (raw === undefined) {
|
|
132
|
+
return { value: [...DEFAULT_SECURITY_CHECKS], wasDefault: true };
|
|
133
|
+
}
|
|
134
|
+
const parts = splitAndNormalizeList(raw, "--checks");
|
|
135
|
+
const invalid = parts.filter((p) => !SECURITY_CHECK_IDS.includes(p));
|
|
136
|
+
if (invalid.length > 0) {
|
|
137
|
+
throw new Error(`Invalid --checks value(s): ${invalid.join(", ")}. Valid values are: ${SECURITY_CHECK_IDS.join(", ")}.`);
|
|
138
|
+
}
|
|
139
|
+
const unique = new Set(parts);
|
|
140
|
+
const ordered = SECURITY_CHECK_IDS.filter((id) => unique.has(id));
|
|
141
|
+
return { value: ordered, wasDefault: false };
|
|
142
|
+
}
|
|
143
|
+
function parseProfileOption(raw) {
|
|
144
|
+
if (raw === undefined) {
|
|
145
|
+
return { value: DEFAULT_SECURITY_PROFILE, wasDefault: true };
|
|
146
|
+
}
|
|
147
|
+
const value = raw.trim();
|
|
148
|
+
if (!SECURITY_PROFILE_IDS.includes(value)) {
|
|
149
|
+
throw new Error(`Invalid --profile value: "${raw}". Valid values are: ${SECURITY_PROFILE_IDS.join(", ")}.`);
|
|
150
|
+
}
|
|
151
|
+
return { value: value, wasDefault: false };
|
|
152
|
+
}
|
|
153
|
+
function parseFormatOption(raw) {
|
|
154
|
+
if (raw === undefined) {
|
|
155
|
+
return { value: [...DEFAULT_SECURITY_OUTPUT_FORMATS], wasDefault: true };
|
|
156
|
+
}
|
|
157
|
+
const parts = splitAndNormalizeList(raw, "--format");
|
|
158
|
+
const invalid = parts.filter((p) => !SECURITY_OUTPUT_FORMATS.includes(p));
|
|
159
|
+
if (invalid.length > 0) {
|
|
160
|
+
throw new Error(`Invalid --format value(s): ${invalid.join(", ")}. Valid values are: ${SECURITY_OUTPUT_FORMATS.join(", ")}.`);
|
|
161
|
+
}
|
|
162
|
+
const unique = new Set(parts);
|
|
163
|
+
const ordered = SECURITY_OUTPUT_FORMATS.filter((id) => unique.has(id));
|
|
164
|
+
return { value: ordered, wasDefault: false };
|
|
165
|
+
}
|
|
166
|
+
function parseFailOnOption(raw) {
|
|
167
|
+
if (raw === undefined) {
|
|
168
|
+
return { value: DEFAULT_SECURITY_FAIL_ON_THRESHOLD, wasDefault: true };
|
|
169
|
+
}
|
|
170
|
+
const value = raw.trim();
|
|
171
|
+
if (!SECURITY_FAIL_ON_THRESHOLDS.includes(value)) {
|
|
172
|
+
throw new Error(`Invalid --fail-on value: "${raw}". Valid values are: ${SECURITY_FAIL_ON_THRESHOLDS.join(", ")}.`);
|
|
173
|
+
}
|
|
174
|
+
return { value: value, wasDefault: false };
|
|
175
|
+
}
|
|
176
|
+
function resolveOutDir(raw) {
|
|
177
|
+
const trimmed = raw.trim();
|
|
178
|
+
if (trimmed.length === 0) {
|
|
179
|
+
throw new Error("Invalid --out value: path must not be empty.");
|
|
180
|
+
}
|
|
181
|
+
return path.resolve(trimmed);
|
|
182
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { runSecurityCommand, resolveNpmCommand } from "../commandRunner.js";
|
|
2
|
+
export async function runCliSecuritySuiteCheck(options) {
|
|
3
|
+
const startedAt = new Date().toISOString();
|
|
4
|
+
if (!options.target.isSelf && !options.target.hasPackageJson) {
|
|
5
|
+
const finishedAt = new Date().toISOString();
|
|
6
|
+
return {
|
|
7
|
+
id: "cli-adversarial-suite",
|
|
8
|
+
name: "Target security test suite",
|
|
9
|
+
category: "cli-adversarial",
|
|
10
|
+
status: "failed",
|
|
11
|
+
severity: "major",
|
|
12
|
+
startedAt,
|
|
13
|
+
finishedAt,
|
|
14
|
+
durationMs: 0,
|
|
15
|
+
command: "npm run test:security",
|
|
16
|
+
commandCwd: options.target.targetRoot,
|
|
17
|
+
exitCode: null,
|
|
18
|
+
findings: [
|
|
19
|
+
{
|
|
20
|
+
id: "target-security-suite-missing-package-json",
|
|
21
|
+
title: "Target package.json is missing",
|
|
22
|
+
severity: "major",
|
|
23
|
+
category: "cli-adversarial",
|
|
24
|
+
description: "Target-aware security suite validation requires a package.json at the target root.",
|
|
25
|
+
recommendation: "Add a package.json with a test:security script or validate a package-based target.",
|
|
26
|
+
releaseImpact: "Should fix before release",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (!options.target.isSelf && !options.target.hasSecurityTestScript) {
|
|
32
|
+
const finishedAt = new Date().toISOString();
|
|
33
|
+
return {
|
|
34
|
+
id: "cli-adversarial-suite",
|
|
35
|
+
name: "Target security test suite",
|
|
36
|
+
category: "cli-adversarial",
|
|
37
|
+
status: "failed",
|
|
38
|
+
severity: "major",
|
|
39
|
+
startedAt,
|
|
40
|
+
finishedAt,
|
|
41
|
+
durationMs: 0,
|
|
42
|
+
command: "npm run test:security",
|
|
43
|
+
commandCwd: options.target.targetRoot,
|
|
44
|
+
exitCode: null,
|
|
45
|
+
findings: [
|
|
46
|
+
{
|
|
47
|
+
id: "target-security-suite-missing-script",
|
|
48
|
+
title: "Target test:security script is missing",
|
|
49
|
+
severity: "major",
|
|
50
|
+
category: "cli-adversarial",
|
|
51
|
+
description: "The target package.json does not define scripts.test:security, so the target security suite could not be validated.",
|
|
52
|
+
recommendation: "Add a target test:security script and rerun security:validate.",
|
|
53
|
+
releaseImpact: "Should fix before release",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const npm = resolveNpmCommand();
|
|
59
|
+
const isSelf = options.target.isSelf;
|
|
60
|
+
const commandCwd = isSelf ? options.toolRoot : options.target.targetRoot;
|
|
61
|
+
const cmd = await runSecurityCommand({
|
|
62
|
+
command: npm,
|
|
63
|
+
args: ["run", "test:security"],
|
|
64
|
+
cwd: commandCwd,
|
|
65
|
+
timeoutMs: Math.min(options.timeoutMs * 3, 180_000),
|
|
66
|
+
});
|
|
67
|
+
const finishedAt = new Date().toISOString();
|
|
68
|
+
const passed = cmd.exitCode === 0;
|
|
69
|
+
const name = isSelf
|
|
70
|
+
? "CLI adversarial test suite (tool self-tests)"
|
|
71
|
+
: "Target security test suite";
|
|
72
|
+
return {
|
|
73
|
+
id: "cli-adversarial-suite",
|
|
74
|
+
name,
|
|
75
|
+
category: "cli-adversarial",
|
|
76
|
+
status: passed ? "passed" : "failed",
|
|
77
|
+
severity: "major",
|
|
78
|
+
startedAt,
|
|
79
|
+
finishedAt,
|
|
80
|
+
durationMs: cmd.durationMs,
|
|
81
|
+
findings: passed
|
|
82
|
+
? []
|
|
83
|
+
: [
|
|
84
|
+
{
|
|
85
|
+
id: "cli-adversarial-suite-failed",
|
|
86
|
+
title: isSelf ? "CLI adversarial test suite had failures" : "Target test:security script failed",
|
|
87
|
+
severity: "major",
|
|
88
|
+
category: "cli-adversarial",
|
|
89
|
+
description: isSelf
|
|
90
|
+
? `Tool-root test:security exited with code ${String(cmd.exitCode)}.`
|
|
91
|
+
: `Target-root test:security exited with code ${String(cmd.exitCode)}.`,
|
|
92
|
+
evidence: buildEvidence(cmd),
|
|
93
|
+
recommendation: isSelf
|
|
94
|
+
? "Fix failing tool security tests before release."
|
|
95
|
+
: "Fix the target test:security failure and rerun security:validate.",
|
|
96
|
+
releaseImpact: "Should fix before release",
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
command: `${npm} run test:security`,
|
|
100
|
+
commandCwd,
|
|
101
|
+
exitCode: cmd.exitCode,
|
|
102
|
+
stdoutSummary: summarizeOutput(cmd.stdout),
|
|
103
|
+
stderrSummary: summarizeOutput(cmd.stderr),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function summarizeOutput(value) {
|
|
107
|
+
const normalized = value.replace(/\r\n/g, "\n").trim();
|
|
108
|
+
if (!normalized) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
return normalized.slice(0, 500);
|
|
112
|
+
}
|
|
113
|
+
function buildEvidence(cmd) {
|
|
114
|
+
const parts = [`cwd=${cmd.cwd}`, `exitCode=${String(cmd.exitCode)}`];
|
|
115
|
+
const stdoutSummary = summarizeOutput(cmd.stdout);
|
|
116
|
+
const stderrSummary = summarizeOutput(cmd.stderr);
|
|
117
|
+
if (stdoutSummary) {
|
|
118
|
+
parts.push(`stdout=${stdoutSummary}`);
|
|
119
|
+
}
|
|
120
|
+
if (stderrSummary) {
|
|
121
|
+
parts.push(`stderr=${stderrSummary}`);
|
|
122
|
+
}
|
|
123
|
+
return parts.join(" | ");
|
|
124
|
+
}
|