@shomra/agent 0.3.28 → 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 (84) 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/rules-javascript.mjs +16 -3
  23. package/src/detect/sast/rules-python.mjs +8 -6
  24. package/src/detect/sast/scanner.mjs +1 -1
  25. package/src/detect/signals/agent-frameworks.mjs +231 -0
  26. package/src/detect/signals/agent-graph-surface.mjs +113 -0
  27. package/src/detect/signals/agentic-ci-surface.mjs +314 -0
  28. package/src/detect/signals/agentic-shim.mjs +82 -0
  29. package/src/detect/signals/artifacts.mjs +8 -36
  30. package/src/detect/signals/autonomy.mjs +9 -1
  31. package/src/detect/signals/chat-template.mjs +211 -0
  32. package/src/detect/signals/ci-workflow.mjs +169 -0
  33. package/src/detect/signals/credential-harvest.mjs +1 -1
  34. package/src/detect/signals/execution-hijack.mjs +4 -2
  35. package/src/detect/signals/gate.mjs +84 -11
  36. package/src/detect/signals/guardrail-shape.mjs +564 -0
  37. package/src/detect/signals/guardrail-surface.mjs +221 -0
  38. package/src/detect/signals/injection.mjs +8 -0
  39. package/src/detect/signals/inspect-shim.mjs +7 -0
  40. package/src/detect/signals/instruction-paths.mjs +60 -0
  41. package/src/detect/signals/manifests.mjs +302 -0
  42. package/src/detect/signals/masking.mjs +14 -1
  43. package/src/detect/signals/mcp-advisories.mjs +109 -0
  44. package/src/detect/signals/mcp-config.mjs +598 -0
  45. package/src/detect/signals/memory-directives.mjs +661 -0
  46. package/src/detect/signals/memory-locations.mjs +158 -0
  47. package/src/detect/signals/memory.mjs +56 -31
  48. package/src/detect/signals/model-config-rules.mjs +655 -0
  49. package/src/detect/signals/model-config.mjs +61 -0
  50. package/src/detect/signals/packages.mjs +2 -2
  51. package/src/detect/signals/prose-context.mjs +6 -9
  52. package/src/detect/signals/scan.mjs +4 -4
  53. package/src/detect/signals/secret-scanner.mjs +241 -0
  54. package/src/detect/signals/secrets.mjs +1 -48
  55. package/src/detect/signals/shell.mjs +10 -10
  56. package/src/gate/advisories.mjs +16 -0
  57. package/src/gate/batch.mjs +10 -0
  58. package/src/gate/environment.mjs +8 -53
  59. package/src/guard/artifact-paths.mjs +107 -0
  60. package/src/guard/classify.mjs +165 -7
  61. package/src/guard/command-resolve.mjs +35 -5
  62. package/src/guard/memory-write.mjs +218 -0
  63. package/src/guard/prompt-guard.mjs +0 -1
  64. package/src/guard/tool-guard.mjs +52 -77
  65. package/src/inventory/agent-posture.mjs +236 -57
  66. package/src/inventory/artifacts/classify.mjs +10 -1
  67. package/src/inventory/artifacts/discover.mjs +113 -3
  68. package/src/inventory/artifacts/extensions.mjs +70 -0
  69. package/src/inventory/artifacts/hook-scripts.mjs +128 -0
  70. package/src/inventory/artifacts/limits.mjs +1 -1
  71. package/src/inventory/artifacts/plugins.mjs +105 -0
  72. package/src/inventory/artifacts/roots.mjs +40 -0
  73. package/src/inventory/discovery/ai-dependencies.mjs +39 -12
  74. package/src/inventory/discovery/all.mjs +4 -0
  75. package/src/inventory/discovery/cloud-clis.mjs +472 -0
  76. package/src/inventory/discovery/coding-agents.mjs +19 -4
  77. package/src/inventory/discovery/mcp-clients.mjs +16 -10
  78. package/src/inventory/discovery/mcp-servers.mjs +125 -35
  79. package/src/inventory/discovery/mcp-stores.mjs +207 -0
  80. package/src/inventory/env-redirect.mjs +148 -0
  81. package/src/inventory/grant-extract.mjs +463 -0
  82. package/src/inventory/project-roots.mjs +108 -0
  83. package/src/inventory/vscode-state.mjs +153 -0
  84. 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
+ }
@@ -1,6 +1,6 @@
1
1
  export const INSTALL_LURE = [
2
- { name: 'Instructs downloading an executable/archive to run', re: /\b(download|install|fetch|grab|extract)\b[^\n]{0,180}\.(zip|exe|dmg|pkg|msi|bin|appimage|jar|scr|apk|deb|rpm|tar\.gz|tgz)\b/i, severity: 'MEDIUM' },
3
- { name: 'Password-protected archive (evades AV / scanners)', re: /\b(extract|unzip|decompress|archive|zip|password)\b[^\n]{0,50}\b(pass(word|phrase)?|pwd)\s*[:=]\s*\S/i, severity: 'HIGH' },
2
+ { name: 'Instructs downloading an executable/archive to run', re: /\b(download|install|fetch|grab|extract)\b[^\n]{0,180}(?<=[\w-])\.(zip|exe|dmg|pkg|msi|bin|appimage|jar|scr|apk|deb|rpm|tar\.gz|tgz)\b(?![\w/])/i, severity: 'MEDIUM' },
3
+ { name: 'Password-protected archive (evades AV / scanners)', re: /(?<![.\w$-])(?:unzip|7z|7za|unrar|rar|zip|tar|gpg|openssl|extract|decompress|archive)\b(?![.:=\w])[^\n]{0,50}(?:^|[\s;|&(])(?:-P\b|--password\b|pass(?:word|phrase)?|pwd)\s*[:= ]\s*\S/i, severity: 'HIGH' },
4
4
  { name: 'Coercion: claims a helper is required before the task works', re: /\b(required to (function|work|deploy|run)|will not (work|function|run)( correctly| properly)?( without)?|does not work without|otherwise it is impossible|cannot [a-z ]{0,24} without (installing|running)|must (be )?(install(ed)?|run) (this |the )?)/i, severity: 'MEDIUM' },
5
5
  { name: 'Coercion: re-run / retry until it succeeds', re: /\b(re-?run (if needed|until|the command)|run (it |the command )?again|try again after)/i, severity: 'LOW' },
6
6
  ];
@@ -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:@/]+:[^\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-----/ },
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,10 +28,10 @@ export function targetsExternalNetwork(line) {
28
28
  });
29
29
  }
30
30
 
31
- const RM_RF_RE = /\brm\b(?=[^\n;|&]*(?:-[a-zA-Z]*r|--recursive))(?=[^\n;|&]*(?:-[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
- if (/\brm\s+-{1,2}[a-zA-Z][\w-]*\s*["'`,)\]}?!]\s*$/.test(line)) return 'local';
34
+ if (/\brm\s+-{1,2}[a-zA-Z][\w-]*\s*["'`,)\]}?!](?![\w./~$*-])/.test(line)) return 'local';
35
35
  const m = /\brm\s+((?:--?[a-zA-Z][\w-]*\s+)+)(.*)$/.exec(line);
36
36
  if (!m) return 'catastrophic';
37
37
 
@@ -75,23 +75,23 @@ export const DANGEROUS_SHELL = [
75
75
  severity: 'HIGH',
76
76
  },
77
77
  { name: 'Fetches from a raw IP address', re: /\b(curl|wget|iwr|irm|invoke-webrequest|invoke-restmethod)\b[^\n]{0,220}https?:\/\/\d{1,3}(\.\d{1,3}){3}/i, severity: 'HIGH', refine: targetsExternalNetwork },
78
- { name: 'Writes to shell profile / SSH keys / crontab', re: /(>>?\s*~?\/?\.?(bashrc|zshrc|bash_profile|profile)|(tee|echo|cat|printf)\b[^\n]{0,80}(\.bashrc|\.zshrc|\.bash_profile|\.profile|authorized_keys)|>>?\s*[^\n]{0,40}authorized_keys|crontab\s+(-|[^\n]{0,40}<)|id_rsa\b[^\n]{0,20}(>|cp|scp|curl|cat))/i, severity: 'HIGH' },
78
+ { name: 'Writes to shell profile / SSH keys / crontab', re: /(>>?\s*(?:~\/|\$HOME\/|\/etc\/)?\.(bashrc|zshrc|bash_profile|profile)\b|>>?\s*\/etc\/profile\b|(tee|echo|cat|printf)\b[^\n]{0,80}(\.bashrc|\.zshrc|\.bash_profile|\.profile|authorized_keys)|>>?\s*[^\n]{0,40}authorized_keys|crontab\s+(-|[^\n]{0,40}<)|id_rsa\b[^\n]{0,20}(>|cp|scp|curl|cat))/i, severity: 'HIGH' },
79
79
 
80
80
  {
81
81
  name: 'World-writable permissions on the filesystem root (chmod -R 777 /)',
82
- re: /\bchmod\b(?=[^\n;|&]*(?:-[a-zA-Z]*R|--recursive))(?=[^\n;|&]*(?:\b0?[0-7][0-7][2367]\b|a\+rwx|a=rwx|o\+w|ugo\+rwx))(?=[^\n;|&]*\s\/(?:\s|\*|$))/i,
82
+ re: /\bchmod\b(?=[^\n;|&]{0,200}(?:-[a-zA-Z]*R|--recursive))(?=[^\n;|&]{0,200}(?:\b0?[0-7][0-7][2367]\b|a\+rwx|a=rwx|o\+w|ugo\+rwx))(?=[^\n;|&]{0,200}\s\/(?:\s|\*|$))/i,
83
83
  severity: 'CRITICAL',
84
84
  },
85
85
  {
86
86
  name: 'World-writable permissions on a credential or system path (chmod 777)',
87
- re: /\bchmod\b(?=[^\n;|&]*(?:\b0?[0-7][0-7][2367]\b|a\+rwx|a=rwx|o\+w|ugo\+rwx))(?=[^\n;|&]*(?:~(?:\s|$|\/\.)|\$HOME\b|\/etc\b|\/root\b|\/usr\b|\/var\b|\/boot\b|\.ssh\b|id_rsa\b|authorized_keys\b|\.aws\b|\.gnupg\b|\.kube\b))/i,
87
+ re: /\bchmod\b(?=[^\n;|&]{0,200}(?:\b0?[0-7][0-7][2367]\b|a\+rwx|a=rwx|o\+w|ugo\+rwx))(?=[^\n;|&]{0,200}(?:~(?:\s|$|\/\.)|\$HOME\b|\/etc\b|\/root\b|\/usr\b|\/var\b|\/boot\b|\.ssh\b|id_rsa\b|authorized_keys\b|\.aws\b|\.gnupg\b|\.kube\b))/i,
88
88
  severity: 'HIGH',
89
89
  },
90
90
  { name: 'Recursive force delete of a protected path (rm -rf)', re: RM_RF_RE, severity: 'HIGH', refine: (l) => rmTargetClass(l) === 'catastrophic' },
91
91
  { name: 'Recursive force delete (rm -rf)', re: RM_RF_RE, severity: 'MEDIUM', refine: (l) => rmTargetClass(l) === 'local' },
92
92
 
93
- { name: 'Inline eval / exec of a string', re: /(?<![-.\w$>:`"'])(eval|exec)\s*[("`']/i, severity: 'HIGH' },
94
- { name: 'Pipes an env dump to the network', re: /\b(env|printenv|set)\b[^\n|]{0,80}\|[^\n]{0,80}(curl|wget|nc\b|http)/i, severity: 'HIGH' },
93
+ { name: 'Inline eval / exec of a string', re: /(?<![-.\w$>:`"'\/])(eval|exec)\s*(?!\((?:[^()\n]{0,160}\)\s*\{|\s*_?[A-Za-z$][\w$]{0,64}\s*\??\s*:))[("`']/i, severity: 'HIGH' },
94
+ { name: 'Pipes an env dump to the network', re: /(?<![.\w$-])(env|printenv|set)\b(?![.:=\w])(?!\s*[:=])[^\n|]{0,80}(?<!\|)\|(?!\|)[^\n]{0,80}(curl\b|wget\b|nc\b|https?\b)/i, severity: 'HIGH' },
95
95
  { name: 'Disables TLS / cert verification', re: /(NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*0|GIT_SSL_NO_VERIFY|--no-check-certificate|--insecure\b|verify\s*=\s*False)/i, severity: 'MEDIUM' },
96
96
  { name: 'python -c one-liner', re: /python[0-9.]*\s+-c\b/i, severity: 'MEDIUM' },
97
97
  { name: 'node -e one-liner', re: /\bnode\s+-e\b/i, severity: 'MEDIUM' },
@@ -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;|&]*(?:-[a-zA-Z]*r|--recursive))(?=[^\n;|&]*(?:-[a-zA-Z]*f|--force))(?=[^\n;|&]*(?:--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' },
@@ -126,12 +126,12 @@ export const DANGEROUS_SHELL = [
126
126
  { name: 'Preloads a shared library into every process (LD_PRELOAD)', re: /\b(?:LD_PRELOAD|LD_AUDIT|DYLD_INSERT_LIBRARIES)\s*=\s*\S|>>?\s*\/etc\/ld\.so\.preload\b/i, severity: 'HIGH' },
127
127
  { name: 'Installs a scheduled or boot-time persistence unit', re: /\bsystemd-run\b[^\n]{0,80}--on-(?:boot|calendar|active|unit)|>>?\s*\/etc\/(?:systemd\/system|cron\.(?:d|daily|hourly)|init\.d)\/\S|\bschtasks\b[^\n]{0,80}\/create\b|\blaunchctl\s+(?:load|bootstrap)\b|\b(?:echo|printf)\b[^\n]{0,120}\|\s*at\s+(?:now|\+|\d)/i, severity: 'MEDIUM' },
128
128
  { name: 'Opens a reverse tunnel to a remote host', re: /\bssh\b[^\n]{0,80}\s-\w*R\s*\d{1,5}:[^\n\s]{1,60}|\b(?:ngrok|cloudflared|localtunnel|frpc)\b[^\n]{0,60}\b(?:tcp|http|tunnel)\b/i, severity: 'HIGH' },
129
- { name: 'Encodes command output into DNS lookups (exfiltration channel)', re: /(?:^|[\n;&|(]\s*)(?:dig|nslookup|drill|host)\s+[^\n]{0,120}(?:\$\(|`[^`\n]+`|\$\{)[^\n]{0,80}\.[a-z]{2,}/i, severity: 'HIGH' },
129
+ { name: 'Encodes command output into DNS lookups (exfiltration channel)', re: /(?:^|[\n;&|(]\s*)(?:dig|nslookup|drill|host)\s+(?![-+*/%|&^]?=)[^\n]{0,120}(?:\$\(|`|\$\{)[^\n]{0,80}\.[a-z]{2,}|\b(?:base64|base32|xxd|hexdump|od\s|openssl\s+enc)\b[^\n]{0,140}\b(?:dig|nslookup|drill|host)\s+[^\n]{0,60}\$\w+\.[\w.-]{2,}\.[a-z]{2,}/i, severity: 'HIGH' },
130
130
  { name: 'Copies credentials or home directories off the machine over ssh', re: new RegExp(String.raw`\b(?:scp|rsync)\b(?=[^\n]{0,200}\s\S{0,40}@[\w.-]+:)(?=[^\n]{0,200}(?:${SENSITIVE_PATH}))` + String.raw`|\btar\b(?=[^\n]{0,160}\|\s*ssh\b)(?=[^\n]{0,160}(?:${SENSITIVE_PATH}))`, 'i'), severity: 'HIGH' },
131
131
  { name: 'Flushes the host firewall', re: /\b(?:iptables|ip6tables|nft)\b[^\n]{0,60}(?:-F\b|--flush\b|flush ruleset)|\bufw\s+disable\b|\bnetsh\s+advfirewall\s+set\s+\S+\s+state\s+off\b/i, severity: 'MEDIUM' },
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;