codegate-ai 0.16.2 → 1.0.0
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/dist/cli.d.ts +3 -1
- package/dist/cli.js +153 -44
- package/dist/commands/scan-command.d.ts +2 -1
- package/dist/commands/scan-command.js +6 -1
- package/dist/commands/trust.d.ts +28 -0
- package/dist/commands/trust.js +69 -0
- package/dist/config/inline-ignore.d.ts +10 -2
- package/dist/config/inline-ignore.js +5 -1
- package/dist/config/suppression-policy.d.ts +1 -1
- package/dist/config/suppression-policy.js +4 -1
- package/dist/config/trust.d.ts +2 -0
- package/dist/config/trust.js +19 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +45 -1
- package/dist/content/content-bundle.d.ts +27 -0
- package/dist/content/content-bundle.js +73 -0
- package/dist/content/content-store.d.ts +27 -0
- package/dist/content/content-store.js +0 -0
- package/dist/content/content-updater.d.ts +32 -0
- package/dist/content/content-updater.js +111 -0
- package/dist/content/known-bad.d.ts +26 -0
- package/dist/content/known-bad.js +100 -0
- package/dist/content/publisher-key.d.ts +10 -0
- package/dist/content/publisher-key.js +10 -0
- package/dist/layer1-discovery/knowledge-base.js +33 -1
- package/dist/layer2-static/data/popular-mcp-packages.d.ts +16 -0
- package/dist/layer2-static/data/popular-mcp-packages.js +83 -0
- package/dist/layer2-static/detectors/known-bad.d.ts +22 -0
- package/dist/layer2-static/detectors/known-bad.js +116 -0
- package/dist/layer2-static/detectors/mcp-package-hygiene.d.ts +28 -0
- package/dist/layer2-static/detectors/mcp-package-hygiene.js +191 -0
- package/dist/layer2-static/detectors/rule-file.js +128 -75
- package/dist/layer2-static/detectors/skill-frontmatter.d.ts +6 -0
- package/dist/layer2-static/detectors/skill-frontmatter.js +130 -0
- package/dist/layer2-static/engine.d.ts +2 -0
- package/dist/layer2-static/engine.js +0 -0
- package/dist/layer2-static/rule-pack-loader.js +24 -1
- package/dist/layer2-static/state/scan-state.d.ts +7 -2
- package/dist/layer2-static/state/scan-state.js +74 -30
- package/dist/layer2-static/text/confusables.d.ts +7 -0
- package/dist/layer2-static/text/confusables.js +70 -0
- package/dist/layer2-static/text/edit-distance.d.ts +6 -0
- package/dist/layer2-static/text/edit-distance.js +33 -0
- package/dist/layer2-static/text/encoded-payloads.d.ts +16 -0
- package/dist/layer2-static/text/encoded-payloads.js +116 -0
- package/dist/layer2-static/text/normalize.d.ts +11 -0
- package/dist/layer2-static/text/normalize.js +19 -0
- package/dist/layer2-static/text/override-phrases.d.ts +14 -0
- package/dist/layer2-static/text/override-phrases.js +49 -0
- package/dist/layer2-static/text/threat-patterns.d.ts +33 -0
- package/dist/layer2-static/text/threat-patterns.js +45 -0
- package/dist/layer2-static/text/unicode.d.ts +33 -0
- package/dist/layer2-static/text/unicode.js +83 -0
- package/dist/layer3-dynamic/deep-resource-executor.d.ts +21 -0
- package/dist/layer3-dynamic/deep-resource-executor.js +73 -0
- package/dist/layer3-dynamic/meta-agent.js +2 -1
- package/dist/layer3-dynamic/registry-client.d.ts +26 -0
- package/dist/layer3-dynamic/registry-client.js +138 -0
- package/dist/layer3-dynamic/registry-findings.d.ts +7 -0
- package/dist/layer3-dynamic/registry-findings.js +64 -0
- package/dist/layer3-dynamic/tool-description-scanner.js +22 -13
- package/dist/layer3-dynamic/toxic-flow.d.ts +4 -0
- package/dist/layer3-dynamic/toxic-flow.js +41 -8
- package/dist/pipeline.d.ts +5 -2
- package/dist/pipeline.js +53 -16
- package/dist/report-summary.d.ts +1 -1
- package/dist/report-summary.js +9 -1
- package/dist/scan.d.ts +13 -0
- package/dist/scan.js +291 -17
- package/dist/types/finding.d.ts +14 -0
- package/dist/types/finding.js +14 -0
- package/dist/types/report.d.ts +2 -0
- package/dist/wrapper.js +2 -19
- package/package.json +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { load as parseYaml } from "js-yaml";
|
|
2
|
+
import { normalizeForMatching } from "../text/normalize.js";
|
|
3
|
+
import { REMOTE_INSTRUCTION_INDIRECTION_PATTERN, REMOTE_SHELL_PATTERN, findOverridePhrase, } from "../text/threat-patterns.js";
|
|
4
|
+
const SKILL_FILE_NAME = "skill.md";
|
|
5
|
+
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u;
|
|
6
|
+
// Tool grants that hand a skill unrestricted execution or wildcard access.
|
|
7
|
+
const BROAD_TOOL_BASES = new Set(["bash", "shell", "terminal", "exec", "execute"]);
|
|
8
|
+
const WILDCARD_GRANTS = new Set(["*", "all"]);
|
|
9
|
+
function isSkillFile(filePath) {
|
|
10
|
+
const segments = filePath.replaceAll("\\", "/").split("/");
|
|
11
|
+
return (segments[segments.length - 1] ?? "").toLowerCase() === SKILL_FILE_NAME;
|
|
12
|
+
}
|
|
13
|
+
function skillDirectoryName(filePath) {
|
|
14
|
+
const segments = filePath.replaceAll("\\", "/").split("/");
|
|
15
|
+
return segments.length >= 2 ? (segments[segments.length - 2] ?? null) : null;
|
|
16
|
+
}
|
|
17
|
+
function makeFinding(input, ruleId, field, severity, category, description, cwe) {
|
|
18
|
+
return {
|
|
19
|
+
rule_id: ruleId,
|
|
20
|
+
finding_id: `SKILL_FRONTMATTER-${input.filePath}-${field}`,
|
|
21
|
+
severity,
|
|
22
|
+
category,
|
|
23
|
+
layer: "L2",
|
|
24
|
+
file_path: input.filePath,
|
|
25
|
+
location: { field: `frontmatter.${field}` },
|
|
26
|
+
description,
|
|
27
|
+
affected_tools: ["claude-code", "codex-cli", "opencode", "cursor"],
|
|
28
|
+
cve: null,
|
|
29
|
+
owasp: ["ASI02"],
|
|
30
|
+
cwe,
|
|
31
|
+
confidence: "HIGH",
|
|
32
|
+
fixable: true,
|
|
33
|
+
remediation_actions: ["remove_field", "quarantine_file"],
|
|
34
|
+
metadata: {
|
|
35
|
+
sources: [input.filePath, field],
|
|
36
|
+
risk_tags: ["skill", "frontmatter"],
|
|
37
|
+
origin: "skill-frontmatter",
|
|
38
|
+
},
|
|
39
|
+
suppressed: false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function parseFrontmatter(textContent) {
|
|
43
|
+
const match = textContent.match(FRONTMATTER_PATTERN);
|
|
44
|
+
if (!match?.[1]) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const parsed = parseYaml(match[1]);
|
|
49
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function collectToolGrants(value) {
|
|
59
|
+
if (typeof value === "string") {
|
|
60
|
+
return value
|
|
61
|
+
.split(",")
|
|
62
|
+
.map((entry) => entry.trim())
|
|
63
|
+
.filter((entry) => entry.length > 0);
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
return value
|
|
67
|
+
.filter((entry) => typeof entry === "string")
|
|
68
|
+
.map((entry) => entry.trim())
|
|
69
|
+
.filter((entry) => entry.length > 0);
|
|
70
|
+
}
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
function parseToolGrant(raw) {
|
|
74
|
+
const match = raw.match(/^([^()]+)(?:\(([^)]*)\))?$/u);
|
|
75
|
+
const base = (match?.[1] ?? raw).trim().toLowerCase();
|
|
76
|
+
const qualifier = match?.[2] !== undefined ? match[2].trim() : null;
|
|
77
|
+
return { raw, base, qualifier };
|
|
78
|
+
}
|
|
79
|
+
function isBroadGrant(grant) {
|
|
80
|
+
if (WILDCARD_GRANTS.has(grant.base)) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (!BROAD_TOOL_BASES.has(grant.base)) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return grant.qualifier === null || grant.qualifier === "" || grant.qualifier === "*";
|
|
87
|
+
}
|
|
88
|
+
function frontmatterTextFields(frontmatter) {
|
|
89
|
+
const texts = [];
|
|
90
|
+
for (const value of Object.values(frontmatter)) {
|
|
91
|
+
if (typeof value === "string") {
|
|
92
|
+
texts.push(value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return texts;
|
|
96
|
+
}
|
|
97
|
+
export function detectSkillFrontmatterIssues(input) {
|
|
98
|
+
if (!isSkillFile(input.filePath)) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const frontmatter = parseFrontmatter(input.textContent);
|
|
102
|
+
if (!frontmatter) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const findings = [];
|
|
106
|
+
const grantsRaw = frontmatter["allowed-tools"] ?? frontmatter.allowed_tools;
|
|
107
|
+
const broadGrants = collectToolGrants(grantsRaw).map(parseToolGrant).filter(isBroadGrant);
|
|
108
|
+
if (broadGrants.length > 0) {
|
|
109
|
+
findings.push(makeFinding(input, "skill-allowed-tools-broad", "allowed-tools", "HIGH", "CONSENT_BYPASS", `Skill frontmatter requests unrestricted tool access: ${broadGrants
|
|
110
|
+
.map((grant) => grant.raw)
|
|
111
|
+
.join(", ")}. Unqualified shell or wildcard grants let the skill run arbitrary commands.`, "CWE-250"));
|
|
112
|
+
}
|
|
113
|
+
const hiddenInstructionTexts = frontmatterTextFields(frontmatter).filter((text) => {
|
|
114
|
+
const normalized = normalizeForMatching(text);
|
|
115
|
+
return (findOverridePhrase(normalized) !== null ||
|
|
116
|
+
REMOTE_SHELL_PATTERN.test(normalized) ||
|
|
117
|
+
REMOTE_INSTRUCTION_INDIRECTION_PATTERN.test(normalized));
|
|
118
|
+
});
|
|
119
|
+
if (hiddenInstructionTexts.length > 0) {
|
|
120
|
+
findings.push(makeFinding(input, "skill-frontmatter-hidden-instructions", "metadata", "HIGH", "RULE_INJECTION", "Skill frontmatter metadata contains override, remote-shell, or remote-instruction language " +
|
|
121
|
+
"outside the visible skill body.", "CWE-116"));
|
|
122
|
+
}
|
|
123
|
+
const declaredName = typeof frontmatter.name === "string" ? frontmatter.name.trim() : null;
|
|
124
|
+
const directoryName = skillDirectoryName(input.filePath);
|
|
125
|
+
if (declaredName && directoryName && declaredName.toLowerCase() !== directoryName.toLowerCase()) {
|
|
126
|
+
findings.push(makeFinding(input, "skill-frontmatter-mismatch", "name", "INFO", "CONFIG_PRESENT", `Skill frontmatter name "${declaredName}" does not match its directory "${directoryName}". ` +
|
|
127
|
+
"Name confusion is a common registry-squatting signal.", "CWE-1021"));
|
|
128
|
+
}
|
|
129
|
+
return findings;
|
|
130
|
+
}
|
|
@@ -3,6 +3,7 @@ import { type SymlinkEscapeEntry } from "./detectors/symlink.js";
|
|
|
3
3
|
import type { AuditPersona, RuntimeMode } from "../config.js";
|
|
4
4
|
import { type Finding } from "../types/finding.js";
|
|
5
5
|
import type { DiscoveryFormat } from "../types/discovery.js";
|
|
6
|
+
import type { ResolvedKnownBadIndicators } from "../content/known-bad.js";
|
|
6
7
|
export interface StaticFileInput {
|
|
7
8
|
filePath: string;
|
|
8
9
|
format: DiscoveryFormat;
|
|
@@ -28,6 +29,7 @@ export interface StaticEngineConfig {
|
|
|
28
29
|
disable?: boolean;
|
|
29
30
|
config?: Record<string, unknown>;
|
|
30
31
|
}>;
|
|
32
|
+
knownBadIndicators?: ResolvedKnownBadIndicators;
|
|
31
33
|
}
|
|
32
34
|
export interface StaticEngineInput {
|
|
33
35
|
projectRoot: string;
|
|
Binary file
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { dirname, extname, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { loadActiveContentBundle } from "../content/content-store.js";
|
|
5
6
|
const defaultRulesDir = resolve(dirname(fileURLToPath(import.meta.url)), "rules");
|
|
6
7
|
const require = createRequire(import.meta.url);
|
|
7
8
|
const Ajv = require("ajv");
|
|
@@ -179,9 +180,31 @@ function normalizeOptions(arg) {
|
|
|
179
180
|
skipRules: normalizeRuleIds(options.skip_rules),
|
|
180
181
|
};
|
|
181
182
|
}
|
|
183
|
+
function loadContentFeedRules() {
|
|
184
|
+
try {
|
|
185
|
+
const bundle = loadActiveContentBundle();
|
|
186
|
+
const rules = bundle?.rules ?? [];
|
|
187
|
+
const validated = [];
|
|
188
|
+
for (const candidate of rules) {
|
|
189
|
+
if (!ruleValidator(candidate)) {
|
|
190
|
+
// One invalid feed rule disqualifies the feed's rule set; bundled
|
|
191
|
+
// rules remain the safe baseline.
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
validated.push(candidate);
|
|
195
|
+
}
|
|
196
|
+
return validated;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
182
202
|
export function loadRulePacks(arg) {
|
|
183
203
|
const options = normalizeOptions(arg);
|
|
184
204
|
const bundledRules = collectRulesFromPaths([options.baseDir]);
|
|
205
|
+
const feedRules = loadContentFeedRules();
|
|
185
206
|
const externalRules = collectRulesFromPaths(options.rulePackPaths);
|
|
186
|
-
|
|
207
|
+
// Later entries win in dedupe: user packs override feed rules, which
|
|
208
|
+
// override bundled rules.
|
|
209
|
+
return filterRules(dedupeByRuleId([...bundledRules, ...feedRules, ...externalRules]), options.allowedRules, options.skipRules);
|
|
187
210
|
}
|
|
@@ -5,6 +5,7 @@ export interface ScanStateServerEntry {
|
|
|
5
5
|
first_seen: string;
|
|
6
6
|
last_seen: string;
|
|
7
7
|
}
|
|
8
|
+
/** Scan state for a single project root (one slice of the state file). */
|
|
8
9
|
export interface ScanState {
|
|
9
10
|
servers: Record<string, ScanStateServerEntry>;
|
|
10
11
|
}
|
|
@@ -19,14 +20,18 @@ export interface EvaluateScanStateSnapshotsInput {
|
|
|
19
20
|
snapshots: McpServerSnapshot[];
|
|
20
21
|
previousState: ScanState;
|
|
21
22
|
nowIso?: string;
|
|
23
|
+
/** Trusted targets get INFO first-seen findings; untrusted get MEDIUM. */
|
|
24
|
+
trustedTarget?: boolean;
|
|
25
|
+
/** When false, first-seen findings are skipped (state is still recorded). */
|
|
26
|
+
firstScanReview?: boolean;
|
|
22
27
|
}
|
|
23
28
|
export interface EvaluateScanStateSnapshotsResult {
|
|
24
29
|
findings: Finding[];
|
|
25
30
|
nextState: ScanState;
|
|
26
31
|
}
|
|
27
32
|
export declare function getScanStatePath(customPath?: string): string;
|
|
28
|
-
export declare function loadScanState(customPath?: string): ScanState;
|
|
29
|
-
export declare function saveScanState(state: ScanState, customPath?: string): void;
|
|
33
|
+
export declare function loadScanState(customPath?: string, projectRoot?: string): ScanState;
|
|
34
|
+
export declare function saveScanState(state: ScanState, customPath?: string, projectRoot?: string): void;
|
|
30
35
|
export declare function resetScanState(customPath?: string): void;
|
|
31
36
|
export declare function evaluateScanStateSnapshots(input: EvaluateScanStateSnapshotsInput): EvaluateScanStateSnapshotsResult;
|
|
32
37
|
export declare function extractMcpServerSnapshots(filePath: string, parsed: unknown): McpServerSnapshot[];
|
|
@@ -2,6 +2,9 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, resolve } from "node:path";
|
|
5
|
+
const SCAN_STATE_FILE_VERSION = 2;
|
|
6
|
+
/** Bucket used when no project root is supplied (direct API use, tooling). */
|
|
7
|
+
const SHARED_PROJECT_KEY = "*";
|
|
5
8
|
const LAUNCHERS = new Set(["npx", "uvx", "node", "python", "python3", "deno", "bun"]);
|
|
6
9
|
const MCP_SERVER_CONTAINER_KEYS = ["mcpServers", "mcp_servers", "context_servers"];
|
|
7
10
|
const REMOTE_MCP_SERVER_ARRAY_KEYS = ["remoteMCPServers", "remote_mcp_servers"];
|
|
@@ -106,18 +109,29 @@ function normalizedUrlServerId(rawUrl) {
|
|
|
106
109
|
return `url:${trimmed}`;
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
|
-
|
|
112
|
+
const STATE_FINDING_KIND = {
|
|
113
|
+
NewServer: "NEW_SERVER",
|
|
114
|
+
ConfigChange: "CONFIG_CHANGE",
|
|
115
|
+
};
|
|
116
|
+
/** First-use of a server in an untrusted project deserves a visible review. */
|
|
117
|
+
function firstSeenSeverity(trustedTarget) {
|
|
118
|
+
return trustedTarget ? "INFO" : "MEDIUM";
|
|
119
|
+
}
|
|
120
|
+
function makeStateFinding(kind, snapshot, previousLastSeen, trustedTarget) {
|
|
110
121
|
const locationField = snapshot.serverPath ?? `mcpServers.${snapshot.serverName}`;
|
|
111
|
-
if (kind ===
|
|
122
|
+
if (kind === STATE_FINDING_KIND.NewServer) {
|
|
123
|
+
const reviewNote = trustedTarget
|
|
124
|
+
? "Not previously scanned."
|
|
125
|
+
: "Not previously scanned in this untrusted project; review its configuration before use.";
|
|
112
126
|
return {
|
|
113
127
|
rule_id: "mcp-server-first-seen",
|
|
114
128
|
finding_id: `NEW_SERVER-${snapshot.serverId}`,
|
|
115
|
-
severity:
|
|
129
|
+
severity: firstSeenSeverity(trustedTarget),
|
|
116
130
|
category: "NEW_SERVER",
|
|
117
131
|
layer: "L2",
|
|
118
132
|
file_path: snapshot.configPath,
|
|
119
133
|
location: { field: locationField },
|
|
120
|
-
description: `MCP server "${snapshot.serverId}" first seen in this project.
|
|
134
|
+
description: `MCP server "${snapshot.serverId}" first seen in this project. ${reviewNote}`,
|
|
121
135
|
affected_tools: ["claude-code", "cursor", "windsurf", "codex-cli", "opencode"],
|
|
122
136
|
cve: null,
|
|
123
137
|
owasp: ["ASI08"],
|
|
@@ -150,31 +164,22 @@ function makeStateFinding(kind, snapshot, previousLastSeen) {
|
|
|
150
164
|
export function getScanStatePath(customPath) {
|
|
151
165
|
return resolve(expandHomePath(customPath ?? defaultPath()));
|
|
152
166
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
try {
|
|
160
|
-
const raw = readFileSync(path, "utf8");
|
|
161
|
-
parsed = JSON.parse(raw);
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
return { servers: {} };
|
|
165
|
-
}
|
|
166
|
-
if (!isRecord(parsed) || !isRecord(parsed.servers)) {
|
|
167
|
-
return { servers: {} };
|
|
167
|
+
function projectStateKey(projectRoot) {
|
|
168
|
+
return projectRoot === undefined ? SHARED_PROJECT_KEY : resolve(projectRoot);
|
|
169
|
+
}
|
|
170
|
+
function parseServerEntries(value) {
|
|
171
|
+
if (!isRecord(value)) {
|
|
172
|
+
return {};
|
|
168
173
|
}
|
|
169
174
|
const entries = {};
|
|
170
|
-
for (const [key,
|
|
171
|
-
if (!isRecord(
|
|
175
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
176
|
+
if (!isRecord(entry)) {
|
|
172
177
|
continue;
|
|
173
178
|
}
|
|
174
|
-
const config_hash = typeof
|
|
175
|
-
const config_path = typeof
|
|
176
|
-
const first_seen = typeof
|
|
177
|
-
const last_seen = typeof
|
|
179
|
+
const config_hash = typeof entry.config_hash === "string" ? entry.config_hash : "";
|
|
180
|
+
const config_path = typeof entry.config_path === "string" ? entry.config_path : "";
|
|
181
|
+
const first_seen = typeof entry.first_seen === "string" ? entry.first_seen : "";
|
|
182
|
+
const last_seen = typeof entry.last_seen === "string" ? entry.last_seen : "";
|
|
178
183
|
if (!config_hash || !config_path || !first_seen || !last_seen) {
|
|
179
184
|
continue;
|
|
180
185
|
}
|
|
@@ -185,12 +190,47 @@ export function loadScanState(customPath) {
|
|
|
185
190
|
last_seen,
|
|
186
191
|
};
|
|
187
192
|
}
|
|
188
|
-
return
|
|
193
|
+
return entries;
|
|
189
194
|
}
|
|
190
|
-
|
|
195
|
+
function loadScanStateFile(customPath) {
|
|
196
|
+
const empty = { version: SCAN_STATE_FILE_VERSION, projects: {} };
|
|
191
197
|
const path = getScanStatePath(customPath);
|
|
198
|
+
if (!existsSync(path)) {
|
|
199
|
+
return empty;
|
|
200
|
+
}
|
|
201
|
+
let parsed;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return empty;
|
|
207
|
+
}
|
|
208
|
+
// Legacy (unversioned, top-level `servers`) and malformed files both reset:
|
|
209
|
+
// legacy entries carry no project provenance, so trusting them would leak
|
|
210
|
+
// baselines across projects.
|
|
211
|
+
if (!isRecord(parsed) ||
|
|
212
|
+
parsed.version !== SCAN_STATE_FILE_VERSION ||
|
|
213
|
+
!isRecord(parsed.projects)) {
|
|
214
|
+
return empty;
|
|
215
|
+
}
|
|
216
|
+
const projects = {};
|
|
217
|
+
for (const [projectKey, slice] of Object.entries(parsed.projects)) {
|
|
218
|
+
if (isRecord(slice)) {
|
|
219
|
+
projects[projectKey] = { servers: parseServerEntries(slice.servers) };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { version: SCAN_STATE_FILE_VERSION, projects };
|
|
223
|
+
}
|
|
224
|
+
export function loadScanState(customPath, projectRoot) {
|
|
225
|
+
const file = loadScanStateFile(customPath);
|
|
226
|
+
return file.projects[projectStateKey(projectRoot)] ?? { servers: {} };
|
|
227
|
+
}
|
|
228
|
+
export function saveScanState(state, customPath, projectRoot) {
|
|
229
|
+
const path = getScanStatePath(customPath);
|
|
230
|
+
const file = loadScanStateFile(customPath);
|
|
231
|
+
file.projects[projectStateKey(projectRoot)] = state;
|
|
192
232
|
mkdirSync(dirname(path), { recursive: true });
|
|
193
|
-
writeFileSync(path, `${JSON.stringify(
|
|
233
|
+
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, "utf8");
|
|
194
234
|
}
|
|
195
235
|
export function resetScanState(customPath) {
|
|
196
236
|
const path = getScanStatePath(customPath);
|
|
@@ -198,6 +238,8 @@ export function resetScanState(customPath) {
|
|
|
198
238
|
}
|
|
199
239
|
export function evaluateScanStateSnapshots(input) {
|
|
200
240
|
const nowIso = input.nowIso ?? new Date().toISOString();
|
|
241
|
+
const trustedTarget = input.trustedTarget ?? false;
|
|
242
|
+
const firstScanReview = input.firstScanReview ?? true;
|
|
201
243
|
const nextState = {
|
|
202
244
|
servers: { ...input.previousState.servers },
|
|
203
245
|
};
|
|
@@ -205,7 +247,9 @@ export function evaluateScanStateSnapshots(input) {
|
|
|
205
247
|
for (const snapshot of input.snapshots) {
|
|
206
248
|
const previous = nextState.servers[snapshot.serverId];
|
|
207
249
|
if (!previous) {
|
|
208
|
-
|
|
250
|
+
if (firstScanReview) {
|
|
251
|
+
findings.push(makeStateFinding(STATE_FINDING_KIND.NewServer, snapshot, null, trustedTarget));
|
|
252
|
+
}
|
|
209
253
|
nextState.servers[snapshot.serverId] = {
|
|
210
254
|
config_hash: snapshot.configHash,
|
|
211
255
|
config_path: snapshot.configPath,
|
|
@@ -215,7 +259,7 @@ export function evaluateScanStateSnapshots(input) {
|
|
|
215
259
|
continue;
|
|
216
260
|
}
|
|
217
261
|
if (previous.config_hash !== snapshot.configHash) {
|
|
218
|
-
findings.push(makeStateFinding(
|
|
262
|
+
findings.push(makeStateFinding(STATE_FINDING_KIND.ConfigChange, snapshot, previous.last_seen, trustedTarget));
|
|
219
263
|
nextState.servers[snapshot.serverId] = {
|
|
220
264
|
config_hash: snapshot.configHash,
|
|
221
265
|
config_path: snapshot.configPath,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal confusables map: non-Latin code points that render like Latin
|
|
3
|
+
* letters and are used to slip phrases past literal matching. NFKC already
|
|
4
|
+
* folds fullwidth/compatibility forms; this covers the Cyrillic and Greek
|
|
5
|
+
* lookalikes NFKC leaves alone. Data-driven so a content feed can extend it.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CONFUSABLE_TO_LATIN: Readonly<Record<string, string>>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal confusables map: non-Latin code points that render like Latin
|
|
3
|
+
* letters and are used to slip phrases past literal matching. NFKC already
|
|
4
|
+
* folds fullwidth/compatibility forms; this covers the Cyrillic and Greek
|
|
5
|
+
* lookalikes NFKC leaves alone. Data-driven so a content feed can extend it.
|
|
6
|
+
*/
|
|
7
|
+
export const CONFUSABLE_TO_LATIN = {
|
|
8
|
+
// Cyrillic lowercase
|
|
9
|
+
а: "a",
|
|
10
|
+
в: "b",
|
|
11
|
+
е: "e",
|
|
12
|
+
ё: "e",
|
|
13
|
+
к: "k",
|
|
14
|
+
м: "m",
|
|
15
|
+
н: "h",
|
|
16
|
+
о: "o",
|
|
17
|
+
р: "p",
|
|
18
|
+
с: "c",
|
|
19
|
+
т: "t",
|
|
20
|
+
у: "y",
|
|
21
|
+
х: "x",
|
|
22
|
+
і: "i",
|
|
23
|
+
ѕ: "s",
|
|
24
|
+
ј: "j",
|
|
25
|
+
ԁ: "d",
|
|
26
|
+
ԛ: "q",
|
|
27
|
+
ѡ: "w",
|
|
28
|
+
// Cyrillic uppercase
|
|
29
|
+
А: "A",
|
|
30
|
+
В: "B",
|
|
31
|
+
Е: "E",
|
|
32
|
+
К: "K",
|
|
33
|
+
М: "M",
|
|
34
|
+
Н: "H",
|
|
35
|
+
О: "O",
|
|
36
|
+
Р: "P",
|
|
37
|
+
С: "C",
|
|
38
|
+
Т: "T",
|
|
39
|
+
У: "Y",
|
|
40
|
+
Х: "X",
|
|
41
|
+
І: "I",
|
|
42
|
+
Ѕ: "S",
|
|
43
|
+
Ј: "J",
|
|
44
|
+
// Greek lowercase
|
|
45
|
+
α: "a",
|
|
46
|
+
ε: "e",
|
|
47
|
+
η: "n",
|
|
48
|
+
ι: "i",
|
|
49
|
+
κ: "k",
|
|
50
|
+
ν: "v",
|
|
51
|
+
ο: "o",
|
|
52
|
+
ρ: "p",
|
|
53
|
+
τ: "t",
|
|
54
|
+
υ: "u",
|
|
55
|
+
// Greek uppercase
|
|
56
|
+
Α: "A",
|
|
57
|
+
Β: "B",
|
|
58
|
+
Ε: "E",
|
|
59
|
+
Ζ: "Z",
|
|
60
|
+
Η: "H",
|
|
61
|
+
Ι: "I",
|
|
62
|
+
Κ: "K",
|
|
63
|
+
Μ: "M",
|
|
64
|
+
Ν: "N",
|
|
65
|
+
Ο: "O",
|
|
66
|
+
Ρ: "P",
|
|
67
|
+
Τ: "T",
|
|
68
|
+
Υ: "Y",
|
|
69
|
+
Χ: "X",
|
|
70
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Damerau-Levenshtein distance (optimal string alignment variant):
|
|
3
|
+
* insertions, deletions, substitutions, and adjacent transpositions each
|
|
4
|
+
* cost 1. Small inputs only (package names), so the O(n*m) table is fine.
|
|
5
|
+
*/
|
|
6
|
+
export declare function damerauLevenshtein(left: string, right: string): number;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Damerau-Levenshtein distance (optimal string alignment variant):
|
|
3
|
+
* insertions, deletions, substitutions, and adjacent transpositions each
|
|
4
|
+
* cost 1. Small inputs only (package names), so the O(n*m) table is fine.
|
|
5
|
+
*/
|
|
6
|
+
export function damerauLevenshtein(left, right) {
|
|
7
|
+
if (left === right) {
|
|
8
|
+
return 0;
|
|
9
|
+
}
|
|
10
|
+
const rows = left.length + 1;
|
|
11
|
+
const cols = right.length + 1;
|
|
12
|
+
const table = Array.from({ length: rows }, () => new Array(cols).fill(0));
|
|
13
|
+
for (let row = 0; row < rows; row += 1) {
|
|
14
|
+
table[row][0] = row;
|
|
15
|
+
}
|
|
16
|
+
for (let col = 0; col < cols; col += 1) {
|
|
17
|
+
table[0][col] = col;
|
|
18
|
+
}
|
|
19
|
+
for (let row = 1; row < rows; row += 1) {
|
|
20
|
+
for (let col = 1; col < cols; col += 1) {
|
|
21
|
+
const cost = left[row - 1] === right[col - 1] ? 0 : 1;
|
|
22
|
+
let value = Math.min(table[row - 1][col] + 1, table[row][col - 1] + 1, table[row - 1][col - 1] + cost);
|
|
23
|
+
if (row > 1 &&
|
|
24
|
+
col > 1 &&
|
|
25
|
+
left[row - 1] === right[col - 2] &&
|
|
26
|
+
left[row - 2] === right[col - 1]) {
|
|
27
|
+
value = Math.min(value, table[row - 2][col - 2] + 1);
|
|
28
|
+
}
|
|
29
|
+
table[row][col] = value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return table[rows - 1][cols - 1];
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const ENCODED_PAYLOAD_KIND: {
|
|
2
|
+
readonly Base64: "base64";
|
|
3
|
+
readonly Hex: "hex";
|
|
4
|
+
};
|
|
5
|
+
export type EncodedPayloadKind = (typeof ENCODED_PAYLOAD_KIND)[keyof typeof ENCODED_PAYLOAD_KIND];
|
|
6
|
+
export interface EncodedPayloadMatch {
|
|
7
|
+
kind: EncodedPayloadKind;
|
|
8
|
+
line: number;
|
|
9
|
+
decodedExcerpt: string;
|
|
10
|
+
matchesRemoteShell: boolean;
|
|
11
|
+
matchesOverridePhrase: boolean;
|
|
12
|
+
matchesSensitiveExfil: boolean;
|
|
13
|
+
matchesCommandExecution: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** Scan text for encoded blobs whose decoded content matches threat patterns. */
|
|
16
|
+
export declare function scanEncodedPayloads(text: string): EncodedPayloadMatch[];
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { normalizeForMatching } from "./normalize.js";
|
|
2
|
+
import { COMMAND_EXECUTION_PATTERN, EXFIL_PATTERN, REMOTE_SHELL_PATTERN, SENSITIVE_FILE_PATTERN, findOverridePhrase, } from "./threat-patterns.js";
|
|
3
|
+
/**
|
|
4
|
+
* Bounded decode-and-rescan for base64/hex blobs embedded in instruction
|
|
5
|
+
* files and tool descriptions. Hard limits keep this pass cheap and
|
|
6
|
+
* non-explosive: one nesting level, capped blob count and decoded size.
|
|
7
|
+
*/
|
|
8
|
+
const BASE64_RUN_PATTERN = /[A-Za-z0-9+/_-]{40,}={0,2}/gu;
|
|
9
|
+
const HEX_RUN_PATTERN = /(?:[0-9a-fA-F]{2}){30,}/gu;
|
|
10
|
+
const MAX_BLOBS_PER_TEXT = 20;
|
|
11
|
+
const MAX_DECODED_BYTES = 64 * 1024;
|
|
12
|
+
const MIN_PRINTABLE_RATIO = 0.3;
|
|
13
|
+
const DECODED_EXCERPT_LENGTH = 160;
|
|
14
|
+
export const ENCODED_PAYLOAD_KIND = {
|
|
15
|
+
Base64: "base64",
|
|
16
|
+
Hex: "hex",
|
|
17
|
+
};
|
|
18
|
+
function printableRatio(text) {
|
|
19
|
+
if (text.length === 0) {
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
let printable = 0;
|
|
23
|
+
for (const char of text) {
|
|
24
|
+
const code = char.codePointAt(0) ?? 0;
|
|
25
|
+
if (code === 0x09 || code === 0x0a || code === 0x0d || (code >= 0x20 && code < 0x7f)) {
|
|
26
|
+
printable += 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return printable / text.length;
|
|
30
|
+
}
|
|
31
|
+
function decodeBase64(blob) {
|
|
32
|
+
// Normalize base64url variants before decoding.
|
|
33
|
+
const normalized = blob.replace(/-/gu, "+").replace(/_/gu, "/");
|
|
34
|
+
try {
|
|
35
|
+
const decoded = Buffer.from(normalized, "base64");
|
|
36
|
+
if (decoded.length === 0 || decoded.length > MAX_DECODED_BYTES) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return decoded.toString("utf8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function decodeHex(blob) {
|
|
46
|
+
try {
|
|
47
|
+
const decoded = Buffer.from(blob, "hex");
|
|
48
|
+
if (decoded.length === 0 || decoded.length > MAX_DECODED_BYTES) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return decoded.toString("utf8");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function lineNumberAt(text, index) {
|
|
58
|
+
return text.slice(0, index).split(/\r?\n/u).length;
|
|
59
|
+
}
|
|
60
|
+
function isDataImageUri(text, blobIndex) {
|
|
61
|
+
const prefixStart = Math.max(0, blobIndex - 40);
|
|
62
|
+
return /data:image\/[a-z+.-]+;base64,?$/iu.test(text.slice(prefixStart, blobIndex));
|
|
63
|
+
}
|
|
64
|
+
function analyzeDecoded(kind, line, decoded) {
|
|
65
|
+
if (printableRatio(decoded) < MIN_PRINTABLE_RATIO) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const normalized = normalizeForMatching(decoded);
|
|
69
|
+
const matchesRemoteShell = REMOTE_SHELL_PATTERN.test(normalized);
|
|
70
|
+
const matchesOverridePhrase = findOverridePhrase(normalized) !== null;
|
|
71
|
+
const matchesSensitiveExfil = SENSITIVE_FILE_PATTERN.test(normalized) && EXFIL_PATTERN.test(normalized);
|
|
72
|
+
const matchesCommandExecution = COMMAND_EXECUTION_PATTERN.test(normalized);
|
|
73
|
+
if (!matchesRemoteShell &&
|
|
74
|
+
!matchesOverridePhrase &&
|
|
75
|
+
!matchesSensitiveExfil &&
|
|
76
|
+
!matchesCommandExecution) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
kind,
|
|
81
|
+
line,
|
|
82
|
+
decodedExcerpt: decoded.slice(0, DECODED_EXCERPT_LENGTH),
|
|
83
|
+
matchesRemoteShell,
|
|
84
|
+
matchesOverridePhrase,
|
|
85
|
+
matchesSensitiveExfil,
|
|
86
|
+
matchesCommandExecution,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Scan text for encoded blobs whose decoded content matches threat patterns. */
|
|
90
|
+
export function scanEncodedPayloads(text) {
|
|
91
|
+
const matches = [];
|
|
92
|
+
let blobsSeen = 0;
|
|
93
|
+
const scanRuns = (pattern, kind, decode) => {
|
|
94
|
+
pattern.lastIndex = 0;
|
|
95
|
+
let match = pattern.exec(text);
|
|
96
|
+
while (match && blobsSeen < MAX_BLOBS_PER_TEXT) {
|
|
97
|
+
blobsSeen += 1;
|
|
98
|
+
const blob = match[0];
|
|
99
|
+
if (kind === ENCODED_PAYLOAD_KIND.Base64 && isDataImageUri(text, match.index)) {
|
|
100
|
+
match = pattern.exec(text);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const decoded = decode(blob);
|
|
104
|
+
if (decoded) {
|
|
105
|
+
const analyzed = analyzeDecoded(kind, lineNumberAt(text, match.index), decoded);
|
|
106
|
+
if (analyzed) {
|
|
107
|
+
matches.push(analyzed);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
match = pattern.exec(text);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
scanRuns(BASE64_RUN_PATTERN, ENCODED_PAYLOAD_KIND.Base64, decodeBase64);
|
|
114
|
+
scanRuns(HEX_RUN_PATTERN, ENCODED_PAYLOAD_KIND.Hex, decodeHex);
|
|
115
|
+
return matches;
|
|
116
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize text before threat-pattern matching so trivial obfuscation
|
|
3
|
+
* (zero-width splits, tag characters, homoglyphs, compatibility forms)
|
|
4
|
+
* cannot dodge literal patterns:
|
|
5
|
+
*
|
|
6
|
+
* NFKC -> strip hidden characters -> fold confusables -> lowercase
|
|
7
|
+
*
|
|
8
|
+
* Normalize per line when line numbers matter: evidence must quote the
|
|
9
|
+
* original line while matching runs against the normalized one.
|
|
10
|
+
*/
|
|
11
|
+
export declare function normalizeForMatching(text: string): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CONFUSABLE_TO_LATIN } from "./confusables.js";
|
|
2
|
+
import { stripHiddenCharacters } from "./unicode.js";
|
|
3
|
+
const confusablePattern = new RegExp(`[${Object.keys(CONFUSABLE_TO_LATIN).join("")}]`, "gu");
|
|
4
|
+
function foldConfusables(text) {
|
|
5
|
+
return text.replace(confusablePattern, (char) => CONFUSABLE_TO_LATIN[char] ?? char);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Normalize text before threat-pattern matching so trivial obfuscation
|
|
9
|
+
* (zero-width splits, tag characters, homoglyphs, compatibility forms)
|
|
10
|
+
* cannot dodge literal patterns:
|
|
11
|
+
*
|
|
12
|
+
* NFKC -> strip hidden characters -> fold confusables -> lowercase
|
|
13
|
+
*
|
|
14
|
+
* Normalize per line when line numbers matter: evidence must quote the
|
|
15
|
+
* original line while matching runs against the normalized one.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeForMatching(text) {
|
|
18
|
+
return foldConfusables(stripHiddenCharacters(text.normalize("NFKC"))).toLowerCase();
|
|
19
|
+
}
|