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.
Files changed (75) hide show
  1. package/README.md +13 -12
  2. package/dist/cli.d.ts +3 -1
  3. package/dist/cli.js +153 -44
  4. package/dist/commands/scan-command.d.ts +2 -1
  5. package/dist/commands/scan-command.js +6 -1
  6. package/dist/commands/trust.d.ts +28 -0
  7. package/dist/commands/trust.js +69 -0
  8. package/dist/config/inline-ignore.d.ts +10 -2
  9. package/dist/config/inline-ignore.js +5 -1
  10. package/dist/config/suppression-policy.d.ts +1 -1
  11. package/dist/config/suppression-policy.js +4 -1
  12. package/dist/config/trust.d.ts +2 -0
  13. package/dist/config/trust.js +19 -0
  14. package/dist/config.d.ts +14 -0
  15. package/dist/config.js +45 -1
  16. package/dist/content/content-bundle.d.ts +27 -0
  17. package/dist/content/content-bundle.js +73 -0
  18. package/dist/content/content-store.d.ts +27 -0
  19. package/dist/content/content-store.js +0 -0
  20. package/dist/content/content-updater.d.ts +32 -0
  21. package/dist/content/content-updater.js +111 -0
  22. package/dist/content/known-bad.d.ts +26 -0
  23. package/dist/content/known-bad.js +100 -0
  24. package/dist/content/publisher-key.d.ts +10 -0
  25. package/dist/content/publisher-key.js +10 -0
  26. package/dist/layer1-discovery/knowledge-base.js +33 -1
  27. package/dist/layer2-static/data/popular-mcp-packages.d.ts +16 -0
  28. package/dist/layer2-static/data/popular-mcp-packages.js +83 -0
  29. package/dist/layer2-static/detectors/known-bad.d.ts +22 -0
  30. package/dist/layer2-static/detectors/known-bad.js +116 -0
  31. package/dist/layer2-static/detectors/mcp-package-hygiene.d.ts +28 -0
  32. package/dist/layer2-static/detectors/mcp-package-hygiene.js +191 -0
  33. package/dist/layer2-static/detectors/rule-file.js +128 -75
  34. package/dist/layer2-static/detectors/skill-frontmatter.d.ts +6 -0
  35. package/dist/layer2-static/detectors/skill-frontmatter.js +130 -0
  36. package/dist/layer2-static/engine.d.ts +2 -0
  37. package/dist/layer2-static/engine.js +0 -0
  38. package/dist/layer2-static/rule-pack-loader.js +24 -1
  39. package/dist/layer2-static/state/scan-state.d.ts +7 -2
  40. package/dist/layer2-static/state/scan-state.js +74 -30
  41. package/dist/layer2-static/text/confusables.d.ts +7 -0
  42. package/dist/layer2-static/text/confusables.js +70 -0
  43. package/dist/layer2-static/text/edit-distance.d.ts +6 -0
  44. package/dist/layer2-static/text/edit-distance.js +33 -0
  45. package/dist/layer2-static/text/encoded-payloads.d.ts +16 -0
  46. package/dist/layer2-static/text/encoded-payloads.js +116 -0
  47. package/dist/layer2-static/text/normalize.d.ts +11 -0
  48. package/dist/layer2-static/text/normalize.js +19 -0
  49. package/dist/layer2-static/text/override-phrases.d.ts +14 -0
  50. package/dist/layer2-static/text/override-phrases.js +49 -0
  51. package/dist/layer2-static/text/threat-patterns.d.ts +33 -0
  52. package/dist/layer2-static/text/threat-patterns.js +45 -0
  53. package/dist/layer2-static/text/unicode.d.ts +33 -0
  54. package/dist/layer2-static/text/unicode.js +83 -0
  55. package/dist/layer3-dynamic/deep-resource-executor.d.ts +21 -0
  56. package/dist/layer3-dynamic/deep-resource-executor.js +73 -0
  57. package/dist/layer3-dynamic/meta-agent.js +2 -1
  58. package/dist/layer3-dynamic/registry-client.d.ts +26 -0
  59. package/dist/layer3-dynamic/registry-client.js +138 -0
  60. package/dist/layer3-dynamic/registry-findings.d.ts +7 -0
  61. package/dist/layer3-dynamic/registry-findings.js +64 -0
  62. package/dist/layer3-dynamic/tool-description-scanner.js +22 -13
  63. package/dist/layer3-dynamic/toxic-flow.d.ts +4 -0
  64. package/dist/layer3-dynamic/toxic-flow.js +41 -8
  65. package/dist/pipeline.d.ts +5 -2
  66. package/dist/pipeline.js +53 -16
  67. package/dist/report-summary.d.ts +1 -1
  68. package/dist/report-summary.js +9 -1
  69. package/dist/scan.d.ts +13 -0
  70. package/dist/scan.js +291 -17
  71. package/dist/types/finding.d.ts +14 -0
  72. package/dist/types/finding.js +14 -0
  73. package/dist/types/report.d.ts +2 -0
  74. package/dist/wrapper.js +2 -19
  75. package/package.json +1 -1
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Single source of truth for hidden/invisible Unicode classes used across
3
+ * detectors. Tag characters (U+E0000-U+E007F) are the primary real-world
4
+ * "ASCII smuggling" vector: they encode invisible ASCII that survives
5
+ * copy/paste and reaches models while remaining unseen by human reviewers.
6
+ *
7
+ * All patterns use escape sequences on purpose: this file must never
8
+ * contain literal hidden characters.
9
+ */
10
+ export const HIDDEN_UNICODE_CLASS = {
11
+ ZeroWidth: "zero-width",
12
+ Bidi: "bidi",
13
+ Tags: "tags",
14
+ VariationSelector: "variation-selector",
15
+ };
16
+ export const ZERO_WIDTH_PATTERN = /[\u200B-\u200D\u2060\uFEFF]/u;
17
+ export const BIDI_CONTROL_PATTERN = /[\u202A-\u202E\u2066-\u2069]/u;
18
+ export const TAG_CHARACTER_PATTERN = /[\u{E0000}-\u{E007F}]/u;
19
+ export const VARIATION_SELECTOR_PATTERN = /[\uFE00-\uFE0F\u{E0100}-\u{E01EF}]/u;
20
+ // Variation selectors are only suspicious in clusters: payload encodings use
21
+ // runs of them, while a single selector legitimately follows an emoji.
22
+ const CLUSTERED_VARIATION_SELECTOR_PATTERN = /[\uFE00-\uFE0F\u{E0100}-\u{E01EF}]{2,}/u;
23
+ /* eslint-disable no-misleading-character-class -- matching the combining/invisible characters themselves is the point */
24
+ const STRIP_FOR_MATCHING_PATTERN = /[\u200B-\u200D\u2060\uFEFF\u202A-\u202E\u2066-\u2069\uFE00-\uFE0F\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}]/gu;
25
+ function classifyCodePoint(codePoint) {
26
+ if ((codePoint >= 0x200b && codePoint <= 0x200d) ||
27
+ codePoint === 0x2060 ||
28
+ codePoint === 0xfeff) {
29
+ return HIDDEN_UNICODE_CLASS.ZeroWidth;
30
+ }
31
+ if ((codePoint >= 0x202a && codePoint <= 0x202e) ||
32
+ (codePoint >= 0x2066 && codePoint <= 0x2069)) {
33
+ return HIDDEN_UNICODE_CLASS.Bidi;
34
+ }
35
+ if (codePoint >= 0xe0000 && codePoint <= 0xe007f) {
36
+ return HIDDEN_UNICODE_CLASS.Tags;
37
+ }
38
+ if ((codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
39
+ (codePoint >= 0xe0100 && codePoint <= 0xe01ef)) {
40
+ return HIDDEN_UNICODE_CLASS.VariationSelector;
41
+ }
42
+ return null;
43
+ }
44
+ /**
45
+ * Find hidden Unicode characters worth flagging. Variation selectors are
46
+ * reported only when clustered (two or more in a row).
47
+ */
48
+ export function findHiddenUnicode(text) {
49
+ const matches = [];
50
+ const clusteredVariationRanges = [];
51
+ const clusterRegex = new RegExp(CLUSTERED_VARIATION_SELECTOR_PATTERN, "gu");
52
+ let clusterMatch = clusterRegex.exec(text);
53
+ while (clusterMatch) {
54
+ clusteredVariationRanges.push({
55
+ start: clusterMatch.index,
56
+ end: clusterMatch.index + clusterMatch[0].length,
57
+ });
58
+ clusterMatch = clusterRegex.exec(text);
59
+ }
60
+ let index = 0;
61
+ for (const char of text) {
62
+ const codePoint = char.codePointAt(0) ?? 0;
63
+ const classified = classifyCodePoint(codePoint);
64
+ if (classified === HIDDEN_UNICODE_CLASS.VariationSelector) {
65
+ const clustered = clusteredVariationRanges.some((range) => index >= range.start && index < range.end);
66
+ if (clustered) {
67
+ matches.push({ index, codePoint, class: classified });
68
+ }
69
+ }
70
+ else if (classified) {
71
+ matches.push({ index, codePoint, class: classified });
72
+ }
73
+ index += char.length;
74
+ }
75
+ return matches;
76
+ }
77
+ /** Remove every hidden character class so pattern matching sees the visible text. */
78
+ export function stripHiddenCharacters(text) {
79
+ return text.replace(STRIP_FOR_MATCHING_PATTERN, "");
80
+ }
81
+ export function hasHiddenUnicodeClass(text, cls) {
82
+ return findHiddenUnicode(text).some((match) => match.class === cls);
83
+ }
@@ -0,0 +1,21 @@
1
+ import type { RuntimeMode } from "../config.js";
2
+ import type { DeepScanResource } from "../pipeline.js";
3
+ import { type RegistryClientDeps } from "./registry-client.js";
4
+ import type { ResourceFetchResult } from "./resource-fetcher.js";
5
+ export interface DeepResourceExecutionContext {
6
+ runtimeMode?: RuntimeMode;
7
+ }
8
+ /**
9
+ * Default deep-resource executor.
10
+ *
11
+ * URL resources (http/sse) are never fetched: connecting to endpoints found
12
+ * in scanned config files is a security risk (crafted responses, SSRF, IP
13
+ * logging), so the URL is recorded as metadata for the agent to analyze.
14
+ *
15
+ * npm/pypi package resources are different: their metadata comes from
16
+ * pinned, well-known registry hosts, not from attacker-chosen endpoints.
17
+ * When the runtime mode is "online" (and the per-resource consent the
18
+ * caller already collected), the hardened registry client fetches a typed
19
+ * metadata subset. Offline (the default) stays record-only.
20
+ */
21
+ export declare function createDeepResourceExecutor(deps?: RegistryClientDeps): (resource: DeepScanResource, context?: DeepResourceExecutionContext) => Promise<ResourceFetchResult>;
@@ -0,0 +1,73 @@
1
+ import { fetchRegistryMetadata, REGISTRY_KIND, } from "./registry-client.js";
2
+ function registryKindFor(resource) {
3
+ if (resource.request.kind === "npm") {
4
+ return REGISTRY_KIND.Npm;
5
+ }
6
+ if (resource.request.kind === "pypi") {
7
+ return REGISTRY_KIND.Pypi;
8
+ }
9
+ return null;
10
+ }
11
+ function recordOnlyResult(resource) {
12
+ return {
13
+ status: "ok",
14
+ attempts: 0,
15
+ elapsedMs: 0,
16
+ metadata: {
17
+ resource_id: resource.id,
18
+ resource_kind: resource.request.kind,
19
+ resource_url: resource.request.locator,
20
+ note: "URL recorded for analysis without making outbound connections.",
21
+ },
22
+ };
23
+ }
24
+ function classifyError(error) {
25
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
26
+ if (message.includes("timeout") || message.includes("abort")) {
27
+ return "timeout";
28
+ }
29
+ return "network_error";
30
+ }
31
+ /**
32
+ * Default deep-resource executor.
33
+ *
34
+ * URL resources (http/sse) are never fetched: connecting to endpoints found
35
+ * in scanned config files is a security risk (crafted responses, SSRF, IP
36
+ * logging), so the URL is recorded as metadata for the agent to analyze.
37
+ *
38
+ * npm/pypi package resources are different: their metadata comes from
39
+ * pinned, well-known registry hosts, not from attacker-chosen endpoints.
40
+ * When the runtime mode is "online" (and the per-resource consent the
41
+ * caller already collected), the hardened registry client fetches a typed
42
+ * metadata subset. Offline (the default) stays record-only.
43
+ */
44
+ export function createDeepResourceExecutor(deps = {}) {
45
+ return async (resource, context) => {
46
+ const registryKind = registryKindFor(resource);
47
+ if (!registryKind || context?.runtimeMode !== "online") {
48
+ return recordOnlyResult(resource);
49
+ }
50
+ const startedAt = Date.now();
51
+ try {
52
+ const registry = await fetchRegistryMetadata(registryKind, resource.request.locator, deps);
53
+ return {
54
+ status: "ok",
55
+ attempts: 1,
56
+ elapsedMs: Date.now() - startedAt,
57
+ metadata: {
58
+ resource_id: resource.id,
59
+ resource_kind: resource.request.kind,
60
+ registry,
61
+ },
62
+ };
63
+ }
64
+ catch (error) {
65
+ return {
66
+ status: classifyError(error),
67
+ attempts: 1,
68
+ elapsedMs: Date.now() - startedAt,
69
+ error: error instanceof Error ? error.message : String(error),
70
+ };
71
+ }
72
+ };
73
+ }
@@ -1,12 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { stripHiddenCharacters } from "../layer2-static/text/unicode.js";
4
5
  const templatesRoot = join(dirname(fileURLToPath(import.meta.url)), "prompt-templates");
5
6
  function readTemplate(name) {
6
7
  return readFileSync(join(templatesRoot, name), "utf8");
7
8
  }
8
9
  function normalize(value) {
9
- return value.replace(/[\u200B-\u200D\u2060\uFEFF]/gu, "").trim();
10
+ return stripHiddenCharacters(value).trim();
10
11
  }
11
12
  export function buildSecurityAnalysisPrompt(input) {
12
13
  return readTemplate("security-analysis.md")
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Hardened metadata client for package registries. Constraints, each tested:
3
+ * pinned HTTPS hosts only, redirects rejected, 5s timeout, 1 MiB streamed
4
+ * body cap, JSON only. Returns a typed subset — never the raw registry blob.
5
+ */
6
+ export declare const REGISTRY_KIND: {
7
+ readonly Npm: "npm";
8
+ readonly Pypi: "pypi";
9
+ };
10
+ export type RegistryKind = (typeof REGISTRY_KIND)[keyof typeof REGISTRY_KIND];
11
+ export interface RegistryPackageMetadata {
12
+ kind: RegistryKind;
13
+ name: string;
14
+ latestVersion: string | null;
15
+ firstPublishedAt: string | null;
16
+ latestPublishedAt: string | null;
17
+ maintainerCount: number | null;
18
+ deprecated: string | null;
19
+ /** npm lifecycle scripts that run code on install (preinstall/install/postinstall). */
20
+ installScripts: string[];
21
+ repositoryUrl: string | null;
22
+ }
23
+ export interface RegistryClientDeps {
24
+ fetchImpl?: typeof fetch;
25
+ }
26
+ export declare function fetchRegistryMetadata(kind: RegistryKind, name: string, deps?: RegistryClientDeps): Promise<RegistryPackageMetadata>;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Hardened metadata client for package registries. Constraints, each tested:
3
+ * pinned HTTPS hosts only, redirects rejected, 5s timeout, 1 MiB streamed
4
+ * body cap, JSON only. Returns a typed subset — never the raw registry blob.
5
+ */
6
+ export const REGISTRY_KIND = {
7
+ Npm: "npm",
8
+ Pypi: "pypi",
9
+ };
10
+ const ALLOWED_HOSTS = {
11
+ npm: "registry.npmjs.org",
12
+ pypi: "pypi.org",
13
+ };
14
+ const TIMEOUT_MS = 5000;
15
+ const MAX_BODY_BYTES = 1024 * 1024;
16
+ function isRecord(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ function endpointFor(kind, name) {
20
+ if (kind === REGISTRY_KIND.Npm) {
21
+ const encoded = name.startsWith("@") ? name.replace(/\//gu, "%2f") : name;
22
+ return `https://${ALLOWED_HOSTS.npm}/${encoded}`;
23
+ }
24
+ return `https://${ALLOWED_HOSTS.pypi}/pypi/${name}/json`;
25
+ }
26
+ function assertAllowedEndpoint(kind, endpoint) {
27
+ const url = new URL(endpoint);
28
+ if (url.protocol !== "https:") {
29
+ throw new Error(`Registry endpoint must use https: ${endpoint}`);
30
+ }
31
+ if (url.hostname !== ALLOWED_HOSTS[kind]) {
32
+ throw new Error(`Registry host not allowlisted: ${url.hostname}`);
33
+ }
34
+ }
35
+ async function readBodyCapped(response) {
36
+ const contentLength = response.headers.get("content-length");
37
+ if (contentLength && Number.parseInt(contentLength, 10) > MAX_BODY_BYTES) {
38
+ throw new Error(`Registry response exceeds ${MAX_BODY_BYTES} bytes`);
39
+ }
40
+ const body = response.body;
41
+ if (!body) {
42
+ const text = await response.text();
43
+ if (text.length > MAX_BODY_BYTES) {
44
+ throw new Error(`Registry response exceeds ${MAX_BODY_BYTES} bytes`);
45
+ }
46
+ return text;
47
+ }
48
+ const reader = body.getReader();
49
+ const chunks = [];
50
+ let total = 0;
51
+ for (;;) {
52
+ const { done, value } = await reader.read();
53
+ if (done) {
54
+ break;
55
+ }
56
+ total += value.byteLength;
57
+ if (total > MAX_BODY_BYTES) {
58
+ await reader.cancel();
59
+ throw new Error(`Registry response exceeds ${MAX_BODY_BYTES} bytes`);
60
+ }
61
+ chunks.push(value);
62
+ }
63
+ return Buffer.concat(chunks).toString("utf8");
64
+ }
65
+ async function fetchRegistryJson(kind, name, deps) {
66
+ const endpoint = endpointFor(kind, name);
67
+ assertAllowedEndpoint(kind, endpoint);
68
+ const fetchImpl = deps.fetchImpl ?? fetch;
69
+ const response = await fetchImpl(endpoint, {
70
+ redirect: "error",
71
+ signal: AbortSignal.timeout(TIMEOUT_MS),
72
+ headers: { accept: "application/json" },
73
+ });
74
+ if (!response.ok) {
75
+ throw new Error(`Registry responded with HTTP ${response.status} for ${name}`);
76
+ }
77
+ const raw = await readBodyCapped(response);
78
+ const parsed = JSON.parse(raw);
79
+ if (!isRecord(parsed)) {
80
+ throw new Error(`Registry returned a non-object document for ${name}`);
81
+ }
82
+ return parsed;
83
+ }
84
+ const NPM_INSTALL_SCRIPT_KEYS = ["preinstall", "install", "postinstall"];
85
+ function parseNpmMetadata(name, doc) {
86
+ const distTags = isRecord(doc["dist-tags"]) ? doc["dist-tags"] : {};
87
+ const latestVersion = typeof distTags.latest === "string" ? distTags.latest : null;
88
+ const time = isRecord(doc.time) ? doc.time : {};
89
+ const versions = isRecord(doc.versions) ? doc.versions : {};
90
+ const latestDoc = latestVersion && isRecord(versions[latestVersion]) ? versions[latestVersion] : null;
91
+ const scripts = latestDoc && isRecord(latestDoc.scripts) ? latestDoc.scripts : {};
92
+ const maintainers = Array.isArray(doc.maintainers) ? doc.maintainers : null;
93
+ const repository = latestDoc && isRecord(latestDoc.repository)
94
+ ? latestDoc.repository
95
+ : isRecord(doc.repository)
96
+ ? doc.repository
97
+ : null;
98
+ return {
99
+ kind: REGISTRY_KIND.Npm,
100
+ name,
101
+ latestVersion,
102
+ firstPublishedAt: typeof time.created === "string" ? time.created : null,
103
+ latestPublishedAt: latestVersion && typeof time[latestVersion] === "string"
104
+ ? time[latestVersion]
105
+ : null,
106
+ maintainerCount: maintainers ? maintainers.length : null,
107
+ deprecated: latestDoc && typeof latestDoc.deprecated === "string" ? latestDoc.deprecated : null,
108
+ installScripts: NPM_INSTALL_SCRIPT_KEYS.filter((key) => typeof scripts[key] === "string"),
109
+ repositoryUrl: repository && typeof repository.url === "string" ? repository.url : null,
110
+ };
111
+ }
112
+ function parsePypiMetadata(name, doc) {
113
+ const info = isRecord(doc.info) ? doc.info : {};
114
+ const urls = Array.isArray(doc.urls) ? doc.urls : [];
115
+ const firstUpload = urls.find((entry) => isRecord(entry));
116
+ const projectUrls = isRecord(info.project_urls) ? info.project_urls : {};
117
+ return {
118
+ kind: REGISTRY_KIND.Pypi,
119
+ name,
120
+ latestVersion: typeof info.version === "string" ? info.version : null,
121
+ firstPublishedAt: null,
122
+ latestPublishedAt: firstUpload && typeof firstUpload.upload_time_iso_8601 === "string"
123
+ ? firstUpload.upload_time_iso_8601
124
+ : null,
125
+ maintainerCount: null,
126
+ deprecated: info.yanked === true ? "yanked" : null,
127
+ installScripts: [],
128
+ repositoryUrl: typeof projectUrls.Source === "string"
129
+ ? projectUrls.Source
130
+ : typeof info.home_page === "string" && info.home_page.length > 0
131
+ ? info.home_page
132
+ : null,
133
+ };
134
+ }
135
+ export async function fetchRegistryMetadata(kind, name, deps = {}) {
136
+ const doc = await fetchRegistryJson(kind, name, deps);
137
+ return kind === REGISTRY_KIND.Npm ? parseNpmMetadata(name, doc) : parsePypiMetadata(name, doc);
138
+ }
@@ -0,0 +1,7 @@
1
+ import type { Finding } from "../types/finding.js";
2
+ export interface RegistryHeuristicsOptions {
3
+ recentPublishDays?: number;
4
+ now?: () => number;
5
+ }
6
+ /** Derive supply-chain findings from registry metadata fetched by the deep scan. */
7
+ export declare function deriveRegistryFindings(resourceId: string, metadata: unknown, options?: RegistryHeuristicsOptions): Finding[];
@@ -0,0 +1,64 @@
1
+ const DEFAULT_RECENT_PUBLISH_DAYS = 30;
2
+ const DAY_MS = 24 * 60 * 60 * 1000;
3
+ function isRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ function extractRegistryMetadata(metadata) {
7
+ if (!isRecord(metadata) || !isRecord(metadata.registry)) {
8
+ return null;
9
+ }
10
+ const registry = metadata.registry;
11
+ if (typeof registry.kind !== "string" || typeof registry.name !== "string") {
12
+ return null;
13
+ }
14
+ return registry;
15
+ }
16
+ function makeFinding(resourceId, registry, ruleId, severity, description) {
17
+ return {
18
+ rule_id: ruleId,
19
+ finding_id: `${ruleId.toUpperCase().replaceAll("-", "_")}-${resourceId}`,
20
+ severity,
21
+ category: "COMMAND_EXEC",
22
+ layer: "L3",
23
+ file_path: resourceId,
24
+ location: { field: `registry.${registry.name}` },
25
+ description,
26
+ affected_tools: [],
27
+ cve: null,
28
+ owasp: ["ASI05"],
29
+ cwe: "CWE-829",
30
+ confidence: "HIGH",
31
+ fixable: false,
32
+ remediation_actions: [],
33
+ metadata: {
34
+ sources: [resourceId],
35
+ risk_tags: ["registry", "supply-chain"],
36
+ origin: "registry-findings",
37
+ },
38
+ suppressed: false,
39
+ };
40
+ }
41
+ /** Derive supply-chain findings from registry metadata fetched by the deep scan. */
42
+ export function deriveRegistryFindings(resourceId, metadata, options = {}) {
43
+ const registry = extractRegistryMetadata(metadata);
44
+ if (!registry) {
45
+ return [];
46
+ }
47
+ const findings = [];
48
+ if (registry.installScripts.length > 0) {
49
+ findings.push(makeFinding(resourceId, registry, "package-install-scripts", "HIGH", `Package "${registry.name}" declares npm lifecycle scripts (${registry.installScripts.join(", ")}) that execute code at install time.`));
50
+ }
51
+ if (registry.deprecated) {
52
+ findings.push(makeFinding(resourceId, registry, "package-deprecated", "MEDIUM", `Package "${registry.name}" is marked deprecated/yanked by its registry: ${registry.deprecated}`));
53
+ }
54
+ const recentDays = options.recentPublishDays ?? DEFAULT_RECENT_PUBLISH_DAYS;
55
+ if (registry.latestPublishedAt) {
56
+ const publishedAt = Date.parse(registry.latestPublishedAt);
57
+ const now = options.now ? options.now() : Date.now();
58
+ if (Number.isFinite(publishedAt) && now - publishedAt < recentDays * DAY_MS) {
59
+ findings.push(makeFinding(resourceId, registry, "package-recently-published", "MEDIUM", `Package "${registry.name}" version ${registry.latestVersion ?? "?"} was published within ` +
60
+ `the last ${recentDays} days. Fresh releases are the window for compromised-package attacks.`));
61
+ }
62
+ }
63
+ return findings;
64
+ }
@@ -1,8 +1,7 @@
1
- const HIDDEN_UNICODE = /[\u200B-\u200D\u2060\uFEFF]/u;
2
- const SENSITIVE_FILE_PATTERN = /(~\/\.ssh|~\/\.aws|id_rsa|\.env|credentials|\.git-credentials)/iu;
3
- const EXFIL_PATTERN = /(send .*https?:\/\/|upload|webhook|post to|exfiltrat)/iu;
4
- const OVERRIDE_PATTERN = /(ignore previous instructions|bypass safety|disable guardrails)/iu;
5
- const EXEC_PATTERN = /(run command|execute shell|bash -c|sh -c|powershell)/iu;
1
+ import { scanEncodedPayloads } from "../layer2-static/text/encoded-payloads.js";
2
+ import { normalizeForMatching } from "../layer2-static/text/normalize.js";
3
+ import { COMMAND_EXECUTION_PATTERN, EXFIL_PATTERN, SENSITIVE_FILE_PATTERN, findOverridePhrase, } from "../layer2-static/text/threat-patterns.js";
4
+ import { HIDDEN_UNICODE_CLASS, findHiddenUnicode } from "../layer2-static/text/unicode.js";
6
5
  function makeFinding(input, tool, ruleId, severity, description) {
7
6
  return {
8
7
  rule_id: ruleId,
@@ -27,24 +26,34 @@ export function scanToolDescriptions(input) {
27
26
  const findings = [];
28
27
  for (const tool of input.tools) {
29
28
  const text = tool.description;
30
- const hasSensitive = SENSITIVE_FILE_PATTERN.test(text);
31
- const hasExfil = EXFIL_PATTERN.test(text);
32
- const hasOverride = OVERRIDE_PATTERN.test(text);
33
- const hasExec = EXEC_PATTERN.test(text);
34
- const hasUnicode = input.unicodeAnalysis === false ? false : HIDDEN_UNICODE.test(text);
29
+ const normalized = normalizeForMatching(text);
30
+ const hasSensitive = SENSITIVE_FILE_PATTERN.test(normalized);
31
+ const hasExfil = EXFIL_PATTERN.test(normalized);
32
+ const overrideMatch = findOverridePhrase(normalized);
33
+ const hasExec = COMMAND_EXECUTION_PATTERN.test(normalized);
34
+ const hiddenMatches = input.unicodeAnalysis === false ? [] : findHiddenUnicode(text);
35
+ const tagMatches = hiddenMatches.filter((match) => match.class === HIDDEN_UNICODE_CLASS.Tags);
36
+ const otherHiddenMatches = hiddenMatches.filter((match) => match.class !== HIDDEN_UNICODE_CLASS.Tags);
35
37
  const isLong = text.length > 1000;
36
38
  if (hasSensitive && hasExfil) {
37
39
  findings.push(makeFinding(input, tool, "tool-description-sensitive-exfiltration", "CRITICAL", `Tool description references sensitive file access with exfiltration behavior: ${tool.name}`));
38
40
  }
39
- if (hasOverride) {
40
- findings.push(makeFinding(input, tool, "tool-description-instruction-override", "HIGH", `Tool description contains instruction-override language: ${tool.name}`));
41
+ if (overrideMatch) {
42
+ findings.push(makeFinding(input, tool, "tool-description-instruction-override", "HIGH", `Tool description contains instruction-override language ("${overrideMatch.phrase}"): ${tool.name}`));
41
43
  }
42
44
  if (hasExec) {
43
45
  findings.push(makeFinding(input, tool, "tool-description-command-execution", "HIGH", `Tool description encourages command execution patterns: ${tool.name}`));
44
46
  }
45
- if (hasUnicode) {
47
+ if (otherHiddenMatches.length > 0) {
46
48
  findings.push(makeFinding(input, tool, "tool-description-hidden-unicode", "MEDIUM", `Tool description includes hidden Unicode characters: ${tool.name}`));
47
49
  }
50
+ if (tagMatches.length > 0) {
51
+ findings.push(makeFinding(input, tool, "tool-description-hidden-unicode-tags", "HIGH", `Tool description includes ${tagMatches.length} Unicode tag character(s) ` +
52
+ `(U+E0000-U+E007F, ASCII smuggling): ${tool.name}`));
53
+ }
54
+ for (const payload of scanEncodedPayloads(text)) {
55
+ findings.push(makeFinding(input, tool, "tool-description-encoded-payload", payload.matchesRemoteShell ? "CRITICAL" : "HIGH", `Tool description contains a ${payload.kind}-encoded payload with hidden instructions: ${tool.name}`));
56
+ }
48
57
  if (isLong) {
49
58
  findings.push(makeFinding(input, tool, "tool-description-unusually-long", "MEDIUM", `Tool description is unusually long and may hide instructions: ${tool.name}`));
50
59
  }
@@ -8,5 +8,9 @@ export interface ToxicFlowInput {
8
8
  scopeId: string;
9
9
  tools: ToxicFlowTool[];
10
10
  knownClassifications?: Record<string, ToxicToolClass[]>;
11
+ /** Maps tool name to its origin server, for cross-server workspace analysis. */
12
+ origins?: Record<string, string>;
13
+ /** Only report chains spanning at least two distinct origins. */
14
+ crossOriginOnly?: boolean;
11
15
  }
12
16
  export declare function detectToxicFlows(input: ToxicFlowInput): Finding[];
@@ -1,3 +1,4 @@
1
+ const MAX_REPORTED_CHAINS = 10;
1
2
  function classifyByDescription(description) {
2
3
  const classes = new Set();
3
4
  const text = description.toLowerCase();
@@ -25,16 +26,20 @@ function classifyTools(input) {
25
26
  return { tool, classes };
26
27
  });
27
28
  }
29
+ function describeTool(input, toolName) {
30
+ const origin = input.origins?.[toolName];
31
+ return origin ? `${toolName} (server: ${origin})` : toolName;
32
+ }
28
33
  function makeFinding(input, sourceTool, sensitiveTool, sinkTool) {
29
34
  return {
30
35
  rule_id: "toxic-flow-chain-detected",
31
- finding_id: `TOXIC_FLOW-${sourceTool}-${sensitiveTool}-${sinkTool}`,
36
+ finding_id: `TOXIC_FLOW-${input.scopeId}-${sourceTool}-${sensitiveTool}-${sinkTool}`,
32
37
  severity: "CRITICAL",
33
38
  category: "TOXIC_FLOW",
34
39
  layer: "L3",
35
40
  file_path: input.scopeId,
36
41
  location: { field: "tool_interaction_graph" },
37
- description: `Toxic Flow detected: ${sourceTool} -> ${sensitiveTool} -> ${sinkTool}. This chain can propagate untrusted input into sensitive data access and external exfiltration.`,
42
+ description: `Toxic Flow detected: ${describeTool(input, sourceTool)} -> ${describeTool(input, sensitiveTool)} -> ${describeTool(input, sinkTool)}. This chain can propagate untrusted input into sensitive data access and external exfiltration.`,
38
43
  affected_tools: [],
39
44
  cve: null,
40
45
  owasp: ["ASI08"],
@@ -51,13 +56,41 @@ function makeFinding(input, sourceTool, sensitiveTool, sinkTool) {
51
56
  suppressed: false,
52
57
  };
53
58
  }
59
+ function distinctOriginCount(input, toolNames) {
60
+ const origins = new Set();
61
+ for (const name of toolNames) {
62
+ const origin = input.origins?.[name];
63
+ if (origin) {
64
+ origins.add(origin);
65
+ }
66
+ }
67
+ return origins.size;
68
+ }
54
69
  export function detectToxicFlows(input) {
55
70
  const classified = classifyTools(input);
56
- const untrusted = classified.find((entry) => entry.classes.has("untrusted_input"))?.tool.name;
57
- const sensitive = classified.find((entry) => entry.classes.has("sensitive_access"))?.tool.name;
58
- const exfil = classified.find((entry) => entry.classes.has("exfiltration_sink"))?.tool.name;
59
- if (!untrusted || !sensitive || !exfil) {
60
- return [];
71
+ const untrusted = classified
72
+ .filter((entry) => entry.classes.has("untrusted_input"))
73
+ .map((entry) => entry.tool.name);
74
+ const sensitive = classified
75
+ .filter((entry) => entry.classes.has("sensitive_access"))
76
+ .map((entry) => entry.tool.name);
77
+ const exfil = classified
78
+ .filter((entry) => entry.classes.has("exfiltration_sink"))
79
+ .map((entry) => entry.tool.name);
80
+ const findings = [];
81
+ for (const source of untrusted) {
82
+ for (const sensitiveTool of sensitive) {
83
+ for (const sink of exfil) {
84
+ if (findings.length >= MAX_REPORTED_CHAINS) {
85
+ return findings;
86
+ }
87
+ if (input.crossOriginOnly &&
88
+ distinctOriginCount(input, [source, sensitiveTool, sink]) < 2) {
89
+ continue;
90
+ }
91
+ findings.push(makeFinding(input, source, sensitiveTool, sink));
92
+ }
93
+ }
61
94
  }
62
- return [makeFinding(input, untrusted, sensitive, exfil)];
95
+ return findings;
63
96
  }
@@ -3,6 +3,7 @@ import { type CodeGateReport } from "./types/report.js";
3
3
  import type { GitHookEntry } from "./layer2-static/detectors/git-hooks.js";
4
4
  import type { SymlinkEscapeEntry } from "./layer2-static/detectors/symlink.js";
5
5
  import type { ResourceFetchResult, ResourceRequest } from "./layer3-dynamic/resource-fetcher.js";
6
+ import { type RegistryHeuristicsOptions } from "./layer3-dynamic/registry-findings.js";
6
7
  import type { Finding } from "./types/finding.js";
7
8
  export interface StaticPipelineInput {
8
9
  version: string;
@@ -27,8 +28,10 @@ export interface DeepScanOutcome {
27
28
  result?: ResourceFetchResult;
28
29
  }
29
30
  export declare function runStaticPipeline(input: StaticPipelineInput): Promise<CodeGateReport>;
30
- export declare function layer3OutcomesToFindings(outcomes: DeepScanOutcome[], options?: {
31
+ export interface Layer3FindingOptions {
31
32
  unicodeAnalysis?: boolean;
32
- }): Finding[];
33
+ registryHeuristics?: RegistryHeuristicsOptions;
34
+ }
35
+ export declare function layer3OutcomesToFindings(outcomes: DeepScanOutcome[], options?: Layer3FindingOptions): Finding[];
33
36
  export declare function mergeLayer3Findings(baseReport: CodeGateReport, layer3Findings: Finding[]): CodeGateReport;
34
37
  export declare function runDeepScanWithConsent(resources: DeepScanResource[], requestConsent: (resource: DeepScanResource) => Promise<boolean> | boolean, execute: (resource: DeepScanResource) => Promise<ResourceFetchResult>): Promise<DeepScanOutcome[]>;