@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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { CHECK_ID_CATEGORY } from "./attackScenario.js";
|
|
2
|
+
import { TARGET_SANDBOX_SCENARIO } from "./scenarios/targetSandboxScenario.js";
|
|
3
|
+
import { PACKAGE_BOUNDARY_SCENARIO } from "./scenarios/packageBoundaryScenario.js";
|
|
4
|
+
import { OUTPUT_BOUNDARY_SCENARIO } from "./scenarios/outputBoundaryScenario.js";
|
|
5
|
+
import { PATH_TRAVERSAL_SCENARIO } from "./scenarios/pathTraversalScenario.js";
|
|
6
|
+
import { CONFIG_INJECTION_SCENARIO } from "./scenarios/configInjectionScenario.js";
|
|
7
|
+
import { SUBPROCESS_INJECTION_SCENARIO } from "./scenarios/subprocessInjectionScenario.js";
|
|
8
|
+
import { SECRET_LEAKAGE_SCENARIO } from "./scenarios/secretLeakageScenario.js";
|
|
9
|
+
import { REPORT_POISONING_SCENARIO } from "./scenarios/reportPoisoningScenario.js";
|
|
10
|
+
import { NETWORK_ASSUMPTION_SCENARIO } from "./scenarios/networkAssumptionScenario.js";
|
|
11
|
+
import { registerScenarioPayloadGroups } from "./payloadCorpus.js";
|
|
12
|
+
registerScenarioPayloadGroups(OUTPUT_BOUNDARY_SCENARIO.id, ["output-boundary"]);
|
|
13
|
+
registerScenarioPayloadGroups(PATH_TRAVERSAL_SCENARIO.id, ["path-traversal"]);
|
|
14
|
+
registerScenarioPayloadGroups(CONFIG_INJECTION_SCENARIO.id, ["malformed-config", "report-poisoning"]);
|
|
15
|
+
registerScenarioPayloadGroups(SUBPROCESS_INJECTION_SCENARIO.id, ["subprocess-injection"]);
|
|
16
|
+
registerScenarioPayloadGroups(REPORT_POISONING_SCENARIO.id, ["report-poisoning"]);
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// v0.2.2 Batch 2/3/4 — attack scenario runner.
|
|
19
|
+
//
|
|
20
|
+
// SCENARIO_REGISTRY was empty in Batch 2. Batch 3 registered the first
|
|
21
|
+
// concrete scenarios (boundary: target sandbox, package boundary, output
|
|
22
|
+
// boundary, path traversal, config injection; subprocess: subprocess
|
|
23
|
+
// injection). Batch 4 adds secret leakage (secrets), report poisoning
|
|
24
|
+
// (boundary — report-rendering safety is a boundary-of-trust concern), and
|
|
25
|
+
// network/local-first assumption (network). All 9 --checks ids now have at
|
|
26
|
+
// least one registered scenario.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
export const SCENARIO_REGISTRY = [
|
|
29
|
+
TARGET_SANDBOX_SCENARIO,
|
|
30
|
+
PACKAGE_BOUNDARY_SCENARIO,
|
|
31
|
+
OUTPUT_BOUNDARY_SCENARIO,
|
|
32
|
+
PATH_TRAVERSAL_SCENARIO,
|
|
33
|
+
CONFIG_INJECTION_SCENARIO,
|
|
34
|
+
REPORT_POISONING_SCENARIO,
|
|
35
|
+
SUBPROCESS_INJECTION_SCENARIO,
|
|
36
|
+
SECRET_LEAKAGE_SCENARIO,
|
|
37
|
+
NETWORK_ASSUMPTION_SCENARIO,
|
|
38
|
+
];
|
|
39
|
+
const DEFAULT_SCENARIO_TIMEOUT_MS = 30_000;
|
|
40
|
+
// Selects scenarios whose checkId is in selectedChecks and whose
|
|
41
|
+
// applicableProfiles either is empty (applies to all profiles) or includes
|
|
42
|
+
// the given profile. Preserves registry order (deterministic).
|
|
43
|
+
export function filterScenarios(scenarios, selectedChecks, profile) {
|
|
44
|
+
const checkSet = new Set(selectedChecks);
|
|
45
|
+
return scenarios.filter((s) => checkSet.has(s.checkId) && (s.applicableProfiles.length === 0 || s.applicableProfiles.includes(profile)));
|
|
46
|
+
}
|
|
47
|
+
async function withTimeout(promise, timeoutMs, scenarioId) {
|
|
48
|
+
let timer;
|
|
49
|
+
try {
|
|
50
|
+
return await Promise.race([
|
|
51
|
+
promise,
|
|
52
|
+
new Promise((_resolve, reject) => {
|
|
53
|
+
timer = setTimeout(() => {
|
|
54
|
+
reject(new Error(`Scenario '${scenarioId}' exceeded its ${timeoutMs}ms execution budget.`));
|
|
55
|
+
}, timeoutMs);
|
|
56
|
+
}),
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
if (timer)
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Runs a single scenario, converting a skip condition, thrown error, or
|
|
65
|
+
// timeout into a structured AttackResult rather than letting exceptions
|
|
66
|
+
// propagate to the caller.
|
|
67
|
+
export async function runScenario(scenario, ctx) {
|
|
68
|
+
const baseFields = {
|
|
69
|
+
scenarioId: scenario.id,
|
|
70
|
+
scenarioTitle: scenario.title,
|
|
71
|
+
checkId: scenario.checkId,
|
|
72
|
+
profileId: ctx.profile,
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
const skipReason = scenario.skipCondition?.(ctx);
|
|
76
|
+
if (skipReason) {
|
|
77
|
+
return {
|
|
78
|
+
...baseFields,
|
|
79
|
+
status: "skipped",
|
|
80
|
+
severity: "skipped",
|
|
81
|
+
confidence: "low",
|
|
82
|
+
evidence: [],
|
|
83
|
+
category: CHECK_ID_CATEGORY[scenario.checkId],
|
|
84
|
+
skippedReason: skipReason,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const outcome = await withTimeout(scenario.run(ctx), scenario.timeoutMs ?? DEFAULT_SCENARIO_TIMEOUT_MS, scenario.id);
|
|
88
|
+
return {
|
|
89
|
+
...baseFields,
|
|
90
|
+
status: outcome.status,
|
|
91
|
+
severity: outcome.severity ?? scenario.severityBaseline,
|
|
92
|
+
confidence: outcome.confidence,
|
|
93
|
+
evidence: outcome.evidence,
|
|
94
|
+
category: outcome.category ?? CHECK_ID_CATEGORY[scenario.checkId],
|
|
95
|
+
recommendation: outcome.recommendation,
|
|
96
|
+
verdictImpact: scenario.verdictImpact,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
return {
|
|
101
|
+
...baseFields,
|
|
102
|
+
status: "blocked",
|
|
103
|
+
severity: scenario.severityBaseline,
|
|
104
|
+
confidence: "low",
|
|
105
|
+
evidence: [],
|
|
106
|
+
category: CHECK_ID_CATEGORY[scenario.checkId],
|
|
107
|
+
errorSummary: err instanceof Error ? err.message : String(err),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function unavailableResult(checkId, profile) {
|
|
112
|
+
return {
|
|
113
|
+
scenarioId: `${checkId}-no-scenarios-registered`,
|
|
114
|
+
scenarioTitle: `No attack scenarios registered for '${checkId}'`,
|
|
115
|
+
checkId,
|
|
116
|
+
profileId: profile,
|
|
117
|
+
status: "skipped",
|
|
118
|
+
severity: "skipped",
|
|
119
|
+
confidence: "low",
|
|
120
|
+
evidence: [],
|
|
121
|
+
category: CHECK_ID_CATEGORY[checkId],
|
|
122
|
+
skippedReason: `No attack scenarios are registered yet for check group '${checkId}'. ` +
|
|
123
|
+
`The attack-scenario framework is present (v0.2.2 Batch 2); concrete scenarios are planned for a later v0.2.2 batch. ` +
|
|
124
|
+
`This must not be treated as evidence that '${checkId}' is safe.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Runs all scenarios matching selectedChecks/profile, then appends one
|
|
128
|
+
// explicit "unavailable" placeholder result for every selected check that
|
|
129
|
+
// matched zero scenarios. Safe when the scenario list is empty. Result order
|
|
130
|
+
// is deterministic: matched scenarios in registry order, then unavailable
|
|
131
|
+
// placeholders in selectedChecks order.
|
|
132
|
+
export async function runAttackScenarios(options) {
|
|
133
|
+
const scenarios = options.scenarios ?? SCENARIO_REGISTRY;
|
|
134
|
+
const matched = filterScenarios(scenarios, options.selectedChecks, options.profile);
|
|
135
|
+
const ctx = {
|
|
136
|
+
toolRoot: options.toolRoot,
|
|
137
|
+
target: options.target,
|
|
138
|
+
profile: options.profile,
|
|
139
|
+
config: options.config,
|
|
140
|
+
targetSnapshotBefore: options.targetSnapshotBefore,
|
|
141
|
+
};
|
|
142
|
+
const results = [];
|
|
143
|
+
for (const scenario of matched) {
|
|
144
|
+
results.push(await runScenario(scenario, ctx));
|
|
145
|
+
}
|
|
146
|
+
const matchedCheckIds = new Set(matched.map((s) => s.checkId));
|
|
147
|
+
for (const checkId of options.selectedChecks) {
|
|
148
|
+
if (!matchedCheckIds.has(checkId)) {
|
|
149
|
+
results.push(unavailableResult(checkId, options.profile));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return results;
|
|
153
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.2.2 Batch 2 — attack scenario contract.
|
|
3
|
+
//
|
|
4
|
+
// This is the shared interface later v0.2.2 batches implement concrete
|
|
5
|
+
// scenarios against. Batch 2 intentionally ships with an empty scenario
|
|
6
|
+
// registry (see attackRunner.ts) — this file only defines the shape.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Maps a planned/attack-scenario check id to its default report category.
|
|
9
|
+
// Used both when no scenarios are registered yet (placeholder result) and by
|
|
10
|
+
// scenario authors as a sensible default category.
|
|
11
|
+
export const CHECK_ID_CATEGORY = {
|
|
12
|
+
deps: "dependency-audit",
|
|
13
|
+
package: "package-content",
|
|
14
|
+
static: "static-scan",
|
|
15
|
+
"cli-adversarial": "cli-adversarial",
|
|
16
|
+
fuzz: "fuzz-smoke",
|
|
17
|
+
boundary: "artifact-safety",
|
|
18
|
+
subprocess: "cli-adversarial",
|
|
19
|
+
secrets: "secret-leakage",
|
|
20
|
+
network: "network-boundary",
|
|
21
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { collectFilesForGlobs } from "../../core/fileGlobs.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// v0.2.2 Batch 4 — shared bounded, deterministic source-file scan helper.
|
|
5
|
+
//
|
|
6
|
+
// Reused by secretLeakageScenario and networkAssumptionScenario. Both need
|
|
7
|
+
// "read a bounded, deterministic set of source-ish files, skip large/binary
|
|
8
|
+
// content" and nothing more — collectFilesForGlobs() already provides
|
|
9
|
+
// deterministic ordering and standard exclusions (node_modules, dist, build,
|
|
10
|
+
// coverage, .git, lab-output, .my-dev-kit); this layer adds a size cap and a
|
|
11
|
+
// small file-content allowlist filter for scenario-specific exclusions.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Default bounded glob set: project manifests/config plus first-party source
|
|
14
|
+
// and scripts. Deliberately excludes tests/, benchmarks/, examples/ — those
|
|
15
|
+
// commonly contain intentionally fake secret-shaped fixtures (including this
|
|
16
|
+
// project's own security payload corpus test suite) that would otherwise be
|
|
17
|
+
// permanent false positives against this project's own repo.
|
|
18
|
+
export const DEFAULT_SOURCE_SCAN_GLOBS = [
|
|
19
|
+
"package.json",
|
|
20
|
+
"package-lock.json",
|
|
21
|
+
"npm-shrinkwrap.json",
|
|
22
|
+
"tsconfig.json",
|
|
23
|
+
"tsconfig.*.json",
|
|
24
|
+
".env",
|
|
25
|
+
".env.*",
|
|
26
|
+
"src/**/*",
|
|
27
|
+
"scripts/**/*",
|
|
28
|
+
];
|
|
29
|
+
// Files that are deliberately excluded from scan output even though they
|
|
30
|
+
// match a glob above — e.g. the payload corpus itself, which intentionally
|
|
31
|
+
// contains fake-secret-shaped strings for Batch 2's test corpus.
|
|
32
|
+
const KNOWN_SYNTHETIC_FIXTURE_FILES = new Set(["src/securityValidation/attackScenarios/payloadCorpus.ts"]);
|
|
33
|
+
// Skip files larger than this to keep the scan bounded and fast; source
|
|
34
|
+
// files this large are not expected in this project.
|
|
35
|
+
const MAX_SCANNABLE_FILE_BYTES = 1_000_000;
|
|
36
|
+
// A single NUL character, used as a cheap binary-file heuristic below.
|
|
37
|
+
// Built via fromCharCode rather than a "\0" string escape to avoid any
|
|
38
|
+
// literal control byte ending up in this source file.
|
|
39
|
+
const NUL_CHAR = String.fromCharCode(0);
|
|
40
|
+
// Returns file contents for the bounded glob set, skipping known synthetic
|
|
41
|
+
// fixtures, oversized files, and anything unreadable as UTF-8 text (treated
|
|
42
|
+
// as binary and skipped, not an error). Stable, deterministic order.
|
|
43
|
+
export function collectBoundedSourceFiles(targetRoot, globs = DEFAULT_SOURCE_SCAN_GLOBS) {
|
|
44
|
+
// collectFilesForGlobs() throws for the *entire* call if any single glob's
|
|
45
|
+
// base path doesn't exist (e.g. no .env file, no tsconfig.*.json match) —
|
|
46
|
+
// resolve each glob independently so one missing/inapplicable glob doesn't
|
|
47
|
+
// silently zero out results for all the others.
|
|
48
|
+
const candidateMap = new Map();
|
|
49
|
+
for (const glob of globs) {
|
|
50
|
+
let matches;
|
|
51
|
+
try {
|
|
52
|
+
matches = collectFilesForGlobs(targetRoot, [glob]);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// This glob's base path doesn't exist for this target — a legitimate
|
|
56
|
+
// "nothing to scan for this pattern" outcome, not an error.
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
for (const m of matches) {
|
|
60
|
+
candidateMap.set(m.relativePath, m.absolutePath);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const candidates = [...candidateMap.entries()]
|
|
64
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
65
|
+
.map(([relativePath, absolutePath]) => ({ relativePath, absolutePath }));
|
|
66
|
+
const files = [];
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
const normalizedRel = candidate.relativePath.replace(/\\/g, "/");
|
|
69
|
+
if (KNOWN_SYNTHETIC_FIXTURE_FILES.has(normalizedRel))
|
|
70
|
+
continue;
|
|
71
|
+
let stat;
|
|
72
|
+
try {
|
|
73
|
+
stat = fs.statSync(candidate.absolutePath);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!stat.isFile() || stat.size > MAX_SCANNABLE_FILE_BYTES)
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
const content = fs.readFileSync(candidate.absolutePath, "utf8");
|
|
82
|
+
if (content.indexOf(NUL_CHAR) !== -1)
|
|
83
|
+
continue;
|
|
84
|
+
files.push({ relativePath: normalizedRel, absolutePath: candidate.absolutePath, content });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
// Returns the 1-based line number containing `index` within `content`.
|
|
93
|
+
export function lineNumberAt(content, index) {
|
|
94
|
+
let line = 1;
|
|
95
|
+
for (let i = 0; i < index && i < content.length; i++) {
|
|
96
|
+
if (content[i] === "\n")
|
|
97
|
+
line++;
|
|
98
|
+
}
|
|
99
|
+
return line;
|
|
100
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.2.2 Batch 2 — exploit evidence model.
|
|
3
|
+
//
|
|
4
|
+
// Evidence attached to an AttackResult. Kept small and JSON-serializable by
|
|
5
|
+
// construction (no functions, no circular references, no raw Buffers).
|
|
6
|
+
// Redaction helpers here must never leak full secret-like values into
|
|
7
|
+
// reports; only bounded, redacted previews are stored.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
export const EXPLOIT_EVIDENCE_KINDS = [
|
|
10
|
+
"command",
|
|
11
|
+
"filesystem",
|
|
12
|
+
"output",
|
|
13
|
+
"target-modification",
|
|
14
|
+
"secret-leak",
|
|
15
|
+
"package-content",
|
|
16
|
+
"skipped-tool",
|
|
17
|
+
"skipped-scenario",
|
|
18
|
+
"observation",
|
|
19
|
+
];
|
|
20
|
+
const MAX_PREVIEW_LENGTH = 200;
|
|
21
|
+
// Patterns for common secret-like tokens. Deliberately conservative — false
|
|
22
|
+
// positives (over-redacting) are safe; false negatives are not.
|
|
23
|
+
const SECRET_LIKE_PATTERNS = [
|
|
24
|
+
/sk-[a-zA-Z0-9]{16,}/g,
|
|
25
|
+
/gh[pousr]_[a-zA-Z0-9]{16,}/g,
|
|
26
|
+
/AKIA[0-9A-Z]{12,}/g,
|
|
27
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
28
|
+
/[A-Za-z0-9_\-]{32,}/g,
|
|
29
|
+
];
|
|
30
|
+
// ANSI CSI sequences (color codes, cursor movement, etc.) and other C0/DEL
|
|
31
|
+
// control bytes. Newline/carriage-return/tab are preserved — they're
|
|
32
|
+
// structural whitespace, not a terminal-rewriting risk.
|
|
33
|
+
const ANSI_CSI_PATTERN = /\x1b\[[0-9;]*[a-zA-Z]/g;
|
|
34
|
+
const OTHER_CONTROL_CHARS_PATTERN = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
|
|
35
|
+
// Strips ANSI escape sequences and other unsafe control bytes. Applied
|
|
36
|
+
// before secret redaction so a payload cannot use control characters to
|
|
37
|
+
// hide from the secret-pattern matchers.
|
|
38
|
+
export function stripUnsafeControlChars(raw) {
|
|
39
|
+
return raw.replace(ANSI_CSI_PATTERN, "").replace(OTHER_CONTROL_CHARS_PATTERN, "");
|
|
40
|
+
}
|
|
41
|
+
// Redacts secret-looking substrings and terminal control sequences, and
|
|
42
|
+
// truncates to a bounded preview. Never returns the full original value when
|
|
43
|
+
// it is long, matches a secret-like pattern, or contains unsafe control
|
|
44
|
+
// bytes (e.g. ANSI escapes from a report-poisoning payload).
|
|
45
|
+
export function redactPreview(raw, maxLength = MAX_PREVIEW_LENGTH) {
|
|
46
|
+
let redacted = stripUnsafeControlChars(raw);
|
|
47
|
+
for (const pattern of SECRET_LIKE_PATTERNS) {
|
|
48
|
+
redacted = redacted.replace(pattern, (match) => `[REDACTED:${match.length}chars]`);
|
|
49
|
+
}
|
|
50
|
+
if (redacted.length > maxLength) {
|
|
51
|
+
return `${redacted.slice(0, maxLength)}... [truncated, ${raw.length} chars total]`;
|
|
52
|
+
}
|
|
53
|
+
return redacted;
|
|
54
|
+
}
|
|
55
|
+
let evidenceCounter = 0;
|
|
56
|
+
// Deterministic-enough id generator for evidence created within a single
|
|
57
|
+
// process run (report rendering does not depend on global uniqueness).
|
|
58
|
+
export function nextEvidenceId(prefix) {
|
|
59
|
+
evidenceCounter += 1;
|
|
60
|
+
return `${prefix}-${evidenceCounter}`;
|
|
61
|
+
}
|
|
62
|
+
export function makeEvidence(partial) {
|
|
63
|
+
const { rawPreview, ...rest } = partial;
|
|
64
|
+
return {
|
|
65
|
+
id: nextEvidenceId(partial.kind),
|
|
66
|
+
...rest,
|
|
67
|
+
redactedPreview: rawPreview !== undefined ? redactPreview(rawPreview) : undefined,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const LOCAL_TOOL_PROFILE = {
|
|
2
|
+
id: "local-tool",
|
|
3
|
+
label: "Local tool",
|
|
4
|
+
description: "Profile for a tool that is only ever run locally and is not published to a registry — package-content and npm-publish-shaped checks are less relevant, boundary/subprocess checks still matter.",
|
|
5
|
+
defaultCheckIds: ["deps", "static", "cli-adversarial", "fuzz"],
|
|
6
|
+
applicableCheckIds: ["deps", "static", "cli-adversarial", "fuzz", "boundary", "subprocess", "secrets", "network"],
|
|
7
|
+
scenarioGroups: ["boundary", "subprocess", "secrets", "network"],
|
|
8
|
+
mandatoryEvidenceCategories: ["command", "filesystem"],
|
|
9
|
+
skippedWording: "planned for a later v0.2.2 batch",
|
|
10
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const NODE_CLI_PACKAGE_PROFILE = {
|
|
2
|
+
id: "node-cli-package",
|
|
3
|
+
label: "Node CLI package",
|
|
4
|
+
description: "Default profile for a published Node.js CLI/package like my-dev-kit itself: local-first, deterministic, read-only with respect to user source.",
|
|
5
|
+
defaultCheckIds: ["deps", "package", "static", "cli-adversarial", "fuzz"],
|
|
6
|
+
applicableCheckIds: [
|
|
7
|
+
"deps",
|
|
8
|
+
"package",
|
|
9
|
+
"static",
|
|
10
|
+
"cli-adversarial",
|
|
11
|
+
"fuzz",
|
|
12
|
+
"boundary",
|
|
13
|
+
"subprocess",
|
|
14
|
+
"secrets",
|
|
15
|
+
"network",
|
|
16
|
+
],
|
|
17
|
+
scenarioGroups: ["boundary", "subprocess", "secrets", "network"],
|
|
18
|
+
mandatoryEvidenceCategories: ["command", "filesystem", "output"],
|
|
19
|
+
skippedWording: "planned for a later v0.2.2 batch",
|
|
20
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const NPM_PACKAGE_PROFILE = {
|
|
2
|
+
id: "npm-package",
|
|
3
|
+
label: "npm package",
|
|
4
|
+
description: "Profile for a general published npm package (not necessarily a CLI) — emphasizes package-content, dependency, and secret-leakage checks over CLI-argument-shaped adversarial tests.",
|
|
5
|
+
defaultCheckIds: ["deps", "package", "static"],
|
|
6
|
+
applicableCheckIds: ["deps", "package", "static", "cli-adversarial", "fuzz", "boundary", "secrets", "network"],
|
|
7
|
+
scenarioGroups: ["boundary", "secrets", "network"],
|
|
8
|
+
mandatoryEvidenceCategories: ["package-content", "secret-leak"],
|
|
9
|
+
skippedWording: "planned for a later v0.2.2 batch",
|
|
10
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.2.2 Batch 6 — report-poisoning JSON schema guard.
|
|
3
|
+
//
|
|
4
|
+
// Batch 3/4 scenarios (configInjectionScenario, reportPoisoningScenario)
|
|
5
|
+
// each hardcoded their own "expected top-level JSON keys" allowlist to
|
|
6
|
+
// detect payload-created structural injection. That approach broke twice
|
|
7
|
+
// (Batch 4, Batch 5) purely because a *legitimate* additive top-level field
|
|
8
|
+
// was introduced elsewhere and nobody remembered to update both copies of
|
|
9
|
+
// the list.
|
|
10
|
+
//
|
|
11
|
+
// This module replaces the hardcoded-allowlist approach with a baseline
|
|
12
|
+
// comparison: render the same report shape twice — once with an inert
|
|
13
|
+
// placeholder string in the content fields a payload would occupy, once
|
|
14
|
+
// with the actual payload — and compare the top-level key *sets* of the
|
|
15
|
+
// parsed JSON. A payload can only produce an "extra" key if it actually broke
|
|
16
|
+
// out of its string context and created new JSON structure; legitimate
|
|
17
|
+
// schema evolution (a new field added to renderJsonReport's output) shows up
|
|
18
|
+
// identically in both renders and is never flagged. This keeps the
|
|
19
|
+
// injection check meaningful without hardcoding field names anywhere.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Inert content used for the "clean" baseline render — deliberately boring,
|
|
22
|
+
// never matches any payload shape.
|
|
23
|
+
export const SAFE_BASELINE_CONTENT = "SAFE_BASELINE_CONTENT_NOT_A_PAYLOAD";
|
|
24
|
+
// Compares the top-level key sets of two already-rendered JSON strings
|
|
25
|
+
// (baseline vs. candidate). Returns parseable:false (and no injected keys)
|
|
26
|
+
// if either fails to parse — a parse failure is reported separately by
|
|
27
|
+
// callers as "jsonCorrupted", not conflated with structural injection.
|
|
28
|
+
export function detectJsonStructuralInjection(baselineJson, candidateJson) {
|
|
29
|
+
let baseline;
|
|
30
|
+
let candidate;
|
|
31
|
+
try {
|
|
32
|
+
baseline = JSON.parse(baselineJson);
|
|
33
|
+
candidate = JSON.parse(candidateJson);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { parseable: false, injectedTopLevelKeys: [] };
|
|
37
|
+
}
|
|
38
|
+
const baselineKeys = new Set(Object.keys(baseline));
|
|
39
|
+
const injectedTopLevelKeys = Object.keys(candidate).filter((k) => !baselineKeys.has(k));
|
|
40
|
+
return { parseable: true, injectedTopLevelKeys };
|
|
41
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { makeEvidence } from "../exploitEvidence.js";
|
|
2
|
+
import { getPayloadsForGroup } from "../payloadCorpus.js";
|
|
3
|
+
import { normalizeSecurityValidateConfig } from "../../validate/cliOptions.js";
|
|
4
|
+
import { renderJsonReport, renderTextReport } from "../../report/renderSecurityReport.js";
|
|
5
|
+
import { SAFE_BASELINE_CONTENT, detectJsonStructuralInjection } from "../reportSchemaGuard.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// v0.2.2 Batch 3 — config injection scenario.
|
|
8
|
+
//
|
|
9
|
+
// Two parts, using the Batch 2 "malformed-config" and "report-poisoning"
|
|
10
|
+
// payload groups:
|
|
11
|
+
// 1. Confirms malformed values for --checks/--profile/--format/--fail-on are
|
|
12
|
+
// rejected by normalizeSecurityValidateConfig() (Batch 1) rather than
|
|
13
|
+
// silently accepted or partially applied.
|
|
14
|
+
// 2. Confirms JSON-breakout / ANSI-escape style strings embedded in report
|
|
15
|
+
// content (e.g. via a check name or skip reason) do not corrupt
|
|
16
|
+
// renderJsonReport()'s output (still valid, parseable JSON) or crash
|
|
17
|
+
// renderTextReport().
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
function makeFixtureReport(injected) {
|
|
20
|
+
const now = new Date().toISOString();
|
|
21
|
+
return {
|
|
22
|
+
metadata: {
|
|
23
|
+
toolRoot: "/tool/root",
|
|
24
|
+
toolPackageName: "my-dev-kit-lab",
|
|
25
|
+
toolPackageVersion: "0.2.1",
|
|
26
|
+
targetRoot: "/tool/root",
|
|
27
|
+
targetDescription: "self (my-dev-kit-lab)",
|
|
28
|
+
packageName: "my-dev-kit-lab",
|
|
29
|
+
packageVersion: "0.2.1",
|
|
30
|
+
branch: injected,
|
|
31
|
+
commit: "abc1234",
|
|
32
|
+
isSelf: true,
|
|
33
|
+
generatedAt: now,
|
|
34
|
+
totalDurationMs: 1,
|
|
35
|
+
},
|
|
36
|
+
sections: [],
|
|
37
|
+
allChecks: [
|
|
38
|
+
{
|
|
39
|
+
id: "config-injection-fixture",
|
|
40
|
+
name: injected,
|
|
41
|
+
category: "artifact-safety",
|
|
42
|
+
status: "skipped",
|
|
43
|
+
severity: "skipped",
|
|
44
|
+
startedAt: now,
|
|
45
|
+
finishedAt: now,
|
|
46
|
+
durationMs: 0,
|
|
47
|
+
findings: [],
|
|
48
|
+
skippedReason: injected,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
allFindings: [],
|
|
52
|
+
verdict: "ready-except-optional-manual-checks",
|
|
53
|
+
recommendedNextStep: injected,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export const CONFIG_INJECTION_SCENARIO = {
|
|
57
|
+
id: "config-injection-safety",
|
|
58
|
+
title: "Config injection: malformed CLI config is rejected; injected content cannot corrupt reports",
|
|
59
|
+
description: "Confirms malformed --checks/--profile/--format/--fail-on values fail cleanly via normalizeSecurityValidateConfig(), and that JSON-breakout/ANSI-escape strings embedded in report content do not corrupt JSON output or crash text rendering.",
|
|
60
|
+
checkId: "boundary",
|
|
61
|
+
applicableProfiles: [],
|
|
62
|
+
severityBaseline: "major",
|
|
63
|
+
verdictImpact: "tool-framework-blocker",
|
|
64
|
+
expectedSafeBehavior: "Malformed config values always throw before any validation runs; injected content in report fields never breaks JSON parseability or text rendering.",
|
|
65
|
+
evidenceRequirements: ["observation", "output"],
|
|
66
|
+
run: async (ctx) => {
|
|
67
|
+
const evidence = [];
|
|
68
|
+
const notRejected = [];
|
|
69
|
+
const malformedPayloads = getPayloadsForGroup("malformed-config");
|
|
70
|
+
const flagsToTest = ["checks", "profile", "format", "failOn"];
|
|
71
|
+
for (const payload of malformedPayloads) {
|
|
72
|
+
for (const flag of flagsToTest) {
|
|
73
|
+
let rejected = false;
|
|
74
|
+
try {
|
|
75
|
+
normalizeSecurityValidateConfig({ [flag]: payload.value }, ctx.toolRoot);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
rejected = true;
|
|
79
|
+
}
|
|
80
|
+
if (!rejected) {
|
|
81
|
+
notRejected.push(`${flag}=${payload.id}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
evidence.push(makeEvidence({
|
|
86
|
+
kind: "observation",
|
|
87
|
+
source: "normalizeSecurityValidateConfig()",
|
|
88
|
+
confidence: "high",
|
|
89
|
+
expectedBehavior: `All ${malformedPayloads.length * flagsToTest.length} malformed (payload, flag) combinations must throw.`,
|
|
90
|
+
observedBehavior: notRejected.length === 0
|
|
91
|
+
? "All malformed combinations were rejected."
|
|
92
|
+
: `${notRejected.length} combination(s) were NOT rejected.`,
|
|
93
|
+
}));
|
|
94
|
+
const poisoningPayloads = getPayloadsForGroup("report-poisoning");
|
|
95
|
+
const baselineJson = renderJsonReport(makeFixtureReport(SAFE_BASELINE_CONTENT));
|
|
96
|
+
let jsonCorrupted = false;
|
|
97
|
+
let textCrashed = false;
|
|
98
|
+
for (const payload of poisoningPayloads) {
|
|
99
|
+
const report = makeFixtureReport(payload.value);
|
|
100
|
+
try {
|
|
101
|
+
const json = renderJsonReport(report);
|
|
102
|
+
// Baseline-diff check (v0.2.2 Batch 6): a payload can only widen the
|
|
103
|
+
// top-level JSON shape by actually breaking out of its string
|
|
104
|
+
// context — legitimate additive schema fields render identically in
|
|
105
|
+
// both the baseline and poisoned renders and are never flagged.
|
|
106
|
+
const injection = detectJsonStructuralInjection(baselineJson, json);
|
|
107
|
+
if (!injection.parseable || injection.injectedTopLevelKeys.length > 0) {
|
|
108
|
+
jsonCorrupted = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
jsonCorrupted = true;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
renderTextReport(report);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
textCrashed = true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
evidence.push(makeEvidence({
|
|
122
|
+
kind: "output",
|
|
123
|
+
source: "renderJsonReport() / renderTextReport() with report-poisoning payloads",
|
|
124
|
+
confidence: "high",
|
|
125
|
+
expectedBehavior: "JSON stays parseable with no extra top-level keys; text rendering never throws.",
|
|
126
|
+
observedBehavior: `jsonCorrupted=${jsonCorrupted}, textCrashed=${textCrashed}`,
|
|
127
|
+
}));
|
|
128
|
+
if (notRejected.length > 0 || jsonCorrupted || textCrashed) {
|
|
129
|
+
return {
|
|
130
|
+
status: "failed",
|
|
131
|
+
confidence: "high",
|
|
132
|
+
evidence,
|
|
133
|
+
recommendation: notRejected.length > 0
|
|
134
|
+
? "Ensure normalizeSecurityValidateConfig() validates every enum flag against its known-value list."
|
|
135
|
+
: "Ensure report rendering treats all field content as opaque data (JSON.stringify already does this by construction).",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
status: "passed",
|
|
140
|
+
confidence: "high",
|
|
141
|
+
evidence,
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
};
|