@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,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
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { makeEvidence } from "../exploitEvidence.js";
|
|
2
|
+
import { collectBoundedSourceFiles, lineNumberAt } from "../boundedSourceScan.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// v0.2.2 Batch 4 — network / local-first assumption scenario.
|
|
5
|
+
//
|
|
6
|
+
// Careful, narrow claim only: a bounded static scan of local-first source
|
|
7
|
+
// paths (manifests, src/, scripts/) finding no obvious unexpected network-API
|
|
8
|
+
// usage. This is NOT proof of runtime network isolation — subprocess-invoked
|
|
9
|
+
// tools (npm audit/outdated, OSV-Scanner, Semgrep's npx fallback) legitimately
|
|
10
|
+
// contact the network when those check groups are selected, and this
|
|
11
|
+
// scenario does not and cannot verify their runtime behavior. No network
|
|
12
|
+
// calls, probes, or instrumentation are performed by this scenario itself.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
const NETWORK_API_PATTERNS = [
|
|
15
|
+
{ id: "fetch-call", regex: /\bfetch\s*\(/g },
|
|
16
|
+
{ id: "http-module-import", regex: /(?:require\(\s*["']https?["']\s*\)|from\s+["']https?["'])/g },
|
|
17
|
+
{ id: "net-tls-module-import", regex: /(?:require\(\s*["'](?:net|tls)["']\s*\)|from\s+["'](?:net|tls)["'])/g },
|
|
18
|
+
{ id: "axios-import", regex: /(?:require\(\s*["']axios["']\s*\)|from\s+["']axios["'])/g },
|
|
19
|
+
{ id: "got-import", regex: /(?:require\(\s*["']got["']\s*\)|from\s+["']got["'])/g },
|
|
20
|
+
{ id: "request-import", regex: /(?:require\(\s*["']request["']\s*\)|from\s+["']request["'])/g },
|
|
21
|
+
{ id: "undici-import", regex: /(?:require\(\s*["']undici["']\s*\)|from\s+["']undici["'])/g },
|
|
22
|
+
{ id: "node-fetch-import", regex: /(?:require\(\s*["']node-fetch["']\s*\)|from\s+["']node-fetch["'])/g },
|
|
23
|
+
{ id: "websocket-usage", regex: /new\s+WebSocket\s*\(/g },
|
|
24
|
+
];
|
|
25
|
+
// Paths where network-touching behavior is expected and intentional (they
|
|
26
|
+
// shell out to npm/OSV-Scanner/etc, not a local-first-source violation).
|
|
27
|
+
const KNOWN_NETWORK_INTENTIONAL_PREFIXES = [
|
|
28
|
+
"src/securityValidation/dependencies/",
|
|
29
|
+
"src/securityValidation/staticScans/",
|
|
30
|
+
];
|
|
31
|
+
function isKnownIntentionalPath(relativePath) {
|
|
32
|
+
return KNOWN_NETWORK_INTENTIONAL_PREFIXES.some((p) => relativePath.startsWith(p));
|
|
33
|
+
}
|
|
34
|
+
function scanFileForNetworkApis(relativePath, content) {
|
|
35
|
+
const matches = [];
|
|
36
|
+
const intentional = isKnownIntentionalPath(relativePath);
|
|
37
|
+
for (const { id, regex } of NETWORK_API_PATTERNS) {
|
|
38
|
+
const re = new RegExp(regex.source, regex.flags);
|
|
39
|
+
let match;
|
|
40
|
+
while ((match = re.exec(content)) !== null) {
|
|
41
|
+
if (match[0].length === 0) {
|
|
42
|
+
re.lastIndex += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
matches.push({ id, file: relativePath, line: lineNumberAt(content, match.index), intentional });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return matches;
|
|
49
|
+
}
|
|
50
|
+
export const NETWORK_ASSUMPTION_SCENARIO = {
|
|
51
|
+
id: "network-local-first-bounded-scan",
|
|
52
|
+
title: "Network assumption: bounded static scan finds no unexpected network API usage in local-first source",
|
|
53
|
+
description: "Bounded static scan of local-first source paths (manifests, src/, scripts/) for obvious network API usage (fetch/http/https/net/tls/axios/got/request/undici/node-fetch/WebSocket). Known network-using validation commands (npm audit, npm outdated, OSV-Scanner) are treated as expected, not violations. This does not prove runtime network isolation.",
|
|
54
|
+
checkId: "network",
|
|
55
|
+
applicableProfiles: [],
|
|
56
|
+
severityBaseline: "informational",
|
|
57
|
+
verdictImpact: "target-project-blocker",
|
|
58
|
+
expectedSafeBehavior: "No obvious unexpected network API usage is found in the bounded local-first source paths inspected.",
|
|
59
|
+
evidenceRequirements: ["observation"],
|
|
60
|
+
run: async (ctx) => {
|
|
61
|
+
const files = collectBoundedSourceFiles(ctx.target.targetRoot);
|
|
62
|
+
const isolationCaveat = makeEvidence({
|
|
63
|
+
kind: "observation",
|
|
64
|
+
source: "scope statement",
|
|
65
|
+
confidence: "high",
|
|
66
|
+
expectedBehavior: "This is a bounded static check of local-first source paths only. It does not prove runtime network isolation of subprocess-invoked tools (npm, CodeQL, Semgrep, OSV-Scanner) or any other process spawned during validation.",
|
|
67
|
+
observedBehavior: "Static-source-only claim; no runtime network instrumentation was performed.",
|
|
68
|
+
});
|
|
69
|
+
if (files.length === 0) {
|
|
70
|
+
return {
|
|
71
|
+
status: "passed",
|
|
72
|
+
confidence: "low",
|
|
73
|
+
evidence: [
|
|
74
|
+
isolationCaveat,
|
|
75
|
+
makeEvidence({
|
|
76
|
+
kind: "observation",
|
|
77
|
+
source: "bounded network scan file collection",
|
|
78
|
+
confidence: "low",
|
|
79
|
+
expectedBehavior: "At least one file would be scanned for a meaningful bounded-scan claim.",
|
|
80
|
+
observedBehavior: "0 files matched the bounded glob set. No findings because there was nothing to scan.",
|
|
81
|
+
}),
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const allMatches = [];
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
allMatches.push(...scanFileForNetworkApis(file.relativePath, file.content));
|
|
88
|
+
}
|
|
89
|
+
allMatches.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
90
|
+
const unexpected = allMatches.filter((m) => !m.intentional);
|
|
91
|
+
const intentional = allMatches.filter((m) => m.intentional);
|
|
92
|
+
const evidence = [
|
|
93
|
+
isolationCaveat,
|
|
94
|
+
makeEvidence({
|
|
95
|
+
kind: "observation",
|
|
96
|
+
source: "bounded network API scan",
|
|
97
|
+
confidence: "medium",
|
|
98
|
+
expectedBehavior: "Bounded scan of manifests/src/scripts for direct network API usage.",
|
|
99
|
+
observedBehavior: `${files.length} file(s) scanned; ${unexpected.length} unexpected match(es), ${intentional.length} known-intentional match(es).`,
|
|
100
|
+
}),
|
|
101
|
+
makeEvidence({
|
|
102
|
+
kind: "observation",
|
|
103
|
+
source: "known network-using validation commands",
|
|
104
|
+
confidence: "high",
|
|
105
|
+
expectedBehavior: "npm audit / npm outdated (deps check) may contact the npm registry; OSV-Scanner may contact the OSV API; Semgrep's npx fallback may contact the npm registry. These are expected when those check groups are selected via --checks and are not local-first source violations.",
|
|
106
|
+
observedBehavior: `${intentional.length} match(es) found under known network-using paths (${KNOWN_NETWORK_INTENTIONAL_PREFIXES.join(", ")}).`,
|
|
107
|
+
}),
|
|
108
|
+
...unexpected.slice(0, 20).map((m) => makeEvidence({
|
|
109
|
+
kind: "observation",
|
|
110
|
+
source: `pattern '${m.id}'`,
|
|
111
|
+
filePath: m.file,
|
|
112
|
+
line: m.line,
|
|
113
|
+
confidence: "medium",
|
|
114
|
+
expectedBehavior: "No unexpected direct network API usage in local-first source paths.",
|
|
115
|
+
observedBehavior: `Detected '${m.id}' usage in ${m.file}:${m.line}.`,
|
|
116
|
+
})),
|
|
117
|
+
];
|
|
118
|
+
if (unexpected.length > 0) {
|
|
119
|
+
return {
|
|
120
|
+
status: "failed",
|
|
121
|
+
confidence: "medium",
|
|
122
|
+
evidence,
|
|
123
|
+
severity: "major",
|
|
124
|
+
recommendation: `Review ${unexpected.length} unexpected network-API usage(s) in local-first source paths and confirm they are intentional, or move them under a clearly network-using module.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return { status: "passed", confidence: "medium", evidence };
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { makeEvidence } from "../exploitEvidence.js";
|
|
3
|
+
import { getPayloadsForGroup } from "../payloadCorpus.js";
|
|
4
|
+
import { reportDirIsInsideExternalTarget } from "./targetSandboxScenario.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// v0.2.2 Batch 3 — output/generated-artifact boundary scenario.
|
|
7
|
+
//
|
|
8
|
+
// Two parts:
|
|
9
|
+
// 1. A real safety check: does the *default* (no --out) report output
|
|
10
|
+
// directory resolve inside an external target's working tree? That is
|
|
11
|
+
// the one genuinely dangerous default-behavior case (Batch 1/2 already
|
|
12
|
+
// made --out itself user-directed and unrestricted by design, so an
|
|
13
|
+
// explicit --out escaping is not itself a finding here).
|
|
14
|
+
// 2. Deterministic, non-destructive classification of the "output-boundary"
|
|
15
|
+
// payload corpus (paths with spaces, relative, absolute, home-dir) to
|
|
16
|
+
// produce evidence that path resolution behaves predictably — this does
|
|
17
|
+
// not write any files.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
export const OUTPUT_BOUNDARY_SCENARIO = {
|
|
20
|
+
id: "output-boundary-report-dir",
|
|
21
|
+
title: "Output boundary: generated reports are not written inside an external target",
|
|
22
|
+
description: "Confirms the default report output directory does not resolve inside an external target's working tree, and classifies output-boundary payload path resolution without writing any files.",
|
|
23
|
+
checkId: "boundary",
|
|
24
|
+
applicableProfiles: [],
|
|
25
|
+
severityBaseline: "major",
|
|
26
|
+
verdictImpact: "release-blocker",
|
|
27
|
+
expectedSafeBehavior: "The default report output directory resolves outside any external target's working tree; payload path resolution is deterministic.",
|
|
28
|
+
evidenceRequirements: ["filesystem", "observation"],
|
|
29
|
+
run: async (ctx) => {
|
|
30
|
+
const evidence = [];
|
|
31
|
+
const insideTarget = reportDirIsInsideExternalTarget(ctx.config.reportDir, ctx.target.targetRoot, ctx.target.isSelf);
|
|
32
|
+
evidence.push(makeEvidence({
|
|
33
|
+
kind: "filesystem",
|
|
34
|
+
source: "default report output directory",
|
|
35
|
+
filePath: path.resolve(ctx.config.reportDir),
|
|
36
|
+
confidence: "high",
|
|
37
|
+
expectedBehavior: "Default report output directory must not resolve inside an external target project.",
|
|
38
|
+
observedBehavior: ctx.target.isSelf
|
|
39
|
+
? "Self-validation: report directory is expected to be under the tool root."
|
|
40
|
+
: insideTarget
|
|
41
|
+
? `Report directory resolves inside external target root '${ctx.target.targetRoot}'.`
|
|
42
|
+
: `Report directory resolves outside external target root '${ctx.target.targetRoot}'.`,
|
|
43
|
+
}));
|
|
44
|
+
const payloads = getPayloadsForGroup("output-boundary");
|
|
45
|
+
for (const p of payloads) {
|
|
46
|
+
const resolved = path.resolve(ctx.toolRoot, p.value);
|
|
47
|
+
evidence.push(makeEvidence({
|
|
48
|
+
kind: "observation",
|
|
49
|
+
source: `output-boundary payload '${p.id}'`,
|
|
50
|
+
confidence: "medium",
|
|
51
|
+
observedBehavior: `Resolved deterministically without any file write.`,
|
|
52
|
+
rawPreview: `${p.value} -> ${resolved}`,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
if (insideTarget) {
|
|
56
|
+
return {
|
|
57
|
+
status: "failed",
|
|
58
|
+
confidence: "high",
|
|
59
|
+
evidence,
|
|
60
|
+
recommendation: "Ensure the default report output directory is derived from the tool root, not the target root, for external-target validation.",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
status: "passed",
|
|
65
|
+
confidence: "high",
|
|
66
|
+
evidence,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { makeEvidence } from "../exploitEvidence.js";
|
|
2
|
+
import { runSecurityCommand, resolveNpmCommand } from "../../commandRunner.js";
|
|
3
|
+
import { parseNpmPackDryRun } from "../../packageChecks/parseNpmPackDryRun.js";
|
|
4
|
+
import { detectForbiddenContents, isCriticalForbidden } from "../../packageChecks/forbiddenPackageContents.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// v0.2.2 Batch 3 — package boundary scenario.
|
|
7
|
+
//
|
|
8
|
+
// Reuses the existing npm-pack-dry-run parser and forbidden-content detector
|
|
9
|
+
// (src/securityValidation/packageChecks/) rather than re-implementing tarball
|
|
10
|
+
// parsing. This scenario adds structured, evidence-first attack-scenario
|
|
11
|
+
// framing on top of the same underlying data the "package" check group
|
|
12
|
+
// already produces — it does not publish or create real package artifacts.
|
|
13
|
+
//
|
|
14
|
+
// v0.2.2 Batch 5: escalates the result severity to "blocker" (via
|
|
15
|
+
// AttackScenarioRunOutcome.severity) when any matched file is
|
|
16
|
+
// isCriticalForbidden() (.env, .pem, .key, .p12, lab-output/, .my-dev-kit/) —
|
|
17
|
+
// reusing the exact same critical-file rule the underlying package check
|
|
18
|
+
// already applies, so a real secret-shaped leak in the tarball reliably
|
|
19
|
+
// triggers the "not-ready" verdict via blocker severity, not just "major".
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
export const PACKAGE_BOUNDARY_SCENARIO = {
|
|
22
|
+
id: "package-boundary-forbidden-content",
|
|
23
|
+
title: "Package boundary: npm tarball excludes forbidden/local-only content",
|
|
24
|
+
description: "Runs npm pack --dry-run against the target and verifies the resulting tarball file list does not include forbidden paths (.env, reports/security/, lab-output/, .my-dev-kit/, tarballs, node_modules, etc).",
|
|
25
|
+
checkId: "boundary",
|
|
26
|
+
applicableProfiles: [],
|
|
27
|
+
severityBaseline: "major",
|
|
28
|
+
verdictImpact: "target-project-blocker",
|
|
29
|
+
expectedSafeBehavior: "The npm pack --dry-run file list contains no files matching the forbidden content patterns.",
|
|
30
|
+
evidenceRequirements: ["package-content", "command"],
|
|
31
|
+
skipCondition: (ctx) => {
|
|
32
|
+
if (!ctx.target.hasPackageJson) {
|
|
33
|
+
return "Target has no package.json; npm pack --dry-run based package boundary check is not applicable.";
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
},
|
|
37
|
+
run: async (ctx) => {
|
|
38
|
+
const npm = resolveNpmCommand();
|
|
39
|
+
const cmd = await runSecurityCommand({
|
|
40
|
+
command: npm,
|
|
41
|
+
args: ["pack", "--dry-run"],
|
|
42
|
+
cwd: ctx.target.targetRoot,
|
|
43
|
+
timeoutMs: ctx.config.commandTimeoutMs,
|
|
44
|
+
});
|
|
45
|
+
const commandEvidence = makeEvidence({
|
|
46
|
+
kind: "command",
|
|
47
|
+
source: "npm pack --dry-run",
|
|
48
|
+
commandCwd: ctx.target.targetRoot,
|
|
49
|
+
commandSummary: `${npm} pack --dry-run`,
|
|
50
|
+
exitCode: cmd.exitCode,
|
|
51
|
+
confidence: "high",
|
|
52
|
+
});
|
|
53
|
+
const packOutput = [cmd.stdout, cmd.stderr].find((stream) => /tarball contents/i.test(stream)) ??
|
|
54
|
+
[cmd.stdout, cmd.stderr].filter(Boolean).join("\n");
|
|
55
|
+
const parsed = parseNpmPackDryRun(packOutput);
|
|
56
|
+
if (parsed.parseError && parsed.files.length === 0) {
|
|
57
|
+
return {
|
|
58
|
+
status: "blocked",
|
|
59
|
+
confidence: "low",
|
|
60
|
+
evidence: [
|
|
61
|
+
commandEvidence,
|
|
62
|
+
makeEvidence({
|
|
63
|
+
kind: "skipped-tool",
|
|
64
|
+
source: "npm pack --dry-run",
|
|
65
|
+
confidence: "low",
|
|
66
|
+
observedBehavior: parsed.parseError,
|
|
67
|
+
expectedBehavior: "npm pack --dry-run should list tarball contents for evaluation.",
|
|
68
|
+
}),
|
|
69
|
+
],
|
|
70
|
+
recommendation: "Ensure npm is available and the target package.json is valid, then re-run.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const { matches, findings } = detectForbiddenContents({
|
|
74
|
+
files: parsed.files,
|
|
75
|
+
forbiddenPatterns: ctx.config.forbiddenPackagePatterns,
|
|
76
|
+
allowedExceptions: ctx.config.allowedPackageExceptions,
|
|
77
|
+
checkId: "attack-boundary-package",
|
|
78
|
+
});
|
|
79
|
+
if (matches.length === 0) {
|
|
80
|
+
return {
|
|
81
|
+
status: "passed",
|
|
82
|
+
confidence: "high",
|
|
83
|
+
evidence: [
|
|
84
|
+
commandEvidence,
|
|
85
|
+
makeEvidence({
|
|
86
|
+
kind: "package-content",
|
|
87
|
+
source: "npm pack --dry-run file list",
|
|
88
|
+
confidence: "high",
|
|
89
|
+
observedBehavior: `${parsed.files.length} file(s) inspected; none matched a forbidden pattern.`,
|
|
90
|
+
expectedBehavior: "No forbidden paths in the tarball file list.",
|
|
91
|
+
}),
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const evidence = [
|
|
96
|
+
commandEvidence,
|
|
97
|
+
...matches.slice(0, 20).map((m, i) => {
|
|
98
|
+
const finding = findings[i];
|
|
99
|
+
return makeEvidence({
|
|
100
|
+
kind: "package-content",
|
|
101
|
+
source: "npm pack --dry-run file list",
|
|
102
|
+
filePath: m.file,
|
|
103
|
+
confidence: "high",
|
|
104
|
+
expectedBehavior: "Forbidden paths must not appear in the published tarball.",
|
|
105
|
+
observedBehavior: `Included '${m.file}' matching forbidden pattern '${m.pattern}' (${finding?.severity ?? "major"} severity).`,
|
|
106
|
+
});
|
|
107
|
+
}),
|
|
108
|
+
];
|
|
109
|
+
const hasCriticalMatch = matches.some((m) => isCriticalForbidden(m.file));
|
|
110
|
+
return {
|
|
111
|
+
status: "failed",
|
|
112
|
+
confidence: "high",
|
|
113
|
+
evidence,
|
|
114
|
+
severity: hasCriticalMatch ? "blocker" : "major",
|
|
115
|
+
recommendation: `Add ${matches.length} matched path(s) to .npmignore or the package.json "files" allowlist before publishing.`,
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|