@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -22
  3. package/dist/scripts/security/resolveToolRoot.js +16 -0
  4. package/dist/scripts/security/runCodeql.js +2 -1
  5. package/dist/scripts/security/runDependencyChecks.js +2 -1
  6. package/dist/scripts/security/runPackageChecks.js +2 -1
  7. package/dist/scripts/security/runSemgrep.js +2 -1
  8. package/dist/scripts/security/validate.js +70 -30
  9. package/dist/src/core/localProjectTarget.js +4 -0
  10. package/dist/src/core/runMeasuredCommand.js +10 -6
  11. package/dist/src/securityValidation/attackScenarios/attackProfile.js +20 -0
  12. package/dist/src/securityValidation/attackScenarios/attackResult.js +37 -0
  13. package/dist/src/securityValidation/attackScenarios/attackRunner.js +153 -0
  14. package/dist/src/securityValidation/attackScenarios/attackScenario.js +21 -0
  15. package/dist/src/securityValidation/attackScenarios/boundedSourceScan.js +100 -0
  16. package/dist/src/securityValidation/attackScenarios/exploitEvidence.js +69 -0
  17. package/dist/src/securityValidation/attackScenarios/payloadCorpus.js +0 -0
  18. package/dist/src/securityValidation/attackScenarios/profiles/localToolProfile.js +10 -0
  19. package/dist/src/securityValidation/attackScenarios/profiles/nodeCliPackageProfile.js +20 -0
  20. package/dist/src/securityValidation/attackScenarios/profiles/npmPackageProfile.js +10 -0
  21. package/dist/src/securityValidation/attackScenarios/reportSchemaGuard.js +41 -0
  22. package/dist/src/securityValidation/attackScenarios/scenarios/configInjectionScenario.js +144 -0
  23. package/dist/src/securityValidation/attackScenarios/scenarios/networkAssumptionScenario.js +129 -0
  24. package/dist/src/securityValidation/attackScenarios/scenarios/outputBoundaryScenario.js +69 -0
  25. package/dist/src/securityValidation/attackScenarios/scenarios/packageBoundaryScenario.js +118 -0
  26. package/dist/src/securityValidation/attackScenarios/scenarios/pathTraversalScenario.js +119 -0
  27. package/dist/src/securityValidation/attackScenarios/scenarios/reportPoisoningScenario.js +176 -0
  28. package/dist/src/securityValidation/attackScenarios/scenarios/secretLeakageScenario.js +143 -0
  29. package/dist/src/securityValidation/attackScenarios/scenarios/subprocessInjectionScenario.js +117 -0
  30. package/dist/src/securityValidation/attackScenarios/scenarios/targetSandboxScenario.js +118 -0
  31. package/dist/src/securityValidation/attackScenarios/targetSnapshot.js +70 -0
  32. package/dist/src/securityValidation/packageChecks/forbiddenPackageContents.js +4 -1
  33. package/dist/src/securityValidation/report/renderSecurityReport.js +153 -12
  34. package/dist/src/securityValidation/types.js +7 -0
  35. package/dist/src/securityValidation/validate/cliOptions.js +182 -0
  36. package/dist/src/securityValidation/validate/runCliSecuritySuiteCheck.js +124 -0
  37. package/dist/src/securityValidation/validate/runSecurityValidation.js +135 -67
  38. package/dist/src/securityValidation/validate/verdict.js +147 -0
  39. 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
+ }