codegate-ai 0.16.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/cli.d.ts +3 -1
  2. package/dist/cli.js +153 -44
  3. package/dist/commands/scan-command.d.ts +2 -1
  4. package/dist/commands/scan-command.js +6 -1
  5. package/dist/commands/trust.d.ts +28 -0
  6. package/dist/commands/trust.js +69 -0
  7. package/dist/config/inline-ignore.d.ts +10 -2
  8. package/dist/config/inline-ignore.js +5 -1
  9. package/dist/config/suppression-policy.d.ts +1 -1
  10. package/dist/config/suppression-policy.js +4 -1
  11. package/dist/config/trust.d.ts +2 -0
  12. package/dist/config/trust.js +19 -0
  13. package/dist/config.d.ts +14 -0
  14. package/dist/config.js +45 -1
  15. package/dist/content/content-bundle.d.ts +27 -0
  16. package/dist/content/content-bundle.js +73 -0
  17. package/dist/content/content-store.d.ts +27 -0
  18. package/dist/content/content-store.js +0 -0
  19. package/dist/content/content-updater.d.ts +32 -0
  20. package/dist/content/content-updater.js +111 -0
  21. package/dist/content/known-bad.d.ts +26 -0
  22. package/dist/content/known-bad.js +100 -0
  23. package/dist/content/publisher-key.d.ts +10 -0
  24. package/dist/content/publisher-key.js +10 -0
  25. package/dist/layer1-discovery/knowledge-base.js +33 -1
  26. package/dist/layer2-static/data/popular-mcp-packages.d.ts +16 -0
  27. package/dist/layer2-static/data/popular-mcp-packages.js +83 -0
  28. package/dist/layer2-static/detectors/known-bad.d.ts +22 -0
  29. package/dist/layer2-static/detectors/known-bad.js +116 -0
  30. package/dist/layer2-static/detectors/mcp-package-hygiene.d.ts +28 -0
  31. package/dist/layer2-static/detectors/mcp-package-hygiene.js +191 -0
  32. package/dist/layer2-static/detectors/rule-file.js +128 -75
  33. package/dist/layer2-static/detectors/skill-frontmatter.d.ts +6 -0
  34. package/dist/layer2-static/detectors/skill-frontmatter.js +130 -0
  35. package/dist/layer2-static/engine.d.ts +2 -0
  36. package/dist/layer2-static/engine.js +0 -0
  37. package/dist/layer2-static/rule-pack-loader.js +24 -1
  38. package/dist/layer2-static/state/scan-state.d.ts +7 -2
  39. package/dist/layer2-static/state/scan-state.js +74 -30
  40. package/dist/layer2-static/text/confusables.d.ts +7 -0
  41. package/dist/layer2-static/text/confusables.js +70 -0
  42. package/dist/layer2-static/text/edit-distance.d.ts +6 -0
  43. package/dist/layer2-static/text/edit-distance.js +33 -0
  44. package/dist/layer2-static/text/encoded-payloads.d.ts +16 -0
  45. package/dist/layer2-static/text/encoded-payloads.js +116 -0
  46. package/dist/layer2-static/text/normalize.d.ts +11 -0
  47. package/dist/layer2-static/text/normalize.js +19 -0
  48. package/dist/layer2-static/text/override-phrases.d.ts +14 -0
  49. package/dist/layer2-static/text/override-phrases.js +49 -0
  50. package/dist/layer2-static/text/threat-patterns.d.ts +33 -0
  51. package/dist/layer2-static/text/threat-patterns.js +45 -0
  52. package/dist/layer2-static/text/unicode.d.ts +33 -0
  53. package/dist/layer2-static/text/unicode.js +83 -0
  54. package/dist/layer3-dynamic/deep-resource-executor.d.ts +21 -0
  55. package/dist/layer3-dynamic/deep-resource-executor.js +73 -0
  56. package/dist/layer3-dynamic/meta-agent.js +2 -1
  57. package/dist/layer3-dynamic/registry-client.d.ts +26 -0
  58. package/dist/layer3-dynamic/registry-client.js +138 -0
  59. package/dist/layer3-dynamic/registry-findings.d.ts +7 -0
  60. package/dist/layer3-dynamic/registry-findings.js +64 -0
  61. package/dist/layer3-dynamic/tool-description-scanner.js +22 -13
  62. package/dist/layer3-dynamic/toxic-flow.d.ts +4 -0
  63. package/dist/layer3-dynamic/toxic-flow.js +41 -8
  64. package/dist/pipeline.d.ts +5 -2
  65. package/dist/pipeline.js +53 -16
  66. package/dist/report-summary.d.ts +1 -1
  67. package/dist/report-summary.js +9 -1
  68. package/dist/scan.d.ts +13 -0
  69. package/dist/scan.js +291 -17
  70. package/dist/types/finding.d.ts +14 -0
  71. package/dist/types/finding.js +14 -0
  72. package/dist/types/report.d.ts +2 -0
  73. package/dist/wrapper.js +2 -19
  74. package/package.json +1 -1
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Instruction-override phrases matched against normalized (NFKC, hidden
3
+ * characters stripped, confusable-folded, lowercased) text. This is the
4
+ * high-signal literal tier; semantic analysis belongs to the Layer 3
5
+ * meta-agent. Data-driven so the content feed can extend it.
6
+ */
7
+ export interface OverridePhrase {
8
+ phrase: string;
9
+ language: string;
10
+ }
11
+ export declare const OVERRIDE_PHRASES: readonly OverridePhrase[];
12
+ export declare function resetActiveOverridePhrasesCache(): void;
13
+ /** Bundled phrases plus any extras delivered by the verified content feed. */
14
+ export declare function activeOverridePhrases(): readonly OverridePhrase[];
@@ -0,0 +1,49 @@
1
+ import { loadActiveContentBundle } from "../../content/content-store.js";
2
+ export const OVERRIDE_PHRASES = [
3
+ { phrase: "ignore previous instructions", language: "en" },
4
+ { phrase: "ignore all previous instructions", language: "en" },
5
+ { phrase: "disregard previous instructions", language: "en" },
6
+ { phrase: "disregard prior instructions", language: "en" },
7
+ { phrase: "skip permissions", language: "en" },
8
+ { phrase: "bypass permissions", language: "en" },
9
+ { phrase: "bypass safety", language: "en" },
10
+ { phrase: "disable guardrails", language: "en" },
11
+ { phrase: "ignora las instrucciones anteriores", language: "es" },
12
+ { phrase: "ignore las instrucciones anteriores", language: "es" },
13
+ { phrase: "ignore as instrucoes anteriores", language: "pt" },
14
+ { phrase: "ignore as instruções anteriores", language: "pt" },
15
+ { phrase: "ignore les instructions precedentes", language: "fr" },
16
+ { phrase: "ignore les instructions précédentes", language: "fr" },
17
+ { phrase: "ignorez les instructions précédentes", language: "fr" },
18
+ { phrase: "ignoriere alle vorherigen anweisungen", language: "de" },
19
+ { phrase: "ignoriere die vorherigen anweisungen", language: "de" },
20
+ { phrase: "игнорируй предыдущие инструкции", language: "ru" },
21
+ { phrase: "игнорируйте предыдущие инструкции", language: "ru" },
22
+ { phrase: "忽略之前的指令", language: "zh" },
23
+ { phrase: "忽略以上指令", language: "zh" },
24
+ { phrase: "忽略先前的指示", language: "zh" },
25
+ { phrase: "以前の指示を無視して", language: "ja" },
26
+ { phrase: "これまでの指示を無視", language: "ja" },
27
+ ];
28
+ let cachedActivePhrases = null;
29
+ export function resetActiveOverridePhrasesCache() {
30
+ cachedActivePhrases = null;
31
+ }
32
+ /** Bundled phrases plus any extras delivered by the verified content feed. */
33
+ export function activeOverridePhrases() {
34
+ if (cachedActivePhrases) {
35
+ return cachedActivePhrases;
36
+ }
37
+ let feedPhrases;
38
+ try {
39
+ const bundle = loadActiveContentBundle();
40
+ feedPhrases = (bundle?.override_phrases ?? []).filter((entry) => typeof entry?.phrase === "string" &&
41
+ entry.phrase.length > 0 &&
42
+ typeof entry?.language === "string");
43
+ }
44
+ catch {
45
+ feedPhrases = [];
46
+ }
47
+ cachedActivePhrases = [...OVERRIDE_PHRASES, ...feedPhrases];
48
+ return cachedActivePhrases;
49
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared threat patterns for instruction/rule-file text and MCP tool
3
+ * descriptions. Callers are expected to match against text run through
4
+ * normalizeForMatching() so hidden-character and homoglyph obfuscation
5
+ * cannot dodge these literals.
6
+ */
7
+ export declare const NEGATION_PATTERN: RegExp;
8
+ export declare const SENSITIVE_READ_PATTERN: RegExp;
9
+ export declare const SENSITIVE_FILE_PATTERN: RegExp;
10
+ export declare const OUTBOUND_TRANSFER_PATTERN: RegExp;
11
+ export declare const EXFIL_PATTERN: RegExp;
12
+ export declare const COMMAND_EXECUTION_PATTERN: RegExp;
13
+ export declare const REMOTE_SHELL_PATTERN: RegExp;
14
+ export declare const SUSPICIOUS_LONG_LINE_PATTERN: RegExp;
15
+ export declare const HTML_COMMENT_PATTERN: RegExp;
16
+ export declare const COMMENT_PAYLOAD_PATTERN: RegExp;
17
+ export declare const COOKIE_EXPORT_PATTERN: RegExp;
18
+ export declare const SESSION_SHARE_PATTERN: RegExp;
19
+ export declare const PROFILE_SYNC_PATTERN: RegExp;
20
+ export declare const BOOTSTRAP_INSTALL_PATTERN: RegExp;
21
+ export declare const AGENT_CONTROL_POINT_PATTERN: RegExp;
22
+ export declare const RESTART_LOAD_PATTERN: RegExp;
23
+ export declare const REMOTE_INSTRUCTION_INDIRECTION_PATTERN: RegExp;
24
+ export interface OverridePhraseMatch {
25
+ phrase: string;
26
+ index: number;
27
+ }
28
+ /**
29
+ * Find the first instruction-override phrase in a normalized line, skipping
30
+ * matches preceded by nearby negation ("never bypass permissions").
31
+ */
32
+ export declare function findOverridePhrase(normalizedLine: string): OverridePhraseMatch | null;
33
+ export declare function hasNegationBefore(normalizedLine: string, matchIndex: number): boolean;
@@ -0,0 +1,45 @@
1
+ import { activeOverridePhrases } from "./override-phrases.js";
2
+ /**
3
+ * Shared threat patterns for instruction/rule-file text and MCP tool
4
+ * descriptions. Callers are expected to match against text run through
5
+ * normalizeForMatching() so hidden-character and homoglyph obfuscation
6
+ * cannot dodge these literals.
7
+ */
8
+ export const NEGATION_PATTERN = /\b(?:must not|should not|do not|don't|never)\b/iu;
9
+ export const SENSITIVE_READ_PATTERN = /\b(?:read|cat)\s+(?:~\/\.ssh(?:\/[^\s]+)?|\.env\b|~\/\.[a-z0-9._-]+(?:\/[^\s]+)*)/iu;
10
+ export const SENSITIVE_FILE_PATTERN = /(~\/\.ssh|~\/\.aws|id_rsa|\.env|credentials|\.git-credentials)/iu;
11
+ export const OUTBOUND_TRANSFER_PATTERN = /\b(?:upload externally|send to (?:an |a )?(?:external )?(?:webhook|endpoint|server)|curl\b|wget\b|invoke-webrequest\b|post to\b|https?:\/\/|exfiltrat(?:e|ion|ing))\b/iu;
12
+ export const EXFIL_PATTERN = /(send .*https?:\/\/|upload|webhook|post to|exfiltrat)/iu;
13
+ export const COMMAND_EXECUTION_PATTERN = /(run command|execute shell|bash -c|sh -c|powershell)/iu;
14
+ export const REMOTE_SHELL_PATTERN = /\b(?:curl|wget)\b[^\n|]{0,240}\|\s*(?:bash|sh)\b|\b(?:invoke-webrequest|iwr)\b[^\n|]{0,240}\|\s*(?:iex|invoke-expression)\b/iu;
15
+ export const SUSPICIOUS_LONG_LINE_PATTERN = /\b(?:ignore previous instructions|skip permissions|bypass permissions|upload externally|curl\b|wget\b|https?:\/\/|bash\s+-lc|sh\s+-c|powershell\b|base64\b|~\/\.ssh|\.env\b)\b/iu;
16
+ export const HTML_COMMENT_PATTERN = /<!--([\s\S]*?)-->/gu;
17
+ export const COMMENT_PAYLOAD_PATTERN = /\b(?:secret instructions|ignore previous instructions|curl\b|wget\b|invoke-webrequest\b|bash\b|powershell\b|session share\b|profile sync\b)\b/iu;
18
+ export const COOKIE_EXPORT_PATTERN = /\bcookies?\s+(?:export|import|get)\b/iu;
19
+ export const SESSION_SHARE_PATTERN = /\bsession\s+share\b|\blive url\b/iu;
20
+ export const PROFILE_SYNC_PATTERN = /\bprofile\s+sync\b|\breal chrome\b|\blogin sessions\b|\bsession tokens?\b|--profile\b/iu;
21
+ export const BOOTSTRAP_INSTALL_PATTERN = /\b(?:npm|pnpm|yarn|bun)\s+install\s+-g\b|\bbrew\s+install\b|\bpipx\s+install\b|\bgo\s+install\b|\b(?:npx|pnpx|uvx)\b[^\n`]{0,160}@latest\b/iu;
22
+ export const AGENT_CONTROL_POINT_PATTERN = /\.claude\/hooks\/|\.claude\/settings\.json|\.claude\/agents\/|\bclaude\.md\b|\bagents\.md\b|\bmcp configuration\b/iu;
23
+ export const RESTART_LOAD_PATTERN = /\brestart\b.*\b(?:load|take effect|activate|reload|work)\b|\bonly load after restart\b|\bafter restarting\b/iu;
24
+ export const REMOTE_INSTRUCTION_INDIRECTION_PATTERN = /\b(?:follow|read|fetch|apply|obey|execute)\b[^\n]{0,40}\b(?:instructions|steps|guide|rules|directives)\b[^\n]{0,40}https?:\/\//iu;
25
+ function hasNearbyNegation(normalizedLine, matchIndex) {
26
+ const prefix = normalizedLine.slice(Math.max(0, matchIndex - 24), matchIndex);
27
+ return NEGATION_PATTERN.test(prefix);
28
+ }
29
+ /**
30
+ * Find the first instruction-override phrase in a normalized line, skipping
31
+ * matches preceded by nearby negation ("never bypass permissions").
32
+ */
33
+ export function findOverridePhrase(normalizedLine) {
34
+ for (const { phrase } of activeOverridePhrases()) {
35
+ const matchIndex = normalizedLine.indexOf(phrase);
36
+ if (matchIndex < 0 || hasNearbyNegation(normalizedLine, matchIndex)) {
37
+ continue;
38
+ }
39
+ return { phrase, index: matchIndex };
40
+ }
41
+ return null;
42
+ }
43
+ export function hasNegationBefore(normalizedLine, matchIndex) {
44
+ return hasNearbyNegation(normalizedLine, matchIndex);
45
+ }
@@ -0,0 +1,33 @@
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 declare const HIDDEN_UNICODE_CLASS: {
11
+ readonly ZeroWidth: "zero-width";
12
+ readonly Bidi: "bidi";
13
+ readonly Tags: "tags";
14
+ readonly VariationSelector: "variation-selector";
15
+ };
16
+ export type HiddenUnicodeClass = (typeof HIDDEN_UNICODE_CLASS)[keyof typeof HIDDEN_UNICODE_CLASS];
17
+ export declare const ZERO_WIDTH_PATTERN: RegExp;
18
+ export declare const BIDI_CONTROL_PATTERN: RegExp;
19
+ export declare const TAG_CHARACTER_PATTERN: RegExp;
20
+ export declare const VARIATION_SELECTOR_PATTERN: RegExp;
21
+ export interface HiddenUnicodeMatch {
22
+ index: number;
23
+ codePoint: number;
24
+ class: HiddenUnicodeClass;
25
+ }
26
+ /**
27
+ * Find hidden Unicode characters worth flagging. Variation selectors are
28
+ * reported only when clustered (two or more in a row).
29
+ */
30
+ export declare function findHiddenUnicode(text: string): HiddenUnicodeMatch[];
31
+ /** Remove every hidden character class so pattern matching sees the visible text. */
32
+ export declare function stripHiddenCharacters(text: string): string;
33
+ export declare function hasHiddenUnicodeClass(text: string, cls: HiddenUnicodeClass): boolean;
@@ -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
+ }