@dailephd/my-dev-kit-lab 0.2.1 → 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 (31) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -24
  3. package/dist/scripts/security/validate.js +68 -29
  4. package/dist/src/securityValidation/attackScenarios/attackProfile.js +20 -0
  5. package/dist/src/securityValidation/attackScenarios/attackResult.js +37 -0
  6. package/dist/src/securityValidation/attackScenarios/attackRunner.js +153 -0
  7. package/dist/src/securityValidation/attackScenarios/attackScenario.js +21 -0
  8. package/dist/src/securityValidation/attackScenarios/boundedSourceScan.js +100 -0
  9. package/dist/src/securityValidation/attackScenarios/exploitEvidence.js +69 -0
  10. package/dist/src/securityValidation/attackScenarios/payloadCorpus.js +0 -0
  11. package/dist/src/securityValidation/attackScenarios/profiles/localToolProfile.js +10 -0
  12. package/dist/src/securityValidation/attackScenarios/profiles/nodeCliPackageProfile.js +20 -0
  13. package/dist/src/securityValidation/attackScenarios/profiles/npmPackageProfile.js +10 -0
  14. package/dist/src/securityValidation/attackScenarios/reportSchemaGuard.js +41 -0
  15. package/dist/src/securityValidation/attackScenarios/scenarios/configInjectionScenario.js +144 -0
  16. package/dist/src/securityValidation/attackScenarios/scenarios/networkAssumptionScenario.js +129 -0
  17. package/dist/src/securityValidation/attackScenarios/scenarios/outputBoundaryScenario.js +69 -0
  18. package/dist/src/securityValidation/attackScenarios/scenarios/packageBoundaryScenario.js +118 -0
  19. package/dist/src/securityValidation/attackScenarios/scenarios/pathTraversalScenario.js +119 -0
  20. package/dist/src/securityValidation/attackScenarios/scenarios/reportPoisoningScenario.js +176 -0
  21. package/dist/src/securityValidation/attackScenarios/scenarios/secretLeakageScenario.js +143 -0
  22. package/dist/src/securityValidation/attackScenarios/scenarios/subprocessInjectionScenario.js +117 -0
  23. package/dist/src/securityValidation/attackScenarios/scenarios/targetSandboxScenario.js +118 -0
  24. package/dist/src/securityValidation/attackScenarios/targetSnapshot.js +70 -0
  25. package/dist/src/securityValidation/packageChecks/forbiddenPackageContents.js +4 -1
  26. package/dist/src/securityValidation/report/renderSecurityReport.js +135 -13
  27. package/dist/src/securityValidation/types.js +7 -0
  28. package/dist/src/securityValidation/validate/cliOptions.js +182 -0
  29. package/dist/src/securityValidation/validate/runSecurityValidation.js +134 -35
  30. package/dist/src/securityValidation/validate/verdict.js +147 -0
  31. package/package.json +16 -16
@@ -0,0 +1,119 @@
1
+ import { makeEvidence } from "../exploitEvidence.js";
2
+ import { getPayloadsForGroup } from "../payloadCorpus.js";
3
+ import { resolveWithinRoot } from "../../../core/pathSafety.js";
4
+ // ---------------------------------------------------------------------------
5
+ // v0.2.2 Batch 3 — path traversal scenario.
6
+ //
7
+ // Exercises the existing src/core/pathSafety.ts containment helper
8
+ // (resolveWithinRoot) — used for writes that must stay within a declared
9
+ // root — against the deterministic path-traversal payload corpus, plus a
10
+ // small set of legitimate paths that must continue to be accepted (spaces,
11
+ // safe relative/absolute-within-root paths). Pure path-math only; nothing is
12
+ // read or written to disk.
13
+ // ---------------------------------------------------------------------------
14
+ const LEGITIMATE_PATH_CASES = [
15
+ { id: "relative-with-spaces", value: "safe sub dir/output.txt", shouldReject: false },
16
+ { id: "relative-nested", value: "a/b/c/output.json", shouldReject: false },
17
+ ];
18
+ export const PATH_TRAVERSAL_SCENARIO = {
19
+ id: "path-traversal-containment",
20
+ title: "Path traversal: containment helper rejects escape payloads and accepts legitimate paths",
21
+ description: "Verifies resolveWithinRoot() rejects the deterministic path-traversal payload corpus and continues to accept legitimate relative paths, including ones with spaces.",
22
+ checkId: "boundary",
23
+ applicableProfiles: [],
24
+ severityBaseline: "blocker",
25
+ verdictImpact: "tool-framework-blocker",
26
+ expectedSafeBehavior: "Every path-traversal payload throws when passed to resolveWithinRoot(); every legitimate path resolves without throwing.",
27
+ evidenceRequirements: ["filesystem", "observation"],
28
+ run: async (ctx) => {
29
+ const evidence = [];
30
+ const unrejectedTraversals = [];
31
+ const wronglyRejectedLegit = [];
32
+ // Payloads whose escape depends on a URL-decode step that never happens
33
+ // at the filesystem-path layer (resolveWithinRoot does no decoding, by
34
+ // design — Node's path module treats "%2f" as a literal filename
35
+ // character, not a separator). These correctly resolve *inside* root and
36
+ // are recorded as informational evidence only, not a rejection failure.
37
+ const payloadsNotApplicableToRawPathResolution = new Set(["path-traversal-encoded"]);
38
+ // On POSIX (Linux/macOS), "\" is a literal filename character, not a
39
+ // path separator — Node's posix path implementation never parses
40
+ // "..\\..\\Windows\\..." as parent-directory components, so it
41
+ // legitimately resolves inside root there. On win32, "\" IS a separator
42
+ // and the payload must still be rejected, so this exclusion only applies
43
+ // off-Windows (verified by the CI cross-platform matrix: this scenario
44
+ // was passing on windows-latest but failing on ubuntu-latest/macos-latest
45
+ // for exactly this platform-semantics reason before this fix).
46
+ if (process.platform !== "win32") {
47
+ payloadsNotApplicableToRawPathResolution.add("path-traversal-windows-parent");
48
+ }
49
+ const traversalPayloads = getPayloadsForGroup("path-traversal");
50
+ for (const p of traversalPayloads) {
51
+ let rejected = false;
52
+ let observed = "";
53
+ try {
54
+ const resolved = resolveWithinRoot(ctx.target.targetRoot, p.value);
55
+ observed = `Resolved to '${resolved}' without being rejected.`;
56
+ }
57
+ catch {
58
+ rejected = true;
59
+ observed = "Rejected (threw), as expected.";
60
+ }
61
+ if (!rejected && !payloadsNotApplicableToRawPathResolution.has(p.id)) {
62
+ unrejectedTraversals.push(p.id);
63
+ }
64
+ evidence.push(makeEvidence({
65
+ kind: "filesystem",
66
+ source: `resolveWithinRoot() with payload '${p.id}'`,
67
+ confidence: "high",
68
+ expectedBehavior: payloadsNotApplicableToRawPathResolution.has(p.id)
69
+ ? "No path separators present without decoding; resolving inside root is safe (not an escape)."
70
+ : "Path-traversal payloads must be rejected (thrown), never silently resolved.",
71
+ observedBehavior: observed,
72
+ rawPreview: p.value,
73
+ }));
74
+ }
75
+ for (const c of LEGITIMATE_PATH_CASES) {
76
+ let accepted = false;
77
+ let observed = "";
78
+ try {
79
+ const resolved = resolveWithinRoot(ctx.target.targetRoot, c.value);
80
+ accepted = true;
81
+ observed = `Resolved to '${resolved}' as expected.`;
82
+ }
83
+ catch (err) {
84
+ observed = `Unexpectedly rejected: ${err instanceof Error ? err.message : String(err)}`;
85
+ }
86
+ if (!accepted) {
87
+ wronglyRejectedLegit.push(c.id);
88
+ }
89
+ evidence.push(makeEvidence({
90
+ kind: "filesystem",
91
+ source: `resolveWithinRoot() with legitimate path '${c.id}'`,
92
+ confidence: "high",
93
+ expectedBehavior: "Legitimate relative paths (including ones with spaces) must be accepted.",
94
+ observedBehavior: observed,
95
+ rawPreview: c.value,
96
+ }));
97
+ }
98
+ if (unrejectedTraversals.length > 0 || wronglyRejectedLegit.length > 0) {
99
+ const parts = [];
100
+ if (unrejectedTraversals.length > 0) {
101
+ parts.push(`${unrejectedTraversals.length} traversal payload(s) were not rejected: ${unrejectedTraversals.join(", ")}`);
102
+ }
103
+ if (wronglyRejectedLegit.length > 0) {
104
+ parts.push(`${wronglyRejectedLegit.length} legitimate path(s) were wrongly rejected: ${wronglyRejectedLegit.join(", ")}`);
105
+ }
106
+ return {
107
+ status: "failed",
108
+ confidence: "high",
109
+ evidence,
110
+ recommendation: parts.join("; "),
111
+ };
112
+ }
113
+ return {
114
+ status: "passed",
115
+ confidence: "high",
116
+ evidence,
117
+ };
118
+ },
119
+ };
@@ -0,0 +1,176 @@
1
+ import { makeEvidence, stripUnsafeControlChars } from "../exploitEvidence.js";
2
+ import { getPayloadsForGroup } from "../payloadCorpus.js";
3
+ import { renderJsonReport, renderTextReport, sanitizeForTextReport } from "../../report/renderSecurityReport.js";
4
+ import { SAFE_BASELINE_CONTENT, detectJsonStructuralInjection } from "../reportSchemaGuard.js";
5
+ // ---------------------------------------------------------------------------
6
+ // v0.2.2 Batch 4 — report poisoning scenario.
7
+ //
8
+ // Registered under checkId "boundary" — the AttackScenario contract supports
9
+ // only one primary check id per scenario, and report-rendering safety is a
10
+ // boundary-of-trust concern (untrusted scanned/attacker content must not
11
+ // escape into trusted report structure), same family as Batch 3's
12
+ // configInjectionScenario (which covers a narrower slice of this: malformed
13
+ // CLI config + basic JSON-breakout safety). This scenario is the dedicated,
14
+ // broader check: ANSI/terminal escapes, fake section headers, HTML/script-
15
+ // like content, markdown-link-like content, and JSON structural integrity —
16
+ // all exercised through the real renderJsonReport()/renderTextReport()
17
+ // functions with controlled, non-destructive mock findings (no payload is
18
+ // ever written into target source files).
19
+ // ---------------------------------------------------------------------------
20
+ const REAL_VERDICT_BLOCK = ["=".repeat(72), "21. RELEASE VERDICT", "=".repeat(72)].join("\n");
21
+ function makePoisonedReport(payloadValue) {
22
+ const now = new Date().toISOString();
23
+ return {
24
+ metadata: {
25
+ toolRoot: "/tool/root",
26
+ toolPackageName: "my-dev-kit-lab",
27
+ toolPackageVersion: "0.2.1",
28
+ targetRoot: "/tool/root",
29
+ targetDescription: "self (my-dev-kit-lab)",
30
+ packageName: "my-dev-kit-lab",
31
+ packageVersion: "0.2.1",
32
+ branch: "feature/security-validate-config-surface",
33
+ commit: "abc1234",
34
+ isSelf: true,
35
+ generatedAt: now,
36
+ totalDurationMs: 1,
37
+ },
38
+ sections: [],
39
+ allChecks: [
40
+ {
41
+ id: "report-poisoning-fixture",
42
+ name: payloadValue,
43
+ category: "artifact-safety",
44
+ status: "failed",
45
+ severity: "major",
46
+ startedAt: now,
47
+ finishedAt: now,
48
+ durationMs: 0,
49
+ findings: [
50
+ {
51
+ id: "report-poisoning-fixture-finding",
52
+ title: payloadValue,
53
+ severity: "major",
54
+ category: "artifact-safety",
55
+ description: payloadValue,
56
+ recommendation: payloadValue,
57
+ releaseImpact: "Should fix before release",
58
+ },
59
+ ],
60
+ skippedReason: payloadValue,
61
+ },
62
+ ],
63
+ allFindings: [],
64
+ verdict: "ready-except-optional-manual-checks",
65
+ recommendedNextStep: payloadValue,
66
+ attackResults: [
67
+ {
68
+ scenarioId: "report-poisoning-fixture-scenario",
69
+ scenarioTitle: payloadValue,
70
+ checkId: "boundary",
71
+ profileId: "node-cli-package",
72
+ status: "failed",
73
+ severity: "major",
74
+ confidence: "low",
75
+ evidence: [
76
+ makeEvidence({ kind: "observation", source: "fixture", confidence: "low", rawPreview: payloadValue }),
77
+ ],
78
+ category: "artifact-safety",
79
+ recommendation: payloadValue,
80
+ skippedReason: payloadValue,
81
+ },
82
+ ],
83
+ };
84
+ }
85
+ export const REPORT_POISONING_SCENARIO = {
86
+ id: "report-poisoning-safety",
87
+ title: "Report poisoning: renderer treats payload content as untrusted data, never trusted structure",
88
+ description: "Renders text and JSON reports with report-poisoning payloads (ANSI escapes, fake section headers, JSON breakout, HTML/script-like, markdown-link-like) embedded in check/finding/attack-result fields, and confirms: JSON stays parseable with no extra top-level keys, text rendering never crashes, the real RELEASE VERDICT block is structurally intact, and no raw ANSI control bytes reach the final text output.",
89
+ checkId: "boundary",
90
+ applicableProfiles: [],
91
+ severityBaseline: "major",
92
+ verdictImpact: "tool-framework-blocker",
93
+ expectedSafeBehavior: "Report-poisoning payloads are rendered as inert content; they never corrupt JSON structure, crash rendering, or forge a trusted verdict/section.",
94
+ evidenceRequirements: ["output", "observation"],
95
+ run: async () => {
96
+ const evidence = [];
97
+ const problems = [];
98
+ const payloads = getPayloadsForGroup("report-poisoning");
99
+ // Baseline-diff check (v0.2.2 Batch 6): compare each poisoned render's
100
+ // top-level JSON keys against an identically-shaped render carrying an
101
+ // inert placeholder instead of the payload. A payload can only widen the
102
+ // top-level shape by actually breaking out of its string context —
103
+ // legitimate additive schema fields appear in both and are never
104
+ // flagged, so this doesn't need to know the current field list at all.
105
+ const baselineJson = renderJsonReport(makePoisonedReport(SAFE_BASELINE_CONTENT));
106
+ for (const payload of payloads) {
107
+ let jsonOk = true;
108
+ let jsonExtraKeys = [];
109
+ let textOk = true;
110
+ let textError;
111
+ let verdictBlockIntact = true;
112
+ let rawAnsiLeaked = false;
113
+ const report = makePoisonedReport(payload.value);
114
+ try {
115
+ const json = renderJsonReport(report);
116
+ const injection = detectJsonStructuralInjection(baselineJson, json);
117
+ jsonExtraKeys = injection.injectedTopLevelKeys;
118
+ jsonOk = injection.parseable && jsonExtraKeys.length === 0;
119
+ }
120
+ catch {
121
+ jsonOk = false;
122
+ }
123
+ let text = "";
124
+ try {
125
+ text = renderTextReport(report);
126
+ }
127
+ catch (err) {
128
+ textOk = false;
129
+ textError = err instanceof Error ? err.message : String(err);
130
+ }
131
+ if (textOk) {
132
+ verdictBlockIntact = text.includes(REAL_VERDICT_BLOCK);
133
+ rawAnsiLeaked = /\x1b\[[0-9;]*[a-zA-Z]/.test(text);
134
+ }
135
+ const payloadProblem = !jsonOk || !textOk || !verdictBlockIntact || rawAnsiLeaked;
136
+ if (payloadProblem) {
137
+ problems.push(payload.id);
138
+ }
139
+ evidence.push(makeEvidence({
140
+ kind: "output",
141
+ source: `renderJsonReport()/renderTextReport() with payload '${payload.id}'`,
142
+ confidence: "high",
143
+ expectedBehavior: "JSON parseable with no extra top-level keys; text rendering does not crash; real verdict block intact; no raw ANSI bytes in text output.",
144
+ observedBehavior: `jsonOk=${jsonOk}${jsonExtraKeys.length > 0 ? ` (extraKeys=${jsonExtraKeys.join(",")})` : ""}, textOk=${textOk}${textError ? ` (${textError})` : ""}, verdictBlockIntact=${verdictBlockIntact}, rawAnsiLeaked=${rawAnsiLeaked}`,
145
+ rawPreview: payload.value,
146
+ }));
147
+ }
148
+ // Direct regression check on the sanitizer itself, independent of the
149
+ // full report pipeline above.
150
+ const ansiPayload = payloads.find((p) => p.id === "report-poisoning-ansi-escape");
151
+ if (ansiPayload) {
152
+ const sanitized = sanitizeForTextReport(ansiPayload.value);
153
+ const stillHasAnsi = /\x1b\[[0-9;]*[a-zA-Z]/.test(sanitized);
154
+ const stillHasAnsiViaStrip = /\x1b\[[0-9;]*[a-zA-Z]/.test(stripUnsafeControlChars(ansiPayload.value));
155
+ if (stillHasAnsi || stillHasAnsiViaStrip) {
156
+ problems.push("ansi-sanitizer-regression");
157
+ }
158
+ evidence.push(makeEvidence({
159
+ kind: "observation",
160
+ source: "sanitizeForTextReport() / stripUnsafeControlChars() direct check",
161
+ confidence: "high",
162
+ expectedBehavior: "ANSI escape sequences are removed by the sanitizer.",
163
+ observedBehavior: stillHasAnsi || stillHasAnsiViaStrip ? "ANSI bytes survived sanitization." : "ANSI bytes removed.",
164
+ }));
165
+ }
166
+ if (problems.length > 0) {
167
+ return {
168
+ status: "failed",
169
+ confidence: "high",
170
+ evidence,
171
+ recommendation: `Report rendering is not safe against payload(s): ${problems.join(", ")}. Fix before treating generated reports as safe to share.`,
172
+ };
173
+ }
174
+ return { status: "passed", confidence: "high", evidence };
175
+ },
176
+ };
@@ -0,0 +1,143 @@
1
+ import { makeEvidence } from "../exploitEvidence.js";
2
+ import { collectBoundedSourceFiles, lineNumberAt } from "../boundedSourceScan.js";
3
+ const SEVERITY_FOR_KIND = {
4
+ "private-key": "blocker",
5
+ "github-token": "blocker",
6
+ "openai-token": "blocker",
7
+ "aws-key": "blocker",
8
+ "generic-secret-assignment": "major",
9
+ };
10
+ const SECRET_PATTERNS = [
11
+ { kind: "private-key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
12
+ { kind: "github-token", regex: /gh[pousr]_[A-Za-z0-9]{16,}/g },
13
+ { kind: "openai-token", regex: /sk-[A-Za-z0-9]{16,}/g },
14
+ { kind: "aws-key", regex: /AKIA[0-9A-Z]{12,}/g },
15
+ {
16
+ kind: "generic-secret-assignment",
17
+ regex: /(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*["']?([A-Za-z0-9_\-]{8,})["']?/gi,
18
+ },
19
+ ];
20
+ const PLACEHOLDER_MARKERS = [
21
+ /\bexample\b/i,
22
+ /change[-_]?me/i,
23
+ /\bdummy\b/i,
24
+ /\btest\b/i,
25
+ /\bfake\b/i,
26
+ /your[-_]?api[-_]?key/i,
27
+ /<[a-z_]+>/i,
28
+ /\bxxxx+\b/i,
29
+ /\bplaceholder\b/i,
30
+ /\bsample\b/i,
31
+ /\bredacted\b/i,
32
+ ];
33
+ function isPlaceholder(text) {
34
+ return PLACEHOLDER_MARKERS.some((p) => p.test(text));
35
+ }
36
+ const SEVERITY_RANK = {
37
+ blocker: 4,
38
+ major: 3,
39
+ minor: 2,
40
+ informational: 1,
41
+ skipped: 0,
42
+ };
43
+ function scanFileForSecrets(relativePath, content) {
44
+ const findings = [];
45
+ for (const { kind, regex } of SECRET_PATTERNS) {
46
+ // Each pattern object's `regex` is reused across files; reset lastIndex
47
+ // via a fresh exec loop to stay correct for global regexes.
48
+ const re = new RegExp(regex.source, regex.flags);
49
+ let match;
50
+ while ((match = re.exec(content)) !== null) {
51
+ const matchedText = match[0];
52
+ // For the generic assignment pattern, judge placeholder-ness on the
53
+ // captured value; for shaped tokens, judge on the whole match.
54
+ const judgedText = kind === "generic-secret-assignment" ? (match[1] ?? matchedText) : matchedText;
55
+ if (isPlaceholder(judgedText) || isPlaceholder(matchedText)) {
56
+ // Downgrade: not a high-confidence finding, skip entirely rather
57
+ // than add noise — placeholders are expected in source/config.
58
+ continue;
59
+ }
60
+ // Guard against zero-length matches looping forever.
61
+ if (matchedText.length === 0) {
62
+ re.lastIndex += 1;
63
+ continue;
64
+ }
65
+ findings.push({
66
+ kind,
67
+ file: relativePath,
68
+ line: lineNumberAt(content, match.index),
69
+ matchedText,
70
+ matchLength: matchedText.length,
71
+ severity: SEVERITY_FOR_KIND[kind],
72
+ });
73
+ }
74
+ }
75
+ return findings;
76
+ }
77
+ export const SECRET_LEAKAGE_SCENARIO = {
78
+ id: "secret-leakage-bounded-scan",
79
+ title: "Secret leakage: bounded static scan finds no high-confidence secret-shaped literals",
80
+ description: "Scans a bounded, deterministic set of project files (manifests, tsconfig, src/, scripts/, .env*) for high-confidence secret-shaped literal values (private keys, GitHub/OpenAI/AWS-style tokens, generic password/token/secret assignments). Placeholder values and bare env-var-name references are excluded from findings.",
81
+ checkId: "secrets",
82
+ applicableProfiles: [],
83
+ severityBaseline: "informational",
84
+ verdictImpact: "target-project-blocker",
85
+ expectedSafeBehavior: "No high-confidence secret-shaped literal values are present in the bounded glob set inspected.",
86
+ evidenceRequirements: ["secret-leak", "observation"],
87
+ run: async (ctx) => {
88
+ const files = collectBoundedSourceFiles(ctx.target.targetRoot);
89
+ if (files.length === 0) {
90
+ return {
91
+ status: "passed",
92
+ confidence: "low",
93
+ evidence: [
94
+ makeEvidence({
95
+ kind: "observation",
96
+ source: "bounded secret scan file collection",
97
+ confidence: "low",
98
+ expectedBehavior: "At least one file would be scanned for a meaningful bounded-scan claim.",
99
+ observedBehavior: "0 files matched the bounded glob set (package.json/tsconfig/src/scripts/.env*). No findings because there was nothing to scan — this does not prove the target has no secrets elsewhere.",
100
+ }),
101
+ ],
102
+ };
103
+ }
104
+ const allFindings = [];
105
+ for (const file of files) {
106
+ allFindings.push(...scanFileForSecrets(file.relativePath, file.content));
107
+ }
108
+ allFindings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
109
+ const evidence = [
110
+ makeEvidence({
111
+ kind: "observation",
112
+ source: "bounded secret scan",
113
+ confidence: "medium",
114
+ expectedBehavior: "Bounded scan of manifests/tsconfig/src/scripts/.env* files.",
115
+ observedBehavior: `${files.length} file(s) scanned; ${allFindings.length} high-confidence secret-shaped finding(s).`,
116
+ }),
117
+ ...allFindings.slice(0, 20).map((f) => makeEvidence({
118
+ kind: "secret-leak",
119
+ source: `pattern '${f.kind}'`,
120
+ filePath: f.file,
121
+ line: f.line,
122
+ confidence: "high",
123
+ expectedBehavior: "No literal high-confidence secret values in scanned source paths.",
124
+ // Manually pre-redacted: never pass the raw matched text through —
125
+ // some generic-assignment matches are shorter than the 32-char
126
+ // generic redaction threshold in redactPreview(), so we redact
127
+ // explicitly here rather than relying on it for this scenario.
128
+ rawPreview: `${f.kind} match in ${f.file}:${f.line} [REDACTED:${f.matchLength}chars]`,
129
+ })),
130
+ ];
131
+ if (allFindings.length === 0) {
132
+ return { status: "passed", confidence: "medium", evidence };
133
+ }
134
+ const worstSeverity = allFindings.reduce((worst, f) => (SEVERITY_RANK[f.severity] > SEVERITY_RANK[worst] ? f.severity : worst), "informational");
135
+ return {
136
+ status: "failed",
137
+ confidence: "high",
138
+ evidence,
139
+ severity: worstSeverity,
140
+ recommendation: `Remove or rotate ${allFindings.length} high-confidence secret-shaped value(s) found in the scanned bounded file set; move secrets to environment variables or a secret manager, never literal source/config values.`,
141
+ };
142
+ },
143
+ };
@@ -0,0 +1,117 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { makeEvidence } from "../exploitEvidence.js";
5
+ import { getPayloadsForGroup } from "../payloadCorpus.js";
6
+ import { runSecurityCommand } from "../../commandRunner.js";
7
+ // ---------------------------------------------------------------------------
8
+ // v0.2.2 Batch 3 — subprocess injection scenario.
9
+ //
10
+ // Non-destructive by construction: never executes a payload as a shell
11
+ // command. Instead it runs `node -e "..."` with each subprocess-injection
12
+ // payload passed as a single argv element (via spawn's argument array, the
13
+ // same mechanism runSecurityCommand/runMeasuredCommand use), and confirms
14
+ // the child process receives it back as exactly one literal argument — proof
15
+ // that no shell re-interpreted it (splitting it into multiple commands via
16
+ // `;`, `&&`, backticks, `$()`, or `|`).
17
+ //
18
+ // Also does a bounded static check that the command-execution helpers this
19
+ // scenario exercises are built with `shell: false` (source inspection of a
20
+ // known-small file, not a general code scanner).
21
+ // ---------------------------------------------------------------------------
22
+ const ECHO_ARGV_SCRIPT = "process.stdout.write(JSON.stringify(process.argv.slice(1)))";
23
+ function commandRunnerSourcePath() {
24
+ const here = path.dirname(fileURLToPath(import.meta.url));
25
+ return path.resolve(here, "..", "..", "commandRunner.ts");
26
+ }
27
+ export const SUBPROCESS_INJECTION_SCENARIO = {
28
+ id: "subprocess-injection-safety",
29
+ title: "Subprocess injection: shell metacharacter payloads are passed as literal arguments",
30
+ description: "Runs a benign argv-echoing Node script with each subprocess-injection payload as a single spawn argument and confirms it round-trips as exactly one literal string, proving no shell re-interpretation. Also checks that commandRunner.ts constructs subprocess calls with shell:false.",
31
+ checkId: "subprocess",
32
+ applicableProfiles: [],
33
+ severityBaseline: "blocker",
34
+ verdictImpact: "tool-framework-blocker",
35
+ expectedSafeBehavior: "Every subprocess-injection payload round-trips as a single literal argv element; commandRunner.ts uses shell:false.",
36
+ evidenceRequirements: ["command", "observation"],
37
+ run: async (ctx) => {
38
+ const evidence = [];
39
+ const notLiteral = [];
40
+ const payloads = getPayloadsForGroup("subprocess-injection");
41
+ for (const p of payloads) {
42
+ const cmd = await runSecurityCommand({
43
+ command: process.execPath,
44
+ args: ["-e", ECHO_ARGV_SCRIPT, p.value],
45
+ cwd: ctx.toolRoot,
46
+ timeoutMs: 10_000,
47
+ });
48
+ let roundTrippedCorrectly = false;
49
+ try {
50
+ const parsed = JSON.parse(cmd.stdout.trim());
51
+ roundTrippedCorrectly = parsed.length === 1 && parsed[0] === p.value;
52
+ }
53
+ catch {
54
+ roundTrippedCorrectly = false;
55
+ }
56
+ if (!roundTrippedCorrectly) {
57
+ notLiteral.push(p.id);
58
+ }
59
+ evidence.push(makeEvidence({
60
+ kind: "command",
61
+ source: `runSecurityCommand() argv round-trip for payload '${p.id}'`,
62
+ commandCwd: ctx.toolRoot,
63
+ commandSummary: "node -e <argv-echo-script> <payload>",
64
+ exitCode: cmd.exitCode,
65
+ confidence: "high",
66
+ expectedBehavior: "Payload must round-trip as exactly one literal argv element.",
67
+ observedBehavior: roundTrippedCorrectly
68
+ ? "Round-tripped as a single literal argument, as expected."
69
+ : "Did NOT round-trip as a single literal argument — possible shell interpretation.",
70
+ rawPreview: p.value,
71
+ }));
72
+ }
73
+ let shellFalseConfirmed = false;
74
+ let sourceReadable = true;
75
+ try {
76
+ const source = fs.readFileSync(commandRunnerSourcePath(), "utf8");
77
+ shellFalseConfirmed = /shell:\s*false/.test(source);
78
+ }
79
+ catch {
80
+ sourceReadable = false;
81
+ }
82
+ evidence.push(makeEvidence({
83
+ kind: "observation",
84
+ source: "static check: src/securityValidation/commandRunner.ts",
85
+ confidence: sourceReadable ? "high" : "low",
86
+ expectedBehavior: "Subprocess calls should be constructed with shell:false.",
87
+ observedBehavior: sourceReadable
88
+ ? shellFalseConfirmed
89
+ ? "shell:false found in commandRunner.ts."
90
+ : "shell:false NOT found in commandRunner.ts."
91
+ : "Could not read commandRunner.ts source for static confirmation.",
92
+ }));
93
+ if (notLiteral.length > 0 || (sourceReadable && !shellFalseConfirmed)) {
94
+ return {
95
+ status: "failed",
96
+ confidence: "high",
97
+ evidence,
98
+ recommendation: notLiteral.length > 0
99
+ ? `Investigate argument construction — payload(s) did not round-trip literally: ${notLiteral.join(", ")}.`
100
+ : "commandRunner.ts must construct child_process.spawn calls with shell:false.",
101
+ };
102
+ }
103
+ if (!sourceReadable) {
104
+ return {
105
+ status: "blocked",
106
+ confidence: "low",
107
+ evidence,
108
+ recommendation: "Could not statically confirm shell:false; argv round-trip evidence is still valid.",
109
+ };
110
+ }
111
+ return {
112
+ status: "passed",
113
+ confidence: "high",
114
+ evidence,
115
+ };
116
+ },
117
+ };