codegate-ai 0.13.0 → 0.14.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/dist/cli.js +66 -0
- package/dist/commands/inventory-command.d.ts +39 -0
- package/dist/commands/inventory-command.js +194 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +27 -0
- package/dist/layer3-dynamic/resource-fetcher.d.ts +16 -0
- package/dist/layer3-dynamic/resource-fetcher.js +105 -7
- package/dist/layer3-dynamic/tool-description-acquisition.d.ts +4 -1
- package/dist/layer3-dynamic/tool-description-acquisition.js +2 -2
- package/dist/layer3-dynamic/url-validation.d.ts +38 -0
- package/dist/layer3-dynamic/url-validation.js +56 -0
- package/dist/scan.js +23 -15
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ import { renderTuiApp } from "./tui/app.js";
|
|
|
17
17
|
import { executeWrapperRun } from "./wrapper.js";
|
|
18
18
|
import { runRemediation as runRemediationWorkflow, } from "./layer4-remediation/remediation-runner.js";
|
|
19
19
|
import { undoLatestSession } from "./commands/undo.js";
|
|
20
|
+
import { runInventory } from "./commands/inventory-command.js";
|
|
20
21
|
import { executeScanCommand } from "./commands/scan-command.js";
|
|
21
22
|
import { executeScanContentCommand, SCAN_CONTENT_TYPES, } from "./commands/scan-content-command.js";
|
|
22
23
|
import { executeSkillsWrapper, launchSkillsPassthrough, } from "./commands/skills-wrapper.js";
|
|
@@ -619,6 +620,70 @@ function addInitCommand(program, deps) {
|
|
|
619
620
|
}
|
|
620
621
|
});
|
|
621
622
|
}
|
|
623
|
+
const INVENTORY_SCOPES = ["user", "project", "all"];
|
|
624
|
+
const INVENTORY_KINDS = ["skills", "configs", "all"];
|
|
625
|
+
function addInventoryCommand(program, deps) {
|
|
626
|
+
program
|
|
627
|
+
.command("inventory")
|
|
628
|
+
.description("List the AI-tool config + skill artifacts the knowledge base knows about, resolved against this machine.")
|
|
629
|
+
.addOption(new Option("--scope <scope>", "scope filter")
|
|
630
|
+
.choices(INVENTORY_SCOPES)
|
|
631
|
+
.default("all"))
|
|
632
|
+
.addOption(new Option("--kind <kind>", "artifact kind filter")
|
|
633
|
+
.choices(INVENTORY_KINDS)
|
|
634
|
+
.default("all"))
|
|
635
|
+
.option("--only-existing", "return only items that currently exist on disk")
|
|
636
|
+
.option("--workspace <path>", "additional project-scope root (repeatable); defaults to cwd when omitted", collectRepeatable, [])
|
|
637
|
+
.addOption(new Option("--format <format>", "output format").choices(["text", "json"]).default("text"))
|
|
638
|
+
.addHelpText("after", renderExampleHelp([
|
|
639
|
+
"codegate inventory",
|
|
640
|
+
"codegate inventory --format json --kind skills --only-existing",
|
|
641
|
+
"codegate inventory --scope user --format json",
|
|
642
|
+
"codegate inventory --workspace . --workspace /path/to/other/repo",
|
|
643
|
+
]))
|
|
644
|
+
.action((options) => {
|
|
645
|
+
try {
|
|
646
|
+
const home = deps.homeDir?.() ?? homedir();
|
|
647
|
+
const explicitWorkspaces = options.workspace ?? [];
|
|
648
|
+
const workspaces = explicitWorkspaces.length > 0
|
|
649
|
+
? explicitWorkspaces.map((w) => resolve(deps.cwd(), w))
|
|
650
|
+
: [deps.cwd()];
|
|
651
|
+
const summary = runInventory({
|
|
652
|
+
scope: options.scope ?? "all",
|
|
653
|
+
kind: options.kind ?? "all",
|
|
654
|
+
onlyExisting: options.onlyExisting === true,
|
|
655
|
+
workspaces,
|
|
656
|
+
homeDir: home,
|
|
657
|
+
});
|
|
658
|
+
if (options.format === "json") {
|
|
659
|
+
deps.stdout(JSON.stringify(summary, null, 2));
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
renderInventoryText(summary, deps.stdout);
|
|
663
|
+
}
|
|
664
|
+
deps.setExitCode(0);
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
668
|
+
deps.stderr(`Inventory failed: ${message}`);
|
|
669
|
+
deps.setExitCode(3);
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
function collectRepeatable(value, previous) {
|
|
674
|
+
return [...previous, value];
|
|
675
|
+
}
|
|
676
|
+
function renderInventoryText(summary, stdout) {
|
|
677
|
+
stdout(`Knowledge base v${summary.kb_version}`);
|
|
678
|
+
stdout(`Tools: ${summary.tools.map((t) => t.name).join(", ")}`);
|
|
679
|
+
stdout(`Items: ${summary.items.length}`);
|
|
680
|
+
stdout("");
|
|
681
|
+
for (const item of summary.items) {
|
|
682
|
+
const mark = item.exists ? "✓" : "·";
|
|
683
|
+
const tag = item.kind === "skill" ? `${item.kind}:${item.type ?? "?"}` : item.kind;
|
|
684
|
+
stdout(` ${mark} [${item.tool}] ${tag} (${item.scope}) ${item.path}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
622
687
|
function addUpdateCommands(program, deps) {
|
|
623
688
|
const guidance = [
|
|
624
689
|
"Updates are bundled with CodeGate releases in v1/v2.",
|
|
@@ -680,6 +745,7 @@ export function createCli(version = packageJson.version ?? "0.0.0-dev", deps = d
|
|
|
680
745
|
addRunCommand(program, version, deps);
|
|
681
746
|
addUndoCommand(program, deps);
|
|
682
747
|
addInitCommand(program, deps);
|
|
748
|
+
addInventoryCommand(program, deps);
|
|
683
749
|
addUpdateCommands(program, deps);
|
|
684
750
|
return program;
|
|
685
751
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** One resolved artifact the scanner knows about. */
|
|
2
|
+
export interface InventoryItem {
|
|
3
|
+
tool: string;
|
|
4
|
+
kind: "config" | "skill";
|
|
5
|
+
/** Only set for skill entries; mirrors KB `skill_paths[].type`. */
|
|
6
|
+
type?: string;
|
|
7
|
+
scope: "user" | "project";
|
|
8
|
+
/** Pattern as declared in the KB (relative, may contain wildcards). */
|
|
9
|
+
pattern: string;
|
|
10
|
+
/** Absolute resolved filesystem path (concrete, not the pattern). */
|
|
11
|
+
path: string;
|
|
12
|
+
/** True if the filesystem shows the path exists. */
|
|
13
|
+
exists: boolean;
|
|
14
|
+
risk_surface: string[];
|
|
15
|
+
/** Only populated for config entries that declare them. */
|
|
16
|
+
fields_of_interest?: Record<string, string>;
|
|
17
|
+
/** Resolution root used (e.g., the home dir or a workspace root). */
|
|
18
|
+
resolved_against: string;
|
|
19
|
+
}
|
|
20
|
+
export interface InventorySummary {
|
|
21
|
+
kb_version: string;
|
|
22
|
+
/** Known tools (from KB file names) with their version ranges. */
|
|
23
|
+
tools: Array<{
|
|
24
|
+
name: string;
|
|
25
|
+
version_range: string;
|
|
26
|
+
}>;
|
|
27
|
+
items: InventoryItem[];
|
|
28
|
+
}
|
|
29
|
+
export interface InventoryOptions {
|
|
30
|
+
scope: "user" | "project" | "all";
|
|
31
|
+
kind: "skills" | "configs" | "all";
|
|
32
|
+
onlyExisting: boolean;
|
|
33
|
+
/** Roots for project-scope resolution. Empty if project scope is skipped. */
|
|
34
|
+
workspaces: string[];
|
|
35
|
+
homeDir: string;
|
|
36
|
+
/** Optional injection for tests. */
|
|
37
|
+
kbBaseDir?: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function runInventory(options: InventoryOptions): InventorySummary;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { loadKnowledgeBase, } from "../layer1-discovery/knowledge-base.js";
|
|
4
|
+
const MAX_WILDCARD_DEPTH = 8;
|
|
5
|
+
const MAX_WILDCARD_MATCHES = 2000;
|
|
6
|
+
export function runInventory(options) {
|
|
7
|
+
const kb = loadKnowledgeBase(options.kbBaseDir);
|
|
8
|
+
const includeConfigs = options.kind === "all" || options.kind === "configs";
|
|
9
|
+
const includeSkills = options.kind === "all" || options.kind === "skills";
|
|
10
|
+
const rawItems = [];
|
|
11
|
+
for (const entry of kb.entries) {
|
|
12
|
+
if (includeConfigs) {
|
|
13
|
+
for (const cp of entry.config_paths) {
|
|
14
|
+
rawItems.push(...resolveConfigEntry(entry.tool, cp, options));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (includeSkills) {
|
|
18
|
+
for (const sp of entry.skill_paths ?? []) {
|
|
19
|
+
rawItems.push(...resolveSkillEntry(entry.tool, sp, options));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const items = options.onlyExisting ? rawItems.filter((item) => item.exists) : rawItems;
|
|
24
|
+
// Stable ordering: by tool, then kind, then scope, then path.
|
|
25
|
+
items.sort((a, b) => {
|
|
26
|
+
if (a.tool !== b.tool)
|
|
27
|
+
return a.tool.localeCompare(b.tool);
|
|
28
|
+
if (a.kind !== b.kind)
|
|
29
|
+
return a.kind.localeCompare(b.kind);
|
|
30
|
+
if (a.scope !== b.scope)
|
|
31
|
+
return a.scope.localeCompare(b.scope);
|
|
32
|
+
return a.path.localeCompare(b.path);
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
kb_version: kb.schemaVersion,
|
|
36
|
+
tools: kb.entries
|
|
37
|
+
.map((entry) => ({
|
|
38
|
+
name: entry.tool,
|
|
39
|
+
version_range: entry.version_range,
|
|
40
|
+
}))
|
|
41
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
42
|
+
items,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function resolveConfigEntry(tool, cp, options) {
|
|
46
|
+
if (!scopeIncluded(cp.scope, options.scope))
|
|
47
|
+
return [];
|
|
48
|
+
const roots = rootsFor(cp.scope, options);
|
|
49
|
+
const items = [];
|
|
50
|
+
for (const root of roots) {
|
|
51
|
+
items.push(...resolvePattern({
|
|
52
|
+
tool,
|
|
53
|
+
kind: "config",
|
|
54
|
+
scope: cp.scope,
|
|
55
|
+
pattern: cp.path,
|
|
56
|
+
root,
|
|
57
|
+
riskSurface: cp.risk_surface,
|
|
58
|
+
fieldsOfInterest: cp.fields_of_interest,
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
return items;
|
|
62
|
+
}
|
|
63
|
+
function resolveSkillEntry(tool, sp, options) {
|
|
64
|
+
if (!scopeIncluded(sp.scope, options.scope))
|
|
65
|
+
return [];
|
|
66
|
+
const roots = rootsFor(sp.scope, options);
|
|
67
|
+
const items = [];
|
|
68
|
+
for (const root of roots) {
|
|
69
|
+
items.push(...resolvePattern({
|
|
70
|
+
tool,
|
|
71
|
+
kind: "skill",
|
|
72
|
+
type: sp.type,
|
|
73
|
+
scope: sp.scope,
|
|
74
|
+
pattern: sp.path,
|
|
75
|
+
root,
|
|
76
|
+
riskSurface: sp.risk_surface,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
return items;
|
|
80
|
+
}
|
|
81
|
+
function scopeIncluded(entryScope, optionScope) {
|
|
82
|
+
if (optionScope === "all")
|
|
83
|
+
return true;
|
|
84
|
+
return entryScope === optionScope;
|
|
85
|
+
}
|
|
86
|
+
function rootsFor(entryScope, options) {
|
|
87
|
+
if (entryScope === "user")
|
|
88
|
+
return [options.homeDir];
|
|
89
|
+
if (options.workspaces.length === 0)
|
|
90
|
+
return [];
|
|
91
|
+
return options.workspaces;
|
|
92
|
+
}
|
|
93
|
+
function resolvePattern(input) {
|
|
94
|
+
const normalized = normalizePattern(input.pattern);
|
|
95
|
+
const hasWildcard = /[*?]/.test(normalized);
|
|
96
|
+
if (!hasWildcard) {
|
|
97
|
+
const absolute = resolve(input.root, normalized);
|
|
98
|
+
return [makeItem(input, absolute, existsSync(absolute))];
|
|
99
|
+
}
|
|
100
|
+
const matches = expandWildcard(input.root, normalized);
|
|
101
|
+
return matches.map((absolute) => makeItem(input, absolute, true));
|
|
102
|
+
}
|
|
103
|
+
function makeItem(input, absolute, exists) {
|
|
104
|
+
return {
|
|
105
|
+
tool: input.tool,
|
|
106
|
+
kind: input.kind,
|
|
107
|
+
type: input.type,
|
|
108
|
+
scope: input.scope,
|
|
109
|
+
pattern: input.pattern,
|
|
110
|
+
path: absolute,
|
|
111
|
+
exists,
|
|
112
|
+
risk_surface: input.riskSurface,
|
|
113
|
+
fields_of_interest: input.fieldsOfInterest,
|
|
114
|
+
resolved_against: input.root,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function normalizePattern(pattern) {
|
|
118
|
+
return pattern.replace(/^~\//, "").replace(/^\/+/, "");
|
|
119
|
+
}
|
|
120
|
+
function escapeRegex(value) {
|
|
121
|
+
return value.replace(/[|\\{}()[\]^$+?.*]/g, "\\$&");
|
|
122
|
+
}
|
|
123
|
+
function wildcardToRegex(pattern) {
|
|
124
|
+
let escaped = escapeRegex(pattern);
|
|
125
|
+
escaped = escaped.replace(/\\\*\\\*\//g, "(?:[^/]+/)*");
|
|
126
|
+
escaped = escaped.replace(/\\\*\\\*/g, ".*");
|
|
127
|
+
escaped = escaped.replace(/\\\*/g, "[^/]*");
|
|
128
|
+
escaped = escaped.replace(/\\\?/g, "[^/]");
|
|
129
|
+
return new RegExp(`^${escaped}$`);
|
|
130
|
+
}
|
|
131
|
+
function fixedPrefix(pattern) {
|
|
132
|
+
const firstStar = pattern.indexOf("*");
|
|
133
|
+
const firstQuestion = pattern.indexOf("?");
|
|
134
|
+
const firstWildcard = firstStar === -1
|
|
135
|
+
? firstQuestion
|
|
136
|
+
: firstQuestion === -1
|
|
137
|
+
? firstStar
|
|
138
|
+
: Math.min(firstStar, firstQuestion);
|
|
139
|
+
if (firstWildcard === -1)
|
|
140
|
+
return pattern;
|
|
141
|
+
const prefix = pattern.slice(0, firstWildcard);
|
|
142
|
+
const lastSlash = prefix.lastIndexOf("/");
|
|
143
|
+
return lastSlash === -1 ? "" : prefix.slice(0, lastSlash);
|
|
144
|
+
}
|
|
145
|
+
function expandWildcard(root, pattern) {
|
|
146
|
+
const matchRegex = wildcardToRegex(pattern);
|
|
147
|
+
const prefix = fixedPrefix(pattern);
|
|
148
|
+
const baseDir = prefix ? resolve(root, prefix) : resolve(root);
|
|
149
|
+
if (!existsSync(baseDir))
|
|
150
|
+
return [];
|
|
151
|
+
try {
|
|
152
|
+
if (!statSync(baseDir).isDirectory())
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
const matches = [];
|
|
159
|
+
const queue = [{ dir: baseDir, depth: 0 }];
|
|
160
|
+
while (queue.length > 0 && matches.length < MAX_WILDCARD_MATCHES) {
|
|
161
|
+
const current = queue.pop();
|
|
162
|
+
if (!current)
|
|
163
|
+
break;
|
|
164
|
+
let entries;
|
|
165
|
+
try {
|
|
166
|
+
entries = readdirSync(current.dir, { withFileTypes: true });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
if (matches.length >= MAX_WILDCARD_MATCHES)
|
|
173
|
+
break;
|
|
174
|
+
const absolute = join(current.dir, entry.name);
|
|
175
|
+
if (entry.isSymbolicLink())
|
|
176
|
+
continue;
|
|
177
|
+
if (entry.isDirectory()) {
|
|
178
|
+
if (current.depth < MAX_WILDCARD_DEPTH) {
|
|
179
|
+
queue.push({ dir: absolute, depth: current.depth + 1 });
|
|
180
|
+
}
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!entry.isFile())
|
|
184
|
+
continue;
|
|
185
|
+
const rel = relative(root, absolute).split(sep).join("/");
|
|
186
|
+
if (rel.startsWith(".."))
|
|
187
|
+
continue;
|
|
188
|
+
if (!matchRegex.test(rel))
|
|
189
|
+
continue;
|
|
190
|
+
matches.push(absolute);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return matches;
|
|
194
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -56,6 +56,22 @@ export interface CodeGateConfig {
|
|
|
56
56
|
workflow_audits?: WorkflowAuditConfig;
|
|
57
57
|
suppress_findings: string[];
|
|
58
58
|
suppression_rules?: SuppressionRule[];
|
|
59
|
+
/**
|
|
60
|
+
* Timeout (in milliseconds) applied to Layer 3 remote resource fetches
|
|
61
|
+
* (npm/PyPI registry lookups, git ls-remote, and any http/sse MCP probes).
|
|
62
|
+
* Kept deliberately low so a slow or deliberately stalling host cannot
|
|
63
|
+
* hang a scan. Overridable via `CODEGATE_LAYER3_REMOTE_FETCH_TIMEOUT_MS`.
|
|
64
|
+
*/
|
|
65
|
+
layer3_remote_fetch_timeout_ms: number;
|
|
66
|
+
/**
|
|
67
|
+
* Maximum response size (in bytes) accepted from a Layer 3 remote fetch.
|
|
68
|
+
* A declared `Content-Length` above this value is rejected immediately,
|
|
69
|
+
* and the streaming reader aborts once the running byte count exceeds
|
|
70
|
+
* this limit (defends against servers that lie about or omit
|
|
71
|
+
* `Content-Length`). Overridable via
|
|
72
|
+
* `CODEGATE_LAYER3_REMOTE_FETCH_MAX_BYTES`.
|
|
73
|
+
*/
|
|
74
|
+
layer3_remote_fetch_max_bytes: number;
|
|
59
75
|
}
|
|
60
76
|
export interface CliConfigOverrides {
|
|
61
77
|
format?: OutputFormat;
|
|
@@ -68,6 +84,10 @@ export interface ResolveConfigOptions {
|
|
|
68
84
|
cli?: CliConfigOverrides;
|
|
69
85
|
}
|
|
70
86
|
export declare const DEFAULT_CONFIG: CodeGateConfig;
|
|
87
|
+
/** Env var name that overrides `layer3_remote_fetch_timeout_ms`. */
|
|
88
|
+
export declare const LAYER3_REMOTE_FETCH_TIMEOUT_ENV = "CODEGATE_LAYER3_REMOTE_FETCH_TIMEOUT_MS";
|
|
89
|
+
/** Env var name that overrides `layer3_remote_fetch_max_bytes`. */
|
|
90
|
+
export declare const LAYER3_REMOTE_FETCH_MAX_BYTES_ENV = "CODEGATE_LAYER3_REMOTE_FETCH_MAX_BYTES";
|
|
71
91
|
export declare function resolveEffectiveConfig(options: ResolveConfigOptions): CodeGateConfig;
|
|
72
92
|
export declare function computeExitCode(findings: Finding[], threshold: SeverityThreshold): number;
|
|
73
93
|
export declare function applyConfigPolicy(report: CodeGateReport, config: CodeGateConfig): CodeGateReport;
|
package/dist/config.js
CHANGED
|
@@ -49,7 +49,32 @@ export const DEFAULT_CONFIG = {
|
|
|
49
49
|
workflow_audits: { enabled: false },
|
|
50
50
|
suppress_findings: [],
|
|
51
51
|
suppression_rules: [],
|
|
52
|
+
layer3_remote_fetch_timeout_ms: 5000,
|
|
53
|
+
layer3_remote_fetch_max_bytes: 1_048_576,
|
|
52
54
|
};
|
|
55
|
+
/** Env var name that overrides `layer3_remote_fetch_timeout_ms`. */
|
|
56
|
+
export const LAYER3_REMOTE_FETCH_TIMEOUT_ENV = "CODEGATE_LAYER3_REMOTE_FETCH_TIMEOUT_MS";
|
|
57
|
+
/** Env var name that overrides `layer3_remote_fetch_max_bytes`. */
|
|
58
|
+
export const LAYER3_REMOTE_FETCH_MAX_BYTES_ENV = "CODEGATE_LAYER3_REMOTE_FETCH_MAX_BYTES";
|
|
59
|
+
function normalizePositiveInteger(value) {
|
|
60
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
61
|
+
return Math.floor(value);
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
64
|
+
const parsed = Number(value.trim());
|
|
65
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
66
|
+
return Math.floor(parsed);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
function readEnvOverride(name) {
|
|
72
|
+
const raw = process.env[name];
|
|
73
|
+
if (raw === undefined) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
return normalizePositiveInteger(raw);
|
|
77
|
+
}
|
|
53
78
|
function normalizeOutputFormat(value) {
|
|
54
79
|
if (!value) {
|
|
55
80
|
return undefined;
|
|
@@ -351,6 +376,8 @@ export function resolveEffectiveConfig(options) {
|
|
|
351
376
|
...(globalConfig.suppression_rules ?? []),
|
|
352
377
|
...(projectConfig.suppression_rules ?? []),
|
|
353
378
|
],
|
|
379
|
+
layer3_remote_fetch_timeout_ms: pickFirst(readEnvOverride(LAYER3_REMOTE_FETCH_TIMEOUT_ENV), normalizePositiveInteger(projectConfig.layer3_remote_fetch_timeout_ms), normalizePositiveInteger(globalConfig.layer3_remote_fetch_timeout_ms), DEFAULT_CONFIG.layer3_remote_fetch_timeout_ms) ?? DEFAULT_CONFIG.layer3_remote_fetch_timeout_ms,
|
|
380
|
+
layer3_remote_fetch_max_bytes: pickFirst(readEnvOverride(LAYER3_REMOTE_FETCH_MAX_BYTES_ENV), normalizePositiveInteger(projectConfig.layer3_remote_fetch_max_bytes), normalizePositiveInteger(globalConfig.layer3_remote_fetch_max_bytes), DEFAULT_CONFIG.layer3_remote_fetch_max_bytes) ?? DEFAULT_CONFIG.layer3_remote_fetch_max_bytes,
|
|
354
381
|
};
|
|
355
382
|
}
|
|
356
383
|
export function computeExitCode(findings, threshold) {
|
|
@@ -8,7 +8,23 @@ export interface ResourceRequest {
|
|
|
8
8
|
export interface ResourceFetcherOptions {
|
|
9
9
|
maxRetries?: number;
|
|
10
10
|
timeoutMs?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Maximum number of bytes accepted in the response body. Enforced against
|
|
13
|
+
* both the declared `Content-Length` header (if present) and the running
|
|
14
|
+
* byte count during streaming read. Defaults to 1 MiB.
|
|
15
|
+
*/
|
|
16
|
+
maxBytes?: number;
|
|
11
17
|
}
|
|
18
|
+
export declare const DEFAULT_FETCH_TIMEOUT_MS = 5000;
|
|
19
|
+
export declare const DEFAULT_FETCH_MAX_BYTES = 1048576;
|
|
20
|
+
/**
|
|
21
|
+
* Extract Layer 3 remote-fetch limits from the resolved CodeGate config.
|
|
22
|
+
* Kept here so callers don't have to remember the config field names.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resourceFetcherOptionsFromConfig(config: {
|
|
25
|
+
layer3_remote_fetch_timeout_ms: number;
|
|
26
|
+
layer3_remote_fetch_max_bytes: number;
|
|
27
|
+
}): ResourceFetcherOptions;
|
|
12
28
|
export interface ResourceFetcherDeps {
|
|
13
29
|
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
14
30
|
runCommand: (command: string, args: string[]) => Promise<SandboxCommandResult>;
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { runSandboxCommand } from "./sandbox.js";
|
|
2
|
+
export const DEFAULT_FETCH_TIMEOUT_MS = 5000;
|
|
3
|
+
export const DEFAULT_FETCH_MAX_BYTES = 1_048_576;
|
|
4
|
+
/**
|
|
5
|
+
* Extract Layer 3 remote-fetch limits from the resolved CodeGate config.
|
|
6
|
+
* Kept here so callers don't have to remember the config field names.
|
|
7
|
+
*/
|
|
8
|
+
export function resourceFetcherOptionsFromConfig(config) {
|
|
9
|
+
return {
|
|
10
|
+
timeoutMs: config.layer3_remote_fetch_timeout_ms,
|
|
11
|
+
maxBytes: config.layer3_remote_fetch_max_bytes,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
2
14
|
function defaultDeps() {
|
|
3
15
|
return {
|
|
4
16
|
fetch: (input, init) => fetch(input, init),
|
|
@@ -26,17 +38,87 @@ function endpointFor(request) {
|
|
|
26
38
|
}
|
|
27
39
|
return request.locator;
|
|
28
40
|
}
|
|
29
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Read a response body while enforcing `maxBytes`. Returns the collected
|
|
43
|
+
* string, or throws a tagged error if the declared `Content-Length` or the
|
|
44
|
+
* streamed size exceeds the cap.
|
|
45
|
+
*/
|
|
46
|
+
async function readBodyWithLimit(response, maxBytes) {
|
|
47
|
+
const declared = response.headers.get("content-length");
|
|
48
|
+
if (declared !== null) {
|
|
49
|
+
const parsed = Number(declared);
|
|
50
|
+
if (Number.isFinite(parsed) && parsed > maxBytes) {
|
|
51
|
+
// Drain & release the stream without reading bytes.
|
|
52
|
+
try {
|
|
53
|
+
await response.body?.cancel();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// no-op: cancel failures are non-fatal.
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`response_too_large: declared Content-Length ${parsed} exceeds limit ${maxBytes}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const body = response.body;
|
|
62
|
+
if (!body) {
|
|
63
|
+
// No stream (e.g., HEAD or empty body): fall back to text().
|
|
64
|
+
const text = await response.text();
|
|
65
|
+
if (Buffer.byteLength(text, "utf8") > maxBytes) {
|
|
66
|
+
throw new Error(`response_too_large: body ${Buffer.byteLength(text, "utf8")} > ${maxBytes}`);
|
|
67
|
+
}
|
|
68
|
+
return text;
|
|
69
|
+
}
|
|
70
|
+
const reader = body.getReader();
|
|
71
|
+
const chunks = [];
|
|
72
|
+
let total = 0;
|
|
73
|
+
try {
|
|
74
|
+
while (true) {
|
|
75
|
+
const { done, value } = await reader.read();
|
|
76
|
+
if (done) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
if (!value) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
total += value.byteLength;
|
|
83
|
+
if (total > maxBytes) {
|
|
84
|
+
try {
|
|
85
|
+
await reader.cancel();
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// no-op
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`response_too_large: streamed ${total} > ${maxBytes}`);
|
|
91
|
+
}
|
|
92
|
+
chunks.push(value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
try {
|
|
97
|
+
reader.releaseLock();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// releaseLock throws if the reader was already cancelled; ignore.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
|
|
104
|
+
return buffer.toString("utf8");
|
|
105
|
+
}
|
|
106
|
+
async function parseResponse(response, maxBytes) {
|
|
30
107
|
const contentType = response.headers.get("content-type") ?? "";
|
|
108
|
+
const text = await readBodyWithLimit(response, maxBytes);
|
|
31
109
|
if (contentType.includes("application/json")) {
|
|
32
|
-
return
|
|
110
|
+
return JSON.parse(text);
|
|
33
111
|
}
|
|
34
|
-
return
|
|
112
|
+
return text;
|
|
35
113
|
}
|
|
36
114
|
function timeoutError(error) {
|
|
37
115
|
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
38
116
|
return message.includes("timeout") || message.includes("aborted");
|
|
39
117
|
}
|
|
118
|
+
function isResponseTooLarge(error) {
|
|
119
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
120
|
+
return message.startsWith("response_too_large");
|
|
121
|
+
}
|
|
40
122
|
export async function fetchResourceMetadata(request, customDeps = defaultDeps(), options = {}) {
|
|
41
123
|
const deps = customDeps;
|
|
42
124
|
const startedAt = deps.now();
|
|
@@ -63,13 +145,19 @@ export async function fetchResourceMetadata(request, customDeps = defaultDeps(),
|
|
|
63
145
|
};
|
|
64
146
|
}
|
|
65
147
|
const endpoint = endpointFor(request);
|
|
66
|
-
const timeoutMs = options.timeoutMs ??
|
|
148
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
149
|
+
const maxBytes = options.maxBytes ?? DEFAULT_FETCH_MAX_BYTES;
|
|
67
150
|
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
68
151
|
try {
|
|
69
152
|
const controller = new AbortController();
|
|
70
153
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
71
|
-
|
|
72
|
-
|
|
154
|
+
let response;
|
|
155
|
+
try {
|
|
156
|
+
response = await deps.fetch(endpoint, { signal: controller.signal });
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
}
|
|
73
161
|
if (response.status === 401 || response.status === 403) {
|
|
74
162
|
return {
|
|
75
163
|
status: "auth_failure",
|
|
@@ -90,14 +178,24 @@ export async function fetchResourceMetadata(request, customDeps = defaultDeps(),
|
|
|
90
178
|
error: `HTTP ${response.status}`,
|
|
91
179
|
};
|
|
92
180
|
}
|
|
181
|
+
const metadata = await parseResponse(response, maxBytes);
|
|
93
182
|
return {
|
|
94
183
|
status: "ok",
|
|
95
184
|
attempts: attempt + 1,
|
|
96
185
|
elapsedMs: deps.now() - startedAt,
|
|
97
|
-
metadata
|
|
186
|
+
metadata,
|
|
98
187
|
};
|
|
99
188
|
}
|
|
100
189
|
catch (error) {
|
|
190
|
+
// Size-limit breaches are deterministic — do not retry, surface as network_error.
|
|
191
|
+
if (isResponseTooLarge(error)) {
|
|
192
|
+
return {
|
|
193
|
+
status: "network_error",
|
|
194
|
+
attempts: attempt + 1,
|
|
195
|
+
elapsedMs: deps.now() - startedAt,
|
|
196
|
+
error: error instanceof Error ? error.message : String(error),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
101
199
|
if (attempt < maxRetries) {
|
|
102
200
|
await deps.sleep(100 * (attempt + 1));
|
|
103
201
|
continue;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ResourceFetchResult, type ResourceRequest } from "./resource-fetcher.js";
|
|
1
|
+
import { type ResourceFetchResult, type ResourceFetcherOptions, type ResourceRequest } from "./resource-fetcher.js";
|
|
2
2
|
export interface AcquiredToolDescription {
|
|
3
3
|
name: string;
|
|
4
4
|
description: string;
|
|
@@ -19,4 +19,7 @@ export interface ToolDescriptionAcquisitionResult {
|
|
|
19
19
|
export interface ToolDescriptionAcquisitionDeps {
|
|
20
20
|
fetchMetadata: (request: ResourceRequest) => Promise<ResourceFetchResult>;
|
|
21
21
|
}
|
|
22
|
+
export interface ToolDescriptionAcquisitionOptions {
|
|
23
|
+
fetchOptions?: ResourceFetcherOptions;
|
|
24
|
+
}
|
|
22
25
|
export declare function acquireToolDescriptions(candidate: ToolDescriptionCandidate, customDeps?: ToolDescriptionAcquisitionDeps): Promise<ToolDescriptionAcquisitionResult>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { fetchResourceMetadata, } from "./resource-fetcher.js";
|
|
2
|
-
function defaultDeps() {
|
|
2
|
+
function defaultDeps(options = {}) {
|
|
3
3
|
return {
|
|
4
|
-
fetchMetadata: async (request) => fetchResourceMetadata(request),
|
|
4
|
+
fetchMetadata: async (request) => fetchResourceMetadata(request, undefined, options.fetchOptions),
|
|
5
5
|
};
|
|
6
6
|
}
|
|
7
7
|
function parseTools(metadata) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL validation and normalisation helpers for Layer 3 remote resource handling.
|
|
3
|
+
*
|
|
4
|
+
* These helpers guarantee that remote resources (HTTP/SSE MCP endpoints,
|
|
5
|
+
* skill-referenced URLs) use a safe, canonical form before they are fed into
|
|
6
|
+
* finding `rule_id` / `file_path` fields or into the fetcher.
|
|
7
|
+
*
|
|
8
|
+
* Historically, L3 resource IDs were composed as `${kind}:${url}` which, for
|
|
9
|
+
* http/sse kinds, produced malformed values like `http:https://mcp.linear.app/mcp`
|
|
10
|
+
* (the kind collides with the URL's own scheme). `buildResourceId` avoids that
|
|
11
|
+
* double-scheme shape by reusing the URL itself as the id for http/sse kinds.
|
|
12
|
+
*/
|
|
13
|
+
export type RemoteScheme = "http" | "https";
|
|
14
|
+
export interface NormalizeRemoteUrlResult {
|
|
15
|
+
ok: true;
|
|
16
|
+
url: string;
|
|
17
|
+
scheme: RemoteScheme;
|
|
18
|
+
}
|
|
19
|
+
export interface NormalizeRemoteUrlError {
|
|
20
|
+
ok: false;
|
|
21
|
+
reason: "empty" | "unsupported_scheme" | "missing_host" | "missing_scheme" | "invalid_url";
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Validate and canonicalise a remote URL. Rejects non http/https schemes,
|
|
25
|
+
* missing hosts, and malformed inputs. Normalises a bare-host path to a
|
|
26
|
+
* single trailing slash and strips trailing slashes from longer paths.
|
|
27
|
+
*/
|
|
28
|
+
export declare function normalizeRemoteUrl(input: string): NormalizeRemoteUrlResult | NormalizeRemoteUrlError;
|
|
29
|
+
export type DeepScanResourceKind = "npm" | "pypi" | "git" | "http" | "sse";
|
|
30
|
+
/**
|
|
31
|
+
* Build a canonical resource id used for findings (`rule_id`, `file_path`) and
|
|
32
|
+
* for consent prompts. For http/sse kinds the id is the URL itself (no
|
|
33
|
+
* `http:` / `sse:` prefix) to avoid the malformed `http:https://...` shape.
|
|
34
|
+
* For npm/pypi/git, the `<kind>:<locator>` prefix is preserved because those
|
|
35
|
+
* locators are not URLs and other code (e.g. `isRegistryMetadataResource`)
|
|
36
|
+
* keys on that prefix.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildResourceId(kind: DeepScanResourceKind, locator: string): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate and canonicalise a remote URL. Rejects non http/https schemes,
|
|
3
|
+
* missing hosts, and malformed inputs. Normalises a bare-host path to a
|
|
4
|
+
* single trailing slash and strips trailing slashes from longer paths.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeRemoteUrl(input) {
|
|
7
|
+
if (typeof input !== "string" || input.trim().length === 0) {
|
|
8
|
+
return { ok: false, reason: "empty" };
|
|
9
|
+
}
|
|
10
|
+
const trimmed = input.trim();
|
|
11
|
+
// Quick reject for bare `http:` / `https:` without `//` and host.
|
|
12
|
+
if (/^https?:\/?$/iu.test(trimmed)) {
|
|
13
|
+
return { ok: false, reason: "missing_host" };
|
|
14
|
+
}
|
|
15
|
+
// Must start with http:// or https:// (case-insensitive).
|
|
16
|
+
if (!/^https?:\/\//iu.test(trimmed)) {
|
|
17
|
+
return { ok: false, reason: "missing_scheme" };
|
|
18
|
+
}
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = new URL(trimmed);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return { ok: false, reason: "invalid_url" };
|
|
25
|
+
}
|
|
26
|
+
const scheme = parsed.protocol.replace(":", "").toLowerCase();
|
|
27
|
+
if (scheme !== "http" && scheme !== "https") {
|
|
28
|
+
return { ok: false, reason: "unsupported_scheme" };
|
|
29
|
+
}
|
|
30
|
+
if (parsed.hostname.length === 0) {
|
|
31
|
+
return { ok: false, reason: "missing_host" };
|
|
32
|
+
}
|
|
33
|
+
// Normalise trailing slashes: keep `/` for root paths, strip for others.
|
|
34
|
+
if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) {
|
|
35
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/u, "");
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
url: parsed.toString(),
|
|
40
|
+
scheme: scheme,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Build a canonical resource id used for findings (`rule_id`, `file_path`) and
|
|
45
|
+
* for consent prompts. For http/sse kinds the id is the URL itself (no
|
|
46
|
+
* `http:` / `sse:` prefix) to avoid the malformed `http:https://...` shape.
|
|
47
|
+
* For npm/pypi/git, the `<kind>:<locator>` prefix is preserved because those
|
|
48
|
+
* locators are not URLs and other code (e.g. `isRegistryMetadataResource`)
|
|
49
|
+
* keys on that prefix.
|
|
50
|
+
*/
|
|
51
|
+
export function buildResourceId(kind, locator) {
|
|
52
|
+
if (kind === "http" || kind === "sse") {
|
|
53
|
+
return locator;
|
|
54
|
+
}
|
|
55
|
+
return `${kind}:${locator}`;
|
|
56
|
+
}
|
package/dist/scan.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { collectLocalTextAnalysisTargets, } from "./layer3-dynamic/local-text-analysis.js";
|
|
5
|
+
import { buildResourceId, normalizeRemoteUrl } from "./layer3-dynamic/url-validation.js";
|
|
5
6
|
import { runStaticPipeline } from "./pipeline.js";
|
|
6
7
|
import { applyReportSummary } from "./report-summary.js";
|
|
7
8
|
import { parseConfigContent, parseConfigFile, } from "./layer1-discovery/config-parser.js";
|
|
@@ -486,18 +487,21 @@ function collectDeepScanResourcesFromParsed(value, filePath, resources) {
|
|
|
486
487
|
continue;
|
|
487
488
|
}
|
|
488
489
|
if (typeof config.url === "string" && isHttpLikeUrl(config.url)) {
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
490
|
+
const normalized = normalizeRemoteUrl(config.url);
|
|
491
|
+
if (normalized.ok) {
|
|
492
|
+
const kind = inferHttpKind(normalized.url);
|
|
493
|
+
const id = buildResourceId(kind, normalized.url);
|
|
494
|
+
if (!resources.has(id)) {
|
|
495
|
+
resources.set(id, {
|
|
495
496
|
id,
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
497
|
+
request: {
|
|
498
|
+
id,
|
|
499
|
+
kind,
|
|
500
|
+
locator: normalized.url,
|
|
501
|
+
},
|
|
502
|
+
commandPreview: `GET ${normalized.url} (from ${filePath} -> ${container.key}.${serverName}.url)`,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
501
505
|
}
|
|
502
506
|
}
|
|
503
507
|
if (Array.isArray(config.command) &&
|
|
@@ -524,8 +528,12 @@ function collectDeepScanResourcesFromParsed(value, filePath, resources) {
|
|
|
524
528
|
if (typeof config.url !== "string" || !isHttpLikeUrl(config.url)) {
|
|
525
529
|
return;
|
|
526
530
|
}
|
|
527
|
-
const
|
|
528
|
-
|
|
531
|
+
const normalized = normalizeRemoteUrl(config.url);
|
|
532
|
+
if (!normalized.ok) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const kind = inferHttpKind(normalized.url);
|
|
536
|
+
const id = buildResourceId(kind, normalized.url);
|
|
529
537
|
if (resources.has(id)) {
|
|
530
538
|
return;
|
|
531
539
|
}
|
|
@@ -534,9 +542,9 @@ function collectDeepScanResourcesFromParsed(value, filePath, resources) {
|
|
|
534
542
|
request: {
|
|
535
543
|
id,
|
|
536
544
|
kind,
|
|
537
|
-
locator:
|
|
545
|
+
locator: normalized.url,
|
|
538
546
|
},
|
|
539
|
-
commandPreview: `GET ${
|
|
547
|
+
commandPreview: `GET ${normalized.url} (from ${filePath} -> ${remoteArray.key}.${index}.url)`,
|
|
540
548
|
});
|
|
541
549
|
});
|
|
542
550
|
}
|