@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.
- package/LICENSE +21 -0
- package/README.md +35 -24
- package/dist/scripts/security/validate.js +68 -29
- 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 +135 -13
- package/dist/src/securityValidation/types.js +7 -0
- package/dist/src/securityValidation/validate/cliOptions.js +182 -0
- package/dist/src/securityValidation/validate/runSecurityValidation.js +134 -35
- package/dist/src/securityValidation/validate/verdict.js +147 -0
- 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
|
+
};
|