codegate-ai 0.16.2 → 1.0.1
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/README.md +13 -12
- 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
package/dist/pipeline.js
CHANGED
|
@@ -2,6 +2,7 @@ import { runStaticEngine, } from "./layer2-static/engine.js";
|
|
|
2
2
|
import { createEmptyReport } from "./types/report.js";
|
|
3
3
|
import { scanToolDescriptions, } from "./layer3-dynamic/tool-description-scanner.js";
|
|
4
4
|
import { detectToxicFlows } from "./layer3-dynamic/toxic-flow.js";
|
|
5
|
+
import { deriveRegistryFindings, } from "./layer3-dynamic/registry-findings.js";
|
|
5
6
|
import { applyReportSummary } from "./report-summary.js";
|
|
6
7
|
import { withFindingFingerprint } from "./report/finding-fingerprint.js";
|
|
7
8
|
export async function runStaticPipeline(input) {
|
|
@@ -158,28 +159,32 @@ function parseToolClassifications(metadata, tools) {
|
|
|
158
159
|
}
|
|
159
160
|
return map;
|
|
160
161
|
}
|
|
161
|
-
function
|
|
162
|
+
function analyzeLayer3Tools(resourceId, metadata, options = {}) {
|
|
162
163
|
const toolEntries = parseToolEntries(metadata);
|
|
163
164
|
if (toolEntries.length === 0) {
|
|
164
|
-
return [];
|
|
165
|
+
return { findings: [], toolDescriptions: [], knownClassifications: {} };
|
|
165
166
|
}
|
|
166
167
|
const toolDescriptions = toolEntries.map((entry) => ({
|
|
167
168
|
name: entry.name,
|
|
168
169
|
description: entry.description,
|
|
169
170
|
}));
|
|
170
171
|
const knownClassifications = parseToolClassifications(metadata, toolEntries);
|
|
171
|
-
return
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
172
|
+
return {
|
|
173
|
+
findings: [
|
|
174
|
+
...scanToolDescriptions({
|
|
175
|
+
serverId: resourceId,
|
|
176
|
+
tools: toolDescriptions,
|
|
177
|
+
unicodeAnalysis: options.unicodeAnalysis,
|
|
178
|
+
}).map(withFindingFingerprint),
|
|
179
|
+
...detectToxicFlows({
|
|
180
|
+
scopeId: resourceId,
|
|
181
|
+
tools: toolDescriptions,
|
|
182
|
+
knownClassifications,
|
|
183
|
+
}).map(withFindingFingerprint),
|
|
184
|
+
],
|
|
185
|
+
toolDescriptions,
|
|
186
|
+
knownClassifications,
|
|
187
|
+
};
|
|
183
188
|
}
|
|
184
189
|
function layer3ErrorFinding(resourceId, status, description) {
|
|
185
190
|
const severity = status === "timeout" ? "MEDIUM" : status === "skipped_without_consent" ? "INFO" : "LOW";
|
|
@@ -204,6 +209,25 @@ function layer3ErrorFinding(resourceId, status, description) {
|
|
|
204
209
|
}
|
|
205
210
|
export function layer3OutcomesToFindings(outcomes, options = {}) {
|
|
206
211
|
const findings = [];
|
|
212
|
+
const workspaceTools = [];
|
|
213
|
+
const workspaceClassifications = {};
|
|
214
|
+
const workspaceOrigins = {};
|
|
215
|
+
const contributingServers = new Set();
|
|
216
|
+
const addWorkspaceTools = (resourceId, analysis) => {
|
|
217
|
+
for (const tool of analysis.toolDescriptions) {
|
|
218
|
+
// Keep workspace tool names unique across servers so origins stay unambiguous.
|
|
219
|
+
const uniqueName = workspaceOrigins[tool.name] === undefined ? tool.name : `${tool.name}@${resourceId}`;
|
|
220
|
+
workspaceTools.push({ name: uniqueName, description: tool.description });
|
|
221
|
+
workspaceOrigins[uniqueName] = resourceId;
|
|
222
|
+
const classifications = analysis.knownClassifications[tool.name];
|
|
223
|
+
if (classifications && classifications.length > 0) {
|
|
224
|
+
workspaceClassifications[uniqueName] = classifications;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (analysis.toolDescriptions.length > 0) {
|
|
228
|
+
contributingServers.add(resourceId);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
207
231
|
for (const outcome of outcomes) {
|
|
208
232
|
if (!outcome.approved || outcome.status === "skipped_without_consent") {
|
|
209
233
|
findings.push(layer3ErrorFinding(outcome.resourceId, "skipped_without_consent", "Deep scan skipped because consent was not granted"));
|
|
@@ -214,8 +238,10 @@ export function layer3OutcomesToFindings(outcomes, options = {}) {
|
|
|
214
238
|
continue;
|
|
215
239
|
}
|
|
216
240
|
const parsed = parseLayer3Response(outcome.resourceId, outcome.result.metadata);
|
|
217
|
-
const
|
|
218
|
-
|
|
241
|
+
const analysis = analyzeLayer3Tools(outcome.resourceId, outcome.result.metadata, options);
|
|
242
|
+
addWorkspaceTools(outcome.resourceId, analysis);
|
|
243
|
+
const registryFindings = deriveRegistryFindings(outcome.resourceId, outcome.result.metadata, options.registryHeuristics).map(withFindingFingerprint);
|
|
244
|
+
const combined = [...parsed, ...analysis.findings, ...registryFindings];
|
|
219
245
|
// If a Layer 3 resource was fetched successfully but carries no
|
|
220
246
|
// actionable metadata (no `findings[]`, no `tools[]`), that is not an
|
|
221
247
|
// issue with the scan target itself — it usually means the default
|
|
@@ -231,6 +257,17 @@ export function layer3OutcomesToFindings(outcomes, options = {}) {
|
|
|
231
257
|
}
|
|
232
258
|
findings.push(...combined);
|
|
233
259
|
}
|
|
260
|
+
// Cross-server pass: a toxic chain whose links live on different servers
|
|
261
|
+
// is invisible to the per-server analysis above.
|
|
262
|
+
if (contributingServers.size >= 2) {
|
|
263
|
+
findings.push(...detectToxicFlows({
|
|
264
|
+
scopeId: "workspace",
|
|
265
|
+
tools: workspaceTools,
|
|
266
|
+
knownClassifications: workspaceClassifications,
|
|
267
|
+
origins: workspaceOrigins,
|
|
268
|
+
crossOriginOnly: true,
|
|
269
|
+
}).map(withFindingFingerprint));
|
|
270
|
+
}
|
|
234
271
|
return findings;
|
|
235
272
|
}
|
|
236
273
|
export function mergeLayer3Findings(baseReport, layer3Findings) {
|
package/dist/report-summary.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Finding } from "./types/finding.js";
|
|
2
2
|
import type { CodeGateReport, ReportSummary } from "./types/report.js";
|
|
3
3
|
export type ReportThreshold = "critical" | "high" | "medium" | "low" | "info";
|
|
4
4
|
export declare function computeExitCode(findings: Finding[], threshold?: ReportThreshold): number;
|
package/dist/report-summary.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isUntrustedInlineSuppression } from "./types/finding.js";
|
|
1
2
|
const SEVERITY_LEVEL = {
|
|
2
3
|
CRITICAL: 4,
|
|
3
4
|
HIGH: 3,
|
|
@@ -12,8 +13,13 @@ const THRESHOLD_LEVEL = {
|
|
|
12
13
|
low: 1,
|
|
13
14
|
info: 0,
|
|
14
15
|
};
|
|
16
|
+
function countsTowardExitCode(finding) {
|
|
17
|
+
// Inline suppressions from untrusted content stay visible as suppressed
|
|
18
|
+
// but must not weaken gating.
|
|
19
|
+
return !finding.suppressed || isUntrustedInlineSuppression(finding);
|
|
20
|
+
}
|
|
15
21
|
export function computeExitCode(findings, threshold = "high") {
|
|
16
|
-
const unsuppressed = findings.filter(
|
|
22
|
+
const unsuppressed = findings.filter(countsTowardExitCode);
|
|
17
23
|
if (unsuppressed.length === 0) {
|
|
18
24
|
return 0;
|
|
19
25
|
}
|
|
@@ -32,11 +38,13 @@ export function summarizeFindings(findings, threshold = "high") {
|
|
|
32
38
|
for (const finding of findings) {
|
|
33
39
|
bySeverity[finding.severity] = (bySeverity[finding.severity] ?? 0) + 1;
|
|
34
40
|
}
|
|
41
|
+
const suppressedUntrusted = findings.filter(isUntrustedInlineSuppression).length;
|
|
35
42
|
return {
|
|
36
43
|
total: findings.length,
|
|
37
44
|
by_severity: bySeverity,
|
|
38
45
|
fixable: findings.filter((finding) => finding.fixable).length,
|
|
39
46
|
suppressed: findings.filter((finding) => finding.suppressed).length,
|
|
47
|
+
...(suppressedUntrusted > 0 ? { suppressed_untrusted: suppressedUntrusted } : {}),
|
|
40
48
|
exit_code: computeExitCode(findings, threshold),
|
|
41
49
|
};
|
|
42
50
|
}
|
package/dist/scan.d.ts
CHANGED
|
@@ -6,6 +6,18 @@ import type { DiscoveryFormat } from "./types/discovery.js";
|
|
|
6
6
|
import type { CodeGateReport } from "./types/report.js";
|
|
7
7
|
import type { CodeGateConfig, ScanCollectionKind, ScanCollectionMode } from "./config.js";
|
|
8
8
|
import type { DeepScanResource } from "./pipeline.js";
|
|
9
|
+
export declare const SKILL_BINARY_KIND: {
|
|
10
|
+
readonly Elf: "elf";
|
|
11
|
+
readonly MachO: "mach-o";
|
|
12
|
+
readonly Pe: "pe";
|
|
13
|
+
readonly Unknown: "binary";
|
|
14
|
+
};
|
|
15
|
+
export type SkillBinaryKind = (typeof SKILL_BINARY_KIND)[keyof typeof SKILL_BINARY_KIND];
|
|
16
|
+
export interface SkillBinaryArtifact {
|
|
17
|
+
reportPath: string;
|
|
18
|
+
kind: SkillBinaryKind;
|
|
19
|
+
executable: boolean;
|
|
20
|
+
}
|
|
9
21
|
export interface ScanEngineInput {
|
|
10
22
|
version: string;
|
|
11
23
|
scanTarget: string;
|
|
@@ -43,6 +55,7 @@ export interface ScanDiscoveryContext {
|
|
|
43
55
|
walked: WalkResult;
|
|
44
56
|
selected: ScanDiscoveryCandidate[];
|
|
45
57
|
parsedCandidates?: ParsedScanDiscoveryCandidate[];
|
|
58
|
+
skillBinaries?: SkillBinaryArtifact[];
|
|
46
59
|
}
|
|
47
60
|
export interface ScanDiscoveryContextOptions {
|
|
48
61
|
includeUserScope?: boolean;
|
package/dist/scan.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, readdirSync, readFileSync, statSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { basename, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { collectLocalTextAnalysisTargets, } from "./layer3-dynamic/local-text-analysis.js";
|
|
5
5
|
import { buildResourceId, normalizeRemoteUrl } from "./layer3-dynamic/url-validation.js";
|
|
6
6
|
import { runStaticPipeline } from "./pipeline.js";
|
|
@@ -11,11 +11,46 @@ import { detectTools } from "./layer1-discovery/tool-detector.js";
|
|
|
11
11
|
import { walkProjectTree } from "./layer1-discovery/file-walker.js";
|
|
12
12
|
import { evaluateScanStateSnapshots, extractMcpServerSnapshots, loadScanState, saveScanState, } from "./layer2-static/state/scan-state.js";
|
|
13
13
|
import { applyInlineIgnoreDirectives, collectInlineIgnoreDirectives, } from "./config/inline-ignore.js";
|
|
14
|
+
import { isTrustedDirectory } from "./config/trust.js";
|
|
15
|
+
import { loadKnownBadIndicators } from "./content/known-bad.js";
|
|
16
|
+
import { escalateKnownBadFindings } from "./layer2-static/detectors/known-bad.js";
|
|
17
|
+
import { normalizeForMatching } from "./layer2-static/text/normalize.js";
|
|
18
|
+
import { REMOTE_INSTRUCTION_INDIRECTION_PATTERN } from "./layer2-static/text/threat-patterns.js";
|
|
19
|
+
import { withFindingFingerprint } from "./report/finding-fingerprint.js";
|
|
14
20
|
import { isGitHubDependabotPath } from "./layer2-static/dependabot/parser.js";
|
|
15
21
|
const MCP_SERVER_CONTAINER_KEYS = ["mcpServers", "mcp_servers", "context_servers"];
|
|
16
22
|
const REMOTE_MCP_SERVER_ARRAY_KEYS = ["remoteMCPServers", "remote_mcp_servers"];
|
|
17
23
|
const USER_SCOPE_WILDCARD_MAX_DEPTH = 6;
|
|
18
24
|
const USER_SCOPE_WILDCARD_MAX_FILES = 500;
|
|
25
|
+
const SKILL_FILE_NAME = "skill.md";
|
|
26
|
+
const SKILL_SIBLING_MAX_DEPTH = 3;
|
|
27
|
+
const SKILL_SIBLING_MAX_FILES = 200;
|
|
28
|
+
const BINARY_SNIFF_BYTES = 512;
|
|
29
|
+
const SKILL_SIBLING_FORMATS = {
|
|
30
|
+
md: "markdown",
|
|
31
|
+
markdown: "markdown",
|
|
32
|
+
json: "json",
|
|
33
|
+
yaml: "yaml",
|
|
34
|
+
yml: "yaml",
|
|
35
|
+
toml: "toml",
|
|
36
|
+
sh: "text",
|
|
37
|
+
bash: "text",
|
|
38
|
+
zsh: "text",
|
|
39
|
+
ps1: "text",
|
|
40
|
+
py: "text",
|
|
41
|
+
js: "text",
|
|
42
|
+
mjs: "text",
|
|
43
|
+
cjs: "text",
|
|
44
|
+
ts: "text",
|
|
45
|
+
rb: "text",
|
|
46
|
+
txt: "text",
|
|
47
|
+
};
|
|
48
|
+
export const SKILL_BINARY_KIND = {
|
|
49
|
+
Elf: "elf",
|
|
50
|
+
MachO: "mach-o",
|
|
51
|
+
Pe: "pe",
|
|
52
|
+
Unknown: "binary",
|
|
53
|
+
};
|
|
19
54
|
const INFERRED_ARTIFACT_RULES = [
|
|
20
55
|
{
|
|
21
56
|
pattern: /(?:^|\/)\.github\/workflows\/[^/]+\.ya?ml$/iu,
|
|
@@ -45,12 +80,19 @@ const INFERRED_ARTIFACT_RULES = [
|
|
|
45
80
|
function escapeRegex(value) {
|
|
46
81
|
return value.replace(/[|\\{}()[\]^$+?.*]/g, "\\$&");
|
|
47
82
|
}
|
|
83
|
+
const wildcardRegexCache = new Map();
|
|
48
84
|
function wildcardToRegex(pattern) {
|
|
85
|
+
const cached = wildcardRegexCache.get(pattern);
|
|
86
|
+
if (cached) {
|
|
87
|
+
return cached;
|
|
88
|
+
}
|
|
49
89
|
let escaped = escapeRegex(pattern);
|
|
50
90
|
escaped = escaped.replace(/\\\*\\\*\//g, "(?:[^/]+/)*");
|
|
51
91
|
escaped = escaped.replace(/\\\*\\\*/g, ".*");
|
|
52
92
|
escaped = escaped.replace(/\\\*/g, "[^/]*");
|
|
53
|
-
|
|
93
|
+
const regex = new RegExp(`^${escaped}$`, "u");
|
|
94
|
+
wildcardRegexCache.set(pattern, regex);
|
|
95
|
+
return regex;
|
|
54
96
|
}
|
|
55
97
|
function normalizePathForMatch(path) {
|
|
56
98
|
return path.split(sep).join("/");
|
|
@@ -279,15 +321,15 @@ function collectSelectedCandidates(absoluteTarget, walkedFiles, patterns, option
|
|
|
279
321
|
relativePath: normalizePathForMatch(relative(absoluteTarget, filePath)),
|
|
280
322
|
}))
|
|
281
323
|
.filter((entry) => !entry.relativePath.startsWith(".."));
|
|
324
|
+
const projectPatterns = patterns
|
|
325
|
+
.filter((candidate) => candidate.scope === "project")
|
|
326
|
+
.map((candidate) => ({ candidate, regex: wildcardToRegex(candidate.pattern) }));
|
|
282
327
|
for (const file of filesByRelativePath) {
|
|
283
328
|
if (!includeProject) {
|
|
284
329
|
continue;
|
|
285
330
|
}
|
|
286
|
-
for (const candidate of
|
|
287
|
-
if (
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
if (!wildcardToRegex(candidate.pattern).test(file.relativePath)) {
|
|
331
|
+
for (const { candidate, regex } of projectPatterns) {
|
|
332
|
+
if (!regex.test(file.relativePath)) {
|
|
291
333
|
continue;
|
|
292
334
|
}
|
|
293
335
|
if (!matchesCollectionKinds(file.relativePath, options.collectKinds)) {
|
|
@@ -418,6 +460,140 @@ function mergeExplicitCandidates(selected, explicitCandidates, collectKinds) {
|
|
|
418
460
|
}
|
|
419
461
|
return Array.from(merged.values());
|
|
420
462
|
}
|
|
463
|
+
function sniffFileHead(path) {
|
|
464
|
+
const buffer = Buffer.alloc(BINARY_SNIFF_BYTES);
|
|
465
|
+
let bytesRead;
|
|
466
|
+
try {
|
|
467
|
+
const fd = openSync(path, "r");
|
|
468
|
+
try {
|
|
469
|
+
bytesRead = readSync(fd, buffer, 0, BINARY_SNIFF_BYTES, 0);
|
|
470
|
+
}
|
|
471
|
+
finally {
|
|
472
|
+
closeSync(fd);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return { binaryKind: null, hasShebang: false };
|
|
477
|
+
}
|
|
478
|
+
const head = buffer.subarray(0, bytesRead);
|
|
479
|
+
const hasShebang = bytesRead >= 2 && head[0] === 0x23 && head[1] === 0x21;
|
|
480
|
+
if (bytesRead >= 4 &&
|
|
481
|
+
head[0] === 0x7f &&
|
|
482
|
+
head[1] === 0x45 &&
|
|
483
|
+
head[2] === 0x4c &&
|
|
484
|
+
head[3] === 0x46) {
|
|
485
|
+
return { binaryKind: SKILL_BINARY_KIND.Elf, hasShebang: false };
|
|
486
|
+
}
|
|
487
|
+
const magic = bytesRead >= 4 ? head.readUInt32BE(0) : 0;
|
|
488
|
+
if (magic === 0xfeedface ||
|
|
489
|
+
magic === 0xfeedfacf ||
|
|
490
|
+
magic === 0xcefaedfe ||
|
|
491
|
+
magic === 0xcffaedfe ||
|
|
492
|
+
magic === 0xcafebabe) {
|
|
493
|
+
return { binaryKind: SKILL_BINARY_KIND.MachO, hasShebang: false };
|
|
494
|
+
}
|
|
495
|
+
if (bytesRead >= 2 && head[0] === 0x4d && head[1] === 0x5a) {
|
|
496
|
+
return { binaryKind: SKILL_BINARY_KIND.Pe, hasShebang: false };
|
|
497
|
+
}
|
|
498
|
+
if (head.includes(0)) {
|
|
499
|
+
return { binaryKind: SKILL_BINARY_KIND.Unknown, hasShebang: false };
|
|
500
|
+
}
|
|
501
|
+
return { binaryKind: null, hasShebang };
|
|
502
|
+
}
|
|
503
|
+
function isSkillCandidate(candidate) {
|
|
504
|
+
return basename(normalizePathForMatch(candidate.reportPath)).toLowerCase() === SKILL_FILE_NAME;
|
|
505
|
+
}
|
|
506
|
+
function siblingReportPath(candidate, siblingAbsolute) {
|
|
507
|
+
const skillDir = dirname(candidate.absolutePath);
|
|
508
|
+
const relFromSkillDir = normalizePathForMatch(relative(skillDir, siblingAbsolute));
|
|
509
|
+
const normalizedReport = normalizePathForMatch(candidate.reportPath);
|
|
510
|
+
const slashIndex = normalizedReport.lastIndexOf("/");
|
|
511
|
+
const parentReport = slashIndex >= 0 ? normalizedReport.slice(0, slashIndex) : "";
|
|
512
|
+
return parentReport ? `${parentReport}/${relFromSkillDir}` : relFromSkillDir;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Skills ship payloads next to SKILL.md (helper scripts, nested docs,
|
|
516
|
+
* sometimes binaries). Collect text-like siblings as scan candidates so the
|
|
517
|
+
* rule-file detectors see them, and record binary artifacts for reporting.
|
|
518
|
+
*/
|
|
519
|
+
function collectSkillSiblings(selected) {
|
|
520
|
+
const known = new Set(selected.map((candidate) => normalizePathForMatch(candidate.reportPath)));
|
|
521
|
+
const candidates = [];
|
|
522
|
+
const binaries = [];
|
|
523
|
+
const visitedDirs = new Set();
|
|
524
|
+
for (const skillCandidate of selected.filter(isSkillCandidate)) {
|
|
525
|
+
const skillDir = dirname(skillCandidate.absolutePath);
|
|
526
|
+
if (visitedDirs.has(skillDir)) {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
visitedDirs.add(skillDir);
|
|
530
|
+
let filesSeen = 0;
|
|
531
|
+
const queue = [{ dir: skillDir, depth: 0 }];
|
|
532
|
+
while (queue.length > 0 && filesSeen < SKILL_SIBLING_MAX_FILES) {
|
|
533
|
+
const current = queue.pop();
|
|
534
|
+
if (!current) {
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
let entries;
|
|
538
|
+
try {
|
|
539
|
+
entries = readdirSync(current.dir, { withFileTypes: true });
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
for (const entry of entries) {
|
|
545
|
+
if (filesSeen >= SKILL_SIBLING_MAX_FILES) {
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
if (entry.isSymbolicLink()) {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const absolutePath = join(current.dir, entry.name);
|
|
552
|
+
if (entry.isDirectory()) {
|
|
553
|
+
if (current.depth < SKILL_SIBLING_MAX_DEPTH) {
|
|
554
|
+
queue.push({ dir: absolutePath, depth: current.depth + 1 });
|
|
555
|
+
}
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (!entry.isFile() || absolutePath === skillCandidate.absolutePath) {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
filesSeen += 1;
|
|
562
|
+
const reportPath = siblingReportPath(skillCandidate, absolutePath);
|
|
563
|
+
if (known.has(reportPath)) {
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const sniffed = sniffFileHead(absolutePath);
|
|
567
|
+
if (sniffed.binaryKind) {
|
|
568
|
+
let executable;
|
|
569
|
+
try {
|
|
570
|
+
executable = (statSync(absolutePath).mode & 0o111) !== 0;
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
executable = false;
|
|
574
|
+
}
|
|
575
|
+
binaries.push({ reportPath, kind: sniffed.binaryKind, executable });
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
const extension = entry.name.includes(".")
|
|
579
|
+
? (entry.name.split(".").pop() ?? "").toLowerCase()
|
|
580
|
+
: "";
|
|
581
|
+
const format = SKILL_SIBLING_FORMATS[extension] ?? (sniffed.hasShebang ? "text" : null);
|
|
582
|
+
if (!format) {
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
known.add(reportPath);
|
|
586
|
+
candidates.push({
|
|
587
|
+
reportPath,
|
|
588
|
+
absolutePath,
|
|
589
|
+
format,
|
|
590
|
+
tool: skillCandidate.tool,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return { candidates, binaries };
|
|
596
|
+
}
|
|
421
597
|
function inferArtifactCandidate(relativePath, absolutePath) {
|
|
422
598
|
for (const rule of INFERRED_ARTIFACT_RULES) {
|
|
423
599
|
if (!rule.pattern.test(relativePath)) {
|
|
@@ -446,6 +622,57 @@ function ensureParsedCandidates(context) {
|
|
|
446
622
|
}
|
|
447
623
|
return context.parsedCandidates;
|
|
448
624
|
}
|
|
625
|
+
function makeUntrustedProjectConfigFinding(ignoredSettings) {
|
|
626
|
+
return {
|
|
627
|
+
rule_id: "untrusted-project-config",
|
|
628
|
+
finding_id: "UNTRUSTED_PROJECT_CONFIG-.codegate.json",
|
|
629
|
+
severity: "INFO",
|
|
630
|
+
category: "CONFIG_CHANGE",
|
|
631
|
+
layer: "L1",
|
|
632
|
+
file_path: ".codegate.json",
|
|
633
|
+
location: { field: ignoredSettings.join(", ") },
|
|
634
|
+
description: `Ignored ${ignoredSettings.length} policy setting(s) from untrusted project config: ` +
|
|
635
|
+
`${ignoredSettings.join(", ")}. Project config in untrusted directories may only set ` +
|
|
636
|
+
"presentation options. Run `codegate trust <dir>` to honor its policy settings.",
|
|
637
|
+
affected_tools: [],
|
|
638
|
+
cve: null,
|
|
639
|
+
owasp: [],
|
|
640
|
+
cwe: "CWE-807",
|
|
641
|
+
confidence: "HIGH",
|
|
642
|
+
fixable: false,
|
|
643
|
+
remediation_actions: [],
|
|
644
|
+
suppressed: false,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
function makeSkillBinaryFinding(artifact) {
|
|
648
|
+
const isExecutableFormat = artifact.kind !== SKILL_BINARY_KIND.Unknown;
|
|
649
|
+
const severity = artifact.executable || isExecutableFormat ? "HIGH" : "MEDIUM";
|
|
650
|
+
const kindLabel = isExecutableFormat ? `${artifact.kind} executable` : "binary file";
|
|
651
|
+
return {
|
|
652
|
+
rule_id: "skill-binary-payload",
|
|
653
|
+
finding_id: `SKILL_BINARY-${artifact.reportPath}`,
|
|
654
|
+
severity,
|
|
655
|
+
category: "COMMAND_EXEC",
|
|
656
|
+
layer: "L1",
|
|
657
|
+
file_path: artifact.reportPath,
|
|
658
|
+
location: { field: "content" },
|
|
659
|
+
description: `Skill directory ships a ${kindLabel}${artifact.executable ? " with execute permissions" : ""}. ` +
|
|
660
|
+
"Binary payloads cannot be reviewed as text and have no place in an instruction skill.",
|
|
661
|
+
affected_tools: ["claude-code", "codex-cli", "opencode", "cursor"],
|
|
662
|
+
cve: null,
|
|
663
|
+
owasp: ["ASI02"],
|
|
664
|
+
cwe: "CWE-506",
|
|
665
|
+
confidence: "HIGH",
|
|
666
|
+
fixable: true,
|
|
667
|
+
remediation_actions: ["quarantine_file"],
|
|
668
|
+
metadata: {
|
|
669
|
+
sources: [artifact.reportPath],
|
|
670
|
+
risk_tags: ["skill", "binary-payload"],
|
|
671
|
+
origin: "skill-siblings",
|
|
672
|
+
},
|
|
673
|
+
suppressed: false,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
449
676
|
function makeParseErrorFinding(filePath, tool, message, strictCollection) {
|
|
450
677
|
return {
|
|
451
678
|
rule_id: "parse-error",
|
|
@@ -507,7 +734,7 @@ function commandResourceFromTokens(command) {
|
|
|
507
734
|
id: `npm:${locator}`,
|
|
508
735
|
kind: "npm",
|
|
509
736
|
locator,
|
|
510
|
-
preview: `
|
|
737
|
+
preview: `GET https://registry.npmjs.org/${locator} (online mode only; offline records the package name)`,
|
|
511
738
|
};
|
|
512
739
|
}
|
|
513
740
|
if (launcher === "uvx" || launcher === "pipx") {
|
|
@@ -519,7 +746,7 @@ function commandResourceFromTokens(command) {
|
|
|
519
746
|
id: `pypi:${locator}`,
|
|
520
747
|
kind: "pypi",
|
|
521
748
|
locator,
|
|
522
|
-
preview: `https://pypi.org/pypi/${locator}/json`,
|
|
749
|
+
preview: `GET https://pypi.org/pypi/${locator}/json (online mode only; offline records the package name)`,
|
|
523
750
|
};
|
|
524
751
|
}
|
|
525
752
|
return null;
|
|
@@ -654,7 +881,7 @@ export function createScanDiscoveryContext(scanTarget, kbInput, options = {}) {
|
|
|
654
881
|
const collectModes = normalizeCollectionModes(options.collectModes);
|
|
655
882
|
const collectKinds = normalizeCollectionKinds(options.collectKinds);
|
|
656
883
|
const explicitOnly = collectModes.size === 1 && collectModes.has("explicit");
|
|
657
|
-
const
|
|
884
|
+
const baseSelected = mergeExplicitCandidates(explicitOnly
|
|
658
885
|
? []
|
|
659
886
|
: collectSelectedCandidates(absoluteTarget, walked.files, patterns, {
|
|
660
887
|
includeUserScope: options.includeUserScope === true,
|
|
@@ -662,21 +889,50 @@ export function createScanDiscoveryContext(scanTarget, kbInput, options = {}) {
|
|
|
662
889
|
collectModes,
|
|
663
890
|
collectKinds,
|
|
664
891
|
}), options.explicitCandidates, collectKinds);
|
|
892
|
+
const skillSiblings = collectSkillSiblings(baseSelected);
|
|
893
|
+
const selected = [...baseSelected, ...skillSiblings.candidates];
|
|
665
894
|
return {
|
|
666
895
|
absoluteTarget,
|
|
667
896
|
kb,
|
|
668
897
|
walked,
|
|
669
898
|
selected,
|
|
670
899
|
parsedCandidates: options.parseSelected ? parseSelectedCandidates(selected) : undefined,
|
|
900
|
+
skillBinaries: skillSiblings.binaries,
|
|
671
901
|
};
|
|
672
902
|
}
|
|
903
|
+
function collectIndirectionUrlResources(textContent, filePath, resources) {
|
|
904
|
+
const lines = textContent.split(/\r?\n/u);
|
|
905
|
+
for (const line of lines) {
|
|
906
|
+
const normalized = normalizeForMatching(line);
|
|
907
|
+
if (!REMOTE_INSTRUCTION_INDIRECTION_PATTERN.test(normalized)) {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
const urlMatch = line.match(/https?:\/\/[^\s)\]"'`<>]+/iu);
|
|
911
|
+
if (!urlMatch) {
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
const url = urlMatch[0];
|
|
915
|
+
const kind = inferHttpKind(url);
|
|
916
|
+
const id = `${kind}:${url}`;
|
|
917
|
+
if (resources.has(id)) {
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
resources.set(id, {
|
|
921
|
+
id,
|
|
922
|
+
request: { id, kind, locator: url },
|
|
923
|
+
commandPreview: `GET ${url} (from ${filePath} -> remote instruction indirection)`,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
}
|
|
673
927
|
export function discoverDeepScanResourcesFromContext(context) {
|
|
674
928
|
const discovered = new Map();
|
|
675
929
|
for (const item of ensureParsedCandidates(context)) {
|
|
676
|
-
if (
|
|
677
|
-
|
|
930
|
+
if (item.parsed.ok) {
|
|
931
|
+
collectDeepScanResourcesFromParsed(item.parsed.data, item.reportPath, discovered);
|
|
932
|
+
}
|
|
933
|
+
if (item.format === "markdown" || item.format === "text") {
|
|
934
|
+
collectIndirectionUrlResources(readCandidateText(item), item.reportPath, discovered);
|
|
678
935
|
}
|
|
679
|
-
collectDeepScanResourcesFromParsed(item.parsed.data, item.reportPath, discovered);
|
|
680
936
|
}
|
|
681
937
|
return Array.from(discovered.values()).sort((a, b) => a.id.localeCompare(b.id));
|
|
682
938
|
}
|
|
@@ -712,6 +968,8 @@ export async function runScanEngine(input) {
|
|
|
712
968
|
});
|
|
713
969
|
const absoluteTarget = context.absoluteTarget;
|
|
714
970
|
const kb = context.kb;
|
|
971
|
+
const scanHomeDir = input.homeDir;
|
|
972
|
+
const knownBadIndicators = loadKnownBadIndicators(scanHomeDir ? { homeDir: () => scanHomeDir } : {});
|
|
715
973
|
const parseErrors = [];
|
|
716
974
|
const staticFiles = [];
|
|
717
975
|
for (const item of ensureParsedCandidates(context)) {
|
|
@@ -772,6 +1030,7 @@ export async function runScanEngine(input) {
|
|
|
772
1030
|
runtimeMode: input.config.runtime_mode,
|
|
773
1031
|
workflowAuditsEnabled: input.config.workflow_audits?.enabled === true,
|
|
774
1032
|
rulePolicies: input.config.rules,
|
|
1033
|
+
knownBadIndicators,
|
|
775
1034
|
},
|
|
776
1035
|
});
|
|
777
1036
|
const snapshots = new Map();
|
|
@@ -780,17 +1039,32 @@ export async function runScanEngine(input) {
|
|
|
780
1039
|
snapshots.set(snapshot.serverId, snapshot);
|
|
781
1040
|
}
|
|
782
1041
|
}
|
|
783
|
-
const
|
|
1042
|
+
const trustedTarget = input.config.project_config_trusted ??
|
|
1043
|
+
isTrustedDirectory(absoluteTarget, input.config.trusted_directories);
|
|
1044
|
+
const previousState = loadScanState(input.scanStatePath, absoluteTarget);
|
|
784
1045
|
const stateResult = evaluateScanStateSnapshots({
|
|
785
1046
|
snapshots: Array.from(snapshots.values()),
|
|
786
1047
|
previousState,
|
|
1048
|
+
trustedTarget,
|
|
1049
|
+
firstScanReview: input.config.first_scan_review,
|
|
787
1050
|
});
|
|
788
|
-
saveScanState(stateResult.nextState, input.scanStatePath);
|
|
1051
|
+
saveScanState(stateResult.nextState, input.scanStatePath, absoluteTarget);
|
|
789
1052
|
const inlineIgnores = collectInlineIgnoreDirectives(staticFiles.map((file) => ({
|
|
790
1053
|
filePath: file.filePath,
|
|
791
1054
|
textContent: file.textContent,
|
|
792
1055
|
})));
|
|
793
|
-
const
|
|
1056
|
+
const ignoredProjectSettings = input.config.ignored_project_settings ?? [];
|
|
1057
|
+
const configNoticeFindings = ignoredProjectSettings.length > 0
|
|
1058
|
+
? [withFindingFingerprint(makeUntrustedProjectConfigFinding(ignoredProjectSettings))]
|
|
1059
|
+
: [];
|
|
1060
|
+
const skillBinaryFindings = (context.skillBinaries ?? []).map((artifact) => withFindingFingerprint(makeSkillBinaryFinding(artifact)));
|
|
1061
|
+
const findings = applyInlineIgnoreDirectives(escalateKnownBadFindings([
|
|
1062
|
+
...report.findings,
|
|
1063
|
+
...parseErrors,
|
|
1064
|
+
...stateResult.findings,
|
|
1065
|
+
...configNoticeFindings,
|
|
1066
|
+
...skillBinaryFindings,
|
|
1067
|
+
], knownBadIndicators), inlineIgnores, { trustedTarget });
|
|
794
1068
|
return applyReportSummary({
|
|
795
1069
|
...report,
|
|
796
1070
|
findings,
|
package/dist/types/finding.d.ts
CHANGED
|
@@ -4,6 +4,19 @@ export declare const FINDING_CATEGORIES: readonly ["ENV_OVERRIDE", "COMMAND_EXEC
|
|
|
4
4
|
export type FindingCategory = (typeof FINDING_CATEGORIES)[number];
|
|
5
5
|
export type FindingLayer = "L1" | "L2" | "L3";
|
|
6
6
|
export type FindingConfidence = "HIGH" | "MEDIUM" | "LOW";
|
|
7
|
+
/**
|
|
8
|
+
* Where a suppression came from. InlineUntrusted marks inline
|
|
9
|
+
* `codegate: ignore[...]` directives found in content the user has not
|
|
10
|
+
* trusted; such findings stay visibly suppressed in reports but still
|
|
11
|
+
* count toward the exit code so untrusted content cannot self-approve.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SUPPRESSION_SOURCE: {
|
|
14
|
+
readonly Inline: "inline";
|
|
15
|
+
readonly InlineUntrusted: "inline-untrusted";
|
|
16
|
+
readonly Config: "config";
|
|
17
|
+
};
|
|
18
|
+
export type SuppressionSource = (typeof SUPPRESSION_SOURCE)[keyof typeof SUPPRESSION_SOURCE];
|
|
19
|
+
export declare function isUntrustedInlineSuppression(finding: Pick<Finding, "suppressed" | "suppression_source">): boolean;
|
|
7
20
|
export interface FindingLocation {
|
|
8
21
|
field?: string;
|
|
9
22
|
line?: number;
|
|
@@ -52,4 +65,5 @@ export interface Finding {
|
|
|
52
65
|
incident_primary?: boolean | null;
|
|
53
66
|
source_config?: FindingSourceConfig | null;
|
|
54
67
|
suppressed: boolean;
|
|
68
|
+
suppression_source?: SuppressionSource | null;
|
|
55
69
|
}
|
package/dist/types/finding.js
CHANGED
|
@@ -18,3 +18,17 @@ export const FINDING_CATEGORIES = [
|
|
|
18
18
|
"CI_TEMPLATE_INJECTION",
|
|
19
19
|
"CI_VULNERABLE_ACTION",
|
|
20
20
|
];
|
|
21
|
+
/**
|
|
22
|
+
* Where a suppression came from. InlineUntrusted marks inline
|
|
23
|
+
* `codegate: ignore[...]` directives found in content the user has not
|
|
24
|
+
* trusted; such findings stay visibly suppressed in reports but still
|
|
25
|
+
* count toward the exit code so untrusted content cannot self-approve.
|
|
26
|
+
*/
|
|
27
|
+
export const SUPPRESSION_SOURCE = {
|
|
28
|
+
Inline: "inline",
|
|
29
|
+
InlineUntrusted: "inline-untrusted",
|
|
30
|
+
Config: "config",
|
|
31
|
+
};
|
|
32
|
+
export function isUntrustedInlineSuppression(finding) {
|
|
33
|
+
return (finding.suppressed === true && finding.suppression_source === SUPPRESSION_SOURCE.InlineUntrusted);
|
|
34
|
+
}
|
package/dist/types/report.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export interface ReportSummary {
|
|
|
4
4
|
by_severity: Record<string, number>;
|
|
5
5
|
fixable: number;
|
|
6
6
|
suppressed: number;
|
|
7
|
+
/** Suppressions requested by untrusted content; these still count toward exit_code. */
|
|
8
|
+
suppressed_untrusted?: number;
|
|
7
9
|
exit_code: number;
|
|
8
10
|
}
|
|
9
11
|
export interface CodeGateReport {
|