@shomra/agent 0.3.29 → 0.3.30

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 (77) hide show
  1. package/package.json +1 -1
  2. package/src/agents/hook-command.mjs +1 -1
  3. package/src/artifacts/matchers.mjs +7 -0
  4. package/src/cli/flags.mjs +2 -2
  5. package/src/cli/help-sections.mjs +7 -0
  6. package/src/cli/help.mjs +1 -1
  7. package/src/commands/check.mjs +3 -11
  8. package/src/commands/gate.mjs +26 -4
  9. package/src/commands/git-hooks.mjs +2 -2
  10. package/src/commands/ledger.mjs +0 -1
  11. package/src/commands/mcp-add.mjs +2 -1
  12. package/src/commands/memory-scan.mjs +135 -47
  13. package/src/commands/pr.mjs +7 -10
  14. package/src/commands/provenance.mjs +8 -13
  15. package/src/commands/scan.mjs +7 -1
  16. package/src/commands/secrets.mjs +4 -5
  17. package/src/core/git-exec.mjs +79 -0
  18. package/src/core/yaml-lite.mjs +300 -0
  19. package/src/core/zip-lite.mjs +37 -0
  20. package/src/detect/local-redact.mjs +1 -3
  21. package/src/detect/sast/rules-config.mjs +1 -1
  22. package/src/detect/sast/scanner.mjs +1 -1
  23. package/src/detect/signals/agent-frameworks.mjs +231 -0
  24. package/src/detect/signals/agent-graph-surface.mjs +113 -0
  25. package/src/detect/signals/agentic-ci-surface.mjs +314 -0
  26. package/src/detect/signals/agentic-shim.mjs +82 -0
  27. package/src/detect/signals/artifacts.mjs +7 -35
  28. package/src/detect/signals/chat-template.mjs +211 -0
  29. package/src/detect/signals/ci-workflow.mjs +169 -0
  30. package/src/detect/signals/gate.mjs +77 -9
  31. package/src/detect/signals/guardrail-shape.mjs +564 -0
  32. package/src/detect/signals/guardrail-surface.mjs +221 -0
  33. package/src/detect/signals/injection.mjs +8 -0
  34. package/src/detect/signals/inspect-shim.mjs +7 -0
  35. package/src/detect/signals/instruction-paths.mjs +60 -0
  36. package/src/detect/signals/manifests.mjs +302 -0
  37. package/src/detect/signals/mcp-advisories.mjs +109 -0
  38. package/src/detect/signals/mcp-config.mjs +598 -0
  39. package/src/detect/signals/memory-directives.mjs +661 -0
  40. package/src/detect/signals/memory-locations.mjs +158 -0
  41. package/src/detect/signals/memory.mjs +47 -29
  42. package/src/detect/signals/model-config-rules.mjs +655 -0
  43. package/src/detect/signals/model-config.mjs +61 -0
  44. package/src/detect/signals/prose-context.mjs +6 -9
  45. package/src/detect/signals/scan.mjs +4 -4
  46. package/src/detect/signals/secret-scanner.mjs +241 -0
  47. package/src/detect/signals/secrets.mjs +1 -48
  48. package/src/detect/signals/shell.mjs +3 -3
  49. package/src/gate/advisories.mjs +16 -0
  50. package/src/gate/batch.mjs +10 -0
  51. package/src/gate/environment.mjs +8 -53
  52. package/src/guard/artifact-paths.mjs +107 -0
  53. package/src/guard/classify.mjs +165 -7
  54. package/src/guard/command-resolve.mjs +35 -5
  55. package/src/guard/memory-write.mjs +218 -0
  56. package/src/guard/prompt-guard.mjs +0 -1
  57. package/src/guard/tool-guard.mjs +52 -77
  58. package/src/inventory/agent-posture.mjs +236 -57
  59. package/src/inventory/artifacts/classify.mjs +10 -1
  60. package/src/inventory/artifacts/discover.mjs +113 -3
  61. package/src/inventory/artifacts/extensions.mjs +70 -0
  62. package/src/inventory/artifacts/hook-scripts.mjs +128 -0
  63. package/src/inventory/artifacts/limits.mjs +1 -1
  64. package/src/inventory/artifacts/plugins.mjs +105 -0
  65. package/src/inventory/artifacts/roots.mjs +40 -0
  66. package/src/inventory/discovery/ai-dependencies.mjs +39 -12
  67. package/src/inventory/discovery/all.mjs +4 -0
  68. package/src/inventory/discovery/cloud-clis.mjs +472 -0
  69. package/src/inventory/discovery/coding-agents.mjs +19 -4
  70. package/src/inventory/discovery/mcp-clients.mjs +16 -10
  71. package/src/inventory/discovery/mcp-servers.mjs +125 -35
  72. package/src/inventory/discovery/mcp-stores.mjs +207 -0
  73. package/src/inventory/env-redirect.mjs +148 -0
  74. package/src/inventory/grant-extract.mjs +463 -0
  75. package/src/inventory/project-roots.mjs +108 -0
  76. package/src/inventory/vscode-state.mjs +153 -0
  77. package/src/mcp/server-tools.mjs +1 -1
@@ -0,0 +1,61 @@
1
+ import { lineOf } from './lines.mjs';
2
+ import { gradeModelConfig, modelConfigFormat } from './model-config-rules.mjs';
3
+ import { extractChatTemplates, isChatTemplateFile, scanChatTemplate } from './chat-template.mjs';
4
+
5
+ const MODELFILE_RE = /^(?:.*\.)?modelfile$/i;
6
+ const base = (p) => String(p ?? '').replace(/\\/g, '/').split('/').pop() ?? '';
7
+
8
+ export function isModelConfigPath(path, content) {
9
+ const b = base(path);
10
+ if (MODELFILE_RE.test(b) || isChatTemplateFile(b)) return true;
11
+ return modelConfigFormat(path, content ?? 'x') !== null;
12
+ }
13
+
14
+ const SENSITIVE_LOCAL = /(?:^|[\\/])(?:\.ssh|\.aws|\.gnupg|\.kube|\.docker|\.config[\\/]gcloud|\.netrc|\.git-credentials|\.env)(?:[\\/]|$)|^\/(?:etc|root|proc|sys)(?:\/|$)|id_(?:rsa|ed25519|ecdsa|dsa)\b|shadow$|passwd$|\.pem$|\.key$/i;
15
+ const localRef = (r) => /^(?:\/|~|\.{1,2}[\\/]|[A-Za-z]:[\\/])/.test(r);
16
+
17
+ function modelfileFindings(content, name) {
18
+ const out = [];
19
+ const push = (severity, title, remediationText, needle) => out.push({ severity, title, remediationText, needle });
20
+ const from = content.match(/^\s*FROM\s+(\S+)/im)?.[1] ?? null;
21
+ const adapters = [...content.matchAll(/^\s*ADAPTER\s+(\S+)/gim)].map((m) => m[1]).slice(0, 10);
22
+ const tmpl = content.match(/^\s*TEMPLATE\s+(?:"""([\s\S]*?)"""|(.*))$/im);
23
+ const template = tmpl ? (tmpl[1] ?? tmpl[2] ?? '').trim() : '';
24
+
25
+ for (const h of template ? scanChatTemplate(template, name, { sink: 'TEMPLATE', renderer: 'Ollama' }) : []) push(h.severity, h.title, h.remediation, 'TEMPLATE');
26
+
27
+ for (const ref of [from, ...adapters].filter((x) => x && localRef(x))) {
28
+ const dev = /^\/dev\//.test(ref);
29
+ if (!dev && !SENSITIVE_LOCAL.test(ref) && !/(?:^|[\\/])\.\.(?:[\\/]|$)/.test(ref)) continue;
30
+ push(dev ? 'MEDIUM' : SENSITIVE_LOCAL.test(ref) ? 'HIGH' : 'MEDIUM',
31
+ dev ? `Model "${name}" reads a device file as weights (${ref})` : `Model "${name}" bundles a local file from outside the model directory (${ref})`,
32
+ 'Reference weights/adapters inside the model directory only, and run Ollama 0.1.34+.', ref);
33
+ }
34
+ if (from && !localRef(from)) {
35
+ const remote = /^https?:\/\//i.test(from);
36
+ const host = /^([a-z0-9-]+(?:\.[a-z0-9-]+)+(?::\d+)?)\//i.exec(from)?.[1] ?? null;
37
+ if (host && !remote && !/^(?:registry\.ollama\.ai|ollama\.com|hf\.co|huggingface\.co)$/i.test(host)) push('MEDIUM', `Model "${name}" is pulled from a third-party registry (${host})`, 'Pull from a registry you control or trust, pinned by digest.', from);
38
+ if (remote) push('HIGH', `Model "${name}" is built from weights fetched over the network`, 'Reference a registry model by name and digest, or vendor the weights and pin them by hash.', from);
39
+ else if (!/@sha256[:-][0-9a-f]{12,}/i.test(from) && !(/:[\w.-]+$/.test(from) && !/:latest$/i.test(from))) push('LOW', `Model "${name}" is built from an unpinned base`, 'Pin the base to an explicit version or digest and update it deliberately.', from);
40
+ }
41
+ if (adapters.length) push('MEDIUM', `Model "${name}" applies a fine-tuning adapter`, 'Pin the adapter by digest, record who trained it and on what data, and re-evaluate the composed model.', adapters[0]);
42
+ return out;
43
+ }
44
+
45
+ export function localModelConfig(content, { path } = {}) {
46
+ const text = String(content ?? '');
47
+ if (!text.trim()) return [];
48
+ const name = base(path) || 'model-config';
49
+ const rows = [];
50
+ if (MODELFILE_RE.test(name) && /^\s*FROM\s+\S+/im.test(text)) rows.push(...modelfileFindings(text, name));
51
+ else {
52
+ for (const f of gradeModelConfig(path ?? name, text)) rows.push({ severity: f.severity, title: f.title, remediationText: f.remediation, needle: f.anchor });
53
+ for (const t of extractChatTemplates(text, path ?? name)) {
54
+ for (const h of scanChatTemplate(t.text, name, { sink: t.name })) rows.push({ severity: h.severity, title: h.title, remediationText: h.remediation, needle: t.name });
55
+ }
56
+ }
57
+ return rows.map(({ needle, ...r }) => {
58
+ const line = needle ? lineOf(text, needle) : undefined;
59
+ return { ...r, ...(line ? { line } : {}) };
60
+ });
61
+ }
@@ -83,10 +83,12 @@ export function isDocumentationLine(line, offset) {
83
83
  }
84
84
 
85
85
  const PROHIBITION_MARKER_RE =
86
- /\b(?:never|do not|don'?t|cannot|can'?t|must not|mustn'?t|should not|shouldn'?t|avoid|avoids|avoiding|refuse to|refrain from|forbidden|prohibited|disallow\w*|instead of|rather than|beware of|do NOT)\b[^.:;\n]{0,60}$/i;
86
+ /\b(?:never|do not|don'?t|cannot|can'?t|must not|mustn'?t|should not|shouldn'?t|avoid|avoids|avoiding|refuse to|refrain from|forbidden|prohibited|disallow\w*|instead of|rather than|beware of|do NOT|(?:ask|check with)(?: the user| a human| me| for (?:confirmation|approval|permission))? before|confirm(?: with the user)? before|(?:get|obtain|wait for) (?:explicit )?(?:approval|confirmation|sign-?off) before|require (?:explicit )?(?:approval|confirmation) (?:before|for))\b[^.:;\n]{0,60}$/i;
87
87
 
88
88
  const DOUBLE_NEGATIVE_RE = /\b(?:hesitate|worry|be afraid|forget|fail|neglect|shy away)\b/i;
89
89
 
90
+ const NEGATED_GUARD_RE = /\b(?:never|do not|don'?t|no need to|without|skip)\s[\w ]{0,20}\b(?:ask|asking|confirm|confirming|check|checking|wait|waiting|prompt|prompting|approval)\b/i;
91
+
90
92
  const COORDINATE_TAIL_RE = /(?:\b(?:and|or|but|then|also)\b|[,;])\s*$/i;
91
93
 
92
94
  export function prohibitsAt(line, offset) {
@@ -95,15 +97,10 @@ export function prohibitsAt(line, offset) {
95
97
  const before = line.slice(Math.max(0, at - 90), at);
96
98
  if (!PROHIBITION_MARKER_RE.test(before)) return false;
97
99
  if (COORDINATE_TAIL_RE.test(before)) return false;
100
+ if (NEGATED_GUARD_RE.test(before)) return false;
98
101
  return !DOUBLE_NEGATIVE_RE.test(before);
99
102
  }
100
103
 
101
- /**
102
- * ⚠ AN OFFSET ON THE OPENING BACKTICK IS INSIDE THE SPAN. Several rules anchor
103
- * on the backtick itself (a markdown code span and a shell command substitution
104
- * are the same character), so counting only what precedes the offset put every
105
- * code-span guard one character outside the span it was meant to be inside.
106
- */
107
104
  function insideCodeSpan(line, offset) {
108
105
  let ticks = 0;
109
106
  for (let i = 0; i < offset && i < line.length; i++) if (line[i] === '`') ticks++;
@@ -112,7 +109,7 @@ function insideCodeSpan(line, offset) {
112
109
  }
113
110
 
114
111
  /**
115
- * ⚠ A LINE THAT NAMES A COMMAND AS ITS SUBJECT IS DESCRIBING IT the second
112
+ * ⚠ A LINE THAT NAMES A COMMAND AS ITS SUBJECT IS DESCRIBING IT - the second
116
113
  * carve-out `carriesHardEvidence` needs, for the same reason `prohibitsAt` was
117
114
  * the first. Security documentation is written in exactly this mood -
118
115
  * *"`curl … | sh` is a pipe-to-shell installer"*, *"`chmod 777` means the file
@@ -128,7 +125,7 @@ function insideCodeSpan(line, offset) {
128
125
  *
129
126
  * ⚠ THE MATCH MUST BE INSIDE A CODE SPAN, and ⚠ AN IMPERATIVE BEFORE IT WINS.
130
127
  *
131
- * Mirrors `describesAt` in the backend's prose-context.ts `local-mirror-bench`
128
+ * Mirrors `describesAt` in the backend's prose-context.ts - `local-mirror-bench`
132
129
  * compares the two on every mood, in both directions.
133
130
  */
134
131
  const DESCRIPTIVE_PREDICATE_RE =
@@ -2,7 +2,7 @@ import { RISKY_CONFIG_MARKERS, riskyConfigHit } from './config-markers.mjs';
2
2
  import { detectCredentialHarvest } from './credential-harvest.mjs';
3
3
  import { egressHost } from './egress.mjs';
4
4
  import { detectExecutionHijack } from './execution-hijack.mjs';
5
- import { BUILD_ARTIFACT, INJECTION_PHRASES, INJECTION_REGEXES, INVISIBLE_CHARS_RE, PRECEDING_NEGATION, describesRatherThanInstructs } from './injection.mjs';
5
+ import { BUILD_ARTIFACT, INJECTION_PHRASES, INJECTION_REGEXES, INVISIBLE_CHARS_RE, precededByNegation, describesRatherThanInstructs } from './injection.mjs';
6
6
  import { lineAt, locate } from './lines.mjs';
7
7
  import { codeMask, deobfuscate } from './masking.mjs';
8
8
  import { PII_PATTERNS, RESERVED_IPV4, SECRET_PATTERNS, VERSION_CONTEXT, isPlaceholderSecret, luhnValid } from './secrets.mjs';
@@ -32,7 +32,7 @@ export function localScan(text, opts = {}) {
32
32
  for (const p of INJECTION_PHRASES) {
33
33
  const at = low.indexOf(p);
34
34
  if (at < 0) continue;
35
- if (PRECEDING_NEGATION.test(t.slice(Math.max(0, at - 20), at))) continue;
35
+ if (precededByNegation(t.slice(Math.max(0, at - 60), at))) continue;
36
36
  findings.push({ label: `Injected instruction: "${p}"`, severity: 'HIGH', category: 'injection', ...locate(t, p, mask) });
37
37
  break;
38
38
  }
@@ -40,7 +40,7 @@ export function localScan(text, opts = {}) {
40
40
  const m = t.match(re);
41
41
  if (!m) continue;
42
42
  const at = m.index ?? 0;
43
- if (PRECEDING_NEGATION.test(t.slice(Math.max(0, at - 20), at))) continue;
43
+ if (precededByNegation(t.slice(Math.max(0, at - 60), at))) continue;
44
44
  if (label === 'Bulk destructive command' && BUILD_ARTIFACT.test(m[0])) continue;
45
45
  if (moodGuarded && describesRatherThanInstructs(t, at)) continue;
46
46
  findings.push({ label, severity: 'HIGH', category: 'injection', ...locate(t, re, mask) });
@@ -48,7 +48,7 @@ export function localScan(text, opts = {}) {
48
48
  if (INVISIBLE_CHARS_RE.test(t)) findings.push({ label: 'Invisible / zero-width characters', severity: 'MEDIUM', category: 'injection', ...locate(t, INVISIBLE_CHARS_RE, mask) });
49
49
  }
50
50
  if (cats.includes('secret')) {
51
- for (const { name, re } of SECRET_PATTERNS) { const m = t.match(re); if (m && !isPlaceholderSecret(m[0])) findings.push({ label: `Live credential: ${name}`, severity: 'CRITICAL', category: 'secret', ...locate(t, re, mask) }); }
51
+ for (const { name, re } of SECRET_PATTERNS) { const m = t.match(re); if (m && (/private key/i.test(name) || !isPlaceholderSecret(m[0]))) findings.push({ label: `Live credential: ${name}`, severity: 'CRITICAL', category: 'secret', ...locate(t, re, mask) }); }
52
52
  }
53
53
  if (cats.includes('pii')) {
54
54
  for (const { name, re } of PII_PATTERNS) {
@@ -0,0 +1,241 @@
1
+ export const SECRET_PATTERNS = [
2
+ { name: 'Stripe live key', re: /\bsk_live_[0-9a-zA-Z]{16,}/ },
3
+ { name: 'OpenAI admin / service-account key', re: /\bsk-(?:svcacct|admin)-[A-Za-z0-9_-]{20,}/ },
4
+ { name: 'OpenAI key', re: /\bsk-[A-Za-z0-9]{20,}/ },
5
+ { name: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}/ },
6
+ { name: 'AWS temporary key id (STS)', re: /\bASIA[0-9A-Z]{16}/ },
7
+ { name: 'GitHub token', re: /ghp_[0-9A-Za-z]{20,}/ },
8
+ { name: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
9
+ { name: 'OpenAI project key', re: /\bsk-proj-[A-Za-z0-9_-]{20,}/ },
10
+ { name: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
11
+ { name: 'Hugging Face token', re: /\bhf_[A-Za-z0-9]{30,}/ },
12
+ { name: 'GitLab PAT', re: /\bglpat-[A-Za-z0-9_-]{20,}/ },
13
+ { name: 'npm token', re: /\bnpm_[A-Za-z0-9]{30,}/ },
14
+ { name: 'Slack token', re: /xox[baprs]-[0-9A-Za-z-]{10,}/ },
15
+ { name: 'AWS secret access key (keyed)', re: /\bAWS_SECRET_ACCESS_KEY\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}\b/ },
16
+ {
17
+ name: 'Database URL with password',
18
+ re: /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?|mssql|sqlserver|oracle|clickhouse|cockroachdb|snowflake|neo4j(?:\+s)?|couchbases?):\/\/(?!(?:user|username|admin|root|myuser|dbuser):(?:pass|password|passwd|secret|changeme|mypassword|yourpassword|xxx+|123456)@)(?!([^\s:@/]+):\1@)(?!(?:some|my|your|our|the|test|demo|example|dummy|fake|sample|foo|bar|placeholder)[\w-]*:(?:some|my|your|our|the|test|demo|example|dummy|fake|sample|foo|bar|placeholder)[\w-]*@)[^\s:@/]+:(?![$<[{])[^\s:@/]{4,}@/i,
19
+ },
20
+ { name: 'Generic bearer', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
21
+ { name: 'Encrypted private key block', re: /-----BEGIN ENCRYPTED PRIVATE KEY-----|-----BEGIN [A-Z ]{2,20}PRIVATE KEY-----\r?\nProc-Type: ?4, ?ENCRYPTED|PuTTY-User-Key-File-\d{1,2}:[^\r\n]{1,80}\r?\nEncryption: ?(?!none\b)[\w-]{2,40}/ },
22
+ { name: 'PuTTY private key', re: /PuTTY-User-Key-File-\d{1,2}:[^\r\n]{1,80}\r?\nEncryption: ?none\b/ },
23
+ { name: 'Private key block', re: /-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----\r?\n(?:[\w-]{1,40}: [^\r\n]{1,200}\r?\n){0,4}(?:\r?\n)?[ \t]{0,8}[A-Za-z0-9+/]{12,}/ },
24
+ { name: 'Groq API key', re: /\bgsk_[A-Za-z0-9]{40,}/ },
25
+ { name: 'Replicate API token', re: /\br8_[A-Za-z0-9]{30,}/ },
26
+ { name: 'Perplexity API key', re: /\bpplx-[A-Za-z0-9]{32,}/ },
27
+ { name: 'Fireworks API key', re: /\bfw_[A-Za-z0-9]{20,}/ },
28
+ { name: 'xAI API key', re: /\bxai-[A-Za-z0-9]{40,}/ },
29
+ { name: 'LangSmith API key', re: /\blsv2_(?:pt|sk)_[A-Za-z0-9]{24,}_[A-Za-z0-9]{8,}/ },
30
+ { name: 'Pinecone API key', re: /\bpcsk_[A-Za-z0-9_]{30,}/ },
31
+ { name: 'OpenRouter API key', re: /\bsk-or-v1-[A-Za-z0-9]{32,}/ },
32
+ { name: 'Discord webhook', re: /\bdiscord(?:app)?\.com\/api\/webhooks\/\d{17,}\/[A-Za-z0-9_-]{40,}/ },
33
+ { name: 'Telegram bot token', re: /\b\d{8,12}:AA[A-Za-z0-9_-]{30,}/ },
34
+ { name: 'Sentry DSN with secret', re: /\bhttps:\/\/[a-f0-9]{32}(?::[a-f0-9]{32})?@[\w.-]*(?:sentry\.io|ingest\.[\w.-]+)\/\d+/ },
35
+ { name: 'Registry auth blob (docker config)', re: /"auth"\s*:\s*"[A-Za-z0-9+/]{24,}={0,2}"/ },
36
+ {
37
+ name: 'Provider API key (named env var)',
38
+ re: /\b(?:AZURE_OPENAI_API_KEY|MISTRAL_API_KEY|COHERE_API_KEY|CO_API_KEY|TOGETHER_API_KEY|DEEPSEEK_API_KEY|DD_API_KEY|DATADOG_API_KEY|TWILIO_AUTH_TOKEN|VERCEL_TOKEN|CLOUDFLARE_API_TOKEN|WEAVIATE_API_KEY|VOYAGE_API_KEY|NVIDIA_API_KEY|CEREBRAS_API_KEY|SAMBANOVA_API_KEY)\s*[=:]\s*["']?[A-Za-z0-9_-]{24,}\b/,
39
+ },
40
+ ];
41
+
42
+ export const ENTERPRISE_SECRET_PATTERNS = [
43
+ { name: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{24,}/ },
44
+ { name: 'OpenAI key', re: /\bsk-(?:proj-)?[A-Za-z0-9]{20,}/ },
45
+ { name: 'OpenAI project/org', re: /\b(?:org|proj)-[A-Za-z0-9]{20,}/ },
46
+ { name: 'Hugging Face token', re: /\bhf_[A-Za-z0-9]{30,}/ },
47
+ { name: 'Cohere key', re: /\bco-[A-Za-z0-9]{40,}/ },
48
+ { name: 'Google AI / API key', re: /\bAIza[0-9A-Za-z_-]{35}/ },
49
+ { name: 'AWS access key id', re: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}/ },
50
+ { name: 'AWS secret access key (keyed)', re: /aws_secret_access_key\s*[=:]\s*["']?[A-Za-z0-9/+]{40}/i },
51
+ { name: 'GCP service-account private key id', re: /"private_key_id"\s*:\s*"[0-9a-f]{40}"/ },
52
+ { name: 'GCP API key', re: /\bAIza[0-9A-Za-z_-]{35}/ },
53
+ { name: 'Azure storage key', re: /AccountKey\s*=\s*[A-Za-z0-9+/]{80,}==/ },
54
+ { name: 'GitHub personal token (classic)', re: /\bghp_[0-9A-Za-z]{36}/ },
55
+ { name: 'GitHub fine-grained token', re: /\bgithub_pat_[0-9A-Za-z_]{60,}/ },
56
+ { name: 'GitHub OAuth/app token', re: /\bgh[osur]_[0-9A-Za-z]{36}/ },
57
+ { name: 'GitLab personal token', re: /\bglpat-[0-9A-Za-z_-]{20,}/ },
58
+ { name: 'npm token', re: /\bnpm_[0-9A-Za-z]{36}/ },
59
+ { name: 'PyPI upload token', re: /\bpypi-AgEIcHlwaS[A-Za-z0-9_-]{50,}/ },
60
+ { name: 'Stripe live secret key', re: /\b[rs]k_(?:live|prod)_[0-9a-zA-Z]{10,99}/ },
61
+ { name: 'Stripe webhook secret', re: /\bwhsec_[A-Za-z0-9+/=]{32,}/ },
62
+ { name: 'Slack token', re: /\bxox[baprs]-[0-9A-Za-z-]{10,}/ },
63
+ { name: 'Slack webhook', re: /https:\/\/hooks\.slack\.com\/services\/T[0-9A-Za-z_]+\/B[0-9A-Za-z_]+\/[0-9A-Za-z]+/ },
64
+ { name: 'SendGrid key', re: /\bSG\.[0-9A-Za-z_-]{22}\.[0-9A-Za-z_-]{43}/ },
65
+ { name: 'Twilio account SID', re: /\bAC[0-9a-f]{32}/ },
66
+ { name: 'Twilio API key', re: /\bSK[0-9a-f]{32}/ },
67
+ { name: 'Mailgun key', re: /\bkey-[0-9a-f]{32}/ },
68
+ { name: 'Datadog API key', re: /\bdd[ap]i_[0-9a-f]{32,}/i },
69
+ { name: 'Shopify access token', re: /\bshp(?:at|ca|pa|ss)_[0-9a-fA-F]{32}/ },
70
+ { name: 'Square access token', re: /\bsq0(?:atp|csp|idp)-[0-9A-Za-z_-]{22,}/ },
71
+ { name: 'DigitalOcean token', re: /\bdop_v1_[0-9a-f]{64}/ },
72
+ { name: 'Cloudflare API token', re: /\b[A-Za-z0-9_-]{40}\b(?=[^\n]{0,40}cloudflare)/i },
73
+ { name: 'Azure AD client secret', re: /(?<![\w~.-])[a-zA-Z0-9_~.]{3}\dQ~[a-zA-Z0-9_~.-]{31,34}(?![\w~.-])/ },
74
+ { name: 'Azure SAS token', re: /[?&]sig=[A-Za-z0-9%+/]{40,120}(?:%3D|=){0,2}/ },
75
+ { name: 'Azure Service Bus / Event Hub key', re: /\bSharedAccessKey=[A-Za-z0-9+/]{42,44}=/ },
76
+ { name: 'AWS Bedrock API key', re: /\bABSK[A-Za-z0-9+/]{109,269}={0,2}|\bbedrock-api-key-YmVkcm9jay5hbWF6b25hd3MuY29t[A-Za-z0-9+/=%]{20,}/ },
77
+ { name: 'Databricks token', re: /\bdapi[a-f0-9]{32}(?:-\d)?\b/ },
78
+ { name: 'Google OAuth client secret', re: /\bGOCSPX-[A-Za-z0-9_-]{28}\b/ },
79
+ { name: 'Google OAuth access token', re: /\bya29\.[0-9A-Za-z_-]{50,}/ },
80
+ { name: 'GitLab CI / deploy / runner token', re: /\b(?:gldt|glrt|glsoat|glft|glimt|glagent|gloas)-[0-9A-Za-z_-]{20,}|\bglptt-[0-9a-f]{40}\b|\bglcbt-[0-9A-Za-z]{1,5}_[0-9A-Za-z_-]{20,}|\bGR1348941[\w-]{20,}/ },
81
+ { name: 'Slack app token', re: /\bxapp-\d-[A-Z0-9]{8,}-\d{6,}-[a-z0-9]{32,}/i },
82
+ { name: 'Mailchimp API key', re: /\b[a-f0-9]{32}-us\d{1,2}\b/ },
83
+ { name: 'HashiCorp Vault token', re: /\bhvs\.[\w-]{90,120}|\bhvb\.[\w-]{138,300}/ },
84
+ { name: 'Terraform Cloud token', re: /\b[a-z0-9]{14}\.atlasv1\.[a-z0-9_=-]{60,70}/i },
85
+ { name: 'Doppler token', re: /\bdp\.(?:pt|st|sa|scim|audit|ct)\.[A-Za-z0-9]{40,44}\b/ },
86
+ { name: '1Password service-account token', re: /\bops_eyJ[A-Za-z0-9+/]{250,}={0,3}/ },
87
+ { name: '1Password secret key', re: /\bA3-[A-Z0-9]{6}-(?:[A-Z0-9]{11}|[A-Z0-9]{6}-[A-Z0-9]{5})-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}\b/ },
88
+ { name: 'age secret key', re: /\bAGE-SECRET-KEY-1[QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L]{58}\b/ },
89
+ { name: 'Grafana token', re: /\bglc_[A-Za-z0-9+/]{32,400}={0,3}|\bglsa_[A-Za-z0-9]{32}_[A-Fa-f0-9]{8}\b/ },
90
+ { name: 'Sentry auth token', re: /\bsntryu_[a-f0-9]{64}\b|\bsntrys_eyJ[A-Za-z0-9+/=_-]{60,}/ },
91
+ { name: 'Linear API key', re: /\blin_api_[A-Za-z0-9]{40}\b/ },
92
+ { name: 'Notion token', re: /\bntn_\d{11}[A-Za-z0-9]{35}\b|\bsecret_[A-Za-z0-9]{43}\b/ },
93
+ { name: 'Postman API key', re: /\bPMAK-[a-f0-9]{24}-[a-f0-9]{34}\b/i },
94
+ { name: 'Pulumi token', re: /\bpul-[a-f0-9]{40}\b/ },
95
+ { name: 'Heroku API key', re: /\bHRKU-AA[0-9A-Za-z_-]{58}\b/ },
96
+ { name: 'Hugging Face org token', re: /\bapi_org_[A-Za-z]{34}\b/ },
97
+ { name: 'Atlassian API token', re: /\bATATT3[A-Za-z0-9_=-]{180,200}/ },
98
+ { name: 'Fly.io token', re: /\bfo1_[\w-]{43}\b|\bfm[12][ar]?_[A-Za-z0-9+/]{100,}={0,3}/ },
99
+ { name: 'PlanetScale token', re: /\bpscale_(?:tkn|pw|oauth)_[\w=.-]{32,64}\b/ },
100
+ { name: 'RubyGems API key', re: /\brubygems_[a-f0-9]{48}\b/ },
101
+ { name: 'Clojars token', re: /\bCLOJARS_[a-z0-9]{60}\b/i },
102
+ { name: 'NuGet API key', re: /\boy2[a-z0-9]{43}\b/ },
103
+ { name: 'Docker Hub token', re: /\bdckr_pat_[A-Za-z0-9_-]{27,}/ },
104
+ { name: 'JFrog Artifactory token', re: /\bAKCp[A-Za-z0-9]{69}\b|\bcmVmd[A-Za-z0-9]{59}\b/ },
105
+ { name: 'Dynatrace token', re: /\bdt0c01\.[A-Za-z0-9]{24}\.[A-Za-z0-9]{64}\b/ },
106
+ { name: 'Prefect API key', re: /\bpnu_[A-Za-z0-9]{36}\b/ },
107
+ { name: 'Brevo API key', re: /\bxkeysib-[a-f0-9]{64}-[A-Za-z0-9]{16}\b/ },
108
+ { name: 'Supabase access token', re: /\bsbp_[a-f0-9]{40}\b/ },
109
+ { name: 'Alibaba Cloud access key', re: /\bLTAI[A-Za-z0-9]{20}\b/ },
110
+ { name: 'Tencent Cloud secret id', re: /\bAKID[A-Za-z0-9]{32}\b/ },
111
+ { name: 'Harness token', re: /\b(?:pat|sat)\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9]{24}\.[A-Za-z0-9]{20}\b/ },
112
+ { name: 'Sourcegraph token', re: /\bsgp_(?:[a-fA-F0-9]{16}|local)_[a-fA-F0-9]{40}\b/ },
113
+ { name: 'Microsoft Teams webhook', re: /\bhttps:\/\/[a-z0-9-]{1,63}\.webhook\.office\.com\/webhookb2\/[a-f0-9-]{36}@[a-f0-9-]{36}\/IncomingWebhook\/[a-f0-9]{32}\/[a-f0-9-]{36}/i },
114
+ { name: 'dotenvx private key', re: /\bDOTENV_PRIVATE_KEY(?:_[A-Z0-9_]{1,40})?[ \t]{0,4}=[ \t]{0,4}["']?[a-f0-9]{64}\b/ },
115
+ { name: 'Credential in URL', re: /\b(?:https?|git|ssh|ftps?|smb|ldaps?):\/\/(?![^\s:@/]{1,64}:(?:\$|\{\{|<|\[|x{3}|\*{3}|password\b|passwd\b|pass\b|secret\b|token\b|changeme\b)[^\s@/]{0,64}@)[^\s:@/{}<>]{1,64}:[^\s:@/{}<>]{8,128}@(?!(?:example\.(?:com|org|net)|localhost|127\.0\.0\.1)(?:[:/\s]|$))[A-Za-z0-9.-]{3,}/i },
116
+ { name: 'JWT', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
117
+ { name: 'Generic bearer', re: /bearer\s+[A-Za-z0-9._~+/-]{20,}=*/i },
118
+ ];
119
+
120
+ export const GENERIC_SECRET_LABELS = new Set(['Generic bearer', 'JWT', 'JSON web token', 'Credential in URL']);
121
+
122
+ export const ALL_SECRET_PATTERNS = [
123
+ ...[...SECRET_PATTERNS, ...ENTERPRISE_SECRET_PATTERNS].filter((p) => !GENERIC_SECRET_LABELS.has(p.name)),
124
+ ...[...SECRET_PATTERNS, ...ENTERPRISE_SECRET_PATTERNS].filter((p) => GENERIC_SECRET_LABELS.has(p.name)),
125
+ ];
126
+
127
+ export function textCarriesSecret(text ) {
128
+ if (!text) return false;
129
+ return ALL_SECRET_PATTERNS.some((p) => p.re.test(text));
130
+ }
131
+
132
+ export function shannonEntropy(s ) {
133
+ if (!s) return 0;
134
+ const freq = new Map ();
135
+ for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
136
+ let bits = 0;
137
+ for (const c of freq.values()) {
138
+ const p = c / s.length;
139
+ bits -= p * Math.log2(p);
140
+ }
141
+ return bits;
142
+ }
143
+
144
+ export const SECRET_KEY_RE =
145
+ /\b(\w*(?:secret|token|passw(?:or)?d|api[_-]?key|apikey|access[_-]?key|client[_-]?secret|auth[_-]?token|private[_-]?key|credential|session[_-]?key|encryption[_-]?key|signing[_-]?key)\w*)\b/i;
146
+
147
+ const ASSIGNMENT_RE =
148
+ /([A-Za-z_$][\w.$-]{0,127})[ \t]{0,32}[:=][ \t]{0,32}(?:(["'`])([^"'`\r\n]{12,512})\2|([A-Za-z0-9_./+=~!@#$%^&*?:,-]{12,512}))/g;
149
+
150
+ const ENV_VAR_KEY_RE = /^[A-Z][A-Z0-9_]{2,}$/;
151
+
152
+ const NON_SECRET_SHAPE = [
153
+ /^[0-9a-f]{7,40}$/i,
154
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
155
+ /^\d[\d.]+\d$/,
156
+ /^(?:[A-Za-z]+[-_/.])+[A-Za-z]+$/,
157
+ /^(?:~|\.{1,2}|\$\{?\w+\}?:?)?\/(?:[\w.-]+\/)*[\w.-]+$/,
158
+ /^[a-z][a-z0-9+.-]{1,30}:\/\/[^\s@]*$/i,
159
+ /^[\w.-]+(?:[\\/][\w.-]+)+\.[a-z0-9]{1,6}$/i,
160
+ /^\$(?:2[abxy]|1|5|6|apr1|argon2(?:i|d|id)|scrypt|pbkdf2(?:-sha\d+)?)\$/,
161
+ /^(?:pk\.eyJ|pk_(?:live|test)_)/,
162
+ /^6LeIxAcTAAAAA(?:JcZVRqyHh71UMIEGNQ_MXjiZKhI|GG-vFI1TnRWxMZNFuojJ4WifJWe)$/,
163
+ ];
164
+
165
+ export const isNonSecretShape = (v ) => NON_SECRET_SHAPE.some((r) => r.test(v.trim()));
166
+ export const NAMES_ITS_ROLE_RE = /passw(?:or)?d|passwd|secret|token|api[_-]?key|credential/i;
167
+
168
+ const ENTROPY_MIN_MIXED = 3.5;
169
+ const ENTROPY_MIN_HEX = 3.0;
170
+
171
+ export function isPlaceholderValue(v ) {
172
+ const low = v.toLowerCase();
173
+ if (/(your|my|the|some|placeholder|example|sample|dummy|test|fake|changeme|redacted|x{3,64}|\.\.\.|todo|replace|insert|here|value|token|secret|key)$/i.test(low)) return true;
174
+ if (/^[x*.\-_0]+$/i.test(v)) return true;
175
+ if (/(.)\1{7,}/.test(v)) return true;
176
+ if (/^(?:abc|123|test|foo|bar|qwerty)/i.test(low)) return true;
177
+ return false;
178
+ }
179
+
180
+ export const SECRET_REFERENCE_RE =
181
+ /^(?:\$\{[^}]{1,120}\}|\$\([^)]{1,200}\)|\$[A-Za-z_][A-Za-z0-9_]{1,80}$|\{\{[^}]{1,160}\}\}|%\{[\w.]{1,80}\}|op:\/\/|vault:|sm:\/\/|awssm:\/\/|gcpsm:\/\/|azkv:\/\/|doppler:\/\/|infisical:\/\/|arn:aws[\w-]{0,20}:(?:secretsmanager|ssm):|projects\/[^/\s]{1,80}\/secrets\/|@Microsoft\.KeyVault\(|!vault\b)/i;
182
+ export const ENCRYPTED_VALUE_RE = /^(?:ENC\[[A-Z0-9_]{3,20},|encrypted:|\$ANSIBLE_VAULT;|-----BEGIN (?:AGE ENCRYPTED FILE|PGP MESSAGE)-----|AgA[A-Za-z0-9+/]{100,})/;
183
+ export const isSecretReference = (v ) => SECRET_REFERENCE_RE.test(v.trim());
184
+ export const isEncryptedValue = (v ) => ENCRYPTED_VALUE_RE.test(v.trim());
185
+ export const IDENTIFIER_SECRET_LABELS = new Set(['Twilio account SID', 'OpenAI project/org']);
186
+ export const MAX_SECRET_HITS = 500;
187
+
188
+ const URL_PLACEHOLDER_PASSWORD_RE =
189
+ /^(?:[$<[{%]|.*(?:passw|secret|change|replace|your|example|placeholder|insecure|dummy|sample|xxx|\*\*\*|todo|fill))/i;
190
+ export function urlCredentialIsPlaceholder(match ) {
191
+ const m = /:\/\/([^\s:@/]*):([^\s@/]*)@/.exec(match);
192
+ return !!m && (URL_PLACEHOLDER_PASSWORD_RE.test(m[2]) || m[2] === m[1]);
193
+ }
194
+
195
+ export function scanSecrets(text ) {
196
+ const s = text ?? '';
197
+ if (!s) return [];
198
+ const out = [];
199
+ const claimed = [];
200
+
201
+ const overlaps = (a , b ) => claimed.some(([x, y]) => a < y && x < b);
202
+
203
+ for (const { name, re } of ALL_SECRET_PATTERNS) {
204
+ if (out.length >= MAX_SECRET_HITS) break;
205
+ const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
206
+ for (const m of s.matchAll(g)) {
207
+ if (out.length >= MAX_SECRET_HITS) break;
208
+ const at = m.index ?? 0;
209
+ if (overlaps(at, at + m[0].length)) continue;
210
+
211
+ if (m[0].length < 200 && !/private key/i.test(name) && isPlaceholderValue(m[0])) continue;
212
+ if ((name === 'Database URL with password' || name === 'Credential in URL') && urlCredentialIsPlaceholder(m[0])) continue;
213
+ claimed.push([at, at + m[0].length]);
214
+ out.push({ name, match: m[0], index: at, kind: 'named', tier: 'structure' });
215
+ }
216
+ }
217
+
218
+ for (const m of s.matchAll(ASSIGNMENT_RE)) {
219
+ if (out.length >= MAX_SECRET_HITS) break;
220
+ const key = m[1];
221
+ const quoted = m[2] !== undefined;
222
+ const raw = (quoted ? m[3] : m[4]) ?? '';
223
+ const value = raw.replace(/[=]{1,64}$/, '');
224
+ const at = (m.index ?? 0) + m[0].lastIndexOf(raw);
225
+ if (overlaps(at, at + value.length)) continue;
226
+ if (!SECRET_KEY_RE.test(key)) continue;
227
+
228
+ if (!quoted && !ENV_VAR_KEY_RE.test(key)) continue;
229
+ if (value.length < 16 || /\s/.test(value)) continue;
230
+ if (isPlaceholderValue(value) || isSecretReference(value) || isEncryptedValue(value) || NAMES_ITS_ROLE_RE.test(value)) continue;
231
+ if (NON_SECRET_SHAPE.some((r) => r.test(value))) continue;
232
+ const isHex = /^[0-9a-f]+$/i.test(value);
233
+ const ent = shannonEntropy(value);
234
+ if (ent < (isHex ? ENTROPY_MIN_HEX : ENTROPY_MIN_MIXED)) continue;
235
+ claimed.push([at, at + value.length]);
236
+ out.push({ name: `High-entropy secret in "${key}"`, match: value, index: at, kind: 'entropy', tier: 'heuristic' });
237
+ }
238
+
239
+ return out.sort((a, b) => a.index - b.index);
240
+ }
241
+
@@ -1,52 +1,5 @@
1
- export const SECRET_PATTERNS = [
2
1
 
3
- { name: 'Stripe live key', re: /\bsk_live_[0-9a-zA-Z]{16,}/ },
4
- { name: 'OpenAI key', re: /\bsk-[A-Za-z0-9]{20,}/ },
5
- { name: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}/ },
6
- { name: 'GitHub token', re: /ghp_[0-9A-Za-z]{20,}/ },
7
-
8
- { name: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
9
- { name: 'OpenAI project key', re: /\bsk-proj-[A-Za-z0-9_-]{20,}/ },
10
- { name: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
11
- { name: 'Hugging Face token', re: /\bhf_[A-Za-z0-9]{30,}/ },
12
- { name: 'GitLab PAT', re: /\bglpat-[A-Za-z0-9_-]{20,}/ },
13
- { name: 'npm token', re: /\bnpm_[A-Za-z0-9]{30,}/ },
14
- { name: 'Slack token', re: /xox[baprs]-[0-9A-Za-z-]{10,}/ },
15
-
16
- { name: 'AWS secret access key (keyed)', re: /\bAWS_SECRET_ACCESS_KEY\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}\b/ },
17
-
18
- {
19
- name: 'Database URL with password',
20
- re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/(?!(?:user|username|admin|root|myuser|dbuser):(?:pass|password|passwd|secret|changeme|mypassword|yourpassword|xxx+|123456)@)(?!([^\s:@/]+):\1@)(?!(?:some|my|your|our|the|test|demo|example|dummy|fake|sample|foo|bar|placeholder)[\w-]*:(?:some|my|your|our|the|test|demo|example|dummy|fake|sample|foo|bar|placeholder)[\w-]*@)[^\s:@/]+:[^\s:@/]{4,}@/i,
21
- },
22
- { name: 'Generic bearer', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
23
- { name: 'Private key block', re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]{0,80}?[\r\n]+\s*[A-Za-z0-9+/=]{32,}/ },
24
-
25
- { name: 'Groq API key', re: /\bgsk_[A-Za-z0-9]{40,}/ },
26
- { name: 'Replicate API token', re: /\br8_[A-Za-z0-9]{30,}/ },
27
- { name: 'Perplexity API key', re: /\bpplx-[A-Za-z0-9]{32,}/ },
28
- { name: 'Fireworks API key', re: /\bfw_[A-Za-z0-9]{20,}/ },
29
- { name: 'xAI API key', re: /\bxai-[A-Za-z0-9]{40,}/ },
30
- { name: 'LangSmith API key', re: /\blsv2_(?:pt|sk)_[A-Za-z0-9]{24,}_[A-Za-z0-9]{8,}/ },
31
- { name: 'Pinecone API key', re: /\bpcsk_[A-Za-z0-9_]{30,}/ },
32
- { name: 'OpenRouter API key', re: /\bsk-or-v1-[A-Za-z0-9]{32,}/ },
33
- { name: 'DigitalOcean token', re: /\bdop_v1_[a-f0-9]{60,}/ },
34
- { name: 'Shopify access token', re: /\bshp(?:at|ca|pa|ss)_[a-fA-F0-9]{30,}/ },
35
- { name: 'GitHub fine-grained PAT', re: /\bgithub_pat_[A-Za-z0-9_]{60,}/ },
36
- { name: 'GitHub OAuth / refresh / server token', re: /\bgh[osur]_[A-Za-z0-9]{20,}/ },
37
- { name: 'SendGrid API key', re: /\bSG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{40,}/ },
38
- { name: 'Slack incoming webhook', re: /\bhooks\.slack\.com\/services\/T[A-Z0-9]{6,}\/B[A-Z0-9]{6,}\/[A-Za-z0-9]{20,}/ },
39
- { name: 'Discord webhook', re: /\bdiscord(?:app)?\.com\/api\/webhooks\/\d{17,}\/[A-Za-z0-9_-]{40,}/ },
40
- { name: 'Telegram bot token', re: /\b\d{8,12}:AA[A-Za-z0-9_-]{30,}/ },
41
- { name: 'Sentry DSN with secret', re: /\bhttps:\/\/[a-f0-9]{32}(?::[a-f0-9]{32})?@[\w.-]*(?:sentry\.io|ingest\.[\w.-]+)\/\d+/ },
42
- { name: 'Azure storage account key', re: /\bAccountKey\s*=\s*[A-Za-z0-9+/]{60,}={0,2}/ },
43
- { name: 'JSON web token', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{20,}/ },
44
- { name: 'Registry auth blob (docker config)', re: /"auth"\s*:\s*"[A-Za-z0-9+/]{24,}={0,2}"/ },
45
- {
46
- name: 'Provider API key (named env var)',
47
- re: /\b(?:AZURE_OPENAI_API_KEY|MISTRAL_API_KEY|COHERE_API_KEY|CO_API_KEY|TOGETHER_API_KEY|DEEPSEEK_API_KEY|DD_API_KEY|DATADOG_API_KEY|TWILIO_AUTH_TOKEN|VERCEL_TOKEN|CLOUDFLARE_API_TOKEN|WEAVIATE_API_KEY|VOYAGE_API_KEY|NVIDIA_API_KEY|CEREBRAS_API_KEY|SAMBANOVA_API_KEY)\s*[=:]\s*["']?[A-Za-z0-9_-]{24,}\b/,
48
- },
49
- ];
2
+ export { ALL_SECRET_PATTERNS as SECRET_PATTERNS } from './secret-scanner.mjs';
50
3
 
51
4
  export const PII_PATTERNS = [
52
5
  { name: 'Email address', re: /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/ },
@@ -28,7 +28,7 @@ export function targetsExternalNetwork(line) {
28
28
  });
29
29
  }
30
30
 
31
- const RM_RF_RE = /\brm\b(?=[^\n;|&]{0,200}(?:-[a-zA-Z]*r|--recursive))(?=[^\n;|&]{0,200}(?:-[a-zA-Z]*f|--force))/i;
31
+ const RM_RF_RE = /(?<!\b(?:docker|podman|nerdctl|kubectl|helm|conda|brew)\s{1,4})\brm\b(?=[^\n;|&]{0,200}?\s(?:-[a-zA-Z]*r[a-zA-Z]*|--recursive)(?![\w-]))(?=[^\n;|&]{0,200}?\s(?:-[a-zA-Z]*f[a-zA-Z]*|--force)(?![\w-]))/i;
32
32
 
33
33
  export function rmTargetClass(line) {
34
34
  if (/\brm\s+-{1,2}[a-zA-Z][\w-]*\s*["'`,)\]}?!](?![\w./~$*-])/.test(line)) return 'local';
@@ -106,7 +106,7 @@ export const DANGEROUS_SHELL = [
106
106
  { name: 'Force-deletes a cloud storage bucket (aws s3 rb --force)', re: /\b(aws\s+s3\s+rb|gsutil\s+(rm\s+-r|rb)|az\s+storage\s+(account|container)\s+delete)\b[^\n]{0,80}(--force|--yes|-f\b|\bs3:\/\/|\bgs:\/\/)/i, severity: 'HIGH' },
107
107
  { name: 'Force-pushes over a protected branch (rewrites shared history)', re: /\bgit\s+push\b[^\n]{0,80}(--force\b(?!-with-lease)|(?:^|\s)-f\b)[^\n]{0,60}\b(main|master|release|prod(uction)?)\b/i, severity: 'MEDIUM' },
108
108
 
109
- { name: 'Recursive force delete of the filesystem root (rm -rf /, --no-preserve-root)', re: /\brm\b(?=[^\n;|&]{0,200}(?:-[a-zA-Z]*r|--recursive))(?=[^\n;|&]{0,200}(?:-[a-zA-Z]*f|--force))(?=[^\n;|&]{0,200}(?:--no-preserve-root|\s\/(?:\s|\*|$)))/i, severity: 'CRITICAL' },
109
+ { name: 'Recursive force delete of the filesystem root (rm -rf /, --no-preserve-root)', re: /(?<!\b(?:docker|podman|nerdctl|kubectl|helm|conda|brew)\s{1,4})\brm\b(?=[^\n;|&]{0,200}?\s(?:-[a-zA-Z]*r[a-zA-Z]*|--recursive)(?![\w-]))(?=[^\n;|&]{0,200}?\s(?:-[a-zA-Z]*f[a-zA-Z]*|--force)(?![\w-]))(?=[^\n;|&]{0,200}(?:--no-preserve-root|\s\/(?:\s|\*|$)))/i, severity: 'CRITICAL' },
110
110
  { name: 'Fork bomb (process-exhaustion DoS)', re: /(:|\b[a-z_][a-z0-9_]*)\s*\(\s*\)\s*\{\s*\1\s*[^\n}]*\|\s*\1[^\n}]*&\s*\}\s*;\s*\1/i, severity: 'HIGH' },
111
111
  { name: 'Writes over a raw disk device (data destruction)', re: /\b(dd\b[^\n]{0,80}\bof=\/dev\/[sh]d|mkfs(\.\w+)?\s+[^\n]{0,40}\/dev\/|>\s*\/dev\/[sh]d[a-z])/i, severity: 'CRITICAL' },
112
112
  { name: 'Reads the system password-hash / sudo policy file', re: /\b(cat|less|more|head|tail|strings|xxd|od|grep|awk|sed|cp|scp|tar)\b[^\n]{0,80}\/etc\/(shadow|gshadow|sudoers(\.d)?)\b/i, severity: 'HIGH' },
@@ -132,6 +132,6 @@ export const DANGEROUS_SHELL = [
132
132
  { name: 'Kills the audit / EDR agent (anti-forensics)', re: /\b(?:pkill|killall|kill)\b[^\n]{0,40}\b(?:auditd|osqueryd?|falcon-sensor|falconctl|wazuh|ossec|filebeat|splunkd|sysmon|crowdstrike|carbonblack|cbagent)\b|\bSet-MpPreference\b[^\n]{0,60}-Disable\w*\s+\$?true/i, severity: 'HIGH' },
133
133
  { name: 'Downloads and executes through a signed system binary (LOLBin)', re: /\bcertutil\b[^\n]{0,80}-urlcache\b|\bbitsadmin\b[^\n]{0,80}\/transfer\b|\bmshta\b\s+https?:\/\/|\bregsvr32\b[^\n]{0,60}\/i:\s*https?:\/\/|\brundll32\b[^\n]{0,60}\b(?:url\.dll|javascript:)|\bwmic\b[^\n]{0,60}\bprocess\s+call\s+create\b|\bmsiexec\b[^\n]{0,40}\/i\s+https?:\/\//i, severity: 'CRITICAL' },
134
134
  { name: 'PowerShell runs a base64-encoded command', re: /\bpowershell(?:\.exe)?\b[^\n]{0,80}\s-(?:e|ec|enc|encoded|encodedcommand)\b/i, severity: 'CRITICAL' },
135
- { name: 'Interpreter opens a raw socket (reverse shell)', re: /\b(?:perl|ruby|php|python[0-9.]*|node)\b[^\n]{0,40}-(?:e|r|c)\b[^\n]{0,200}\b(?:fsockopen|socket\s*\(|Socket::|SOCK_STREAM|net\.connect|createConnection)\b/i, severity: 'CRITICAL' },
135
+ { name: 'Interpreter opens a raw socket (reverse shell)', re: /\b(?:perl|ruby|php|python[0-9.]*|node)\b[^\n]{0,40}-(?:e|r|c)\b[^\n]{0,200}(?:\bfsockopen|\bsocket\s*\(|\bSocket::|\bSOCK_STREAM\b|\bnet\.connect|\bcreateConnection\b)/i, severity: 'CRITICAL' },
136
136
  { name: 'Netcat listener or command-execution flag', re: /\bn?c(?:at)?\b[^\n]{0,40}\s-\w*[ec]\s+\S{0,30}(?:sh|bash|cmd|powershell)\b|\bn?c(?:at)?\b[^\n]{0,20}\s-\w*l\w*\s*(?:-\w+\s*)*\d{2,5}\b/i, severity: 'HIGH' },
137
137
  ];
@@ -0,0 +1,16 @@
1
+ import { grade } from '../detect/guard-signals.mjs';
2
+ import { lineOf } from '../detect/signals/lines.mjs';
3
+ import { advisoriesDisabled, mcpAdvisoryFindings } from '../detect/signals/mcp-advisories.mjs';
4
+
5
+
6
+ export async function withMcpAdvisories(local, { content, path, kind, flags = {}, fetchImpl } = {}) {
7
+ if (!['mcp', 'auto', undefined].includes(kind) || advisoriesDisabled(flags)) return { local, advisories: 'skipped' };
8
+ const { status, findings } = await mcpAdvisoryFindings(content, path ?? '', fetchImpl ? { fetchImpl } : {});
9
+ if (!findings.length) return { local, advisories: status };
10
+ const seen = new Set(local.findings.map((f) => f.title));
11
+ const added = findings
12
+ .filter((f) => !seen.has(f.title))
13
+ .map((f) => ({ severity: f.severity, title: f.title, remediationText: f.remediationText, ...(lineOf(content, f.anchor) ? { line: lineOf(content, f.anchor) } : {}) }));
14
+ const all = [...local.findings, ...added];
15
+ return { local: { ...local, ...grade(all), findings: all }, advisories: status };
16
+ }
@@ -7,6 +7,7 @@ import { localGate } from '../detect/guard-signals.mjs';
7
7
  import { applyRepoPolicy, loadRepoPolicy } from './repo-policy.mjs';
8
8
  import { localAsGateResult } from './result.mjs';
9
9
  import { collectLocalSast, mergeSastIntoResult } from './sast.mjs';
10
+ import { withMcpAdvisories } from './advisories.mjs';
10
11
  import { loadBaseline, loadIgnoreRules, suppressResult } from './suppressions.mjs';
11
12
 
12
13
  const DEFAULT_CONCURRENCY = 8;
@@ -122,6 +123,15 @@ export async function gateArtifactList(artifacts, { apiKey, url, env, flags, roo
122
123
  const prepared = analyseLocally(artifacts, quiet);
123
124
  const { verdicts, rejected, backendDown } = await requestServerVerdicts(prepared, { apiKey, url, env, flags, quiet });
124
125
 
126
+ let advisoryOutage = false;
127
+ await Promise.all(prepared.map(async (p, i) => {
128
+ if (verdicts[i] || p.artifact.kind !== 'mcp') return;
129
+ const { local, advisories } = await withMcpAdvisories(p.local, { content: p.content, path: p.artifact.rel, kind: 'mcp', flags });
130
+ p.local = local;
131
+ if (advisories === 'unavailable') advisoryOutage = true;
132
+ }));
133
+ if (advisoryOutage && !quiet) console.log(` ${yellow('⚠')} ${dim('Advisory lookup (OSV) unreachable - pinned MCP packages were not checked for published CVEs.')}`);
134
+
125
135
  const results = [];
126
136
  let blocked = 0;
127
137
  let flagged = 0;
@@ -1,32 +1,10 @@
1
- import { execSync } from 'node:child_process';
1
+ import { git } from '../core/git-exec.mjs';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
- export const GATE_KINDS = ['mcp', 'skill', 'command', 'subagent', 'hook', 'rules', 'agent-card', 'memory', 'auto'];
6
-
7
- /**
8
- * ⚠ A CLOUD AGENT SESSION IS NOT A DEVELOPER MACHINE, and until this existed it
9
- * reported as one. Claude Code on the web runs in an EPHEMERAL container: fresh
10
- * $HOME, no `shomra` config, a hostname nobody will ever see again. It carries
11
- * no CI variables, so it fell through to LOCAL - and an operator counting
12
- * "screened laptops" was counting containers that no longer exist.
13
- *
14
- * ⚠ CI IS CHECKED FIRST and stays first. A cloud session driven by a GitHub
15
- * Action is CI: that branch carries repo, ref and commit, which is the stronger
16
- * attribution. REMOTE is what is left when nothing else names where this ran.
17
- */
18
- /**
19
- * ⚠ ONE ENTRY PER RUNTIME, and only where the variable has been SEEN. A marker
20
- * invented from a vendor's docs either never fires - useless - or fires on a
21
- * name something else uses, which labels a real laptop as an ephemeral
22
- * container and puts a `floor` on a machine the org actually owns. Add a row
23
- * here once somebody has read the variable out of a live session of that
24
- * runtime; until then that runtime uses SHOMRA_ENVIRONMENT below, which is the
25
- * whole reason the override exists.
26
- *
27
- * `verified` records who has actually seen it, so the next person can tell a
28
- * confirmed marker from an optimistic one.
29
- */
5
+ export const GATE_KINDS = ['mcp', 'skill', 'command', 'subagent', 'hook', 'rules', 'agent-card', 'memory', 'plugin', 'tool-manifest', 'model-config', 'extension', 'workflow', 'guardrail', 'framework', 'auto'];
6
+
7
+
30
8
  export const REMOTE_RUNTIMES = [
31
9
  {
32
10
  runner: 'claude-code-cloud',
@@ -37,13 +15,6 @@ export const REMOTE_RUNTIMES = [
37
15
  },
38
16
  ];
39
17
 
40
- /**
41
- * ⚠ CODESPACES AND DEVCONTAINERS ARE DELIBERATELY ABSENT. They are containers,
42
- * but they PERSIST between sessions, can be enrolled, and their guard survives
43
- * to report a window it spent blind - which is the whole distinction REMOTE
44
- * draws. Filing them here would put "we cannot vouch for its silence" on
45
- * machines that can in fact vouch for it.
46
- */
47
18
  export function remoteRunner(e = process.env) {
48
19
  for (const rt of REMOTE_RUNTIMES) {
49
20
  for (const key of rt.vars) if (String(e?.[key] ?? '').trim()) return rt.runner;
@@ -61,15 +32,7 @@ export function declaredEnvironment(e = process.env) {
61
32
  return v in ENV_RANK ? v : null;
62
33
  }
63
34
 
64
- /**
65
- * ⚠⚠ THE OVERRIDE MAY ONLY EVER RAISE. `SHOMRA_ENVIRONMENT` exists so an
66
- * operator can declare a runtime we do not yet detect - a cloud agent from a
67
- * vendor whose markers nobody has read. Letting it go the other way would make
68
- * it a switch that relabels a detected ephemeral container as a trusted laptop,
69
- * clearing the `floor` its unreportable silence earns. That is the same
70
- * privilege-reduction shape `mayRaiseOnly` and `foldTimeout` refuse on the
71
- * server, and it would be reachable by anything that can set an env var.
72
- */
35
+
73
36
  export function mergeEnvironment(detected, declared) {
74
37
  if (!declared) return detected;
75
38
  return ENV_RANK[declared] > ENV_RANK[detected] ? declared : detected;
@@ -147,16 +110,8 @@ export function detectEnv(env) {
147
110
  }
148
111
 
149
112
  function gitContext() {
150
- const run = (args) => {
151
- try {
152
- return execSync(`git ${args}`, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 })
153
- .toString()
154
- .trim();
155
- } catch {
156
- return undefined;
157
- }
158
- };
159
- const origin = run('config --get remote.origin.url');
113
+ const run = (args) => git(args, { timeout: 2000 })?.trim();
114
+ const origin = run(['config', '--get', 'remote.origin.url']);
160
115
  let repo;
161
116
  if (origin) {
162
117
  const m = origin.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
@@ -165,7 +120,7 @@ function gitContext() {
165
120
 
166
121
  let repoUrl = origin || undefined;
167
122
  if (repoUrl) repoUrl = repoUrl.replace(/^([a-z][\w+.-]*:\/\/)[^/@]*@/i, '$1');
168
- return { repo, repoUrl, ref: run('rev-parse --abbrev-ref HEAD'), commit: run('rev-parse HEAD') };
123
+ return { repo, repoUrl, ref: run(['rev-parse', '--abbrev-ref', 'HEAD']), commit: run(['rev-parse', 'HEAD']) };
169
124
  }
170
125
 
171
126
  export function collectSiblings(fullTarget, relPath) {